quvyta-framework 0.1.2

A Rust framework for building terminal applications
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
//! Sparklines: a series of numbers as a row of small columns.

use super::eighths;
use crate::color::Rgb;
use crate::event::{Event, MouseButton, MouseKind};
use crate::geometry::{Rect, Size, clamp_u16};
use crate::icons::GlyphMode;
use crate::keymap::Key;
use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};

/// How much a hovered tone is lifted towards the text colour, the same step the theme uses for
/// hovered surfaces.
const HOVER_LIFT: f32 = 0.08;

/// Builds a message from the point being read, or from no point at all.
type ReadMessage<Msg> = Box<dyn Fn(Option<usize>) -> Msg>;

/// A compact trend: one column per value, newest on the right, measured in eighths of a cell.
///
/// Columns scale from the lowest to the highest value shown, or over a fixed range. Every value
/// keeps at least one eighth so a quiet moment still reads as a sample. ASCII mode has no partial
/// blocks: whole cells fill with colour and the cell a column ends in takes the share of colour it
/// 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
/// height for taller columns.
///
/// With [`Sparkline::on_read`] a point can be read off the chart: pressing or dragging over the
/// columns reads the one under the pointer, and while focused ←/→ move one point, Home and End
/// jump to the oldest and the newest shown, and Esc stops reading. The message carries the
/// position in the values given, so the application writes the value itself; the application owns
/// which point is being read ([`Sparkline::reading`]). The read column rises to a tone band of its
/// own and is drawn in the accent, the hovered column's band is one hover step brighter than the
/// ground, and nothing moves: bands and columns stay in their cells.
///
/// Style keys: `sparkline` (`fg` for the columns, `peak` and `low` for the highlighted extremes,
/// `reading` for the column being read, `baseline` for the tone band of a reference value,
/// `track` for the ground in ASCII mode).
pub struct Sparkline<Msg> {
    values: Vec<f32>,
    range: Option<(f32, f32)>,
    extremes: bool,
    baseline: Option<f32>,
    reading: Option<usize>,
    on_read: Option<ReadMessage<Msg>>,
}

impl<Msg: 'static> Sparkline<Msg> {
    /// A sparkline of `values`, oldest first.
    #[must_use]
    pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
        Self {
            values: values.into_iter().collect(),
            range: None,
            extremes: false,
            baseline: None,
            reading: None,
            on_read: None,
        }
    }

    /// Scales columns over `min..max` instead of the values shown, e.g. `0.0..100.0` for percent.
    #[must_use]
    pub fn range(mut self, min: f32, max: f32) -> Self {
        self.range = Some((min, max));
        self
    }

    /// Colours the highest and the lowest column shown.
    #[must_use]
    pub fn highlight_extremes(mut self) -> Self {
        self.extremes = true;
        self
    }

    /// Tints the cells at the level of `value`, such as a limit or an average, with a quiet band.
    #[must_use]
    pub fn baseline(mut self, value: f32) -> Self {
        self.baseline = Some(value);
        self
    }

    /// The point being read, as a position in the values given. A point that is not shown, because
    /// the area is narrower than the series, is not marked.
    #[must_use]
    pub fn reading(mut self, index: Option<usize>) -> Self {
        self.reading = index;
        self
    }

    /// Lets a point be read off the chart with the pointer and the keyboard; the message carries
    /// the position in the values given, or nothing when reading stops.
    #[must_use]
    pub fn on_read(mut self, message: impl Fn(Option<usize>) -> Msg + 'static) -> Self {
        self.on_read = Some(Box::new(message));
        self
    }

    fn scale(&self, shown: &[f32]) -> (f32, f32) {
        let (min, max) = self.range.unwrap_or_else(|| {
            let min = shown.iter().copied().fold(f32::INFINITY, f32::min);
            let max = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
            (min, max)
        });
        (min, if max > min { max } else { min + 1.0 })
    }

    /// Where the values shown in an area `width` cells wide start, and how many there are.
    fn window(&self, width: u16) -> (usize, usize) {
        let count = self.values.len().min(usize::from(width));
        (self.values.len() - count, count)
    }

    /// The point a pointer at column `x` reads: the nearest one, so a drag that leaves the
    /// columns keeps reading the end of the series.
    fn point_at(&self, area: Rect, x: i32) -> Option<usize> {
        let (start, count) = self.window(area.width);
        let last = count.checked_sub(1)?;
        let column = usize::try_from((x - area.x).max(0)).unwrap_or(0).min(last);
        Some(start + column)
    }

    /// Reads `target` when it is another point than the one being read.
    fn read(&self, cx: &mut EventCx<'_, Msg>, target: Option<usize>) {
        if target != self.reading
            && let Some(message) = &self.on_read
        {
            cx.emit(message(target));
        }
    }
}

