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}