cbd-tui: give the spectrum segments, seams and filled shadows

Follow-up on the shading and peaks, plus the knobs to tune them.

The gradient now ends at a configured `spectrum_top_color` -- the secondary
purple -- rather than blending toward white, so the top row *is* the top
color. "none" ramps brightness alone.

A peak now fills the gap between the bar and its held maximum instead of
marking it with a rule, so each bar trails a shadow of where it just was.
`spectrum_peak_fill = false` keeps the rule, which is also the fallback
for a font without U+2594.

The dividers make the segments legible without breaking the field apart:
the seam between two bars is the bar itself at 35% brightness rather than
an empty column, and `spectrum_row_gap` eighths are left unlit at the top
of every cell so a full row draws `▇` and each value row reads on its own.
`spectrum_bar_width` is a minimum -- bars take the spare columns of their
slot and seams stay exactly `spectrum_bar_gap` wide, because an uneven bar
reads as an uneven level while an uneven seam is only untidy. Keeping the
seam inside the bar's own cells means all 24 bins still fit a normal pane.

Peaks fall in seconds now, not frames: `update_spectrum` times the interval
between frames and `spectrum_peak_fall` says how long a full-scale shadow
takes to reach the floor (4s, up from an effective 1.7s). The server's frame
rate is its own business, and a per-frame decay silently retunes itself when
it changes. `advance_spectrum` takes the interval so the fall is testable
without a clock, and the renderer floors the divisor itself rather than
trusting the config clamp -- a zero would freeze every shadow on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-27 13:27:28 +02:00
parent d5b35a4da2
commit d25620c6a9
6 changed files with 883 additions and 169 deletions

View File

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

View File

@ -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<Color>,
/// 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<Color>,
/// 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<Column> {
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<char> {
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<Color> {
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<Color>, 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<Line<'static>> {
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<f32>,
/// 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<f32>,
/// 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<Instant>,
/// 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<f32>) {
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<f32>, 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<Color> = 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<u32> = (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<Color>)> {
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<Color>)> {
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<Option<Color>> = 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]

View File

@ -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<Color> {
/// 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<Color> {
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();

View File

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

View File

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

View File

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