diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index f42d191..75a1cd4 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -25,6 +25,7 @@ pub use list::{Filter, StatefulList}; use bindings::Action; use library::Library; use now_playing::NowPlaying; +pub use now_playing::SpectrumStyle; use queue::Queue; pub use register::Register; @@ -65,6 +66,10 @@ pub(crate) struct UiItem { } pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); +/// [`COLOR_PRIMARY`] as a config-file string — the default +/// `spectrum_peak_color`. The pair is checked by +/// `the_default_peak_color_is_the_primary_blue`. +pub const COLOR_PRIMARY_HEX: &str = "#81a1c1"; // const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172); pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index 9e23ccf..964e075 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -13,34 +13,152 @@ use ratatui::{ Frame, }; -use super::{COLOR_RED, COLOR_SECONDARY}; +use super::{COLOR_PRIMARY, COLOR_RED, COLOR_SECONDARY}; /// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell. const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; +/// The peak-hold marker: a thin rule above the bar, the width of a cell +/// (U+2594 upper one-eighth block). +const PEAK_MARK: char = '▔'; + +/// Brightness of the bottom gradient row, as a fraction of the configured +/// color. Dim enough to read as a floor, light enough to stay visible on a +/// dark background. +const GRADIENT_FLOOR: f32 = 0.55; + +/// How far the top gradient row is blended toward white, so a loud bar +/// reads as hot rather than merely tall. +const GRADIENT_HIGHLIGHT: f32 = 0.3; + +/// Fall of a peak marker per spectrum frame. The server streams ~20 fps, so +/// this drops a full-scale peak to the floor in a bit under two seconds: +/// long enough to see what a transient reached, short enough to track the +/// music. Frames keep arriving (zeroed) while audio is idle, so the +/// markers fall away on pause rather than freezing on screen. +const PEAK_DECAY: f32 = 0.03; + +/// How the spectrum is painted, resolved from the client config once at +/// startup (see `config::spectrum_style`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SpectrumStyle { + /// Bar color, and the base the gradient is derived from. + pub color: Color, + /// Shade the bars from dim at the floor to bright at the top. + pub gradient: bool, + /// Peak-hold marker color, or `None` to draw no markers. + pub peak: Option, +} + +impl Default for SpectrumStyle { + fn default() -> Self { + SpectrumStyle { + color: COLOR_RED, + gradient: true, + peak: Some(COLOR_PRIMARY), + } + } +} + +/// A bin level off the wire, clamped into `[0, 1]`. The bins are `float`s +/// from a peer, so NaN and infinity are possible; both read as silence +/// rather than propagating into the glyph arithmetic. +fn level_of(raw: f32) -> f32 { + if raw.is_finite() { + raw.clamp(0.0, 1.0) + } else { + 0.0 + } +} + +/// The bin a column samples: the bins are stretched across the full width, +/// whatever the server's bin count. +fn bin_for(col: usize, width: usize, bins: usize) -> usize { + if bins == 0 { + return 0; + } + (col * bins / width.max(1)).min(bins - 1) +} + +/// The color of the bar row `from_bottom` rows up in a `height`-row area: +/// [`GRADIENT_FLOOR`]-dimmed at the bottom, blended toward white by +/// [`GRADIENT_HIGHLIGHT`] at the top. +/// +/// Only an `Rgb` base can be interpolated. A color name or a palette index +/// is a reference into the terminal's own theme — its RGB value is not ours +/// to know — so those are returned unchanged and render flat. +fn gradient_color(base: Color, from_bottom: usize, height: usize) -> Color { + let Color::Rgb(r, g, b) = base else { + return base; + }; + let t = if height <= 1 { + 1.0 + } else { + from_bottom as f32 / (height - 1) as f32 + }; + let ramp = |channel: u8| { + let channel = f32::from(channel); + let low = channel * GRADIENT_FLOOR; + let high = channel + (255.0 - channel) * GRADIENT_HIGHLIGHT; + (low + (high - low) * t).round().clamp(0.0, 255.0) as u8 + }; + Color::Rgb(ramp(r), ramp(g), ramp(b)) +} + /// Renders `bins` as full-height vertical bars filling a `width`×`height` /// 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, color: Color) -> Vec> { +/// +/// `peaks` holds the decaying per-bin maxima (see [`NowPlaying::update_spectrum`]). +/// A peak is marked only in a row the bar does not reach at all: a cell +/// holds one glyph, and a marker inside the bar's own top cell would eat +/// up to seven eighths of the bar to say something the bar top already says. +fn spectrum_lines( + bins: &[f32], + peaks: &[f32], + width: usize, + height: usize, + style: SpectrumStyle, +) -> Vec> { (0..height) .map(|row| { // Row 0 is the top; count cells up from the bottom. let from_bottom = height - 1 - row; - let cells: String = (0..width) - .map(|col| { - let bin = if bins.is_empty() { - 0 - } else { - (col * bins.len() / width.max(1)).min(bins.len() - 1) - }; - let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0); - let total_eighths = (level * height as f32 * 8.0).round() as usize; - let cell = total_eighths.saturating_sub(from_bottom * 8).min(8); - BLOCKS[cell] - }) - .collect(); - Line::from(Span::styled(cells, Style::default().fg(color))) + let bar_color = if style.gradient { + gradient_color(style.color, from_bottom, height) + } else { + style.color + }; + // Runs of same-colored cells, so a row is a handful of spans + // rather than one per column. + let mut runs: Vec<(Color, String)> = Vec::new(); + for col in 0..width { + let bin = bin_for(col, width, bins.len()); + let level = level_of(bins.get(bin).copied().unwrap_or(0.0)); + let eighths = (level * height as f32 * 8.0).round() as usize; + let cell = eighths.saturating_sub(from_bottom * 8).min(8); + let peak = style.peak.filter(|_| { + let peak = level_of(peaks.get(bin).copied().unwrap_or(0.0)); + let peak_eighths = (peak * height as f32 * 8.0).round() as usize; + // The row holding the peak's topmost eighth, and only + // where the bar has left this cell empty. + cell == 0 && peak_eighths > 0 && (peak_eighths - 1) / 8 == from_bottom + }); + let (glyph, color) = match peak { + Some(peak_color) => (PEAK_MARK, peak_color), + None => (BLOCKS[cell], bar_color), + }; + match runs.last_mut() { + Some((run_color, text)) if *run_color == color => text.push(glyph), + _ => runs.push((color, glyph.to_string())), + } + } + Line::from( + runs.into_iter() + .map(|(color, text)| Span::styled(text, Style::default().fg(color))) + .collect::>(), + ) }) .collect() } @@ -76,11 +194,15 @@ pub struct NowPlaying { /// Latest frequency-spectrum bars (architecture/spectrum.md), empty /// until the first frame arrives. spectrum: Vec, + /// Per-bin peak hold: the highest level each bin has reached lately, + /// decayed by [`PEAK_DECAY`] a frame. Drawn as the marker line above + /// the bars. + spectrum_peaks: 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, + /// Bar color, gradient and peak color (config `spectrum_color`, + /// `spectrum_gradient`, `spectrum_peak_color`). + spectrum_style: SpectrumStyle, /// Whether the server output is muted. muted: bool, /// The server's output level, `1.0` = 100%. This is the level playback @@ -97,8 +219,9 @@ impl Default for NowPlaying { position: None, track: None, spectrum: Vec::new(), + spectrum_peaks: Vec::new(), spectrum_enabled: true, - spectrum_color: COLOR_RED, + spectrum_style: SpectrumStyle::default(), muted: false, volume: 1.0, } @@ -122,18 +245,31 @@ impl NowPlaying { pub fn update_modifiers(&mut self, mods: &QueueModifiers) { self.modifiers = *mods; } - /// Applies a spectrum frame from the server (already normalized). + /// Applies a spectrum frame from the server (already normalized) and + /// advances the peak hold: a bin at or above its peak raises it + /// instantly, otherwise the peak falls by [`PEAK_DECAY`]. The bin count + /// comes off the wire, so the peaks follow whatever arrives. pub fn update_spectrum(&mut self, bins: Vec) { + self.spectrum_peaks.resize(bins.len(), 0.0); + for (peak, level) in self.spectrum_peaks.iter_mut().zip(&bins) { + let level = level_of(*level); + *peak = if level >= *peak { + level + } else { + (*peak - PEAK_DECAY).max(0.0) + }; + } self.spectrum = bins; } /// Enables/disables the spectrum row (from client config). 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; + /// Sets how the bars are painted (from client config). Parsing the + /// config strings is the caller's job, so an unusable value never + /// reaches here. + pub fn set_spectrum_style(&mut self, style: SpectrumStyle) { + self.spectrum_style = style; } /// Shows or hides the spectrum row (the `v` keybinding). The server /// keeps streaming the bars; this only gates rendering. @@ -287,13 +423,20 @@ impl NowPlaying { } // 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. + // progress, in the configured color, under a peak-hold marker line. + // 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, self.spectrum_color); + let lines = spectrum_lines( + &self.spectrum, + &self.spectrum_peaks, + width, + height, + self.spectrum_style, + ); f.render_widget(Paragraph::new(lines), area); } } @@ -357,13 +500,24 @@ mod tests { is_captured: false, }), spectrum: Vec::new(), + spectrum_peaks: Vec::new(), spectrum_enabled: true, - spectrum_color: COLOR_RED, + spectrum_style: SpectrumStyle::default(), muted: false, volume: 1.0, } } + /// One flat color, no markers: the glyph tests are about the bars' + /// geometry, so they pin the paint down and let the color tests own it. + fn flat() -> SpectrumStyle { + SpectrumStyle { + color: COLOR_RED, + gradient: false, + peak: None, + } + } + fn render(pane: &NowPlaying) { let backend = TestBackend::new(60, 12); let mut terminal = Terminal::new(backend).expect("test terminal"); @@ -391,7 +545,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, COLOR_RED); + let lines = spectrum_lines(&[1.0, 0.0], &[], 2, 4, flat()); assert_eq!(lines.len(), 4, "one line per row"); let text: Vec = lines .iter() @@ -405,7 +559,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, COLOR_RED); + let lines = spectrum_lines(&[0.5], &[], 1, 4, flat()); let col: Vec = lines .iter() .map(|l| l.spans[0].content.chars().next().unwrap()) @@ -438,19 +592,189 @@ mod tests { .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)); + /// Every distinct foreground the full-block cells are drawn in. + fn bar_colors(pane: &NowPlaying) -> Vec { + 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(); + let mut colors: Vec = Vec::new(); + for y in 0..buffer.area.height { + for x in 0..buffer.area.width { + if buffer[(x, y)].symbol() != "█" { + continue; + } + if let Some(fg) = buffer[(x, y)].style().fg { + if !colors.contains(&fg) { + colors.push(fg); + } + } + } + } + colors } #[test] - fn the_bars_take_the_configured_color() { + fn the_default_style_is_the_queue_red_under_a_blue_peak() { + let style = SpectrumStyle::default(); + assert_eq!(style.color, super::super::COLOR_RED); + assert_eq!(style.peak, Some(super::super::COLOR_PRIMARY)); + assert!(style.gradient, "shaded out of the box"); + } + + #[test] + fn flat_bars_are_drawn_in_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)); + pane.set_spectrum_style(SpectrumStyle { + color: Color::Rgb(1, 2, 3), + gradient: false, + peak: None, + }); assert_eq!(first_bar_color(&pane), Some(Color::Rgb(1, 2, 3))); + assert_eq!( + bar_colors(&pane), + vec![Color::Rgb(1, 2, 3)], + "gradient off: one color for the whole bar" + ); + } + + #[test] + fn shaded_bars_use_a_different_color_per_row() { + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![1.0; 24]); + // Full-level bars over several rows, shaded: each row is its own + // shade of the base color. + assert!( + bar_colors(&pane).len() > 1, + "expected a gradient, got {:?}", + bar_colors(&pane) + ); + } + + #[test] + fn the_gradient_runs_dim_at_the_floor_to_bright_at_the_top() { + let base = super::super::COLOR_RED; + let (bottom, top) = (gradient_color(base, 0, 8), gradient_color(base, 7, 8)); + let brightness = |color: Color| match color { + Color::Rgb(r, g, b) => u32::from(r) + u32::from(g) + u32::from(b), + other => panic!("expected rgb, got {other:?}"), + }; + assert!( + brightness(bottom) < brightness(base), + "floor dimmer than the base: {bottom:?}" + ); + assert!( + brightness(top) > brightness(base), + "top brighter than the base: {top:?}" + ); + // Monotonic in between, so the ramp reads as one gradient. + let ramp: Vec = (0..8) + .map(|r| brightness(gradient_color(base, r, 8))) + .collect(); + assert!(ramp.windows(2).all(|w| w[0] <= w[1]), "{ramp:?}"); + // A one-row area is all "top": the single row keeps the full color + // rather than being dimmed to the floor. + assert_eq!(gradient_color(base, 0, 1), gradient_color(base, 7, 8)); + } + + #[test] + fn a_named_or_indexed_color_is_never_shaded() { + // Their RGB belongs to the terminal theme, so there is nothing to + // interpolate — they render flat, whatever `gradient` says. + for color in [Color::LightBlue, Color::Indexed(208), Color::Reset] { + assert_eq!(gradient_color(color, 0, 8), color); + assert_eq!(gradient_color(color, 7, 8), color); + } + } + + #[test] + fn a_held_peak_marks_the_row_above_the_bar() { + // Bar at the floor, peak still at full scale: the marker sits in + // the top row, in the peak color, and the bar keeps its own. + let style = SpectrumStyle { + color: COLOR_RED, + gradient: false, + peak: Some(COLOR_PRIMARY), + }; + let lines = spectrum_lines(&[0.1], &[1.0], 1, 4, style); + let top = &lines[0].spans[0]; + assert_eq!(top.content.as_ref(), PEAK_MARK.to_string()); + assert_eq!(top.style.fg, Some(COLOR_PRIMARY)); + let bottom = &lines[3].spans[0]; + assert_eq!(bottom.style.fg, Some(COLOR_RED), "bar keeps its color"); + } + + #[test] + fn a_peak_inside_the_bar_is_not_drawn() { + // A cell holds one glyph: a marker in the bar's own top cell would + // eat the bar to repeat what its top edge already shows. + let style = SpectrumStyle { + color: COLOR_RED, + gradient: false, + peak: Some(COLOR_PRIMARY), + }; + let lines = spectrum_lines(&[1.0], &[1.0], 1, 4, style); + assert!( + !lines + .iter() + .any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))), + "peak at the bar top must not overwrite it" + ); + } + + #[test] + fn peaks_are_left_out_when_switched_off() { + let lines = spectrum_lines(&[0.1], &[1.0], 1, 4, flat()); + assert!( + !lines + .iter() + .any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))), + "`spectrum_peak_color = \"none\"` draws no markers" + ); + } + + #[test] + fn a_peak_holds_then_falls_to_the_floor() { + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![1.0]); + assert_eq!( + pane.spectrum_peaks, + vec![1.0], + "a hit sets the peak at once" + ); + // Silence afterwards: the peak comes down a step per frame. + pane.update_spectrum(vec![0.0]); + assert!((pane.spectrum_peaks[0] - (1.0 - PEAK_DECAY)).abs() < 1e-6); + for _ in 0..200 { + pane.update_spectrum(vec![0.0]); + } + assert_eq!(pane.spectrum_peaks, vec![0.0], "and reaches the floor"); + } + + #[test] + fn a_peak_follows_a_changed_bin_count() { + // The bin count comes off the wire and may change mid-stream. + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![1.0; 8]); + pane.update_spectrum(vec![0.5; 3]); + assert_eq!(pane.spectrum_peaks.len(), 3); + pane.update_spectrum(vec![0.5; 16]); + assert_eq!(pane.spectrum_peaks.len(), 16); + } + + #[test] + fn a_nonsense_bin_level_reads_as_silence() { + // Bins are floats from a peer: NaN and infinity must not become + // bars, peaks, or a panic. + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0]); + assert_eq!(pane.spectrum_peaks, vec![0.0; 4]); + let rows = rendered_rows(&pane); + assert!( + !rows.iter().any(|r| r.contains('█')), + "nonsense levels must draw nothing: {rows:?}" + ); } #[test] diff --git a/cbd-tui/src/config.rs b/cbd-tui/src/config.rs index 27334a8..c16eaef 100644 --- a/cbd-tui/src/config.rs +++ b/cbd-tui/src/config.rs @@ -6,6 +6,8 @@ use std::{ use crabidy_core::{clap, clap_serde_derive, serde::Serialize, ClapSerde}; use ratatui::style::Color; +use crate::app::SpectrumStyle; + #[derive(ClapSerde, Serialize, Debug)] #[clap(author, version, about)] pub struct Config { @@ -178,30 +180,74 @@ pub struct ServerConfig { /// 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 with a warning — see [`spectrum_style`]. #[default(crate::app::COLOR_RED_HEX.to_string())] #[clap(long)] pub spectrum_color: String, + + /// Shade the bars from dim at the floor to bright at the top, instead + /// of one flat color. Only a hex `spectrum_color` can be shaded: a + /// name or palette index has no RGB value of ours to interpolate, so + /// those stay flat whatever this says. + #[default(true)] + #[clap(long)] + pub spectrum_gradient: bool, + + /// Color of the peak-hold markers riding above the bars, in the same + /// three forms as `spectrum_color`. Defaults to the primary blue; + /// "none" (or "off") draws no markers. + #[default(crate::app::COLOR_PRIMARY_HEX.to_string())] + #[clap(long)] + pub spectrum_peak_color: String, } -/// Resolves [`ServerConfig::spectrum_color`] to a ratatui [`Color`]. +/// Resolves the spectrum options to a [`SpectrumStyle`]. /// /// 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 +/// unparsable color is reported on stderr and the default 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(); +pub fn spectrum_style(config: &Config) -> SpectrumStyle { + SpectrumStyle { + color: color_or_default( + &config.server.spectrum_color, + "spectrum_color", + crate::app::COLOR_RED, + ), + gradient: config.server.spectrum_gradient, + peak: peak_color(&config.server.spectrum_peak_color), + } +} + +/// Parses one color option, falling back to `fallback` with a warning. An +/// empty value is a blank in the file rather than a mistake, so it takes +/// the default silently. +fn color_or_default(raw: &str, key: &str, fallback: Color) -> Color { + let raw = raw.trim(); if raw.is_empty() { - return crate::app::COLOR_RED; + return fallback; } Color::from_str(raw).unwrap_or_else(|_| { - eprintln!("invalid spectrum_color {raw:?}, using the default"); - crate::app::COLOR_RED + eprintln!("invalid {key} {raw:?}, using the default"); + fallback }) } +/// Parses `spectrum_peak_color`, where "none"/"off" mean "draw no markers". +/// Those two words are not colors, so there is no value they shadow. +fn peak_color(raw: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.eq_ignore_ascii_case("none") || trimmed.eq_ignore_ascii_case("off") { + return None; + } + Some(color_or_default( + trimmed, + "spectrum_peak_color", + crate::app::COLOR_PRIMARY, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -247,7 +293,19 @@ mod tests { // 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); + assert_eq!(spectrum_style(&config).color, crate::app::COLOR_RED); + } + + #[test] + fn the_default_peak_color_is_the_primary_blue() { + let config = Config::default(); + assert_eq!( + config.server.spectrum_peak_color, + crate::app::COLOR_PRIMARY_HEX + ); + let style = spectrum_style(&config); + assert_eq!(style.peak, Some(crate::app::COLOR_PRIMARY)); + assert!(style.gradient, "bars are shaded out of the box"); } #[test] @@ -261,7 +319,10 @@ mod tests { (" #010203 ", Color::Rgb(1, 2, 3)), ] { config.server.spectrum_color = raw.to_string(); - assert_eq!(spectrum_color(&config), expected, "parsing {raw:?}"); + assert_eq!(spectrum_style(&config).color, expected, "parsing {raw:?}"); + // The peak color takes the same three forms. + config.server.spectrum_peak_color = raw.to_string(); + assert_eq!(spectrum_style(&config).peak, Some(expected)); } } @@ -272,14 +333,34 @@ mod tests { let mut config = Config::default(); for raw in ["", " ", "puce", "#12345", "300", "#"] { config.server.spectrum_color = raw.to_string(); + config.server.spectrum_peak_color = raw.to_string(); + let style = spectrum_style(&config); + assert_eq!(style.color, crate::app::COLOR_RED, "rejecting {raw:?}"); assert_eq!( - spectrum_color(&config), - crate::app::COLOR_RED, + style.peak, + Some(crate::app::COLOR_PRIMARY), "rejecting {raw:?}" ); } } + #[test] + fn a_peak_color_of_none_switches_the_markers_off() { + // "none" and "off" are not colors, so neither shadows a real value. + let mut config = Config::default(); + for raw in ["none", "NONE", " off ", "Off"] { + config.server.spectrum_peak_color = raw.to_string(); + assert_eq!(spectrum_style(&config).peak, None, "for {raw:?}"); + } + } + + #[test] + fn the_gradient_can_be_switched_off() { + let mut config = Config::default(); + config.server.spectrum_gradient = false; + assert!(!spectrum_style(&config).gradient); + } + #[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 83221f6..dca7c14 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -45,9 +45,9 @@ pub async fn run(config: &'static Config) -> Result<(), Box> { 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); + let spectrum_style = config::spectrum_style(config); tokio::task::spawn_blocking(move || { - run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_color); + run_ui(ui_tx, ui_rx, spectrum_enabled, spectrum_style); }) .await?; @@ -216,7 +216,7 @@ fn run_ui( tx: Sender, rx: Receiver, spectrum_enabled: bool, - spectrum_color: ratatui::style::Color, + spectrum_style: app::SpectrumStyle, ) { // setup terminal enable_raw_mode().unwrap(); @@ -228,7 +228,7 @@ fn run_ui( // 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); + app.now_playing.set_spectrum_style(spectrum_style); 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 20860ca..b47e15d 100644 --- a/docs/src/clients/tui.md +++ b/docs/src/clients/tui.md @@ -156,6 +156,34 @@ 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. +### Shading and peaks + +The bars are **shaded** by height: dim at the floor, brightening toward +the top, so a loud bar reads as hot and not merely tall. Each row gets its +own shade of `spectrum_color`, interpolated from 55% brightness at the +bottom to 30% toward white at the top. Set `spectrum_gradient = false` for +one flat color instead. + +Shading needs a color it can compute with, so it applies only to a +**hex** `spectrum_color`. A name or a palette index is a reference into +your terminal's own theme — its actual RGB value is the terminal's +business, not the client's — so those always render flat, whatever +`spectrum_gradient` says. + +Above the bars ride **peak-hold markers**: a thin rule (`▔`) per column +that jumps to each new maximum and then falls back at about half scale a +second, so you can see what a transient reached after the bar has dropped. +`spectrum_peak_color` sets their color (same three forms), defaulting to +the primary blue so they read as a shadow of the red bars rather than part +of them. `spectrum_peak_color = "none"` (or `"off"`) leaves them out — +also the thing to do if your terminal font lacks `▔` and draws a box. + +A marker is only drawn in a row the bar does not reach: a terminal cell +holds one glyph, so a marker inside the bar's own top cell would eat the +bar to repeat what its top edge already shows. Markers fall away when +playback stops, since the server keeps streaming zeroed bars while the +audio is idle. + ## 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 ba28741..713bfde 100644 --- a/docs/src/config.md +++ b/docs/src/config.md @@ -111,12 +111,22 @@ spectrum = true # 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" + +# Shade the bars from dim at the floor to bright at the top instead of +# one flat color. Only a hex spectrum_color can be shaded — a name or a +# palette index has no RGB value the client may interpolate. +spectrum_gradient = true + +# Color of the peak-hold markers riding above the bars, in the same three +# forms as spectrum_color. "none" (or "off") draws no markers. +spectrum_peak_color = "#81a1c1" ``` -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 +Every option except `spectrum_color`, `spectrum_gradient` and +`spectrum_peak_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 once instead of editing by hand, use the `auth` subcommand (see [Command line](./clients/cli.md)): @@ -132,8 +142,8 @@ plaintext credential, not a hash. Keep the client config file private. The password is never written to logs. ``` -For the client's `spectrum` option in context, see -[The terminal client](./clients/tui.md). +For the client's `spectrum` options in context — shading, and the +peak-hold markers — see [The terminal client](./clients/tui.md). ## Where the server's own data lives diff --git a/plan/summary.md b/plan/summary.md index 3b4ac2a..27489b1 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1668,3 +1668,46 @@ 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. + +## TUI spectrum shading and peak hold (2026-07-27) + +Modelled on BeSpec's LED mode: bars shaded by height, with a peak marker riding +above each one. + +Gradients are possible in a TUI because every cell carries its own foreground. +The useful accident is that shading by *height* makes the color a function of +the row alone, so it costs nothing per column — the paint is decided once per +row and the row's cells still coalesce into a couple of spans. + +`gradient_color` interpolates the configured base from 55% brightness at the +floor to 30% toward white at the top. Only a `Color::Rgb` base can be +interpolated: a color name or a palette index is a reference into the +terminal's own theme, whose RGB value is not the client's to know, so those +render flat whatever `spectrum_gradient` says. Documented rather than +worked around, since guessing an RGB for `red` would defeat the point of +naming it. + +The peak hold is per-bin state in `NowPlaying`, advanced in `update_spectrum`: +a bin at or above its peak raises it instantly, otherwise the peak falls +`PEAK_DECAY` (0.03) a frame — about 1.7s from full scale at the server's 20 fps. +The server keeps streaming zeroed frames while audio is idle, so markers fall +away on pause instead of freezing on screen. Bin count comes off the wire, so +the peaks `resize` to whatever arrives. + +A marker is drawn only in a row the bar does not reach at all. A cell holds one +glyph, so a marker inside the bar's own top cell would replace up to seven +eighths of bar to repeat what the bar's top edge already shows. Skipping it +loses nothing: a peak that close to the bar is not news. + +The three spectrum options (`spectrum_color`, `spectrum_gradient`, +`spectrum_peak_color`) now resolve together into a `SpectrumStyle`, which +replaces `set_spectrum_color`. `spectrum_peak_color = "none"` (or `"off"`) +draws no markers — also the escape hatch for a terminal font without U+2594. +Neither word is a color, so neither shadows a value someone might have meant. + +`level_of` now guards every bin: they are wire floats, so NaN and infinity read +as silence in one place rather than being left to the arithmetic. Neither could +panic before — `clamp` propagates NaN and a NaN float cast saturates to 0 — and +a NaN can never become a held peak, since `level >= *peak` is false for it. The +guard is so the peak comparison and the glyph arithmetic agree on what a +nonsense level means instead of each shrugging it off differently.