Skip to main content

presentar_terminal/widgets/
sparkline.rs

1//! Sparkline widget for compact inline graphs.
2//!
3//! Provides minimal inline visualization using vertical block characters.
4//! Ideal for embedding in tables or status lines.
5
6use presentar_core::{
7    Brick, BrickAssertion, BrickBudget, BrickVerification, Canvas, Color, Constraints, Event,
8    LayoutResult, Point, Rect, Size, TextStyle, TypeId, Widget,
9};
10use std::any::Any;
11use std::time::Duration;
12
13/// Block characters for sparkline rendering (8 levels).
14const SPARK_CHARS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
15
16/// Trend direction indicator.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum TrendDirection {
19    /// Upward trend
20    Up,
21    /// Downward trend
22    Down,
23    /// No significant change
24    #[default]
25    Flat,
26}
27
28impl TrendDirection {
29    /// Get arrow character for trend.
30    #[must_use]
31    pub const fn arrow(&self) -> char {
32        match self {
33            Self::Up => '↑',
34            Self::Down => '↓',
35            Self::Flat => '→',
36        }
37    }
38
39    /// Get color for trend.
40    #[must_use]
41    pub fn color(&self) -> Color {
42        match self {
43            Self::Up => Color::new(0.3, 1.0, 0.5, 1.0),   // Green
44            Self::Down => Color::new(1.0, 0.3, 0.3, 1.0), // Red
45            Self::Flat => Color::new(0.7, 0.7, 0.7, 1.0), // Gray
46        }
47    }
48}
49
50/// Compact sparkline widget for inline graphs.
51#[derive(Debug, Clone)]
52pub struct Sparkline {
53    /// Data points to display.
54    data: Vec<f64>,
55    /// Minimum value for scaling.
56    min: f64,
57    /// Maximum value for scaling.
58    max: f64,
59    /// Sparkline color.
60    color: Color,
61    /// Whether to show trend indicator.
62    show_trend: bool,
63    /// UX-121: Whether to show Y-axis min/max labels.
64    show_y_axis: bool,
65    /// UX-121: Y-axis label format (e.g., "{:.0}%").
66    y_format: Option<String>,
67    /// Cached bounds.
68    bounds: Rect,
69}
70
71impl Default for Sparkline {
72    fn default() -> Self {
73        Self::new(vec![])
74    }
75}
76
77impl Sparkline {
78    /// Create a new sparkline with data.
79    #[must_use]
80    pub fn new(data: Vec<f64>) -> Self {
81        let (min, max) = Self::compute_range(&data);
82        Self {
83            data,
84            min,
85            max,
86            color: Color::new(0.3, 0.7, 1.0, 1.0),
87            show_trend: false,
88            show_y_axis: false,
89            y_format: None,
90            bounds: Rect::default(),
91        }
92    }
93
94    /// Set the color.
95    #[must_use]
96    pub fn with_color(mut self, color: Color) -> Self {
97        self.color = color;
98        self
99    }
100
101    /// Set the data range.
102    #[must_use]
103    pub fn with_range(mut self, min: f64, max: f64) -> Self {
104        // Provability: range values must be finite
105        debug_assert!(min.is_finite(), "min must be finite");
106        debug_assert!(max.is_finite(), "max must be finite");
107        self.min = min;
108        self.max = max.max(min + 0.001);
109        self
110    }
111
112    /// Show trend indicator.
113    #[must_use]
114    pub fn with_trend(mut self, show: bool) -> Self {
115        self.show_trend = show;
116        self
117    }
118
119    /// UX-121: Show Y-axis min/max labels.
120    #[must_use]
121    pub fn with_y_axis(mut self, show: bool) -> Self {
122        self.show_y_axis = show;
123        self
124    }
125
126    /// UX-121: Set Y-axis label format (e.g., "{:.0}%", "{:.1}ms").
127    #[must_use]
128    pub fn with_y_format(mut self, format: impl Into<String>) -> Self {
129        self.y_format = Some(format.into());
130        self.show_y_axis = true;
131        self
132    }
133
134    /// Get the Y-axis label width needed for layout.
135    #[must_use]
136    #[allow(clippy::literal_string_with_formatting_args)]
137    pub fn y_axis_width(&self) -> u16 {
138        if !self.show_y_axis {
139            return 0;
140        }
141        // Estimate width based on format or default
142        let max_label = if let Some(ref fmt) = self.y_format {
143            fmt.replace("{:.0}", "999").replace("{:.1}", "99.9")
144        } else {
145            format!("{:.0}", self.max.abs().max(self.min.abs()))
146        };
147        (max_label.len() + 1) as u16
148    }
149
150    /// Update data.
151    pub fn set_data(&mut self, data: Vec<f64>) {
152        let (min, max) = Self::compute_range(&data);
153        self.data = data;
154        self.min = min;
155        self.max = max;
156    }
157
158    /// Get current trend direction.
159    #[must_use]
160    pub fn trend(&self) -> TrendDirection {
161        if self.data.len() < 2 {
162            return TrendDirection::Flat;
163        }
164
165        let recent = self.data.len().saturating_sub(3);
166        let recent_avg: f64 =
167            self.data[recent..].iter().sum::<f64>() / (self.data.len() - recent) as f64;
168
169        let older_end = recent.min(self.data.len());
170        let older_start = older_end.saturating_sub(3);
171        if older_start >= older_end {
172            return TrendDirection::Flat;
173        }
174        let older_avg: f64 = self.data[older_start..older_end].iter().sum::<f64>()
175            / (older_end - older_start) as f64;
176
177        let threshold = (self.max - self.min) * 0.05;
178        if recent_avg > older_avg + threshold {
179            TrendDirection::Up
180        } else if recent_avg < older_avg - threshold {
181            TrendDirection::Down
182        } else {
183            TrendDirection::Flat
184        }
185    }
186
187    fn compute_range(data: &[f64]) -> (f64, f64) {
188        if data.is_empty() {
189            return (0.0, 1.0);
190        }
191        let min = data.iter().fold(f64::MAX, |a, &b| a.min(b));
192        let max = data.iter().fold(f64::MIN, |a, &b| a.max(b));
193        if (max - min).abs() < f64::EPSILON {
194            (min - 0.5, max + 0.5)
195        } else {
196            (min, max)
197        }
198    }
199
200    fn normalize(&self, value: f64) -> f64 {
201        let range = self.max - self.min;
202        if range.abs() < f64::EPSILON {
203            0.5
204        } else {
205            ((value - self.min) / range).clamp(0.0, 1.0)
206        }
207    }
208}
209
210impl Brick for Sparkline {
211    fn brick_name(&self) -> &'static str {
212        "sparkline"
213    }
214
215    fn assertions(&self) -> &[BrickAssertion] {
216        static ASSERTIONS: &[BrickAssertion] = &[BrickAssertion::max_latency_ms(16)];
217        ASSERTIONS
218    }
219
220    fn budget(&self) -> BrickBudget {
221        BrickBudget::uniform(16)
222    }
223
224    fn verify(&self) -> BrickVerification {
225        BrickVerification {
226            passed: self.assertions().to_vec(),
227            failed: vec![],
228            verification_time: Duration::from_micros(5),
229        }
230    }
231
232    fn to_html(&self) -> String {
233        String::new()
234    }
235
236    fn to_css(&self) -> String {
237        String::new()
238    }
239}
240
241impl Widget for Sparkline {
242    fn type_id(&self) -> TypeId {
243        TypeId::of::<Self>()
244    }
245
246    fn measure(&self, constraints: Constraints) -> Size {
247        let width = (self.data.len() as f32 + if self.show_trend { 2.0 } else { 0.0 })
248            .min(constraints.max_width)
249            .max(1.0);
250        constraints.constrain(Size::new(width, 1.0))
251    }
252
253    fn layout(&mut self, bounds: Rect) -> LayoutResult {
254        self.bounds = bounds;
255        LayoutResult {
256            size: Size::new(bounds.width, bounds.height.max(1.0)),
257        }
258    }
259
260    fn paint(&self, canvas: &mut dyn Canvas) {
261        if self.data.is_empty() || self.bounds.width < 1.0 {
262            return;
263        }
264
265        let available_width = if self.show_trend {
266            (self.bounds.width as usize).saturating_sub(2)
267        } else {
268            self.bounds.width as usize
269        };
270
271        if available_width == 0 {
272            return;
273        }
274
275        // Build sparkline string
276        let mut spark = String::with_capacity(available_width);
277
278        for i in 0..available_width.min(self.data.len()) {
279            let idx = (i * self.data.len()) / available_width;
280            let value = self.data.get(idx).copied().unwrap_or(0.0);
281            let norm = self.normalize(value);
282            let char_idx = ((norm * 7.0).round() as usize).min(7);
283            spark.push(SPARK_CHARS[char_idx]);
284        }
285
286        let style = TextStyle {
287            color: self.color,
288            ..Default::default()
289        };
290        canvas.draw_text(&spark, Point::new(self.bounds.x, self.bounds.y), &style);
291
292        // Draw trend indicator
293        if self.show_trend {
294            let trend = self.trend();
295            let trend_style = TextStyle {
296                color: trend.color(),
297                ..Default::default()
298            };
299            canvas.draw_text(
300                &format!(" {}", trend.arrow()),
301                Point::new(self.bounds.x + available_width as f32, self.bounds.y),
302                &trend_style,
303            );
304        }
305    }
306
307    fn event(&mut self, _event: &Event) -> Option<Box<dyn Any + Send>> {
308        None
309    }
310
311    fn children(&self) -> &[Box<dyn Widget>] {
312        &[]
313    }
314
315    fn children_mut(&mut self) -> &mut [Box<dyn Widget>] {
316        &mut []
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    struct MockCanvas {
325        texts: Vec<(String, Point)>,
326    }
327
328    impl MockCanvas {
329        fn new() -> Self {
330            Self { texts: vec![] }
331        }
332    }
333
334    impl Canvas for MockCanvas {
335        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
336        fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
337        fn draw_text(&mut self, text: &str, position: Point, _style: &TextStyle) {
338            self.texts.push((text.to_string(), position));
339        }
340        fn draw_line(&mut self, _from: Point, _to: Point, _color: Color, _width: f32) {}
341        fn fill_circle(&mut self, _center: Point, _radius: f32, _color: Color) {}
342        fn stroke_circle(&mut self, _center: Point, _radius: f32, _color: Color, _width: f32) {}
343        fn fill_arc(&mut self, _c: Point, _r: f32, _s: f32, _e: f32, _color: Color) {}
344        fn draw_path(&mut self, _points: &[Point], _color: Color, _width: f32) {}
345        fn fill_polygon(&mut self, _points: &[Point], _color: Color) {}
346        fn push_clip(&mut self, _rect: Rect) {}
347        fn pop_clip(&mut self) {}
348        fn push_transform(&mut self, _transform: presentar_core::Transform2D) {}
349        fn pop_transform(&mut self) {}
350    }
351
352    #[test]
353    fn test_sparkline_creation() {
354        let spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
355        assert_eq!(spark.data.len(), 5);
356    }
357
358    #[test]
359    fn test_sparkline_assertions() {
360        let spark = Sparkline::new(vec![1.0]);
361        assert!(!spark.assertions().is_empty());
362    }
363
364    #[test]
365    fn test_sparkline_verify() {
366        let spark = Sparkline::new(vec![1.0, 2.0]);
367        assert!(spark.verify().is_valid());
368    }
369
370    #[test]
371    fn test_sparkline_with_color() {
372        let spark = Sparkline::new(vec![1.0]).with_color(Color::RED);
373        assert_eq!(spark.color, Color::RED);
374    }
375
376    #[test]
377    fn test_sparkline_with_range() {
378        let spark = Sparkline::new(vec![1.0]).with_range(0.0, 100.0);
379        assert_eq!(spark.min, 0.0);
380        assert_eq!(spark.max, 100.0);
381    }
382
383    #[test]
384    fn test_sparkline_with_trend() {
385        let spark = Sparkline::new(vec![1.0]).with_trend(true);
386        assert!(spark.show_trend);
387    }
388
389    #[test]
390    fn test_sparkline_trend_up() {
391        let spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
392        assert_eq!(spark.trend(), TrendDirection::Up);
393    }
394
395    #[test]
396    fn test_sparkline_trend_down() {
397        let spark = Sparkline::new(vec![8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0]);
398        assert_eq!(spark.trend(), TrendDirection::Down);
399    }
400
401    #[test]
402    fn test_sparkline_trend_flat() {
403        let spark = Sparkline::new(vec![5.0, 5.0, 5.0, 5.0, 5.0]);
404        assert_eq!(spark.trend(), TrendDirection::Flat);
405    }
406
407    #[test]
408    fn test_sparkline_paint() {
409        let mut spark = Sparkline::new(vec![0.0, 0.5, 1.0]);
410        spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
411        let mut canvas = MockCanvas::new();
412        spark.paint(&mut canvas);
413        assert!(!canvas.texts.is_empty());
414    }
415
416    #[test]
417    fn test_sparkline_paint_with_trend() {
418        let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0, 4.0, 5.0]).with_trend(true);
419        spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
420        let mut canvas = MockCanvas::new();
421        spark.paint(&mut canvas);
422        assert!(canvas.texts.len() >= 1);
423    }
424
425    #[test]
426    fn test_sparkline_empty() {
427        let mut spark = Sparkline::new(vec![]);
428        spark.bounds = Rect::new(0.0, 0.0, 10.0, 1.0);
429        let mut canvas = MockCanvas::new();
430        spark.paint(&mut canvas);
431        assert!(canvas.texts.is_empty());
432    }
433
434    #[test]
435    fn test_sparkline_measure() {
436        let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
437        let size = spark.measure(Constraints::loose(Size::new(100.0, 10.0)));
438        assert!(size.width >= 3.0);
439        assert_eq!(size.height, 1.0);
440    }
441
442    #[test]
443    fn test_sparkline_layout() {
444        let mut spark = Sparkline::new(vec![1.0, 2.0]);
445        let bounds = Rect::new(5.0, 10.0, 20.0, 1.0);
446        let result = spark.layout(bounds);
447        assert_eq!(result.size.width, 20.0);
448        assert_eq!(spark.bounds, bounds);
449    }
450
451    #[test]
452    fn test_trend_direction_arrow() {
453        assert_eq!(TrendDirection::Up.arrow(), '↑');
454        assert_eq!(TrendDirection::Down.arrow(), '↓');
455        assert_eq!(TrendDirection::Flat.arrow(), '→');
456    }
457
458    #[test]
459    fn test_trend_direction_color() {
460        let _ = TrendDirection::Up.color();
461        let _ = TrendDirection::Down.color();
462        let _ = TrendDirection::Flat.color();
463    }
464
465    #[test]
466    fn test_sparkline_set_data() {
467        let mut spark = Sparkline::new(vec![1.0]);
468        spark.set_data(vec![1.0, 2.0, 3.0, 4.0]);
469        assert_eq!(spark.data.len(), 4);
470    }
471
472    #[test]
473    fn test_sparkline_brick_name() {
474        let spark = Sparkline::new(vec![]);
475        assert_eq!(spark.brick_name(), "sparkline");
476    }
477
478    #[test]
479    fn test_sparkline_budget() {
480        let spark = Sparkline::new(vec![]);
481        let budget = spark.budget();
482        assert!(budget.paint_ms > 0);
483    }
484
485    #[test]
486    fn test_sparkline_type_id() {
487        let spark = Sparkline::new(vec![]);
488        assert_eq!(Widget::type_id(&spark), TypeId::of::<Sparkline>());
489    }
490
491    #[test]
492    fn test_sparkline_children() {
493        let spark = Sparkline::new(vec![]);
494        assert!(spark.children().is_empty());
495    }
496
497    #[test]
498    fn test_sparkline_children_mut() {
499        let mut spark = Sparkline::new(vec![]);
500        assert!(spark.children_mut().is_empty());
501    }
502
503    #[test]
504    fn test_sparkline_event() {
505        let mut spark = Sparkline::new(vec![]);
506        let event = Event::KeyDown {
507            key: presentar_core::Key::Enter,
508        };
509        assert!(spark.event(&event).is_none());
510    }
511
512    #[test]
513    fn test_sparkline_default() {
514        let spark = Sparkline::default();
515        assert!(spark.data.is_empty());
516    }
517
518    #[test]
519    fn test_sparkline_to_html() {
520        let spark = Sparkline::new(vec![]);
521        assert!(spark.to_html().is_empty());
522    }
523
524    #[test]
525    fn test_sparkline_to_css() {
526        let spark = Sparkline::new(vec![]);
527        assert!(spark.to_css().is_empty());
528    }
529
530    #[test]
531    fn test_sparkline_trend_single_value() {
532        // Test trend with single value (data.len() < 2)
533        let spark = Sparkline::new(vec![5.0]);
534        assert_eq!(spark.trend(), TrendDirection::Flat);
535    }
536
537    #[test]
538    fn test_sparkline_trend_two_values() {
539        // Test trend with exactly 2 values
540        let spark = Sparkline::new(vec![1.0, 2.0]);
541        // With only 2 values, older_start >= older_end triggers
542        assert_eq!(spark.trend(), TrendDirection::Flat);
543    }
544
545    #[test]
546    fn test_sparkline_trend_three_values() {
547        // Test trend with exactly 3 values (boundary case)
548        // With 3 values: recent = 3-3=0, so recent slice is [1,2,3]
549        // older_end = 0, older_start = 0, so older_start >= older_end -> Flat
550        let spark = Sparkline::new(vec![1.0, 2.0, 3.0]);
551        assert_eq!(spark.trend(), TrendDirection::Flat);
552    }
553
554    #[test]
555    fn test_sparkline_normalize_zero_range() {
556        // Test normalize with min == max (zero range)
557        let spark = Sparkline::new(vec![5.0, 5.0, 5.0]);
558        // When all values are the same, range is ~0
559        let normalized = spark.normalize(5.0);
560        assert!((normalized - 0.5).abs() < f64::EPSILON);
561    }
562
563    #[test]
564    fn test_sparkline_paint_zero_available_width() {
565        // Test paint with show_trend taking all width
566        let mut spark = Sparkline::new(vec![1.0, 2.0]).with_trend(true);
567        spark.bounds = Rect::new(0.0, 0.0, 2.0, 1.0); // Width 2, but trend needs 2
568        let mut canvas = MockCanvas::new();
569        spark.paint(&mut canvas);
570        // Should handle gracefully (available_width becomes 0)
571    }
572
573    #[test]
574    fn test_sparkline_paint_narrow_width() {
575        // Test paint with very narrow width
576        let mut spark = Sparkline::new(vec![1.0, 2.0, 3.0]).with_trend(true);
577        spark.bounds = Rect::new(0.0, 0.0, 1.0, 1.0);
578        let mut canvas = MockCanvas::new();
579        spark.paint(&mut canvas);
580        // Should not panic
581    }
582
583    #[test]
584    fn test_sparkline_with_y_axis() {
585        let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(true);
586        assert!(spark.show_y_axis);
587    }
588
589    #[test]
590    fn test_sparkline_with_y_axis_false() {
591        let spark = Sparkline::new(vec![1.0, 2.0]).with_y_axis(false);
592        assert!(!spark.show_y_axis);
593    }
594
595    #[test]
596    fn test_sparkline_with_y_format() {
597        let spark = Sparkline::new(vec![1.0, 2.0]).with_y_format("{:.0}%");
598        assert!(spark.show_y_axis);
599        assert_eq!(spark.y_format, Some("{:.0}%".to_string()));
600    }
601
602    #[test]
603    fn test_sparkline_y_axis_width_no_axis() {
604        let spark = Sparkline::new(vec![1.0, 2.0]);
605        assert_eq!(spark.y_axis_width(), 0);
606    }
607
608    #[test]
609    fn test_sparkline_y_axis_width_with_axis() {
610        let spark = Sparkline::new(vec![1.0, 100.0]).with_y_axis(true);
611        let width = spark.y_axis_width();
612        assert!(width > 0);
613    }
614
615    #[test]
616    fn test_sparkline_y_axis_width_with_format() {
617        let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.0}%");
618        let width = spark.y_axis_width();
619        assert!(width > 0);
620    }
621
622    #[test]
623    fn test_sparkline_y_axis_width_with_format_decimal() {
624        let spark = Sparkline::new(vec![1.0, 100.0]).with_y_format("{:.1}ms");
625        let width = spark.y_axis_width();
626        assert!(width > 0);
627    }
628}