194 lines
6.8 KiB
Markdown
194 lines
6.8 KiB
Markdown
|
|
# 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):
|
||
|
|
```go
|
||
|
|
modalThemeSelect
|
||
|
|
```
|
||
|
|
|
||
|
|
### 2. `internal/ui/model.go` — New theme selector state
|
||
|
|
|
||
|
|
Add new struct and field to `Model`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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**:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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()`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
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.go` — `t` binding unchanged, only behavior changes
|
||
|
|
- `internal/ui/pane.go` — no changes
|