impl<Msg: 'static> Widget<Msg> for Sparkline<Msg> {
    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
        let width = clamp_u16(i32::try_from(self.values.len()).unwrap_or(i32::MAX));
        Size::new(width, u16::from(width > 0)).min(available)
    }

    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
        if area.is_empty() || self.values.is_empty() {
            return;
        }
        let readable = self.on_read.is_some();
        if readable {
            cx.register_hit(area);
        }
        let (start, count) = self.window(area.width);
        let shown = &self.values[start..];
        let (min, max) = self.scale(shown);
        let style = cx.style("sparkline", None, &[]);
        let fill = style.color("fg").unwrap_or_else(|| cx.color("accent"));
        let peak = style.color("peak").unwrap_or(fill);
        let low = style.color("low").unwrap_or(fill);
        let marked = style.color("reading").unwrap_or_else(|| cx.color("accent"));
        let cells = area.height;
        let total = u32::from(cells) * 8;
        // The lowest value keeps one eighth and the highest fills the column.
        let level = |value: f32| 1 + eighths::scaled((value - min) / (max - min), total - 1);

        let ascii = cx.env().glyph_mode() == GlyphMode::Ascii;
        let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
        if ascii {
            cx.clear(Rect::new(area.x, area.y, clamp_u16(i32::try_from(count).unwrap_or(0)), area.height), track);
        }
        // The row of the baseline band, counted from the bottom, and its colour.
        let band = self.baseline.map(|value| {
            let row = u16::try_from(level(value).saturating_sub(1) / 8).unwrap_or(0).min(cells - 1);
            (row, style.color("baseline").unwrap_or_else(|| cx.color("raised")))
        });
        if let Some((row, color)) = band {
            let width = clamp_u16(i32::try_from(count).unwrap_or(0));
            cx.fill(Rect::new(area.x, area.bottom() - 1 - i32::from(row), width, 1), color);
        }

        let hovered = if readable { cx.pointer().and_then(|(x, _)| self.point_at(area, x)) } else { None };
        let read = self.reading.filter(|index| (start..start + count).contains(index));
        let ground = (cx.color("raised"), cx.color("active"), cx.color("text"));
        let (peak_index, low_index) = if self.extremes { extremes(shown) } else { (None, None) };
        for (offset, value) in shown.iter().copied().enumerate() {
            let index = start + offset;
            let color = if Some(index) == read {
                marked
            } else if Some(offset) == peak_index {
                peak
            } else if Some(offset) == low_index {
                low
            } else {
                fill
            };
            let column = Rect::new(area.x + i32::try_from(offset).unwrap_or(0), area.y, 1, cells);
            let lit = column_band(ground, Some(index) == read, Some(index) == hovered);
            if let Some(tone) = lit {
                cx.fill(column, tone);
            }
            if ascii {
                let ground = lit.unwrap_or(track);
                paint_ascii_column(cx, column, level(value), color, |row| match band {
                    Some((band_row, band_color)) if band_row == row && lit.is_none() => band_color,
                    _ => ground,
                });
            } else {
                eighths::vertical(cx, column, level(value), color);
            }
        }
    }

    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
        if self.on_read.is_none() || self.values.is_empty() {
            return false;
        }
        let area = cx.area();
        let (start, count) = self.window(area.width);
        let Some(last) = count.checked_sub(1).map(|last| start + last) else {
            return false;
        };
        match event {
            Event::Key(key) => {
                // A first key reads the newest point, which is the one a reader wants first.
                let current = self.reading.filter(|index| (start..=last).contains(index));
                let target = if key.is_plain(Key::Left) {
                    current.map_or(last, |index| index.saturating_sub(1).max(start))
                } else if key.is_plain(Key::Right) {
                    current.map_or(last, |index| (index + 1).min(last))
                } else if key.is_plain(Key::Home) {
                    start
                } else if key.is_plain(Key::End) {
                    last
                } else if key.is_plain(Key::Esc) {
                    if self.reading.is_none() {
                        return false;
                    }
                    self.read(cx, None);
                    return true;
                } else {
                    return false;
                };
                self.read(cx, Some(target));
                true
            }
            Event::Mouse(mouse) => match mouse.kind {
                MouseKind::Down(MouseButton::Left) => {
                    // The pointer is captured so a drag that leaves the columns keeps reading.
                    cx.capture_pointer();
                    self.read(cx, self.point_at(area, mouse.x));
                    true
                }
                MouseKind::Drag(MouseButton::Left) => {
                    self.read(cx, self.point_at(area, mouse.x));
                    true
                }
                MouseKind::Up(MouseButton::Left) => true,
                _ => false,
            },
            _ => false,
        }
    }

    fn focusable(&self) -> bool {
        self.on_read.is_some() && !self.values.is_empty()
    }
}

