diff --git a/internal/ui/screens/menu.go b/internal/ui/screens/menu.go new file mode 100644 index 0000000000000000000000000000000000000000..6520b3989a1dc5a0ec8356e80a10ba2e669b7f09 --- /dev/null +++ b/internal/ui/screens/menu.go @@ -0,0 +1,176 @@ +// Package screens provides Screen implementations that wrap keld's +// interactive UI components for use with the unified session. +package screens + +import ( + "fmt" + "strings" + "unicode" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "git.secluded.site/keld/internal/theme" + "git.secluded.site/keld/internal/ui" +) + +// MenuItem represents a single entry in the command menu. +type MenuItem struct { + // Label is the display text (e.g. "backup"). + Label string + // Hotkey is the single character that instantly selects this item. + Hotkey rune + // Value is the string returned by Selection() when chosen. If + // empty, Label is used. + Value string +} + +// itemValue returns the effective value for an item. +func (mi MenuItem) itemValue() string { + if mi.Value != "" { + return mi.Value + } + return mi.Label +} + +// menuKeys defines the key bindings for the menu screen. +var menuKeys = struct { + Up key.Binding + Down key.Binding + Enter key.Binding + Esc key.Binding +}{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/↓", "navigate")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↑/↓", "navigate")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Esc: key.NewBinding(key.WithKeys("esc")), +} + +// Menu is a Screen adapter that presents a selectable list of +// commands. It replaces the standalone menu.Model with a version +// that integrates into the unified session. +type Menu struct { + items []MenuItem + cursor int + selection string + styles *theme.Styles +} + +// NewMenu creates a menu screen for the given items. The styles +// pointer should come from Session.Styles() so theme updates +// propagate automatically. +func NewMenu(items []MenuItem, styles *theme.Styles) *Menu { + owned := make([]MenuItem, len(items)) + copy(owned, items) + return &Menu{ + items: owned, + styles: styles, + } +} + +// Init is a no-op for the menu — it has no async startup work. +func (m *Menu) Init() tea.Cmd { return nil } + +// Update handles key presses for navigation, selection, and hotkeys. +func (m *Menu) Update(msg tea.Msg) (ui.Screen, tea.Cmd) { + kp, ok := msg.(tea.KeyPressMsg) + if !ok { + return m, nil + } + + switch { + case key.Matches(kp, menuKeys.Esc): + return m, ui.BackCmd + + case key.Matches(kp, menuKeys.Up): + if m.cursor > 0 { + m.cursor-- + } + return m, nil + + case key.Matches(kp, menuKeys.Down): + if m.cursor < len(m.items)-1 { + m.cursor++ + } + return m, nil + + case key.Matches(kp, menuKeys.Enter): + m.selection = m.items[m.cursor].itemValue() + return m, ui.DoneCmd + } + + // Check for hotkey match. Use rune decoding so multi-byte + // UTF-8 characters (e.g. 'ñ') are handled correctly. + runes := []rune(kp.Text) + if len(runes) == 1 { + for i, item := range m.items { + if item.Hotkey == runes[0] { + m.cursor = i + m.selection = item.itemValue() + return m, ui.DoneCmd + } + } + } + + return m, nil +} + +// View renders the menu as a vertical list with a cursor indicator. +func (m *Menu) View() string { + accent := m.styles.Accent + hotStyle := lipgloss.NewStyle().Bold(true).Foreground(accent) + labelStyle := lipgloss.NewStyle() + cursorStyle := lipgloss.NewStyle().Foreground(accent).Bold(true) + + var b strings.Builder + for i, item := range m.items { + cursor := " " + if i == m.cursor { + cursor = cursorStyle.Render(theme.Cursor) + } + + line := renderMenuItem(item, hotStyle, labelStyle) + fmt.Fprintf(&b, "%s%s\n", cursor, line) + } + + return b.String() +} + +// Title returns the menu's display title. +func (m *Menu) Title() string { return "Select a command" } + +// KeyBindings returns the key bindings for the help bar. +func (m *Menu) KeyBindings() []key.Binding { + return []key.Binding{menuKeys.Up, menuKeys.Enter} +} + +// Selection returns the chosen command name, or "" if nothing has +// been selected yet. +func (m *Menu) Selection() string { return m.selection } + +// renderMenuItem formats a menu item with the hotkey highlighted. +// For example, with hotkey 'b' and label "backup", it renders +// "[b]ackup" where [b] is in the accent style. +// +// The search is rune-based so multi-byte characters are handled +// correctly. +func renderMenuItem(item MenuItem, hotStyle, labelStyle lipgloss.Style) string { + label := item.Label + hk := unicode.ToLower(item.Hotkey) + + runes := []rune(label) + for i, r := range runes { + if unicode.ToLower(r) == hk { + before := string(runes[:i]) + match := string(runes[i : i+1]) + after := string(runes[i+1:]) + return labelStyle.Render(before) + + hotStyle.Render("["+match+"]") + + labelStyle.Render(after) + } + } + + // Hotkey not found in label — show as prefix. + return hotStyle.Render("["+string(item.Hotkey)+"]") + " " + labelStyle.Render(label) +} diff --git a/internal/ui/screens/menu_test.go b/internal/ui/screens/menu_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c63aee3cba8f2d3fd7148e3ec89fb16cb917fba1 --- /dev/null +++ b/internal/ui/screens/menu_test.go @@ -0,0 +1,275 @@ +package screens + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/theme" + "git.secluded.site/keld/internal/ui" +) + +// testStyles returns a *theme.Styles for testing. Dark mode is used +// because it's the session default. +func testStyles() *theme.Styles { + s := theme.New(true) + return &s +} + +func testItems() []MenuItem { + return []MenuItem{ + {Label: "backup", Hotkey: 'b'}, + {Label: "restore", Hotkey: 'r'}, + {Label: "snapshots", Hotkey: 's'}, + } +} + +func TestMenuTitle(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + if got := m.Title(); got != "Select a command" { + t.Errorf("Title() = %q, want %q", got, "Select a command") + } +} + +func TestMenuKeyBindings(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + bindings := m.KeyBindings() + + if len(bindings) == 0 { + t.Fatal("KeyBindings() returned no bindings") + } + + // Should include bindings for navigation and selection. + helpKeys := make(map[string]bool) + for _, b := range bindings { + helpKeys[b.Help().Key] = true + } + + for _, want := range []string{"↑/↓", "enter"} { + if !helpKeys[want] { + t.Errorf("KeyBindings() missing help key %q, got %v", want, helpKeys) + } + } +} + +func TestMenuSelectionEmpty(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + if got := m.Selection(); got != "" { + t.Errorf("Selection() before interaction = %q, want empty", got) + } +} + +func TestMenuEnterSelects(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + + // Cursor starts at 0 ("backup"). Press enter. + updated, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + menu := updated.(*Menu) + + if cmd == nil { + t.Fatal("expected DoneCmd on enter") + } + // Verify the cmd produces a DoneMsg. + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("cmd produced %T, want ui.DoneMsg", msg) + } + + if got := menu.Selection(); got != "backup" { + t.Errorf("Selection() = %q, want %q", got, "backup") + } +} + +func TestMenuHotkeySelects(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + + // Press 'r' — should jump to "restore" and select it. + updated, cmd := m.Update(tea.KeyPressMsg{Code: 'r', Text: "r"}) + menu := updated.(*Menu) + + if cmd == nil { + t.Fatal("expected DoneCmd on hotkey") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("cmd produced %T, want ui.DoneMsg", msg) + } + + if got := menu.Selection(); got != "restore" { + t.Errorf("Selection() = %q, want %q", got, "restore") + } +} + +func TestMenuEscReturnsBack(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + + _, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if cmd == nil { + t.Fatal("expected BackCmd on Esc") + } + msg := cmd() + if _, ok := msg.(ui.BackMsg); !ok { + t.Errorf("cmd produced %T, want ui.BackMsg", msg) + } +} + +func TestMenuCursorNavigation(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + + // Start at 0. Move down twice. + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + updated, _ = updated.(*Menu).Update(tea.KeyPressMsg{Code: tea.KeyDown}) + menu := updated.(*Menu) + + // Select to verify cursor is at index 2 ("snapshots"). + updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + menu = updated.(*Menu) + if got := menu.Selection(); got != "snapshots" { + t.Errorf("after two downs, Selection() = %q, want %q", got, "snapshots") + } +} + +func TestMenuCursorClampsAtBounds(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + + // Try moving up past the top — should clamp at 0. + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + menu := updated.(*Menu) + + updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + menu = updated.(*Menu) + if got := menu.Selection(); got != "backup" { + t.Errorf("after up at top, Selection() = %q, want %q", got, "backup") + } +} + +func TestMenuCursorClampsAtBottom(t *testing.T) { + t.Parallel() + + items := []MenuItem{{Label: "one", Hotkey: 'o'}} + m := NewMenu(items, testStyles()) + + // Move down past the bottom — should clamp at last item. + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) + menu := updated.(*Menu) + + updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + menu = updated.(*Menu) + if got := menu.Selection(); got != "one" { + t.Errorf("after down past end, Selection() = %q, want %q", got, "one") + } +} + +func TestMenuViewShowsCursor(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + view := m.View() + + if !strings.Contains(view, theme.Cursor) { + t.Errorf("View() should contain cursor %q, got:\n%s", theme.Cursor, view) + } +} + +func TestMenuViewNoHelpText(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + view := m.View() + + // The old menu rendered its own help string. The session now + // provides the help bar, so the menu view must not include it. + if strings.Contains(view, "navigate") { + t.Errorf("View() should not contain help text, got:\n%s", view) + } + if strings.Contains(view, "q to quit") { + t.Errorf("View() should not contain quit help, got:\n%s", view) + } +} + +func TestMenuItemValueOverridesLabel(t *testing.T) { + t.Parallel() + + items := []MenuItem{ + {Label: "display name", Hotkey: 'd', Value: "actual-value"}, + } + m := NewMenu(items, testStyles()) + + updated, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + menu := updated.(*Menu) + + if got := menu.Selection(); got != "actual-value" { + t.Errorf("Selection() = %q, want %q (Value should override Label)", got, "actual-value") + } +} + +func TestMenuQKeyNoSpecialBehavior(t *testing.T) { + t.Parallel() + + // Without a quit item, 'q' should do nothing special — it's not + // a hotkey for any of these items. + m := NewMenu(testItems(), testStyles()) + + updated, cmd := m.Update(tea.KeyPressMsg{Code: 'q', Text: "q"}) + menu := updated.(*Menu) + + if cmd != nil { + t.Error("'q' should not produce any command when not a hotkey") + } + if menu.Selection() != "" { + t.Error("'q' should not select anything when not a hotkey") + } +} + +func TestMenuKJNavigation(t *testing.T) { + t.Parallel() + + m := NewMenu(testItems(), testStyles()) + + // 'j' should move down (vim-style). + updated, _ := m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"}) + menu := updated.(*Menu) + + updated, _ = menu.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + menu = updated.(*Menu) + if got := menu.Selection(); got != "restore" { + t.Errorf("after 'j', Selection() = %q, want %q", got, "restore") + } +} + +func TestMenuRuneHotkey(t *testing.T) { + t.Parallel() + + // A hotkey that is a multi-byte UTF-8 rune (like 'ñ') should + // still be matched correctly via rune comparison. + items := []MenuItem{{Label: "año", Hotkey: 'ñ'}} + m := NewMenu(items, testStyles()) + + updated, cmd := m.Update(tea.KeyPressMsg{Code: 0, Text: "ñ"}) + menu := updated.(*Menu) + + if cmd == nil { + t.Fatal("expected DoneCmd for multi-byte rune hotkey") + } + if menu.Selection() != "año" { + t.Errorf("Selection() = %q, want %q", menu.Selection(), "año") + } +} diff --git a/internal/ui/screens/preset.go b/internal/ui/screens/preset.go new file mode 100644 index 0000000000000000000000000000000000000000..02a787dd57231ad5053ce54b152e0d44e54a3e97 --- /dev/null +++ b/internal/ui/screens/preset.go @@ -0,0 +1,149 @@ +package screens + +import ( + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "charm.land/huh/v2" + + "git.secluded.site/keld/internal/theme" + "git.secluded.site/keld/internal/ui" +) + +// globalDefaultLabel is the breadcrumb display text when the user +// selects "(global defaults only)". This avoids the ambiguity of +// Selection() returning "" for both "not yet selected" and "selected +// global defaults". +const globalDefaultLabel = "(global defaults)" + +// Preset is a Screen adapter that wraps a huh Select form for +// choosing a configuration preset. It intercepts Esc for back +// navigation when the filter is not active, and delegates all other +// input to the embedded form. +type Preset struct { + presets []string + styles *theme.Styles + form *huh.Form + selectFld *huh.Select[string] + selected string + selection string +} + +// NewPreset creates a preset selector screen. The styles pointer +// should come from Session.Styles() so theme updates propagate +// automatically. +// +// The huh form is not built until Init is called, so the form +// picks up whatever theme the session has settled on by then +// (after background colour detection). +func NewPreset(presets []string, styles *theme.Styles) *Preset { + return &Preset{ + presets: presets, + styles: styles, + } +} + +// buildForm constructs the huh form and select field. Called from +// the constructor and from Init when the form needs resetting +// (e.g. after back navigation). +func (p *Preset) buildForm() { + opts := make([]huh.Option[string], 0, len(p.presets)+1) + opts = append(opts, huh.NewOption("(global defaults only)", "")) + for _, name := range p.presets { + opts = append(opts, huh.NewOption(name, name)) + } + + // Preserve any previous selection so back-navigation shows + // the user's earlier choice. Reset the breadcrumb label since + // the screen is being re-activated (no longer "completed"). + p.selection = "" + + sel := huh.NewSelect[string](). + Options(opts...). + Value(&p.selected) + + p.selectFld = sel + + p.form = huh.NewForm( + huh.NewGroup(sel), + ).WithTheme(p.styles.Huh).WithShowHelp(false) +} + +// Init initialises the embedded form. On first call or after +// completion, the form is (re)built so it picks up the current +// theme and can accept input again. +func (p *Preset) Init() tea.Cmd { + if p.form == nil || p.form.State != huh.StateNormal { + p.buildForm() + } + return p.form.Init() +} + +// Update handles messages. Esc is intercepted for back navigation +// when the select field is not in filter mode. All other messages +// are forwarded to the huh form. +func (p *Preset) Update(msg tea.Msg) (ui.Screen, tea.Cmd) { + if p.form == nil { + return p, nil + } + + switch msg.(type) { + case tea.BackgroundColorMsg: + // The session has already updated *styles. Rebuild the + // form so it picks up the new huh theme. + p.buildForm() + return p, p.form.Init() + } + + // Intercept Esc for back navigation, but only when the select + // field is not filtering. When filtering, Esc should close the + // filter (handled by huh internally). + if kp, ok := msg.(tea.KeyPressMsg); ok { + if kp.Code == tea.KeyEscape && !p.selectFld.GetFiltering() { + return p, ui.BackCmd + } + } + + // Forward to huh. + model, cmd := p.form.Update(msg) + if f, ok := model.(*huh.Form); ok { + p.form = f + } + + if p.form.State == huh.StateCompleted { + if p.selected == "" { + p.selection = globalDefaultLabel + } else { + p.selection = p.selected + } + return p, ui.DoneCmd + } + + return p, cmd +} + +// View renders the form. +func (p *Preset) View() string { + if p.form == nil { + return "" + } + return p.form.View() +} + +// Title returns the screen's display title. +func (p *Preset) Title() string { return "Select a preset" } + +// KeyBindings returns bindings for the help bar. The huh form +// handles its own key bindings internally; we expose the most +// relevant ones for display. +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. +func (p *Preset) Selection() string { return p.selection } diff --git a/internal/ui/screens/preset_test.go b/internal/ui/screens/preset_test.go new file mode 100644 index 0000000000000000000000000000000000000000..79f29450d7cf41467a6cf1b2317e664dc3b11843 --- /dev/null +++ b/internal/ui/screens/preset_test.go @@ -0,0 +1,265 @@ +package screens + +import ( + "image/color" + "testing" + + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/theme" + "git.secluded.site/keld/internal/ui" +) + +func testPresets() []string { + return []string{"home@cloud", "work@local", "media"} +} + +// drainPreset feeds commands back into the preset screen until a +// DoneMsg or BackMsg is produced, or the command chain is exhausted. +// This is necessary because huh uses internal message chains +// (nextFieldMsg → nextGroupMsg → StateCompleted) that must be +// processed sequentially. +func drainPreset(p *Preset, initialCmd tea.Cmd) (*Preset, tea.Cmd) { + cmd := initialCmd + for cmd != nil { + msg := cmd() + if msg == nil { + return p, nil + } + // Check if this is one of our terminal messages. + switch msg.(type) { + case ui.DoneMsg: + return p, cmd + case ui.BackMsg: + return p, cmd + } + // Otherwise feed it back to the screen. + var screen ui.Screen + screen, cmd = p.Update(msg) + p = screen.(*Preset) + } + return p, nil +} + +func TestPresetTitle(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + if got := p.Title(); got != "Select a preset" { + t.Errorf("Title() = %q, want %q", got, "Select a preset") + } +} + +func TestPresetSelectionEmpty(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + if got := p.Selection(); got != "" { + t.Errorf("Selection() before interaction = %q, want empty", got) + } +} + +func TestPresetKeyBindings(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + bindings := p.KeyBindings() + + if len(bindings) == 0 { + t.Fatal("KeyBindings() returned no bindings") + } +} + +func TestPresetEscWhenNotFilteringReturnsBack(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + // Initialise the form so huh is ready to receive input. + p.Init() + // Send a WindowSizeMsg so huh can lay out its fields. + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Esc when not filtering should signal back navigation. + _, cmd := p.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + + if cmd == nil { + t.Fatal("expected BackCmd on Esc when not filtering") + } + msg := cmd() + if _, ok := msg.(ui.BackMsg); !ok { + t.Errorf("cmd produced %T, want ui.BackMsg", msg) + } +} + +func TestPresetEscWhileFilteringDoesNotBack(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Activate filter mode by pressing '/'. + p.Update(tea.KeyPressMsg{Code: '/', Text: "/"}) + + // Esc while filtering should close the filter, not back out. + updated, cmd := p.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + preset := updated.(*Preset) + + // Should still be on this screen (no BackCmd). + if cmd != nil { + msg := cmd() + if _, ok := msg.(ui.BackMsg); ok { + t.Error("Esc while filtering should not produce BackMsg") + } + } + + // Screen should still be functional (not backed out). + if preset.Selection() != "" { + t.Error("preset should not have a selection after Esc from filter") + } +} + +func TestPresetCompleteReturnsDone(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Press enter to select the focused option (first one: global defaults). + // huh uses an internal message chain (nextFieldMsg → nextGroupMsg → + // StateCompleted), so we drain until DoneMsg appears. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + _, cmd = drainPreset(screen.(*Preset), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after form completion") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("cmd produced %T, want ui.DoneMsg", msg) + } +} + +func TestPresetSelectionValue(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Move down to the first real preset ("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.Selection(); got != "home@cloud" { + t.Errorf("Selection() = %q, want %q", got, "home@cloud") + } +} + +func TestPresetGlobalDefaultDisplayLabel(t *testing.T) { + t.Parallel() + + // When the user selects "(global defaults only)", the resolved + // value is "" but the breadcrumb display should be meaningful. + p := NewPreset(testPresets(), testStyles()) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // First option is the global default. Select it. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + p, _ = drainPreset(screen.(*Preset), cmd) + + // Selection() should return a display-friendly label, not "". + sel := p.Selection() + if sel == "" { + t.Error("Selection() for global defaults should return a display label, not empty string") + } +} + +func TestPresetReinitPreservesSelection(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 p.Selection() != "home@cloud" { + t.Fatalf("precondition: Selection() = %q, want %q", p.Selection(), "home@cloud") + } + + // Re-initialise (simulates back navigation). The form state + // should carry the previous selection through. + p.Init() + + // The internal selected value should still be "home@cloud". + if p.selected != "home@cloud" { + t.Errorf("after re-init, selected = %q, want %q (should preserve previous choice)", p.selected, "home@cloud") + } +} + +func TestPresetReinitAfterBack(t *testing.T) { + t.Parallel() + + p := NewPreset(testPresets(), testStyles()) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Complete the form first so the state is no longer StateNormal. + var screen ui.Screen + var cmd tea.Cmd + screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + p, _ = drainPreset(screen.(*Preset), cmd) + + // Re-initialise (simulates the session navigating back to this screen). + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + // Should be functional again — selecting should produce DoneCmd. + screen, cmd = p.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + _, cmd = drainPreset(screen.(*Preset), cmd) + + if cmd == nil { + t.Fatal("expected DoneCmd after re-init, form may not have been reset") + } + msg := cmd() + if _, ok := msg.(ui.DoneMsg); !ok { + t.Errorf("after re-init, cmd produced %T, want ui.DoneMsg", msg) + } +} + +func TestPresetRebuildsFormOnBackgroundChange(t *testing.T) { + t.Parallel() + + styles := testStyles() + p := NewPreset(testPresets(), styles) + p.Init() + p.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + + originalForm := p.form + + // Simulate background detection switching to light. The session + // would have already mutated *styles before forwarding this msg. + *styles = theme.New(false) + updated, _ := p.Update(tea.BackgroundColorMsg{Color: color.White}) + p = updated.(*Preset) + + // The form should have been rebuilt with the new theme. + if p.form == originalForm { + t.Error("form was not rebuilt after BackgroundColorMsg; cached theme is stale") + } +}