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:
parent
d2f687983e
commit
430c87451b
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue