From b3fec6d3c023b26d834c0b6a8d5c439883d490ee Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 22 Jul 2026 00:06:49 +0200 Subject: [PATCH] Make the TUI spectrum bars fill the height below the progress The bars were a single glyph row because the info block grew to fill the now-playing pane. The info block now takes a fixed height and the spectrum fills the remaining rows, drawn as full-height columns (full blocks stacked from the bottom, a partial block for the fractional top cell) instead of one row of sub-cell glyphs. Co-Authored-By: Claude Opus 4.8 (1M context) --- cbd-tui/src/app/now_playing.rs | 124 ++++++++++++++++++++++----------- 1 file changed, 82 insertions(+), 42 deletions(-) diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index d02cb62..b324da4 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -14,13 +14,38 @@ use ratatui::{ use super::{COLOR_PRIMARY, COLOR_SECONDARY}; -/// Vertical block glyphs by eighths, index 0 = empty. Maps a bar level -/// in `[0, 1]` to a height. +/// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell. const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; -fn bar_glyph(level: f32) -> char { - let idx = (level.clamp(0.0, 1.0) * 8.0).round() as usize; - BLOCKS[idx.min(8)] +/// Fixed height of the now-playing info block (4 text lines + border) +/// when the spectrum is shown; the spectrum then fills the rest. +const INFO_HEIGHT: u16 = 6; + +/// Renders `bins` as full-height vertical bars filling a `width`×`height` +/// area: one `Line` per row, top row first. Each column maps to a bin; +/// its level in `[0, 1]` fills from the bottom, using partial block +/// glyphs for the topmost fractional cell. Pure, so it is unit-tested. +fn spectrum_lines(bins: &[f32], width: usize, height: usize) -> Vec> { + (0..height) + .map(|row| { + // Row 0 is the top; count cells up from the bottom. + let from_bottom = height - 1 - row; + let cells: String = (0..width) + .map(|col| { + let bin = if bins.is_empty() { + 0 + } else { + (col * bins.len() / width.max(1)).min(bins.len() - 1) + }; + let level = bins.get(bin).copied().unwrap_or(0.0).clamp(0.0, 1.0); + let total_eighths = (level * height as f32 * 8.0).round() as usize; + let cell = total_eighths.saturating_sub(from_bottom * 8).min(8); + BLOCKS[cell] + }) + .collect(); + Line::from(Span::styled(cells, Style::default().fg(COLOR_PRIMARY))) + }) + .collect() } pub struct NowPlaying { @@ -100,17 +125,22 @@ impl NowPlaying { } pub fn render(&self, f: &mut Frame, area: Rect) { - // Info block fills, then a fixed progress row and (when enabled) - // a fixed spectrum row. Length(1) is hard-fixed so the rows are - // never squeezed out by the block above them. - let spectrum_row = if self.spectrum_enabled { 1 } else { 0 }; + // With the spectrum on, the info block takes a fixed height, the + // progress a fixed row, and the spectrum fills whatever is left + // (`Min(0)`) so the bars are as tall as the pane allows. With it + // off, the info block fills as before. + 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([ - Constraint::Min(3), - Constraint::Length(1), - Constraint::Length(spectrum_row), - ]) + .constraints(constraints) .split(area); let media_info_text = if let Some(track) = &self.track { @@ -221,23 +251,16 @@ impl NowPlaying { f.render_widget(time_p, elapsed_layout[1]); } - // The spectrum row: one block glyph per bar, in the accent color. - // Stretched to the pane width so it fills the row regardless of - // the server's bin count. + // The spectrum: full-height accent bars filling the region left + // below the progress. Columns stretch across the pane width + // regardless of the server's bin count. if self.spectrum_enabled && !self.spectrum.is_empty() { - let width = now_playing_layout[2].width as usize; - let bars: String = (0..width) - .map(|col| { - // Nearest-neighbor map from column to bin. - let bin = col * self.spectrum.len() / width.max(1); - bar_glyph(self.spectrum[bin.min(self.spectrum.len() - 1)]) - }) - .collect(); - let spectrum_p = Paragraph::new(Line::from(Span::styled( - bars, - Style::default().fg(COLOR_PRIMARY), - ))); - f.render_widget(spectrum_p, now_playing_layout[2]); + 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, width, height); + f.render_widget(Paragraph::new(lines), area); + } } } } @@ -292,24 +315,41 @@ mod tests { } #[test] - fn bar_glyph_maps_level_to_height() { - assert_eq!(bar_glyph(0.0), ' '); - assert_eq!(bar_glyph(1.0), '█'); - assert_eq!(bar_glyph(-5.0), ' ', "clamped"); - assert_eq!(bar_glyph(2.0), '█', "clamped"); - assert!(BLOCKS.contains(&bar_glyph(0.5))); + 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); + 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 the_spectrum_row_renders_block_glyphs_when_enabled() { + 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); + 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); - // A full-height frame draws the tallest block somewhere. - assert!( - rows.iter().any(|r| r.contains('█')), - "expected spectrum bars, got: {rows:?}" - ); + // Full-level bars fill several rows with the full block. + let full_rows = rows.iter().filter(|r| r.contains('█')).count(); + assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}"); } #[test]