Skip to main content

retroglyph_ui/widget/
sparkline.rs

1//! [`Sparkline`]: a single-row bar chart of recent samples.
2use retroglyph_core::color::Style;
3use retroglyph_core::symbols::bar::NINE_LEVELS;
4
5use super::{Meter, Widget};
6use crate::Surface;
7
8/// A single-row sparkline of `samples`, scaled to the sample max, using the
9/// eight vertical block glyphs `▁▂▃▄▅▆▇█`.
10///
11/// The most recent samples are right-aligned so the graph scrolls left as
12/// new data arrives. By default, bar height *and color* track each sample's
13/// fraction of the max via [`Meter`] (a green-to-red load ramp); call
14/// [`Sparkline::style`] to draw every bar in one fixed color instead, height
15/// only, the right choice once the color channel would otherwise imply
16/// something the data doesn't mean (e.g. a frame-time graph, where "tallest
17/// bar in view" isn't the same thing as "bad": [`super::PerfOverlay`] does
18/// this). Only the first row of `area` is drawn.
19///
20/// Unlike [`super::BoxBorder`], [`super::Gauge`], [`super::StatBar`],
21/// [`super::Table`], and [`super::Button`], `Sparkline` has no `theme()`/
22/// `theme_on()` pair: [`Meter`] already gives its bars a semantic
23/// green-to-red color, and a fixed [`Sparkline::style`] override has no
24/// single [`Theme`](crate::Theme) role to map onto either.
25///
26/// # Examples
27///
28/// ```
29/// use retroglyph_core::grid::{Grid, Rect};
30/// use retroglyph_ui::{Surface, Sparkline, Widget};
31///
32/// let samples = [1.0, 3.0, 2.0, 4.0, 1.5];
33/// let mut grid = Grid::new(10, 1);
34/// let area = Rect::new(0, 0, 10, 1);
35/// Sparkline::new(&samples).render(&mut Surface::new(&mut grid, area, 0));
36/// ```
37#[derive(Clone, Copy, Debug)]
38pub struct Sparkline<'a> {
39    samples: &'a [f32],
40    style: Option<Style>,
41}
42
43impl<'a> Sparkline<'a> {
44    /// A sparkline of `samples`, colored by [`Meter`] (green-to-red load ramp) unless overridden
45    /// with [`style`](Self::style).
46    #[must_use]
47    pub const fn new(samples: &'a [f32]) -> Self {
48        Self {
49            samples,
50            style: None,
51        }
52    }
53
54    /// Draws every bar in `style`'s foreground color instead of the default [`Meter`] ramp.
55    /// Height still tracks each sample's fraction of the max; only the color stops varying.
56    #[must_use]
57    pub const fn style(mut self, style: Style) -> Self {
58        self.style = Some(style);
59        self
60    }
61}
62
63impl Widget for Sparkline<'_> {
64    fn render(&self, surface: &mut Surface<'_>) {
65        let width = usize::from(surface.width());
66        if width == 0 {
67            return;
68        }
69        // Floor the max away from zero so an all-zero (or empty) sample window scales to a flat
70        // row of blanks instead of dividing by zero (`sample / 0.0` is NaN, which would break the
71        // clamp and the level cast below). 1e-6 is small enough to never perturb a real max.
72        let max = self
73            .samples
74            .iter()
75            .copied()
76            .fold(0.0_f32, f32::max)
77            .max(1e-6);
78
79        // Take the last `width` samples so the graph is right-aligned.
80        let start = self.samples.len().saturating_sub(width);
81        let recent = &self.samples[start..];
82        let pad = width - recent.len();
83
84        for i in 0..width {
85            // `i` ranges over `0..width`, itself widened from this surface's own `u16` width, so
86            // narrowing it back is always exact.
87            #[allow(clippy::cast_possible_truncation)]
88            let x = i as u16;
89            if i < pad {
90                surface.put((x, 0), ' ', Style::new());
91                continue;
92            }
93            let ratio = (recent[i - pad] / max).clamp(0.0, 1.0);
94            // `NINE_LEVELS` is `▁..█` indexed 0..=8 (9 glyphs), so a clamped ratio maps to a level
95            // by scaling to the top index 8 and rounding. `.min(8)` is belt-and-suspenders against
96            // a rounding result of exactly 8 (it never exceeds it, given the clamp above).
97            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
98            let level = retroglyph_core::math::round(ratio * 8.0) as usize;
99            let style = self
100                .style
101                .unwrap_or_else(|| Style::new().fg(Meter::new(ratio).color()));
102            surface.put((x, 0), NINE_LEVELS[level.min(8)], style);
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use retroglyph_core::grid::{Grid, Pos, Rect};
110
111    use super::*;
112
113    #[test]
114    fn right_aligns_recent_samples_and_pads_the_rest() {
115        let area = Rect::new(0, 0, 5, 1);
116        let mut grid = Grid::new(5, 1);
117        Sparkline::new(&[1.0, 2.0]).render(&mut Surface::new(&mut grid, area, 0));
118
119        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
120        assert_eq!(grid[Pos::new(2, 0)].glyph(), ' ');
121        assert_eq!(grid[Pos::new(3, 0)].glyph(), NINE_LEVELS[4]); // 1.0 / 2.0 -> half
122        assert_eq!(grid[Pos::new(4, 0)].glyph(), NINE_LEVELS[8]); // 2.0 / 2.0 -> full
123    }
124
125    #[test]
126    fn empty_samples_is_a_no_op_beyond_blank_padding() {
127        let area = Rect::new(0, 0, 3, 1);
128        let mut grid = Grid::new(3, 1);
129        Sparkline::new(&[]).render(&mut Surface::new(&mut grid, area, 0));
130        for x in 0..3 {
131            assert_eq!(grid[Pos::new(x, 0)].glyph(), ' ');
132        }
133    }
134
135    #[test]
136    fn style_overrides_the_default_meter_ramp_with_one_fixed_color() {
137        use retroglyph_core::color::Color;
138
139        let area = Rect::new(0, 0, 3, 1);
140        let mut grid = Grid::new(3, 1);
141        let accent = Style::new().fg(Color::Rgb {
142            r: 90,
143            g: 170,
144            b: 250,
145        });
146        Sparkline::new(&[1.0, 4.0, 2.0])
147            .style(accent)
148            .render(&mut Surface::new(&mut grid, area, 0));
149
150        // Height still tracks the ratio (the low, high, mid samples land on different block
151        // levels)...
152        assert_ne!(grid[Pos::new(0, 0)].glyph(), grid[Pos::new(1, 0)].glyph());
153        // ...but every bar shares the one fixed color, not a ramp that would color the tallest
154        // bar (here, `4.0`, the max) differently from the shortest.
155        for x in 0..3 {
156            assert_eq!(grid[Pos::new(x, 0)].style(), accent);
157        }
158    }
159}