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'
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():
- Read current theme name from
m.cfg.UI.Theme - Get all theme names via
theme.Names() - Find current theme index in the list
- Create
themeSelectorStatewith names, cursor=current index, original=current theme - Set
m.modal.kind = modalThemeSelect - 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
Selectionbackground 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
- 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. - Safe revert: On Esc, the original theme (saved in
themeSelectorState.original) is restored. - Config save only on Enter: Only
finalizeTheme()persists to config file. - Color swatches: Each theme row shows 5 small colored blocks (Background, Panel, Accent, Text, Selection) so the user can visually compare themes.
- Clean state management:
themeSelectorisnilwhen the dialog is closed, making it easy to check state.
Not changed
internal/theme/— storage layer unchangedinternal/config/— no config changesinternal/ui/keymap.go—tbinding unchanged, only behavior changesinternal/ui/pane.go— no changes