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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-22 00:06:49 +02:00
parent 0b9970b550
commit b3fec6d3c0
1 changed files with 82 additions and 42 deletions

View File

@ -14,13 +14,38 @@ use ratatui::{
use super::{COLOR_PRIMARY, COLOR_SECONDARY}; use super::{COLOR_PRIMARY, COLOR_SECONDARY};
/// Vertical block glyphs by eighths, index 0 = empty. Maps a bar level /// Vertical block glyphs by eighths, index 0 = empty, 8 = full cell.
/// in `[0, 1]` to a height.
const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
fn bar_glyph(level: f32) -> char { /// Fixed height of the now-playing info block (4 text lines + border)
let idx = (level.clamp(0.0, 1.0) * 8.0).round() as usize; /// when the spectrum is shown; the spectrum then fills the rest.
BLOCKS[idx.min(8)] 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<Line<'static>> {
(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 { pub struct NowPlaying {
@ -100,17 +125,22 @@ impl NowPlaying {
} }
pub fn render(&self, f: &mut Frame, area: Rect) { pub fn render(&self, f: &mut Frame, area: Rect) {
// Info block fills, then a fixed progress row and (when enabled) // With the spectrum on, the info block takes a fixed height, the
// a fixed spectrum row. Length(1) is hard-fixed so the rows are // progress a fixed row, and the spectrum fills whatever is left
// never squeezed out by the block above them. // (`Min(0)`) so the bars are as tall as the pane allows. With it
let spectrum_row = if self.spectrum_enabled { 1 } else { 0 }; // 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() let now_playing_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints(constraints)
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(spectrum_row),
])
.split(area); .split(area);
let media_info_text = if let Some(track) = &self.track { let media_info_text = if let Some(track) = &self.track {
@ -221,23 +251,16 @@ impl NowPlaying {
f.render_widget(time_p, elapsed_layout[1]); f.render_widget(time_p, elapsed_layout[1]);
} }
// The spectrum row: one block glyph per bar, in the accent color. // The spectrum: full-height accent bars filling the region left
// Stretched to the pane width so it fills the row regardless of // below the progress. Columns stretch across the pane width
// the server's bin count. // regardless of the server's bin count.
if self.spectrum_enabled && !self.spectrum.is_empty() { if self.spectrum_enabled && !self.spectrum.is_empty() {
let width = now_playing_layout[2].width as usize; let area = now_playing_layout[2];
let bars: String = (0..width) let (width, height) = (area.width as usize, area.height as usize);
.map(|col| { if width > 0 && height > 0 {
// Nearest-neighbor map from column to bin. let lines = spectrum_lines(&self.spectrum, width, height);
let bin = col * self.spectrum.len() / width.max(1); f.render_widget(Paragraph::new(lines), area);
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]);
} }
} }
} }
@ -292,24 +315,41 @@ mod tests {
} }
#[test] #[test]
fn bar_glyph_maps_level_to_height() { fn spectrum_lines_fill_full_height_columns() {
assert_eq!(bar_glyph(0.0), ' '); // A full-level bin fills every row of its column with the full
assert_eq!(bar_glyph(1.0), '█'); // block; a zero bin leaves every row blank.
assert_eq!(bar_glyph(-5.0), ' ', "clamped"); let lines = spectrum_lines(&[1.0, 0.0], 2, 4);
assert_eq!(bar_glyph(2.0), '█', "clamped"); assert_eq!(lines.len(), 4, "one line per row");
assert!(BLOCKS.contains(&bar_glyph(0.5))); 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] #[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<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); let mut pane = now_playing(10_000, 60_000);
pane.update_spectrum(vec![1.0; 24]); pane.update_spectrum(vec![1.0; 24]);
let rows = rendered_rows(&pane); let rows = rendered_rows(&pane);
// A full-height frame draws the tallest block somewhere. // Full-level bars fill several rows with the full block.
assert!( let full_rows = rows.iter().filter(|r| r.contains('█')).count();
rows.iter().any(|r| r.contains('█')), assert!(full_rows >= 2, "expected tall spectrum bars, got: {rows:?}");
"expected spectrum bars, got: {rows:?}"
);
} }
#[test] #[test]