1use super::eighths;
4use crate::color::Rgb;
5use crate::geometry::{Rect, Size, clamp_u16};
6use crate::icons::GlyphMode;
7use crate::widget::{MeasureCx, PaintCx, Widget};
8
9#[derive(Debug, Clone, PartialEq)]
20pub struct Sparkline {
21 values: Vec<f32>,
22 range: Option<(f32, f32)>,
23 extremes: bool,
24 baseline: Option<f32>,
25}
26
27impl Sparkline {
28 #[must_use]
30 pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
31 Self { values: values.into_iter().collect(), range: None, extremes: false, baseline: None }
32 }
33
34 #[must_use]
36 pub fn range(mut self, min: f32, max: f32) -> Self {
37 self.range = Some((min, max));
38 self
39 }
40
41 #[must_use]
43 pub fn highlight_extremes(mut self) -> Self {
44 self.extremes = true;
45 self
46 }
47
48 #[must_use]
50 pub fn baseline(mut self, value: f32) -> Self {
51 self.baseline = Some(value);
52 self
53 }
54
55 fn scale(&self, shown: &[f32]) -> (f32, f32) {
56 let (min, max) = self.range.unwrap_or_else(|| {
57 let min = shown.iter().copied().fold(f32::INFINITY, f32::min);
58 let max = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
59 (min, max)
60 });
61 (min, if max > min { max } else { min + 1.0 })
62 }
63}
64
65impl<Msg: 'static> Widget<Msg> for Sparkline {
66 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
67 let width = clamp_u16(i32::try_from(self.values.len()).unwrap_or(i32::MAX));
68 Size::new(width, u16::from(width > 0)).min(available)
69 }
70
71 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
72 if area.is_empty() || self.values.is_empty() {
73 return;
74 }
75 let count = self.values.len().min(usize::from(area.width));
76 let shown = &self.values[self.values.len() - count..];
77 let (min, max) = self.scale(shown);
78 let style = cx.style("sparkline", None, &[]);
79 let fill = style.color("fg").unwrap_or_else(|| cx.color("accent"));
80 let peak = style.color("peak").unwrap_or(fill);
81 let low = style.color("low").unwrap_or(fill);
82 let cells = area.height;
83 let total = u32::from(cells) * 8;
84 let level = |value: f32| 1 + eighths::scaled((value - min) / (max - min), total - 1);
86
87 let ascii = cx.env().glyph_mode() == GlyphMode::Ascii;
88 let track = style.color("track").unwrap_or_else(|| cx.color("raised"));
89 if ascii {
90 cx.clear(Rect::new(area.x, area.y, clamp_u16(i32::try_from(count).unwrap_or(0)), area.height), track);
91 }
92 let band = self.baseline.map(|value| {
94 let row = u16::try_from(level(value).saturating_sub(1) / 8).unwrap_or(0).min(cells - 1);
95 (row, style.color("baseline").unwrap_or_else(|| cx.color("raised")))
96 });
97 if let Some((row, color)) = band {
98 let width = clamp_u16(i32::try_from(count).unwrap_or(0));
99 cx.fill(Rect::new(area.x, area.bottom() - 1 - i32::from(row), width, 1), color);
100 }
101
102 let (peak_index, low_index) = if self.extremes { extremes(shown) } else { (None, None) };
103 for (index, value) in shown.iter().copied().enumerate() {
104 let color = if Some(index) == peak_index {
105 peak
106 } else if Some(index) == low_index {
107 low
108 } else {
109 fill
110 };
111 let column = Rect::new(area.x + i32::try_from(index).unwrap_or(0), area.y, 1, cells);
112 if ascii {
113 paint_ascii_column(cx, column, level(value), color, |row| match band {
114 Some((band_row, band_color)) if band_row == row => band_color,
115 _ => track,
116 });
117 } else {
118 eighths::vertical(cx, column, level(value), color);
119 }
120 }
121 }
122}
123
124fn paint_ascii_column(cx: &mut PaintCx<'_>, column: Rect, eighths: u32, color: Rgb, ground: impl Fn(u16) -> Rgb) {
130 let full = u16::try_from(eighths / 8).unwrap_or(u16::MAX).min(column.height);
131 cx.fill(Rect::new(column.x, column.bottom() - i32::from(full), 1, full), color);
132 let partial = eighths % 8;
133 if partial > 0 && full < column.height {
134 let tone = ground(full).mix(color, partial as f32 / 8.0);
136 cx.fill(Rect::new(column.x, column.bottom() - i32::from(full) - 1, 1, 1), tone);
137 }
138}
139
140fn extremes(shown: &[f32]) -> (Option<usize>, Option<usize>) {
145 let highest = shown.iter().copied().fold(f32::NEG_INFINITY, f32::max);
146 let lowest = shown.iter().copied().fold(f32::INFINITY, f32::min);
147 let peak = shown.iter().rposition(|value| *value >= highest);
148 let low = shown.iter().rposition(|value| *value <= lowest).filter(|_| highest > lowest);
149 (peak, low)
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::runtime::{App, Command, Harness};
156 use crate::widget::{Length, View};
157
158 struct Demo(Sparkline, u16);
159
160 impl App for Demo {
161 type Msg = ();
162 fn update(&mut self, _: ()) -> Command<()> {
163 Command::none()
164 }
165 fn view(&self, ui: &mut View<'_, ()>) {
166 ui.add(self.0.clone()).height(Length::Cells(self.1));
167 }
168 }
169
170 fn column(h: &Harness<Demo>) -> crate::color::Rgb {
172 let theme = h.env().theme();
173 theme.color("surface").expect("token").mix(theme.color("accent").expect("token"), 0.72)
174 }
175
176 const LOAD: [f32; 8] = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
177
178 #[test]
179 fn one_row_uses_eighth_blocks_and_keeps_newest() {
180 let h = Harness::new(Demo(Sparkline::new(LOAD), 1), 8, 1);
181 assert_eq!(h.screen(), "▁▂▃▄▅▆▇\n");
182 assert_eq!(h.bg(7, 0), Some(column(&h)));
183 let narrow = Harness::new(Demo(Sparkline::new(LOAD), 1), 4, 1);
184 assert_eq!(narrow.screen(), "▁▃▆\n");
185 }
186
187 #[test]
188 fn taller_columns_and_fixed_range() {
189 let h = Harness::new(Demo(Sparkline::new([50.0, 100.0, 0.0]).range(0.0, 100.0), 2), 3, 2);
190 assert_eq!(h.screen(), "▁\n ▁\n");
191 assert_eq!(h.bg(0, 1), Some(column(&h)));
192 assert_eq!(h.bg(1, 0), Some(column(&h)));
193 }
194
195 #[test]
196 fn extremes_and_baseline_are_coloured() {
197 let spark = Sparkline::new([3.0, 9.0, 1.0, 5.0]).highlight_extremes();
198 let h = Harness::new(Demo(spark.clone(), 1), 4, 1);
199 assert_eq!(h.screen(), "▃ ▁▅\n");
200 let theme = h.env().theme();
201 assert_eq!(h.bg(1, 0), theme.color("accent"), "the peak is the brightest column");
202 assert_eq!(h.fg(2, 0), theme.color("muted"), "the low column is muted");
203 assert_ne!(h.fg(0, 0), h.fg(2, 0));
204 let lined = Harness::new(Demo(spark.baseline(5.0), 1), 4, 1);
205 assert_ne!(lined.bg(0, 0), h.bg(0, 0), "the baseline row is tinted");
206 assert_eq!(lined.bg(1, 0), theme.color("accent"), "columns are drawn over the band");
207 }
208
209 #[test]
210 fn extremes_mark_the_latest_highest_and_lowest() {
211 assert_eq!(extremes(&[3.0, 9.0, 1.0, 5.0]), (Some(1), Some(2)));
212 assert_eq!(extremes(&[9.0, 1.0, 9.0, 1.0, 4.0]), (Some(2), Some(3)), "latest occurrence of each");
213 assert_eq!(extremes(&[2.0, 2.0, 2.0]), (Some(2), None), "a flat series has no low");
214 assert_eq!(extremes(&[]), (None, None));
215 }
216
217 #[test]
218 fn extremes_take_linear_time_on_the_widest_series() {
219 let falling: Vec<f32> = (0..u16::MAX).rev().map(f32::from).collect();
222 let started = std::time::Instant::now();
223 assert_eq!(extremes(&falling), (Some(0), Some(falling.len() - 1)));
224 assert!(started.elapsed() < std::time::Duration::from_millis(500), "took {:?}", started.elapsed());
225 }
226
227 #[test]
228 fn ascii_fills_cells_on_a_track() {
229 let mut h = Harness::new(Demo(Sparkline::new([0.0, 10.0]), 2), 2, 2);
230 h.set_glyph_mode(GlyphMode::Ascii);
231 assert_eq!(h.screen(), "\n\n");
232 let theme = h.env().theme();
233 assert_eq!(h.bg(0, 0), theme.color("raised"));
234 let lowest = h.bg(0, 1);
235 assert!(lowest.is_some() && lowest != theme.color("raised"), "the lowest value still tints one cell");
236 assert_eq!(h.bg(1, 0), Some(column(&h)));
237 }
238
239 #[test]
240 fn ascii_shows_every_sample_as_at_least_one_cell() {
241 let mut h = Harness::new(Demo(Sparkline::new([0.0, 1.0, 50.0, 100.0]), 3), 4, 3);
242 h.set_glyph_mode(GlyphMode::Ascii);
243 let raised = h.env().theme().color("raised");
244 for x in 0..4 {
245 assert_ne!(h.bg(x, 2), raised, "sample {x} is visible");
246 }
247 assert_eq!(h.bg(0, 1), raised, "low samples do not grow past one cell");
248 assert_eq!(h.bg(3, 0), Some(column(&h)), "the highest fills the column");
249 assert_eq!(h.bg(2, 2), Some(column(&h)), "cells a column passes are whole colour");
250 }
251
252 #[test]
253 fn one_row_ascii_keeps_the_trend_in_tone() {
254 let mut h = Harness::new(Demo(Sparkline::new(LOAD), 1), 8, 1);
255 h.set_glyph_mode(GlyphMode::Ascii);
256 let brightness = |x: u16| h.bg(x, 0).map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b));
257 let raised = h.env().theme().color("raised");
258 assert!((0..8).all(|x| h.bg(x, 0) != raised), "every sample tints its cell");
259 assert!((1..8).all(|x| brightness(x) != brightness(x - 1)), "rising values read as rising tones");
260 assert_eq!(h.bg(7, 0), Some(column(&h)), "the highest is whole colour");
261 }
262}