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);
|
// const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172);
|
||||||
pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82);
|
pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82);
|
||||||
pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173);
|
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);
|
pub const COLOR_RED: Color = Color::Rgb(191, 97, 106);
|
||||||
/// [`COLOR_RED`] as a config-file string — the default `spectrum_color`,
|
/// [`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
|
/// 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")]
|
#[cfg(feature = "notifications")]
|
||||||
use notify_rust::Notification;
|
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.
|
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
|
||||||
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
|
||||||
/// The peak-hold marker: a thin rule above the bar, the width of a cell
|
/// The unfilled peak-hold shadow: a thin rule at the peak, the width of a
|
||||||
/// (U+2594 upper one-eighth block).
|
/// cell (U+2594 upper one-eighth block).
|
||||||
const PEAK_MARK: char = '▔';
|
const PEAK_MARK: char = '▔';
|
||||||
|
|
||||||
/// Brightness of the bottom gradient row, as a fraction of the configured
|
/// Brightness of the bottom gradient row, as a fraction of the configured
|
||||||
|
|
@ -27,35 +30,57 @@ const PEAK_MARK: char = '▔';
|
||||||
/// dark background.
|
/// dark background.
|
||||||
const GRADIENT_FLOOR: f32 = 0.55;
|
const GRADIENT_FLOOR: f32 = 0.55;
|
||||||
|
|
||||||
/// How far the top gradient row is blended toward white, so a loud bar
|
/// Brightness of a divider column, as a fraction of the bar it belongs to.
|
||||||
/// reads as hot rather than merely tall.
|
/// The divider carries its bar's own shape at this shade, so the bars stay
|
||||||
const GRADIENT_HIGHLIGHT: f32 = 0.3;
|
/// 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
|
/// Shortest `peak_fall` that still means "falls": below this the shadows
|
||||||
/// this drops a full-scale peak to the floor in a bit under two seconds:
|
/// would be gone before the next frame, which is what
|
||||||
/// long enough to see what a transient reached, short enough to track the
|
/// `spectrum_peak_color = "none"` is for.
|
||||||
/// music. Frames keep arriving (zeroed) while audio is idle, so the
|
const MIN_PEAK_FALL: f32 = 0.05;
|
||||||
/// markers fall away on pause rather than freezing on screen.
|
|
||||||
const PEAK_DECAY: f32 = 0.03;
|
|
||||||
|
|
||||||
/// How the spectrum is painted, resolved from the client config once at
|
/// How the spectrum is painted, resolved from the client config once at
|
||||||
/// startup (see `config::spectrum_style`).
|
/// startup (see `config::spectrum_style`).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub struct SpectrumStyle {
|
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,
|
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,
|
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>,
|
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 {
|
impl Default for SpectrumStyle {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
SpectrumStyle {
|
SpectrumStyle {
|
||||||
color: COLOR_RED,
|
color: COLOR_RED,
|
||||||
|
top: Some(COLOR_SECONDARY),
|
||||||
gradient: true,
|
gradient: true,
|
||||||
peak: Some(COLOR_PRIMARY),
|
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,
|
/// What one column of the spectrum area shows: a bar's level and the peak
|
||||||
/// whatever the server's bin count.
|
/// held above it, drawn either as the bar itself or as the dim seam that
|
||||||
fn bin_for(col: usize, width: usize, bins: usize) -> usize {
|
/// divides it from its neighbour.
|
||||||
if bins == 0 {
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
return 0;
|
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:
|
/// 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
|
/// the base dimmed to [`GRADIENT_FLOOR`] at the bottom, interpolated to
|
||||||
/// [`GRADIENT_HIGHLIGHT`] at the top.
|
/// `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
|
/// 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.
|
/// to know — so an `Rgb` base with a named `top` ramps brightness alone,
|
||||||
fn gradient_color(base: Color, from_bottom: usize, height: usize) -> Color {
|
/// and a named base renders flat.
|
||||||
let Color::Rgb(r, g, b) = base else {
|
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;
|
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 {
|
let t = if height <= 1 {
|
||||||
1.0
|
1.0
|
||||||
} else {
|
} else {
|
||||||
from_bottom as f32 / (height - 1) as f32
|
from_bottom as f32 / (height - 1) as f32
|
||||||
};
|
};
|
||||||
let ramp = |channel: u8| {
|
let ramp = |base: u8, top: u8| {
|
||||||
let channel = f32::from(channel);
|
let low = f32::from(base) * GRADIENT_FLOOR;
|
||||||
let low = channel * GRADIENT_FLOOR;
|
let high = f32::from(top);
|
||||||
let high = channel + (255.0 - channel) * GRADIENT_HIGHLIGHT;
|
|
||||||
(low + (high - low) * t).round().clamp(0.0, 255.0) as u8
|
(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`
|
/// Renders `bins` as vertical bars filling a `width`×`height` area: one
|
||||||
/// area: one `Line` per row, top row first. Each column maps to a bin;
|
/// `Line` per row, top row first. A bar's level in `[0, 1]` fills from the
|
||||||
/// its level in `[0, 1]` fills from the bottom, using partial block
|
/// bottom, using partial block glyphs for the topmost fractional cell.
|
||||||
/// glyphs for the topmost fractional cell. Pure, so it is unit-tested.
|
/// Pure, so it is unit-tested.
|
||||||
///
|
///
|
||||||
/// `peaks` holds the decaying per-bin maxima (see [`NowPlaying::update_spectrum`]).
|
/// `peaks` holds the decaying per-bin maxima (see
|
||||||
/// A peak is marked only in a row the bar does not reach at all: a cell
|
/// [`NowPlaying::update_spectrum`]). Where a bar has fallen below its peak
|
||||||
/// holds one glyph, and a marker inside the bar's own top cell would eat
|
/// the gap between the two is filled in the peak color, so each bar trails
|
||||||
/// up to seven eighths of the bar to say something the bar top already says.
|
/// 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(
|
fn spectrum_lines(
|
||||||
bins: &[f32],
|
bins: &[f32],
|
||||||
peaks: &[f32],
|
peaks: &[f32],
|
||||||
|
|
@ -121,33 +255,49 @@ fn spectrum_lines(
|
||||||
height: usize,
|
height: usize,
|
||||||
style: SpectrumStyle,
|
style: SpectrumStyle,
|
||||||
) -> Vec<Line<'static>> {
|
) -> Vec<Line<'static>> {
|
||||||
|
let columns = spectrum_columns(bins, peaks, width, style);
|
||||||
(0..height)
|
(0..height)
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
// Row 0 is the top; count cells up from the bottom.
|
// Row 0 is the top; count cells up from the bottom.
|
||||||
let from_bottom = height - 1 - row;
|
let from_bottom = height - 1 - row;
|
||||||
let bar_color = if style.gradient {
|
let bar_color = if style.gradient {
|
||||||
gradient_color(style.color, from_bottom, height)
|
gradient_color(style.color, style.top, from_bottom, height)
|
||||||
} else {
|
} else {
|
||||||
style.color
|
style.color
|
||||||
};
|
};
|
||||||
// Runs of same-colored cells, so a row is a handful of spans
|
// Runs of same-colored cells, so a row is a handful of spans
|
||||||
// rather than one per column.
|
// rather than one per column.
|
||||||
let mut runs: Vec<(Color, String)> = Vec::new();
|
let mut runs: Vec<(Color, String)> = Vec::new();
|
||||||
for col in 0..width {
|
let seam_color = dimmed(bar_color, DIVIDER_DIM);
|
||||||
let bin = bin_for(col, width, bins.len());
|
for column in &columns {
|
||||||
let level = level_of(bins.get(bin).copied().unwrap_or(0.0));
|
// A seam shows its own bar, dimmed. Where the color cannot
|
||||||
let eighths = (level * height as f32 * 8.0).round() as usize;
|
// be dimmed the seam is an empty column instead.
|
||||||
let cell = eighths.saturating_sub(from_bottom * 8).min(8);
|
let (bar_color, peak_color) = match (column.divider, seam_color) {
|
||||||
let peak = style.peak.filter(|_| {
|
(false, _) => (Some(bar_color), style.peak),
|
||||||
let peak = level_of(peaks.get(bin).copied().unwrap_or(0.0));
|
(true, Some(seam)) => {
|
||||||
let peak_eighths = (peak * height as f32 * 8.0).round() as usize;
|
(Some(seam), style.peak.and_then(|c| dimmed(c, DIVIDER_DIM)))
|
||||||
// The row holding the peak's topmost eighth, and only
|
}
|
||||||
// where the bar has left this cell empty.
|
(true, None) => (None, None),
|
||||||
cell == 0 && peak_eighths > 0 && (peak_eighths - 1) / 8 == from_bottom
|
};
|
||||||
});
|
let (glyph, color) = match bar_color {
|
||||||
let (glyph, color) = match peak {
|
None => (' ', style.color),
|
||||||
Some(peak_color) => (PEAK_MARK, peak_color),
|
Some(bar_color) => {
|
||||||
None => (BLOCKS[cell], 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() {
|
match runs.last_mut() {
|
||||||
Some((run_color, text)) if *run_color == color => text.push(glyph),
|
Some((run_color, text)) if *run_color == color => text.push(glyph),
|
||||||
|
|
@ -195,9 +345,11 @@ pub struct NowPlaying {
|
||||||
/// until the first frame arrives.
|
/// until the first frame arrives.
|
||||||
spectrum: Vec<f32>,
|
spectrum: Vec<f32>,
|
||||||
/// Per-bin peak hold: the highest level each bin has reached lately,
|
/// Per-bin peak hold: the highest level each bin has reached lately,
|
||||||
/// decayed by [`PEAK_DECAY`] a frame. Drawn as the marker line above
|
/// falling at the configured rate. Drawn as the shadow above the bars.
|
||||||
/// the bars.
|
|
||||||
spectrum_peaks: Vec<f32>,
|
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).
|
/// Whether to draw the spectrum row (config `spectrum`, default on).
|
||||||
spectrum_enabled: bool,
|
spectrum_enabled: bool,
|
||||||
/// Bar color, gradient and peak color (config `spectrum_color`,
|
/// Bar color, gradient and peak color (config `spectrum_color`,
|
||||||
|
|
@ -220,6 +372,7 @@ impl Default for NowPlaying {
|
||||||
track: None,
|
track: None,
|
||||||
spectrum: Vec::new(),
|
spectrum: Vec::new(),
|
||||||
spectrum_peaks: Vec::new(),
|
spectrum_peaks: Vec::new(),
|
||||||
|
spectrum_frame_at: None,
|
||||||
spectrum_enabled: true,
|
spectrum_enabled: true,
|
||||||
spectrum_style: SpectrumStyle::default(),
|
spectrum_style: SpectrumStyle::default(),
|
||||||
muted: false,
|
muted: false,
|
||||||
|
|
@ -245,18 +398,35 @@ impl NowPlaying {
|
||||||
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
|
||||||
self.modifiers = *mods;
|
self.modifiers = *mods;
|
||||||
}
|
}
|
||||||
/// Applies a spectrum frame from the server (already normalized) and
|
/// Applies a spectrum frame from the server (already normalized),
|
||||||
/// advances the peak hold: a bin at or above its peak raises it
|
/// timing it against the previous frame so the peaks fall at the
|
||||||
/// instantly, otherwise the peak falls by [`PEAK_DECAY`]. The bin count
|
/// configured rate whatever the server's frame rate is.
|
||||||
/// comes off the wire, so the peaks follow whatever arrives.
|
|
||||||
pub fn update_spectrum(&mut self, bins: Vec<f32>) {
|
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);
|
self.spectrum_peaks.resize(bins.len(), 0.0);
|
||||||
for (peak, level) in self.spectrum_peaks.iter_mut().zip(&bins) {
|
for (peak, level) in self.spectrum_peaks.iter_mut().zip(&bins) {
|
||||||
let level = level_of(*level);
|
let level = level_of(*level);
|
||||||
*peak = if level >= *peak {
|
*peak = if level >= *peak {
|
||||||
level
|
level
|
||||||
} else {
|
} else {
|
||||||
(*peak - PEAK_DECAY).max(0.0)
|
(*peak - fall).max(0.0)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
self.spectrum = bins;
|
self.spectrum = bins;
|
||||||
|
|
@ -501,6 +671,7 @@ mod tests {
|
||||||
}),
|
}),
|
||||||
spectrum: Vec::new(),
|
spectrum: Vec::new(),
|
||||||
spectrum_peaks: Vec::new(),
|
spectrum_peaks: Vec::new(),
|
||||||
|
spectrum_frame_at: None,
|
||||||
spectrum_enabled: true,
|
spectrum_enabled: true,
|
||||||
spectrum_style: SpectrumStyle::default(),
|
spectrum_style: SpectrumStyle::default(),
|
||||||
muted: false,
|
muted: false,
|
||||||
|
|
@ -513,11 +684,26 @@ mod tests {
|
||||||
fn flat() -> SpectrumStyle {
|
fn flat() -> SpectrumStyle {
|
||||||
SpectrumStyle {
|
SpectrumStyle {
|
||||||
color: COLOR_RED,
|
color: COLOR_RED,
|
||||||
|
top: None,
|
||||||
gradient: false,
|
gradient: false,
|
||||||
peak: None,
|
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) {
|
fn render(pane: &NowPlaying) {
|
||||||
let backend = TestBackend::new(60, 12);
|
let backend = TestBackend::new(60, 12);
|
||||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
|
|
@ -574,8 +760,9 @@ mod tests {
|
||||||
let mut pane = now_playing(10_000, 60_000);
|
let mut pane = now_playing(10_000, 60_000);
|
||||||
pane.update_spectrum(vec![1.0; 24]);
|
pane.update_spectrum(vec![1.0; 24]);
|
||||||
let rows = rendered_rows(&pane);
|
let rows = rendered_rows(&pane);
|
||||||
// Full-level bars fill several rows with the full block.
|
// Full-level bars fill several rows to the row gap.
|
||||||
let full_rows = rows.iter().filter(|r| r.contains('█')).count();
|
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:?}");
|
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");
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
|
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
|
||||||
let buffer = terminal.backend().buffer().clone();
|
let buffer = terminal.backend().buffer().clone();
|
||||||
|
let filled = full_cell(pane.spectrum_style).to_string();
|
||||||
(0..buffer.area.height)
|
(0..buffer.area.height)
|
||||||
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
|
.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)
|
.and_then(|(x, y)| buffer[(x, y)].style().fg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -598,10 +786,11 @@ mod tests {
|
||||||
let mut terminal = Terminal::new(backend).expect("test terminal");
|
let mut terminal = Terminal::new(backend).expect("test terminal");
|
||||||
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
|
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
|
||||||
let buffer = terminal.backend().buffer().clone();
|
let buffer = terminal.backend().buffer().clone();
|
||||||
|
let filled = full_cell(pane.spectrum_style).to_string();
|
||||||
let mut colors: Vec<Color> = Vec::new();
|
let mut colors: Vec<Color> = Vec::new();
|
||||||
for y in 0..buffer.area.height {
|
for y in 0..buffer.area.height {
|
||||||
for x in 0..buffer.area.width {
|
for x in 0..buffer.area.width {
|
||||||
if buffer[(x, y)].symbol() != "█" {
|
if buffer[(x, y)].symbol() != filled {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(fg) = buffer[(x, y)].style().fg {
|
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() {
|
fn the_default_style_is_the_queue_red_under_a_blue_peak() {
|
||||||
let style = SpectrumStyle::default();
|
let style = SpectrumStyle::default();
|
||||||
assert_eq!(style.color, super::super::COLOR_RED);
|
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_eq!(style.peak, Some(super::super::COLOR_PRIMARY));
|
||||||
assert!(style.gradient, "shaded out of the box");
|
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]
|
#[test]
|
||||||
|
|
@ -628,8 +825,7 @@ mod tests {
|
||||||
pane.update_spectrum(vec![1.0; 24]);
|
pane.update_spectrum(vec![1.0; 24]);
|
||||||
pane.set_spectrum_style(SpectrumStyle {
|
pane.set_spectrum_style(SpectrumStyle {
|
||||||
color: Color::Rgb(1, 2, 3),
|
color: Color::Rgb(1, 2, 3),
|
||||||
gradient: false,
|
..flat()
|
||||||
peak: None,
|
|
||||||
});
|
});
|
||||||
assert_eq!(first_bar_color(&pane), Some(Color::Rgb(1, 2, 3)));
|
assert_eq!(first_bar_color(&pane), Some(Color::Rgb(1, 2, 3)));
|
||||||
assert_eq!(
|
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]
|
#[test]
|
||||||
fn the_gradient_runs_dim_at_the_floor_to_bright_at_the_top() {
|
fn the_gradient_runs_dim_at_the_floor_to_bright_at_the_top() {
|
||||||
let base = super::super::COLOR_RED;
|
let base = super::super::COLOR_RED;
|
||||||
let (bottom, top) = (gradient_color(base, 0, 8), gradient_color(base, 7, 8));
|
let bottom = gradient_color(base, None, 0, 8);
|
||||||
let brightness = |color: Color| match color {
|
let top = gradient_color(base, None, 7, 8);
|
||||||
Color::Rgb(r, g, b) => u32::from(r) + u32::from(g) + u32::from(b),
|
|
||||||
other => panic!("expected rgb, got {other:?}"),
|
|
||||||
};
|
|
||||||
assert!(
|
assert!(
|
||||||
brightness(bottom) < brightness(base),
|
brightness(bottom) < brightness(base),
|
||||||
"floor dimmer than the base: {bottom:?}"
|
"floor dimmer than the base: {bottom:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
// Without a top color the ramp is brightness alone, so the top row
|
||||||
brightness(top) > brightness(base),
|
// is the base itself rather than something hotter.
|
||||||
"top brighter than the base: {top:?}"
|
assert_eq!(top, base, "top of a topless ramp is the base");
|
||||||
);
|
|
||||||
// Monotonic in between, so the ramp reads as one gradient.
|
// Monotonic in between, so the ramp reads as one gradient.
|
||||||
let ramp: Vec<u32> = (0..8)
|
let ramp: Vec<u32> = (0..8)
|
||||||
.map(|r| brightness(gradient_color(base, r, 8)))
|
.map(|r| brightness(gradient_color(base, None, r, 8)))
|
||||||
.collect();
|
.collect();
|
||||||
assert!(ramp.windows(2).all(|w| w[0] <= w[1]), "{ramp:?}");
|
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
|
// A one-row area is all "top": the single row keeps the full color
|
||||||
// rather than being dimmed to the floor.
|
// 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]
|
#[test]
|
||||||
|
|
@ -683,75 +899,305 @@ mod tests {
|
||||||
// Their RGB belongs to the terminal theme, so there is nothing to
|
// Their RGB belongs to the terminal theme, so there is nothing to
|
||||||
// interpolate — they render flat, whatever `gradient` says.
|
// interpolate — they render flat, whatever `gradient` says.
|
||||||
for color in [Color::LightBlue, Color::Indexed(208), Color::Reset] {
|
for color in [Color::LightBlue, Color::Indexed(208), Color::Reset] {
|
||||||
assert_eq!(gradient_color(color, 0, 8), color);
|
assert_eq!(gradient_color(color, None, 0, 8), color);
|
||||||
assert_eq!(gradient_color(color, 7, 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]
|
#[test]
|
||||||
fn a_held_peak_marks_the_row_above_the_bar() {
|
fn an_unfilled_shadow_is_a_rule_at_the_peak_alone() {
|
||||||
// Bar at the floor, peak still at full scale: the marker sits in
|
|
||||||
// the top row, in the peak color, and the bar keeps its own.
|
|
||||||
let style = SpectrumStyle {
|
let style = SpectrumStyle {
|
||||||
color: COLOR_RED,
|
|
||||||
gradient: false,
|
|
||||||
peak: Some(COLOR_PRIMARY),
|
peak: Some(COLOR_PRIMARY),
|
||||||
|
peak_fill: false,
|
||||||
|
..flat()
|
||||||
};
|
};
|
||||||
let lines = spectrum_lines(&[0.1], &[1.0], 1, 4, style);
|
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style));
|
||||||
let top = &lines[0].spans[0];
|
assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor");
|
||||||
assert_eq!(top.content.as_ref(), PEAK_MARK.to_string());
|
assert_eq!(column[1].0, ' ', "nothing under the rule");
|
||||||
assert_eq!(top.style.fg, Some(COLOR_PRIMARY));
|
assert_eq!(column[2].0, ' ', "nothing under the rule");
|
||||||
let bottom = &lines[3].spans[0];
|
assert_eq!(column[3], (PEAK_MARK, Some(COLOR_PRIMARY)), "the rule");
|
||||||
assert_eq!(bottom.style.fg, Some(COLOR_RED), "bar keeps its color");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_peak_inside_the_bar_is_not_drawn() {
|
fn a_shadow_inside_the_bar_is_not_drawn() {
|
||||||
// A cell holds one glyph: a marker in the bar's own top cell would
|
// A cell holds one glyph: a shadow in a row the bar occupies would
|
||||||
// eat the bar to repeat what its top edge already shows.
|
// eat bar to repeat what its top edge already shows.
|
||||||
let style = SpectrumStyle {
|
for peak_fill in [true, false] {
|
||||||
color: COLOR_RED,
|
let style = SpectrumStyle {
|
||||||
gradient: false,
|
peak: Some(COLOR_PRIMARY),
|
||||||
peak: Some(COLOR_PRIMARY),
|
peak_fill,
|
||||||
};
|
..flat()
|
||||||
let lines = spectrum_lines(&[1.0], &[1.0], 1, 4, style);
|
};
|
||||||
|
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!(
|
assert!(
|
||||||
!lines
|
column[1..].iter().all(|(glyph, _)| *glyph == ' '),
|
||||||
.iter()
|
"no shadow: {column:?}"
|
||||||
.any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))),
|
);
|
||||||
"peak at the bar top must not overwrite it"
|
}
|
||||||
|
|
||||||
|
/// 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]
|
#[test]
|
||||||
fn peaks_are_left_out_when_switched_off() {
|
fn bars_take_the_spare_columns_rather_than_the_seams() {
|
||||||
let lines = spectrum_lines(&[0.1], &[1.0], 1, 4, flat());
|
// `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!(
|
assert!(
|
||||||
!lines
|
row.iter()
|
||||||
.iter()
|
.any(|(glyph, color)| *glyph == '█' && *color == Some(COLOR_RED)),
|
||||||
.any(|l| l.spans.iter().any(|s| s.content.contains(PEAK_MARK))),
|
"still draws a bar: {row:?}"
|
||||||
"`spectrum_peak_color = \"none\"` draws no markers"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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);
|
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!(
|
assert_eq!(
|
||||||
pane.spectrum_peaks,
|
pane.spectrum_peaks,
|
||||||
vec![1.0],
|
vec![1.0],
|
||||||
"a hit sets the peak at once"
|
"a hit sets the peak at once"
|
||||||
);
|
);
|
||||||
// Silence afterwards: the peak comes down a step per frame.
|
// Silence afterwards: half the fall time takes it half way down.
|
||||||
pane.update_spectrum(vec![0.0]);
|
pane.advance_spectrum(vec![0.0], Duration::from_secs(1));
|
||||||
assert!((pane.spectrum_peaks[0] - (1.0 - PEAK_DECAY)).abs() < 1e-6);
|
assert!(
|
||||||
for _ in 0..200 {
|
(pane.spectrum_peaks[0] - 0.5).abs() < 1e-6,
|
||||||
pane.update_spectrum(vec![0.0]);
|
"{:?}",
|
||||||
}
|
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");
|
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]
|
#[test]
|
||||||
fn a_peak_follows_a_changed_bin_count() {
|
fn a_peak_follows_a_changed_bin_count() {
|
||||||
// The bin count comes off the wire and may change mid-stream.
|
// 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]);
|
assert_eq!(pane.spectrum_peaks, vec![0.0; 4]);
|
||||||
let rows = rendered_rows(&pane);
|
let rows = rendered_rows(&pane);
|
||||||
assert!(
|
assert!(
|
||||||
!rows.iter().any(|r| r.contains('█')),
|
!rows
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.contains(full_cell(pane.spectrum_style))),
|
||||||
"nonsense levels must draw nothing: {rows:?}"
|
"nonsense levels must draw nothing: {rows:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -784,7 +1232,9 @@ mod tests {
|
||||||
pane.update_spectrum(vec![1.0; 24]);
|
pane.update_spectrum(vec![1.0; 24]);
|
||||||
let rows = rendered_rows(&pane);
|
let rows = rendered_rows(&pane);
|
||||||
assert!(
|
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:?}"
|
"disabled spectrum must not draw bars: {rows:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -793,13 +1243,14 @@ mod tests {
|
||||||
fn toggle_spectrum_hides_then_shows_the_bars() {
|
fn toggle_spectrum_hides_then_shows_the_bars() {
|
||||||
let mut pane = now_playing(10_000, 60_000);
|
let mut pane = now_playing(10_000, 60_000);
|
||||||
pane.update_spectrum(vec![1.0; 24]);
|
pane.update_spectrum(vec![1.0; 24]);
|
||||||
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
let filled = full_cell(pane.spectrum_style);
|
||||||
// `v` hides the bars…
|
assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
|
||||||
|
// `f` hides the bars…
|
||||||
pane.toggle_spectrum();
|
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.
|
// …and again brings them back.
|
||||||
pane.toggle_spectrum();
|
pane.toggle_spectrum();
|
||||||
assert!(rendered_rows(&pane).iter().any(|r| r.contains('█')));
|
assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -193,12 +193,52 @@ pub struct ServerConfig {
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub spectrum_gradient: bool,
|
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;
|
/// 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())]
|
#[default(crate::app::COLOR_PRIMARY_HEX.to_string())]
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub spectrum_peak_color: String,
|
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`].
|
/// Resolves the spectrum options to a [`SpectrumStyle`].
|
||||||
|
|
@ -215,8 +255,32 @@ pub fn spectrum_style(config: &Config) -> SpectrumStyle {
|
||||||
"spectrum_color",
|
"spectrum_color",
|
||||||
crate::app::COLOR_RED,
|
crate::app::COLOR_RED,
|
||||||
),
|
),
|
||||||
|
top: optional_color(
|
||||||
|
&config.server.spectrum_top_color,
|
||||||
|
"spectrum_top_color",
|
||||||
|
crate::app::COLOR_SECONDARY,
|
||||||
|
),
|
||||||
gradient: config.server.spectrum_gradient,
|
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".
|
/// Parses a color option that can be switched off, where "none"/"off" mean
|
||||||
/// Those two words are not colors, so there is no value they shadow.
|
/// "leave this out". Neither word is a color, so there is no value they
|
||||||
fn peak_color(raw: &str) -> Option<Color> {
|
/// shadow.
|
||||||
|
fn optional_color(raw: &str, key: &str, fallback: Color) -> Option<Color> {
|
||||||
let trimmed = raw.trim();
|
let trimmed = raw.trim();
|
||||||
if trimmed.eq_ignore_ascii_case("none") || trimmed.eq_ignore_ascii_case("off") {
|
if trimmed.eq_ignore_ascii_case("none") || trimmed.eq_ignore_ascii_case("off") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(color_or_default(
|
Some(color_or_default(trimmed, key, fallback))
|
||||||
trimmed,
|
}
|
||||||
"spectrum_peak_color",
|
|
||||||
crate::app::COLOR_PRIMARY,
|
/// 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)]
|
#[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]
|
#[test]
|
||||||
fn the_gradient_can_be_switched_off() {
|
fn the_gradient_can_be_switched_off() {
|
||||||
let mut config = Config::default();
|
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
|
will approximate. An unparsable value warns on stderr and falls back to
|
||||||
the default rather than stopping the client.
|
the default rather than stopping the client.
|
||||||
|
|
||||||
### Shading and peaks
|
### Shading
|
||||||
|
|
||||||
The bars are **shaded** by height: dim at the floor, brightening toward
|
The bars are **shaded** by height: dim at the floor, brightening toward the
|
||||||
the top, so a loud bar reads as hot and not merely tall. Each row gets its
|
top, so a loud bar reads as hot and not merely tall. Each row gets its own
|
||||||
own shade of `spectrum_color`, interpolated from 55% brightness at the
|
shade, interpolated from 55% of `spectrum_color` at the bottom to
|
||||||
bottom to 30% toward white at the top. Set `spectrum_gradient = false` for
|
`spectrum_top_color` — the secondary purple — at the very top. Set
|
||||||
one flat color instead.
|
`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
|
Shading needs colors it can compute with, so it applies only to **hex**
|
||||||
**hex** `spectrum_color`. A name or a palette index is a reference into
|
values. A name or a palette index is a reference into your terminal's own
|
||||||
your terminal's own theme — its actual RGB value is the terminal's
|
theme — its actual RGB value is the terminal's business, not the client's —
|
||||||
business, not the client's — so those always render flat, whatever
|
so a named `spectrum_color` renders flat whatever `spectrum_gradient` says,
|
||||||
`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
|
### Segments and seams
|
||||||
that jumps to each new maximum and then falls back at about half scale a
|
|
||||||
second, so you can see what a transient reached after the bar has dropped.
|
|
||||||
`spectrum_peak_color` sets their color (same three forms), defaulting to
|
|
||||||
the primary blue so they read as a shadow of the red bars rather than part
|
|
||||||
of them. `spectrum_peak_color = "none"` (or `"off"`) leaves them out —
|
|
||||||
also the thing to do if your terminal font lacks `▔` and draws a box.
|
|
||||||
|
|
||||||
A marker is only drawn in a row the bar does not reach: a terminal cell
|
The bars form one connected field, divided two ways so the individual
|
||||||
holds one glyph, so a marker inside the bar's own top cell would eat the
|
segments stand out:
|
||||||
bar to repeat what its top edge already shows. Markers fall away when
|
|
||||||
playback stops, since the server keeps streaming zeroed bars while the
|
- **Between bars**, a seam: the last `spectrum_bar_gap` columns of each bar
|
||||||
audio is idle.
|
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
|
## Key bindings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -113,20 +113,42 @@ spectrum = true
|
||||||
spectrum_color = "#bf616a"
|
spectrum_color = "#bf616a"
|
||||||
|
|
||||||
# Shade the bars from dim at the floor to bright at the top instead of
|
# 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
|
# one flat color. Only hex colors can be shaded — a name or a palette
|
||||||
# palette index has no RGB value the client may interpolate.
|
# index has no RGB value the client may interpolate.
|
||||||
spectrum_gradient = true
|
spectrum_gradient = true
|
||||||
|
|
||||||
# Color of the peak-hold markers riding above the bars, in the same three
|
# Color the shading reaches at the top of the bars, in the same three
|
||||||
# forms as spectrum_color. "none" (or "off") draws no markers.
|
# 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"
|
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
|
Every option except the `spectrum_*` appearance settings (everything below
|
||||||
`spectrum_peak_color` is also a command-line flag, given before the
|
`spectrum` itself) is also a command-line flag, given before the subcommand
|
||||||
subcommand (`cbd-tui --address http://pi:50051 --user owner`,
|
(`cbd-tui --address http://pi:50051 --user owner`, `cbd --spectrum false`).
|
||||||
`cbd --spectrum false`). A provided flag overrides the file value; an
|
A provided flag overrides the file value; an omitted flag
|
||||||
omitted flag
|
|
||||||
leaves the file value in place. To write credentials into the config
|
leaves the file value in place. To write credentials into the config
|
||||||
once instead of editing by hand, use the `auth` subcommand (see
|
once instead of editing by hand, use the `auth` subcommand (see
|
||||||
[Command line](./clients/cli.md)):
|
[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
|
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
|
guard is so the peak comparison and the glyph arithmetic agree on what a
|
||||||
nonsense level means instead of each shrugging it off differently.
|
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