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'
This commit is contained in:
parent
cd877ab584
commit
92e72bc8c6
4 changed files with 433 additions and 5 deletions
|
|
@ -53,7 +53,7 @@ func DefaultKeyMap() KeyMap {
|
|||
Info: key.NewBinding(key.WithKeys("f9", "o"), key.WithHelp("F9/o", "info")),
|
||||
SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")),
|
||||
ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")),
|
||||
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")),
|
||||
CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select theme")),
|
||||
CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")),
|
||||
SSH: key.NewBinding(key.WithKeys("f12", "s"), key.WithHelp("F12/s", "ssh")),
|
||||
Mirror: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "mirror pane")),
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ const (
|
|||
modalArchiveType
|
||||
modalArchiveProgress
|
||||
modalSSHConnect
|
||||
modalThemeSelect
|
||||
)
|
||||
|
||||
type fileOpKind int
|
||||
|
|
@ -83,6 +84,12 @@ type modalState struct {
|
|||
pending *pendingOperation
|
||||
}
|
||||
|
||||
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 previewMsg struct {
|
||||
entryPath string
|
||||
preview vfs.Preview
|
||||
|
|
@ -254,6 +261,7 @@ type Model struct {
|
|||
deleteKind string // "trash" or "permanent" — selected in delete modal
|
||||
ssh *sshState
|
||||
preSSHPath string // original path before entering SSH mode
|
||||
themeSelector *themeSelectorState // nil when not in theme selector dialog
|
||||
}
|
||||
|
||||
func NewModel(cfg config.Config, configPath string) (Model, error) {
|
||||
|
|
@ -1187,7 +1195,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.toggleHidden()
|
||||
case key.Matches(msg, m.keys.CycleTheme):
|
||||
log.Printf("[KEY] Cycle theme")
|
||||
return m.cycleTheme()
|
||||
return m.openThemeSelector()
|
||||
case key.Matches(msg, m.keys.CycleSort):
|
||||
log.Printf("[KEY] Cycle sort")
|
||||
return m.cycleSort()
|
||||
|
|
@ -1358,7 +1366,7 @@ func (m Model) View() string {
|
|||
m.overlay.hide()
|
||||
}
|
||||
modalWidth := min(72, m.width-8)
|
||||
if m.modal.kind == modalHelp {
|
||||
if m.modal.kind == modalHelp || m.modal.kind == modalThemeSelect {
|
||||
modalWidth = min(96, m.width-8)
|
||||
}
|
||||
view = overlayCenter(view, renderModal(m, m.palette, modalWidth), m.width)
|
||||
|
|
@ -1600,6 +1608,56 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
|
||||
case modalThemeSelect:
|
||||
switch {
|
||||
case msg.String() == "up" || msg.String() == "k":
|
||||
if m.themeSelector == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.themeSelector.cursor--
|
||||
if m.themeSelector.cursor < 0 {
|
||||
m.themeSelector.cursor = 0
|
||||
}
|
||||
selected := m.themeSelector.names[m.themeSelector.cursor]
|
||||
m.applyThemePreview(selected)
|
||||
log.Printf("[THEME] Preview: %s", selected)
|
||||
return m, nil
|
||||
case msg.String() == "down" || msg.String() == "j":
|
||||
if m.themeSelector == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.themeSelector.cursor++
|
||||
if m.themeSelector.cursor >= len(m.themeSelector.names) {
|
||||
m.themeSelector.cursor = len(m.themeSelector.names) - 1
|
||||
}
|
||||
selected := m.themeSelector.names[m.themeSelector.cursor]
|
||||
m.applyThemePreview(selected)
|
||||
log.Printf("[THEME] Preview: %s", selected)
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Confirm):
|
||||
if m.themeSelector == nil {
|
||||
return m, nil
|
||||
}
|
||||
selected := m.themeSelector.names[m.themeSelector.cursor]
|
||||
m.finalizeTheme(selected)
|
||||
m.themeSelector = nil
|
||||
m.modal = modalState{}
|
||||
m.status = fmt.Sprintf("Theme: %s", selected)
|
||||
log.Printf("[THEME] Applied: %s", selected)
|
||||
return m, nil
|
||||
case isModalCloseKey(msg, m.keys):
|
||||
if m.themeSelector == nil {
|
||||
return m, nil
|
||||
}
|
||||
m.applyThemePreview(m.themeSelector.original)
|
||||
m.themeSelector = nil
|
||||
m.modal = modalState{}
|
||||
m.status = "Theme unchanged"
|
||||
log.Printf("[THEME] Reverted to: %s", m.cfg.UI.Theme)
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case modalArchiveProgress:
|
||||
if key.Matches(msg, m.keys.Background) {
|
||||
if m.archiveJob == nil {
|
||||
|
|
@ -2831,6 +2889,52 @@ func (m *Model) cycleTheme() (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) openThemeSelector() (tea.Model, tea.Cmd) {
|
||||
names := theme.Names()
|
||||
current := m.cfg.UI.Theme
|
||||
cursor := 0
|
||||
for i, name := range names {
|
||||
if name == current {
|
||||
cursor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
m.themeSelector = &themeSelectorState{
|
||||
names: names,
|
||||
cursor: cursor,
|
||||
original: current,
|
||||
}
|
||||
m.modal = modalState{
|
||||
kind: modalThemeSelect,
|
||||
}
|
||||
log.Printf("[THEME] Theme selector opened — current=%s cursor=%d", current, cursor)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) applyThemePreview(name string) {
|
||||
palette, err := theme.Resolve(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m.palette = palette
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func copyTextToClipboard(text string) error {
|
||||
if err := clipboard.WriteAll(text); err == nil {
|
||||
return nil
|
||||
|
|
@ -3710,6 +3814,10 @@ func renderModal(m Model, palette theme.Palette, width int) string {
|
|||
return renderSSHConnectModal(m, palette, width)
|
||||
}
|
||||
|
||||
if m.modal.kind == modalThemeSelect {
|
||||
return renderThemeSelectModal(m, palette, width)
|
||||
}
|
||||
|
||||
modal := m.modal
|
||||
outerWidth := max(width, 8)
|
||||
contentWidth := max(outerWidth-6, 1)
|
||||
|
|
@ -3749,6 +3857,133 @@ func renderModal(m Model, palette theme.Palette, width int) string {
|
|||
return box.Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func renderThemeSelectModal(m Model, palette theme.Palette, width int) string {
|
||||
outerWidth := max(width, 8)
|
||||
contentWidth := max(outerWidth-6, 1)
|
||||
|
||||
// Pre-compute name column width (align names left, swatches right)
|
||||
themeNames := m.themeSelector.names
|
||||
maxNameLen := 0
|
||||
for _, name := range themeNames {
|
||||
if len(name) > maxNameLen {
|
||||
maxNameLen = len(name)
|
||||
}
|
||||
}
|
||||
// Leave room for cursor (2 chars), name, spacing, and 5 swatches (10 chars)
|
||||
swatchArea := 12 // " " gap + 5*" " swatches = 12
|
||||
nameColWidth := contentWidth - swatchArea - 3 // -3 for padding
|
||||
if nameColWidth < 10 {
|
||||
nameColWidth = 10
|
||||
}
|
||||
|
||||
titleStyle := lipgloss.NewStyle().
|
||||
Width(contentWidth).
|
||||
Background(palette.Panel).
|
||||
Bold(true).
|
||||
Foreground(palette.Accent)
|
||||
|
||||
padStyle := lipgloss.NewStyle().
|
||||
Width(contentWidth).
|
||||
Background(palette.Panel)
|
||||
|
||||
textColStyle := lipgloss.NewStyle().
|
||||
Width(nameColWidth).
|
||||
Background(palette.Panel).
|
||||
Foreground(palette.Text)
|
||||
|
||||
textColSelStyle := lipgloss.NewStyle().
|
||||
Width(nameColWidth).
|
||||
Background(palette.Selection).
|
||||
Foreground(palette.Text)
|
||||
|
||||
cursorStyle := lipgloss.NewStyle().
|
||||
Width(2).
|
||||
Background(palette.Panel).
|
||||
Foreground(palette.Accent)
|
||||
|
||||
cursorSelStyle := lipgloss.NewStyle().
|
||||
Width(2).
|
||||
Background(palette.Selection).
|
||||
Foreground(palette.Accent)
|
||||
|
||||
instructions := lipgloss.NewStyle().
|
||||
Width(contentWidth).
|
||||
Background(palette.Panel).
|
||||
Foreground(palette.Muted).
|
||||
Render("↑/↓ navigate · Enter apply · Esc cancel")
|
||||
|
||||
box := lipgloss.NewStyle().
|
||||
Width(contentWidth).
|
||||
Padding(1, 2).
|
||||
Background(palette.Panel).
|
||||
Foreground(palette.Text).
|
||||
BorderStyle(lipgloss.DoubleBorder()).
|
||||
BorderForeground(palette.BorderActive).
|
||||
BorderBackground(palette.Panel)
|
||||
|
||||
spacer := padStyle.Render(" ")
|
||||
|
||||
lines := []string{
|
||||
titleStyle.Render("Select Theme"),
|
||||
spacer,
|
||||
instructions,
|
||||
spacer,
|
||||
}
|
||||
|
||||
for i, name := range themeNames {
|
||||
tp, err := theme.Resolve(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine if this is the selected row
|
||||
isSelected := i == m.themeSelector.cursor
|
||||
|
||||
// Cursor indicator
|
||||
var cursorPart string
|
||||
if isSelected {
|
||||
cursorPart = cursorSelStyle.Render("▸")
|
||||
} else {
|
||||
cursorPart = cursorStyle.Render(" ")
|
||||
}
|
||||
|
||||
// Name part with padding to align
|
||||
paddedName := fmt.Sprintf("%-*s", maxNameLen, name)
|
||||
var namePart string
|
||||
if isSelected {
|
||||
namePart = textColSelStyle.Render(paddedName)
|
||||
} else {
|
||||
namePart = textColStyle.Render(paddedName)
|
||||
}
|
||||
|
||||
// Swatches use the resolved theme's palette colors
|
||||
// Background for swatches depends on whether row is selected
|
||||
swatchBg := palette.Panel
|
||||
if isSelected {
|
||||
swatchBg = palette.Selection
|
||||
}
|
||||
swatch := lipgloss.NewStyle().Background(swatchBg).Render(
|
||||
lipgloss.NewStyle().Background(tp.Background).Render(" ") +
|
||||
lipgloss.NewStyle().Background(tp.Panel).Render(" ") +
|
||||
lipgloss.NewStyle().Background(tp.Accent).Render(" ") +
|
||||
lipgloss.NewStyle().Background(tp.Text).Render(" ") +
|
||||
lipgloss.NewStyle().Background(tp.Selection).Render(" "),
|
||||
)
|
||||
|
||||
// Gap between name and swatches
|
||||
gapStyle := lipgloss.NewStyle().
|
||||
Width(1).
|
||||
Background(swatchBg)
|
||||
gap := gapStyle.Render(" ")
|
||||
|
||||
// Build the row
|
||||
row := cursorPart + namePart + gap + swatch
|
||||
lines = append(lines, row)
|
||||
}
|
||||
|
||||
return box.Render(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func renderModalBodyLine(raw string, width int, palette theme.Palette) string {
|
||||
base := lipgloss.NewStyle().
|
||||
Width(width).
|
||||
|
|
|
|||
193
plans/theme-selector-dialog.md
Normal file
193
plans/theme-selector-dialog.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# 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
|
||||
|
|
@ -4,7 +4,7 @@ right_path = ''
|
|||
|
||||
[ui]
|
||||
app_title = 'vcom'
|
||||
theme = 'ayu-dark'
|
||||
theme = 'catppuccin-mocha'
|
||||
icon_mode = 'auto'
|
||||
show_title_bar = true
|
||||
show_footer = true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue