Skip to main content

qframe/widgets/
sparkline.rs

1//! Sparklines: a series of numbers as a row of small columns.
2
3use super::eighths;
4use crate::color::Rgb;
5use crate::geometry::{Rect, Size, clamp_u16};
6use crate::icons::GlyphMode;
7use crate::widget::{MeasureCx, PaintCx, Widget};
8
9/// A compact trend: one column per value, newest on the right, measured in eighths of a cell.
10///
11/// Columns scale from the lowest to the highest value shown, or over a fixed range. Every value
12/// keeps at least one eighth so a quiet moment still reads as a sample. ASCII mode has no partial
13/// blocks: whole cells fill with colour and the cell a column ends in takes the share of colour it
14/// covers, so even the lowest sample tints one cell. When the series is wider than the area, the newest values are kept. Give the node a
15/// height for taller columns.
16///
17/// Style keys: `sparkline` (`fg` for the columns, `peak` and `low` for the highlighted extremes,
18/// `baseline` for the tone band of a reference value, `track` for the ground in ASCII mode).
19#[derive(Debug, Clone, PartialEq)]
20pub struct Sparkline {
21    values: Vec<f32>,
22    range: Option<(f32, f32)>,
23    extremes: bool,
24    baseline: Option<f32>,
25}
26
27impl Sparkline {
28    /// A sparkline of `values`, oldest first.
29    #[must_use]
30    pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
31        Self { values: values.into_iter().collect(), range: None, extremes: false, baseline: None }
32    }
33
34    /// Scales columns over `min..max` instead of the values shown, e.g. `0.0..100.0` for percent.
35    #[must_use]
36    pub fn range(mut self, min: f32, max: f32) -> Self {
37        self.range = Some((min, max));
38        self
39    }
40
41    /// Colours the highest and the lowest column shown.
42    #[must_use]
43    pub fn highlight_extremes(mut self) -> Self {
44        self.extremes = true;
45        self
46    }
47
48    /// Tints the cells at the level of `value`, such as a limit or an average, with a quiet band.
49    #[must_use]
50    pub fn baseline(mut self, value: f32) -> Self {
51        self.baseline = Some(value);
52        self
53    }
54
55    fn scale(&self, shown: &[f32]) -> (f32, f32) {
56        let (min, max) = self.range.unwrap_or_else(|| {
57            let min = shown.iter().copied().fold(f32::INFINITY, f32::min);
58            let max = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
59            (min, max)
60        });
61        (min, if max > min { max } else { min + 1.0 })
62    }
63}
64
65impl<Msg: 'static> Widget<Msg> for Sparkline {
66    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
67        let width = clamp_u16(i32::try_from(self.values.len()).unwrap_or(i32::MAX));
68        Size::new(width, u16::from(width > 0)).min(available)
69    }
70
71    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
72        if area.is_empty() || self.values.is_empty() {
73            return;
74        }
75        let count = self.values.len().min(usize::from(area.width));
76        let shown = &self.values[self.values.len() - count..];
77        let (min, max) = self.scale(shown);
78        let style = cx.style("sparkline", None, &[]);
79        let fill = style.color("fg").unwrap_or_else(|| cx.color("accent"));
80        let peak = style.color("peak").unwrap_or(fill);
81        let low = style.color("low").unwrap_or(fill);
82        let cells = area.height;
83        let total = u32::from(cells) * 8;
84        // The lowest value keeps one eighth and the highest fills the column.
85        let level = |value: f32| 1 + eighths::scaled((value - min) / (max - min), total - 1);
86
87        let ascii = cx.env().glyph_mode() == GlyphMode::Ascii;
88        let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
89        if ascii {
90            cx.clear(Rect::new(area.x, area.y, clamp_u16(i32::try_from(count).unwrap_or(0)), area.height), track);
91        }
92        // The row of the baseline band, counted from the bottom, and its colour.
93        let band = self.baseline.map(|value| {
94            let row = u16::try_from(level(value).saturating_sub(1) / 8).unwrap_or(0).min(cells - 1);
95            (row, style.color("baseline").unwrap_or_else(|| cx.color("raised")))
96        });
97        if let Some((row, color)) = band {
98            let width = clamp_u16(i32::try_from(count).unwrap_or(0));
99            cx.fill(Rect::new(area.x, area.bottom() - 1 - i32::from(row), width, 1), color);
100        }
101
102        let (peak_index, low_index) = if self.extremes { extremes(shown) } else { (None, None) };
103        for (index, value) in shown.iter().copied().enumerate() {
104            let color = if Some(index) == peak_index {
105                peak
106            } else if Some(index) == low_index {
107                low
108            } else {
109                fill
110            };
111            let column = Rect::new(area.x + i32::try_from(index).unwrap_or(0), area.y, 1, cells);
112            if ascii {
113                paint_ascii_column(cx, column, level(value), color, |row| match band {
114                    Some((band_row, band_color)) if band_row == row => band_color,
115                    _ => track,
116                });
117            } else {
118                eighths::vertical(cx, column, level(value), color);
119            }
120        }
121    }
122}
123
124/// Draws a column of `eighths` in ASCII mode, which has no partial blocks: whole cells take
125/// `color`, and the cell the column ends in takes the share of `color` it covers over its ground
126/// (`ground(row)`, rows counted from the bottom). Rounding to whole cells instead would hide the
127/// lowest samples, or, one row tall, draw every sample as the same full cell; blending keeps every
128/// sample visible and the trend readable, as cell-stepped colour does everywhere.
129fn paint_ascii_column(cx: &mut PaintCx<'_>, column: Rect, eighths: u32, color: Rgb, ground: impl Fn(u16) -> Rgb) {
130    let full = u16::try_from(eighths / 8).unwrap_or(u16::MAX).min(column.height);
131    cx.fill(Rect::new(column.x, column.bottom() - i32::from(full), 1, full), color);
132    let partial = eighths % 8;
133    if partial > 0 && full < column.height {
134        // `partial` is below eight, so the share is exact in f32.
135        let tone = ground(full).mix(color, partial as f32 / 8.0);
136        cx.fill(Rect::new(column.x, column.bottom() - i32::from(full) - 1, 1, 1), tone);
137    }
138}
139
140/// The positions of the highest and the lowest value, or `None` for each that is not marked.
141///
142/// Only the latest occurrence of each extreme is marked, so a flat top is not a stripe, and a flat
143/// series has a peak but no low.
144fn extremes(shown: &[f32]) -> (Option<usize>, Option<usize>) {
145    let highest = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
146    let lowest = shown.iter().copied().fold(f32::INFINITY, f32::min);
147    let peak = shown.iter().rposition(|value| *value >= highest);
148    let low = shown.iter().rposition(|value| *value <= lowest).filter(|_| highest > lowest);
149    (peak, low)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::runtime::{App, Command, Harness};
156    use crate::widget::{Length, View};
157
158    struct Demo(Sparkline, u16);
159
160    impl App for Demo {
161        type Msg = ();
162        fn update(&mut self, _: ()) -> Command<()> {
163            Command::none()
164        }
165        fn view(&self, ui: &mut View<'_, ()>) {
166            ui.add(self.0.clone()).height(Length::Cells(self.1));
167        }
168    }
169
170    /// The column colour of the built-in theme.
171    fn column(h: &Harness<Demo>) -> crate::color::Rgb {
172        let theme = h.env().theme();
173        theme.color("surface").expect("token").mix(theme.color("accent").expect("token"), 0.72)
174    }
175
176    const LOAD: [f32; 8] = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
177
178    #[test]
179    fn one_row_uses_eighth_blocks_and_keeps_newest() {
180        let h = Harness::new(Demo(Sparkline::new(LOAD), 1), 8, 1);
181        assert_eq!(h.screen(), "▁▂▃▄▅▆▇\n");
182        assert_eq!(h.bg(7, 0), Some(column(&h)));
183        let narrow = Harness::new(Demo(Sparkline::new(LOAD), 1), 4, 1);
184        assert_eq!(narrow.screen(), "▁▃▆\n");
185    }
186
187    #[test]
188    fn taller_columns_and_fixed_range() {
189        let h = Harness::new(Demo(Sparkline::new([50.0, 100.0, 0.0]).range(0.0, 100.0), 2), 3, 2);
190        assert_eq!(h.screen(), "▁\n  ▁\n");
191        assert_eq!(h.bg(0, 1), Some(column(&h)));
192        assert_eq!(h.bg(1, 0), Some(column(&h)));
193    }
194
195    #[test]
196    fn extremes_and_baseline_are_coloured() {
197        let spark = Sparkline::new([3.0, 9.0, 1.0, 5.0]).highlight_extremes();
198        let h = Harness::new(Demo(spark.clone(), 1), 4, 1);
199        assert_eq!(h.screen(), "▃ ▁▅\n");
200        let theme = h.env().theme();
201        assert_eq!(h.bg(1, 0), theme.color("accent"), "the peak is the brightest column");
202        assert_eq!(h.fg(2, 0), theme.color("muted"), "the low column is muted");
203        assert_ne!(h.fg(0, 0), h.fg(2, 0));
204        let lined = Harness::new(Demo(spark.baseline(5.0), 1), 4, 1);
205        assert_ne!(lined.bg(0, 0), h.bg(0, 0), "the baseline row is tinted");
206        assert_eq!(lined.bg(1, 0), theme.color("accent"), "columns are drawn over the band");
207    }
208
209    #[test]
210    fn extremes_mark_the_latest_highest_and_lowest() {
211        assert_eq!(extremes(&[3.0, 9.0, 1.0, 5.0]), (Some(1), Some(2)));
212        assert_eq!(extremes(&[9.0, 1.0, 9.0, 1.0, 4.0]), (Some(2), Some(3)), "latest occurrence of each");
213        assert_eq!(extremes(&[2.0, 2.0, 2.0]), (Some(2), None), "a flat series has no low");
214        assert_eq!(extremes(&[]), (None, None));
215    }
216
217    #[test]
218    fn extremes_take_linear_time_on_the_widest_series() {
219        // A falling series is the worst case for a quadratic scan: about two billion comparisons
220        // at this width, which takes seconds in a debug build.
221        let falling: Vec<f32> = (0..u16::MAX).rev().map(f32::from).collect();
222        let started = std::time::Instant::now();
223        assert_eq!(extremes(&falling), (Some(0), Some(falling.len() - 1)));
224        assert!(started.elapsed() < std::time::Duration::from_millis(500), "took {:?}", started.elapsed());
225    }
226
227    #[test]
228    fn ascii_fills_cells_on_a_track() {
229        let mut h = Harness::new(Demo(Sparkline::new([0.0, 10.0]), 2), 2, 2);
230        h.set_glyph_mode(GlyphMode::Ascii);
231        assert_eq!(h.screen(), "\n\n");
232        let theme = h.env().theme();
233        assert_eq!(h.bg(0, 0), theme.color("raised"));
234        let lowest = h.bg(0, 1);
235        assert!(lowest.is_some() && lowest != theme.color("raised"), "the lowest value still tints one cell");
236        assert_eq!(h.bg(1, 0), Some(column(&h)));
237    }
238
239    #[test]
240    fn ascii_shows_every_sample_as_at_least_one_cell() {
241        let mut h = Harness::new(Demo(Sparkline::new([0.0, 1.0, 50.0, 100.0]), 3), 4, 3);
242        h.set_glyph_mode(GlyphMode::Ascii);
243        let raised = h.env().theme().color("raised");
244        for x in 0..4 {
245            assert_ne!(h.bg(x, 2), raised, "sample {x} is visible");
246        }
247        assert_eq!(h.bg(0, 1), raised, "low samples do not grow past one cell");
248        assert_eq!(h.bg(3, 0), Some(column(&h)), "the highest fills the column");
249        assert_eq!(h.bg(2, 2), Some(column(&h)), "cells a column passes are whole colour");
250    }
251
252    #[test]
253    fn one_row_ascii_keeps_the_trend_in_tone() {
254        let mut h = Harness::new(Demo(Sparkline::new(LOAD), 1), 8, 1);
255        h.set_glyph_mode(GlyphMode::Ascii);
256        let brightness = |x: u16| h.bg(x, 0).map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b));
257        let raised = h.env().theme().color("raised");
258        assert!((0..8).all(|x| h.bg(x, 0) != raised), "every sample tints its cell");
259        assert!((1..8).all(|x| brightness(x) != brightness(x - 1)), "rising values read as rising tones");
260        assert_eq!(h.bg(7, 0), Some(column(&h)), "the highest is whole colour");
261    }
262}