Skip to content

Commit da085b7

Browse files
committed
feat: add theme picker
1 parent 9813ed6 commit da085b7

9 files changed

Lines changed: 305 additions & 27 deletions

File tree

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ direct HTTP streams, and YouTube live/videos via `yt-dlp`).
3535
thunder), CC0 sources, embedded in the binary. Each channel has its
3636
own volume; mixes layer underneath the main station.
3737
- **Four themes** — Tokyo Night, Catppuccin Mocha, Gruvbox Dark, Rose
38-
Pine. Cycle with `t`.
38+
Pine. Open the theme picker with `t`.
3939
- **Mini mode** — collapses to ~6 lines for a tmux pane (`m`).
4040
- **macOS media key** — the hardware Play/Pause key toggles the
4141
current station while `lofi-player` is running.
@@ -139,7 +139,7 @@ Quit with `q` or `ctrl+c`.
139139
| macOS `Play/Pause` | pause / resume the current station |
140140
| `+` / `=` | volume up (5%) |
141141
| `-` / `_` | volume down (5%) |
142-
| `t` | cycle theme |
142+
| `t` | open theme picker |
143143
| `m` | toggle mini mode |
144144
| `a` | add station (modal) |
145145
| `e` | edit selected station (modal) |
@@ -254,8 +254,9 @@ Four palettes ship in the binary:
254254
- **Gruvbox Dark** — earthy, high-contrast.
255255
- **Rose Pine** — muted, soft mauve.
256256

257-
Cycle live with `t`. The choice is persisted to state and reapplied on
258-
the next launch.
257+
Open the live picker with `t`, preview palettes with `↑/↓` or `j/k`,
258+
then press `enter` to select or `esc` to cancel. The choice is persisted
259+
to state and reapplied on the next launch.
259260

260261
## Mini mode and tmux
261262

README.ru.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ TUI-плеер для лофи, чиллхопа и эмбиент-радио
3636
white noise, thunder), CC0-исходники, вшиты в бинарник. У каждого
3737
канала своя громкость, миксы накладываются под основную станцию.
3838
- **Четыре темы** — Tokyo Night, Catppuccin Mocha, Gruvbox Dark,
39-
Rose Pine. Перебор по `t`.
39+
Rose Pine. Выбор из списка по `t`.
4040
- **Mini-режим** — сворачивает UI до ~6 строк, чтобы влезть в
4141
tmux-панель (`m`).
4242
- **Media key на macOS** — аппаратная клавиша Play/Pause переключает
@@ -143,7 +143,7 @@ lofi-player
143143
| macOS `Play/Pause` | пауза / продолжение текущей станции |
144144
| `+` / `=` | громкость +5% |
145145
| `-` / `_` | громкость -5% |
146-
| `t` | следующая тема |
146+
| `t` | открыть выбор темы |
147147
| `m` | переключить mini-режим |
148148
| `a` | добавить станцию (модалка) |
149149
| `e` | редактировать выбранную станцию (модалка) |
@@ -260,8 +260,9 @@ stations:
260260
- **Gruvbox Dark** — землистая, контрастная.
261261
- **Rose Pine** — приглушённая, мягкая мауве.
262262

263-
Перебор вживую по `t`. Выбор сохраняется в state и применяется при
264-
следующем запуске.
263+
Открой выбор темы по `t`, предпросматривай палитры через `↑/↓` или
264+
`j/k`, затем нажми `enter` для выбора или `esc` для отмены. Выбор
265+
сохраняется в state и применяется при следующем запуске.
265266

266267
## Mini-режим и tmux
267268

internal/theme/theme.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ package theme
77

88
import "github.com/charmbracelet/lipgloss"
99

