1321 lines
50 KiB
Rust
1321 lines
50 KiB
Rust
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));
|
||
}
|
||
}
|