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::event::{Event, MouseButton, MouseKind};
6use crate::geometry::{Rect, Size, clamp_u16};
7use crate::icons::GlyphMode;
8use crate::keymap::Key;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11/// How much a hovered tone is lifted towards the text colour, the same step the theme uses for
12/// hovered surfaces.
13const HOVER_LIFT: f32 = 0.08;
14
15/// Builds a message from the point being read, or from no point at all.
16type ReadMessage<Msg> = Box<dyn Fn(Option<usize>) -> Msg>;
17
18/// A compact trend: one column per value, newest on the right, measured in eighths of a cell.
19///
20/// Columns scale from the lowest to the highest value shown, or over a fixed range. Every value
21/// keeps at least one eighth so a quiet moment still reads as a sample. ASCII mode has no partial
22/// blocks: whole cells fill with colour and the cell a column ends in takes the share of colour it
23/// 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
24/// height for taller columns.
25///
26/// With [`Sparkline::on_read`] a point can be read off the chart: pressing or dragging over the
27/// columns reads the one under the pointer, and while focused ←/→ move one point, Home and End
28/// jump to the oldest and the newest shown, and Esc stops reading. The message carries the
29/// position in the values given, so the application writes the value itself; the application owns
30/// which point is being read ([`Sparkline::reading`]). The read column rises to a tone band of its
31/// own and is drawn in the accent, the hovered column's band is one hover step brighter than the
32/// ground, and nothing moves: bands and columns stay in their cells.
33///
34/// Style keys: `sparkline` (`fg` for the columns, `peak` and `low` for the highlighted extremes,
35/// `reading` for the column being read, `baseline` for the tone band of a reference value,
36/// `track` for the ground in ASCII mode).
37pub struct Sparkline<Msg> {
38    values: Vec<f32>,
39    range: Option<(f32, f32)>,
40    extremes: bool,
41    baseline: Option<f32>,
42    reading: Option<usize>,
43    on_read: Option<ReadMessage<Msg>>,
44}
45
46impl<Msg: 'static> Sparkline<Msg> {
47    /// A sparkline of `values`, oldest first.
48    #[must_use]
49    pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
50        Self {
51            values: values.into_iter().collect(),
52            range: None,
53            extremes: false,
54            baseline: None,
55            reading: None,
56            on_read: None,
57        }
58    }
59
60    /// Scales columns over `min..max` instead of the values shown, e.g. `0.0..100.0` for percent.
61    #[must_use]
62    pub fn range(mut self, min: f32, max: f32) -> Self {
63        self.range = Some((min, max));
64        self
65    }
66
67    /// Colours the highest and the lowest column shown.
68    #[must_use]
69    pub fn highlight_extremes(mut self) -> Self {
70        self.extremes = true;
71        self
72    }
73
74    /// Tints the cells at the level of `value`, such as a limit or an average, with a quiet band.
75    #[must_use]
76    pub fn baseline(mut self, value: f32) -> Self {
77        self.baseline = Some(value);
78        self
79    }
80
81    /// The point being read, as a position in the values given. A point that is not shown, because
82    /// the area is narrower than the series, is not marked.
83    #[must_use]
84    pub fn reading(mut self, index: Option<usize>) -> Self {
85        self.reading = index;
86        self
87    }
88
89    /// Lets a point be read off the chart with the pointer and the keyboard; the message carries
90    /// the position in the values given, or nothing when reading stops.
91    #[must_use]
92    pub fn on_read(mut self, message: impl Fn(Option<usize>) -> Msg + 'static) -> Self {
93        self.on_read = Some(Box::new(message));
94        self
95    }
96
97    fn scale(&self, shown: &[f32]) -> (f32, f32) {
98        let (min, max) = self.range.unwrap_or_else(|| {
99            let min = shown.iter().copied().fold(f32::INFINITY, f32::min);
100            let max = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
101            (min, max)
102        });
103        (min, if max > min { max } else { min + 1.0 })
104    }
105
106    /// Where the values shown in an area `width` cells wide start, and how many there are.
107    fn window(&self, width: u16) -> (usize, usize) {
108        let count = self.values.len().min(usize::from(width));
109        (self.values.len() - count, count)
110    }
111
112    /// The point a pointer at column `x` reads: the nearest one, so a drag that leaves the
113    /// columns keeps reading the end of the series.
114    fn point_at(&self, area: Rect, x: i32) -> Option<usize> {
115        let (start, count) = self.window(area.width);
116        let last = count.checked_sub(1)?;
117        let column = usize::try_from((x - area.x).max(0)).unwrap_or(0).min(last);
118        Some(start + column)
119    }
120
121    /// Reads `target` when it is another point than the one being read.
122    fn read(&self, cx: &mut EventCx<'_, Msg>, target: Option<usize>) {
123        if target != self.reading
124            && let Some(message) = &self.on_read
125        {
126            cx.emit(message(target));
127        }
128    }
129}
130
131impl<Msg: 'static> Widget<Msg> for Sparkline<Msg> {
132    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
133        let width = clamp_u16(i32::try_from(self.values.len()).unwrap_or(i32::MAX));
134        Size::new(width, u16::from(width > 0)).min(available)
135    }
136
137    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
138        if area.is_empty() || self.values.is_empty() {
139            return;
140        }
141        let readable = self.on_read.is_some();
142        if readable {
143            cx.register_hit(area);
144        }
145        let (start, count) = self.window(area.width);
146        let shown = &self.values[start..];
147        let (min, max) = self.scale(shown);
148        let style = cx.style("sparkline", None, &[]);
149        let fill = style.color("fg").unwrap_or_else(|| cx.color("accent"));
150        let peak = style.color("peak").unwrap_or(fill);
151        let low = style.color("low").unwrap_or(fill);
152        let marked = style.color("reading").unwrap_or_else(|| cx.color("accent"));
153        let cells = area.height;
154        let total = u32::from(cells) * 8;
155        // The lowest value keeps one eighth and the highest fills the column.
156        let level = |value: f32| 1 + eighths::scaled((value - min) / (max - min), total - 1);
157
158        let ascii = cx.env().glyph_mode() == GlyphMode::Ascii;
159        let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
160        if ascii {
161            cx.clear(Rect::new(area.x, area.y, clamp_u16(i32::try_from(count).unwrap_or(0)), area.height), track);
162        }
163        // The row of the baseline band, counted from the bottom, and its colour.
164        let band = self.baseline.map(|value| {
165            let row = u16::try_from(level(value).saturating_sub(1) / 8).unwrap_or(0).min(cells - 1);
166            (row, style.color("baseline").unwrap_or_else(|| cx.color("raised")))
167        });
168        if let Some((row, color)) = band {
169            let width = clamp_u16(i32::try_from(count).unwrap_or(0));
170            cx.fill(Rect::new(area.x, area.bottom() - 1 - i32::from(row), width, 1), color);
171        }
172
173        let hovered = if readable { cx.pointer().and_then(|(x, _)| self.point_at(area, x)) } else { None };
174        let read = self.reading.filter(|index| (start..start + count).contains(index));
175        let ground = (cx.color("raised"), cx.color("active"), cx.color("text"));
176        let (peak_index, low_index) = if self.extremes { extremes(shown) } else { (None, None) };
177        for (offset, value) in shown.iter().copied().enumerate() {
178            let index = start + offset;
179            let color = if Some(index) == read {
180                marked
181            } else if Some(offset) == peak_index {
182                peak
183            } else if Some(offset) == low_index {
184                low
185            } else {
186                fill
187            };
188            let column = Rect::new(area.x + i32::try_from(offset).unwrap_or(0), area.y, 1, cells);
189            let lit = column_band(ground, Some(index) == read, Some(index) == hovered);
190            if let Some(tone) = lit {
191                cx.fill(column, tone);
192            }
193            if ascii {
194                let ground = lit.unwrap_or(track);
195                paint_ascii_column(cx, column, level(value), color, |row| match band {
196                    Some((band_row, band_color)) if band_row == row && lit.is_none() => band_color,
197                    _ => ground,
198                });
199            } else {
200                eighths::vertical(cx, column, level(value), color);
201            }
202        }
203    }
204
205    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
206        if self.on_read.is_none() || self.values.is_empty() {
207            return false;
208        }
209        let area = cx.area();
210        let (start, count) = self.window(area.width);
211        let Some(last) = count.checked_sub(1).map(|last| start + last) else {
212            return false;
213        };
214        match event {
215            Event::Key(key) => {
216                // A first key reads the newest point, which is the one a reader wants first.
217                let current = self.reading.filter(|index| (start..=last).contains(index));
218                let target = if key.is_plain(Key::Left) {
219                    current.map_or(last, |index| index.saturating_sub(1).max(start))
220                } else if key.is_plain(Key::Right) {
221                    current.map_or(last, |index| (index + 1).min(last))
222                } else if key.is_plain(Key::Home) {
223                    start
224                } else if key.is_plain(Key::End) {
225                    last
226                } else if key.is_plain(Key::Esc) {
227                    if self.reading.is_none() {
228                        return false;
229                    }
230                    self.read(cx, None);
231                    return true;
232                } else {
233                    return false;
234                };
235                self.read(cx, Some(target));
236                true
237            }
238            Event::Mouse(mouse) => match mouse.kind {
239                MouseKind::Down(MouseButton::Left) => {
240                    // The pointer is captured so a drag that leaves the columns keeps reading.
241                    cx.capture_pointer();
242                    self.read(cx, self.point_at(area, mouse.x));
243                    true
244                }
245                MouseKind::Drag(MouseButton::Left) => {
246                    self.read(cx, self.point_at(area, mouse.x));
247                    true
248                }
249                MouseKind::Up(MouseButton::Left) => true,
250                _ => false,
251            },
252            _ => false,
253        }
254    }
255
256    fn focusable(&self) -> bool {
257        self.on_read.is_some() && !self.values.is_empty()
258    }
259}
260
261/// The tone behind a column: the read one sits on the active surface, a hovered one is lifted a
262/// hover step above the ground it stands on, and a plain column has no band of its own.
263/// `ground` is `(raised, active, text)`.
264fn column_band(ground: (Rgb, Rgb, Rgb), read: bool, hovered: bool) -> Option<Rgb> {
265    let (raised, active, text) = ground;
266    match (read, hovered) {
267        (false, false) => None,
268        (true, false) => Some(active),
269        (read, true) => Some(if read { active } else { raised }.mix(text, HOVER_LIFT)),
270    }
271}
272
273/// Draws a column of `eighths` in ASCII mode, which has no partial blocks: whole cells take
274/// `color`, and the cell the column ends in takes the share of `color` it covers over its ground
275/// (`ground(row)`, rows counted from the bottom). Rounding to whole cells instead would hide the
276/// lowest samples, or, one row tall, draw every sample as the same full cell; blending keeps every
277/// sample visible and the trend readable, as cell-stepped colour does everywhere.
278fn paint_ascii_column(cx: &mut PaintCx<'_>, column: Rect, eighths: u32, color: Rgb, ground: impl Fn(u16) -> Rgb) {
279    let full = u16::try_from(eighths / 8).unwrap_or(u16::MAX).min(column.height);
280    cx.fill(Rect::new(column.x, column.bottom() - i32::from(full), 1, full), color);
281    let partial = eighths % 8;
282    if partial > 0 && full < column.height {
283        // `partial` is below eight, so the share is exact in f32.
284        let tone = ground(full).mix(color, partial as f32 / 8.0);
285        cx.fill(Rect::new(column.x, column.bottom() - i32::from(full) - 1, 1, 1), tone);
286    }
287}
288
289/// The positions of the highest and the lowest value, or `None` for each that is not marked.
290///
291/// Only the latest occurrence of each extreme is marked, so a flat top is not a stripe, and a flat
292/// series has a peak but no low.
293fn extremes(shown: &[f32]) -> (Option<usize>, Option<usize>) {
294    let highest = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
295    let lowest = shown.iter().copied().fold(f32::INFINITY, f32::min);
296    let peak = shown.iter().rposition(|value| *value >= highest);
297    let low = shown.iter().rposition(|value| *value <= lowest).filter(|_| highest > lowest);
298    (peak, low)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::runtime::{App, Command, Harness};
305    use crate::widget::{Length, View};
306
307    /// A sparkline of `values`, with the capabilities each test turns on.
308    struct Demo {
309        values: Vec<f32>,
310        rows: u16,
311        range: Option<(f32, f32)>,
312        extremes: bool,
313        baseline: Option<f32>,
314        reading: Option<usize>,
315        readable: bool,
316    }
317
318    impl Demo {
319        fn new(values: impl IntoIterator<Item = f32>, rows: u16) -> Self {
320            Self {
321                values: values.into_iter().collect(),
322                rows,
323                range: None,
324                extremes: false,
325                baseline: None,
326                reading: None,
327                readable: false,
328            }
329        }
330
331        fn range(mut self, min: f32, max: f32) -> Self {
332            self.range = Some((min, max));
333            self
334        }
335
336        fn extremes(mut self) -> Self {
337            self.extremes = true;
338            self
339        }
340
341        fn baseline(mut self, value: f32) -> Self {
342            self.baseline = Some(value);
343            self
344        }
345
346        fn readable(mut self) -> Self {
347            self.readable = true;
348            self
349        }
350    }
351
352    impl App for Demo {
353        type Msg = Option<usize>;
354        fn update(&mut self, reading: Option<usize>) -> Command<Option<usize>> {
355            self.reading = reading;
356            Command::none()
357        }
358        fn view(&self, ui: &mut View<'_, Option<usize>>) {
359            let mut spark = Sparkline::new(self.values.iter().copied());
360            if let Some((min, max)) = self.range {
361                spark = spark.range(min, max);
362            }
363            if self.extremes {
364                spark = spark.highlight_extremes();
365            }
366            if let Some(value) = self.baseline {
367                spark = spark.baseline(value);
368            }
369            if self.readable {
370                spark = spark.reading(self.reading).on_read(|index| index);
371            }
372            ui.add(spark).height(Length::Cells(self.rows)).id("trend");
373        }
374    }
375
376    fn harness(demo: Demo, width: u16) -> Harness<Demo> {
377        let height = demo.rows;
378        Harness::new(demo, width, height)
379    }
380
381    /// The column colour of the built-in theme.
382    fn column(h: &Harness<Demo>) -> Rgb {
383        let theme = h.env().theme();
384        theme.color("surface").expect("token").mix(theme.color("accent").expect("token"), 0.72)
385    }
386
387    const LOAD: [f32; 8] = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
388
389    #[test]
390    fn one_row_uses_eighth_blocks_and_keeps_newest() {
391        let h = harness(Demo::new(LOAD, 1), 8);
392        assert_eq!(h.screen(), "▁▂▃▄▅▆▇\n");
393        assert_eq!(h.bg(7, 0), Some(column(&h)));
394        let narrow = harness(Demo::new(LOAD, 1), 4);
395        assert_eq!(narrow.screen(), "▁▃▆\n");
396    }
397
398    #[test]
399    fn taller_columns_and_fixed_range() {
400        let h = harness(Demo::new([50.0, 100.0, 0.0], 2).range(0.0, 100.0), 3);
401        assert_eq!(h.screen(), "▁\n  ▁\n");
402        assert_eq!(h.bg(0, 1), Some(column(&h)));
403        assert_eq!(h.bg(1, 0), Some(column(&h)));
404    }
405
406    #[test]
407    fn extremes_and_baseline_are_coloured() {
408        let h = harness(Demo::new([3.0, 9.0, 1.0, 5.0], 1).extremes(), 4);
409        assert_eq!(h.screen(), "▃ ▁▅\n");
410        let theme = h.env().theme();
411        assert_eq!(h.bg(1, 0), theme.color("accent"), "the peak is the brightest column");
412        assert_eq!(h.fg(2, 0), theme.color("muted"), "the low column is muted");
413        assert_ne!(h.fg(0, 0), h.fg(2, 0));
414        let lined = harness(Demo::new([3.0, 9.0, 1.0, 5.0], 1).extremes().baseline(5.0), 4);
415        assert_ne!(lined.bg(0, 0), h.bg(0, 0), "the baseline row is tinted");
416        assert_eq!(lined.bg(1, 0), theme.color("accent"), "columns are drawn over the band");
417    }
418
419    #[test]
420    fn extremes_mark_the_latest_highest_and_lowest() {
421        assert_eq!(extremes(&[3.0, 9.0, 1.0, 5.0]), (Some(1), Some(2)));
422        assert_eq!(extremes(&[9.0, 1.0, 9.0, 1.0, 4.0]), (Some(2), Some(3)), "latest occurrence of each");
423        assert_eq!(extremes(&[2.0, 2.0, 2.0]), (Some(2), None), "a flat series has no low");
424        assert_eq!(extremes(&[]), (None, None));
425    }
426
427    #[test]
428    fn extremes_take_linear_time_on_the_widest_series() {
429        // A falling series is the worst case for a quadratic scan: about two billion comparisons
430        // at this width, which takes seconds in a debug build.
431        let falling: Vec<f32> = (0..u16::MAX).rev().map(f32::from).collect();
432        let started = std::time::Instant::now();
433        assert_eq!(extremes(&falling), (Some(0), Some(falling.len() - 1)));
434        assert!(started.elapsed() < std::time::Duration::from_millis(500), "took {:?}", started.elapsed());
435    }
436
437    #[test]
438    fn ascii_fills_cells_on_a_track() {
439        let mut h = harness(Demo::new([0.0, 10.0], 2), 2);
440        h.set_glyph_mode(GlyphMode::Ascii);
441        assert_eq!(h.screen(), "\n\n");
442        let theme = h.env().theme();
443        assert_eq!(h.bg(0, 0), theme.color("raised"));
444        let lowest = h.bg(0, 1);
445        assert!(lowest.is_some() && lowest != theme.color("raised"), "the lowest value still tints one cell");
446        assert_eq!(h.bg(1, 0), Some(column(&h)));
447    }
448
449    #[test]
450    fn ascii_shows_every_sample_as_at_least_one_cell() {
451        let mut h = harness(Demo::new([0.0, 1.0, 50.0, 100.0], 3), 4);
452        h.set_glyph_mode(GlyphMode::Ascii);
453        let raised = h.env().theme().color("raised");
454        for x in 0..4 {
455            assert_ne!(h.bg(x, 2), raised, "sample {x} is visible");
456        }
457        assert_eq!(h.bg(0, 1), raised, "low samples do not grow past one cell");
458        assert_eq!(h.bg(3, 0), Some(column(&h)), "the highest fills the column");
459        assert_eq!(h.bg(2, 2), Some(column(&h)), "cells a column passes are whole colour");
460    }
461
462    #[test]
463    fn one_row_ascii_keeps_the_trend_in_tone() {
464        let mut h = harness(Demo::new(LOAD, 1), 8);
465        h.set_glyph_mode(GlyphMode::Ascii);
466        let brightness = |x: u16| h.bg(x, 0).map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b));
467        let raised = h.env().theme().color("raised");
468        assert!((0..8).all(|x| h.bg(x, 0) != raised), "every sample tints its cell");
469        assert!((1..8).all(|x| brightness(x) != brightness(x - 1)), "rising values read as rising tones");
470        assert_eq!(h.bg(7, 0), Some(column(&h)), "the highest is whole colour");
471    }
472
473    /// Four samples on a two-row sparkline: 20, 90, 40 and 60 percent.
474    fn readable() -> Harness<Demo> {
475        harness(Demo::new([20.0, 90.0, 40.0, 60.0], 2).range(0.0, 100.0).readable(), 4)
476    }
477
478    #[test]
479    fn a_plain_sparkline_neither_takes_focus_nor_sends_messages() {
480        let mut h = harness(Demo::new([20.0, 90.0, 40.0, 60.0], 2).range(0.0, 100.0), 4);
481        let before = h.screen();
482        h.press("tab").press("right").press("end").click(1, 1).hover(2, 0);
483        assert_eq!(h.app().reading, None, "nothing was read");
484        assert_eq!(h.screen(), before, "and nothing changed on screen");
485    }
486
487    #[test]
488    fn a_press_reads_the_point_under_the_pointer_and_a_drag_scrubs() {
489        let mut h = readable();
490        h.click(2, 1);
491        assert_eq!(h.app().reading, Some(2), "the third column was read");
492        h.drag((2, 1), (0, 1));
493        assert_eq!(h.app().reading, Some(0), "the drag read the oldest point");
494        h.drag((0, 1), (40, 1));
495        assert_eq!(h.app().reading, Some(3), "a drag past the columns keeps the newest point");
496    }
497
498    #[test]
499    fn keys_move_the_reading_and_esc_stops_it() {
500        let mut h = readable();
501        h.press("tab");
502        assert_eq!(h.app().reading, None, "focus alone reads nothing");
503        h.press("left");
504        assert_eq!(h.app().reading, Some(3), "the first key reads the newest point");
505        h.press("left");
506        assert_eq!(h.app().reading, Some(2));
507        h.press("home");
508        assert_eq!(h.app().reading, Some(0));
509        h.press("left");
510        assert_eq!(h.app().reading, Some(0), "the oldest shown point is the end of the way");
511        h.press("right");
512        assert_eq!(h.app().reading, Some(1));
513        h.press("end");
514        assert_eq!(h.app().reading, Some(3));
515        h.press("right");
516        assert_eq!(h.app().reading, Some(3), "and so is the newest");
517        h.press("esc");
518        assert_eq!(h.app().reading, None, "esc stops reading");
519    }
520
521    #[test]
522    fn the_read_column_rises_and_the_hovered_one_lightens_without_moving() {
523        let mut h = readable();
524        let quiet = h.screen();
525        let ground = h.bg(1, 0);
526        h.hover(1, 0);
527        let hovered = h.bg(1, 0);
528        assert_ne!(hovered, ground, "the hovered column stands on a lighter ground");
529        assert_eq!(h.screen(), quiet, "hovering moves no cell");
530        assert_eq!(h.bg(2, 0), ground, "only the hovered column changes");
531
532        h.send(Some(1));
533        let (accent, active) = {
534            let theme = h.env().theme();
535            (theme.color("accent"), theme.color("active"))
536        };
537        assert_eq!(h.bg(1, 1), accent, "the read column is drawn in the accent");
538        assert_ne!(h.bg(1, 0), hovered, "and its band is the active surface, lifted while hovered");
539        assert_eq!(h.screen(), quiet, "reading moves no cell either");
540        h.hover(30, 30);
541        assert_eq!(h.bg(1, 0), active, "unhovered, the read column keeps the active band");
542    }
543
544    #[test]
545    fn a_reading_outside_the_shown_window_is_not_marked() {
546        let mut h = harness(Demo::new(LOAD, 1).readable(), 3);
547        h.send(Some(0));
548        let theme = h.env().theme();
549        assert_eq!(h.app().reading, Some(0));
550        assert!((0..3).all(|x| h.bg(x, 0) != theme.color("active")), "no column is marked:\n{}", h.screen());
551        // Only the three newest values are shown, so the keys work inside that window.
552        h.press("tab").press("home");
553        assert_eq!(h.app().reading, Some(5), "home reads the oldest point shown");
554    }
555
556    #[test]
557    fn reading_works_in_every_glyph_mode_and_with_one_value() {
558        for mode in [GlyphMode::Unicode, GlyphMode::Nerd, GlyphMode::Ascii] {
559            let mut h = readable();
560            h.set_glyph_mode(mode);
561            let before = h.screen();
562            h.click(3, 1);
563            assert_eq!(h.app().reading, Some(3), "{mode:?}");
564            assert_eq!(h.screen(), before, "{mode:?} moves no cell");
565            assert_eq!(h.bg(3, 1), h.env().theme().color("accent"), "{mode:?} marks the read column");
566
567            let mut single = harness(Demo::new([42.0], 1).readable(), 6);
568            single.set_glyph_mode(mode);
569            single.press("tab").press("right");
570            assert_eq!(single.app().reading, Some(0), "{mode:?} reads the only value");
571        }
572    }
573
574    #[test]
575    fn reading_holds_in_every_theme() {
576        for theme in ["monochrome", "nordic", "amber", "iris"] {
577            let mut h = readable();
578            h.set_theme(theme);
579            h.click(3, 1);
580            assert_eq!(h.app().reading, Some(3), "{theme}");
581            let accent = h.env().theme().color("accent");
582            assert_eq!(h.bg(3, 1), accent, "{theme} marks the read column in its accent");
583            assert_ne!(h.bg(3, 0), h.bg(0, 0), "{theme} raises the read column's band above the ground");
584        }
585    }
586
587    #[test]
588    fn an_empty_readable_sparkline_stays_quiet() {
589        let mut h = harness(Demo::new([], 1).readable(), 6);
590        assert_eq!(h.screen(), "\n");
591        h.press("tab").press("right").press("home").click(0, 0);
592        assert_eq!(h.app().reading, None, "there is nothing to read");
593        assert_eq!(h.screen(), "\n");
594    }
595
596    #[test]
597    fn equal_values_are_all_readable() {
598        let mut h = harness(Demo::new([50.0, 50.0, 50.0], 2).range(0.0, 100.0).readable(), 3);
599        assert_eq!(h.screen(), "▁▁▁\n\n", "equal values stand at one flat level");
600        h.click(1, 1);
601        assert_eq!(h.app().reading, Some(1));
602        assert_eq!(h.bg(1, 1), h.env().theme().color("accent"));
603        assert_eq!(h.bg(0, 1), Some(column(&h)), "its neighbours keep the column colour");
604    }
605
606    #[test]
607    fn every_builtin_theme_gives_the_read_column_its_own_tone() {
608        let registry = crate::theme::ThemeRegistry::builtin();
609        for (id, _) in registry.list() {
610            let theme = registry.resolve(&id).theme.expect("resolves");
611            let style = theme.style("sparkline", None, &[]);
612            let tone = |key: &str| style.paint(key).map(|paint| paint.at(0.0));
613            let (Some(reading), Some(line)) = (tone("reading"), tone("fg")) else {
614                panic!("theme {id} names no `reading` or `fg` tone");
615            };
616            // The distance a theme keeps between colours of different meaning.
617            let distance = reading.perceptual_distance(line);
618            assert!(distance >= 0.10, "theme {id}: the read column {reading} is too close to the line {line}");
619            assert_eq!(Some(reading), theme.color("accent"), "theme {id} reads in its accent, as it selects");
620        }
621    }
622
623    #[test]
624    fn reading_survives_a_one_cell_area() {
625        let mut h = harness(Demo::new(LOAD, 1).readable(), 1);
626        h.press("tab").press("left");
627        assert_eq!(h.app().reading, Some(7), "the only column shown is the newest value");
628        h.press("left");
629        assert_eq!(h.app().reading, Some(7));
630        h.click(0, 0);
631        assert_eq!(h.app().reading, Some(7));
632    }
633}