Skip to main content

retroglyph_widgets/widget/
sparkline.rs

1//! [`Sparkline`]: a single-row bar chart of recent samples.
2use retroglyph_core::{Backend, Rect, Style, Terminal};
3
4use super::{Meter, Widget};
5
6/// Vertical block glyphs from empty to full, indexed 0..=8.
7const BLOCKS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
8
9/// A single-row sparkline of `samples`, scaled to the sample max, using the
10/// eight vertical block glyphs `▁▂▃▄▅▆▇█`.
11///
12/// The most recent samples are right-aligned so the graph scrolls left as
13/// new data arrives. Bar height (and color) tracks each sample's fraction
14/// of the max via [`Meter`]. Only the first row of `area` is drawn.
15#[derive(Clone, Copy, Debug)]
16pub struct Sparkline<'a> {
17    samples: &'a [f32],
18}
19
20impl<'a> Sparkline<'a> {
21    /// A sparkline of `samples`.
22    #[must_use]
23    pub const fn new(samples: &'a [f32]) -> Self {
24        Self { samples }
25    }
26}
27
28impl<B: Backend> Widget<B> for Sparkline<'_> {
29    fn render(self, area: Rect, term: &mut Terminal<B>) {
30        let width = area.width_usize();
31        if width == 0 {
32            return;
33        }
34        let y = area.top();
35        let max = self
36            .samples
37            .iter()
38            .copied()
39            .fold(0.0_f32, f32::max)
40            .max(1e-6);
41
42        // Take the last `width` samples so the graph is right-aligned.
43        let start = self.samples.len().saturating_sub(width);
44        let recent = &self.samples[start..];
45        let pad = width - recent.len();
46
47        for i in 0..width {
48            let x = area.left() + i as u16;
49            if i < pad {
50                term.put_styled(x, y, ' ', Style::new());
51                continue;
52            }
53            let ratio = (recent[i - pad] / max).clamp(0.0, 1.0);
54            let level = (ratio * 8.0).round() as usize;
55            term.put_styled(
56                x,
57                y,
58                BLOCKS[level.min(8)],
59                Style::new().fg(Meter::new(ratio).color()),
60            );
61        }
62        term.reset_style();
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use retroglyph_core::Headless;
69
70    use super::*;
71
72    #[test]
73    fn right_aligns_recent_samples_and_pads_the_rest() {
74        let area = Rect::new(0, 0, 5, 1);
75        let mut term = Terminal::new(Headless::new(5, 1));
76        Sparkline::new(&[1.0, 2.0]).render(area, &mut term);
77
78        assert_eq!(term.grid().get(0, 0).glyph(), ' ');
79        assert_eq!(term.grid().get(2, 0).glyph(), ' ');
80        assert_eq!(term.grid().get(3, 0).glyph(), BLOCKS[4]); // 1.0 / 2.0 -> half
81        assert_eq!(term.grid().get(4, 0).glyph(), BLOCKS[8]); // 2.0 / 2.0 -> full
82    }
83
84    #[test]
85    fn empty_samples_is_a_no_op_beyond_blank_padding() {
86        let area = Rect::new(0, 0, 3, 1);
87        let mut term = Terminal::new(Headless::new(3, 1));
88        Sparkline::new(&[]).render(area, &mut term);
89        for x in 0..3 {
90            assert_eq!(term.grid().get(x, 0).glyph(), ' ');
91        }
92    }
93}