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, /// 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, /// 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 { 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 { 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 { 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, 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> { 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::>(), ) }) .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, modifiers: QueueModifiers, position: Option, track: Option, /// Latest frequency-spectrum bars (architecture/spectrum.md), empty /// until the first frame arrives. spectrum: Vec, /// 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, /// 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, /// 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) { 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) { 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, 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 { 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::() }) .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 = 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 = 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 { 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 { 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 = 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 = (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)> { 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)> { 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> = 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)); } }