From ea464dedc2aa45101e23a55ffb0b7ba97b8994e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bjarne=20=C3=98verli?= Date: Tue, 18 Aug 2026 19:31:35 +0200 Subject: [PATCH] fix: make built-in themes accessible --- docs/themes.md | 36 +++++++++-------- ipc/protocol.go | 1 + site/index.html | 2 +- theme/accessibility_test.go | 62 +++++++++++++++++++++++++++++ theme/load_test.go | 1 + theme/theme.go | 12 ++++-- theme/theme_test.go | 10 +++++ theme/themes/ayu-mirage-dark.toml | 1 + theme/themes/catppuccin-latte.toml | 9 +++-- theme/themes/catppuccin.toml | 1 + theme/themes/dracula.toml | 3 +- theme/themes/ember.toml | 5 ++- theme/themes/ethereal.toml | 1 + theme/themes/everforest.toml | 3 +- theme/themes/flexoki-light.toml | 7 ++-- theme/themes/gruvbox.toml | 1 + theme/themes/hackerman.toml | 7 ++-- theme/themes/kanagawa.toml | 3 +- theme/themes/matte-black.toml | 7 ++-- theme/themes/miasma.toml | 11 ++--- theme/themes/neon-blade-runner.toml | 3 +- theme/themes/nord.toml | 5 ++- theme/themes/osaka-jade.toml | 5 ++- theme/themes/ristretto.toml | 3 +- theme/themes/rose-pine.toml | 9 +++-- theme/themes/tokyo-night.toml | 3 +- theme/themes/vantablack.toml | 9 +++-- theme/themes/winamp.toml | 1 + ui/model/audio.go | 6 +-- ui/model/inline_overlays.go | 8 ++-- ui/model/keys.go | 60 ++++++++++++++-------------- ui/model/keys_nav.go | 2 +- ui/model/overlays.go | 6 +-- ui/model/pl_picker.go | 10 ++--- ui/model/playback.go | 22 +++++----- ui/model/playback_test.go | 6 +++ ui/model/state.go | 8 +++- ui/model/status_ttl_policy_test.go | 43 ++++++++++++++++++++ ui/model/styles.go | 9 ++--- ui/model/update.go | 44 ++++++++++---------- ui/model/view.go | 13 +++++- ui/model/view_state_test.go | 40 +++++++++++++++++++ ui/styles.go | 55 ++++++++++++++++++++----- ui/styles_test.go | 23 +++++++++++ 44 files changed, 420 insertions(+), 156 deletions(-) create mode 100644 theme/accessibility_test.go create mode 100644 ui/styles_test.go diff --git a/docs/themes.md b/docs/themes.md index 9683093..de8d91a 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -1,6 +1,6 @@ # Themes -cliamp ships with 21 built-in color themes and supports custom themes via simple TOML files. +cliamp ships with 21 contrast-checked color themes and supports custom themes via simple TOML files. Press `t` during playback to open the theme picker. Navigate with `↑`/`↓`, preview live as you move, confirm with `Enter`, or cancel with `Esc`. @@ -18,13 +18,15 @@ Create a `.toml` file in `~/.config/cliamp/themes/`: mkdir -p ~/.config/cliamp/themes ``` -Each file needs all 6 colors as `#RRGGBB` hex values. Incomplete or malformed -custom themes are ignored, so they cannot silently make focus, warning, error, -or disabled states unreadable. The filename (minus `.toml`) becomes the theme name. +Each file needs all 6 foreground colors as `#RRGGBB` hex values. Add `bg` to set +a matching background; omit it to keep your terminal background. Incomplete or +malformed custom themes are ignored. The filename (minus `.toml`) becomes the +theme name. ### Example: `~/.config/cliamp/themes/solarized.toml` ```toml +bg = "#002b36" accent = "#268bd2" bright_fg = "#eee8d5" fg = "#839496" @@ -37,20 +39,22 @@ That's it. Press `t` and your theme appears in the list immediately. ### Color reference -| Key | What it colors | -|-------------|---------------------------------------------------| -| `accent` | Title, track name, seek bar, selected items | -| `bright_fg` | Primary text, time display, help key pill text | -| `fg` | Muted/secondary text, help bar, inactive elements, help key pill background | -| `green` | Playing indicator, volume bar, spectrum low | -| `yellow` | Spectrum middle | -| `red` | Spectrum top, error messages | +| Key | What it colors | +|-------------|---------------------------------------------| +| `bg` | Optional application background | +| `accent` | Title, track name, seek bar, selected items | +| `bright_fg` | Primary text and time display | +| `fg` | Muted text, help bar, inactive elements | +| `green` | Playing, success, volume, spectrum low | +| `yellow` | Warnings and spectrum middle | +| `red` | Errors and spectrum top | -All values are six-digit hex strings (for example, `"#ff5733"`). +All values are six-digit hex strings (for example, `"#ff5733"`). Help-key +pill text automatically switches between black and white for readable contrast. -Important UI states also use stable text markers such as `>`, `Q`, `★`, and `!`, -so selection, queued, bookmarked, and unavailable tracks remain distinguishable -in monochrome terminals. +Important UI states also use stable text markers such as `>`, `Q`, `★`, `!`, +`WARN:`, and `ERR:`, so state and feedback remain distinguishable in monochrome +terminals. ## Overriding a built-in theme diff --git a/ipc/protocol.go b/ipc/protocol.go index 59ba1bc..83107bc 100644 --- a/ipc/protocol.go +++ b/ipc/protocol.go @@ -70,6 +70,7 @@ type Response struct { // Empty hex fields mean the default (ANSI fallback) theme is active. type ThemeInfo struct { Name string `json:"name"` + BG string `json:"bg,omitempty"` Accent string `json:"accent,omitempty"` Fg string `json:"fg,omitempty"` BrightFg string `json:"bright_fg,omitempty"` diff --git a/site/index.html b/site/index.html index 5ce0995..9f1d7af 100644 --- a/site/index.html +++ b/site/index.html @@ -754,7 +754,7 @@ user_id = "your-account-user-id"
10-Band Equalizer

Parametric EQ presets plus a persistent Custom curve that survives preset changes and restarts.

-
Themes & Visualizers

21 built-in themes and spectrum, waveform, particle, and true-stereo modes. Stereo provides dedicated L/R horizontal LED peak meters. Hot-swap with t / v.

+
Themes & Visualizers

21 contrast-checked built-in themes and spectrum, waveform, particle, and true-stereo modes. Stereo provides dedicated L/R horizontal LED peak meters. Hot-swap with t / v.

Playlists

TOML playlists with dynamic directory sources (--dir), M3U/M3U8/PLS import/export, duplicate-safe writes, and TUI/CLI sort tools.

Recently Played

Auto-recorded listening history. Browse it as a virtual playlist or run cliamp history from the shell.

HTTP Streaming

Play from URLs, internet radio, remote M3U playlists, and HLS (.m3u8) live streams via ffmpeg.

