Skip to main content

rich_ext/chart/
bar_spectrum.rs

1//! A bar spectrum, as cava draws one: a level per band as a column rising
2//! from the bottom, optionally mirrored about the middle.
3
4use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
6
7use super::plot_canvas::spec_style;
8use super::{Charset, Line, lines_to_segments};
9
10/// A column's top, in eighths of a cell, lowest first.
11const EIGHTHS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
12/// Rows a bar spectrum takes when it is given no height and
13/// [`BarSpectrum::height`] is not set.
14const DEFAULT_HEIGHT: usize = 8;
15/// The tallest bar spectrum drawn, in rows.
16const MAX_HEIGHT: usize = u16::MAX as usize;
17
18/// Levels as columns rising from the bottom, as cava and a graphic
19/// equaliser show a spectrum: each band (from
20/// [`dsp::bands`](crate::dsp::bands), lowest first, falling under
21/// [`dsp::Falloff`](crate::dsp::Falloff) for cava's look) a bar
22/// [`bar_width`](Self::bar_width) cells wide, [`gap`](Self::gap) cells
23/// apart, its top in eighths of a cell.
24///
25/// - [`mirror`](Self::mirror) draws the bands twice, the low ones in the
26///   middle and the high ones at both edges (cava's mirrored layout);
27///   [`stereo`](Self::stereo) puts one channel on each side, the left
28///   reversed.
29/// - Levels are scaled from 0 to the loudest band (or
30///   [`range`](Self::range)); a level outside is drawn at the end it
31///   passes, a NaN as 0.
32/// - The bars are centred in the width they are given. When they do not
33///   fit, the gaps go first, then the bars narrow to a cell, then
34///   neighbouring bands share a column (the louder shown).
35/// - It fills the height it is given (a `Layout` region, an intuiTUIve
36///   node) unless [`height`](Self::height) is set; given none, it is 8
37///   rows. In ASCII, each bar is `#`s to the nearest whole cell.
38///
39/// ```
40/// use rich::Console;
41/// use rich_ext::chart::BarSpectrum;
42///
43/// let console = Console::builder().width(7).color_system(None).build();
44/// let bars = BarSpectrum::new([1.0, 0.5, 0.25]).range(1.0).height(2);
45/// assert_eq!(console.render_export(&bars), " █     \n █ █ ▄ \n");
46/// // Mirrored: the low bands in the middle.
47/// let console = Console::builder().width(11).color_system(None).build();
48/// let mirrored = bars.mirror(true);
49/// assert_eq!(console.render_export(&mirrored), "    █ █    \n▄ █ █ █ █ ▄\n");
50/// ```
51#[derive(Clone, Debug, PartialEq)]
52#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
53pub struct BarSpectrum {
54    left: Vec<f64>,
55    right: Option<Vec<f64>>,
56    mirror: bool,
57    max: Option<f64>,
58    bar_width: usize,
59    gap: usize,
60    style: Option<String>,
61    charset: Charset,
62    height: Option<usize>,
63}
64
65impl BarSpectrum {
66    /// A bar per band of `bands`, lowest frequency first.
67    pub fn new(bands: impl IntoIterator<Item = f64>) -> Self {
68        BarSpectrum {
69            left: bands.into_iter().collect(),
70            right: None,
71            mirror: false,
72            max: None,
73            bar_width: 1,
74            gap: 1,
75            style: None,
76            charset: Charset::Auto,
77            height: None,
78        }
79    }
80
81    /// Two channels, mirrored: `left`'s bands from the middle out to the
82    /// left edge, `right`'s from the middle to the right edge.
83    pub fn stereo(
84        left: impl IntoIterator<Item = f64>,
85        right: impl IntoIterator<Item = f64>,
86    ) -> Self {
87        let mut bars = BarSpectrum::new(left);
88        bars.right = Some(right.into_iter().collect());
89        bars.mirror = true;
90        bars
91    }
92
93    /// Draw the bands twice, the low ones in the middle (default false).
94    /// A [`stereo`](Self::stereo) spectrum is always mirrored.
95    pub fn mirror(mut self, mirror: bool) -> Self {
96        self.mirror = mirror || self.right.is_some();
97        self
98    }
99
100    /// The level of a full bar (default: the loudest band). Bars start at
101    /// 0.
102    pub fn range(mut self, max: f64) -> Self {
103        self.max = (max.is_finite() && max > 0.0).then_some(max);
104        self
105    }
106
107    /// Cells a bar is wide (default 1, at least 1).
108    pub fn bar_width(mut self, cells: usize) -> Self {
109        self.bar_width = cells.max(1);
110        self
111    }
112
113    /// Cells between bars (default 1).
114    pub fn gap(mut self, cells: usize) -> Self {
115        self.gap = cells;
116        self
117    }
118
119    /// The bars' style: a theme key or a definition (default
120    /// `chart.bar`).
121    pub fn style(mut self, style: impl Into<String>) -> Self {
122        self.style = Some(style.into());
123        self
124    }
125
126    /// Which glyphs to draw with (default [`Charset::Auto`]: blocks, or
127    /// ASCII on an ASCII-only console).
128    pub fn charset(mut self, charset: Charset) -> Self {
129        self.charset = charset;
130        self
131    }
132
133    /// Height in rows (at most 65535), whatever height it is given.
134    pub fn height(mut self, rows: usize) -> Self {
135        self.height = Some(rows.min(MAX_HEIGHT));
136        self
137    }
138
139    /// The bands, as given: the left channel's (or the only one), and the
140    /// right channel's for a [`stereo`](Self::stereo) spectrum.
141    pub fn bands(&self) -> (&[f64], Option<&[f64]>) {
142        (&self.left, self.right.as_deref())
143    }
144
145    /// The levels in the order they are drawn, left to right.
146    fn bars(&self) -> Vec<f64> {
147        let clean = |v: &f64| if v.is_nan() { 0.0 } else { *v };
148        let mut bars: Vec<f64> = Vec::new();
149        if self.mirror {
150            bars.extend(self.left.iter().rev().map(clean));
151        }
152        bars.extend(self.right.as_ref().unwrap_or(&self.left).iter().map(clean));
153        bars
154    }
155
156    /// The level of a full bar.
157    fn top(&self, bars: &[f64]) -> f64 {
158        self.max.unwrap_or_else(|| {
159            let loudest = bars
160                .iter()
161                .copied()
162                .filter(|v| v.is_finite())
163                .fold(0.0, f64::max);
164            if loudest > 0.0 { loudest } else { 1.0 }
165        })
166    }
167
168    fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
169        let width = options.max_width;
170        let height = self
171            .height
172            .or(options.height)
173            .unwrap_or(DEFAULT_HEIGHT)
174            .min(MAX_HEIGHT);
175        let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
176        let style: Option<Style> =
177            spec_style(console, self.style.as_deref().unwrap_or("chart.bar"));
178        let mut bars = self.bars();
179        let top = self.top(&bars);
180        // Fit: the gaps, then the bars' width, then share columns.
181        let (mut bar_width, mut gap) = (self.bar_width, self.gap);
182        let span = |n: usize, w: usize, g: usize| n * w + n.saturating_sub(1) * g;
183        if span(bars.len(), bar_width, gap) > width {
184            gap = 0;
185        }
186        if span(bars.len(), bar_width, gap) > width {
187            bar_width = (width / bars.len().max(1)).max(1);
188        }
189        if bars.len() > width && width > 0 {
190            let n = bars.len();
191            bars = (0..width)
192                .map(|c| {
193                    let (from, to) = (c * n / width, ((c + 1) * n / width).max(c * n / width + 1));
194                    bars[from..to.min(n)].iter().copied().fold(0.0, f64::max)
195                })
196                .collect();
197        }
198        let used = span(bars.len(), bar_width, gap).min(width);
199        let left = (width - used) / 2;
200        // Each bar's height in eighths of a cell (whole cells in ASCII).
201        let eighths: Vec<usize> = bars
202            .iter()
203            .map(|v| {
204                let share = (v / top).clamp(0.0, 1.0);
205                if ascii {
206                    (share * height as f64).round() as usize * 8
207                } else {
208                    (share * (height * 8) as f64).round() as usize
209                }
210            })
211            .collect();
212        (0..height)
213            .map(|r| {
214                let above = (height - 1 - r) * 8;
215                let mut line = Line::new();
216                line.pad(left);
217                for (i, &e) in eighths.iter().enumerate() {
218                    if i > 0 {
219                        line.pad(gap);
220                    }
221                    let glyph = match e.saturating_sub(above) {
222                        0 => ' ',
223                        n if n >= 8 => {
224                            if ascii {
225                                '#'
226                            } else {
227                                '█'
228                            }
229                        }
230                        n => EIGHTHS[n],
231                    };
232                    if glyph == ' ' {
233                        line.pad(bar_width);
234                    } else {
235                        line.push(&glyph.to_string().repeat(bar_width), style.clone());
236                    }
237                }
238                line.pad(width.saturating_sub(line.width()));
239                line
240            })
241            .collect()
242    }
243}
244
245impl crate::surface::Draw for BarSpectrum {
246    fn draw(
247        &self,
248        console: &Console,
249        options: &ConsoleOptions,
250        screen: &mut crate::surface::Screen,
251        rect: crate::surface::Rect,
252    ) {
253        super::draw_lines(
254            self.lines(console, options),
255            options.max_width,
256            options,
257            screen,
258            rect,
259        );
260    }
261}
262
263impl Renderable for BarSpectrum {
264    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
265        lines_to_segments(self.lines(console, options), options.max_width)
266    }
267
268    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
269        let n = self.bars().len().max(1);
270        let natural = n * self.bar_width + (n - 1) * self.gap;
271        Measurement::new(n.min(natural), natural)
272            .with_maximum(options.max_width)
273            .normalize()
274    }
275}
276
277/// `Bar spectrum, 16 bands, mirrored, levels from 0 to 0.8`, then the
278/// loudest band (`Loudest: band 3 of 16, 0.8`); a stereo one says each
279/// channel's.
280impl crate::a11y::AccessibleText for BarSpectrum {
281    fn accessible_text(&self, _width: usize) -> String {
282        use super::ValueFormat;
283        use super::describe::{counted, extent, range};
284        if self.left.is_empty() && self.right.as_ref().is_none_or(Vec::is_empty) {
285            return "Bar spectrum, no bands".to_string();
286        }
287        let mut head = format!(
288            "Bar spectrum, {}",
289            counted(self.left.len(), "band", "bands")
290        );
291        if self.right.is_some() {
292            head.push_str(", stereo");
293        } else if self.mirror {
294            head.push_str(", mirrored");
295        }
296        let all = self.left.iter().chain(self.right.iter().flatten()).copied();
297        if let Some((lo, hi)) = extent(all) {
298            head.push_str(&format!(", levels {}", range(lo, hi, ValueFormat::Compact)));
299        }
300        let mut out = vec![head];
301        let loudest = |what: &str, bands: &[f64]| {
302            let (at, level) = bands
303                .iter()
304                .enumerate()
305                .filter(|(_, v)| v.is_finite())
306                .max_by(|a, b| a.1.total_cmp(b.1))?;
307            Some(format!(
308                "{what}: band {} of {}, {}",
309                at + 1,
310                bands.len(),
311                ValueFormat::Compact.format(*level)
312            ))
313        };
314        match &self.right {
315            None => out.extend(loudest("Loudest", &self.left)),
316            Some(right) => {
317                out.extend(loudest("Left loudest", &self.left));
318                out.extend(loudest("Right loudest", right));
319            }
320        }
321        out.join("\n")
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use crate::a11y::AccessibleText;
329
330    fn plain(width: usize, bars: &BarSpectrum) -> Vec<String> {
331        let console = Console::builder().width(width).color_system(None).build();
332        console
333            .render_export(bars)
334            .lines()
335            .map(str::to_string)
336            .collect()
337    }
338
339    #[test]
340    fn tops_are_eighths_of_a_cell() {
341        let bars = BarSpectrum::new([1.0, 0.5, 1.0 / 16.0])
342            .range(1.0)
343            .gap(0)
344            .height(1);
345        assert_eq!(plain(3, &bars), ["█▄▁"]);
346    }
347
348    #[test]
349    fn stereo_puts_a_channel_each_side() {
350        let bars = BarSpectrum::stereo([1.0, 0.0], [0.0, 1.0])
351            .range(1.0)
352            .gap(0)
353            .height(1);
354        // Left reversed (high, low), then right (low, high).
355        assert_eq!(plain(4, &bars), [" █ █"]);
356        // A stereo spectrum stays mirrored.
357        assert_eq!(plain(4, &bars.mirror(false)), [" █ █"]);
358    }
359
360    #[test]
361    fn too_many_bands_lose_gaps_then_share_columns() {
362        let bars = BarSpectrum::new([1.0, 0.0, 0.0, 1.0])
363            .range(1.0)
364            .bar_width(3)
365            .height(1);
366        // 4 × 3 + 3 gaps is 15: without gaps 12 fits.
367        assert_eq!(plain(12, &bars), ["███      ███"]);
368        // Six wide: bars narrow to a cell and are centred.
369        assert_eq!(plain(6, &bars), [" █  █ "]);
370        // Two wide: two bands a column, the louder shown.
371        assert_eq!(plain(2, &bars), ["██"]);
372    }
373
374    #[test]
375    fn levels_are_clamped_and_nan_is_silence() {
376        let bars = BarSpectrum::new([2.0, -1.0, f64::NAN]).range(1.0).height(1);
377        assert_eq!(plain(5, &bars), ["█    "]);
378        // Without a range, the loudest band is full; all silent is empty.
379        assert_eq!(plain(3, &BarSpectrum::new([0.0, 0.0]).height(1)), ["   "]);
380        assert_eq!(plain(3, &BarSpectrum::new([2.0, 1.0]).height(1)), ["█ ▄"]);
381    }
382
383    #[test]
384    fn ascii_bars_round_to_whole_cells() {
385        let bars = BarSpectrum::new([1.0, 0.4])
386            .range(1.0)
387            .charset(Charset::Ascii)
388            .height(2);
389        assert_eq!(plain(3, &bars), ["#  ", "# #"]);
390    }
391
392    #[test]
393    fn accessible_text_names_the_loudest_band() {
394        let bars = BarSpectrum::new([0.2, 0.8, 0.1]).mirror(true);
395        assert_eq!(
396            bars.accessible_text(80),
397            "Bar spectrum, 3 bands, mirrored, levels from 0.1 to 0.8\n\
398             Loudest: band 2 of 3, 0.8"
399        );
400        let stereo = BarSpectrum::stereo([1.0], [2.0]);
401        assert_eq!(
402            stereo.accessible_text(80),
403            "Bar spectrum, 1 band, stereo, levels from 1 to 2\n\
404             Left loudest: band 1 of 1, 1\nRight loudest: band 1 of 1, 2"
405        );
406        assert_eq!(
407            BarSpectrum::new([]).accessible_text(80),
408            "Bar spectrum, no bands"
409        );
410    }
411}