diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 5d0df15..9d8cdbd 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -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")), diff --git a/internal/ui/model.go b/internal/ui/model.go index e5e0cec..687eb31 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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 @@ -253,7 +260,8 @@ type Model struct { archiveFormat string deleteKind string // "trash" or "permanent" — selected in delete modal ssh *sshState - preSSHPath string // original path before entering SSH mode + 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). diff --git a/plans/theme-selector-dialog.md b/plans/theme-selector-dialog.md new file mode 100644 index 0000000..1eeceec --- /dev/null +++ b/plans/theme-selector-dialog.md @@ -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 diff --git a/vcom.toml b/vcom.toml index 498e302..55ad510 100644 --- a/vcom.toml +++ b/vcom.toml @@ -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