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