/// The tone behind a column: the read one sits on the active surface, a hovered one is lifted a
/// hover step above the ground it stands on, and a plain column has no band of its own.
/// `ground` is `(raised, active, text)`.
fn column_band(ground: (Rgb, Rgb, Rgb), read: bool, hovered: bool) -> Option<Rgb> {
    let (raised, active, text) = ground;
    match (read, hovered) {
        (false, false) => None,
        (true, false) => Some(active),
        (read, true) => Some(if read { active } else { raised }.mix(text, HOVER_LIFT)),
    }
}

/// Draws a column of `eighths` in ASCII mode, which has no partial blocks: whole cells take
/// `color`, and the cell the column ends in takes the share of `color` it covers over its ground
/// (`ground(row)`, rows counted from the bottom). Rounding to whole cells instead would hide the
/// lowest samples, or, one row tall, draw every sample as the same full cell; blending keeps every
/// sample visible and the trend readable, as cell-stepped colour does everywhere.
fn paint_ascii_column(cx: &mut PaintCx<'_>, column: Rect, eighths: u32, color: Rgb, ground: impl Fn(u16) -> Rgb) {
    let full = u16::try_from(eighths / 8).unwrap_or(u16::MAX).min(column.height);
    cx.fill(Rect::new(column.x, column.bottom() - i32::from(full), 1, full), color);
    let partial = eighths % 8;
    if partial > 0 && full < column.height {
        // `partial` is below eight, so the share is exact in f32.
        let tone = ground(full).mix(color, partial as f32 / 8.0);
        cx.fill(Rect::new(column.x, column.bottom() - i32::from(full) - 1, 1, 1), tone);
    }
}

