Skip to main content

retroglyph_ui/widget/
sparkline.rs

1//! [`Sparkline`]: a single-row bar chart of recent samples.
2use retroglyph_core::color::{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 `▁▂▃▄▅▆▇█` by default (see [`Sparkline::glyphs`] to pick a
10/// different ramp).
11///
12/// The most recent samples are right-aligned so the graph scrolls left as
13/// new data arrives. By default, bar height *and color* track each sample's
14/// fraction of the max via [`Meter`] (a green-to-red load ramp). Two ways to override the color:
15/// [`Sparkline::style`] draws every bar in one fixed color instead, height only, the right choice
16/// once the color channel would otherwise imply something the data doesn't mean (e.g. a
17/// frame-time graph, where "tallest bar in view" isn't the same thing as "bad":
18/// [`super::PerfOverlay`] does this); [`Sparkline::fill_color`] instead swaps [`Meter`]'s ramp for
19/// a caller-supplied one, e.g. for a bar where high isn't bad (matching
20/// [`super::Gauge::fill_color`]/[`super::StatBar::fill_color`]). If both are set, `style` wins,
21/// since it also carries a background color that a color-only ramp can't express. Only the first
22/// row of `area` is drawn.
23///
24/// Unlike [`super::BoxBorder`], [`super::Gauge`], [`super::StatBar`],
25/// [`super::Table`], and [`super::Button`], `Sparkline` has no `theme()`/
26/// `theme_on()` pair: [`Meter`] already gives its bars a semantic
27/// green-to-red color, and a fixed [`Sparkline::style`] override has no
28/// single [`Theme`](crate::theme::Theme) role to map onto either.
29///
30/// # Examples
31///
32/// ```
33/// use retroglyph_core::grid::{Grid, Rect};
34/// use retroglyph_ui::widget::{Sparkline, Widget};
35/// use retroglyph_ui::Surface;
36///
37/// let samples = [1.0, 3.0, 2.0, 4.0, 1.5];
38/// let mut grid = Grid::new(10, 1);
39/// let area = Rect::new(0, 0, 10, 1);
40/// Sparkline::new(&samples).render(&mut Surface::new(&mut grid, area, 0));
41/// ```
42#[derive(Clone, Copy, Debug)]
43pub struct Sparkline<'a> {
44    samples: &'a [f32],
45    style: Option<Style>,
46    fill_color: Option<fn(f32) -> Color>,
47    glyphs: &'a [char],
48}
49
50impl<'a> Sparkline<'a> {
51    /// A sparkline of `samples`, colored by [`Meter`] (green-to-red load ramp) unless overridden
52    /// with [`style`](Self::style) or [`fill_color`](Self::fill_color), drawn with the eighth-block
53    /// glyph ramp [`NINE_LEVELS`](retroglyph_core::symbols::bar::NINE_LEVELS) unless overridden
54    /// with [`glyphs`](Self::glyphs).
55    #[must_use]
56    pub const fn new(samples: &'a [f32]) -> Self {
57        Self {
58            samples,
59            style: None,
60            fill_color: None,
61            glyphs: &NINE_LEVELS,
62        }
63    }
64
65    /// Draws every bar in `style`'s foreground color instead of the default [`Meter`] ramp.
66    /// Height still tracks each sample's fraction of the max; only the color stops varying.
67    ///
68    /// Takes priority over [`Sparkline::fill_color`] if both are set.
69    #[must_use]
70    pub const fn style(mut self, style: Style) -> Self {
71        self.style = Some(style);
72        self
73    }
74
75    /// Overrides each bar's color from [`Meter`]'s default green→yellow→red load ramp to
76    /// `fill_color`, called with the bar's clamped `0.0..=1.0` fraction of the sample max. Height
77    /// still tracks that same fraction.
78    ///
79    /// Matches [`super::Gauge::fill_color`]/[`super::StatBar::fill_color`]'s shape: for a bar
80    /// where high isn't bad (e.g. a health-derived sparkline, where full should read safe, not
81    /// danger), supply a ramp that fits, e.g. `|r| Color::lerp(Color::RED, Color::GREEN, r)`. A
82    /// plain `fn` pointer rather than a boxed closure, so `Sparkline` stays `Copy`; a
83    /// non-capturing closure like the one above coerces to it automatically.
84    ///
85    /// Ignored if [`Sparkline::style`] is also set: `style` also carries a background color that
86    /// a color-only ramp can't express, so it wins.
87    #[must_use]
88    pub const fn fill_color(mut self, fill_color: fn(f32) -> Color) -> Self {
89        self.fill_color = Some(fill_color);
90        self
91    }
92
93    /// Overrides the glyph ramp from the default eighth blocks
94    /// ([`NINE_LEVELS`](retroglyph_core::symbols::bar::NINE_LEVELS), `' '▁▂▃▄▅▆▇█`) to `glyphs`,
95    /// indexed from empty (`glyphs[0]`) to full (`glyphs[glyphs.len() - 1]`).
96    ///
97    /// Six of the eight eighth-block glyphs are outside CP437: a pixel backend that resolves
98    /// glyphs through a CP437 table draws its fallback solid block for all of them, and a
99    /// tileset/sprite backend can't tint an arbitrary glyph by [`Style::fg`]. On either backend,
100    /// pass [`SHADE_LEVELS`](retroglyph_core::symbols::bar::SHADE_LEVELS) (`' '░▒▓█`, all within
101    /// CP437) for a ramp that still renders five distinguishable levels instead of collapsing to
102    /// one block. `glyphs` must be non-empty; an empty slice falls back to a single space.
103    #[must_use]
104    pub const fn glyphs(mut self, glyphs: &'a [char]) -> Self {
105        self.glyphs = glyphs;
106        self
107    }
108}
109
110impl Widget for Sparkline<'_> {
111    fn render(&self, surface: &mut Surface<'_>) {
112        let width = usize::from(surface.width());
113        if width == 0 {
114            return;
115        }
116        // Floor the max away from zero so an all-zero (or empty) sample window scales to a flat
117        // row of blanks instead of dividing by zero (`sample / 0.0` is NaN, which would break the
118        // clamp and the level cast below). 1e-6 is small enough to never perturb a real max.
119        let max = self
120            .samples
121            .iter()
122            .copied()
123            .fold(0.0_f32, f32::max)
124            .max(1e-6);
125
126        // Take the last `width` samples so the graph is right-aligned.
127        let start = self.samples.len().saturating_sub(width);
128        let recent = &self.samples[start..];
129        let pad = width - recent.len();
130
131        // An empty `glyphs` override has no "full" glyph to fall back to, so treat it as a single
132        // blank level rather than panicking on the `glyphs.len() - 1` top index below.
133        let glyphs: &[char] = if self.glyphs.is_empty() {
134            &[' ']
135        } else {
136            self.glyphs
137        };
138        let top = glyphs.len() - 1;
139
140        for i in 0..width {
141            // `i` ranges over `0..width`, itself widened from this surface's own `u16` width, so
142            // narrowing it back is always exact.
143            #[allow(clippy::cast_possible_truncation)]
144            let x = i as u16;
145            if i < pad {
146                surface.put((x, 0), ' ', Style::new());
147                continue;
148            }
149            let ratio = (recent[i - pad] / max).clamp(0.0, 1.0);
150            // `glyphs` is indexed `0..=top` (empty to full), so a clamped ratio maps to a level by
151            // scaling to the top index and rounding. `.min(top)` is belt-and-suspenders against a
152            // rounding result of exactly `top` (it never exceeds it, given the clamp above).
153            // `top` is a glyph-ramp length, always tiny relative to `f32`'s 24-bit mantissa.
154            #[allow(
155                clippy::cast_possible_truncation,
156                clippy::cast_sign_loss,
157                clippy::cast_precision_loss
158            )]
159            let level = retroglyph_core::math::round(ratio * top as f32) as usize;
160            let color = self
161                .fill_color
162                .map_or_else(|| Meter::new(ratio).color(), |fill_color| fill_color(ratio));
163            let style = self.style.unwrap_or_else(|| Style::new().fg(color));
164            surface.put((x, 0), glyphs[level.min(top)], style);
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use retroglyph_core::grid::{Grid, Pos, Rect};
172
173    use super::*;
174
175    #[test]
176    fn right_aligns_recent_samples_and_pads_the_rest() {
177        let area = Rect::new(0, 0, 5, 1);
178        let mut grid = Grid::new(5, 1);
179        Sparkline::new(&[1.0, 2.0]).render(&mut Surface::new(&mut grid, area, 0));
180
181        assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
182        assert_eq!(grid[Pos::new(2, 0)].glyph(), ' ');
183        assert_eq!(grid[Pos::new(3, 0)].glyph(), NINE_LEVELS[4]); // 1.0 / 2.0 -> half
184        assert_eq!(grid[Pos::new(4, 0)].glyph(), NINE_LEVELS[8]); // 2.0 / 2.0 -> full
185    }
186
187    #[test]
188    fn empty_samples_is_a_no_op_beyond_blank_padding() {
189        let area = Rect::new(0, 0, 3, 1);
190        let mut grid = Grid::new(3, 1);
191        Sparkline::new(&[]).render(&mut Surface::new(&mut grid, area, 0));
192        for x in 0..3 {
193            assert_eq!(grid[Pos::new(x, 0)].glyph(), ' ');
194        }
195    }
196
197    #[test]
198    fn style_overrides_the_default_meter_ramp_with_one_fixed_color() {
199        use retroglyph_core::color::Color;
200
201        let area = Rect::new(0, 0, 3, 1);
202        let mut grid = Grid::new(3, 1);
203        let accent = Style::new().fg(Color::rgb(90, 170, 250));
204        Sparkline::new(&[1.0, 4.0, 2.0])
205            .style(accent)
206            .render(&mut Surface::new(&mut grid, area, 0));
207
208        // Height still tracks the ratio (the low, high, mid samples land on different block
209        // levels)...
210        assert_ne!(grid[Pos::new(0, 0)].glyph(), grid[Pos::new(1, 0)].glyph());
211        // ...but every bar shares the one fixed color, not a ramp that would color the tallest
212        // bar (here, `4.0`, the max) differently from the shortest.
213        for x in 0..3 {
214            assert_eq!(grid[Pos::new(x, 0)].style(), accent);
215        }
216    }
217
218    #[test]
219    fn fill_color_overrides_the_default_meter_ramp() {
220        use retroglyph_core::color::Color;
221
222        fn white_to_red(ratio: f32) -> Color {
223            Color::lerp(Color::WHITE, Color::RED, ratio)
224        }
225
226        let area = Rect::new(0, 0, 3, 1);
227        let mut grid = Grid::new(3, 1);
228        Sparkline::new(&[1.0, 4.0, 2.0])
229            .fill_color(white_to_red)
230            .render(&mut Surface::new(&mut grid, area, 0));
231
232        // Full ratio (the `4.0` max): the default ramp would also be red-ish here, so assert
233        // against the custom ramp's own output rather than a color literal that might coincide.
234        assert_eq!(grid[Pos::new(1, 0)].style().foreground(), white_to_red(1.0));
235    }
236
237    #[test]
238    fn style_wins_over_fill_color_when_both_are_set() {
239        use retroglyph_core::color::Color;
240
241        fn white_to_red(ratio: f32) -> Color {
242            Color::lerp(Color::WHITE, Color::RED, ratio)
243        }
244        let accent = Style::new().fg(Color::rgb(90, 170, 250));
245
246        let area = Rect::new(0, 0, 3, 1);
247        let mut grid = Grid::new(3, 1);
248        Sparkline::new(&[1.0, 4.0, 2.0])
249            .fill_color(white_to_red)
250            .style(accent)
251            .render(&mut Surface::new(&mut grid, area, 0));
252
253        for x in 0..3 {
254            assert_eq!(grid[Pos::new(x, 0)].style(), accent);
255        }
256    }
257
258    #[test]
259    fn glyphs_overrides_the_default_eighth_block_ramp() {
260        use retroglyph_core::symbols::bar::SHADE_LEVELS;
261
262        let area = Rect::new(0, 0, 3, 1);
263        let mut grid = Grid::new(3, 1);
264        Sparkline::new(&[1.0, 4.0, 2.0])
265            .glyphs(&SHADE_LEVELS)
266            .render(&mut Surface::new(&mut grid, area, 0));
267
268        // `4.0`'s ratio is 1.0 (the max), so it maps to `SHADE_LEVELS`' top index, `FULL`; none of
269        // the rendered glyphs fall outside the CP437-safe ramp.
270        assert_eq!(grid[Pos::new(1, 0)].glyph(), SHADE_LEVELS[4]);
271        for x in 0..3 {
272            assert!(SHADE_LEVELS.contains(&grid[Pos::new(x, 0)].glyph()));
273        }
274    }
275
276    #[test]
277    fn empty_glyphs_falls_back_to_a_single_blank_level() {
278        let area = Rect::new(0, 0, 3, 1);
279        let mut grid = Grid::new(3, 1);
280        Sparkline::new(&[1.0, 4.0, 2.0])
281            .glyphs(&[])
282            .render(&mut Surface::new(&mut grid, area, 0));
283
284        for x in 0..3 {
285            assert_eq!(grid[Pos::new(x, 0)].glyph(), ' ');
286        }
287    }
288}