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