diff --git a/theme/accessibility_test.go b/theme/accessibility_test.go new file mode 100644 index 0000000..ca32634 --- /dev/null +++ b/theme/accessibility_test.go @@ -0,0 +1,62 @@ +package theme + +import ( + "math" + "strconv" + "strings" + "testing" +) + +func TestBuiltinThemesMeetTextContrast(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + themes := LoadAll() + if len(themes) != 21 { + t.Fatalf("LoadAll() returned %d built-in themes, want 21", len(themes)) + } + + for _, th := range themes { + if th.BG == "" { + t.Errorf("built-in theme %q has no background", th.Name) + continue + } + for _, role := range []struct { + name string + color string + }{ + {"accent", th.Accent}, + {"bright_fg", th.BrightFG}, + {"fg", th.FG}, + {"green", th.Green}, + {"yellow", th.Yellow}, + {"red", th.Red}, + } { + if ratio := contrastRatio(role.color, th.BG); ratio < 4.5 { + t.Errorf("theme %q %s contrast = %.2f:1, want at least 4.5:1", th.Name, role.name, ratio) + } + } + } +} + +func contrastRatio(a, b string) float64 { + lighter := relativeLuminance(a) + darker := relativeLuminance(b) + if lighter < darker { + lighter, darker = darker, lighter + } + return (lighter + 0.05) / (darker + 0.05) +} + +func relativeLuminance(hex string) float64 { + value, err := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 24) + if err != nil { + return 0 + } + linear := func(channel uint64) float64 { + component := float64(channel) / 255 + if component <= 0.04045 { + return component / 12.92 + } + return math.Pow((component+0.055)/1.055, 2.4) + } + return 0.2126*linear(value>>16) + 0.7152*linear((value>>8)&0xff) + 0.0722*linear(value&0xff) +} diff --git a/theme/load_test.go b/theme/load_test.go index d2ec530..32b01df 100644 --- a/theme/load_test.go +++ b/theme/load_test.go @@ -36,6 +36,7 @@ func TestLoadAllIncludesWinampPalette(t *testing.T) { t.Setenv("HOME", t.TempDir()) want := Theme{ Name: "winamp", + BG: "#000000", Accent: "#00FF00", BrightFG: "#FFFFFF", FG: "#969696", diff --git a/theme/theme.go b/theme/theme.go index 4643138..bf05d98 100644 --- a/theme/theme.go +++ b/theme/theme.go @@ -25,6 +25,7 @@ const DefaultName = "Default - Terminal colors" // Theme holds a named color scheme with hex color values. type Theme struct { Name string + BG string Accent string // hex BrightFG string FG string @@ -37,11 +38,11 @@ var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) // IsDefault returns true if this is the sentinel default theme (no hex values). func (t Theme) IsDefault() bool { - return t.Accent == "" && t.Green == "" && t.BrightFG == "" + return t.BG == "" && t.Accent == "" && t.Green == "" && t.BrightFG == "" } -// Validate ensures a custom theme supplies the complete six-color palette in -// CSS hex notation. The terminal-default sentinel intentionally has no colors. +// Validate ensures a custom theme supplies the complete six-color foreground +// palette in CSS hex notation. Background is optional for custom themes. func (t Theme) Validate() error { if t.IsDefault() { return nil @@ -64,6 +65,9 @@ func (t Theme) Validate() error { return fmt.Errorf("theme %q: %s must be #RRGGBB", t.Name, color.name) } } + if t.BG != "" && !hexColor.MatchString(t.BG) { + return fmt.Errorf("theme %q: bg must be #RRGGBB", t.Name) + } return nil } @@ -92,6 +96,8 @@ func Parse(name string, r io.Reader) (Theme, error) { val = strings.Trim(val, `"'`) switch key { + case "bg": + t.BG = val case "accent": t.Accent = val case "bright_fg": diff --git a/theme/theme_test.go b/theme/theme_test.go index 5e6a5c4..3de2274 100644 --- a/theme/theme_test.go +++ b/theme/theme_test.go @@ -23,6 +23,7 @@ func TestIsDefault(t *testing.T) { }{ {"empty hex values", Theme{Name: "Default"}, true}, {"has accent", Theme{Name: "Custom", Accent: "#ff0000"}, false}, + {"has background", Theme{Name: "Custom", BG: "#000000"}, false}, {"has green", Theme{Name: "Custom", Green: "#00ff00"}, false}, {"has bright fg", Theme{Name: "Custom", BrightFG: "#ffffff"}, false}, } @@ -37,6 +38,7 @@ func TestIsDefault(t *testing.T) { func TestParse(t *testing.T) { input := `# Solarized Dark theme +bg = "#002b36" accent = "#268bd2" bright_fg = "#93a1a1" fg = "#839496" @@ -52,6 +54,9 @@ red = "#dc322f" if th.Name != "solarized-dark" { t.Errorf("Name = %q, want solarized-dark", th.Name) } + if th.BG != "#002b36" { + t.Errorf("BG = %q, want #002b36", th.BG) + } if th.Accent != "#268bd2" { t.Errorf("Accent = %q, want #268bd2", th.Accent) } @@ -144,4 +149,9 @@ func TestThemeValidate(t *testing.T) { if err := valid.Validate(); err == nil { t.Fatal("Validate() accepted invalid color") } + valid.Red = "#667788" + valid.BG = "black" + if err := valid.Validate(); err == nil { + t.Fatal("Validate() accepted invalid background color") + } } diff --git a/theme/themes/ayu-mirage-dark.toml b/theme/themes/ayu-mirage-dark.toml index dbf1639..c3d5f1a 100644 --- a/theme/themes/ayu-mirage-dark.toml +++ b/theme/themes/ayu-mirage-dark.toml @@ -1,3 +1,4 @@ +bg = "#1f2430" accent = "#73d0ff" bright_fg = "#f3f4f5" fg = "#cccac2" diff --git a/theme/themes/catppuccin-latte.toml b/theme/themes/catppuccin-latte.toml index ae7951d..3acaa9f 100644 --- a/theme/themes/catppuccin-latte.toml +++ b/theme/themes/catppuccin-latte.toml @@ -1,6 +1,7 @@ -accent = "#1e66f5" +bg = "#eff1f5" +accent = "#1a5cc8" bright_fg = "#4c4f69" -fg = "#8c8fa1" -green = "#40a02b" -yellow = "#df8e1d" +fg = "#686b80" +green = "#287a16" +yellow = "#956400" red = "#d20f39" diff --git a/theme/themes/catppuccin.toml b/theme/themes/catppuccin.toml index 1c65678..15bd967 100644 --- a/theme/themes/catppuccin.toml +++ b/theme/themes/catppuccin.toml @@ -1,3 +1,4 @@ +bg = "#1e1e2e" accent = "#89b4fa" bright_fg = "#cdd6f4" fg = "#9399b2" diff --git a/theme/themes/dracula.toml b/theme/themes/dracula.toml index 1d2ad9c..1d5e98b 100644 --- a/theme/themes/dracula.toml +++ b/theme/themes/dracula.toml @@ -1,8 +1,9 @@ # Dracula — gothic purple with vivid spectrum. # The most popular terminal theme. draculatheme.com +bg = "#282a36" accent = "#bd93f9" bright_fg = "#f8f8f2" -fg = "#6272a4" +fg = "#8b9ac4" green = "#50fa7b" yellow = "#f1fa8c" red = "#ff5555" diff --git a/theme/themes/ember.toml b/theme/themes/ember.toml index 4949579..ea19bb6 100644 --- a/theme/themes/ember.toml +++ b/theme/themes/ember.toml @@ -1,8 +1,9 @@ # Ember — deep warm hearth. # Campfire coals, darkroom safelight, analog warmth. +bg = "#121212" accent = "#e07040" bright_fg = "#e8d0b8" fg = "#907868" -green = "#a08858" +green = "#9aaa68" yellow = "#d8a050" -red = "#c04848" +red = "#d15a5a" diff --git a/theme/themes/ethereal.toml b/theme/themes/ethereal.toml index 0e49354..ede94f4 100644 --- a/theme/themes/ethereal.toml +++ b/theme/themes/ethereal.toml @@ -1,3 +1,4 @@ +bg = "#060b1e" accent = "#7d82d9" bright_fg = "#ffcead" fg = "#9a96a8" diff --git a/theme/themes/everforest.toml b/theme/themes/everforest.toml index 5a237ae..e18299e 100644 --- a/theme/themes/everforest.toml +++ b/theme/themes/everforest.toml @@ -1,6 +1,7 @@ +bg = "#2d353b" accent = "#7fbbb3" bright_fg = "#d3c6aa" -fg = "#7a8478" +fg = "#9aa59a" green = "#a7c080" yellow = "#dbbc7f" red = "#e67e80" diff --git a/theme/themes/flexoki-light.toml b/theme/themes/flexoki-light.toml index c31a302..1446e4a 100644 --- a/theme/themes/flexoki-light.toml +++ b/theme/themes/flexoki-light.toml @@ -1,6 +1,7 @@ +bg = "#FFFCF0" accent = "#205EA6" bright_fg = "#100F0F" fg = "#6F6E69" -green = "#879A39" -yellow = "#D0A215" -red = "#D14D41" +green = "#617A0A" +yellow = "#8B6A00" +red = "#AF3029" diff --git a/theme/themes/gruvbox.toml b/theme/themes/gruvbox.toml index 3599941..f9f083a 100644 --- a/theme/themes/gruvbox.toml +++ b/theme/themes/gruvbox.toml @@ -1,3 +1,4 @@ +bg = "#282828" accent = "#7daea3" bright_fg = "#d4be98" fg = "#a89984" diff --git a/theme/themes/hackerman.toml b/theme/themes/hackerman.toml index c15988a..d9aacad 100644 --- a/theme/themes/hackerman.toml +++ b/theme/themes/hackerman.toml @@ -1,6 +1,7 @@ +bg = "#0b0c16" accent = "#82FB9C" bright_fg = "#ddf7ff" fg = "#8e95b8" -green = "#4fe88f" -yellow = "#50f7d4" -red = "#50f872" +green = "#2ec27e" +yellow = "#f7df50" +red = "#ff5f78" diff --git a/theme/themes/kanagawa.toml b/theme/themes/kanagawa.toml index 6fa8256..c71a381 100644 --- a/theme/themes/kanagawa.toml +++ b/theme/themes/kanagawa.toml @@ -1,6 +1,7 @@ +bg = "#1f1f28" accent = "#7e9cd8" bright_fg = "#dcd7ba" fg = "#938aa9" green = "#76946a" yellow = "#c0a36e" -red = "#c34043" +red = "#e46876" diff --git a/theme/themes/matte-black.toml b/theme/themes/matte-black.toml index c5ea425..173ef1c 100644 --- a/theme/themes/matte-black.toml +++ b/theme/themes/matte-black.toml @@ -1,6 +1,7 @@ +bg = "#121212" accent = "#e68e0d" bright_fg = "#bebebe" -fg = "#777777" -green = "#FFC107" -yellow = "#b91c1c" +fg = "#838383" +green = "#8FAF5F" +yellow = "#FFC107" red = "#D35F5F" diff --git a/theme/themes/miasma.toml b/theme/themes/miasma.toml index 268c5a9..1018d43 100644 --- a/theme/themes/miasma.toml +++ b/theme/themes/miasma.toml @@ -1,6 +1,7 @@ -accent = "#78824b" +bg = "#222222" +accent = "#a5af6b" bright_fg = "#c2c2b0" -fg = "#666666" -green = "#5f875f" -yellow = "#b36d43" -red = "#685742" +fg = "#929292" +green = "#87af87" +yellow = "#c9a554" +red = "#c87575" diff --git a/theme/themes/neon-blade-runner.toml b/theme/themes/neon-blade-runner.toml index 96e163c..25b05ee 100644 --- a/theme/themes/neon-blade-runner.toml +++ b/theme/themes/neon-blade-runner.toml @@ -1,9 +1,10 @@ # Blade Runner 1982 — Neon noir. # Hot pink neon signs, cyan rain reflections, amber instrument readouts. # Cronenweth's Los Angeles: perpetual night, wet streets, practical light. +bg = "#121212" accent = "#e8609a" bright_fg = "#b8c4d0" fg = "#758494" green = "#4eb8a8" yellow = "#d4a040" -red = "#c85070" +red = "#d65f7e" diff --git a/theme/themes/nord.toml b/theme/themes/nord.toml index 093107c..de489a0 100644 --- a/theme/themes/nord.toml +++ b/theme/themes/nord.toml @@ -1,6 +1,7 @@ +bg = "#2e3440" accent = "#81a1c1" bright_fg = "#d8dee9" -fg = "#8690a0" +fg = "#9da7b8" green = "#a3be8c" yellow = "#ebcb8b" -red = "#bf616a" +red = "#e08a92" diff --git a/theme/themes/osaka-jade.toml b/theme/themes/osaka-jade.toml index f0877a8..e443cbb 100644 --- a/theme/themes/osaka-jade.toml +++ b/theme/themes/osaka-jade.toml @@ -1,6 +1,7 @@ +bg = "#111c18" accent = "#509475" bright_fg = "#F7E8B2" fg = "#C1C497" -green = "#549e6a" -yellow = "#459451" +green = "#86c994" +yellow = "#e5c736" red = "#FF5345" diff --git a/theme/themes/ristretto.toml b/theme/themes/ristretto.toml index ce20595..86bb28a 100644 --- a/theme/themes/ristretto.toml +++ b/theme/themes/ristretto.toml @@ -1,6 +1,7 @@ +bg = "#2c2525" accent = "#f38d70" bright_fg = "#e6d9db" -fg = "#948a8b" +fg = "#9a9091" green = "#adda78" yellow = "#f9cc6c" red = "#fd6883" diff --git a/theme/themes/rose-pine.toml b/theme/themes/rose-pine.toml index c8f2636..22ad8fd 100644 --- a/theme/themes/rose-pine.toml +++ b/theme/themes/rose-pine.toml @@ -1,6 +1,7 @@ -accent = "#56949f" +bg = "#faf4ed" +accent = "#3e7380" bright_fg = "#575279" -fg = "#908caa" +fg = "#6e6a86" green = "#286983" -yellow = "#ea9d34" -red = "#b4637a" +yellow = "#966400" +red = "#9c4f66" diff --git a/theme/themes/tokyo-night.toml b/theme/themes/tokyo-night.toml index 23765bf..8a77e9c 100644 --- a/theme/themes/tokyo-night.toml +++ b/theme/themes/tokyo-night.toml @@ -1,6 +1,7 @@ +bg = "#1a1b26" accent = "#7aa2f7" bright_fg = "#cfc9c2" -fg = "#737aa2" +fg = "#848cb8" green = "#9ece6a" yellow = "#e0af68" red = "#f7768e" diff --git a/theme/themes/vantablack.toml b/theme/themes/vantablack.toml index f8bb9b3..9851dd0 100644 --- a/theme/themes/vantablack.toml +++ b/theme/themes/vantablack.toml @@ -1,6 +1,7 @@ -accent = "#d0d0d0" +bg = "#000000" +accent = "#ececec" bright_fg = "#ffffff" fg = "#8d8d8d" -green = "#b6b6b6" -yellow = "#cecece" -red = "#a4a4a4" +green = "#a4a4a4" +yellow = "#b9b9b9" +red = "#cecece" diff --git a/theme/themes/winamp.toml b/theme/themes/winamp.toml index 49e11e9..abb7163 100644 --- a/theme/themes/winamp.toml +++ b/theme/themes/winamp.toml @@ -1,5 +1,6 @@ # Winamp 2.91 base skin. Text colors come from PLEDIT.TXT and the # spectrum colors from VISCOLOR.TXT. +bg = "#000000" accent = "#00FF00" bright_fg = "#FFFFFF" fg = "#969696" diff --git a/ui/model/audio.go b/ui/model/audio.go index 760c6e6..71b3775 100644 --- a/ui/model/audio.go +++ b/ui/model/audio.go @@ -93,7 +93,7 @@ func (m *Model) cycleEQPreset() { func (m *Model) saveEQ() { name := m.EQPresetName() if err := m.configSaver.Save("eq_preset", fmt.Sprintf("%q", name)); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } bands := m.eqCustomBands parts := make([]string, len(bands)) @@ -102,7 +102,7 @@ func (m *Model) saveEQ() { } eqVal := "[" + strings.Join(parts, ", ") + "]" if err := m.configSaver.Save("eq", eqVal); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } } @@ -110,7 +110,7 @@ func (m *Model) saveEQ() { func (m *Model) saveSpeed() { speed := m.player.Speed() if err := m.configSaver.Save("speed", fmt.Sprintf("%.2f", speed)); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } } diff --git a/ui/model/inline_overlays.go b/ui/model/inline_overlays.go index cd3ebb0..c7c796a 100644 --- a/ui/model/inline_overlays.go +++ b/ui/model/inline_overlays.go @@ -323,7 +323,7 @@ func (m Model) renderJumpBody() string { budget := m.effectivePlaylistVisible() pos := m.player.Position() dur := m.player.Duration() - inputLine := dimStyle.Faint(true).Render(" " + formatJumpPlaceholder(dur)) + inputLine := dimStyle.Render(" " + formatJumpPlaceholder(dur)) if m.jumpInput != "" { inputLine = playlistSelectedStyle.Render(" " + m.textWithCursor("jump", m.jumpInput)) } @@ -358,7 +358,7 @@ func (m Model) renderLyricsBody() string { if errors.Is(m.lyrics.err, lyrics.ErrNotFound) { lines = append(lines, dimStyle.Render(" No lyrics found for this track.")) } else { - lines = append(lines, helpStyle.Render(" Lyrics fetch failed: "+m.lyrics.err.Error())) + lines = append(lines, errorStyle.Render(" Lyrics fetch failed: "+m.lyrics.err.Error())) } case len(m.lyrics.lines) == 0: artist, title := m.lyricsArtistTitle() @@ -444,7 +444,7 @@ func (m Model) renderNetSearchBody() string { lines = append(lines, dimStyle.Render(" Type a query and press Enter to search "+m.netSearchSource()+".")) } if m.netSearch.err != "" { - lines = append(lines, "", helpStyle.Render(" "+m.netSearch.err)) + lines = append(lines, "", errorStyle.Render(" "+m.netSearch.err)) } return bodyLines(lines, budget) } @@ -531,7 +531,7 @@ func (m Model) renderSpotSearchBody() string { body = bodyLines(lines, budget) } if m.spotSearch.err != "" && m.spotSearch.screen != spotSearchPlaylist { - return strings.Join([]string{body, helpStyle.Render(" " + m.spotSearch.err)}, "\n") + return strings.Join([]string{body, errorStyle.Render(" " + m.spotSearch.err)}, "\n") } return body } diff --git a/ui/model/keys.go b/ui/model/keys.go index 3cd8a50..614958a 100644 --- a/ui/model/keys.go +++ b/ui/model/keys.go @@ -367,7 +367,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { } m.provLoading = true m.activeProviderPlaylistID = "" - m.status.Showf(statusTTLShort, "Refreshing %s…", m.provider.Name()) + m.status.Activityf(statusTTLShort, "Refreshing %s…", m.provider.Name()) return m.fetchProviderPlaylists() } case "f": @@ -531,7 +531,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { tracks := m.playlist.Tracks() track := tracks[m.plCursor] if err := bs.SetBookmarkByPath(m.loadedPlaylist, track.Path); err != nil { - m.status.Showf(statusTTLDefault, "Save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Save failed: %s", err) return nil } m.playlist.ToggleBookmark(m.plCursor) @@ -640,7 +640,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "r": m.playlist.CycleRepeat() if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", m.playlist.Repeat().String())); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } m.player.ClearPreload() return m.preloadNext() @@ -648,7 +648,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { case "z": m.playlist.ToggleShuffle() if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", m.playlist.Shuffled())); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } m.player.ClearPreload() return m.preloadNext() @@ -782,7 +782,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd { m.applyHeightMode() m.adjustScroll() if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } case "ctrl+v": @@ -885,19 +885,19 @@ func (m *Model) handleFullVisualizerKey(msg tea.KeyPressMsg) tea.Cmd { func (m *Model) saveTrack() tea.Cmd { track, idx := m.currentPlaybackTrack() if idx < 0 { - m.status.Show("Nothing to save", statusTTLShort) + m.status.Warning("Nothing to save", statusTTLShort) return nil } home, err := os.UserHomeDir() if err != nil { - m.status.Showf(statusTTLShort, "Save failed: %s", err) + m.status.Errorf(statusTTLShort, "Save failed: %s", err) return nil } saveDir := filepath.Join(home, "Music", "cliamp") if err := os.MkdirAll(saveDir, 0o755); err != nil { - m.status.Showf(statusTTLShort, "Save failed: %s", err) + m.status.Errorf(statusTTLShort, "Save failed: %s", err) return nil } @@ -910,7 +910,7 @@ func (m *Model) saveTrack() tea.Cmd { // Only save local temp files (yt-dlp downloads), not streams or user's own files. if track.Stream || !strings.HasPrefix(track.Path, os.TempDir()) { - m.status.Show("Only downloaded tracks can be saved", statusTTLShort) + m.status.Warning("Only downloaded tracks can be saved", statusTTLShort) return nil } @@ -930,7 +930,7 @@ func (m *Model) saveTrack() tea.Cmd { dest := filepath.Join(saveDir, name+ext) if err := fileutil.CopyFile(track.Path, dest); err != nil { - m.status.Showf(statusTTLShort, "Save failed: %s", err) + m.status.Errorf(statusTTLShort, "Save failed: %s", err) return nil } @@ -1586,7 +1586,7 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyPressMsg) tea.Cmd { } if d, ok := m.localProvider.(provider.PlaylistDeleter); ok { if err := d.DeletePlaylist(name); err != nil { - m.status.Showf(statusTTLDefault, "Delete failed: %s", err) + m.status.Errorf(statusTTLDefault, "Delete failed: %s", err) } else { m.status.Showf(statusTTLDefault, "Deleted %q (u to undo)", name) } @@ -1672,7 +1672,7 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyPressMsg) tea.Cmd { case "w": tracks := m.playlist.Tracks() if len(tracks) == 0 { - m.status.Show("Queue is empty", statusTTLShort) + m.status.Warning("Queue is empty", statusTTLShort) return nil } m.openPlaylistPicker(tracks, fmt.Sprintf("Save %d queued tracks", len(tracks))) @@ -1683,7 +1683,7 @@ func (m *Model) handlePlMgrListKey(msg tea.KeyPressMsg) tea.Cmd { } name := m.plManager.playlists[realIdx].Name if name == history.PlaylistName { - m.status.Show("Recently Played cannot be renamed", statusTTLDefault) + m.status.Warning("Recently Played cannot be renamed", statusTTLDefault) return nil } m.plManager.renameOldName = name @@ -2019,16 +2019,16 @@ func (m *Model) plMgrSetTrackUndo() { func (m *Model) plMgrUndoLast() { undo := m.plManager.undo if undo.kind == plUndoNone || undo.name == "" { - m.status.Show("Nothing to undo", statusTTLShort) + m.status.Warning("Nothing to undo", statusTTLShort) return } saver := m.localSaver() if saver == nil { - m.status.Show("Undo unavailable", statusTTLDefault) + m.status.Warning("Undo unavailable", statusTTLDefault) return } if err := saver.SavePlaylist(undo.name, cloneTracks(undo.tracks)); err != nil { - m.status.Showf(statusTTLDefault, "Undo failed: %s", err) + m.status.Errorf(statusTTLDefault, "Undo failed: %s", err) return } m.plManager.undo = plManagerUndo{} @@ -2115,11 +2115,11 @@ func (m *Model) plMgrToggleMarkAll() { func (m *Model) plMgrSaveTracks(status string) bool { saver := m.localSaver() if saver == nil { - m.status.Show("Playlist saving is not supported", statusTTLDefault) + m.status.Warning("Playlist saving is not supported", statusTTLDefault) return false } if err := saver.SavePlaylist(m.plManager.selPlaylist, m.plManager.tracks); err != nil { - m.status.Showf(statusTTLDefault, "Save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Save failed: %s", err) return false } if status != "" { @@ -2135,7 +2135,7 @@ func (m *Model) plMgrRemoveSelectedTracks() { } for _, i := range indices { if m.plManager.tracks[i].DirSourced { - m.status.Showf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", m.plManager.tracks[i].DisplayName()) + m.status.Warningf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", m.plManager.tracks[i].DisplayName()) return } } @@ -2164,7 +2164,7 @@ func (m *Model) plMgrRemoveSelectedTracks() { func (m *Model) plMgrMoveTrack(delta int) { if m.plManager.filter != "" { - m.status.Show("Clear filter before moving tracks", statusTTLDefault) + m.status.Warning("Clear filter before moving tracks", statusTTLDefault) return } from := m.plManager.cursor @@ -2249,11 +2249,11 @@ func (m *Model) persistLoadedPlaylistOrder() { } } if err := saver.SavePlaylist(m.loadedPlaylist, m.playlist.Tracks()); err != nil { - m.status.Showf(statusTTLDefault, "Save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Save failed: %s", err) return } if hasDirTracks { - m.status.Showf(statusTTLDefault, "Reordered %q (directory-sourced tracks keep scan order)", m.loadedPlaylist) + m.status.Warningf(statusTTLDefault, "Reordered %q (directory-sourced tracks keep scan order)", m.loadedPlaylist) return } m.status.Showf(statusTTLDefault, "Reordered %q", m.loadedPlaylist) @@ -2263,28 +2263,28 @@ func (m *Model) persistLoadedPlaylistOrder() { func (m *Model) addToPlaylist(name string) { track, idx := m.currentPlaybackTrack() if idx < 0 { - m.status.Show("No track to add", statusTTLShort) + m.status.Warning("No track to add", statusTTLShort) return } if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok { added, skipped, err := bw.AddTracksToPlaylist(context.Background(), name, []playlist.Track{track}) if err != nil { - m.status.Showf(statusTTLDefault, "Failed: %s", err) + m.status.Errorf(statusTTLDefault, "Failed: %s", err) return } switch { case added > 0: m.status.Showf(statusTTLDefault, "Added to %q", name) case skipped > 0: - m.status.Showf(statusTTLDefault, "Already in %q", name) + m.status.Warningf(statusTTLDefault, "Already in %q", name) default: - m.status.Showf(statusTTLDefault, "Nothing added to %q", name) + m.status.Warningf(statusTTLDefault, "Nothing added to %q", name) } return } if w, ok := m.localProvider.(provider.PlaylistWriter); ok { if err := w.AddTrackToPlaylist(context.Background(), name, track); err != nil { - m.status.Showf(statusTTLDefault, "Failed: %s", err) + m.status.Errorf(statusTTLDefault, "Failed: %s", err) } else { m.status.Showf(statusTTLDefault, "Added to %q", name) } @@ -2310,19 +2310,19 @@ func (m *Model) createPlaylistFromManager(name string) bool { if bw, ok := m.localProvider.(provider.PlaylistBatchWriter); ok { added, skipped, err := bw.AddTracksToPlaylist(context.Background(), id, []playlist.Track{track}) if err != nil { - m.status.Showf(statusTTLDefault, "Created %q, add failed: %s", name, err) + m.status.Errorf(statusTTLDefault, "Created %q, add failed: %s", name, err) return true } if added > 0 { m.status.Showf(statusTTLDefault, "Created %q & added track", name) } else if skipped > 0 { - m.status.Showf(statusTTLDefault, "Created %q; track was duplicate", name) + m.status.Warningf(statusTTLDefault, "Created %q; track was duplicate", name) } return true } if w, ok := m.localProvider.(provider.PlaylistWriter); ok { if err := w.AddTrackToPlaylist(context.Background(), id, track); err != nil { - m.status.Showf(statusTTLDefault, "Created %q, add failed: %s", name, err) + m.status.Errorf(statusTTLDefault, "Created %q, add failed: %s", name, err) return true } m.status.Showf(statusTTLDefault, "Created %q & added track", name) diff --git a/ui/model/keys_nav.go b/ui/model/keys_nav.go index 36af104..e63434b 100644 --- a/ui/model/keys_nav.go +++ b/ui/model/keys_nav.go @@ -298,7 +298,7 @@ func (m *Model) handleNavAlbumListKey(msg tea.KeyPressMsg, artistAlbums bool) te m.navClearSearch() if saver, ok := m.navBrowser.prov.(provider.AlbumSortSaver); ok { if err := saver.SaveAlbumSort(m.navBrowser.sortType); err != nil { - m.status.Showf(statusTTLDefault, "Sort save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Sort save failed: %s", err) } } return fetchNavAlbumListCmd(ab, m.navBrowser.sortType, 0, m.nextNavRequest()) diff --git a/ui/model/overlays.go b/ui/model/overlays.go index da26a5b..4063472 100644 --- a/ui/model/overlays.go +++ b/ui/model/overlays.go @@ -192,7 +192,7 @@ func (m *Model) visPickerSelect() { return } if err := m.configSaver.Save("visualizer", fmt.Sprintf("%q", m.vis.ModeName())); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } m.visPickerClose() } @@ -370,7 +370,7 @@ func (m *Model) openPlaylistManager() { func (m *Model) plMgrEnterTrackList(name string) { tracks, err := m.localProvider.Tracks(name) if err != nil { - m.status.Showf(statusTTLDefault, "Load failed: %s", err) + m.status.Errorf(statusTTLDefault, "Load failed: %s", err) return } m.plManager.selPlaylist = name @@ -463,7 +463,7 @@ func (m *Model) plMgrRefreshList() { } playlists, err := m.localProvider.Playlists() if err != nil { - m.status.Showf(statusTTLDefault, "Load failed: %s", err) + m.status.Errorf(statusTTLDefault, "Load failed: %s", err) } m.plManager.playlists = playlists if m.plManager.filter != "" { diff --git a/ui/model/pl_picker.go b/ui/model/pl_picker.go index a651835..5c20cee 100644 --- a/ui/model/pl_picker.go +++ b/ui/model/pl_picker.go @@ -15,12 +15,12 @@ import ( func (m *Model) openPlaylistPicker(tracks []playlist.Track, title string) { if m.localProvider == nil { - m.status.Show("Local playlists are unavailable", statusTTLDefault) + m.status.Warning("Local playlists are unavailable", statusTTLDefault) return } lists, err := m.localProvider.Playlists() if err != nil { - m.status.Showf(statusTTLDefault, "Playlist list failed: %s", err) + m.status.Errorf(statusTTLDefault, "Playlist list failed: %s", err) return } playlists := make([]playlist.PlaylistInfo, 0, len(lists)) @@ -231,13 +231,13 @@ func (m *Model) writePickerTracks(name string) bool { } switch { case added > 0 && skipped > 0: - m.status.Showf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, name, skipped) + m.status.Warningf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, name, skipped) case added > 0: m.status.Showf(statusTTLDefault, "Added %d to %q", added, name) case skipped > 0: - m.status.Showf(statusTTLDefault, "Skipped %d duplicates in %q", skipped, name) + m.status.Warningf(statusTTLDefault, "Skipped %d duplicates in %q", skipped, name) default: - m.status.Showf(statusTTLDefault, "Nothing added to %q", name) + m.status.Warningf(statusTTLDefault, "Nothing added to %q", name) } m.refreshPlaylistManagerAfterWrite(name) return true diff --git a/ui/model/playback.go b/ui/model/playback.go index 2999f43..1285962 100644 --- a/ui/model/playback.go +++ b/ui/model/playback.go @@ -85,11 +85,11 @@ func (m *Model) playCurrentTrack() tea.Cmd { if !ok { m.player.Stop() m.clearPlaybackTrack() - m.status.Show("No available tracks", statusTTLDefault) + m.status.Warning("No available tracks", statusTTLDefault) return nil } if activation.Skipped { - m.status.Show("Track unavailable, skipping...", statusTTLDefault) + m.status.Warning("Track unavailable, skipping...", statusTTLDefault) } m.plCursor = activation.Index m.adjustScroll() @@ -192,7 +192,7 @@ func (m *Model) removeSelectedFromPlaylist() { snapshot := m.playlist.Snapshot() track := m.playlist.Tracks()[idx] if track.DirSourced { - m.status.Showf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", track.DisplayName()) + m.status.Warningf(statusTTLDefault, "Can't remove %q: it's supplied by the playlist's directory source", track.DisplayName()) return } loaded := m.loadedPlaylist @@ -203,7 +203,7 @@ func (m *Model) removeSelectedFromPlaylist() { var err error saved, err = m.localProvider.Tracks(loaded) if err != nil { - m.status.Showf(statusTTLDefault, "Remove failed: %s", err) + m.status.Errorf(statusTTLDefault, "Remove failed: %s", err) return } // saved rescans directory sources, so a new file could have shifted @@ -217,13 +217,13 @@ func (m *Model) removeSelectedFromPlaylist() { } } if savedIdx < 0 { - m.status.Showf(statusTTLDefault, "Remove failed: selected track is no longer in %q", loaded) + m.status.Errorf(statusTTLDefault, "Remove failed: selected track is no longer in %q", loaded) return } original := cloneTracks(saved) saved = append(saved[:savedIdx:savedIdx], saved[savedIdx+1:]...) if err := saver.SavePlaylist(loaded, saved); err != nil { - m.status.Showf(statusTTLDefault, "Remove failed: %s", err) + m.status.Errorf(statusTTLDefault, "Remove failed: %s", err) return } saved = original @@ -257,17 +257,17 @@ func (m *Model) removeSelectedFromPlaylist() { func (m *Model) undoPlaylistMutation() { undo := m.playlistUndo if !undo.active { - m.status.Show("Nothing to undo", statusTTLShort) + m.status.Warning("Nothing to undo", statusTTLShort) return } if undo.persisted { saver := m.localSaver() if saver == nil { - m.status.Show("Undo unavailable", statusTTLDefault) + m.status.Warning("Undo unavailable", statusTTLDefault) return } if err := saver.SavePlaylist(undo.loaded, cloneTracks(undo.saved)); err != nil { - m.status.Showf(statusTTLDefault, "Undo failed: %s", err) + m.status.Errorf(statusTTLDefault, "Undo failed: %s", err) return } } @@ -286,7 +286,7 @@ func (m *Model) playTrack(track playlist.Track) tea.Cmd { m.pausedAt = time.Time{} if track.Feed || playlist.IsFeed(track.Path) { m.feedLoading = true - m.status.Show("Loading feed...", statusTTLLong) + m.status.Activity("Loading feed...", statusTTLLong) return resolveFeedTrackCmd(track.Path) } track, fetchCmd := m.beginPlaybackTrack(track) @@ -457,7 +457,7 @@ func (m *Model) reconnectYTDLOnUnpause() tea.Cmd { m.seek.grace = 0 m.seek.graceFor = 0 m.player.CancelSeekYTDL() - m.status.Show("Reconnecting stream...", statusTTLMedium) + m.status.Activity("Reconnecting stream...", statusTTLMedium) p := m.player return func() tea.Msg { diff --git a/ui/model/playback_test.go b/ui/model/playback_test.go index c1b20bc..3495a98 100644 --- a/ui/model/playback_test.go +++ b/ui/model/playback_test.go @@ -225,6 +225,9 @@ func TestPlayCurrentTrackUnplayableUsesSelectionOrder(t *testing.T) { if m.status.text != "Track unavailable, skipping..." { t.Fatalf("status.text = %q, want %q", m.status.text, "Track unavailable, skipping...") } + if m.status.kind != feedbackWarning { + t.Fatalf("status.kind = %v, want %v", m.status.kind, feedbackWarning) + } if p.QueueLen() != 1 { t.Fatalf("QueueLen() = %d, want 1", p.QueueLen()) } @@ -260,6 +263,9 @@ func TestPlayCurrentTrackUnplayableStopsWhenNoReplacementExists(t *testing.T) { if m.status.text != "No available tracks" { t.Fatalf("status.text = %q, want %q", m.status.text, "No available tracks") } + if m.status.kind != feedbackWarning { + t.Fatalf("status.kind = %v, want %v", m.status.kind, feedbackWarning) + } } func modelAfterProviderPlaylistLoadWhilePlaying(t *testing.T) (Model, *playbackFakeEngine) { diff --git a/ui/model/state.go b/ui/model/state.go index ab85837..af53026 100644 --- a/ui/model/state.go +++ b/ui/model/state.go @@ -388,6 +388,10 @@ func (s *statusMsg) Warningf(ttl statusTTL, format string, args ...any) { s.Warning(fmt.Sprintf(format, args...), ttl) } +func (s *statusMsg) Errorf(ttl statusTTL, format string, args ...any) { + s.Error(fmt.Sprintf(format, args...), ttl) +} + func (s *statusMsg) Activity(text string, ttl statusTTL) { s.show(feedbackActivity, text, ttl) } @@ -400,8 +404,8 @@ func (s *statusMsg) Warning(text string, ttl statusTTL) { s.show(feedbackWarning, text, ttl) } -func (s *statusMsg) Error(text string) { - s.show(feedbackError, text, 0) +func (s *statusMsg) Error(text string, ttl statusTTL) { + s.show(feedbackError, text, ttl) } func (s *statusMsg) show(kind feedbackKind, text string, ttl statusTTL) { diff --git a/ui/model/status_ttl_policy_test.go b/ui/model/status_ttl_policy_test.go index 9cf2a7f..a2938e9 100644 --- a/ui/model/status_ttl_policy_test.go +++ b/ui/model/status_ttl_policy_test.go @@ -14,6 +14,9 @@ func TestStatusShowAtSetsTextAndDeadline(t *testing.T) { if status.text != "Saved" { t.Fatalf("text = %q, want %q", status.text, "Saved") } + if status.kind != feedbackSuccess { + t.Fatalf("kind = %v, want %v", status.kind, feedbackSuccess) + } want := now.Add(time.Duration(statusTTLMedium)) if !status.expiresAt.Equal(want) { t.Fatalf("expiresAt = %v, want %v", status.expiresAt, want) @@ -48,3 +51,43 @@ func TestStatusClearResetsMessage(t *testing.T) { t.Fatalf("expiresAt after Clear() = %v, want zero", status.expiresAt) } } + +func TestStatusSemanticKinds(t *testing.T) { + tests := []struct { + name string + show func(*statusMsg) + want feedbackKind + }{ + {name: "activity", show: func(s *statusMsg) { s.Activity("Loading...", statusTTLShort) }, want: feedbackActivity}, + {name: "warning", show: func(s *statusMsg) { s.Warning("Unavailable", statusTTLShort) }, want: feedbackWarning}, + {name: "error", show: func(s *statusMsg) { s.Error("Load failed", statusTTLShort) }, want: feedbackError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var status statusMsg + tt.show(&status) + if status.kind != tt.want { + t.Fatalf("kind = %v, want %v", status.kind, tt.want) + } + if status.expiresAt.IsZero() { + t.Fatal("expiresAt is zero, want timed feedback") + } + }) + } +} + +func TestStatusErrorf(t *testing.T) { + var status statusMsg + + status.Errorf(statusTTLShort, "Load failed: %s", "offline") + if status.text != "Load failed: offline" { + t.Fatalf("text = %q, want %q", status.text, "Load failed: offline") + } + if status.kind != feedbackError { + t.Fatalf("kind = %v, want %v", status.kind, feedbackError) + } + if status.expiresAt.IsZero() { + t.Fatal("expiresAt is zero, want timed error") + } +} diff --git a/ui/model/styles.go b/ui/model/styles.go index 98f2587..f63ec3a 100644 --- a/ui/model/styles.go +++ b/ui/model/styles.go @@ -30,7 +30,7 @@ var ( Bold(true) feedbackWarningStyle = lipgloss.NewStyle(). - Foreground(ui.ColorAccent). + Foreground(ui.ColorWarning). Bold(true) dimStyle = lipgloss.NewStyle(). @@ -59,8 +59,7 @@ var ( Bold(true) playlistUnavailableStyle = lipgloss.NewStyle(). - Foreground(ui.ColorDim). - Faint(true) + Foreground(ui.ColorDim) helpStyle = lipgloss.NewStyle(). Foreground(ui.ColorDim) @@ -82,7 +81,7 @@ func rebuildModelStyles() { statusStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true) feedbackActivityStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) feedbackSuccessStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true) - feedbackWarningStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true) + feedbackWarningStyle = lipgloss.NewStyle().Foreground(ui.ColorWarning).Bold(true) dimStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) labelStyle = lipgloss.NewStyle().Foreground(ui.ColorText).Bold(true) eqActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true) @@ -90,7 +89,7 @@ func rebuildModelStyles() { playlistActiveStyle = lipgloss.NewStyle().Foreground(ui.ColorPlaying).Bold(true) playlistItemStyle = lipgloss.NewStyle().Foreground(ui.ColorText) playlistSelectedStyle = lipgloss.NewStyle().Foreground(ui.ColorAccent).Bold(true) - playlistUnavailableStyle = lipgloss.NewStyle().Foreground(ui.ColorDim).Faint(true) + playlistUnavailableStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) helpStyle = lipgloss.NewStyle().Foreground(ui.ColorDim) helpKeyStyle = lipgloss.NewStyle().Foreground(ui.ColorKeyFG).Background(ui.ColorKeyBG).Bold(true) errorStyle = lipgloss.NewStyle().Foreground(ui.ColorError) diff --git a/ui/model/update.go b/ui/model/update.go index ba3f4cc..2ce8518 100644 --- a/ui/model/update.go +++ b/ui/model/update.go @@ -393,7 +393,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.navBrowser.loading = false if msg.err != nil { - m.status.Showf(statusTTLDefault, "Artist load failed: %s", msg.err) + m.status.Errorf(statusTTLDefault, "Artist load failed: %s", msg.err) return m, nil } m.navBrowser.artists = msg.artists @@ -408,7 +408,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.navBrowser.albumLoading = false m.navBrowser.loading = false if msg.err != nil { - m.status.Showf(statusTTLDefault, "Album load failed: %s", msg.err) + m.status.Errorf(statusTTLDefault, "Album load failed: %s", msg.err) return m, nil } if msg.offset == 0 { @@ -436,7 +436,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.navBrowser.loading = false if msg.err != nil { - m.status.Showf(statusTTLDefault, "Track load failed: %s", msg.err) + m.status.Errorf(statusTTLDefault, "Track load failed: %s", msg.err) return m, nil } m.navBrowser.tracks = msg.tracks @@ -453,7 +453,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.catalogBatch.loading = false if msg.err != nil { m.catalogBatch.done = true - m.status.Show("Catalog load failed", statusTTLDefault) + m.status.Error("Catalog load failed", statusTTLDefault) return m, nil } if msg.added == 0 { @@ -475,7 +475,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.provLoading = false if msg.err != nil { - m.status.Show("Search failed", statusTTLDefault) + m.status.Error("Search failed", statusTTLDefault) } else { if lists, err := m.provider.Playlists(); err == nil { m.providerLists = lists @@ -483,7 +483,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.provCursor = 0 m.provScroll = 0 if msg.count == 0 { - m.status.Show("No stations found", statusTTLDefault) + m.status.Warning("No stations found", statusTTLDefault) } } return m, nil @@ -496,7 +496,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ytdlBatch.loading = false if msg.err != nil { m.ytdlBatch.done = true - m.status.Showf(statusTTLBatch, "Radio batch load failed: %v", msg.err) + m.status.Errorf(statusTTLBatch, "Radio batch load failed: %v", msg.err) return m, nil } if len(msg.tracks) == 0 { @@ -518,7 +518,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case feedTrackResolvedMsg: m.feedLoading = false if len(msg.tracks) == 0 { - m.status.Show("No episodes found in feed.", statusTTLDefault) + m.status.Warning("No episodes found in feed.", statusTTLDefault) return m, nil } m.playlist.Replace(msg.tracks) @@ -541,7 +541,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.addToHeaderState(msg.tracks) m.status.Showf(statusTTLDefault, "Loaded %d track(s)", len(msg.tracks)) } else { - m.status.Show("No tracks found at URL.", statusTTLDefault) + m.status.Warning("No tracks found at URL.", statusTTLDefault) } if len(msg.tracks) > 0 { // Set up incremental loading for YouTube Radio playlists. @@ -597,17 +597,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case fbTracksResolvedMsg: if len(msg.tracks) == 0 { - m.status.Show("No audio files found", statusTTLDefault) + m.status.Warning("No audio files found", statusTTLDefault) return m, nil } if msg.targetPlaylist != "" { added, skipped, err := m.writeTracksToPlaylist(msg.targetPlaylist, msg.tracks) if err != nil { - m.status.Showf(statusTTLDefault, "Add failed: %s", err) + m.status.Errorf(statusTTLDefault, "Add failed: %s", err) } else if skipped > 0 { - m.status.Showf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, msg.targetPlaylist, skipped) - } else { + m.status.Warningf(statusTTLBatch, "Added %d to %q, skipped %d duplicates", added, msg.targetPlaylist, skipped) + } else if added > 0 { m.status.Showf(statusTTLDefault, "Added %d to %q", added, msg.targetPlaylist) + } else { + m.status.Warningf(statusTTLDefault, "Nothing added to %q", msg.targetPlaylist) } m.refreshPlaylistManagerAfterWrite(msg.targetPlaylist) return m, nil @@ -657,7 +659,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.err != nil { m.err = msg.err if track, idx := m.currentPlaybackTrack(); idx >= 0 { - m.status.Showf(statusTTLLong, "Couldn't play %s — track is gated, restricted, or unavailable.", track.DisplayName()) + m.status.Errorf(statusTTLLong, "Couldn't play %s — track is gated, restricted, or unavailable.", track.DisplayName()) } } else { m.err = nil @@ -678,7 +680,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ytdlSavedMsg: m.save.finishDownload() if msg.err != nil { - m.status.Showf(statusTTLMedium, "Download failed: %s", msg.err) + m.status.Errorf(statusTTLMedium, "Download failed: %s", msg.err) } else { m.status.Showf(statusTTLMedium, "Saved to %s", msg.path) } @@ -725,9 +727,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spotSearch.results = msg.tracks m.spotSearch.cursor = 0 m.spotSearch.screen = spotSearchResults - if len(msg.tracks) == 0 { - m.spotSearch.err = "No results found" - } m.applyHeightMode() m.clampActiveScrollState() return m, nil @@ -803,7 +802,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case devicesListedMsg: m.devicePicker.loading = false if msg.err != nil { - m.status.Showf(statusTTLDefault, "Device list failed: %s", msg.err) + m.status.Errorf(statusTTLDefault, "Device list failed: %s", msg.err) m.devicePicker.visible = false } else { m.devicePicker.devices = msg.devices @@ -812,7 +811,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case deviceSwitchedMsg: if msg.err != nil { - m.status.Showf(statusTTLDefault, "Switch failed: %s", msg.err) + m.status.Errorf(statusTTLDefault, "Switch failed: %s", msg.err) } else { m.status.Showf(statusTTLDefault, "Audio output: %s", msg.name) _ = m.configSaver.Save("audio_device", msg.name) @@ -1020,7 +1019,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } shuffled := m.playlist.Shuffled() if err := m.configSaver.Save("shuffle", fmt.Sprintf("%v", shuffled)); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } m.player.ClearPreload() cmd := m.preloadNext() @@ -1042,7 +1041,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } mode := m.playlist.Repeat() if err := m.configSaver.Save("repeat", fmt.Sprintf("%q", mode.String())); err != nil { - m.status.Showf(statusTTLDefault, "Config save failed: %s", err) + m.status.Errorf(statusTTLDefault, "Config save failed: %s", err) } m.player.ClearPreload() cmd := m.preloadNext() @@ -1174,6 +1173,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t := m.themes[m.themeIdx] resp.Theme = &ipc.ThemeInfo{ Name: t.Name, + BG: t.BG, Accent: t.Accent, Fg: t.FG, BrightFg: t.BrightFG, diff --git a/ui/model/view.go b/ui/model/view.go index 7ad1977..de84635 100644 --- a/ui/model/view.go +++ b/ui/model/view.go @@ -119,6 +119,10 @@ func (m Model) View() tea.View { if m.layout.tooSmall() { content := fmt.Sprintf("Terminal too small. Resize to at least 40x10 (current: %dx%d).", m.width, m.height) view := tea.NewView(ui.FitRect(content, max(1, m.width), max(1, m.height))) + view.BackgroundColor = ui.ColorBackground + if ui.ColorBackground != nil { + view.ForegroundColor = ui.ColorText + } view.AltScreen = true return view } @@ -148,6 +152,10 @@ func (m Model) View() tea.View { rendered = ui.FitRect(rendered, m.layout.frameWidth, max(1, m.height)) view := tea.NewView(rendered) + view.BackgroundColor = ui.ColorBackground + if ui.ColorBackground != nil { + view.ForegroundColor = ui.ColorText + } view.AltScreen = true view.WindowTitle = currentTerminalTitle(m.termTitle, m.width, m.terminalTitleValues()) return view @@ -247,16 +255,19 @@ func (m Model) renderTransient() string { return ui.FitRect(feedbackActivityStyle.Render(text), m.layout.panelWidth, 1) } if m.status.text != "" { + text := m.status.text style := feedbackSuccessStyle switch m.status.kind { case feedbackActivity: style = feedbackActivityStyle case feedbackWarning: style = feedbackWarningStyle + text = "WARN: " + text case feedbackError: style = errorStyle + text = "ERR: " + text } - return ui.FitRect(style.Render(m.status.text), m.layout.panelWidth, 1) + return ui.FitRect(style.Render(text), m.layout.panelWidth, 1) } if n := len(m.logLines); n > 0 { return ui.FitRect(dimStyle.Render(m.logLines[n-1].text), m.layout.panelWidth, 1) diff --git a/ui/model/view_state_test.go b/ui/model/view_state_test.go index a995c2f..2ab5077 100644 --- a/ui/model/view_state_test.go +++ b/ui/model/view_state_test.go @@ -9,6 +9,7 @@ import ( "charm.land/lipgloss/v2" "github.com/bjarneo/cliamp/playlist" + "github.com/bjarneo/cliamp/theme" "github.com/bjarneo/cliamp/ui" ) @@ -59,6 +60,45 @@ func TestMainViewShrinksPlaylistForFooterMessages(t *testing.T) { } } +func TestViewAppliesThemeBackground(t *testing.T) { + applyThemeAll(theme.Theme{ + Name: "test", + BG: "#112233", + Accent: "#88aacc", + BrightFG: "#ffffff", + FG: "#aabbcc", + Green: "#88cc88", + Yellow: "#ddcc77", + Red: "#ee8888", + }) + t.Cleanup(func() { applyThemeAll(theme.Default()) }) + + view := (Model{width: 20, height: 5}).View() + if view.BackgroundColor == nil { + t.Fatal("BackgroundColor is nil for a theme with bg") + } + if view.ForegroundColor == nil { + t.Fatal("ForegroundColor is nil for a theme with bg") + } + r, g, b, _ := view.BackgroundColor.RGBA() + if r>>8 != 0x11 || g>>8 != 0x22 || b>>8 != 0x33 { + t.Fatalf("BackgroundColor = #%02x%02x%02x, want #112233", r>>8, g>>8, b>>8) + } +} + +func TestRenderTransientIncludesNonColorSeverityLabels(t *testing.T) { + m := Model{layout: frameLayout{panelWidth: 80}} + m.status.Warning("Unavailable", statusTTLShort) + if got := stripAnsi(m.renderTransient()); !strings.Contains(got, "WARN: Unavailable") { + t.Fatalf("warning feedback = %q, want WARN label", got) + } + + m.status.Error("Load failed", statusTTLShort) + if got := stripAnsi(m.renderTransient()); !strings.Contains(got, "ERR: Load failed") { + t.Fatalf("error feedback = %q, want ERR label", got) + } +} + func TestRenderPlaylistKeepsCursorVisibleWhenFooterShrinksBudget(t *testing.T) { if sharedPlayer == nil { t.Skip("audio hardware unavailable") diff --git a/ui/styles.go b/ui/styles.go index 31d9053..63c3591 100644 --- a/ui/styles.go +++ b/ui/styles.go @@ -2,6 +2,9 @@ package ui import ( "image/color" + "math" + "strconv" + "strings" "charm.land/lipgloss/v2" @@ -11,16 +14,18 @@ import ( // CLIAMP color palette using standard ANSI terminal colors (0-15). // These adapt to the user's terminal theme for consistent appearance. var ( - ColorTitle color.Color = lipgloss.ANSIColor(10) // bright green - ColorText color.Color = lipgloss.ANSIColor(15) // bright white - ColorDim color.Color = lipgloss.ANSIColor(7) // white (light gray) - ColorAccent color.Color = lipgloss.ANSIColor(11) // bright yellow - ColorPlaying color.Color = lipgloss.ANSIColor(10) // bright green - ColorSeekBar color.Color = lipgloss.ANSIColor(11) // bright yellow - ColorVolume color.Color = lipgloss.ANSIColor(2) // green - ColorError color.Color = lipgloss.ANSIColor(9) // bright red - ColorKeyBG color.Color = lipgloss.ANSIColor(8) // bright black (dark gray) - ColorKeyFG color.Color = lipgloss.ANSIColor(15) // bright white + ColorBackground color.Color + ColorTitle color.Color = lipgloss.ANSIColor(10) // bright green + ColorText color.Color = lipgloss.ANSIColor(15) // bright white + ColorDim color.Color = lipgloss.ANSIColor(7) // white (light gray) + ColorAccent color.Color = lipgloss.ANSIColor(11) // bright yellow + ColorPlaying color.Color = lipgloss.ANSIColor(10) // bright green + ColorSeekBar color.Color = lipgloss.ANSIColor(11) // bright yellow + ColorVolume color.Color = lipgloss.ANSIColor(2) // green + ColorError color.Color = lipgloss.ANSIColor(9) // bright red + ColorWarning color.Color = lipgloss.ANSIColor(11) // bright yellow + ColorKeyBG color.Color = lipgloss.ANSIColor(8) // bright black (dark gray) + ColorKeyFG color.Color = lipgloss.ANSIColor(15) // bright white // Spectrum gradient: green -> yellow -> red SpectrumLow color.Color = lipgloss.ANSIColor(10) // bright green @@ -60,6 +65,7 @@ var FrameStyle = lipgloss.NewStyle(). // If the theme is the default (empty hex values), ANSI fallback colors are restored. func ApplyThemeColors(t theme.Theme) { if t.IsDefault() { + ColorBackground = nil ColorTitle = lipgloss.ANSIColor(10) ColorText = lipgloss.ANSIColor(15) ColorDim = lipgloss.ANSIColor(7) @@ -68,12 +74,18 @@ func ApplyThemeColors(t theme.Theme) { ColorSeekBar = lipgloss.ANSIColor(11) ColorVolume = lipgloss.ANSIColor(2) ColorError = lipgloss.ANSIColor(9) + ColorWarning = lipgloss.ANSIColor(11) ColorKeyBG = lipgloss.ANSIColor(8) ColorKeyFG = lipgloss.ANSIColor(15) SpectrumLow = lipgloss.ANSIColor(10) SpectrumMid = lipgloss.ANSIColor(11) SpectrumHigh = lipgloss.ANSIColor(9) } else { + if t.BG == "" { + ColorBackground = nil + } else { + ColorBackground = lipgloss.Color(t.BG) + } ColorTitle = lipgloss.Color(t.Accent) ColorText = lipgloss.Color(t.BrightFG) ColorDim = lipgloss.Color(t.FG) @@ -82,8 +94,9 @@ func ApplyThemeColors(t theme.Theme) { ColorSeekBar = lipgloss.Color(t.Accent) ColorVolume = lipgloss.Color(t.Green) ColorError = lipgloss.Color(t.Red) + ColorWarning = lipgloss.Color(t.Yellow) ColorKeyBG = lipgloss.Color(t.Accent) - ColorKeyFG = lipgloss.Color(t.BrightFG) + ColorKeyFG = lipgloss.Color(contrastingTextColor(t.Accent)) SpectrumLow = lipgloss.Color(t.Green) SpectrumMid = lipgloss.Color(t.Yellow) SpectrumHigh = lipgloss.Color(t.Red) @@ -95,3 +108,23 @@ func ApplyThemeColors(t theme.Theme) { specHighStyle = lipgloss.NewStyle().Foreground(SpectrumHigh) refreshSpecANSI() } + +func contrastingTextColor(hex string) string { + value, err := strconv.ParseUint(strings.TrimPrefix(hex, "#"), 16, 24) + if err != nil { + return "#ffffff" + } + linear := func(channel uint64) float64 { + component := float64(channel) / 255 + if component <= 0.04045 { + return component / 12.92 + } + return math.Pow((component+0.055)/1.055, 2.4) + } + luminance := 0.2126*linear(value>>16) + 0.7152*linear((value>>8)&0xff) + 0.0722*linear(value&0xff) + // This is the crossover where black provides more contrast than white. + if luminance > 0.179 { + return "#000000" + } + return "#ffffff" +} diff --git a/ui/styles_test.go b/ui/styles_test.go new file mode 100644 index 0000000..76e8db7 --- /dev/null +++ b/ui/styles_test.go @@ -0,0 +1,23 @@ +package ui + +import "testing" + +func TestContrastingTextColor(t *testing.T) { + tests := []struct { + name string + accent string + want string + }{ + {name: "light accent", accent: "#f7df50", want: "#000000"}, + {name: "dark accent", accent: "#3e4a5e", want: "#ffffff"}, + {name: "invalid accent", accent: "blue", want: "#ffffff"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := contrastingTextColor(tt.accent); got != tt.want { + t.Errorf("contrastingTextColor(%q) = %q, want %q", tt.accent, got, tt.want) + } + }) + } +}