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