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:
parent
d5b35a4da2
commit
d25620c6a9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue