vcom/plans/theme-selector-dialog.md
vrubelroman 92e72bc8c6 feat: theme selector dialog with live preview (t key)
Replace the simple cycleTheme() with a modal dialog that shows all
19 themes with color swatches, live preview on Up/Down navigation,
and commit/revert on Enter/Esc.

Changes:
- Add modalThemeSelect kind to modalKind enum
- Add themeSelectorState struct + field to Model
- Add openThemeSelector() replacing cycleTheme() call
- Add handleModalKey case for modalThemeSelect (Up/Down/Enter/Esc)
- Add applyThemePreview() and finalizeTheme() helper methods
- Add renderThemeSelectModal() with aligned theme names + swatches
- Update renderModal() dispatch and widen modal for theme selector
- Update help text: 't' now shows 'select theme'
2026-04-29 16:13:29 +03:00

6.8 KiB

Plan: Theme Selector Dialog with Live Preview

Summary

Replace the current cycleTheme() (simple cycle through themes on t) with a modal dialog that shows all themes, their base colors, supports live preview via Up/Down navigation, and commit/revert on Enter/Esc.

Changes

1. internal/ui/model.go — New modal kind

Add to modalKind const (around line 34):

modalThemeSelect

2. internal/ui/model.go — New theme selector state

Add new struct and field to Model:

type themeSelectorState struct {
    names    []string    // all theme names in order
    cursor   int         // current cursor index in the list
    original string      // the theme name before opening dialog (for Esc revert)
}

type Model struct {
    // ... existing fields ...
    themeSelector *themeSelectorState  // nil when not in theme selector dialog
}

3. internal/ui/model.go — New handler openThemeSelector()

Replace the cycleTheme() call with openThemeSelector():

  1. Read current theme name from m.cfg.UI.Theme
  2. Get all theme names via theme.Names()
  3. Find current theme index in the list
  4. Create themeSelectorState with names, cursor=current index, original=current theme
  5. Set m.modal.kind = modalThemeSelect
  6. Set modal title/body (body may be empty since list is rendered in renderThemeSelectModal)

The t key match (case key.Matches(msg, m.keys.CycleTheme)) now calls m.openThemeSelector() instead of m.cycleTheme().

4. internal/ui/model.go — Modal key handling

Add a new case in handleModalKey() for modalThemeSelect:

case modalThemeSelect:
    switch {
    case msg.String() == "up" || msg.String() == "k":
        // Move cursor up, clamp to 0, apply theme preview
        m.themeSelector.cursor--
        if m.themeSelector.cursor < 0 { m.themeSelector.cursor = 0 }
        m.applyThemePreview(m.themeSelector.names[m.themeSelector.cursor])
        return m, nil
    case msg.String() == "down" || msg.String() == "j":
        // Move cursor down, clamp to len-1, apply theme preview
        m.themeSelector.cursor++
        if m.themeSelector.cursor >= len(m.themeSelector.names) {
            m.themeSelector.cursor = len(m.themeSelector.names) - 1
        }
        m.applyThemePreview(m.themeSelector.names[m.themeSelector.cursor])
        return m, nil
    case key.Matches(msg, m.keys.Confirm):  // Enter
        // Apply selected theme and save
        selected := m.themeSelector.names[m.themeSelector.cursor]
        m.finalizeTheme(selected)
        m.themeSelector = nil
        m.modal = modalState{}
        m.status = fmt.Sprintf("Theme: %s", selected)
        return m, nil
    case msg.String() == "esc":
        // Revert to original theme
        m.applyThemePreview(m.themeSelector.original)
        m.themeSelector = nil
        m.modal = modalState{}
        m.status = "Theme unchanged"
        return m, nil
    }

5. internal/ui/model.go — applyThemePreview helper

A new method that applies a theme palette to m.palette without saving to config:

func (m *Model) applyThemePreview(name string) {
    palette, err := theme.Resolve(name)
    if err != nil {
        return  // silently ignore resolve errors during preview
    }
    m.palette = palette
    // Don't update m.cfg.UI.Theme or save — that's only on Enter
}

6. internal/ui/model.go — finalizeTheme helper

A new method that applies the theme AND saves to config:

func (m *Model) finalizeTheme(name string) {
    palette, err := theme.Resolve(name)
    if err != nil {
        m.status = err.Error()
        return
    }
    m.cfg.UI.Theme = name
    m.palette = palette
    savedPath, saveErr := config.Save(m.cfg, m.configPath)
    if saveErr != nil {
        m.status = fmt.Sprintf("Theme: %s (save failed: %v)", name, saveErr)
        return
    }
    m.configPath = savedPath
}

7. internal/ui/model.go — renderThemeSelectModal

Create a new render function renderThemeSelectModal():

func renderThemeSelectModal(m Model, palette theme.Palette, width int) string {
    outerWidth := max(width, 8)
    contentWidth := max(outerWidth-6, 1)
    
    // Styles
    titleStyle := lipgloss.NewStyle()...
    box := lipgloss.NewStyle()...
    
    // Build theme list rows
    lines := []string{titleStyle.Render("Select Theme"), spacer}
    lines = append(lines, instructions)
    lines = append(lines, spacer)
    
    for i, name := range m.themeSelector.names {
        resolved, err := theme.Resolve(name)
        // skip if error
        
        // Selection indicator + theme name
        // Color swatches: Background, Panel, Accent, Text, Selection
        // Highlight current item
    }
    
    return box.Render(strings.Join(lines, "\n"))
}

Each row shows:

  • Cursor indicator ( or )
  • Theme name
  • Color swatches as small colored blocks: Background, Panel, Accent, Text, Selection
  • Currently selected item is highlighted with Selection background color

8. internal/ui/model.go — Update View dispatch

Add a new condition in renderModal() (around line 3698) to dispatch to renderThemeSelectModal when modalKind == modalThemeSelect:

if m.modal.kind == modalThemeSelect {
    return renderThemeSelectModal(m, palette, width)
}

9. internal/ui/model.go — Remove old cycleTheme (optional)

The old cycleTheme() method can be kept for backward compatibility or removed. The t key will now open the dialog instead.

10. Help dialog update (optional)

Update the help dialog to reflect the new behavior: "t open theme selector" instead of "t cycle theme".

Files modified

File Changes
internal/ui/model.go Add modalThemeSelect kind, themeSelectorState struct, openThemeSelector(), applyThemePreview(), finalizeTheme(), key handling in handleModalKey, renderThemeSelectModal(), update renderModal() dispatch
(none else) Theme data, config saving, keymap — all remain unchanged

Key design points

  1. Live preview: Every Up/Down key press instantly resolves the new theme palette and applies it to m.palette. The user sees the full UI change in real time.
  2. Safe revert: On Esc, the original theme (saved in themeSelectorState.original) is restored.
  3. Config save only on Enter: Only finalizeTheme() persists to config file.
  4. Color swatches: Each theme row shows 5 small colored blocks (Background, Panel, Accent, Text, Selection) so the user can visually compare themes.
  5. Clean state management: themeSelector is nil when the dialog is closed, making it easy to check state.

Not changed

  • internal/theme/ — storage layer unchanged
  • internal/config/ — no config changes
  • internal/ui/keymap.got binding unchanged, only behavior changes
  • internal/ui/pane.go — no changes