Skip to main content

qframe/widgets/
heatmap.rs

1//! Heatmaps: a grid of days, each one a tone for how much happened that day.
2
3use crate::color::{ColorDepth, Rgb};
4use crate::event::Event;
5use crate::geometry::{Rect, Size, clamp_u16};
6use crate::keymap::Key;
7use crate::theme::State;
8use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
9
10use super::press::{self, Press};
11
12/// Rows of a heatmap unless the caller asks for another number: a week of days.
13const DEFAULT_ROWS: u16 = 7;
14
15/// How many tones a value can take besides the empty one.
16///
17/// Four steps is what a reader can name — quiet, some, busy, busiest — and what a low-colour
18/// terminal can still show something of. A continuous ramp would look precise and read as noise.
19const LEVELS: u32 = 4;
20
21/// How far each level mixes from the empty tone towards the full one. The first step starts well
22/// clear of the empty tone, so a single quiet day is never mistaken for nothing at all.
23const MIX: [f32; LEVELS as usize] = [0.30, 0.53, 0.76, 1.0];
24
25/// Smallest colour difference a reader notices, from [`Rgb::perceptual_distance`].
26const VISIBLE: f64 = 0.03;
27
28/// Builds a message from the index of a chosen value.
29type SelectMessage<Msg> = Box<dyn Fn(usize) -> Msg>;
30
31/// What the runtime remembers about a heatmap between frames.
32#[derive(Debug, Default)]
33struct HeatmapMemory {
34    /// Where the keyboard left the cursor, as an index into the values.
35    cursor: Option<usize>,
36}
37
38/// A grid of days as tones: one cell per value, a column per week, the tone carrying how much
39/// that day holds.
40///
41/// Values run oldest first and fill the grid column by column, so a column is a week and a row
42/// is a weekday; the newest column is on the right. `starts_at` leaves the first cells of the
43/// first column blank, for a year that does not begin on the first weekday: a blank cell is a
44/// day outside the range and shows nothing at all, while a day inside it that holds nothing
45/// takes the empty tone. The two never look the same.
46///
47/// The tone is a step, not a gradient: a value at or below zero takes the empty tone, and any
48/// value above it takes one of four steps towards the full tone, the topmost step reserved for
49/// the largest value (or for `max`, when the scale is fixed). With `series` the full tone is one
50/// of the theme's series tones instead of the accent, so several heatmaps beside each other read
51/// as different categories — name them with a [`Legend`](super::Legend).
52///
53/// Nothing is drawn with characters, so the grid looks the same in every glyph mode. Where the
54/// terminal cannot tell two steps apart (a 16-colour terminal), the steps that would collapse
55/// are dropped and the remaining tones are spread over the four levels: fewer steps, but never
56/// two different levels in the same tone.
57///
58/// A heatmap is a picture and stays passive until `on_select` is given. With it, the cell under
59/// the pointer and the cell the keyboard cursor is on light up, a click or Enter reports that
60/// cell's index, and the caller writes its value as text — a single cell's number cannot be read
61/// out of a tone, and the runtime does not tell widgets about pointer movement, so lighting a
62/// cell is what hovering can do and reading the number needs the one press that the keyboard
63/// makes with Enter.
64///
65/// Narrow areas keep the newest columns and drop the oldest whole columns, so the grid never
66/// shows a half week; [`Heatmap::columns`] says how many are left, for a caller that wants to
67/// write "the last 12 weeks". An area shorter than the grid keeps the rows that fit from the
68/// top. An area with no room, or a heatmap with no values, draws nothing and measures nothing,
69/// which leaves the caller room for an empty state.
70///
71/// Style keys: `heatmap` (`empty` for a day that holds nothing, `fill` for the full tone,
72/// `cursor` for the tone the lit cell mixes towards) and `heatmap:focus` (`cursor` while the
73/// keyboard moved the cursor last).
74pub struct Heatmap<Msg> {
75    values: Vec<f32>,
76    rows: u16,
77    max: Option<f32>,
78    starts_at: u16,
79    series: Option<usize>,
80    selected: Option<usize>,
81    on_select: Option<SelectMessage<Msg>>,
82}
83
84impl<Msg: 'static> Heatmap<Msg> {
85    /// A heatmap of `values`, oldest first, in a grid seven rows tall.
86    #[must_use]
87    pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
88        Self {
89            values: values.into_iter().collect(),
90            rows: DEFAULT_ROWS,
91            max: None,
92            starts_at: 0,
93            series: None,
94            selected: None,
95            on_select: None,
96        }
97    }
98
99    /// Rows of the grid; seven by default, one per weekday. At least one.
100    #[must_use]
101    pub fn rows(mut self, rows: u16) -> Self {
102        self.rows = rows.max(1);
103        self
104    }
105
106    /// The value the topmost step stands for, e.g. a daily goal; the largest value by default.
107    #[must_use]
108    pub fn max(mut self, max: f32) -> Self {
109        self.max = Some(max);
110        self
111    }
112
113    /// Blank cells before the first value, for a range that does not start on the first row of a
114    /// column. Kept within one column.
115    #[must_use]
116    pub fn starts_at(mut self, row: u16) -> Self {
117        self.starts_at = row % self.rows;
118        self
119    }
120
121    /// Builds the tones from the theme's `index`-th series tone instead of the accent, for one
122    /// category among several.
123    #[must_use]
124    pub fn series(mut self, index: usize) -> Self {
125        self.series = Some(index);
126        self
127    }
128
129    /// The value the cursor rests on, which the caller keeps as it hears `on_select`.
130    #[must_use]
131    pub fn selected(mut self, index: Option<usize>) -> Self {
132        self.selected = index;
133        self
134    }
135
136    /// Message for a cell chosen with a click or with Enter, carrying its index into the values.
137    #[must_use]
138    pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
139        self.on_select = Some(Box::new(message));
140        self
141    }
142
143    /// How many columns the grid has in `width` cells: every week that fits, newest kept.
144    #[must_use]
145    pub fn columns(&self, width: u16) -> u16 {
146        self.total_columns().min(width)
147    }
148
149    /// Cells the grid holds, blank leading cells included.
150    fn cell_count(&self) -> usize {
151        usize::from(self.starts_at).saturating_add(self.values.len())
152    }
153
154    /// Columns the whole grid needs.
155    fn total_columns(&self) -> u16 {
156        let rows = usize::from(self.rows);
157        let columns = self.cell_count().div_ceil(rows);
158        clamp_u16(i32::try_from(columns).unwrap_or(i32::MAX))
159    }
160
161    /// The first column shown in `width` cells; earlier columns are dropped.
162    fn first_column(&self, width: u16) -> u16 {
163        self.total_columns().saturating_sub(self.columns(width))
164    }
165
166    /// The value the topmost step stands for; never zero, so a grid of zeroes stays empty
167    /// instead of lighting up.
168    fn scale(&self) -> f32 {
169        let largest = self.values.iter().copied().fold(0.0, f32::max);
170        let max = self.max.unwrap_or(largest);
171        if max > 0.0 { max } else { 1.0 }
172    }
173
174    /// The step `value` takes: zero for nothing, else 1 to [`LEVELS`], the top step only for a
175    /// value at or above the scale.
176    fn level(&self, value: f32) -> u32 {
177        if value <= 0.0 {
178            return 0;
179        }
180        // The share is small and positive, so the product fits f32 and the ceiling fits u32.
181        let share = (value / self.scale()).clamp(0.0, 1.0);
182        ((share * LEVELS as f32).ceil() as u32).clamp(1, LEVELS)
183    }
184
185    /// Where the value at `index` sits on screen, or `None` when its column or row is cut.
186    fn cell_rect(&self, area: Rect, index: usize) -> Option<Rect> {
187        let place = usize::from(self.starts_at).checked_add(index)?;
188        let rows = usize::from(self.rows);
189        let column = u16::try_from(place / rows).ok()?;
190        let row = u16::try_from(place % rows).ok()?;
191        let first = self.first_column(area.width);
192        if column < first || row >= area.height {
193            return None;
194        }
195        Some(Rect::new(area.x + i32::from(column - first), area.y + i32::from(row), 1, 1))
196    }
197
198    /// The value the cell at `x`, `y` holds, or `None` outside the grid or on a blank cell.
199    fn value_at(&self, area: Rect, x: i32, y: i32) -> Option<usize> {
200        if !area.contains(x, y) {
201            return None;
202        }
203        let column = u16::try_from(x - area.x).ok()?.checked_add(self.first_column(area.width))?;
204        let row = u16::try_from(y - area.y).ok()?;
205        let place = usize::from(column).checked_mul(usize::from(self.rows))?.checked_add(usize::from(row))?;
206        let index = place.checked_sub(usize::from(self.starts_at))?;
207        (index < self.values.len()).then_some(index)
208    }
209
210    /// The empty tone and the full one.
211    fn tones(&self, cx: &mut PaintCx<'_>) -> (Rgb, Rgb) {
212        let style = cx.style("heatmap", None, &[]);
213        let empty = style.color("empty").unwrap_or_else(|| cx.color("raised"));
214        let full = style.color("fill").unwrap_or_else(|| match self.series {
215            Some(index) => cx.env().theme().series_color(index),
216            None => cx.color("accent"),
217        });
218        (empty, full)
219    }
220
221    /// The cell the keyboard cursor is on, and the value the pointer is over.
222    fn lit(&self, cx: &mut PaintCx<'_>, area: Rect) -> (Option<usize>, Option<usize>) {
223        if self.on_select.is_none() {
224            return (None, None);
225        }
226        let pointed = cx.pointer_within().and_then(|(x, y)| self.value_at(area, x, y));
227        let focused = cx.is_focus_visible();
228        let cursor = cx.memory::<HeatmapMemory>().cursor.or(self.selected).filter(|_| focused);
229        (cursor, pointed)
230    }
231
232    /// Moves the keyboard cursor `step` cells along the values, clamped to their ends, and says
233    /// that the key was used.
234    fn move_cursor(&self, cx: &mut EventCx<'_, Msg>, step: i32) -> bool {
235        let last = self.values.len().saturating_sub(1);
236        let from = cx.memory::<HeatmapMemory>().cursor.or(self.selected).unwrap_or(last);
237        let target = i32::try_from(from).unwrap_or(0).saturating_add(step);
238        let target = usize::try_from(target.max(0)).unwrap_or(0).min(last);
239        cx.memory::<HeatmapMemory>().cursor = Some(target);
240        true
241    }
242
243    /// Reports the cell the cursor is on, which a press or Enter chooses.
244    fn choose(&self, cx: &mut EventCx<'_, Msg>, index: usize) {
245        cx.memory::<HeatmapMemory>().cursor = Some(index);
246        cx.flash();
247        if let Some(message) = &self.on_select {
248            cx.emit(message(index));
249        }
250    }
251}
252
253/// How `depth` shows `color`: two tones with the same answer are one tone on screen.
254fn shown_as(color: Rgb, depth: ColorDepth) -> u32 {
255    match depth {
256        ColorDepth::TrueColor => u32::from(color.r) << 16 | u32::from(color.g) << 8 | u32::from(color.b),
257        ColorDepth::Ansi256 => u32::from(color.to_ansi256()),
258        ColorDepth::Ansi16 => u32::from(color.to_ansi16()),
259    }
260}
261
262/// The tones from the quietest step to the full one that `depth` can tell apart.
263///
264/// A step the terminal would show in the tone of the step below it is left out, so no two levels
265/// share a tone; the levels are then spread over the tones that are left. A terminal that shows
266/// the whole ramp as one colour still gets the full tone, because a day that holds something must
267/// never look like a day that holds nothing.
268fn ramp(empty: Rgb, full: Rgb, depth: ColorDepth) -> Vec<Rgb> {
269    let mut tones = Vec::with_capacity(LEVELS as usize);
270    let mut previous = empty;
271    for mix in MIX {
272        let tone = empty.mix(full, mix);
273        if shown_as(tone, depth) != shown_as(previous, depth) {
274            tones.push(tone);
275            previous = tone;
276        }
277    }
278    if tones.is_empty() {
279        tones.push(full);
280    }
281    tones
282}
283
284/// `tone` stepped away from itself so the cell under the cursor stands out: `amount` of the way
285/// towards `towards`, or, when that changes nothing because the tone is already there (a theme
286/// whose accent is its text colour, drawn on the busiest day), the same step back towards
287/// `empty`. Either way the cell moves by a step a reader can see, and the value it carries is
288/// still the tone it is a step away from.
289fn lift(tone: Rgb, towards: Rgb, empty: Rgb, amount: f32) -> Rgb {
290    let lifted = tone.mix(towards, amount);
291    if lifted.perceptual_distance(tone) < VISIBLE { tone.mix(empty, amount) } else { lifted }
292}
293
294/// The tone of `level` (1 to [`LEVELS`]) in `ramp`: the quietest level takes the first tone, the
295/// top level the last, and the levels between are spread over what is left.
296fn tone_of(ramp: &[Rgb], level: u32) -> Option<Rgb> {
297    let steps = u32::try_from(ramp.len()).unwrap_or(1).saturating_sub(1);
298    let last = LEVELS - 1;
299    // Rounded so the levels in the middle do not all fall to the quieter tone.
300    let index = usize::try_from((level.saturating_sub(1) * steps + last / 2) / last).unwrap_or(0);
301    ramp.get(index).copied()
302}
303
304impl<Msg: 'static> Widget<Msg> for Heatmap<Msg> {
305    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
306        if self.values.is_empty() {
307            return Size::default();
308        }
309        Size::new(self.total_columns(), self.rows).min(available)
310    }
311
312    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
313        if area.is_empty() || self.values.is_empty() {
314            return;
315        }
316        let (empty, full) = self.tones(cx);
317        let steps = ramp(empty, full, cx.env().depth());
318        let (cursor, pointed) = self.lit(cx, area);
319        let style = cx.style("heatmap", None, &[]);
320        let pointer_lift = style.color("cursor").unwrap_or_else(|| cx.color("text"));
321        let keyboard_lift = cx.style("heatmap", None, &[State::Focus]).color("cursor").unwrap_or(pointer_lift);
322        let tone = |level: u32| match level {
323            0 => empty,
324            level => tone_of(&steps, level).unwrap_or(empty),
325        };
326        for (index, value) in self.values.iter().copied().enumerate() {
327            let Some(rect) = self.cell_rect(area, index) else {
328                continue;
329            };
330            let plain = tone(self.level(value));
331            let color = if pointed == Some(index) {
332                lift(plain, pointer_lift, empty, 0.35)
333            } else if pointed.is_none() && cursor == Some(index) {
334                lift(plain, keyboard_lift, empty, 0.5)
335            } else {
336                plain
337            };
338            cx.fill(rect, color);
339        }
340        if self.on_select.is_some() {
341            cx.register_hit(area);
342        }
343    }
344
345    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
346        if self.on_select.is_none() || self.values.is_empty() {
347            return false;
348        }
349        let area = cx.area();
350        let rows = i32::from(self.rows);
351        if let Event::Key(key) = event {
352            if key.is_plain(Key::Left) {
353                return self.move_cursor(cx, -rows);
354            } else if key.is_plain(Key::Right) {
355                return self.move_cursor(cx, rows);
356            } else if key.is_plain(Key::Up) {
357                return self.move_cursor(cx, -1);
358            } else if key.is_plain(Key::Down) {
359                return self.move_cursor(cx, 1);
360            } else if key.is_plain(Key::Home) {
361                return self.move_cursor(cx, i32::MIN);
362            } else if key.is_plain(Key::End) {
363                return self.move_cursor(cx, i32::MAX);
364            }
365        }
366        match press::read(cx, event) {
367            Press::Ignored => false,
368            Press::Used => true,
369            Press::Key => {
370                let last = self.values.len() - 1;
371                let index = cx.memory::<HeatmapMemory>().cursor.or(self.selected).unwrap_or(last);
372                self.choose(cx, index.min(last));
373                true
374            }
375            Press::Click(x, y) => {
376                if let Some(index) = self.value_at(area, x, y) {
377                    self.choose(cx, index);
378                }
379                true
380            }
381        }
382    }
383
384    fn focusable(&self) -> bool {
385        self.on_select.is_some()
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::icons::GlyphMode;
393    use crate::runtime::{App, Command, Harness};
394    use crate::widget::{Length, View};
395
396    #[derive(Default)]
397    struct Demo {
398        values: Vec<f32>,
399        rows: u16,
400        height: Option<u16>,
401        starts_at: u16,
402        series: Option<usize>,
403        interactive: bool,
404        chosen: Option<usize>,
405    }
406
407    impl Demo {
408        fn new(values: impl IntoIterator<Item = f32>) -> Self {
409            Self { values: values.into_iter().collect(), rows: 7, ..Self::default() }
410        }
411    }
412
413    impl App for Demo {
414        type Msg = usize;
415        fn update(&mut self, index: usize) -> Command<usize> {
416            self.chosen = Some(index);
417            Command::none()
418        }
419        fn view(&self, ui: &mut View<'_, usize>) {
420            let mut map: Heatmap<usize> =
421                Heatmap::new(self.values.iter().copied()).rows(self.rows).starts_at(self.starts_at);
422            if let Some(index) = self.series {
423                map = map.series(index);
424            }
425            if self.interactive {
426                map = map.selected(self.chosen).on_select(|index| index);
427            }
428            let height = self.height.map_or(Length::Fill(1), Length::Cells);
429            ui.add(map).width(Length::Fill(1)).height(height).id("map");
430        }
431    }
432
433    /// The tones the built-in theme gives a heatmap: the empty one and the four steps.
434    fn tones(h: &Harness<Demo>) -> (Rgb, [Rgb; 4]) {
435        let theme = h.env().theme();
436        let empty = theme.color("raised").expect("token");
437        let full = theme.color("accent").expect("token");
438        (empty, [0, 1, 2, 3].map(|i| empty.mix(full, MIX[i])))
439    }
440
441    /// The colour of a cell the heatmap did not draw: the screen's own ground.
442    fn blank(h: &Harness<Demo>) -> Option<Rgb> {
443        h.env().theme().color("canvas")
444    }
445
446    #[test]
447    fn a_week_of_days_fills_a_column_and_the_newest_column_is_last() {
448        let h = Harness::new(Demo::new([0.0, 1.0, 2.0, 3.0, 4.0, 0.0, 0.0, 4.0]), 4, 7);
449        let (empty, steps) = tones(&h);
450        assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "a heatmap is made of colour, not characters");
451        assert_eq!(h.bg(0, 0), Some(empty), "a day with nothing takes the empty tone");
452        assert_eq!(h.bg(0, 1), Some(steps[0]), "the quietest day takes the first step");
453        assert_eq!(h.bg(0, 4), Some(steps[3]), "the busiest day takes the top step");
454        assert_eq!(h.bg(1, 0), Some(steps[3]), "the eighth value starts the next column");
455        assert_eq!(h.bg(2, 0), blank(&h), "nothing is drawn past the last column");
456    }
457
458    #[test]
459    fn days_outside_the_range_stay_blank_while_empty_days_take_a_tone() {
460        let mut demo = Demo::new([5.0, 5.0]);
461        demo.starts_at = 3;
462        let h = Harness::new(demo, 2, 7);
463        let (empty, steps) = tones(&h);
464        for row in 0..3 {
465            assert_eq!(h.bg(0, row), blank(&h), "row {row} is before the first value");
466        }
467        assert_eq!(h.bg(0, 3), Some(steps[3]));
468        assert_eq!(h.bg(0, 4), Some(steps[3]));
469        assert_eq!(h.bg(0, 5), blank(&h), "nothing follows the last value");
470        assert_ne!(blank(&h), Some(empty), "a day outside the range is not an empty day");
471    }
472
473    #[test]
474    fn nothing_and_all_zeroes_are_different_pictures() {
475        let nothing = Harness::new(Demo::new([]), 4, 7);
476        assert!((0..7).all(|row| nothing.bg(0, row) == blank(&nothing)), "no values draw nothing at all");
477        let zeroes = Harness::new(Demo::new([0.0; 7]), 4, 7);
478        let (empty, _) = tones(&zeroes);
479        assert!((0..7).all(|row| zeroes.bg(0, row) == Some(empty)), "a quiet week is a column of empty tone");
480    }
481
482    #[test]
483    fn a_single_value_takes_the_top_step_and_a_fixed_scale_holds_it_down() {
484        let h = Harness::new(Demo::new([3.0]), 2, 7);
485        let (_, steps) = tones(&h);
486        assert_eq!(h.bg(0, 0), Some(steps[3]), "the only value is the largest one");
487
488        struct Fixed;
489        impl App for Fixed {
490            type Msg = ();
491            fn update(&mut self, _: ()) -> Command<()> {
492                Command::none()
493            }
494            fn view(&self, ui: &mut View<'_, ()>) {
495                let map: Heatmap<()> = Heatmap::new([3.0]).max(12.0);
496                ui.add(map).width(Length::Fill(1)).height(Length::Fill(1));
497            }
498        }
499        let fixed = Harness::new(Fixed, 2, 7);
500        let theme = fixed.env().theme();
501        let empty = theme.color("raised").expect("token");
502        let step = empty.mix(theme.color("accent").expect("token"), MIX[0]);
503        assert_eq!(fixed.bg(0, 0), Some(step), "a quarter of the goal is the first step");
504    }
505
506    #[test]
507    fn a_narrow_area_keeps_the_newest_weeks_and_says_how_many() {
508        let values: Vec<f32> = (0u16..70).map(|i| f32::from(i % 5)).collect();
509        let map: Heatmap<()> = Heatmap::new(values.iter().copied());
510        assert_eq!(map.columns(80), 10, "ten weeks fit in a wide area");
511        assert_eq!(map.columns(4), 4, "a narrow area shows four weeks");
512        assert_eq!(map.columns(0), 0);
513
514        let h = Harness::new(Demo::new(values), 3, 7);
515        let (_, steps) = tones(&h);
516        // Three columns fit, so the grid starts at the eighth week: value 63, which is 3 of 4.
517        assert_eq!(h.bg(2, 0), Some(steps[2]), "the rightmost column is the newest week");
518        // The leftmost column drawn is the eighth week, whose first value is 49: 4 of 4.
519        assert_eq!(h.bg(0, 0), Some(steps[3]), "the oldest weeks are dropped, not squeezed");
520    }
521
522    #[test]
523    fn a_short_area_keeps_the_rows_that_fit() {
524        let mut demo = Demo::new([4.0; 14]);
525        demo.height = Some(3);
526        let h = Harness::new(demo, 2, 5);
527        let (_, steps) = tones(&h);
528        for column in 0..2 {
529            for row in 0..3 {
530                assert_eq!(h.bg(column, row), Some(steps[3]), "{column},{row}");
531            }
532        }
533        assert_eq!(h.bg(0, 3), blank(&h), "rows past the area are not drawn");
534    }
535
536    #[test]
537    fn tiny_areas_draw_what_they_can_without_panicking() {
538        for (width, height) in [(1, 1), (2, 1), (1, 3), (3, 2)] {
539            let h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0, 5.0]), width, height);
540            assert_eq!(h.screen().lines().count(), usize::from(height), "{width}×{height}");
541        }
542    }
543
544    #[test]
545    fn the_grid_is_the_same_in_every_glyph_mode() {
546        for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
547            let mut h = Harness::new(Demo::new([0.0, 2.0, 4.0]), 2, 7);
548            h.set_glyph_mode(mode);
549            let (empty, steps) = tones(&h);
550            assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "{mode:?} draws no characters");
551            assert_eq!(h.bg(0, 0), Some(empty), "{mode:?}");
552            assert_eq!(h.bg(0, 2), Some(steps[3]), "{mode:?}");
553        }
554    }
555
556    #[test]
557    fn every_theme_tells_the_steps_and_the_empty_tone_apart() {
558        for theme in ["monochrome", "nordic", "amber", "iris"] {
559            let mut h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0]), 2, 7);
560            h.set_theme(theme);
561            let drawn: Vec<Rgb> = (0..4).filter_map(|row| h.bg(0, row)).collect();
562            assert_eq!(drawn.len(), 4, "{theme}");
563            let empty = h.env().theme().color("raised").expect("token");
564            for pair in drawn.windows(2) {
565                assert!(
566                    pair[1].perceptual_distance(pair[0]) >= VISIBLE,
567                    "{theme}: {:?} and {:?} are one tone",
568                    pair[0],
569                    pair[1]
570                );
571            }
572            assert!(drawn[0].perceptual_distance(empty) >= VISIBLE, "{theme}: a quiet day shows over an empty one");
573        }
574    }
575
576    #[test]
577    fn the_ramp_keeps_only_the_tones_a_terminal_can_tell_apart() {
578        let h = Harness::new(Demo::new([1.0]), 2, 7);
579        let (empty, _) = tones(&h);
580        let full = h.env().theme().color("accent").expect("token");
581
582        let true_color = ramp(empty, full, ColorDepth::TrueColor);
583        assert_eq!(true_color.len(), 4, "true colour shows every step");
584        assert_eq!(tone_of(&true_color, 1), Some(true_color[0]));
585        assert_eq!(tone_of(&true_color, 4), Some(true_color[3]), "the top level takes the full tone");
586
587        for depth in [ColorDepth::Ansi256, ColorDepth::Ansi16] {
588            let steps = ramp(empty, full, depth);
589            assert!(!steps.is_empty(), "{depth:?} still shows that something is there");
590            let shown: Vec<u32> = steps.iter().map(|tone| shown_as(*tone, depth)).collect();
591            let mut distinct = shown.clone();
592            distinct.sort_unstable();
593            distinct.dedup();
594            assert_eq!(distinct.len(), shown.len(), "{depth:?} shows no two steps in one tone: {shown:?}");
595            let levels: Vec<Rgb> = (1..=LEVELS).filter_map(|level| tone_of(&steps, level)).collect();
596            assert_eq!(levels.len(), LEVELS as usize, "{depth:?} gives every level a tone");
597            assert_eq!(levels.last(), steps.last(), "{depth:?} keeps the busiest day the brightest");
598            for pair in levels.windows(2) {
599                assert!(
600                    pair[0].relative_luminance() <= pair[1].relative_luminance(),
601                    "{depth:?} never turns a busier day quieter: {pair:?}"
602                );
603            }
604        }
605
606        // A terminal that shows the whole ramp in one tone still draws the days.
607        let flat = ramp(empty, empty, ColorDepth::Ansi16);
608        assert_eq!(flat, vec![empty]);
609        assert_eq!(tone_of(&flat, 4), Some(empty));
610    }
611
612    #[test]
613    fn a_heatmap_without_a_message_is_a_picture() {
614        let mut h = Harness::new(Demo::new([1.0, 2.0]), 4, 7);
615        h.press("tab");
616        assert!(!h.is_focused("map"), "a picture takes no focus");
617        h.click(0, 0);
618        assert_eq!(h.app().chosen, None, "a click on a picture chooses nothing");
619    }
620
621    #[test]
622    fn the_pointer_and_the_keyboard_both_reach_a_cell() {
623        let mut demo = Demo::new((0u16..21).map(|i| f32::from(i % 5 + 1)));
624        demo.interactive = true;
625        let mut h = Harness::new(demo, 4, 7);
626        let plain = h.bg(0, 3);
627
628        h.hover(0, 2);
629        let lit = h.bg(0, 2).expect("the hovered cell is drawn");
630        assert_ne!(Some(lit), h.bg(0, 3), "the cell under the pointer lights up");
631        assert_eq!(h.bg(0, 3), plain, "its neighbours keep their tone");
632
633        h.click(0, 2);
634        assert_eq!(h.app().chosen, Some(2), "a click reports the cell");
635
636        h.press("tab");
637        h.press("down");
638        h.press("enter");
639        assert_eq!(h.app().chosen, Some(3), "the keyboard moves one day and Enter reports it");
640        h.press("right");
641        h.press("enter");
642        assert_eq!(h.app().chosen, Some(10), "a column is a week");
643        h.press("left");
644        h.press("up");
645        h.press("enter");
646        assert_eq!(h.app().chosen, Some(2), "back a week and up a day");
647        h.press("home");
648        h.press("enter");
649        assert_eq!(h.app().chosen, Some(0));
650        h.press("end");
651        h.press("enter");
652        assert_eq!(h.app().chosen, Some(20), "End goes to the newest day");
653    }
654
655    #[test]
656    fn the_keyboard_cursor_lights_a_cell_without_choosing_it() {
657        let mut demo = Demo::new([1.0; 7]);
658        demo.interactive = true;
659        let mut h = Harness::new(demo, 2, 7);
660        h.press("tab");
661        h.press("home");
662        let lit = h.bg(0, 0).expect("drawn");
663        let quiet = h.bg(0, 1).expect("drawn");
664        assert_ne!(lit, quiet, "the cursor cell steps away from its tone");
665        assert_eq!(h.app().chosen, None, "moving the cursor chooses nothing");
666    }
667
668    #[test]
669    fn a_lit_cell_steps_away_from_its_tone_in_every_theme() {
670        for theme in ["monochrome", "nordic", "amber", "iris"] {
671            let mut demo = Demo::new([4.0; 7]);
672            demo.interactive = true;
673            let mut h = Harness::new(demo, 2, 7);
674            h.set_theme(theme);
675            h.hover(0, 0);
676            let lit = h.bg(0, 0).expect("drawn");
677            let plain = h.bg(0, 1).expect("drawn");
678            assert!(
679                lit.perceptual_distance(plain) >= VISIBLE,
680                "{theme}: the busiest day shows the pointer ({lit:?} against {plain:?})"
681            );
682        }
683    }
684
685    #[test]
686    fn levels_step_with_the_share_of_the_scale() {
687        let map: Heatmap<()> = Heatmap::new([0.0, 1.0, 25.0, 50.0, 75.0, 100.0]).max(100.0);
688        assert_eq!(map.level(0.0), 0);
689        assert_eq!(map.level(-4.0), 0, "a negative value is nothing");
690        assert_eq!(map.level(1.0), 1, "any day with something reaches the first step");
691        assert_eq!(map.level(25.0), 1);
692        assert_eq!(map.level(26.0), 2);
693        assert_eq!(map.level(75.0), 3);
694        assert_eq!(map.level(76.0), 4);
695        assert_eq!(map.level(100.0), 4);
696        assert_eq!(map.level(400.0), 4, "values above the scale stay at the top step");
697        let zeroes: Heatmap<()> = Heatmap::new([0.0, 0.0]);
698        assert_eq!(zeroes.level(0.0), 0, "a grid of zeroes never lights up");
699    }
700
701    #[test]
702    fn a_series_heatmap_takes_its_tone_from_the_theme() {
703        let mut demo = Demo::new([4.0]);
704        demo.series = Some(2);
705        let h = Harness::new(demo, 2, 7);
706        let theme = h.env().theme();
707        let empty = theme.color("raised").expect("token");
708        assert_eq!(h.bg(0, 0), Some(empty.mix(theme.series_color(2), MIX[3])));
709    }
710}