10+
// Info describes a built-in theme for pickers, help text, and docs.
11+
type Info struct {
12+
// Name is the canonical identifier used in config/state files.
13+
Name string
14+
// DisplayName is the human-friendly label shown in the TUI.
15+
DisplayName string
16+
// Description is a short mood note that helps users choose a palette.
17+
Description string
18+
}
19+
1020
// Theme is a flat palette of semantic color roles.
1121
//
1222
// Roles are intentionally semantic (Primary, Accent, Success) rather than
@@ -124,14 +134,32 @@ func Lookup(name string) (Theme, bool) {
124134

125135
// Names returns all registered theme names in stable order — Tokyo Night
126136
// first (the default), then the rest alphabetically. Callers use this to
127-
// drive the t-cycle binding in the TUI.
137+
// drive the theme picker in the TUI.
128138
func Names() []string {
129-
return []string{
130-
"tokyo-night",
131-
"catppuccin-mocha",
132-
"gruvbox-dark",
133-
"rose-pine",
139+
out := make([]string, len(infos))
140+
for i, info := range infos {
141+
out[i] = info.Name
134142
}
143+
return out
144+
}
145+
146+
// InfoFor returns the metadata for a registered theme. Unknown names fall
147+
// back to Tokyo Night metadata and false, mirroring Lookup.
148+
func InfoFor(name string) (Info, bool) {
149+
for _, info := range infos {
150+
if info.Name == name {
151+
return info, true
152+
}
153+
}
154+
return infos[0], false
155+
}
156+
157+
// Infos returns all registered theme metadata in the same stable order as
158+
// Names. The returned slice is a copy so callers cannot mutate the registry.
159+
func Infos() []Info {
160+
out := make([]Info, len(infos))
161+
copy(out, infos)
162+
return out
135163
}
136164

137165
// Next returns the theme name that follows current in the cycle order
@@ -147,6 +175,14 @@ func Next(current string) string {
147175
return names[0]
148176
}
149177

178+
// infos is the canonical order used by Names and the TUI picker.
179+
var infos = []Info{
180+
{Name: "tokyo-night", DisplayName: "Tokyo Night", Description: "cool neon on deep blue"},
181+
{Name: "catppuccin-mocha", DisplayName: "Catppuccin Mocha", Description: "soft pastels on warm charcoal"},
182+
{Name: "gruvbox-dark", DisplayName: "Gruvbox Dark", Description: "earthy contrast with vintage warmth"},
183+
{Name: "rose-pine", DisplayName: "Rose Pine", Description: "muted mauve, calm and low-glare"},
184+
}
185+
150186
// registry maps theme names to constructors. Phase 0 shipped only Tokyo
151187
// Night; Phase 2 adds catppuccin-mocha, gruvbox-dark, and rose-pine.
152188
var registry = map[string]func() Theme{

internal/theme/theme_test.go

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,34 @@ func TestNamesStartsWithTokyoNight(t *testing.T) {
6969
}
7070
}
7171

72+
func TestThemeInfosMatchNames(t *testing.T) {
73+
infos := Infos()
74+
names := Names()
75+
if len(infos) != len(names) {
76+
t.Fatalf("Infos length = %d, Names length = %d", len(infos), len(names))
77+
}
78+
for i, info := range infos {
79+
if info.Name != names[i] {
80+
t.Errorf("Infos()[%d].Name = %q, want %q", i, info.Name, names[i])
81+
}
82+
if info.DisplayName == "" {
83+
t.Errorf("Info %q has empty DisplayName", info.Name)
84+
}
85+
if info.Description == "" {
86+
t.Errorf("Info %q has empty Description", info.Name)
87+
}
88+
got, ok := InfoFor(info.Name)
89+
if !ok || got != info {
90+
t.Errorf("InfoFor(%q) = %+v, %v; want %+v, true", info.Name, got, ok, info)
91+
}
92+
}
93+
94+
fallback, ok := InfoFor("missing")
95+
if ok || fallback.Name != "tokyo-night" {
96+
t.Errorf("InfoFor(missing) = %+v, %v; want tokyo-night fallback, false", fallback, ok)
97+
}
98+
}
99+
72100
func TestNext(t *testing.T) {
73101
names := Names()
74102
if len(names) < 2 {
@@ -79,13 +107,13 @@ func TestNext(t *testing.T) {
79107
from, want string
80108
}{
81109
{names[0], names[1]},
82-
{names[len(names)-1], names[0]}, // wrap around
83-
{"unknown-theme", names[0]}, // unknown cycles back to first
84-
{"", names[0]}, // empty cycles to first
110+
{names[len(names)-1], names[0]}, // wrap around
111+
{"unknown-theme", names[0]}, // unknown cycles back to first
112+
{"", names[0]}, // empty cycles to first
85113
}
86114
for _, tc := range tests {
87115
if got := Next(tc.from); got != tc.want {
88116
t.Errorf("Next(%q) = %q, want %q", tc.from, got, tc.want)
89117
}
90118
}
91-
}
119+
}

internal/tui/keys.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func DefaultKeyMap() KeyMap {
5353
),
5454
ThemeCycle: key.NewBinding(
5555
key.WithKeys("t"),
56-
key.WithHelp("t", "theme"),
56+
key.WithHelp("t", "themes"),
5757
),
5858
Mini: key.NewBinding(
5959
key.WithKeys("m"),

internal/tui/model.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ const (
6464
modeConfirmDelete
6565
modeShareStation
6666
modeImportStations
67+
modeThemePicker
6768
)
6869

6970
// Model is the root Bubble Tea model.
@@ -133,6 +134,12 @@ type Model struct {
133134
importStations []config.Station
134135
importSkipped int
135136

137+
// themeCursor is the highlighted row in the theme picker. The picker
138+
// previews themes live while moving; themeBeforePicker is restored on
139+
// Esc so browsing is non-destructive until Enter confirms.
140+
themeCursor int
141+
themeBeforePicker string
142+
136143
mixer *audio.AmbientMixer
137144
mixerUI mixerModel
138145

internal/tui/model_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ func send(t *testing.T, m Model, keys ...string) Model {
4444
msg = tea.KeyMsg{Type: tea.KeyDown}
4545
case "esc":
4646
msg = tea.KeyMsg{Type: tea.KeyEsc}
47+
case "enter":
48+
msg = tea.KeyMsg{Type: tea.KeyEnter}
4749
default:
4850
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}
4951
}
@@ -175,6 +177,53 @@ func TestUpdate_HelpToggle(t *testing.T) {
175177
}
176178
}
177179

180+
func TestThemePickerPreviewCancelAndConfirm(t *testing.T) {
181+
m := fixture()
182+
m = send(t, m, "t")
183+
if m.mode != modeThemePicker {
184+
t.Fatalf("mode after t: got %v, want modeThemePicker", m.mode)
185+
}
186+
if m.themeBeforePicker != "tokyo-night" {
187+
t.Fatalf("themeBeforePicker = %q, want tokyo-night", m.themeBeforePicker)
188+
}
189+
190+
m = send(t, m, "j")
191+
if m.theme.Name != "catppuccin-mocha" {
192+
t.Fatalf("theme after preview down = %q, want catppuccin-mocha", m.theme.Name)
193+
}
194+
m = send(t, m, "esc")
195+
if m.mode != modeFull {
196+
t.Fatalf("mode after esc: got %v, want modeFull", m.mode)
197+
}
198+
if m.theme.Name != "tokyo-night" {
199+
t.Fatalf("theme after cancel = %q, want tokyo-night", m.theme.Name)
200+
}
201+
202+
m = send(t, m, "t", "j", "enter")
203+
if m.mode != modeFull {
204+
t.Fatalf("mode after enter: got %v, want modeFull", m.mode)
205+
}
206+
if m.theme.Name != "catppuccin-mocha" {
207+
t.Fatalf("theme after confirm = %q, want catppuccin-mocha", m.theme.Name)
208+
}
209+
}
210+
211+
func TestThemePickerViewAndTopChip(t *testing.T) {
212+
m := fixture()
213+
out := m.View()
214+
if !strings.Contains(out, "Tokyo Night") {
215+
t.Fatalf("View missing active theme chip; got:\n%s", out)
216+
}
217+
218+
m = send(t, m, "t")
219+
out = m.View()
220+
for _, want := range []string{"themes", "Tokyo Night", "Catppuccin Mocha", "Gruvbox Dark", "Rose Pine", "enter", "esc"} {
221+
if !strings.Contains(out, want) {
222+
t.Fatalf("theme picker missing %q; got:\n%s", want, out)
223+
}
224+
}
225+
}
226+
178227
func TestUpdate_QuitReturnsTeaQuit(t *testing.T) {
179228
m := fixture()
180229
_, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})

internal/tui/update.go

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9292
if m.mode == modeImportStations {
9393
return m.updateImportStations(msg)
9494
}
95+
if m.mode == modeThemePicker {
96+
return m.updateThemePicker(msg)
97+
}
9598

9699
switch msg := msg.(type) {
97100
case tea.WindowSizeMsg:
@@ -229,13 +232,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
229232
return m, setVolumeCmd(m.player, m.volume)
230233

231234
case key.Matches(msg, m.keys.ThemeCycle):
232-
next, _ := theme.Lookup(theme.Next(m.theme.Name))
233-
m.theme = next
234-
m.styles = NewStyles(next)
235-
// Spinner color is baked at construction; refresh it so the
236-
// spinner stays in sync with the active theme's Muted tone.
237-
m.spinner.Style = lipgloss.NewStyle().Foreground(next.Muted)
238-
return m, nil
235+
return m.openThemePicker(), nil
239236

240237
case key.Matches(msg, m.keys.Mini):
241238
if m.mode == modeFull {
@@ -384,6 +381,55 @@ func (m Model) updateImportStations(msg tea.Msg) (tea.Model, tea.Cmd) {
384381
return m, nil
385382
}
386383

384+
func (m Model) openThemePicker() Model {
385+
m.modePrev = m.mode
386+
m.mode = modeThemePicker
387+
m.themeBeforePicker = m.theme.Name
388+
m.themeCursor = m.currentThemeIndex()
389+
return m
390+
}
391+
392+
func (m Model) updateThemePicker(msg tea.Msg) (tea.Model, tea.Cmd) {
393+
km, ok := msg.(tea.KeyMsg)
394+
if !ok {
395+
return m, nil
396+
}
397+
398+
names := theme.Names()
399+
if len(names) == 0 {
400+
m.mode = m.modePrev
401+
return m, nil
402+
}
403+
404+
move := func(delta int) {
405+
m.themeCursor = (m.themeCursor + delta + len(names)) % len(names)
406+
m = m.applyTheme(names[m.themeCursor])
407+
}
408+
409+
switch km.String() {
410+
case "up", "k":
411+
move(-1)
412+
return m, nil
413+
case "down", "j":
414+
move(1)
415+
return m, nil
416+
case "enter":
417+
m.mode = m.modePrev
418+
m.themeBeforePicker = ""
419+
return m, nil
420+
case "esc":
421+
if m.themeBeforePicker != "" {
422+
m = m.applyTheme(m.themeBeforePicker)
423+
}
424+
m.mode = m.modePrev
425+
m.themeBeforePicker = ""
426+
return m, nil
427+
case "q", "ctrl+c":
428+
return m, tea.Quit
429+
}
430+
return m, nil
431+
}
432+
387433
// updateConfirmDelete handles the delete-confirmation modal. y/Y/enter
388434
// commits, n/N/esc cancels. Anything else is ignored so the user can't
389435
// accidentally dismiss it by stray keys.
@@ -451,6 +497,25 @@ func (m Model) newStationsOnly(stations []config.Station) ([]config.Station, int
451497
return out, skipped
452498
}
453499

500+
func (m Model) currentThemeIndex() int {
501+
for i, name := range theme.Names() {
502+
if name == m.theme.Name {
503+
return i
504+
}
505+
}
506+
return 0
507+
}
508+
509+
func (m Model) applyTheme(name string) Model {
510+
next, _ := theme.Lookup(name)
511+
m.theme = next
512+
m.styles = NewStyles(next)
513+
// Spinner color is baked at construction; refresh it so the
514+
// spinner stays in sync with the active theme's Muted tone.
515+
m.spinner.Style = lipgloss.NewStyle().Foreground(next.Muted)
516+
return m
517+
}
518+
454519
// commitDelete removes the pending station from cfg.Stations,
455520
// rewrites the config, and adjusts cursor / playingIdx so the model
456521
// stays consistent (deleting the currently-playing station pauses

0 commit comments

Comments
 (0)