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::market::series::Threshold;
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}
35
36impl<'a> Sparkline<'a> {
37 pub fn new(values: &'a [Option<f64>], style: Style) -> Self {
39 Self {
40 values,
41 threshold: None,
42 style,
43 }
44 }
45
46 pub fn threshold(mut self, threshold: Option<Threshold>) -> Self {
48 self.threshold = threshold;
49 self
50 }
51}
52
53fn threshold_value(t: Threshold) -> f64 {
55 match t {
56 Threshold::Max(v) | Threshold::Min(v) | Threshold::Target(v) => v,
57 }
58}
59
60fn newest<T>(items: &[T], width: usize) -> &[T] {
62 &items[items.len().saturating_sub(width)..]
63}
64
65impl Widget for Sparkline<'_> {
66 fn render(self, area: Rect, buf: &mut Buffer) {
67 if area.is_empty() {
68 return;
69 }
70 let shown = newest(self.values, area.width as usize);
71 let line = self
72 .threshold
73 .map(threshold_value)
74 .filter(|v| v.is_finite());
75 let known = shown.iter().flatten().copied().filter(|v| v.is_finite());
76 let (mut lo, mut hi) = known.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| {
77 (lo.min(v), hi.max(v))
78 });
79 if let Some(t) = line {
80 lo = lo.min(t);
82 hi = hi.max(t);
83 }
84 if !lo.is_finite() {
85 return; }
87 let steps = u32::from(area.height) * STEPS_PER_ROW;
88 let range = hi - lo;
89 let height_of = |v: f64| -> u32 {
91 if range <= 0.0 {
92 return steps / 2;
93 }
94 let h = ((v - lo) / range * f64::from(steps - 1)).round() as u32 + 1;
95 h.min(steps)
96 };
97 let x0 = area.right() - shown.len() as u16;
98 for (i, value) in shown.iter().enumerate() {
99 let x = x0 + i as u16;
100 let Some(v) = value.filter(|v| v.is_finite()) else {
101 continue; };
103 let mut left = height_of(v);
104 for row in (area.top()..area.bottom()).rev() {
105 if left == 0 {
106 break;
107 }
108 let eighths = left.min(STEPS_PER_ROW);
109 buf[(x, row)]
110 .set_symbol(BARS[eighths as usize - 1])
111 .set_style(self.style);
112 left -= eighths;
113 }
114 }
115 if let Some(t) = line {
116 let rows_up = height_of(t).saturating_sub(1) / STEPS_PER_ROW;
117 let row = area.bottom() - 1 - rows_up as u16;
118 for x in area.left()..area.right() {
119 let cell = &mut buf[(x, row)];
120 if cell.symbol() == " " {
121 cell.set_symbol(THRESHOLD_GLYPH).set_style(SECONDARY);
122 }
123 }
124 }
125 }
126}
127
128pub struct HealthStrip<'a> {
130 runs: &'a [bool],
131}
132
133impl<'a> HealthStrip<'a> {
134 pub fn new(runs: &'a [bool]) -> Self {
136 Self { runs }
137 }
138}
139
140impl Widget for HealthStrip<'_> {
141 fn render(self, area: Rect, buf: &mut Buffer) {
142 if area.is_empty() {
143 return;
144 }
145 let shown = newest(self.runs, area.width as usize);
146 let x0 = area.right() - shown.len() as u16;
147 for (i, healthy) in shown.iter().enumerate() {
148 let (glyph, style) = if *healthy {
149 (PASS_GLYPH, OK)
150 } else {
151 (FAIL_GLYPH, ERR)
152 };
153 buf[(x0 + i as u16, area.top())]
154 .set_symbol(glyph)
155 .set_style(style);
156 }
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 fn draw(widget: impl Widget, width: u16, height: u16) -> Vec<String> {
165 let area = Rect::new(0, 0, width, height);
166 let mut buf = Buffer::empty(area);
167 widget.render(area, &mut buf);
168 (0..height)
169 .map(|y| {
170 (0..width)
171 .map(|x| buf[(x, y)].symbol().to_string())
172 .collect()
173 })
174 .collect()
175 }
176
177 #[test]
178 fn test_a_missing_value_is_a_gap_not_a_zero() {
179 let values = [Some(1.0), None, Some(3.0)];
180 let rows = draw(Sparkline::new(&values, Style::new()), 3, 1);
181 let cells: Vec<char> = rows[0].chars().collect();
182 assert_eq!(cells[1], ' ', "{rows:?}");
183 assert_ne!(cells[0], ' ');
184 assert_eq!(cells[2], '█', "the top value fills its cell");
185 }
186
187 #[test]
188 fn test_only_the_newest_values_that_fit_are_drawn() {
189 let values = [Some(100.0), Some(1.0), Some(2.0)];
192 let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
193 assert_eq!(rows[0], "▁█", "{rows:?}");
194 }
195
196 #[test]
197 fn test_the_threshold_line_fills_the_empty_cells_of_its_row() {
198 let values = [Some(0.0), Some(10.0), Some(0.0)];
200 let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Max(8.0)));
201 let rows = draw(chart, 3, 2);
202 assert_eq!(rows[0], "─█─", "{rows:?}");
203 assert!(!rows[1].contains('─'), "{rows:?}");
204 }
205
206 #[test]
207 fn test_the_scale_includes_a_threshold_beyond_the_values() {
208 let values = [Some(10.0), Some(12.0)];
211 let chart = Sparkline::new(&values, Style::new()).threshold(Some(Threshold::Min(100.0)));
212 let rows = draw(chart, 2, 2);
213 assert_eq!(rows[0], "──", "{rows:?}");
214 assert!(rows[1].chars().all(|c| c != '█'), "{rows:?}");
215 }
216
217 #[test]
218 fn test_no_values_draw_nothing() {
219 let values = [None, None];
220 let rows = draw(Sparkline::new(&values, Style::new()), 2, 1);
221 assert_eq!(rows[0], " ");
222 }
223
224 #[test]
225 fn test_the_health_strip_marks_each_run_newest_last() {
226 let runs = [true, false, true, true];
227 let rows = draw(HealthStrip::new(&runs), 3, 1);
228 assert_eq!(rows[0], "✗✓✓");
229 }
230}