diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 75a1cd4..597ffec 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -73,6 +73,10 @@ 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); +/// [`COLOR_SECONDARY`] as a config-file string — the default +/// `spectrum_top_color`. The pair is checked by +/// `the_default_top_color_is_the_secondary_purple`. +pub const COLOR_SECONDARY_HEX: &str = "#b48ead"; 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 diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index 964e075..a8c436f 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -1,4 +1,7 @@ -use std::{ops::Div, time::Duration}; +use std::{ + ops::Div, + time::{Duration, Instant}, +}; #[cfg(feature = "notifications")] use notify_rust::Notification; @@ -18,8 +21,8 @@ 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). +/// The unfilled peak-hold shadow: a thin rule at the peak, 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 @@ -27,35 +30,57 @@ const PEAK_MARK: char = '▔'; /// 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; +/// Brightness of a divider column, as a fraction of the bar it belongs to. +/// The divider carries its bar's own shape at this shade, so the bars stay +/// one connected field with a seam between them rather than becoming +/// separate sticks with holes in between. +const DIVIDER_DIM: f32 = 0.35; -/// 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; +/// Shortest `peak_fall` that still means "falls": below this the shadows +/// would be gone before the next frame, which is what +/// `spectrum_peak_color = "none"` is for. +const MIN_PEAK_FALL: f32 = 0.05; /// 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. + /// Bar color: the gradient's floor, and the whole bar without one. pub color: Color, - /// Shade the bars from dim at the floor to bright at the top. + /// Color the gradient reaches at the top of the area, or `None` to + /// ramp brightness alone. + pub top: Option, + /// Shade the bars by height instead of one flat color. pub gradient: bool, - /// Peak-hold marker color, or `None` to draw no markers. + /// Peak-hold shadow color, or `None` to draw no shadows. pub peak: Option, + /// Fill the shadow from the bar up to its peak, rather than drawing a + /// thin rule at the peak alone. + pub peak_fill: bool, + /// Seconds a full-scale peak takes to fall to the floor. + pub peak_fall: f32, + /// Least bar width in cells; a bar takes any spare columns of its slot + /// beyond this, so the field stays connected. + pub bar_width: usize, + /// Width of the seam dividing two bars, in cells. + pub bar_gap: usize, + /// Height of the line dividing two value rows, in eighths of a row. + /// 0 stacks the rows into a solid bar. + pub row_gap: usize, } impl Default for SpectrumStyle { fn default() -> Self { SpectrumStyle { color: COLOR_RED, + top: Some(COLOR_SECONDARY), gradient: true, peak: Some(COLOR_PRIMARY), + peak_fill: true, + peak_fall: 4.0, + bar_width: 1, + bar_gap: 1, + row_gap: 1, } } } @@ -71,49 +96,158 @@ fn level_of(raw: f32) -> f32 { } } -/// 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; +/// What one column of the spectrum area shows: a bar's level and the peak +/// held above it, drawn either as the bar itself or as the dim seam that +/// divides it from its neighbour. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Column { + level: f32, + peak: f32, + /// This column is the seam at the right of its bar. + divider: bool, +} + +/// Assigns every column of the area to a bar, marking those that are its +/// divider. +/// +/// A bar fills its slot except for the last `style.bar_gap` columns, and is +/// never narrower than `style.bar_width`, so spare columns widen the bars +/// rather than the seams. There is one bar per bin unless the area is too +/// narrow to hold that many — then a bar covers a group of neighbouring bins +/// and takes the loudest of them, because a spectrum's news is its peaks and +/// an average would flatten them. +fn spectrum_columns( + bins: &[f32], + peaks: &[f32], + width: usize, + style: SpectrumStyle, +) -> Vec { + let bar_width = style.bar_width.max(1); + let pitch = bar_width + style.bar_gap; + // Never more bars than bins (they would repeat a neighbour), never + // fewer than one (a very narrow pane still shows something). + let bars = (width / pitch.max(1)).clamp(1, bins.len().max(1)); + // Loudest level and peak over a bar's share of the bins. + let loudest = |slice: &[f32]| slice.iter().copied().map(level_of).fold(0.0, f32::max); + // The first column of bar `n`, so that it inverts the column-to-bar + // mapping below exactly: rounding the two the same way instead would + // put a column outside the slot it belongs to. + let first_col = |bar: usize| (bar * width).div_ceil(bars); + (0..width) + .map(|col| { + let bar = (col * bars / width.max(1)).min(bars - 1); + let start = first_col(bar); + let span = first_col(bar + 1).min(width) - start; + // The bar leads its slot and the seam trails it. A slot with no + // room for both keeps the bar: a seam that leaves no bar shows + // nothing at all. + let bar_cells = bar_width.max(span.saturating_sub(style.bar_gap)).min(span); + let lo = bar * bins.len() / bars; + let hi = ((bar + 1) * bins.len() / bars).max(lo + 1).min(bins.len()); + Column { + level: loudest(bins.get(lo..hi).unwrap_or_default()), + peak: loudest(peaks.get(lo..hi).unwrap_or_default()), + divider: col - start >= bar_cells, + } + }) + .collect() +} + +/// Eighths of the cell in row `from_bottom` that a bar at `level` fills: 0 +/// for a row the bar does not reach, and at most `8 - row_gap` for one it +/// fills completely — the unfilled eighths at the top of the cell are the +/// line dividing this value row from the one above. +fn cell_eighths(level: f32, height: usize, from_bottom: usize, row_gap: usize) -> usize { + let eighths = (level * height as f32 * 8.0).round() as usize; + eighths + .saturating_sub(from_bottom * 8) + .min(8usize.saturating_sub(row_gap)) +} + +/// The glyph a peak shadow puts in row `from_bottom`, if any: filled from +/// the row above the bar up to the peak, or — unfilled — a thin rule in the +/// single row the peak reaches. +fn shadow_glyph( + peak: f32, + height: usize, + from_bottom: usize, + style: SpectrumStyle, +) -> Option { + let eighths = (level_of(peak) * height as f32 * 8.0).round() as usize; + if eighths == 0 { + return None; } - (col * bins / width.max(1)).min(bins - 1) + if style.peak_fill { + match cell_eighths(peak, height, from_bottom, style.row_gap) { + 0 => None, + cell => Some(BLOCKS[cell]), + } + } else if (eighths - 1) / 8 == from_bottom { + Some(PEAK_MARK) + } else { + None + } +} + +/// `color` at `factor` of its brightness, for the seam between two bars. +/// +/// Only an `Rgb` color can be dimmed — a name or a palette index is the +/// terminal's to resolve — so those return `None` and the seam falls back to +/// an empty column, the one divider that needs no color. +fn dimmed(color: Color, factor: f32) -> Option { + let Color::Rgb(r, g, b) = color else { + return None; + }; + let dim = |channel: u8| (f32::from(channel) * factor).round().clamp(0.0, 255.0) as u8; + Some(Color::Rgb(dim(r), dim(g), dim(b))) } /// 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. +/// the base dimmed to [`GRADIENT_FLOOR`] at the bottom, interpolated to +/// `top` (or the undimmed base, without one) at the very top. /// -/// Only an `Rgb` base can be interpolated. A color name or a palette index +/// Only `Rgb` colors 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 { +/// to know — so an `Rgb` base with a named `top` ramps brightness alone, +/// and a named base renders flat. +fn gradient_color(base: Color, top: Option, from_bottom: usize, height: usize) -> Color { + let Color::Rgb(base_r, base_g, base_b) = base else { return base; }; + let (top_r, top_g, top_b) = match top { + Some(Color::Rgb(r, g, b)) => (r, g, b), + _ => (base_r, base_g, base_b), + }; 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; + let ramp = |base: u8, top: u8| { + let low = f32::from(base) * GRADIENT_FLOOR; + let high = f32::from(top); (low + (high - low) * t).round().clamp(0.0, 255.0) as u8 }; - Color::Rgb(ramp(r), ramp(g), ramp(b)) + Color::Rgb( + ramp(base_r, top_r), + ramp(base_g, top_g), + ramp(base_b, top_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. +/// Renders `bins` as vertical bars filling a `width`×`height` area: one +/// `Line` per row, top row first. A bar's level in `[0, 1]` fills from the +/// bottom, using partial block glyphs for the topmost fractional cell. +/// Pure, so it is unit-tested. /// -/// `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. +/// `peaks` holds the decaying per-bin maxima (see +/// [`NowPlaying::update_spectrum`]). Where a bar has fallen below its peak +/// the gap between the two is filled in the peak color, so each bar trails +/// a shadow of where it just was. +/// +/// The bars form one field: neighbours are told apart by a dimmed seam +/// column, and value rows by leaving the top `style.row_gap` eighths of each +/// cell unlit. fn spectrum_lines( bins: &[f32], peaks: &[f32], @@ -121,33 +255,49 @@ fn spectrum_lines( height: usize, style: SpectrumStyle, ) -> Vec> { + let columns = spectrum_columns(bins, peaks, width, style); (0..height) .map(|row| { // Row 0 is the top; count cells up from the bottom. let from_bottom = height - 1 - row; let bar_color = if style.gradient { - gradient_color(style.color, from_bottom, height) + gradient_color(style.color, style.top, 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), + let seam_color = dimmed(bar_color, DIVIDER_DIM); + for column in &columns { + // A seam shows its own bar, dimmed. Where the color cannot + // be dimmed the seam is an empty column instead. + let (bar_color, peak_color) = match (column.divider, seam_color) { + (false, _) => (Some(bar_color), style.peak), + (true, Some(seam)) => { + (Some(seam), style.peak.and_then(|c| dimmed(c, DIVIDER_DIM))) + } + (true, None) => (None, None), + }; + let (glyph, color) = match bar_color { + None => (' ', style.color), + Some(bar_color) => { + let cell = cell_eighths(column.level, height, from_bottom, style.row_gap); + // The bar itself, else its shadow reaching up to + // the held peak, else nothing. A cell holds one + // glyph, so the bar always wins its own rows: a + // shadow inside them would eat bar to repeat what + // the bar's top edge already shows. + match (cell, peak_color) { + (0, Some(peak_color)) => { + match shadow_glyph(column.peak, height, from_bottom, style) { + Some(glyph) => (glyph, peak_color), + None => (' ', bar_color), + } + } + _ => (BLOCKS[cell], bar_color), + } + } }; match runs.last_mut() { Some((run_color, text)) if *run_color == color => text.push(glyph), @@ -195,9 +345,11 @@ pub struct NowPlaying { /// 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. + /// falling at the configured rate. Drawn as the shadow above the bars. spectrum_peaks: Vec, + /// When the last spectrum frame arrived, so the peaks fall in seconds + /// rather than in frames of whatever rate the server happens to send. + spectrum_frame_at: Option, /// Whether to draw the spectrum row (config `spectrum`, default on). spectrum_enabled: bool, /// Bar color, gradient and peak color (config `spectrum_color`, @@ -220,6 +372,7 @@ impl Default for NowPlaying { track: None, spectrum: Vec::new(), spectrum_peaks: Vec::new(), + spectrum_frame_at: None, spectrum_enabled: true, spectrum_style: SpectrumStyle::default(), muted: false, @@ -245,18 +398,35 @@ impl NowPlaying { pub fn update_modifiers(&mut self, mods: &QueueModifiers) { self.modifiers = *mods; } - /// 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. + /// Applies a spectrum frame from the server (already normalized), + /// timing it against the previous frame so the peaks fall at the + /// configured rate whatever the server's frame rate is. pub fn update_spectrum(&mut self, bins: Vec) { + let now = Instant::now(); + let elapsed = self + .spectrum_frame_at + .replace(now) + .map_or(Duration::ZERO, |previous| { + now.saturating_duration_since(previous) + }); + self.advance_spectrum(bins, elapsed); + } + + /// [`NowPlaying::update_spectrum`] with the frame interval given rather + /// than measured — the testable core. + /// + /// A bin at or above its peak raises it at once; otherwise the peak + /// falls by whatever share of `peak_fall` this frame took. The bin count + /// comes off the wire, so the peaks follow whatever arrives. + fn advance_spectrum(&mut self, bins: Vec, elapsed: Duration) { + let fall = elapsed.as_secs_f32() / self.spectrum_style.peak_fall.max(MIN_PEAK_FALL); 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) + (*peak - fall).max(0.0) }; } self.spectrum = bins; @@ -501,6 +671,7 @@ mod tests { }), spectrum: Vec::new(), spectrum_peaks: Vec::new(), + spectrum_frame_at: None, spectrum_enabled: true, spectrum_style: SpectrumStyle::default(), muted: false, @@ -513,11 +684,26 @@ mod tests { fn flat() -> SpectrumStyle { SpectrumStyle { color: COLOR_RED, + top: None, gradient: false, peak: None, + peak_fill: true, + peak_fall: 4.0, + // One solid cell per bin, no seams and no row lines: the + // geometry tests are about the bars' height, and the layout + // tests own the widths and the dividers. + bar_width: 1, + bar_gap: 0, + row_gap: 0, } } + /// The glyph a completely filled row uses under `style` — `█` only when + /// no line divides the value rows. + fn full_cell(style: SpectrumStyle) -> char { + BLOCKS[8 - style.row_gap] + } + fn render(pane: &NowPlaying) { let backend = TestBackend::new(60, 12); let mut terminal = Terminal::new(backend).expect("test terminal"); @@ -574,8 +760,9 @@ mod tests { let mut pane = now_playing(10_000, 60_000); pane.update_spectrum(vec![1.0; 24]); let rows = rendered_rows(&pane); - // Full-level bars fill several rows with the full block. - let full_rows = rows.iter().filter(|r| r.contains('█')).count(); + // Full-level bars fill several rows to the row gap. + let filled = full_cell(SpectrumStyle::default()); + let full_rows = rows.iter().filter(|r| r.contains(filled)).count(); assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}"); } @@ -586,9 +773,10 @@ mod tests { 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 filled = full_cell(pane.spectrum_style).to_string(); (0..buffer.area.height) .flat_map(|y| (0..buffer.area.width).map(move |x| (x, y))) - .find(|&(x, y)| buffer[(x, y)].symbol() == "█") + .find(|&(x, y)| buffer[(x, y)].symbol() == filled) .and_then(|(x, y)| buffer[(x, y)].style().fg) } @@ -598,10 +786,11 @@ mod tests { 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 filled = full_cell(pane.spectrum_style).to_string(); 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() != "█" { + if buffer[(x, y)].symbol() != filled { continue; } if let Some(fg) = buffer[(x, y)].style().fg { @@ -618,8 +807,16 @@ mod tests { 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.top, Some(super::super::COLOR_SECONDARY)); assert_eq!(style.peak, Some(super::super::COLOR_PRIMARY)); assert!(style.gradient, "shaded out of the box"); + assert!(style.peak_fill, "shadows filled out of the box"); + assert_eq!( + (style.bar_width, style.bar_gap, style.row_gap), + (1, 1, 1), + "connected bars, a seam between them, a line between rows" + ); + assert_eq!(style.peak_fall, 4.0); } #[test] @@ -628,8 +825,7 @@ mod tests { pane.update_spectrum(vec![1.0; 24]); pane.set_spectrum_style(SpectrumStyle { color: Color::Rgb(1, 2, 3), - gradient: false, - peak: None, + ..flat() }); assert_eq!(first_bar_color(&pane), Some(Color::Rgb(1, 2, 3))); assert_eq!( @@ -652,30 +848,50 @@ mod tests { ); } + /// Channel sum, as a stand-in for how bright a color reads. + fn brightness(color: Color) -> u32 { + match color { + Color::Rgb(r, g, b) => u32::from(r) + u32::from(g) + u32::from(b), + other => panic!("expected rgb, got {other:?}"), + } + } + #[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:?}"), - }; + let bottom = gradient_color(base, None, 0, 8); + let top = gradient_color(base, None, 7, 8); assert!( brightness(bottom) < brightness(base), "floor dimmer than the base: {bottom:?}" ); - assert!( - brightness(top) > brightness(base), - "top brighter than the base: {top:?}" - ); + // Without a top color the ramp is brightness alone, so the top row + // is the base itself rather than something hotter. + assert_eq!(top, base, "top of a topless ramp is the base"); // Monotonic in between, so the ramp reads as one gradient. let ramp: Vec = (0..8) - .map(|r| brightness(gradient_color(base, r, 8))) + .map(|r| brightness(gradient_color(base, None, 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)); + assert_eq!(gradient_color(base, None, 0, 1), base); + } + + #[test] + fn the_top_color_is_where_the_gradient_ends() { + // Red at the floor reaching the secondary purple at the top. + let (base, top) = (super::super::COLOR_RED, super::super::COLOR_SECONDARY); + assert_eq!(gradient_color(base, Some(top), 7, 8), top); + assert!( + brightness(gradient_color(base, Some(top), 0, 8)) < brightness(base), + "the floor is still dimmed" + ); + // Mid-way is neither end but lies between them on every channel. + let middle = gradient_color(base, Some(top), 4, 8); + assert_ne!(middle, base); + assert_ne!(middle, top); + assert!(brightness(middle) < brightness(top)); } #[test] @@ -683,75 +899,305 @@ mod tests { // 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); + assert_eq!(gradient_color(color, None, 0, 8), color); + assert_eq!(gradient_color(color, Some(COLOR_RED), 7, 8), color); + } + // A named *top* cannot be interpolated either, so the ramp falls + // back to brightness alone rather than guessing its RGB. + let base = super::super::COLOR_RED; + assert_eq!( + gradient_color(base, Some(Color::LightBlue), 7, 8), + gradient_color(base, None, 7, 8) + ); + } + + /// The single column of a one-wide render, bottom row first, as + /// (glyph, color) pairs. + fn column_of(lines: &[Line<'static>]) -> Vec<(char, Option)> { + lines + .iter() + .rev() + .map(|line| { + let span = &line.spans[0]; + (span.content.chars().next().expect("a cell"), span.style.fg) + }) + .collect() + } + + #[test] + fn a_filled_shadow_reaches_from_the_bar_up_to_the_peak() { + // Bar at an eighth of the area, peak still at full scale: the rows + // between are filled in the peak color, the bar keeps its own. + let style = SpectrumStyle { + peak: Some(COLOR_PRIMARY), + ..flat() + }; + let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style)); + assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor"); + for (row, cell) in column.iter().enumerate().skip(1) { + assert_eq!( + *cell, + ('█', Some(COLOR_PRIMARY)), + "row {row} is filled shadow" + ); } } #[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. + fn an_unfilled_shadow_is_a_rule_at_the_peak_alone() { let style = SpectrumStyle { - color: COLOR_RED, - gradient: false, peak: Some(COLOR_PRIMARY), + peak_fill: false, + ..flat() }; - 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"); + let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style)); + assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor"); + assert_eq!(column[1].0, ' ', "nothing under the rule"); + assert_eq!(column[2].0, ' ', "nothing under the rule"); + assert_eq!(column[3], (PEAK_MARK, Some(COLOR_PRIMARY)), "the rule"); } #[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); + fn a_shadow_inside_the_bar_is_not_drawn() { + // A cell holds one glyph: a shadow in a row the bar occupies would + // eat bar to repeat what its top edge already shows. + for peak_fill in [true, false] { + let style = SpectrumStyle { + peak: Some(COLOR_PRIMARY), + peak_fill, + ..flat() + }; + let lines = spectrum_lines(&[1.0], &[1.0], 1, 4, style); + assert!( + lines + .iter() + .all(|l| l.spans.iter().all(|s| s.style.fg == Some(COLOR_RED))), + "a peak at the bar top must not overwrite it (fill: {peak_fill})" + ); + } + } + + #[test] + fn shadows_are_left_out_when_switched_off() { + // `spectrum_peak_color = "none"`: the rows above the bar stay empty. + let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, flat())); + assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor"); assert!( - !lines - .iter() - .any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))), - "peak at the bar top must not overwrite it" + column[1..].iter().all(|(glyph, _)| *glyph == ' '), + "no shadow: {column:?}" + ); + } + + /// A single rendered row as (glyph, color) per column. + fn row_of(lines: &[Line<'static>], row: usize) -> Vec<(char, Option)> { + lines[row] + .spans + .iter() + .flat_map(|span| span.content.chars().map(move |c| (c, span.style.fg))) + .collect() + } + + #[test] + fn a_seam_divides_the_bars_without_separating_them() { + // Four bins across twelve columns: each bar takes its slot but its + // last column, which carries the same glyph dimmed. The field stays + // connected — no column is empty where a bar stands. + let style = SpectrumStyle { + bar_gap: 1, + ..flat() + }; + let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims"); + let row = row_of(&spectrum_lines(&[1.0; 4], &[], 12, 1, style), 0); + assert!(row.iter().all(|(glyph, _)| *glyph == '█'), "{row:?}"); + let colors: Vec> = row.iter().map(|(_, color)| *color).collect(); + assert_eq!( + colors, + vec![ + Some(COLOR_RED), + Some(COLOR_RED), + Some(seam), + Some(COLOR_RED), + Some(COLOR_RED), + Some(seam), + Some(COLOR_RED), + Some(COLOR_RED), + Some(seam), + Some(COLOR_RED), + Some(COLOR_RED), + Some(seam), + ] ); } #[test] - fn peaks_are_left_out_when_switched_off() { - let lines = spectrum_lines(&[0.1], &[1.0], 1, 4, flat()); + fn bars_take_the_spare_columns_rather_than_the_seams() { + // `bar_width` is a floor, not a width: two bins over nine columns + // give bars of three with a one-cell seam, not bars of one. + let style = SpectrumStyle { + bar_width: 1, + bar_gap: 1, + ..flat() + }; + let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims"); + let row = row_of(&spectrum_lines(&[1.0; 2], &[], 8, 1, style), 0); + let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count(); + assert_eq!(seams, 2, "one seam per bar: {row:?}"); + } + + #[test] + fn a_wider_seam_takes_more_columns() { + let style = SpectrumStyle { + bar_gap: 3, + ..flat() + }; + let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims"); + let row = row_of(&spectrum_lines(&[1.0; 3], &[], 12, 1, style), 0); + let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count(); + assert_eq!(seams, 9, "three cells of seam per bar: {row:?}"); + } + + #[test] + fn bars_never_outnumber_the_bins() { + // A wide pane and few bins: bars would otherwise repeat their + // neighbours, which reads as data that is not there. + let style = SpectrumStyle { + bar_gap: 1, + ..flat() + }; + let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims"); + let row = row_of(&spectrum_lines(&[1.0; 3], &[], 40, 1, style), 0); + let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count(); + assert_eq!(seams, 3, "three bars, three seams: {row:?}"); + } + + #[test] + fn a_bar_too_wide_for_its_slot_keeps_its_cell() { + // Bars wider than the pane can give them: every column is bar, and + // the seam goes rather than the bar. + let style = SpectrumStyle { + bar_width: 40, + bar_gap: 8, + ..flat() + }; + let row = row_of(&spectrum_lines(&[1.0, 0.0], &[], 4, 1, style), 0); + assert_eq!(row.len(), 4); assert!( - !lines - .iter() - .any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))), - "`spectrum_peak_color = \"none\"` draws no markers" + row.iter() + .any(|(glyph, color)| *glyph == '█' && *color == Some(COLOR_RED)), + "still draws a bar: {row:?}" ); } #[test] - fn a_peak_holds_then_falls_to_the_floor() { + fn a_seam_cannot_be_dimmed_from_a_named_color_so_it_empties() { + // A palette color has no RGB of ours to dim, so the seam falls back + // to the one divider that needs no color. + let style = SpectrumStyle { + color: Color::LightBlue, + bar_gap: 1, + ..flat() + }; + let row = row_of(&spectrum_lines(&[1.0; 2], &[], 6, 1, style), 0); + let blanks = row.iter().filter(|(glyph, _)| *glyph == ' ').count(); + assert_eq!(blanks, 2, "one empty seam per bar: {row:?}"); + } + + #[test] + fn a_row_gap_leaves_the_top_of_each_cell_dark() { + // The line between value rows: a full row stops short of the cell + // top by the gap, so the segments of a bar can be told apart. + for (row_gap, expected) in [(0, '█'), (1, '▇'), (2, '▆'), (4, '▄')] { + let style = SpectrumStyle { row_gap, ..flat() }; + let row = row_of(&spectrum_lines(&[1.0], &[], 1, 4, style), 0); + assert_eq!(row[0].0, expected, "row_gap {row_gap}"); + } + } + + #[test] + fn a_row_gap_lowers_a_shadow_the_same_way() { + // Shadows are bars too: a solid shadow keeps the same line between + // rows, or it would read as a different kind of thing. + let style = SpectrumStyle { + peak: Some(COLOR_PRIMARY), + row_gap: 1, + ..flat() + }; + let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style)); + assert_eq!(column[0], ('▇', Some(COLOR_RED)), "bar at the floor"); + assert_eq!(column[3], ('▇', Some(COLOR_PRIMARY)), "shadow on top"); + } + + #[test] + fn a_narrow_pane_folds_bins_together_by_their_loudest() { + // Two bins to one bar: the loud one must survive, since a spectrum's + // news is its peaks. + let lines = spectrum_lines(&[0.0, 1.0], &[], 1, 4, flat()); + let column = column_of(&lines); + assert!( + column.iter().all(|(glyph, _)| *glyph == '█'), + "the loud bin wins: {column:?}" + ); + } + + #[test] + fn a_peak_holds_then_falls_over_the_configured_seconds() { let mut pane = now_playing(10_000, 60_000); - pane.update_spectrum(vec![1.0]); + pane.set_spectrum_style(SpectrumStyle { + peak_fall: 2.0, + ..SpectrumStyle::default() + }); + pane.advance_spectrum(vec![1.0], Duration::ZERO); 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]); - } + // Silence afterwards: half the fall time takes it half way down. + pane.advance_spectrum(vec![0.0], Duration::from_secs(1)); + assert!( + (pane.spectrum_peaks[0] - 0.5).abs() < 1e-6, + "{:?}", + pane.spectrum_peaks + ); + pane.advance_spectrum(vec![0.0], Duration::from_secs(1)); assert_eq!(pane.spectrum_peaks, vec![0.0], "and reaches the floor"); } + #[test] + fn the_fall_is_paced_in_seconds_not_frames() { + // The server's frame rate is not ours to rely on: twenty frames of + // a twentieth of a second must fall as far as one frame of a second. + let fall_after = |frames: u32, each: Duration| { + let mut pane = now_playing(10_000, 60_000); + pane.set_spectrum_style(SpectrumStyle { + peak_fall: 4.0, + ..SpectrumStyle::default() + }); + pane.advance_spectrum(vec![1.0], Duration::ZERO); + for _ in 0..frames { + pane.advance_spectrum(vec![0.0], each); + } + pane.spectrum_peaks[0] + }; + let many = fall_after(20, Duration::from_millis(50)); + let one = fall_after(1, Duration::from_secs(1)); + assert!((many - one).abs() < 1e-5, "{many} vs {one}"); + } + + #[test] + fn an_unusable_fall_time_still_falls() { + // `peak_fall` is clamped in config, but the renderer is not entitled + // to assume that: a zero would divide by zero and freeze the peaks. + let mut pane = now_playing(10_000, 60_000); + pane.set_spectrum_style(SpectrumStyle { + peak_fall: 0.0, + ..SpectrumStyle::default() + }); + pane.advance_spectrum(vec![1.0], Duration::ZERO); + pane.advance_spectrum(vec![0.0], Duration::from_millis(100)); + assert_eq!(pane.spectrum_peaks, vec![0.0]); + } + #[test] fn a_peak_follows_a_changed_bin_count() { // The bin count comes off the wire and may change mid-stream. @@ -772,7 +1218,9 @@ mod tests { assert_eq!(pane.spectrum_peaks, vec![0.0; 4]); let rows = rendered_rows(&pane); assert!( - !rows.iter().any(|r| r.contains('█')), + !rows + .iter() + .any(|r| r.contains(full_cell(pane.spectrum_style))), "nonsense levels must draw nothing: {rows:?}" ); } @@ -784,7 +1232,9 @@ mod tests { pane.update_spectrum(vec![1.0; 24]); let rows = rendered_rows(&pane); assert!( - !rows.iter().any(|r| r.contains('█')), + !rows + .iter() + .any(|r| r.contains(full_cell(pane.spectrum_style))), "disabled spectrum must not draw bars: {rows:?}" ); } @@ -793,13 +1243,14 @@ mod tests { fn toggle_spectrum_hides_then_shows_the_bars() { let mut pane = now_playing(10_000, 60_000); pane.update_spectrum(vec![1.0; 24]); - assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); - // `v` hides the bars… + let filled = full_cell(pane.spectrum_style); + assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled))); + // `f` hides the bars… pane.toggle_spectrum(); - assert!(!rendered_rows(&pane).iter().any(|r| r.contains('█'))); + assert!(!rendered_rows(&pane).iter().any(|r| r.contains(filled))); // …and again brings them back. pane.toggle_spectrum(); - assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); + assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled))); } #[test] diff --git a/cbd-tui/src/config.rs b/cbd-tui/src/config.rs index c16eaef..51c05be 100644 --- a/cbd-tui/src/config.rs +++ b/cbd-tui/src/config.rs @@ -193,12 +193,52 @@ pub struct ServerConfig { #[clap(long)] pub spectrum_gradient: bool, - /// Color of the peak-hold markers riding above the bars, in the same + /// Color the shading reaches at the top of the bars, in the same three + /// forms as `spectrum_color`. Defaults to the secondary purple; + /// "none" (or "off") shades brightness alone, keeping one hue. + #[default(crate::app::COLOR_SECONDARY_HEX.to_string())] + #[clap(long)] + pub spectrum_top_color: String, + + /// Color of the peak-hold shadows trailing above the bars, in the same /// three forms as `spectrum_color`. Defaults to the primary blue; - /// "none" (or "off") draws no markers. + /// "none" (or "off") draws no shadows. #[default(crate::app::COLOR_PRIMARY_HEX.to_string())] #[clap(long)] pub spectrum_peak_color: String, + + /// Fill the shadow from the bar up to its held peak. Set false for a + /// thin rule at the peak alone, leaving the space below it empty. + #[default(true)] + #[clap(long)] + pub spectrum_peak_fill: bool, + + /// Seconds a full-scale shadow takes to fall to the floor, 0.05-60 + /// (out of range is clamped with a warning). Higher lingers longer. + #[default(4.0)] + #[clap(long)] + pub spectrum_peak_fall: f32, + + /// Least bar width in terminal cells, 1-16 (out of range is clamped + /// with a warning). Bars take any spare columns beyond this, so raise + /// it only to force wider bars at the cost of showing fewer bands. + #[default(1)] + #[clap(long)] + pub spectrum_bar_width: usize, + + /// Width of the seam dividing two bars, in cells, 0-8 (out of range is + /// clamped with a warning). The seam is the bar itself dimmed, so the + /// bars stay one connected field; 0 removes it. + #[default(1)] + #[clap(long)] + pub spectrum_bar_gap: usize, + + /// Height of the line dividing two value rows, in eighths of a row, + /// 0-4 (out of range is clamped with a warning). This is what makes the + /// individual segments of a bar visible; 0 stacks them solid. + #[default(1)] + #[clap(long)] + pub spectrum_row_gap: usize, } /// Resolves the spectrum options to a [`SpectrumStyle`]. @@ -215,8 +255,32 @@ pub fn spectrum_style(config: &Config) -> SpectrumStyle { "spectrum_color", crate::app::COLOR_RED, ), + top: optional_color( + &config.server.spectrum_top_color, + "spectrum_top_color", + crate::app::COLOR_SECONDARY, + ), gradient: config.server.spectrum_gradient, - peak: peak_color(&config.server.spectrum_peak_color), + peak: optional_color( + &config.server.spectrum_peak_color, + "spectrum_peak_color", + crate::app::COLOR_PRIMARY, + ), + peak_fill: config.server.spectrum_peak_fill, + peak_fall: seconds( + config.server.spectrum_peak_fall, + "spectrum_peak_fall", + 0.05, + 60.0, + ), + bar_width: cells( + config.server.spectrum_bar_width, + "spectrum_bar_width", + 1, + 16, + ), + bar_gap: cells(config.server.spectrum_bar_gap, "spectrum_bar_gap", 0, 8), + row_gap: cells(config.server.spectrum_row_gap, "spectrum_row_gap", 0, 4), } } @@ -234,18 +298,39 @@ fn color_or_default(raw: &str, key: &str, fallback: Color) -> Color { }) } -/// 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 { +/// Parses a color option that can be switched off, where "none"/"off" mean +/// "leave this out". Neither word is a color, so there is no value they +/// shadow. +fn optional_color(raw: &str, key: &str, fallback: Color) -> 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, - )) + Some(color_or_default(trimmed, key, fallback)) +} + +/// Clamps a cell-count option into `min..=max`, warning when it was out of +/// range. A width of a thousand cells is a typo, not an instruction, and +/// clamping keeps a typo from emptying the pane. +fn cells(value: usize, key: &str, min: usize, max: usize) -> usize { + if value < min || value > max { + eprintln!("{key} {value} is outside {min}-{max}, clamping"); + } + value.clamp(min, max) +} + +/// Clamps a duration option into `min..=max` seconds, warning when it was +/// out of range. A TOML float can also be NaN or infinite, which no clamp +/// would fix, so those take `min`. +fn seconds(value: f32, key: &str, min: f32, max: f32) -> f32 { + if !value.is_finite() { + eprintln!("{key} {value} is not a number of seconds, using {min}"); + return min; + } + if value < min || value > max { + eprintln!("{key} {value} is outside {min}-{max}, clamping"); + } + value.clamp(min, max) } #[cfg(test)] @@ -354,6 +439,72 @@ mod tests { } } + #[test] + fn the_default_top_color_is_the_secondary_purple() { + let config = Config::default(); + assert_eq!( + config.server.spectrum_top_color, + crate::app::COLOR_SECONDARY_HEX + ); + assert_eq!( + spectrum_style(&config).top, + Some(crate::app::COLOR_SECONDARY) + ); + } + + #[test] + fn a_top_color_of_none_shades_brightness_alone() { + let mut config = Config::default(); + for raw in ["none", "OFF"] { + config.server.spectrum_top_color = raw.to_string(); + assert_eq!(spectrum_style(&config).top, None, "for {raw:?}"); + } + } + + #[test] + fn the_default_layout_is_connected_bars_with_thin_dividers() { + let style = spectrum_style(&Config::default()); + assert_eq!(style.bar_width, 1, "bars take the spare columns"); + assert_eq!(style.bar_gap, 1, "a one-cell seam"); + assert_eq!(style.row_gap, 1, "an eighth of a row between segments"); + assert!(style.peak_fill, "shadows filled"); + assert_eq!(style.peak_fall, 4.0); + } + + #[test] + fn the_widths_and_the_fall_are_clamped_not_obeyed() { + // A config file is user input: a nonsense width must not empty the + // pane, and a nonsense fall time must not freeze or divide by zero. + let mut config = Config::default(); + config.server.spectrum_bar_width = 0; + config.server.spectrum_bar_gap = 999; + config.server.spectrum_row_gap = 8; + config.server.spectrum_peak_fall = 0.0; + let style = spectrum_style(&config); + assert_eq!(style.bar_width, 1); + assert_eq!(style.bar_gap, 8); + assert_eq!(style.row_gap, 4, "a whole cell of gap would draw nothing"); + assert_eq!(style.peak_fall, 0.05); + // The other end, and a float that is no duration at all. + config.server.spectrum_bar_width = 999; + config.server.spectrum_peak_fall = f32::NAN; + let style = spectrum_style(&config); + assert_eq!(style.bar_width, 16); + assert_eq!(style.peak_fall, 0.05); + config.server.spectrum_peak_fall = 900.0; + assert_eq!(spectrum_style(&config).peak_fall, 60.0); + } + + #[test] + fn the_shadow_fill_and_fall_come_from_the_file() { + let mut config = Config::default(); + config.server.spectrum_peak_fill = false; + config.server.spectrum_peak_fall = 1.5; + let style = spectrum_style(&config); + assert!(!style.peak_fill); + assert_eq!(style.peak_fall, 1.5); + } + #[test] fn the_gradient_can_be_switched_off() { let mut config = Config::default(); diff --git a/docs/src/clients/tui.md b/docs/src/clients/tui.md index b47e15d..e706d60 100644 --- a/docs/src/clients/tui.md +++ b/docs/src/clients/tui.md @@ -156,33 +156,61 @@ 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 +### Shading -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. +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, interpolated from 55% of `spectrum_color` at the bottom to +`spectrum_top_color` — the secondary purple — at the very top. Set +`spectrum_top_color = "none"` to ramp brightness alone and keep one hue, or +`spectrum_gradient = false` for one flat color. -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. +Shading needs colors it can compute with, so it applies only to **hex** +values. 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 a named `spectrum_color` renders flat whatever `spectrum_gradient` says, +and a named `spectrum_top_color` is ignored in favour of a brightness ramp. -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. +### Segments and seams -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. +The bars form one connected field, divided two ways so the individual +segments stand out: + +- **Between bars**, a seam: the last `spectrum_bar_gap` columns of each bar + are the same bar drawn at 35% brightness, so neighbours are told apart + without the field breaking into separate sticks. `spectrum_bar_gap = 0` + removes it. (A named `spectrum_color` cannot be dimmed, so its seam is an + empty column instead.) +- **Between value rows**, a line: the top `spectrum_row_gap` eighths of every + cell are left unlit, so a full row draws `▇` rather than `█` and each + segment of a bar reads separately. `spectrum_row_gap = 0` stacks them + solid; 2 or more make the line heavier, at a little vertical resolution. + +`spectrum_bar_width` is a **minimum**, not a width: bars take any spare +columns beyond it, which is what keeps the field connected. Raise it to force +wider bars — at the cost of showing fewer bands, since there is then room for +fewer bars than the server sends and each covers a group of neighbouring +bands (taking the loudest of them). + +### Peak shadows + +Each bar trails a **peak-hold shadow**: the gap between the bar and the +highest level it lately reached, filled in `spectrum_peak_color` — the +primary blue by default, so it reads as a shadow of the red bar rather than +part of it. It shows what a transient reached after the bar has dropped away. + +- `spectrum_peak_fall` is how long, in seconds, a full-scale shadow takes to + fall to the floor (4 by default). It is real time, not frames, so the + server's frame rate does not change the feel. +- `spectrum_peak_fill = false` draws a thin rule (`▔`) at the peak instead of + filling the space below it — also the thing to do if your terminal font + lacks `▔`… in which case leave it `true`. +- `spectrum_peak_color = "none"` (or `"off"`) leaves shadows out entirely. + +A shadow never appears in a row the bar itself occupies: a terminal cell +holds one glyph, so a shadow there would eat bar to repeat what the bar's top +edge already shows. Shadows fall away when playback stops, since the server +keeps streaming zeroed bars while the audio is idle. ## Key bindings diff --git a/docs/src/config.md b/docs/src/config.md index 713bfde..068774b 100644 --- a/docs/src/config.md +++ b/docs/src/config.md @@ -113,20 +113,42 @@ spectrum = true 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. +# one flat color. Only hex colors 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. +# Color the shading reaches at the top of the bars, in the same three +# forms as spectrum_color. "none" (or "off") shades brightness alone. +spectrum_top_color = "#b48ead" + +# Color of the peak-hold shadows trailing above the bars, in the same +# three forms as spectrum_color. "none" (or "off") draws no shadows. spectrum_peak_color = "#81a1c1" + +# Fill the shadow from the bar up to its peak. false draws a thin rule at +# the peak alone. +spectrum_peak_fill = true + +# Seconds a full-scale shadow takes to fall to the floor, 0.05-60. +spectrum_peak_fall = 4.0 + +# Least bar width in cells, 1-16. Bars take any spare columns beyond +# this, so raise it only to force wider bars and fewer bands. +spectrum_bar_width = 1 + +# Width in cells of the seam dividing two bars, 0-8. The seam is the bar +# itself dimmed, so the bars stay one connected field; 0 removes it. +spectrum_bar_gap = 1 + +# Height of the line dividing two value rows, in eighths of a row, 0-4. +# This is what makes the segments of a bar visible; 0 stacks them solid. +spectrum_row_gap = 1 ``` -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 +Every option except the `spectrum_*` appearance settings (everything below +`spectrum` itself) 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)): diff --git a/plan/summary.md b/plan/summary.md index 27489b1..aaa20c0 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1711,3 +1711,61 @@ panic before — `clamp` propagates NaN and a NaN float cast saturates to 0 — 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. + +## TUI spectrum: segments, seams and shadows (2026-07-27) + +A second pass over the shading and peaks, after seeing them run. + +**Purple, not white.** The gradient now interpolates from the dimmed base at +the floor to a configured `spectrum_top_color` — `COLOR_SECONDARY`, the +secondary purple — at the top, instead of blending toward white by a +fraction. Naming the far end is both what was wanted and simpler to explain +than a blend factor: the top row *is* the top color. `"none"` ramps +brightness alone. A named top color cannot be interpolated any more than a +named base can, so it falls back to the brightness ramp rather than the +client inventing an RGB for `light-blue`. + +**Filled shadows.** A peak now fills the gap between the bar and its held +maximum rather than marking it with a rule, so each bar trails a shadow of +where it just was. The rule is kept as `spectrum_peak_fill = false`, since it +is also the fallback for a font without U+2594. The old "never inside the +bar's own rows" constraint carries over unchanged, and expresses itself +naturally now that the shadow is drawn with the same `cell_eighths` the bar +uses. + +**Seams, not gaps.** The first attempt at "narrower bars, clearer dividers" +made the bars *separate* — one-cell sticks with blank columns between them — +which was wrong. What was wanted is one connected field with the segments +legible. So: + +- The divider between bars is now the bar itself at 35% brightness rather + than an empty column. Neighbours are distinguishable, the field stays + continuous, and — because the seam is a cell of the bar, not a hole — the + full 24 bins still fit a normal pane at a pitch of two. +- `spectrum_bar_width` became a *minimum*: bars take the spare columns of + their slot, seams stay exactly `spectrum_bar_gap` wide. Otherwise a pane + that divides unevenly gives bars of different widths, and an uneven bar + reads as an uneven level — a lie about the data, where an uneven seam is + only untidy. +- The horizontal divider is `spectrum_row_gap` eighths left unlit at the top + of every cell, so a full row draws `▇` and each value row reads as its own + segment. One eighth costs 1/8 of a row's resolution and is the subtlest + line a cell can hold; a whole row of gap would cost half the resolution, + which is why the clamp stops at 4. + +A cell is the smallest thing a terminal can color, so the two dividers cannot +be made literally identical: the vertical one is a dimmed column, the +horizontal one a fraction of a row. Both are thin and neither is black. + +**Time, not frames.** `spectrum_peak_fall` is seconds for a full-scale shadow +to reach the floor, and `update_spectrum` measures the interval between frames +rather than assuming the server's ~20 fps. The frame rate is the server's +business and may change; a per-frame decay silently retunes itself when it +does. `advance_spectrum` takes the interval as an argument so the fall is +testable without a clock, and the renderer floors the divisor at +`MIN_PEAK_FALL` rather than trusting the config layer's clamp — a zero there +would divide by zero and freeze every shadow on screen. + +Seven appearance keys is a lot of config surface, justified by these being +pure appearance with no right answer: each one is a question that came back +with a different answer than the default assumed.