tui: make the spectrum color configurable

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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-27 09:43:21 +02:00
parent d2f687983e
commit 430c87451b
7 changed files with 179 additions and 14 deletions

View File

@ -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_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82);
pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173);
pub const COLOR_RED: Color = Color::Rgb(191, 97, 106); 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); pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
// const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112); // const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112);
// const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233); // const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233);

View File

@ -13,7 +13,7 @@ use ratatui::{
Frame, Frame,
}; };
use super::{COLOR_PRIMARY, COLOR_SECONDARY}; use super::{COLOR_RED, COLOR_SECONDARY};
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell. /// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; 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; /// 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 /// its level in `[0, 1]` fills from the bottom, using partial block
/// glyphs for the topmost fractional cell. Pure, so it is unit-tested. /// glyphs for the topmost fractional cell. Pure, so it is unit-tested.
fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec<Line<'static>> { fn spectrum_lines(bins: &[f32], width: usize, height: usize, color: Color) -> Vec<Line<'static>> {
(0..height) (0..height)
.map(|row| { .map(|row| {
// Row 0 is the top; count cells up from the bottom. // 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<Line<'static
BLOCKS[cell] BLOCKS[cell]
}) })
.collect(); .collect();
Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY))) Line::from(Span::styled(cells, Style::default().fg(color)))
}) })
.collect() .collect()
} }
@ -78,6 +78,9 @@ pub struct NowPlaying {
spectrum: Vec<f32>, spectrum: Vec<f32>,
/// Whether to draw the spectrum row (config `spectrum`, default on). /// Whether to draw the spectrum row (config `spectrum`, default on).
spectrum_enabled: bool, 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. /// Whether the server output is muted.
muted: bool, muted: bool,
/// The server's output level, `1.0` = 100%. This is the level playback /// The server's output level, `1.0` = 100%. This is the level playback
@ -95,6 +98,7 @@ impl Default for NowPlaying {
track: None, track: None,
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_enabled: true, spectrum_enabled: true,
spectrum_color: COLOR_RED,
muted: false, muted: false,
volume: 1.0, volume: 1.0,
} }
@ -126,6 +130,11 @@ impl NowPlaying {
pub fn set_spectrum_enabled(&mut self, enabled: bool) { pub fn set_spectrum_enabled(&mut self, enabled: bool) {
self.spectrum_enabled = enabled; 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 /// Shows or hides the spectrum row (the `v` keybinding). The server
/// keeps streaming the bars; this only gates rendering. /// keeps streaming the bars; this only gates rendering.
pub fn toggle_spectrum(&mut self) { pub fn toggle_spectrum(&mut self) {
@ -277,14 +286,14 @@ impl NowPlaying {
f.render_widget(time_p, elapsed_layout[1]); f.render_widget(time_p, elapsed_layout[1]);
} }
// The spectrum: full-height accent bars filling the region left // The spectrum: full-height bars filling the region left below the
// below the progress. Columns stretch across the pane width // progress, in the configured color. Columns stretch across the
// regardless of the server's bin count. // pane width regardless of the server's bin count.
if self.spectrum_enabled && !self.spectrum.is_empty() { if self.spectrum_enabled && !self.spectrum.is_empty() {
let area = now_playing_layout[2]; let area = now_playing_layout[2];
let (width, height) = (area.width as usize, area.height as usize); let (width, height) = (area.width as usize, area.height as usize);
if width > 0 && height > 0 { 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); f.render_widget(Paragraph::new(lines), area);
} }
} }
@ -349,6 +358,7 @@ mod tests {
}), }),
spectrum: Vec::new(), spectrum: Vec::new(),
spectrum_enabled: true, spectrum_enabled: true,
spectrum_color: COLOR_RED,
muted: false, muted: false,
volume: 1.0, volume: 1.0,
} }
@ -381,7 +391,7 @@ mod tests {
fn spectrum_lines_fill_full_height_columns() { fn spectrum_lines_fill_full_height_columns() {
// A full-level bin fills every row of its column with the full // A full-level bin fills every row of its column with the full
// block; a zero bin leaves every row blank. // 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"); assert_eq!(lines.len(), 4, "one line per row");
let text: Vec<String> = lines let text: Vec<String> = lines
.iter() .iter()
@ -395,7 +405,7 @@ mod tests {
#[test] #[test]
fn spectrum_lines_grow_from_the_bottom() { fn spectrum_lines_grow_from_the_bottom() {
// Half level over 4 rows ≈ 16 eighths → the bottom two rows fill. // 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<char> = lines let col: Vec<char> = lines
.iter() .iter()
.map(|l| l.spans[0].content.chars().next().unwrap()) .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:?}"); 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<Color> {
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] #[test]
fn the_spectrum_row_is_hidden_when_disabled() { fn the_spectrum_row_is_hidden_when_disabled() {
let mut pane = now_playing(10_000, 60_000); let mut pane = now_playing(10_000, 60_000);

View File

@ -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 crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde};
use ratatui::style::Color;
#[derive(ClapSerde, Serialize, Debug)] #[derive(ClapSerde, Serialize, Debug)]
#[clap(author, version, about)] #[clap(author, version, about)]
@ -169,6 +173,33 @@ pub struct ServerConfig {
#[default(true)] #[default(true)]
#[clap(long)] #[clap(long)]
pub spectrum: bool, 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)] #[cfg(test)]
@ -210,6 +241,45 @@ mod tests {
assert!(!config.server.spectrum); 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] #[test]
fn write_auth_round_trips_user_password_address() { fn write_auth_round_trips_user_password_address() {
let dir = TempDir::new().expect("tempdir"); let dir = TempDir::new().expect("tempdir");

View File

@ -43,8 +43,11 @@ pub async fn run(config: &'static Config) -> Result<(), Box<dyn Error>> {
tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() }); tokio::spawn(async move { orchestrate(config, (tx, rx)).await.unwrap() });
let spectrum_enabled = config.server.spectrum; 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 || { tokio::task::spawn_blocking(move || {
run_ui(ui_tx, ui_rx, spectrum_enabled); run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_color);
}) })
.await?; .await?;
@ -209,7 +212,12 @@ async fn poll(
Ok(()) Ok(())
} }
fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled: bool) { fn run_ui(
tx: Sender<MessageFromUi>,
rx: Receiver<MessageToUi>,
spectrum_enabled: bool,
spectrum_color: ratatui::style::Color,
) {
// setup terminal // setup terminal
enable_raw_mode().unwrap(); enable_raw_mode().unwrap();
let mut stdout = io::stdout(); let mut stdout = io::stdout();
@ -220,6 +228,7 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>, spectrum_enabled
// create app and run it // create app and run it
let mut app = App::new(tx); let mut app = App::new(tx);
app.now_playing.set_spectrum_enabled(spectrum_enabled); app.now_playing.set_spectrum_enabled(spectrum_enabled);
app.now_playing.set_spectrum_color(spectrum_color);
let tick_rate = Duration::from_millis(100); let tick_rate = Duration::from_millis(100);
let mut last_tick = Instant::now(); let mut last_tick = Instant::now();

View File

@ -135,7 +135,7 @@ its visible set is recomputed.
## Frequency spectrum ## Frequency spectrum
A row of frequency bars is drawn under the track progress while audio 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 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 (~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 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 `false` starts hidden). Either way it is only a display choice — the
server always computes and streams the bars while audio flows. 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 ## Key bindings
Global keys work in either pane. Pane keys apply only while that pane is Global keys work in either pane. Pane keys apply only while that pane is

View File

@ -105,9 +105,16 @@ password = ""
# Show the frequency-spectrum bars under the track progress. # Show the frequency-spectrum bars under the track progress.
spectrum = true 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 (`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum
false`). A provided flag overrides the file value; an omitted flag false`). A provided flag overrides the file value; an omitted flag
leaves the file value in place. To write credentials into the config leaves the file value in place. To write credentials into the config

View File

@ -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 `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 prev/next/pause marks, which have the same emoji presentation. It is ignored
where unsupported, at no cost: those glyphs are the fallback anyway. 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.