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_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);

View File

@ -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<Line<'static>> {
fn spectrum_lines(bins: &[f32], width: usize, height: usize, color: Color) -> Vec<Line<'static>> {
(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<Line<'static
BLOCKS[cell]
})
.collect();
Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY)))
Line::from(Span::styled(cells, Style::default().fg(color)))
})
.collect()
}
@ -78,6 +78,9 @@ pub struct NowPlaying {
spectrum: Vec<f32>,
/// 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<String> = 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<char> = 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<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]
fn the_spectrum_row_is_hidden_when_disabled() {
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 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");

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() });
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<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
enable_raw_mode().unwrap();
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
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();

View File

@ -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

View File

@ -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

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
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.