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/// The tones from the quietest step to the full one that `depth` can tell apart on a screen whose
254/// ground is `ground`.
255///
256/// A step the terminal would show in the tone of the step below it is left out, so no two levels
257/// share a tone; the levels are then spread over the tones that are left. A terminal that shows
258/// the whole ramp as one colour still gets the full tone, because a day that holds something must
259/// never look like a day that holds nothing.
260fn ramp(empty: Rgb, full: Rgb, depth: ColorDepth, ground: Rgb) -> Vec<Rgb> {
261    let mut tones = Vec::with_capacity(LEVELS as usize);
262    let mut previous = empty;
263    for mix in MIX {
264        let tone = empty.mix(full, mix);
265        if depth.tells_apart(tone, previous, ground) {
266            tones.push(tone);
267            previous = tone;
268        }
269    }
270    if tones.is_empty() {
271        tones.push(full);
272    }
273    tones
274}
275
276/// `tone` stepped away from itself so the cell under the cursor stands out: `amount` of the way
277/// towards `towards`, or, when that changes nothing because the tone is already there (a theme
278/// whose accent is its text colour, drawn on the busiest day), the same step back towards
279/// `empty`. Either way the cell moves by a step a reader can see, and the value it carries is
280/// still the tone it is a step away from.
281fn lift(tone: Rgb, towards: Rgb, empty: Rgb, amount: f32) -> Rgb {
282    let lifted = tone.mix(towards, amount);
283    if lifted.perceptual_distance(tone) < VISIBLE { tone.mix(empty, amount) } else { lifted }
284}
285
286/// The tone of `level` (1 to [`LEVELS`]) in `ramp`: the quietest level takes the first tone, the
287/// top level the last, and the levels between are spread over what is left.
288fn tone_of(ramp: &[Rgb], level: u32) -> Option<Rgb> {
289    let steps = u32::try_from(ramp.len()).unwrap_or(1).saturating_sub(1);
290    let last = LEVELS - 1;
291    // Rounded so the levels in the middle do not all fall to the quieter tone.
292    let index = usize::try_from((level.saturating_sub(1) * steps + last / 2) / last).unwrap_or(0);
293    ramp.get(index).copied()
294}
295
296impl<Msg: 'static> Widget<Msg> for Heatmap<Msg> {
297    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
298        if self.values.is_empty() {
299            return Size::default();
300        }
301        Size::new(self.total_columns(), self.rows).min(available)
302    }
303
304    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
305        if area.is_empty() || self.values.is_empty() {
306            return;
307        }
308        let (empty, full) = self.tones(cx);
309        let steps = ramp(empty, full, cx.env().depth(), cx.color("canvas"));
310        let (cursor, pointed) = self.lit(cx, area);
311        let style = cx.style("heatmap", None, &[]);
312        let pointer_lift = style.color("cursor").unwrap_or_else(|| cx.color("text"));
313        let keyboard_lift = cx.style("heatmap", None, &[State::Focus]).color("cursor").unwrap_or(pointer_lift);
314        let tone = |level: u32| match level {
315            0 => empty,
316            level => tone_of(&steps, level).unwrap_or(empty),
317        };
318        for (index, value) in self.values.iter().copied().enumerate() {
319            let Some(rect) = self.cell_rect(area, index) else {
320                continue;
321            };
322            let plain = tone(self.level(value));
323            let color = if pointed == Some(index) {
324                lift(plain, pointer_lift, empty, 0.35)
325            } else if pointed.is_none() && cursor == Some(index) {
326                lift(plain, keyboard_lift, empty, 0.5)
327            } else {
328                plain
329            };
330            cx.fill(rect, color);
331        }
332        if self.on_select.is_some() {
333            cx.register_hit(area);
334        }
335    }
336
337    fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
338        if self.on_select.is_none() || self.values.is_empty() {
339            return false;
340        }
341        let area = cx.area();
342        let rows = i32::from(self.rows);
343        if let Event::Key(key) = event {
344            if key.is_plain(Key::Left) {
345                return self.move_cursor(cx, -rows);
346            } else if key.is_plain(Key::Right) {
347                return self.move_cursor(cx, rows);
348            } else if key.is_plain(Key::Up) {
349                return self.move_cursor(cx, -1);
350            } else if key.is_plain(Key::Down) {
351                return self.move_cursor(cx, 1);
352            } else if key.is_plain(Key::Home) {
353                return self.move_cursor(cx, i32::MIN);
354            } else if key.is_plain(Key::End) {
355                return self.move_cursor(cx, i32::MAX);
356            }
357        }
358        match press::read(cx, event) {
359            Press::Ignored => false,
360            Press::Used => true,
361            Press::Key => {
362                let last = self.values.len() - 1;
363                let index = cx.memory::<HeatmapMemory>().cursor.or(self.selected).unwrap_or(last);
364                self.choose(cx, index.min(last));
365                true
366            }
367            Press::Click(x, y) => {
368                if let Some(index) = self.value_at(area, x, y) {
369                    self.choose(cx, index);
370                }
371                true
372            }
373        }
374    }
375
376    fn focusable(&self) -> bool {
377        self.on_select.is_some()
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::icons::GlyphMode;
385    use crate::runtime::{App, Command, Harness};
386    use crate::widget::{Length, View};
387
388    #[derive(Default)]
389    struct Demo {
390        values: Vec<f32>,
391        rows: u16,
392        height: Option<u16>,
393        starts_at: u16,
394        series: Option<usize>,
395        interactive: bool,
396        chosen: Option<usize>,
397    }
398
399    impl Demo {
400        fn new(values: impl IntoIterator<Item = f32>) -> Self {
401            Self { values: values.into_iter().collect(), rows: 7, ..Self::default() }
402        }
403    }
404
405    impl App for Demo {
406        type Msg = usize;
407        fn update(&mut self, index: usize) -> Command<usize> {
408            self.chosen = Some(index);
409            Command::none()
410        }
411        fn view(&self, ui: &mut View<'_, usize>) {
412            let mut map: Heatmap<usize> =
413                Heatmap::new(self.values.iter().copied()).rows(self.rows).starts_at(self.starts_at);
414            if let Some(index) = self.series {
415                map = map.series(index);
416            }
417            if self.interactive {
418                map = map.selected(self.chosen).on_select(|index| index);
419            }
420            let height = self.height.map_or(Length::Fill(1), Length::Cells);
421            ui.add(map).width(Length::Fill(1)).height(height).id("map");
422        }
423    }
424
425    /// The tones the built-in theme gives a heatmap: the empty one and the four steps.
426    fn tones(h: &Harness<Demo>) -> (Rgb, [Rgb; 4]) {
427        let theme = h.env().theme();
428        let empty = theme.color("raised").expect("token");
429        let full = theme.color("accent").expect("token");
430        (empty, [0, 1, 2, 3].map(|i| empty.mix(full, MIX[i])))
431    }
432
433    /// The colour of a cell the heatmap did not draw: the screen's own ground.
434    fn blank(h: &Harness<Demo>) -> Option<Rgb> {
435        h.env().theme().color("canvas")
436    }
437
438    #[test]
439    fn a_week_of_days_fills_a_column_and_the_newest_column_is_last() {
440        let h = Harness::new(Demo::new([0.0, 1.0, 2.0, 3.0, 4.0, 0.0, 0.0, 4.0]), 4, 7);
441        let (empty, steps) = tones(&h);
442        assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "a heatmap is made of colour, not characters");
443        assert_eq!(h.bg(0, 0), Some(empty), "a day with nothing takes the empty tone");
444        assert_eq!(h.bg(0, 1), Some(steps[0]), "the quietest day takes the first step");
445        assert_eq!(h.bg(0, 4), Some(steps[3]), "the busiest day takes the top step");
446        assert_eq!(h.bg(1, 0), Some(steps[3]), "the eighth value starts the next column");
447        assert_eq!(h.bg(2, 0), blank(&h), "nothing is drawn past the last column");
448    }
449
450    #[test]
451    fn days_outside_the_range_stay_blank_while_empty_days_take_a_tone() {
452        let mut demo = Demo::new([5.0, 5.0]);
453        demo.starts_at = 3;
454        let h = Harness::new(demo, 2, 7);
455        let (empty, steps) = tones(&h);
456        for row in 0..3 {
457            assert_eq!(h.bg(0, row), blank(&h), "row {row} is before the first value");
458        }
459        assert_eq!(h.bg(0, 3), Some(steps[3]));
460        assert_eq!(h.bg(0, 4), Some(steps[3]));
461        assert_eq!(h.bg(0, 5), blank(&h), "nothing follows the last value");
462        assert_ne!(blank(&h), Some(empty), "a day outside the range is not an empty day");
463    }
464
465    #[test]
466    fn nothing_and_all_zeroes_are_different_pictures() {
467        let nothing = Harness::new(Demo::new([]), 4, 7);
468        assert!((0..7).all(|row| nothing.bg(0, row) == blank(&nothing)), "no values draw nothing at all");
469        let zeroes = Harness::new(Demo::new([0.0; 7]), 4, 7);
470        let (empty, _) = tones(&zeroes);
471        assert!((0..7).all(|row| zeroes.bg(0, row) == Some(empty)), "a quiet week is a column of empty tone");
472    }
473
474    #[test]
475    fn a_single_value_takes_the_top_step_and_a_fixed_scale_holds_it_down() {
476        let h = Harness::new(Demo::new([3.0]), 2, 7);
477        let (_, steps) = tones(&h);
478        assert_eq!(h.bg(0, 0), Some(steps[3]), "the only value is the largest one");
479
480        struct Fixed;
481        impl App for Fixed {
482            type Msg = ();
483            fn update(&mut self, _: ()) -> Command<()> {
484                Command::none()
485            }
486            fn view(&self, ui: &mut View<'_, ()>) {
487                let map: Heatmap<()> = Heatmap::new([3.0]).max(12.0);
488                ui.add(map).width(Length::Fill(1)).height(Length::Fill(1));
489            }
490        }
491        let fixed = Harness::new(Fixed, 2, 7);
492        let theme = fixed.env().theme();
493        let empty = theme.color("raised").expect("token");
494        let step = empty.mix(theme.color("accent").expect("token"), MIX[0]);
495        assert_eq!(fixed.bg(0, 0), Some(step), "a quarter of the goal is the first step");
496    }
497
498    #[test]
499    fn a_narrow_area_keeps_the_newest_weeks_and_says_how_many() {
500        let values: Vec<f32> = (0u16..70).map(|i| f32::from(i % 5)).collect();
501        let map: Heatmap<()> = Heatmap::new(values.iter().copied());
502        assert_eq!(map.columns(80), 10, "ten weeks fit in a wide area");
503        assert_eq!(map.columns(4), 4, "a narrow area shows four weeks");
504        assert_eq!(map.columns(0), 0);
505
506        let h = Harness::new(Demo::new(values), 3, 7);
507        let (_, steps) = tones(&h);
508        // Three columns fit, so the grid starts at the eighth week: value 63, which is 3 of 4.
509        assert_eq!(h.bg(2, 0), Some(steps[2]), "the rightmost column is the newest week");
510        // The leftmost column drawn is the eighth week, whose first value is 49: 4 of 4.
511        assert_eq!(h.bg(0, 0), Some(steps[3]), "the oldest weeks are dropped, not squeezed");
512    }
513
514    #[test]
515    fn a_short_area_keeps_the_rows_that_fit() {
516        let mut demo = Demo::new([4.0; 14]);
517        demo.height = Some(3);
518        let h = Harness::new(demo, 2, 5);
519        let (_, steps) = tones(&h);
520        for column in 0..2 {
521            for row in 0..3 {
522                assert_eq!(h.bg(column, row), Some(steps[3]), "{column},{row}");
523            }
524        }
525        assert_eq!(h.bg(0, 3), blank(&h), "rows past the area are not drawn");
526    }
527
528    #[test]
529    fn tiny_areas_draw_what_they_can_without_panicking() {
530        for (width, height) in [(1, 1), (2, 1), (1, 3), (3, 2)] {
531            let h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0, 5.0]), width, height);
532            assert_eq!(h.screen().lines().count(), usize::from(height), "{width}×{height}");
533        }
534    }
535
536    #[test]
537    fn the_grid_is_the_same_in_every_glyph_mode() {
538        for mode in [GlyphMode::Nerd, GlyphMode::Unicode, GlyphMode::Ascii] {
539            let mut h = Harness::new(Demo::new([0.0, 2.0, 4.0]), 2, 7);
540            h.set_glyph_mode(mode);
541            let (empty, steps) = tones(&h);
542            assert_eq!(h.screen(), "\n\n\n\n\n\n\n", "{mode:?} draws no characters");
543            assert_eq!(h.bg(0, 0), Some(empty), "{mode:?}");
544            assert_eq!(h.bg(0, 2), Some(steps[3]), "{mode:?}");
545        }
546    }
547
548    #[test]
549    fn every_theme_tells_the_steps_and_the_empty_tone_apart() {
550        for theme in ["monochrome", "nordic", "amber", "iris"] {
551            let mut h = Harness::new(Demo::new([1.0, 2.0, 3.0, 4.0]), 2, 7);
552            h.set_theme(theme);
553            let drawn: Vec<Rgb> = (0..4).filter_map(|row| h.bg(0, row)).collect();
554            assert_eq!(drawn.len(), 4, "{theme}");
555            let empty = h.env().theme().color("raised").expect("token");
556            for pair in drawn.windows(2) {
557                assert!(
558                    pair[1].perceptual_distance(pair[0]) >= VISIBLE,
559                    "{theme}: {:?} and {:?} are one tone",
560                    pair[0],
561                    pair[1]
562                );
563            }
564            assert!(drawn[0].perceptual_distance(empty) >= VISIBLE, "{theme}: a quiet day shows over an empty one");
565        }
566    }
567
568    #[test]
569    fn the_ramp_keeps_only_the_tones_a_terminal_can_tell_apart() {
570        let h = Harness::new(Demo::new([1.0]), 2, 7);
571        let (empty, _) = tones(&h);
572        let full = h.env().theme().color("accent").expect("token");
573        let ground = h.env().theme().color("canvas").expect("token");
574
575        let true_color = ramp(empty, full, ColorDepth::TrueColor, ground);
576        assert_eq!(true_color.len(), 4, "true colour shows every step");
577        assert_eq!(tone_of(&true_color, 1), Some(true_color[0]));
578        assert_eq!(tone_of(&true_color, 4), Some(true_color[3]), "the top level takes the full tone");
579
580        for depth in [ColorDepth::Ansi256, ColorDepth::Ansi16] {
581            let steps = ramp(empty, full, depth, ground);
582            assert!(!steps.is_empty(), "{depth:?} still shows that something is there");
583            let shown: Vec<u32> = steps.iter().map(|tone| depth.shown(*tone, ground)).collect();
584            let mut distinct = shown.clone();
585            distinct.sort_unstable();
586            distinct.dedup();
587            assert_eq!(distinct.len(), shown.len(), "{depth:?} shows no two steps in one tone: {shown:?}");
588            let levels: Vec<Rgb> = (1..=LEVELS).filter_map(|level| tone_of(&steps, level)).collect();
589            assert_eq!(levels.len(), LEVELS as usize, "{depth:?} gives every level a tone");
590            assert_eq!(levels.last(), steps.last(), "{depth:?} keeps the busiest day the brightest");
591            for pair in levels.windows(2) {
592                assert!(
593                    pair[0].relative_luminance() <= pair[1].relative_luminance(),
594                    "{depth:?} never turns a busier day quieter: {pair:?}"
595                );
596            }
597        }
598
599        // A terminal that shows the whole ramp in one tone still draws the days.
600        let flat = ramp(empty, empty, ColorDepth::Ansi16, ground);
601        assert_eq!(flat, vec![empty]);
602        assert_eq!(tone_of(&flat, 4), Some(empty));
603    }
604
605    #[test]
606    fn a_heatmap_without_a_message_is_a_picture() {
607        let mut h = Harness::new(Demo::new([1.0, 2.0]), 4, 7);
608        h.press("tab");
609        assert!(!h.is_focused("map"), "a picture takes no focus");
610        h.click(0, 0);
611        assert_eq!(h.app().chosen, None, "a click on a picture chooses nothing");
612    }
613
614    #[test]
615    fn the_pointer_and_the_keyboard_both_reach_a_cell() {
616        let mut demo = Demo::new((0u16..21).map(|i| f32::from(i % 5 + 1)));
617        demo.interactive = true;
618        let mut h = Harness::new(demo, 4, 7);
619        let plain = h.bg(0, 3);
620
621        h.hover(0, 2);
622        let lit = h.bg(0, 2).expect("the hovered cell is drawn");
623        assert_ne!(Some(lit), h.bg(0, 3), "the cell under the pointer lights up");
624        assert_eq!(h.bg(0, 3), plain, "its neighbours keep their tone");
625
626        h.click(0, 2);
627        assert_eq!(h.app().chosen, Some(2), "a click reports the cell");
628
629        h.press("tab");
630        h.press("down");
631        h.press("enter");
632        assert_eq!(h.app().chosen, Some(3), "the keyboard moves one day and Enter reports it");
633        h.press("right");
634        h.press("enter");
635        assert_eq!(h.app().chosen, Some(10), "a column is a week");
636        h.press("left");
637        h.press("up");
638        h.press("enter");
639        assert_eq!(h.app().chosen, Some(2), "back a week and up a day");
640        h.press("home");
641        h.press("enter");
642        assert_eq!(h.app().chosen, Some(0));
643        h.press("end");
644        h.press("enter");
645        assert_eq!(h.app().chosen, Some(20), "End goes to the newest day");
646    }
647
648    #[test]
649    fn the_keyboard_cursor_lights_a_cell_without_choosing_it() {
650        let mut demo = Demo::new([1.0; 7]);
651        demo.interactive = true;
652        let mut h = Harness::new(demo, 2, 7);
653        h.press("tab");
654        h.press("home");
655        let lit = h.bg(0, 0).expect("drawn");
656        let quiet = h.bg(0, 1).expect("drawn");
657        assert_ne!(lit, quiet, "the cursor cell steps away from its tone");
658        assert_eq!(h.app().chosen, None, "moving the cursor chooses nothing");
659    }
660
661    #[test]
662    fn a_lit_cell_steps_away_from_its_tone_in_every_theme() {
663        for theme in ["monochrome", "nordic", "amber", "iris"] {
664            let mut demo = Demo::new([4.0; 7]);
665            demo.interactive = true;
666            let mut h = Harness::new(demo, 2, 7);
667            h.set_theme(theme);
668            h.hover(0, 0);
669            let lit = h.bg(0, 0).expect("drawn");
670            let plain = h.bg(0, 1).expect("drawn");
671            assert!(
672                lit.perceptual_distance(plain) >= VISIBLE,
673                "{theme}: the busiest day shows the pointer ({lit:?} against {plain:?})"
674            );
675        }
676    }
677
678    #[test]
679    fn levels_step_with_the_share_of_the_scale() {
680        let map: Heatmap<()> = Heatmap::new([0.0, 1.0, 25.0, 50.0, 75.0, 100.0]).max(100.0);
681        assert_eq!(map.level(0.0), 0);
682        assert_eq!(map.level(-4.0), 0, "a negative value is nothing");
683        assert_eq!(map.level(1.0), 1, "any day with something reaches the first step");
684        assert_eq!(map.level(25.0), 1);
685        assert_eq!(map.level(26.0), 2);
686        assert_eq!(map.level(75.0), 3);
687        assert_eq!(map.level(76.0), 4);
688        assert_eq!(map.level(100.0), 4);
689        assert_eq!(map.level(400.0), 4, "values above the scale stay at the top step");
690        let zeroes: Heatmap<()> = Heatmap::new([0.0, 0.0]);
691        assert_eq!(zeroes.level(0.0), 0, "a grid of zeroes never lights up");
692    }
693
694    #[test]
695    fn a_series_heatmap_takes_its_tone_from_the_theme() {
696        let mut demo = Demo::new([4.0]);
697        demo.series = Some(2);
698        let h = Harness::new(demo, 2, 7);
699        let theme = h.env().theme();
700        let empty = theme.color("raised").expect("token");
701        assert_eq!(h.bg(0, 0), Some(empty.mix(theme.series_color(2), MIX[3])));
702    }
703}