/// The positions of the highest and the lowest value, or `None` for each that is not marked.
///
/// Only the latest occurrence of each extreme is marked, so a flat top is not a stripe, and a flat
/// series has a peak but no low.
fn extremes(shown: &[f32]) -> (Option<usize>, Option<usize>) {
    let highest = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
    let lowest = shown.iter().copied().fold(f32::INFINITY, f32::min);
    let peak = shown.iter().rposition(|value| *value >= highest);
    let low = shown.iter().rposition(|value| *value <= lowest).filter(|_| highest > lowest);
    (peak, low)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::runtime::{App, Command, Harness};
    use crate::widget::{Length, View};

    /// A sparkline of `values`, with the capabilities each test turns on.
    struct Demo {
        values: Vec<f32>,
        rows: u16,
        range: Option<(f32, f32)>,
        extremes: bool,
        baseline: Option<f32>,
        reading: Option<usize>,
        readable: bool,
    }

    impl Demo {
        fn new(values: impl IntoIterator<Item = f32>, rows: u16) -> Self {
            Self {
                values: values.into_iter().collect(),
                rows,
                range: None,
                extremes: false,
                baseline: None,
                reading: None,
                readable: false,
            }
        }

        fn range(mut self, min: f32, max: f32) -> Self {
            self.range = Some((min, max));
            self
        }

        fn extremes(mut self) -> Self {
            self.extremes = true;
            self
        }

        fn baseline(mut self, value: f32) -> Self {
            self.baseline = Some(value);
            self
        }

        fn readable(mut self) -> Self {
            self.readable = true;
            self
        }
    }

    impl App for Demo {
        type Msg = Option<usize>;
        fn update(&mut self, reading: Option<usize>) -> Command<Option<usize>> {
            self.reading = reading;
            Command::none()
        }
        fn view(&self, ui: &mut View<'_, Option<usize>>) {
            let mut spark = Sparkline::new(self.values.iter().copied());
            if let Some((min, max)) = self.range {
                spark = spark.range(min, max);
            }
            if self.extremes {
                spark = spark.highlight_extremes();
            }
            if let Some(value) = self.baseline {
                spark = spark.baseline(value);
            }
            if self.readable {
                spark = spark.reading(self.reading).on_read(|index| index);
            }
            ui.add(spark).height(Length::Cells(self.rows)).id("trend");
        }
    }

    fn harness(demo: Demo, width: u16) -> Harness<Demo> {
        let height = demo.rows;
        Harness::new(demo, width, height)
    }

    /// The column colour of the built-in theme.
    fn column(h: &Harness<Demo>) -> Rgb {
        let theme = h.env().theme();
        theme.color("surface").expect("token").mix(theme.color("accent").expect("token"), 0.72)
    }

    const LOAD: [f32; 8] = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];

    #[test]
    fn one_row_uses_eighth_blocks_and_keeps_newest() {
        let h = harness(Demo::new(LOAD, 1), 8);
        assert_eq!(h.screen(), "▁▂▃▄▅▆▇\n");
        assert_eq!(h.bg(7, 0), Some(column(&h)));
        let narrow = harness(Demo::new(LOAD, 1), 4);
        assert_eq!(narrow.screen(), "▁▃▆\n");
    }

    #[test]
    fn taller_columns_and_fixed_range() {
        let h = harness(Demo::new([50.0, 100.0, 0.0], 2).range(0.0, 100.0), 3);
        assert_eq!(h.screen(), "\n\n");
        assert_eq!(h.bg(0, 1), Some(column(&h)));
        assert_eq!(h.bg(1, 0), Some(column(&h)));
    }

    #[test]
    fn extremes_and_baseline_are_coloured() {
        let h = harness(Demo::new([3.0, 9.0, 1.0, 5.0], 1).extremes(), 4);
        assert_eq!(h.screen(), "▃ ▁▅\n");
        let theme = h.env().theme();
        assert_eq!(h.bg(1, 0), theme.color("accent"), "the peak is the brightest column");
        assert_eq!(h.fg(2, 0), theme.color("muted"), "the low column is muted");
        assert_ne!(h.fg(0, 0), h.fg(2, 0));
        let lined = harness(Demo::new([3.0, 9.0, 1.0, 5.0], 1).extremes().baseline(5.0), 4);
        assert_ne!(lined.bg(0, 0), h.bg(0, 0), "the baseline row is tinted");
        assert_eq!(lined.bg(1, 0), theme.color("accent"), "columns are drawn over the band");
    }

    #[test]
    fn extremes_mark_the_latest_highest_and_lowest() {
        assert_eq!(extremes(&[3.0, 9.0, 1.0, 5.0]), (Some(1), Some(2)));
        assert_eq!(extremes(&[9.0, 1.0, 9.0, 1.0, 4.0]), (Some(2), Some(3)), "latest occurrence of each");
        assert_eq!(extremes(&[2.0, 2.0, 2.0]), (Some(2), None), "a flat series has no low");
        assert_eq!(extremes(&[]), (None, None));
    }

    #[test]
    fn extremes_take_linear_time_on_the_widest_series() {
        // A falling series is the worst case for a quadratic scan: about two billion comparisons
        // at this width, which takes seconds in a debug build.
        let falling: Vec<f32> = (0..u16::MAX).rev().map(f32::from).collect();
        let started = std::time::Instant::now();
        assert_eq!(extremes(&falling), (Some(0), Some(falling.len() - 1)));
        assert!(started.elapsed() < std::time::Duration::from_millis(500), "took {:?}", started.elapsed());
    }

    #[test]
    fn ascii_fills_cells_on_a_track() {
        let mut h = harness(Demo::new([0.0, 10.0], 2), 2);
        h.set_glyph_mode(GlyphMode::Ascii);
        assert_eq!(h.screen(), "\n\n");
        let theme = h.env().theme();
        assert_eq!(h.bg(0, 0), theme.color("raised"));
        let lowest = h.bg(0, 1);
        assert!(lowest.is_some() && lowest != theme.color("raised"), "the lowest value still tints one cell");
        assert_eq!(h.bg(1, 0), Some(column(&h)));
    }

    #[test]
    fn ascii_shows_every_sample_as_at_least_one_cell() {
        let mut h = harness(Demo::new([0.0, 1.0, 50.0, 100.0], 3), 4);
        h.set_glyph_mode(GlyphMode::Ascii);
        let raised = h.env().theme().color("raised");
        for x in 0..4 {
            assert_ne!(h.bg(x, 2), raised, "sample {x} is visible");
        }
        assert_eq!(h.bg(0, 1), raised, "low samples do not grow past one cell");
        assert_eq!(h.bg(3, 0), Some(column(&h)), "the highest fills the column");
        assert_eq!(h.bg(2, 2), Some(column(&h)), "cells a column passes are whole colour");
    }

    #[test]
    fn one_row_ascii_keeps_the_trend_in_tone() {
        let mut h = harness(Demo::new(LOAD, 1), 8);
        h.set_glyph_mode(GlyphMode::Ascii);
        let brightness = |x: u16| h.bg(x, 0).map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b));
        let raised = h.env().theme().color("raised");
        assert!((0..8).all(|x| h.bg(x, 0) != raised), "every sample tints its cell");
        assert!((1..8).all(|x| brightness(x) != brightness(x - 1)), "rising values read as rising tones");
        assert_eq!(h.bg(7, 0), Some(column(&h)), "the highest is whole colour");
    }

    /// Four samples on a two-row sparkline: 20, 90, 40 and 60 percent.
    fn readable() -> Harness<Demo> {
        harness(Demo::new([20.0, 90.0, 40.0, 60.0], 2).range(0.0, 100.0).readable(), 4)
    }

    #[test]
    fn a_plain_sparkline_neither_takes_focus_nor_sends_messages() {
        let mut h = harness(Demo::new([20.0, 90.0, 40.0, 60.0], 2).range(0.0, 100.0), 4);
        let before = h.screen();
        h.press("tab").press("right").press("end").click(1, 1).hover(2, 0);
        assert_eq!(h.app().reading, None, "nothing was read");
        assert_eq!(h.screen(), before, "and nothing changed on screen");
    }

    #[test]
    fn a_press_reads_the_point_under_the_pointer_and_a_drag_scrubs() {
        let mut h = readable();
        h.click(2, 1);
        assert_eq!(h.app().reading, Some(2), "the third column was read");
        h.drag((2, 1), (0, 1));
        assert_eq!(h.app().reading, Some(0), "the drag read the oldest point");
        h.drag((0, 1), (40, 1));
        assert_eq!(h.app().reading, Some(3), "a drag past the columns keeps the newest point");
    }

    #[test]
    fn keys_move_the_reading_and_esc_stops_it() {
        let mut h = readable();
        h.press("tab");
        assert_eq!(h.app().reading, None, "focus alone reads nothing");
        h.press("left");
        assert_eq!(h.app().reading, Some(3), "the first key reads the newest point");
        h.press("left");
        assert_eq!(h.app().reading, Some(2));
        h.press("home");
        assert_eq!(h.app().reading, Some(0));
        h.press("left");
        assert_eq!(h.app().reading, Some(0), "the oldest shown point is the end of the way");
        h.press("right");
        assert_eq!(h.app().reading, Some(1));
        h.press("end");
        assert_eq!(h.app().reading, Some(3));
        h.press("right");
        assert_eq!(h.app().reading, Some(3), "and so is the newest");
        h.press("esc");
        assert_eq!(h.app().reading, None, "esc stops reading");
    }

    #[test]
    fn the_read_column_rises_and_the_hovered_one_lightens_without_moving() {
        let mut h = readable();
        let quiet = h.screen();
        let ground = h.bg(1, 0);
        h.hover(1, 0);
        let hovered = h.bg(1, 0);
        assert_ne!(hovered, ground, "the hovered column stands on a lighter ground");
        assert_eq!(h.screen(), quiet, "hovering moves no cell");
        assert_eq!(h.bg(2, 0), ground, "only the hovered column changes");

        h.send(Some(1));
        let (accent, active) = {
            let theme = h.env().theme();
            (theme.color("accent"), theme.color("active"))
        };
        assert_eq!(h.bg(1, 1), accent, "the read column is drawn in the accent");
        assert_ne!(h.bg(1, 0), hovered, "and its band is the active surface, lifted while hovered");
        assert_eq!(h.screen(), quiet, "reading moves no cell either");
        h.hover(30, 30);
        assert_eq!(h.bg(1, 0), active, "unhovered, the read column keeps the active band");
    }

    #[test]
    fn a_reading_outside_the_shown_window_is_not_marked() {
        let mut h = harness(Demo::new(LOAD, 1).readable(), 3);
        h.send(Some(0));
        let theme = h.env().theme();
        assert_eq!(h.app().reading, Some(0));
        assert!((0..3).all(|x| h.bg(x, 0) != theme.color("active")), "no column is marked:\n{}", h.screen());
        // Only the three newest values are shown, so the keys work inside that window.
        h.press("tab").press("home");
        assert_eq!(h.app().reading, Some(5), "home reads the oldest point shown");
    }

    #[test]
    fn reading_works_in_every_glyph_mode_and_with_one_value() {
        for mode in [GlyphMode::Unicode, GlyphMode::Nerd, GlyphMode::Ascii] {
            let mut h = readable();
            h.set_glyph_mode(mode);
            let before = h.screen();
            h.click(3, 1);
            assert_eq!(h.app().reading, Some(3), "{mode:?}");
            assert_eq!(h.screen(), before, "{mode:?} moves no cell");
            assert_eq!(h.bg(3, 1), h.env().theme().color("accent"), "{mode:?} marks the read column");

            let mut single = harness(Demo::new([42.0], 1).readable(), 6);
            single.set_glyph_mode(mode);
            single.press("tab").press("right");
            assert_eq!(single.app().reading, Some(0), "{mode:?} reads the only value");
        }
    }

    #[test]
    fn reading_holds_in_every_theme() {
        for theme in ["monochrome", "nordic", "amber", "iris"] {
            let mut h = readable();
            h.set_theme(theme);
            h.click(3, 1);
            assert_eq!(h.app().reading, Some(3), "{theme}");
            let accent = h.env().theme().color("accent");
            assert_eq!(h.bg(3, 1), accent, "{theme} marks the read column in its accent");
            assert_ne!(h.bg(3, 0), h.bg(0, 0), "{theme} raises the read column's band above the ground");
        }
    }

    #[test]
    fn an_empty_readable_sparkline_stays_quiet() {
        let mut h = harness(Demo::new([], 1).readable(), 6);
        assert_eq!(h.screen(), "\n");
        h.press("tab").press("right").press("home").click(0, 0);
        assert_eq!(h.app().reading, None, "there is nothing to read");
        assert_eq!(h.screen(), "\n");
    }

    #[test]
    fn equal_values_are_all_readable() {
        let mut h = harness(Demo::new([50.0, 50.0, 50.0], 2).range(0.0, 100.0).readable(), 3);
        assert_eq!(h.screen(), "▁▁▁\n\n", "equal values stand at one flat level");
        h.click(1, 1);
        assert_eq!(h.app().reading, Some(1));
        assert_eq!(h.bg(1, 1), h.env().theme().color("accent"));
        assert_eq!(h.bg(0, 1), Some(column(&h)), "its neighbours keep the column colour");
    }

    #[test]
    fn every_builtin_theme_gives_the_read_column_its_own_tone() {
        let registry = crate::theme::ThemeRegistry::builtin();
        for (id, _) in registry.list() {
            let theme = registry.resolve(&id).theme.expect("resolves");
            let style = theme.style("sparkline", None, &[]);
            let tone = |key: &str| style.paint(key).map(|paint| paint.at(0.0));
            let (Some(reading), Some(line)) = (tone("reading"), tone("fg")) else {
                panic!("theme {id} names no `reading` or `fg` tone");
            };
            // The distance a theme keeps between colours of different meaning.
            let distance = reading.perceptual_distance(line);
            assert!(distance >= 0.10, "theme {id}: the read column {reading} is too close to the line {line}");
            assert_eq!(Some(reading), theme.color("accent"), "theme {id} reads in its accent, as it selects");
        }
    }

    #[test]
    fn reading_survives_a_one_cell_area() {
        let mut h = harness(Demo::new(LOAD, 1).readable(), 1);
        h.press("tab").press("left");
        assert_eq!(h.app().reading, Some(7), "the only column shown is the newest value");
        h.press("left");
        assert_eq!(h.app().reading, Some(7));
        h.click(0, 0);
        assert_eq!(h.app().reading, Some(7));
    }
}