diff --git a/cmd/root.go b/cmd/root.go index d64e0d4fbce3e370c5bd59ab5c98565749e22a63..df689371dc94940f4febcb27cbc939671c3788fd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -16,15 +16,18 @@ import ( "git.secluded.site/keld/internal/config" "git.secluded.site/keld/internal/form" - "git.secluded.site/keld/internal/menu" "git.secluded.site/keld/internal/picker" "git.secluded.site/keld/internal/restic" + "git.secluded.site/keld/internal/theme" + "git.secluded.site/keld/internal/ui" + "git.secluded.site/keld/internal/ui/screens" ) var ( flagPreset string flagShowCmd bool flagConfigFile string + presetResolved bool ) const overrideArgumentsKey = "_arguments" @@ -46,18 +49,23 @@ var rootCmd = &cobra.Command{ TraverseChildren: true, RunE: func(cmd *cobra.Command, _ []string) error { - selected, err := runMenu() + commandName, preset, err := runInteractive() if err != nil { - return fmt.Errorf("menu: %w", err) + return err } - if selected == "" || selected == "quit" { + if commandName == "" { + // User cancelled. return nil } - if !slices.Contains(knownCommands, selected) { - return fmt.Errorf("unknown menu command %q", selected) - } - return runCommand(selected, cmd, nil) + // Preset was resolved by the session; pass it directly + // so runCommand skips its own preset prompt. + flagPreset = preset + // Mark that the session already handled preset selection + // so runCommand does not re-prompt even when preset is "". + presetResolved = true + + return runCommand(commandName, cmd, nil) }, } @@ -92,8 +100,10 @@ func runCommand(commandName string, cmd *cobra.Command, rawArgs []string) error overrides := parsePassthrough(rawArgs) // In interactive mode, fill missing preset/command inputs via prompts. + // When the unified session has already resolved the preset (presetResolved), + // skip the standalone preset prompt. if interactive { - if preset == "" { + if !presetResolved && preset == "" { p, err := promptPreset() if err != nil { return err @@ -209,20 +219,55 @@ func isFlagToken(arg string) bool { return strings.HasPrefix(arg, "-") && arg != "-" && arg != "--" } -// runMenu launches the interactive BubbleTea menu and returns the chosen -// command, or "" if the user quit. -func runMenu() (string, error) { - m := menu.New(menuItems) - p := tea.NewProgram(m) +// runInteractive launches the unified TUI session with a command +// menu and (if needed) a preset selector. Returns the chosen command +// name and preset, or ("", "") if the user cancelled. +func runInteractive() (command, preset string, err error) { + styles := theme.New(true) + + var screenList []ui.Screen + + menuScreen := screens.NewMenu(menuItems, &styles) + screenList = append(screenList, menuScreen) + + // Build the preset screen only when needed: + // - --preset on CLI: skip (already resolved). + // - Zero presets: skip (global defaults only). + // - One preset: auto-select, skip. + // - Multiple presets: show the filterable selector. + presets := config.Presets() + var presetScreen *screens.Preset + if flagPreset != "" { + preset = flagPreset + } else if len(presets) > 1 { + presetScreen = screens.NewPreset(presets, &styles) + screenList = append(screenList, presetScreen) + } else if len(presets) == 1 { + preset = presets[0] + } + + session := ui.New(screenList, &styles) + p := tea.NewProgram(session) result, err := p.Run() if err != nil { - return "", err + return "", "", fmt.Errorf("interactive session: %w", err) } - model, ok := result.(menu.Model) - if !ok { - return "", fmt.Errorf("unexpected menu model type %T", result) + + s := result.(ui.Session) + if !s.Completed() { + return "", "", nil + } + + command = menuScreen.Selection() + if !slices.Contains(knownCommands, command) { + return "", "", fmt.Errorf("unknown menu command %q", command) } - return model.Choice(), nil + + if presetScreen != nil { + preset = presetScreen.Value() + } + + return command, preset, nil } // validatePreset checks that the given preset name matches one of the usable diff --git a/cmd/wrapspec.go b/cmd/wrapspec.go index 5402ef40c12fdff033c2730fc5477aa6e7929d86..d35e0069b818984cf3338f01449dc5258b8aed3f 100644 --- a/cmd/wrapspec.go +++ b/cmd/wrapspec.go @@ -1,6 +1,6 @@ package cmd -import "git.secluded.site/keld/internal/menu" +import "git.secluded.site/keld/internal/ui/screens" // wrappedCommand describes a restic command that keld exposes in its // interactive menu and shell completions. @@ -21,13 +21,12 @@ var wrappedCommands = []wrappedCommand{ {Name: "init", Hotkey: 'i'}, } -// menuItems is derived from wrappedCommands with a trailing "quit" entry. -var menuItems = func() []menu.Item { - items := make([]menu.Item, 0, len(wrappedCommands)+1) - for _, wc := range wrappedCommands { - items = append(items, menu.Item{Label: wc.Name, Hotkey: wc.Hotkey}) +// menuItems is derived from wrappedCommands for the interactive menu. +var menuItems = func() []screens.MenuItem { + items := make([]screens.MenuItem, len(wrappedCommands)) + for i, wc := range wrappedCommands { + items[i] = screens.MenuItem{Label: wc.Name, Hotkey: wc.Hotkey} } - items = append(items, menu.Item{Label: "quit", Hotkey: 'q'}) return items }() diff --git a/internal/menu/menu.go b/internal/menu/menu.go deleted file mode 100644 index b8d56d6ef0c6cc874ecad56e1973139adf968283..0000000000000000000000000000000000000000 --- a/internal/menu/menu.go +++ /dev/null @@ -1,177 +0,0 @@ -package menu - -import ( - "fmt" - "strings" - - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -// Item represents a single menu entry. -type Item struct { - // Label is the full display text (e.g. "backup"). - Label string - // Hotkey is the single character that instantly selects this item (e.g. 'b'). - Hotkey rune - // Value is the string returned by Choice() when this item is selected. - // If empty, Label is used. - Value string -} - -// Model is a hand-rolled BubbleTea v2 model for an interactive hotkey menu. -type Model struct { - items []Item - cursor int - choice string - quitting bool - - hasDarkBG bool - lightDark lipgloss.LightDarkFunc -} - -// New creates a menu Model with the given items. -func New(items []Item) Model { - return Model{ - items: items, - hasDarkBG: true, // sensible default until we hear from the terminal - lightDark: lipgloss.LightDark(true), - } -} - -// Choice returns the selected item's value, or "" if nothing was chosen. -func (m Model) Choice() string { - return m.choice -} - -// Init requests the terminal background color so we can adapt styling. -func (m Model) Init() tea.Cmd { - return tea.RequestBackgroundColor -} - -// Update handles key presses and background color detection. -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.BackgroundColorMsg: - m.hasDarkBG = msg.IsDark() - m.lightDark = lipgloss.LightDark(m.hasDarkBG) - return m, nil - - case tea.KeyPressMsg: - switch msg.String() { - case "ctrl+c", "q": - m.quitting = true - return m, tea.Quit - - case "up", "k": - if m.cursor > 0 { - m.cursor-- - } - return m, nil - - case "down", "j": - if m.cursor < len(m.items)-1 { - m.cursor++ - } - return m, nil - - case "enter": - m.choice = m.itemValue(m.cursor) - return m, tea.Quit - - default: - // Check if the keypress matches any item's hotkey. - if len(msg.Text) == 1 { - r := rune(msg.Text[0]) - for i, item := range m.items { - if item.Hotkey == r { - m.cursor = i - m.choice = m.itemValue(i) - return m, tea.Quit - } - } - } - } - } - - return m, nil -} - -// View renders the menu as an inline vertical list. -func (m Model) View() tea.View { - if m.quitting || m.choice != "" { - return tea.NewView("") - } - - accentColor := m.lightDark( - lipgloss.Color("#7D56F4"), - lipgloss.Color("#AD8AFF"), - ) - normalColor := m.lightDark( - lipgloss.Color("#333333"), - lipgloss.Color("#DDDDDD"), - ) - cursorColor := m.lightDark( - lipgloss.Color("#7D56F4"), - lipgloss.Color("#AD8AFF"), - ) - - hotStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(accentColor) - labelStyle := lipgloss.NewStyle(). - Foreground(normalColor) - cursorStyle := lipgloss.NewStyle(). - Foreground(cursorColor). - Bold(true) - - var b strings.Builder - for i, item := range m.items { - cursor := " " - if i == m.cursor { - cursor = cursorStyle.Render("▸ ") - } - - line := renderItem(item, hotStyle, labelStyle) - fmt.Fprintf(&b, "%s%s\n", cursor, line) - } - - b.WriteString("\n") - b.WriteString(labelStyle.Render("↑/↓ navigate • hotkey or enter to select • q to quit")) - b.WriteString("\n") - - return tea.NewView(b.String()) -} - -// renderItem formats a single menu item with the hotkey character styled -// differently from the rest of the label. For example, with hotkey 'b' and -// label "backup", it renders "[b]ackup" where [b] is in the accent style. -func renderItem(item Item, hotStyle, labelStyle lipgloss.Style) string { - label := item.Label - hk := string(item.Hotkey) - idx := strings.Index(strings.ToLower(label), strings.ToLower(hk)) - - if idx < 0 { - // Hotkey not in label — show it as a prefix. - return hotStyle.Render("["+hk+"]") + " " + labelStyle.Render(label) - } - - before := label[:idx] - match := label[idx : idx+len(hk)] - after := label[idx+len(hk):] - - return labelStyle.Render(before) + - hotStyle.Render("["+match+"]") + - labelStyle.Render(after) -} - -// itemValue returns the value for the item at index i. -func (m Model) itemValue(i int) string { - if i < 0 || i >= len(m.items) { - return "" - } - if m.items[i].Value != "" { - return m.items[i].Value - } - return m.items[i].Label -} diff --git a/internal/ui/screens/preset.go b/internal/ui/screens/preset.go index 02a787dd57231ad5053ce54b152e0d44e54a3e97..983a412671497655ed28692d02f87e9a32517763 100644 --- a/internal/ui/screens/preset.go +++ b/internal/ui/screens/preset.go @@ -140,10 +140,15 @@ func (p *Preset) KeyBindings() []key.Binding { return []key.Binding{ key.NewBinding(key.WithKeys("↑/↓"), key.WithHelp("↑/↓", "navigate")), key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), - key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), } } // Selection returns the chosen preset name for breadcrumb display, -// or "" if nothing has been selected yet. +// or "" if nothing has been selected yet. For global defaults this +// returns a display label like "(global defaults)", not the empty +// string — use [Value] for the actual config preset name. func (p *Preset) Selection() string { return p.selection } + +// Value returns the resolved preset name for use in config resolution. +// Returns "" when the user selected global defaults. +func (p *Preset) Value() string { return p.selected } diff --git a/internal/ui/screens/preset_test.go b/internal/ui/screens/preset_test.go index 79f29450d7cf41467a6cf1b2317e664dc3b11843..ee69673bfdcfe0813db97b7ac56973ce8b1ce550 100644 --- a/internal/ui/screens/preset_test.go +++ b/internal/ui/screens/preset_test.go @@ -263,3 +263,40 @@ func TestPresetRebuildsFormOnBackgroundChange(t *testing.T) { t.Error("form was not rebuilt after BackgroundColorMsg; cached theme is stale") } } + +func TestPresetValueReturnsActualPresetName(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Move down to "home@cloud" and select it. + p.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + p, _ = drainPreset(screen.(*Preset), cmd) + + if got := p.Value(); got != "home@cloud" { + t.Errorf("Value() = %q, want %q", got, "home@cloud") + } +} + +func TestPresetValueEmptyForGlobalDefaults(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // First option is global defaults. Select it. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + p, _ = drainPreset(screen.(*Preset), cmd) + + if got := p.Value(); got != "" { + t.Errorf("Value() = %q, want empty for global defaults", got) + } +}