From 430c87451b751d671ce68776c69d3f16536f2fa2 Mon Sep 17 00:00:00 2001 From: Test User Date: Mon, 27 Jul 2026 09:43:21 +0200 Subject: [PATCH] tui: make the spectrum color configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bars were hardcoded to `COLOR_PRIMARY`, the same muted blue as the pane borders, so the one moving thing on screen read as chrome. `spectrum_color` in the client config now picks it, parsed by ratatui's `Color: FromStr`: a `#rrggbb` triple, a color name, or a 0-255 palette index, the latter two deferring to the terminal theme. It defaults to `COLOR_RED` — the red the queue marks the playing track with — so the two agree on what "now" looks like. The default lives twice, as `COLOR_RED` for the renderer and `COLOR_RED_HEX` for the config file, one being a `Color` and the other a string a user edits. `the_default_spectrum_color_is_the_queue_red` stops them drifting. A config file is user input, so an unparsable value warns on stderr and falls back to the red instead of failing. It resolves in `run`, before the alternate screen is entered, or the warning would be drawn over and lost. Existing config files predate the key: they keep working and take the default, since every field of the `ClapSerde` opt struct is optional. Co-Authored-By: Claude Opus 5 (1M context) --- cbd-tui/src/app/mod.rs | 4 ++ cbd-tui/src/app/now_playing.rs | 56 +++++++++++++++++++++----- cbd-tui/src/config.rs | 72 +++++++++++++++++++++++++++++++++- cbd-tui/src/lib.rs | 13 +++++- docs/src/clients/tui.md | 11 +++++- docs/src/config.md | 9 ++++- plan/summary.md | 28 +++++++++++++ 7 files changed, 179 insertions(+), 14 deletions(-) diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 22e46ef..f42d191 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -69,6 +69,10 @@ pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); pub const COLOR_RED: Color = Color::Rgb(191, 97, 106); +/// [`COLOR_RED`] as a config-file string — the default `spectrum_color`, +/// so the bars match the playing queue row out of the box. The pair is +/// checked by `the_default_spectrum_color_is_the_queue_red`. +pub const COLOR_RED_HEX: &str = "#bf616a"; pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140); // const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112); // const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233); diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index 789eff3..9e23ccf 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -13,7 +13,7 @@ use ratatui::{ Frame, }; -use super::{COLOR_PRIMARY, COLOR_SECONDARY}; +use super::{COLOR_RED, COLOR_SECONDARY}; /// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell. const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; @@ -22,7 +22,7 @@ const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', /// area: one `Line` per row, top row first. Each column maps to a bin; /// its level in `[0, 1]` fills from the bottom, using partial block /// glyphs for the topmost fractional cell. Pure, so it is unit-tested. -fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec> { +fn spectrum_lines(bins: &[f32], width: usize, height: usize, color: Color) -> Vec> { (0..height) .map(|row| { // Row 0 is the top; count cells up from the bottom. @@ -40,7 +40,7 @@ fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec, /// Whether to draw the spectrum row (config `spectrum`, default on). spectrum_enabled: bool, + /// Bar color (config `spectrum_color`), defaulting to the red the + /// queue marks the playing track with. + spectrum_color: Color, /// Whether the server output is muted. muted: bool, /// The server's output level, `1.0` = 100%. This is the level playback @@ -95,6 +98,7 @@ impl Default for NowPlaying { track: None, spectrum: Vec::new(), spectrum_enabled: true, + spectrum_color: COLOR_RED, muted: false, volume: 1.0, } @@ -126,6 +130,11 @@ impl NowPlaying { pub fn set_spectrum_enabled(&mut self, enabled: bool) { self.spectrum_enabled = enabled; } + /// Sets the bar color (from client config). Parsing the config string + /// is the caller's job, so an unusable value never reaches here. + pub fn set_spectrum_color(&mut self, color: Color) { + self.spectrum_color = color; + } /// Shows or hides the spectrum row (the `v` keybinding). The server /// keeps streaming the bars; this only gates rendering. pub fn toggle_spectrum(&mut self) { @@ -277,14 +286,14 @@ impl NowPlaying { f.render_widget(time_p, elapsed_layout[1]); } - // The spectrum: full-height accent bars filling the region left - // below the progress. Columns stretch across the pane width - // regardless of the server's bin count. + // The spectrum: full-height bars filling the region left below the + // progress, in the configured color. Columns stretch across the + // pane width regardless of the server's bin count. if self.spectrum_enabled && !self.spectrum.is_empty() { let area = now_playing_layout[2]; let (width, height) = (area.width as usize, area.height as usize); if width > 0 && height > 0 { - let lines = spectrum_lines(&self.spectrum, width, height); + let lines = spectrum_lines(&self.spectrum, width, height, self.spectrum_color); f.render_widget(Paragraph::new(lines), area); } } @@ -349,6 +358,7 @@ mod tests { }), spectrum: Vec::new(), spectrum_enabled: true, + spectrum_color: COLOR_RED, muted: false, volume: 1.0, } @@ -381,7 +391,7 @@ mod tests { fn spectrum_lines_fill_full_height_columns() { // A full-level bin fills every row of its column with the full // block; a zero bin leaves every row blank. - let lines = spectrum_lines(&[1.0, 0.0], 2, 4); + let lines = spectrum_lines(&[1.0, 0.0], 2, 4, COLOR_RED); assert_eq!(lines.len(), 4, "one line per row"); let text: Vec = lines .iter() @@ -395,7 +405,7 @@ mod tests { #[test] fn spectrum_lines_grow_from_the_bottom() { // Half level over 4 rows ≈ 16 eighths → the bottom two rows fill. - let lines = spectrum_lines(&[0.5], 1, 4); + let lines = spectrum_lines(&[0.5], 1, 4, COLOR_RED); let col: Vec = lines .iter() .map(|l| l.spans[0].content.chars().next().unwrap()) @@ -415,6 +425,34 @@ mod tests { assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}"); } + /// The foreground of the first full-block cell in the buffer — the + /// color the bars are actually drawn in. + fn first_bar_color(pane: &NowPlaying) -> Option { + let backend = TestBackend::new(60, 12); + let mut terminal = Terminal::new(backend).expect("test terminal"); + terminal.draw(|f| pane.render(f, f.area())).expect("draw"); + let buffer = terminal.backend().buffer().clone(); + (0..buffer.area.height) + .flat_map(|y| (0..buffer.area.width).map(move |x| (x, y))) + .find(|&(x, y)| buffer[(x, y)].symbol() == "█") + .and_then(|(x, y)| buffer[(x, y)].style().fg) + } + + #[test] + fn the_bars_default_to_the_queues_playing_red() { + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![1.0; 24]); + assert_eq!(first_bar_color(&pane), Some(super::super::COLOR_RED)); + } + + #[test] + fn the_bars_take_the_configured_color() { + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![1.0; 24]); + pane.set_spectrum_color(Color::Rgb(1, 2, 3)); + assert_eq!(first_bar_color(&pane), Some(Color::Rgb(1, 2, 3))); + } + #[test] fn the_spectrum_row_is_hidden_when_disabled() { let mut pane = now_playing(10_000, 60_000); diff --git a/cbd-tui/src/config.rs b/cbd-tui/src/config.rs index 433d0b6..27334a8 100644 --- a/cbd-tui/src/config.rs +++ b/cbd-tui/src/config.rs @@ -1,6 +1,10 @@ -use std::path::{Path, PathBuf}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde}; +use ratatui::style::Color; #[derive(ClapSerde, Serialize, Debug)] #[clap(author, version, about)] @@ -169,6 +173,33 @@ pub struct ServerConfig { #[default(true)] #[clap(long)] pub spectrum: bool, + + /// Color of the frequency-spectrum bars: a `#rrggbb` hex triple, one + /// of ratatui's names ("red", "light-blue", …), or a 0-255 index into + /// the terminal palette. Defaults to the red the queue marks the + /// playing track with. An unparsable value falls back to that + /// default with a warning — see [`spectrum_color`]. + #[default(crate::app::COLOR_RED_HEX.to_string())] + #[clap(long)] + pub spectrum_color: String, +} + +/// Resolves [`ServerConfig::spectrum_color`] to a ratatui [`Color`]. +/// +/// A config file is user input: a typo must not take the client down, so an +/// unparsable value is reported on stderr and the default red is used. The +/// terminal decides what a name or palette index looks like, so this cannot +/// tell a working color from an invisible one — only a syntactic one from a +/// nonsense one. +pub fn spectrum_color(config: &Config) -> Color { + let raw = config.server.spectrum_color.trim(); + if raw.is_empty() { + return crate::app::COLOR_RED; + } + Color::from_str(raw).unwrap_or_else(|_| { + eprintln!("invalid spectrum_color {raw:?}, using the default"); + crate::app::COLOR_RED + }) } #[cfg(test)] @@ -210,6 +241,45 @@ mod tests { assert!(!config.server.spectrum); } + #[test] + fn the_default_spectrum_color_is_the_queue_red() { + // The config default is a string and the renderer wants a `Color`; + // this is what keeps the two spellings of the same red together. + let config = Config::default(); + assert_eq!(config.server.spectrum_color, crate::app::COLOR_RED_HEX); + assert_eq!(spectrum_color(&config), crate::app::COLOR_RED); + } + + #[test] + fn a_spectrum_color_is_read_as_hex_name_or_palette_index() { + let mut config = Config::default(); + for (raw, expected) in [ + ("#00ff7f", Color::Rgb(0, 255, 127)), + ("light-blue", Color::LightBlue), + ("208", Color::Indexed(208)), + // Surrounding whitespace is a config-file slip, not a value. + (" #010203 ", Color::Rgb(1, 2, 3)), + ] { + config.server.spectrum_color = raw.to_string(); + assert_eq!(spectrum_color(&config), expected, "parsing {raw:?}"); + } + } + + #[test] + fn an_unusable_spectrum_color_falls_back_to_the_default() { + // A config file is user input: a typo warns and keeps the client + // running rather than taking it down. + let mut config = Config::default(); + for raw in ["", " ", "puce", "#12345", "300", "#"] { + config.server.spectrum_color = raw.to_string(); + assert_eq!( + spectrum_color(&config), + crate::app::COLOR_RED, + "rejecting {raw:?}" + ); + } + } + #[test] fn write_auth_round_trips_user_password_address() { let dir = TempDir::new().expect("tempdir"); diff --git a/cbd-tui/src/lib.rs b/cbd-tui/src/lib.rs index 610efe0..83221f6 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -43,8 +43,11 @@ pub async fn run(config: &'static Config) -> Result<(), Box> { tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() }); let spectrum_enabled = config.server.spectrum; + // Resolved here rather than in the UI thread so a bad config string is + // reported on stderr before the alternate screen swallows it. + let spectrum_color = config::spectrum_color(config); tokio::task::spawn_blocking(move || { - run_ui(ui_tx, ui_rx, spectrum_enabled); + run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_color); }) .await?; @@ -209,7 +212,12 @@ async fn poll( Ok(()) } -fn run_ui(tx: Sender, rx: Receiver, spectrum_enabled: bool) { +fn run_ui( + tx: Sender, + rx: Receiver, + spectrum_enabled: bool, + spectrum_color: ratatui::style::Color, +) { // setup terminal enable_raw_mode().unwrap(); let mut stdout = io::stdout(); @@ -220,6 +228,7 @@ fn run_ui(tx: Sender, rx: Receiver, spectrum_enabled // create app and run it let mut app = App::new(tx); app.now_playing.set_spectrum_enabled(spectrum_enabled); + app.now_playing.set_spectrum_color(spectrum_color); let tick_rate = Duration::from_millis(100); let mut last_tick = Instant::now(); diff --git a/docs/src/clients/tui.md b/docs/src/clients/tui.md index 2c4d43b..20860ca 100644 --- a/docs/src/clients/tui.md +++ b/docs/src/clients/tui.md @@ -135,7 +135,7 @@ its visible set is recomputed. ## Frequency spectrum A row of frequency bars is drawn under the track progress while audio -plays, as block glyphs (`▁▂▃▄▅▆▇█`) in the accent color. The bars are +plays, as block glyphs (`▁▂▃▄▅▆▇█`). The bars are produced on the **server**: it taps its own audio output, runs an FFT (~20 fps), folds the result into a few log-spaced bins, and streams them on the update stream like every other bit of live state. So the @@ -147,6 +147,15 @@ config sets the startup default (`spectrum = true` is the default; `false` starts hidden). Either way it is only a display choice — the server always computes and streams the bars while audio flows. +`spectrum_color` in the client config picks their color, as a `#rrggbb` +hex triple, a color name (`red`, `light-blue`, …), or a 0-255 index into +the terminal palette. It defaults to the red the queue marks the playing +track with, so the two agree on what "now" looks like. A palette index or +a name leaves the shade to your terminal theme, which is the point of +them; a hex triple asks for one exact color, which a 256-color terminal +will approximate. An unparsable value warns on stderr and falls back to +the default rather than stopping the client. + ## Key bindings Global keys work in either pane. Pane keys apply only while that pane is diff --git a/docs/src/config.md b/docs/src/config.md index f43a51a..ba28741 100644 --- a/docs/src/config.md +++ b/docs/src/config.md @@ -105,9 +105,16 @@ password = "" # Show the frequency-spectrum bars under the track progress. spectrum = true + +# Color of those bars: a "#rrggbb" hex triple, a color name ("red", +# "light-blue", …), or a 0-255 index into the terminal palette. The +# default is the red the queue marks the playing track with. An +# unparsable value warns on stderr and falls back to that default. +spectrum_color = "#bf616a" ``` -Every option is also a command-line flag, given before the subcommand +Every option except `spectrum_color` is also a command-line flag, given +before the subcommand (`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum false`). A provided flag overrides the file value; an omitted flag leaves the file value in place. To write credentials into the config diff --git a/plan/summary.md b/plan/summary.md index 54a7bd8..3b4ac2a 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1640,3 +1640,31 @@ a nudge next to a skip. `font-variant-emoji: text` on the controls row asks for the text form of the prev/next/pause marks, which have the same emoji presentation. It is ignored where unsupported, at no cost: those glyphs are the fallback anyway. + +## TUI spectrum color (2026-07-27) + +The bars were hardcoded to `COLOR_PRIMARY`, the same muted blue as the pane +borders, so they read as chrome rather than as the thing that is moving. + +`spectrum_color` in the client config now picks the color, parsed by ratatui's +`Color: FromStr` — so a `#rrggbb` triple, a color name, or a 0-255 palette +index all work, and a name or index defers to the terminal theme. It defaults +to `COLOR_RED`, the red the queue marks the playing track with, so the two +agree on what "now" looks like. + +The default lives twice, as `COLOR_RED` for the renderer and `COLOR_RED_HEX` +for the config file, since one is a `Color` and the other a string a user +edits. `the_default_spectrum_color_is_the_queue_red` is what stops them +drifting. + +A config file is user input, so `config::spectrum_color` reports an unparsable +value on stderr and falls back to the red rather than failing. It resolves in +`run`, before the alternate screen is entered, or the warning would be drawn +over and lost. Empty counts as unset for the same reason a missing key does. + +No CLI flag: the other options have one because they are things you override +per invocation, and a color is not. + +Existing config files predate the key. They keep working — every field of the +`ClapSerde` opt struct is optional — and take the default, but the new key +only appears in files written from now on.