Detailed changes
@@ -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
@@ -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
}()
@@ -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
-}
@@ -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 }
@@ -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)
+ }
+}