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};
/// 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<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 {
@ -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<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 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);
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]