crabidy/cbd-tui/src/app/now_playing.rs

1321 lines
50 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::{
ops::Div,
time::{Duration, Instant},
};
#[cfg(feature = "notifications")]
use notify_rust::Notification;
use crabidy_core::proto::crabidy::{PlayState, QueueModifiers, Track, TrackPosition};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, LineGauge, Paragraph, Wrap},
Frame,
};
use super::{COLOR_PRIMARY, COLOR_RED, COLOR_SECONDARY};
/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
/// The unfilled peak-hold shadow: a thin rule at the peak, the width of a
/// cell (U+2594 upper one-eighth block).
const PEAK_MARK: char = '▔';
/// Brightness of the bottom gradient row, as a fraction of the configured
/// color. Dim enough to read as a floor, light enough to stay visible on a
/// dark background.
const GRADIENT_FLOOR: f32 = 0.55;
/// Brightness of a divider column, as a fraction of the bar it belongs to.
/// The divider carries its bar's own shape at this shade, so the bars stay
/// one connected field with a seam between them rather than becoming
/// separate sticks with holes in between.
const DIVIDER_DIM: f32 = 0.35;
/// Shortest `peak_fall` that still means "falls": below this the shadows
/// would be gone before the next frame, which is what
/// `spectrum_peak_color = "none"` is for.
const MIN_PEAK_FALL: f32 = 0.05;
/// How the spectrum is painted, resolved from the client config once at
/// startup (see `config::spectrum_style`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SpectrumStyle {
/// Bar color: the gradient's floor, and the whole bar without one.
pub color: Color,
/// Color the gradient reaches at the top of the area, or `None` to
/// ramp brightness alone.
pub top: Option<Color>,
/// Shade the bars by height instead of one flat color.
pub gradient: bool,
/// Peak-hold shadow color, or `None` to draw no shadows.
pub peak: Option<Color>,
/// Fill the shadow from the bar up to its peak, rather than drawing a
/// thin rule at the peak alone.
pub peak_fill: bool,
/// Seconds a full-scale peak takes to fall to the floor.
pub peak_fall: f32,
/// Least bar width in cells; a bar takes any spare columns of its slot
/// beyond this, so the field stays connected.
pub bar_width: usize,
/// Width of the seam dividing two bars, in cells.
pub bar_gap: usize,
/// Height of the line dividing two value rows, in eighths of a row.
/// 0 stacks the rows into a solid bar.
pub row_gap: usize,
}
impl Default for SpectrumStyle {
fn default() -> Self {
SpectrumStyle {
color: COLOR_RED,
top: Some(COLOR_SECONDARY),
gradient: true,
peak: Some(COLOR_PRIMARY),
peak_fill: true,
peak_fall: 4.0,
bar_width: 1,
bar_gap: 1,
row_gap: 1,
}
}
}
/// 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
}
}
/// What one column of the spectrum area shows: a bar's level and the peak
/// held above it, drawn either as the bar itself or as the dim seam that
/// divides it from its neighbour.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Column {
level: f32,
peak: f32,
/// This column is the seam at the right of its bar.
divider: bool,
}
/// Assigns every column of the area to a bar, marking those that are its
/// divider.
///
/// A bar fills its slot except for the last `style.bar_gap` columns, and is
/// never narrower than `style.bar_width`, so spare columns widen the bars
/// rather than the seams. There is one bar per bin unless the area is too
/// narrow to hold that many — then a bar covers a group of neighbouring bins
/// and takes the loudest of them, because a spectrum's news is its peaks and
/// an average would flatten them.
fn spectrum_columns(
bins: &[f32],
peaks: &[f32],
width: usize,
style: SpectrumStyle,
) -> Vec<Column> {
let bar_width = style.bar_width.max(1);
let pitch = bar_width + style.bar_gap;
// Never more bars than bins (they would repeat a neighbour), never
// fewer than one (a very narrow pane still shows something).
let bars = (width / pitch.max(1)).clamp(1, bins.len().max(1));
// Loudest level and peak over a bar's share of the bins.
let loudest = |slice: &[f32]| slice.iter().copied().map(level_of).fold(0.0, f32::max);
// The first column of bar `n`, so that it inverts the column-to-bar
// mapping below exactly: rounding the two the same way instead would
// put a column outside the slot it belongs to.
let first_col = |bar: usize| (bar * width).div_ceil(bars);
(0..width)
.map(|col| {
let bar = (col * bars / width.max(1)).min(bars - 1);
let start = first_col(bar);
let span = first_col(bar + 1).min(width) - start;
// The bar leads its slot and the seam trails it. A slot with no
// room for both keeps the bar: a seam that leaves no bar shows
// nothing at all.
let bar_cells = bar_width.max(span.saturating_sub(style.bar_gap)).min(span);
let lo = bar * bins.len() / bars;
let hi = ((bar + 1) * bins.len() / bars).max(lo + 1).min(bins.len());
Column {
level: loudest(bins.get(lo..hi).unwrap_or_default()),
peak: loudest(peaks.get(lo..hi).unwrap_or_default()),
divider: col - start >= bar_cells,
}
})
.collect()
}
/// Eighths of the cell in row `from_bottom` that a bar at `level` fills: 0
/// for a row the bar does not reach, and at most `8 - row_gap` for one it
/// fills completely — the unfilled eighths at the top of the cell are the
/// line dividing this value row from the one above.
fn cell_eighths(level: f32, height: usize, from_bottom: usize, row_gap: usize) -> usize {
let eighths = (level * height as f32 * 8.0).round() as usize;
eighths
.saturating_sub(from_bottom * 8)
.min(8usize.saturating_sub(row_gap))
}
/// The glyph a peak shadow puts in row `from_bottom`, if any: filled from
/// the row above the bar up to the peak, or — unfilled — a thin rule in the
/// single row the peak reaches.
fn shadow_glyph(
peak: f32,
height: usize,
from_bottom: usize,
style: SpectrumStyle,
) -> Option<char> {
let eighths = (level_of(peak) * height as f32 * 8.0).round() as usize;
if eighths == 0 {
return None;
}
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 base dimmed to [`GRADIENT_FLOOR`] at the bottom, interpolated to
/// `top` (or the undimmed base, without one) at the very top.
///
/// Only `Rgb` colors can be interpolated. A color name or a palette index
/// is a reference into the terminal's own theme — its RGB value is not ours
/// to know — so an `Rgb` base with a named `top` ramps brightness alone,
/// and a named base renders flat.
fn gradient_color(base: Color, top: Option<Color>, from_bottom: usize, height: usize) -> Color {
let Color::Rgb(base_r, base_g, base_b) = base else {
return base;
};
let (top_r, top_g, top_b) = match top {
Some(Color::Rgb(r, g, b)) => (r, g, b),
_ => (base_r, base_g, base_b),
};
let t = if height <= 1 {
1.0
} else {
from_bottom as f32 / (height - 1) as f32
};
let ramp = |base: u8, top: u8| {
let low = f32::from(base) * GRADIENT_FLOOR;
let high = f32::from(top);
(low + (high - low) * t).round().clamp(0.0, 255.0) as u8
};
Color::Rgb(
ramp(base_r, top_r),
ramp(base_g, top_g),
ramp(base_b, top_b),
)
}
/// Renders `bins` as vertical bars filling a `width`×`height` area: one
/// `Line` per row, top row first. A bar's level in `[0, 1]` fills from the
/// bottom, using partial block glyphs for the topmost fractional cell.
/// Pure, so it is unit-tested.
///
/// `peaks` holds the decaying per-bin maxima (see
/// [`NowPlaying::update_spectrum`]). Where a bar has fallen below its peak
/// the gap between the two is filled in the peak color, so each bar trails
/// a shadow of where it just was.
///
/// The bars form one field: neighbours are told apart by a dimmed seam
/// column, and value rows by leaving the top `style.row_gap` eighths of each
/// cell unlit.
fn spectrum_lines(
bins: &[f32],
peaks: &[f32],
width: usize,
height: usize,
style: SpectrumStyle,
) -> Vec<Line<'static>> {
let columns = spectrum_columns(bins, peaks, width, style);
(0..height)
.map(|row| {
// Row 0 is the top; count cells up from the bottom.
let from_bottom = height - 1 - row;
let bar_color = if style.gradient {
gradient_color(style.color, style.top, from_bottom, height)
} else {
style.color
};
// Runs of same-colored cells, so a row is a handful of spans
// rather than one per column.
let mut runs: Vec<(Color, String)> = Vec::new();
let seam_color = dimmed(bar_color, DIVIDER_DIM);
for column in &columns {
// A seam shows its own bar, dimmed. Where the color cannot
// be dimmed the seam is an empty column instead.
let (bar_color, peak_color) = match (column.divider, seam_color) {
(false, _) => (Some(bar_color), style.peak),
(true, Some(seam)) => {
(Some(seam), style.peak.and_then(|c| dimmed(c, DIVIDER_DIM)))
}
(true, None) => (None, None),
};
let (glyph, color) = match bar_color {
None => (' ', style.color),
Some(bar_color) => {
let cell = cell_eighths(column.level, height, from_bottom, style.row_gap);
// The bar itself, else its shadow reaching up to
// the held peak, else nothing. A cell holds one
// glyph, so the bar always wins its own rows: a
// shadow inside them would eat bar to repeat what
// the bar's top edge already shows.
match (cell, peak_color) {
(0, Some(peak_color)) => {
match shadow_glyph(column.peak, height, from_bottom, style) {
Some(glyph) => (glyph, peak_color),
None => (' ', bar_color),
}
}
_ => (BLOCKS[cell], bar_color),
}
}
};
match runs.last_mut() {
Some((run_color, text)) if *run_color == color => text.push(glyph),
_ => runs.push((color, glyph.to_string())),
}
}
Line::from(
runs.into_iter()
.map(|(color, text)| Span::styled(text, Style::default().fg(color)))
.collect::<Vec<_>>(),
)
})
.collect()
}
/// Formats the server's output level for the status line: `1.0` reads as
/// `100%`, and since the server clamps to 1.1 the display can reach
/// `110%`. While muted the level is kept on screen — the server reports
/// the level it would unmute to, which is what the user wants to see —
/// with the mute called out rather than the number replaced.
///
/// A volume off the wire is a `float`, so it can arrive as NaN or
/// infinity from a wrong or malicious peer; that reads as `--` instead of
/// `NaN%`, and negative zero as `0%`.
fn format_volume(volume: f32, muted: bool) -> String {
let level = if volume.is_finite() {
format!("{:.0}%", (volume * 100.0).max(0.0))
} else {
"--".to_string()
};
if muted {
format!("{level} (muted)")
} else {
level
}
}
pub struct NowPlaying {
play_state: PlayState,
duration: Option<Duration>,
modifiers: QueueModifiers,
position: Option<Duration>,
track: Option<Track>,
/// Latest frequency-spectrum bars (architecture/spectrum.md), empty
/// until the first frame arrives.
spectrum: Vec<f32>,
/// Per-bin peak hold: the highest level each bin has reached lately,
/// falling at the configured rate. Drawn as the shadow above the bars.
spectrum_peaks: Vec<f32>,
/// When the last spectrum frame arrived, so the peaks fall in seconds
/// rather than in frames of whatever rate the server happens to send.
spectrum_frame_at: Option<Instant>,
/// Whether to draw the spectrum row (config `spectrum`, default on).
spectrum_enabled: bool,
/// Bar color, gradient and peak color (config `spectrum_color`,
/// `spectrum_gradient`, `spectrum_peak_color`).
spectrum_style: SpectrumStyle,
/// Whether the server output is muted.
muted: bool,
/// The server's output level, `1.0` = 100%. This is the level playback
/// would resume at, so it stays meaningful while muted.
volume: f32,
}
impl Default for NowPlaying {
fn default() -> Self {
NowPlaying {
play_state: PlayState::Unspecified,
duration: None,
modifiers: QueueModifiers::default(),
position: None,
track: None,
spectrum: Vec::new(),
spectrum_peaks: Vec::new(),
spectrum_frame_at: None,
spectrum_enabled: true,
spectrum_style: SpectrumStyle::default(),
muted: false,
volume: 1.0,
}
}
}
impl NowPlaying {
pub fn update_play_state(&mut self, play_state: PlayState) {
self.play_state = play_state;
}
pub fn update_position(&mut self, pos: TrackPosition) {
self.position = Some(Duration::from_millis(pos.position.into()));
self.duration = Some(Duration::from_millis(pos.duration.into()));
}
pub fn update_track(&mut self, active: Option<Track>) {
if let Some(track) = &active {
notify_now_playing(track);
}
self.track = active;
}
pub fn update_modifiers(&mut self, mods: &QueueModifiers) {
self.modifiers = *mods;
}
/// Applies a spectrum frame from the server (already normalized),
/// timing it against the previous frame so the peaks fall at the
/// configured rate whatever the server's frame rate is.
pub fn update_spectrum(&mut self, bins: Vec<f32>) {
let now = Instant::now();
let elapsed = self
.spectrum_frame_at
.replace(now)
.map_or(Duration::ZERO, |previous| {
now.saturating_duration_since(previous)
});
self.advance_spectrum(bins, elapsed);
}
/// [`NowPlaying::update_spectrum`] with the frame interval given rather
/// than measured — the testable core.
///
/// A bin at or above its peak raises it at once; otherwise the peak
/// falls by whatever share of `peak_fall` this frame took. The bin count
/// comes off the wire, so the peaks follow whatever arrives.
fn advance_spectrum(&mut self, bins: Vec<f32>, elapsed: Duration) {
let fall = elapsed.as_secs_f32() / self.spectrum_style.peak_fall.max(MIN_PEAK_FALL);
self.spectrum_peaks.resize(bins.len(), 0.0);
for (peak, level) in self.spectrum_peaks.iter_mut().zip(&bins) {
let level = level_of(*level);
*peak = if level >= *peak {
level
} else {
(*peak - fall).max(0.0)
};
}
self.spectrum = bins;
}
/// Enables/disables the spectrum row (from client config).
pub fn set_spectrum_enabled(&mut self, enabled: bool) {
self.spectrum_enabled = enabled;
}
/// Sets how the bars are painted (from client config). Parsing the
/// config strings is the caller's job, so an unusable value never
/// reaches here.
pub fn set_spectrum_style(&mut self, style: SpectrumStyle) {
self.spectrum_style = style;
}
/// Shows or hides the spectrum row (the `v` keybinding). The server
/// keeps streaming the bars; this only gates rendering.
pub fn toggle_spectrum(&mut self) {
self.spectrum_enabled = !self.spectrum_enabled;
}
/// Reflects the server's mute state.
pub fn update_mute(&mut self, muted: bool) {
self.muted = muted;
}
/// Reflects the server's output level (see [`format_volume`]).
pub fn update_volume(&mut self, volume: f32) {
self.volume = volume;
}
pub fn render(&self, f: &mut Frame, area: Rect) {
// With the spectrum on, the info block takes exactly the height
// its content needs, the progress a fixed row, and the spectrum
// fills whatever is left (`Min(0)`) — so both size themselves and
// the bars are as tall as the pane allows. With it off, the info
// block fills as before.
let info_lines = if self.track.is_some() { 4 } else { 3 };
let info_height = info_lines + 2; // + top and bottom border
let constraints = if self.spectrum_enabled {
vec![
Constraint::Length(info_height),
Constraint::Length(1),
Constraint::Min(0),
]
} else {
vec![Constraint::Min(3), Constraint::Length(1)]
};
let now_playing_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(area);
// Shuffle, repeat and volume describe the *server*, not the track,
// so this line is drawn whether or not something is loaded — the
// volume you are about to press `K` on should be on screen before
// playback starts.
let mods = format!(
"Shuffle: {}, Repeat: {}, Volume: {}",
self.modifiers.shuffle,
self.modifiers.repeat,
format_volume(self.volume, self.muted),
);
let media_info_text = if let Some(track) = &self.track {
let play_text = match self.play_state {
PlayState::Loading => "",
PlayState::Paused => "",
PlayState::Playing => "",
_ => "",
};
let album_text = match &track.album {
Some(album) => album.title.to_string(),
None => "No album".to_string(),
};
vec![
Line::from(Span::raw(mods)),
Line::from(Span::raw(play_text)),
Line::from(vec![
Span::styled(
track.title.to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::raw(" by "),
Span::styled(
track.artist.to_string(),
Style::default().add_modifier(Modifier::BOLD),
),
]),
Line::from(Span::raw(album_text)),
]
} else {
vec![
Line::from(Span::raw(mods)),
Line::from(Span::raw("")),
Line::from(Span::raw("No track playing")),
]
};
let media_info_p = Paragraph::new(media_info_text)
.block(
Block::default()
.title("Now playing")
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(COLOR_SECONDARY)),
)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
f.render_widget(media_info_p, now_playing_layout[0]);
if let (Some(position), Some(duration), Some(_track)) =
(self.position, self.duration, &self.track)
{
let pos = position.as_secs();
let dur = duration.as_secs();
let completion_size = if dur < 3600 { 12 } else { 15 };
let elapsed_layout = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(10), Constraint::Max(completion_size)])
.split(now_playing_layout[1]);
// Clamped: the player's position can overrun a stale or wrong
// duration (streams, hand-written track files), and
// `LineGauge` panics on ratios outside 0..=1.
let ratio = if duration.is_zero() {
0.0
} else {
position
.as_secs_f64()
.div(duration.as_secs_f64())
.clamp(0.0, 1.0)
};
let progress = LineGauge::default()
.label("")
.block(Block::default().borders(Borders::NONE))
.filled_style(Style::default().fg(COLOR_SECONDARY).bg(Color::Black))
.ratio(ratio);
f.render_widget(progress, elapsed_layout[0]);
let pos_min = (pos / 60) % 60;
let pos_secs = pos % 60;
let dur_min = (dur / 60) % 60;
let dur_secs = dur % 60;
let completion_text = if dur < 3600 {
format!(
"{:0>2}:{:0>2}/{:0>2}:{:0>2}",
pos_min, pos_secs, dur_min, dur_secs,
)
} else {
let pos_hours = pos_secs / 60 / 60;
let dur_hours = dur_secs / 60 / 60;
format!(
"{:0>1}:{:0>2}:{:0>2}/{:0>1}:{:0>2}:{:0>2}",
pos_hours, pos_min, pos_secs, dur_hours, dur_min, dur_secs,
)
};
let time_text = Span::raw(completion_text);
let time_p = Paragraph::new(Line::from(time_text));
f.render_widget(time_p, elapsed_layout[1]);
}
// The spectrum: full-height bars filling the region left below the
// progress, in the configured color, under a peak-hold marker line.
// Columns stretch across the pane width regardless of the server's
// bin count.
if self.spectrum_enabled && !self.spectrum.is_empty() {
let area = now_playing_layout[2];
let (width, height) = (area.width as usize, area.height as usize);
if width > 0 && height > 0 {
let lines = spectrum_lines(
&self.spectrum,
&self.spectrum_peaks,
width,
height,
self.spectrum_style,
);
f.render_widget(Paragraph::new(lines), area);
}
}
}
}
/// Shows the desktop "now playing" notification. A missing notification daemon
/// must not crash the TUI, so a failure is only logged. The explicit appname
/// keeps notification-daemon rules (e.g. mako `app-name=` criteria) stable even
/// if the binary is renamed or wrapped.
#[cfg(feature = "notifications")]
fn notify_now_playing(track: &Track) {
let body = if let Some(ref album) = track.album {
format!(
"{} by {}\n\n{} ({})",
track.title,
track.artist,
album.title,
// FIXME: get out year and format differently if it's missing
album.release_date()
)
} else {
format!("{} by {}", track.title, track.artist)
};
if let Err(err) = Notification::new()
.appname("crabidy")
.summary("Now playing")
.body(&body)
.show()
{
tracing::debug!("could not show desktop notification: {err}");
}
}
/// Built without the `notifications` feature: nothing to show
/// (architecture/build-features.md D1).
#[cfg(not(feature = "notifications"))]
fn notify_now_playing(_track: &Track) {}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{backend::TestBackend, Terminal};
/// A now-playing pane mid-track, built directly (no `update_track`,
/// which fires a desktop notification).
fn now_playing(position_ms: u32, duration_ms: u32) -> NowPlaying {
NowPlaying {
play_state: PlayState::Playing,
duration: Some(Duration::from_millis(duration_ms.into())),
modifiers: QueueModifiers::default(),
position: Some(Duration::from_millis(position_ms.into())),
track: Some(Track {
path: "/fs/radio.cbd-track.toml".to_string(),
artist: "artist".to_string(),
title: "title".to_string(),
duration: None,
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
}),
spectrum: Vec::new(),
spectrum_peaks: Vec::new(),
spectrum_frame_at: None,
spectrum_enabled: true,
spectrum_style: SpectrumStyle::default(),
muted: false,
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,
top: None,
gradient: false,
peak: None,
peak_fill: true,
peak_fall: 4.0,
// One solid cell per bin, no seams and no row lines: the
// geometry tests are about the bars' height, and the layout
// tests own the widths and the dividers.
bar_width: 1,
bar_gap: 0,
row_gap: 0,
}
}
/// The glyph a completely filled row uses under `style` — `█` only when
/// no line divides the value rows.
fn full_cell(style: SpectrumStyle) -> char {
BLOCKS[8 - style.row_gap]
}
fn render(pane: &NowPlaying) {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| pane.render(f, f.area()))
.expect("draw must not panic");
}
/// Renders and returns the buffer rows as strings.
fn rendered_rows(pane: &NowPlaying) -> Vec<String> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
#[test]
fn spectrum_lines_fill_full_height_columns() {
// A full-level bin fills every row of its column with the full
// block; a zero bin leaves every row blank.
let lines = spectrum_lines(&[1.0, 0.0], &[], 2, 4, flat());
assert_eq!(lines.len(), 4, "one line per row");
let text: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
// Column 0 (level 1.0) is full in every row; column 1 (0.0) empty.
assert!(text.iter().all(|row| row.starts_with('█')), "{text:?}");
assert!(text.iter().all(|row| row.ends_with(' ')), "{text:?}");
}
#[test]
fn spectrum_lines_grow_from_the_bottom() {
// Half level over 4 rows ≈ 16 eighths → the bottom two rows fill.
let lines = spectrum_lines(&[0.5], &[], 1, 4, flat());
let col: Vec<char> = lines
.iter()
.map(|l| l.spans[0].content.chars().next().unwrap())
.collect();
// Top rows empty, bottom rows full — bars rise from the floor.
assert_eq!(col[0], ' ', "top empty: {col:?}");
assert_eq!(col[3], '█', "bottom full: {col:?}");
}
#[test]
fn the_spectrum_renders_block_glyphs_when_enabled() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
let rows = rendered_rows(&pane);
// Full-level bars fill several rows to the row gap.
let filled = full_cell(SpectrumStyle::default());
let full_rows = rows.iter().filter(|r| r.contains(filled)).count();
assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}");
}
/// The foreground of the first full-block cell in the buffer — the
/// color the bars are actually drawn in.
fn first_bar_color(pane: &NowPlaying) -> Option<Color> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
let buffer = terminal.backend().buffer().clone();
let filled = full_cell(pane.spectrum_style).to_string();
(0..buffer.area.height)
.flat_map(|y| (0..buffer.area.width).map(move |x| (x, y)))
.find(|&(x, y)| buffer[(x, y)].symbol() == filled)
.and_then(|(x, y)| buffer[(x, y)].style().fg)
}
/// Every distinct foreground the full-block cells are drawn in.
fn bar_colors(pane: &NowPlaying) -> Vec<Color> {
let backend = TestBackend::new(60, 12);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(|f| pane.render(f, f.area())).expect("draw");
let buffer = terminal.backend().buffer().clone();
let filled = full_cell(pane.spectrum_style).to_string();
let mut colors: Vec<Color> = Vec::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
if buffer[(x, y)].symbol() != filled {
continue;
}
if let Some(fg) = buffer[(x, y)].style().fg {
if !colors.contains(&fg) {
colors.push(fg);
}
}
}
}
colors
}
#[test]
fn the_default_style_is_the_queue_red_under_a_blue_peak() {
let style = SpectrumStyle::default();
assert_eq!(style.color, super::super::COLOR_RED);
assert_eq!(style.top, Some(super::super::COLOR_SECONDARY));
assert_eq!(style.peak, Some(super::super::COLOR_PRIMARY));
assert!(style.gradient, "shaded out of the box");
assert!(style.peak_fill, "shadows filled out of the box");
assert_eq!(
(style.bar_width, style.bar_gap, style.row_gap),
(1, 1, 1),
"connected bars, a seam between them, a line between rows"
);
assert_eq!(style.peak_fall, 4.0);
}
#[test]
fn flat_bars_are_drawn_in_the_configured_color() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
pane.set_spectrum_style(SpectrumStyle {
color: Color::Rgb(1, 2, 3),
..flat()
});
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)
);
}
/// Channel sum, as a stand-in for how bright a color reads.
fn brightness(color: Color) -> u32 {
match color {
Color::Rgb(r, g, b) => u32::from(r) + u32::from(g) + u32::from(b),
other => panic!("expected rgb, got {other:?}"),
}
}
#[test]
fn the_gradient_runs_dim_at_the_floor_to_bright_at_the_top() {
let base = super::super::COLOR_RED;
let bottom = gradient_color(base, None, 0, 8);
let top = gradient_color(base, None, 7, 8);
assert!(
brightness(bottom) < brightness(base),
"floor dimmer than the base: {bottom:?}"
);
// Without a top color the ramp is brightness alone, so the top row
// is the base itself rather than something hotter.
assert_eq!(top, base, "top of a topless ramp is the base");
// Monotonic in between, so the ramp reads as one gradient.
let ramp: Vec<u32> = (0..8)
.map(|r| brightness(gradient_color(base, None, r, 8)))
.collect();
assert!(ramp.windows(2).all(|w| w[0] <= w[1]), "{ramp:?}");
// A one-row area is all "top": the single row keeps the full color
// rather than being dimmed to the floor.
assert_eq!(gradient_color(base, 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]
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, None, 0, 8), color);
assert_eq!(gradient_color(color, Some(COLOR_RED), 7, 8), color);
}
// A named *top* cannot be interpolated either, so the ramp falls
// back to brightness alone rather than guessing its RGB.
let base = super::super::COLOR_RED;
assert_eq!(
gradient_color(base, Some(Color::LightBlue), 7, 8),
gradient_color(base, None, 7, 8)
);
}
/// The single column of a one-wide render, bottom row first, as
/// (glyph, color) pairs.
fn column_of(lines: &[Line<'static>]) -> Vec<(char, Option<Color>)> {
lines
.iter()
.rev()
.map(|line| {
let span = &line.spans[0];
(span.content.chars().next().expect("a cell"), span.style.fg)
})
.collect()
}
#[test]
fn a_filled_shadow_reaches_from_the_bar_up_to_the_peak() {
// Bar at an eighth of the area, peak still at full scale: the rows
// between are filled in the peak color, the bar keeps its own.
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
..flat()
};
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style));
assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor");
for (row, cell) in column.iter().enumerate().skip(1) {
assert_eq!(
*cell,
('█', Some(COLOR_PRIMARY)),
"row {row} is filled shadow"
);
}
}
#[test]
fn an_unfilled_shadow_is_a_rule_at_the_peak_alone() {
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
peak_fill: false,
..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[1].0, ' ', "nothing under the rule");
assert_eq!(column[2].0, ' ', "nothing under the rule");
assert_eq!(column[3], (PEAK_MARK, Some(COLOR_PRIMARY)), "the rule");
}
#[test]
fn a_shadow_inside_the_bar_is_not_drawn() {
// A cell holds one glyph: a shadow in a row the bar occupies would
// eat bar to repeat what its top edge already shows.
for peak_fill in [true, false] {
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
peak_fill,
..flat()
};
let lines = spectrum_lines(&[1.0], &[1.0], 1, 4, style);
assert!(
lines
.iter()
.all(|l| l.spans.iter().all(|s| s.style.fg == Some(COLOR_RED))),
"a peak at the bar top must not overwrite it (fill: {peak_fill})"
);
}
}
#[test]
fn shadows_are_left_out_when_switched_off() {
// `spectrum_peak_color = "none"`: the rows above the bar stay empty.
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, flat()));
assert_eq!(column[0], ('█', Some(COLOR_RED)), "bar at the floor");
assert!(
column[1..].iter().all(|(glyph, _)| *glyph == ' '),
"no shadow: {column:?}"
);
}
/// A single rendered row as (glyph, color) per column.
fn row_of(lines: &[Line<'static>], row: usize) -> Vec<(char, Option<Color>)> {
lines[row]
.spans
.iter()
.flat_map(|span| span.content.chars().map(move |c| (c, span.style.fg)))
.collect()
}
#[test]
fn a_seam_divides_the_bars_without_separating_them() {
// Four bins across twelve columns: each bar takes its slot but its
// last column, which carries the same glyph dimmed. The field stays
// connected — no column is empty where a bar stands.
let style = SpectrumStyle {
bar_gap: 1,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 4], &[], 12, 1, style), 0);
assert!(row.iter().all(|(glyph, _)| *glyph == '█'), "{row:?}");
let colors: Vec<Option<Color>> = row.iter().map(|(_, color)| *color).collect();
assert_eq!(
colors,
vec![
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
Some(COLOR_RED),
Some(COLOR_RED),
Some(seam),
]
);
}
#[test]
fn bars_take_the_spare_columns_rather_than_the_seams() {
// `bar_width` is a floor, not a width: two bins over nine columns
// give bars of three with a one-cell seam, not bars of one.
let style = SpectrumStyle {
bar_width: 1,
bar_gap: 1,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 2], &[], 8, 1, style), 0);
let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count();
assert_eq!(seams, 2, "one seam per bar: {row:?}");
}
#[test]
fn a_wider_seam_takes_more_columns() {
let style = SpectrumStyle {
bar_gap: 3,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 3], &[], 12, 1, style), 0);
let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count();
assert_eq!(seams, 9, "three cells of seam per bar: {row:?}");
}
#[test]
fn bars_never_outnumber_the_bins() {
// A wide pane and few bins: bars would otherwise repeat their
// neighbours, which reads as data that is not there.
let style = SpectrumStyle {
bar_gap: 1,
..flat()
};
let seam = dimmed(COLOR_RED, DIVIDER_DIM).expect("rgb dims");
let row = row_of(&spectrum_lines(&[1.0; 3], &[], 40, 1, style), 0);
let seams = row.iter().filter(|(_, color)| *color == Some(seam)).count();
assert_eq!(seams, 3, "three bars, three seams: {row:?}");
}
#[test]
fn a_bar_too_wide_for_its_slot_keeps_its_cell() {
// Bars wider than the pane can give them: every column is bar, and
// the seam goes rather than the bar.
let style = SpectrumStyle {
bar_width: 40,
bar_gap: 8,
..flat()
};
let row = row_of(&spectrum_lines(&[1.0, 0.0], &[], 4, 1, style), 0);
assert_eq!(row.len(), 4);
assert!(
row.iter()
.any(|(glyph, color)| *glyph == '█' && *color == Some(COLOR_RED)),
"still draws a bar: {row:?}"
);
}
#[test]
fn a_seam_cannot_be_dimmed_from_a_named_color_so_it_empties() {
// A palette color has no RGB of ours to dim, so the seam falls back
// to the one divider that needs no color.
let style = SpectrumStyle {
color: Color::LightBlue,
bar_gap: 1,
..flat()
};
let row = row_of(&spectrum_lines(&[1.0; 2], &[], 6, 1, style), 0);
let blanks = row.iter().filter(|(glyph, _)| *glyph == ' ').count();
assert_eq!(blanks, 2, "one empty seam per bar: {row:?}");
}
#[test]
fn a_row_gap_leaves_the_top_of_each_cell_dark() {
// The line between value rows: a full row stops short of the cell
// top by the gap, so the segments of a bar can be told apart.
for (row_gap, expected) in [(0, '█'), (1, '▇'), (2, '▆'), (4, '▄')] {
let style = SpectrumStyle { row_gap, ..flat() };
let row = row_of(&spectrum_lines(&[1.0], &[], 1, 4, style), 0);
assert_eq!(row[0].0, expected, "row_gap {row_gap}");
}
}
#[test]
fn a_row_gap_lowers_a_shadow_the_same_way() {
// Shadows are bars too: a solid shadow keeps the same line between
// rows, or it would read as a different kind of thing.
let style = SpectrumStyle {
peak: Some(COLOR_PRIMARY),
row_gap: 1,
..flat()
};
let column = column_of(&spectrum_lines(&[0.25], &[1.0], 1, 4, style));
assert_eq!(column[0], ('▇', Some(COLOR_RED)), "bar at the floor");
assert_eq!(column[3], ('▇', Some(COLOR_PRIMARY)), "shadow on top");
}
#[test]
fn a_narrow_pane_folds_bins_together_by_their_loudest() {
// Two bins to one bar: the loud one must survive, since a spectrum's
// news is its peaks.
let lines = spectrum_lines(&[0.0, 1.0], &[], 1, 4, flat());
let column = column_of(&lines);
assert!(
column.iter().all(|(glyph, _)| *glyph == '█'),
"the loud bin wins: {column:?}"
);
}
#[test]
fn a_peak_holds_then_falls_over_the_configured_seconds() {
let mut pane = now_playing(10_000, 60_000);
pane.set_spectrum_style(SpectrumStyle {
peak_fall: 2.0,
..SpectrumStyle::default()
});
pane.advance_spectrum(vec![1.0], Duration::ZERO);
assert_eq!(
pane.spectrum_peaks,
vec![1.0],
"a hit sets the peak at once"
);
// Silence afterwards: half the fall time takes it half way down.
pane.advance_spectrum(vec![0.0], Duration::from_secs(1));
assert!(
(pane.spectrum_peaks[0] - 0.5).abs() < 1e-6,
"{:?}",
pane.spectrum_peaks
);
pane.advance_spectrum(vec![0.0], Duration::from_secs(1));
assert_eq!(pane.spectrum_peaks, vec![0.0], "and reaches the floor");
}
#[test]
fn the_fall_is_paced_in_seconds_not_frames() {
// The server's frame rate is not ours to rely on: twenty frames of
// a twentieth of a second must fall as far as one frame of a second.
let fall_after = |frames: u32, each: Duration| {
let mut pane = now_playing(10_000, 60_000);
pane.set_spectrum_style(SpectrumStyle {
peak_fall: 4.0,
..SpectrumStyle::default()
});
pane.advance_spectrum(vec![1.0], Duration::ZERO);
for _ in 0..frames {
pane.advance_spectrum(vec![0.0], each);
}
pane.spectrum_peaks[0]
};
let many = fall_after(20, Duration::from_millis(50));
let one = fall_after(1, Duration::from_secs(1));
assert!((many - one).abs() < 1e-5, "{many} vs {one}");
}
#[test]
fn an_unusable_fall_time_still_falls() {
// `peak_fall` is clamped in config, but the renderer is not entitled
// to assume that: a zero would divide by zero and freeze the peaks.
let mut pane = now_playing(10_000, 60_000);
pane.set_spectrum_style(SpectrumStyle {
peak_fall: 0.0,
..SpectrumStyle::default()
});
pane.advance_spectrum(vec![1.0], Duration::ZERO);
pane.advance_spectrum(vec![0.0], Duration::from_millis(100));
assert_eq!(pane.spectrum_peaks, vec![0.0]);
}
#[test]
fn a_peak_follows_a_changed_bin_count() {
// The bin count comes off the wire and may change mid-stream.
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(full_cell(pane.spectrum_style))),
"nonsense levels must draw nothing: {rows:?}"
);
}
#[test]
fn the_spectrum_row_is_hidden_when_disabled() {
let mut pane = now_playing(10_000, 60_000);
pane.set_spectrum_enabled(false);
pane.update_spectrum(vec![1.0; 24]);
let rows = rendered_rows(&pane);
assert!(
!rows
.iter()
.any(|r| r.contains(full_cell(pane.spectrum_style))),
"disabled spectrum must not draw bars: {rows:?}"
);
}
#[test]
fn toggle_spectrum_hides_then_shows_the_bars() {
let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]);
let filled = full_cell(pane.spectrum_style);
assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
// `f` hides the bars…
pane.toggle_spectrum();
assert!(!rendered_rows(&pane).iter().any(|r| r.contains(filled)));
// …and again brings them back.
pane.toggle_spectrum();
assert!(rendered_rows(&pane).iter().any(|r| r.contains(filled)));
}
#[test]
fn the_volume_reads_as_a_percentage() {
assert_eq!(format_volume(1.0, false), "100%");
assert_eq!(format_volume(0.85, false), "85%");
assert_eq!(format_volume(0.0, false), "0%");
// The server clamps to 1.1, so the display tops out at 110%.
assert_eq!(format_volume(1.1, false), "110%");
}
/// Muting must not hide the level: the server reports the level it
/// would unmute to, which is what the user is about to adjust.
#[test]
fn a_muted_server_still_shows_its_level() {
assert_eq!(format_volume(0.4, true), "40% (muted)");
}
/// The wire carries a `float`, so NaN/infinity are reachable from a
/// wrong peer and must not render as `NaN%`.
#[test]
fn a_broken_volume_renders_as_unknown() {
assert_eq!(format_volume(f32::NAN, false), "--");
assert_eq!(format_volume(f32::INFINITY, false), "--");
assert_eq!(format_volume(f32::NEG_INFINITY, true), "-- (muted)");
// Negative zero would otherwise print as `-0%`.
assert_eq!(format_volume(-0.0, false), "0%");
}
#[test]
fn the_volume_is_on_screen_while_playing() {
let mut pane = now_playing(10_000, 60_000);
pane.update_volume(0.6);
let rows = rendered_rows(&pane);
assert!(
rows.iter().any(|r| r.contains("Volume: 60%")),
"expected the level in the status line: {rows:?}"
);
}
/// Volume describes the server, not the track, so it is visible before
/// anything is loaded — that is when you reach for `K` blind.
#[test]
fn the_volume_is_on_screen_without_a_track() {
let mut pane = NowPlaying::default();
pane.update_volume(0.25);
pane.update_mute(true);
let rows = rendered_rows(&pane);
assert!(
rows.iter().any(|r| r.contains("Volume: 25% (muted)")),
"expected the level with no track loaded: {rows:?}"
);
}
/// The position can overrun a stale or wrong duration (streams,
/// hand-written track files); the gauge must clamp instead of hitting
/// ratatui's `ratio should be between 0 and 1` panic.
#[test]
fn progress_gauge_survives_position_past_duration() {
render(&now_playing(90_000, 60_000));
}
#[test]
fn progress_gauge_survives_a_zero_duration() {
render(&now_playing(5_000, 0));
}
}