1use crate::cli::theme::{ERR, OK, SECONDARY};
10use ratatui::buffer::Buffer;
11use ratatui::layout::Rect;
12use ratatui::style::Style;
13use ratatui::widgets::Widget;
14use scope::metrics::{Threshold, Unit};
15
16const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
18
19const STEPS_PER_ROW: u32 = 8;
21
22const THRESHOLD_GLYPH: &str = "─";
24
25const PASS_GLYPH: &str = "✓";
27const FAIL_GLYPH: &str = "✗";
28
29pub struct Sparkline<'a> {
31 values: &'a [Option<f64>],
32 threshold: Option<Threshold>,
33 style: Style,
34 track: bool,
35 min_span: Option<f64>,
36}
37
38impl<'a> Sparkline<'a> {
39 pub fn new(values: &'a [Option<f64>], style: Style) -> Self {
41 Self {
42 values,
43 threshold: None,
44 style,
45 track: false,
46 min_span: None,
47 }
48 }
49
50 pub fn min_span(mut self, span: f64) -> Self {
54 self.min_span = Some(span).filter(|s| s.is_finite() && *s > 0.0);
55 self
56 }
57
58 pub fn track(mut self) -> Self {
63 self.track = true;
64 self
65 }
66
67 pub fn threshold(mut self, threshold: Option<Threshold>) -> Self {
69 self.threshold = threshold;
70 self
71 }
72}
73
74pub(crate) fn min_span(unit: Unit, values: &[Option<f64>]) -> f64 {
77 let latest = values
78 .iter()
79 .rev()
80 .flatten()
81 .next()
82 .copied()
83 .unwrap_or(0.0)
84 .abs();
85 match unit {
86 Unit::Price | Unit::Native => (latest * 0.001).max(1e-9),
87 Unit::Percent => 0.05,
88 Unit::Quote | Unit::Usd => (latest * 0.05).max(1.0),
89 Unit::Count => 1.0,
90 Unit::Score => 0.5,
91 Unit::Flag => 1.0,
92 }
93}
94
95pub(crate) fn enlarge_hint(cols: u16, rows: u16, what: &str) -> String {
98 let n = |k: u16, one: &str, many: &str| format!("{k} {}", if k == 1 { one } else { many });
99 let by = match (cols, rows) {
100 (0, r) => format!("{} taller", n(r, "row", "rows")),
101 (c, 0) => format!("{} wider", n(c, "column", "columns")),
102 (c, r) => format!(
103 "{} wider and {} taller",
104 n(c, "column", "columns"),
105 n(r, "row", "rows")
106 ),
107 };
108 format!("! Window too small: enlarge it {by} to see {what}")
109}
110
111fn threshold_value(t: Threshold) -> f64 {
113 match t {
114 Threshold::Max(v) | Threshold::Min(v) | Threshold::Target(v) => v,
115 }
116}
117
118fn newest<T>(items: &[T], width: usize) -> &[T] {
120 &items[items.len().saturating_sub(width)..]
121}
122
123impl Widget for Sparkline<'_> {
124 fn render(self, area: Rect, buf: &mut Buffer) {
125 if area.is_empty() {
126 return;
127 }
128 let shown = newest(self.values, area.width as usize);
129 let line = self
130 .threshold
131 .map(threshold_value)
132 .filter(|v| v.is_finite());
133 let known = shown.iter().flatten().copied().filter(|v| v.is_finite());
134 let (mut lo, mut hi) = known.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
135 (lo.min(v), hi.max(v))
136 });
137 if let Some(t) = line.filter(|_| !self.track) {
138 lo = lo.min(t);
140 hi = hi.max(t);
141 }
142 if !lo.is_finite() {
143 return; }
145 if let Some(span) = self.min_span
146 && hi - lo < span
147 {
148 let mid = (lo + hi) / 2.0;
149 lo = mid - span / 2.0;
150 hi = mid + span / 2.0;
151 }
152 let steps = u32::from(area.height) * STEPS_PER_ROW;
153 let range = hi - lo;
154 let height_of = |v: f64| -> u32 {
156 if range <= 0.0 {
157 return steps / 2;
158 }
159 let h = ((v - lo) / range * f64::from(steps - 1)).round() as u32 + 1;
160 h.min(steps)
161 };
162 let x0 = if self.track {
163 area.left()
164 } else {
165 area.right() - shown.len() as u16
166 };
167 if self.track {
168 let row = area.bottom() - 1;
170 for x in x0 + shown.len() as u16..area.right() {
171 buf[(x, row)]
172 .set_symbol(THRESHOLD_GLYPH)
173 .set_style(SECONDARY);
174 }
175 }
176 for (i, value) in shown.iter().enumerate() {
177 let x = x0 + i as u16;
178 let Some(v) = value.filter(|v| v.is_finite()) else {
179 continue; };
181 let style = match (self.track, self.threshold) {
182 (true, Some(Threshold::Max(t))) if v > t => ERR,
183 (true, Some(Threshold::Min(t))) if v < t => ERR,
184 _ => self.style,
185 };
186 let mut left = height_of(v);
187 for row in (area.top()..area.bottom()).rev() {
188 if left == 0 {
189 break;
190 }
191 let eighths = left.min(STEPS_PER_ROW);
192 buf[(x, row)]
193 .set_symbol(BARS[eighths as usize - 1])
194 .set_style(style);
195 left -= eighths;
196 }
197 }
198 if let Some(t) = line.filter(|_| !self.track) {
199 let rows_up = height_of(t).saturating_sub(1) / STEPS_PER_ROW;
200 let row = area.bottom() - 1 - rows_up as u16;
201 for x in area.left()..area.right() {
202 let cell = &mut buf[(x, row)];
203 if cell.symbol() == " " {
204 cell.set_symbol(THRESHOLD_GLYPH).set_style(SECONDARY);
205 }
206 }
207 }
208 }
209}
210
211pub struct HealthStrip<'a> {
213 runs: &'a [bool],
214 track: bool,
215}
216
217impl<'a> HealthStrip<'a> {
218 pub fn new(runs: &'a [bool]) -> Self {
220 Self { runs, track: false }
221 }
222
223 pub fn track(mut self) -> Self {
225 self.track = true;
226 self
227 }
228}
229
230impl Widget for HealthStrip<'_> {
231 fn render(self, area: Rect, buf: &mut Buffer) {
232 if area.is_empty() {
233 return;
234 }
235 let shown = newest(self.runs, area.width as usize);
236 let x0 = if self.track {
237 for x in area.left() + shown.len() as u16..area.right() {
238 buf[(x, area.top())]
239 .set_symbol(THRESHOLD_GLYPH)
240 .set_style(SECONDARY);
241 }
242 area.left()
243 } else {
244 area.right() - shown.len() as u16
245 };
246 for (i, healthy) in shown.iter().enumerate() {
247 let (glyph, style) = if *healthy {
248 (PASS_GLYPH, OK)
249 } else {
250 (FAIL_GLYPH, ERR)
251 };
252 buf[(x0 + i as u16, area.top())]
253 .set_symbol(glyph)
254 .set_style(style);
255 }
256 }
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn draw(widget: impl Widget, width: u16, height: u16) -> Vec<String> {
264 let area = Rect::new(0, 0, width, height);
265 let mut buf = Buffer::empty(area);
266 widget.render(area, &mut buf);
267 (0..height)
268 .map(|y| {
269 (0..width)
270 .map(|x| buf[(x, y)].symbol().to_string())
271 .collect()
272 })
273 .collect()
274 }
275
276 #[test]
277 fn test_track_fills_from_the_left_then_scrolls() {
278 let values = [Some(1.0), Some(2.0)];
279 let rows = draw(Sparkline::new(&values, Style::new()).track(), 6, 1);
280 assert_eq!(rows[0], "▁█────", "{rows:?}");
281 let values: Vec<Option<f64>> = (1..=8).map(|v| Some(f64::from(v))).collect();
282 let rows = draw(Sparkline::new(&values, Style::new()).track(), 4, 1);
283 assert!(!rows[0].contains('─'), "a full track scrolls: {rows:?}");
284 assert!(
285 rows[0].ends_with('█'),
286 "the newest at the right edge: {rows:?}"
287 );
288 }
289
290 #[test]
291 fn test_track_colors_a_bar_outside_its_limit_and_draws_no_threshold_line() {
292 let values = [Some(1.0), Some(5.0)];
293 let area = Rect::new(0, 0, 4, 2);
294 let mut buf = Buffer::empty(area);
295 Sparkline::new(&values, OK)
296 .threshold(Some(Threshold::Max(3.0)))
297 .track()
298 .render(area, &mut buf);
299 assert_eq!(buf[(0, 1)].style().fg, OK.fg, "within the limit");
300 assert_eq!(buf[(1, 1)].style().fg, ERR.fg, "above the max");
301 assert_eq!(buf[(2, 0)].symbol(), " ");
303 assert_eq!(buf[(2, 1)].symbol(), "─");
304 }
305
306 #[test]
307 fn test_noise_under_the_min_span_reads_flat() {
308 let values = [Some(1000.0), Some(1001.0), Some(1000.5)];
310 let rows = draw(Sparkline::new(&values, Style::new()).min_span(100.0), 3, 1);
311 let heights: Vec<usize> = rows[0]
312 .chars()
313 .map(|c| BARS.iter().position(|b| b.starts_with(c)).unwrap())
314 .collect();
315 assert!(
316 heights.iter().all(|h| (3..=4).contains(h)),
317 "near the middle: {rows:?}"
318 );
319 let values = [Some(1000.0), Some(1500.0)];
321 let rows = draw(Sparkline::new(&values, Style::new()).min_span(100.0), 2, 1);
322 assert_eq!(rows[0], "▁█", "{rows:?}");
323 }
324
325 #[test]
326 fn test_health_strip_track_fills_from_the_left() {
327 let rows = draw(HealthStrip::new(&[true, false]).track(), 5, 1);
328 assert_eq!(rows[0], "✓✗───");
329 }
330
331 #[test]
332 fn test_a_missing_value_is_a_gap_not_a_zero() {
333 let values = [Some(1.0), None, Some(3.0)];
334 let rows = draw(Sparkline::new(&values, Style::new()), 3, 1);
335 let cells: Vec<char> = rows[0].chars().collect();
336 assert_eq!(cells[1], ' ', "{rows:?}");
337 assert_ne!(cells[0], ' ');
338 assert_eq!(cells[2], '█', "the top value fills its cell");
339 }
340
341 #[test]
342 fn test_only_the_newest_values_that_fit_are_drawn() {
343 let values = [Some(100.0), Some(1.0), Some(2.0)];
346 let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
347 assert_eq!(rows[0], "▁█", "{rows:?}");
348 }
349
350 #[test]
351 fn test_the_threshold_line_fills_the_empty_cells_of_its_row() {
352 let values = [Some(0.0), Some(10.0), Some(0.0)];
354 let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Max(8.0)));
355 let rows = draw(chart, 3, 2);
356 assert_eq!(rows[0], "─█─", "{rows:?}");
357 assert!(!rows[1].contains('─'), "{rows:?}");
358 }
359
360 #[test]
361 fn test_the_scale_includes_a_threshold_beyond_the_values() {
362 let values = [Some(10.0), Some(12.0)];
365 let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Min(100.0)));
366 let rows = draw(chart, 2, 2);
367 assert_eq!(rows[0], "──", "{rows:?}");
368 assert!(rows[1].chars().all(|c| c != '█'), "{rows:?}");
369 }
370
371 #[test]
372 fn test_no_values_draw_nothing() {
373 let values = [None, None];
374 let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
375 assert_eq!(rows[0], " ");
376 }
377
378 #[test]
379 fn test_the_health_strip_marks_each_run_newest_last() {
380 let runs = [true, false, true, true];
381 let rows = draw(HealthStrip::new(&runs), 3, 1);
382 assert_eq!(rows[0], "✗✓✓");
383 }
384}