Skip to main content

ftui_widgets/
sparkline.rs

1#![forbid(unsafe_code)]
2
3//! Sparkline widget for compact trend visualization.
4//!
5//! Sparklines render data as a series of 8-level Unicode block characters
6//! (▁▂▃▄▅▆▇█) for visualizing trends in minimal space.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ftui_widgets::sparkline::Sparkline;
12//!
13//! let data = vec![1.0, 4.0, 2.0, 8.0, 3.0, 6.0, 5.0];
14//! let sparkline = Sparkline::new(&data)
15//!     .style(Style::new().fg(PackedRgba::CYAN));
16//! sparkline.render(area, frame);
17//! ```
18
19use crate::{MeasurableWidget, SizeConstraints, Widget, clear_text_row};
20use ftui_core::geometry::{Rect, Size};
21use ftui_render::cell::{Cell, PackedRgba};
22use ftui_render::frame::Frame;
23use ftui_style::Style;
24
25/// Block characters for sparkline rendering (9 levels: empty + 8 bars).
26const SPARK_CHARS: [char; 9] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
27
28/// A compact sparkline widget for trend visualization.
29///
30/// Sparklines display a series of values as a row of Unicode block characters,
31/// with height proportional to value. Useful for showing trends in dashboards,
32/// status bars, and data-dense UIs.
33///
34/// # Features
35///
36/// - Auto-scaling: Automatically determines min/max from data if not specified
37/// - Manual bounds: Set explicit min/max for consistent scaling across multiple sparklines
38/// - Color gradient: Optional start/end colors for value-based coloring
39/// - Baseline: Optional baseline value (default 0.0) for distinguishing positive/negative
40///
41/// # Block Characters
42///
43/// Uses 9 levels of height: empty space plus 8 bar heights (▁▂▃▄▅▆▇█)
44#[derive(Debug, Clone)]
45pub struct Sparkline<'a> {
46    /// Data values to display.
47    data: &'a [f64],
48    /// Optional minimum value (auto-detected if None).
49    min: Option<f64>,
50    /// Optional maximum value (auto-detected if None).
51    max: Option<f64>,
52    /// Base style for all characters.
53    style: Style,
54    /// Optional gradient: (low_color, high_color).
55    gradient: Option<(PackedRgba, PackedRgba)>,
56    /// Baseline value (default 0.0) - values at baseline show as empty.
57    baseline: f64,
58}
59
60impl<'a> Sparkline<'a> {
61    /// Create a new sparkline from data slice.
62    #[must_use]
63    pub fn new(data: &'a [f64]) -> Self {
64        Self {
65            data,
66            min: None,
67            max: None,
68            style: Style::default(),
69            gradient: None,
70            baseline: 0.0,
71        }
72    }
73
74    /// Set explicit minimum value for scaling.
75    ///
76    /// If not set, minimum is auto-detected from data.
77    #[must_use]
78    pub fn min(mut self, min: f64) -> Self {
79        self.min = Some(min);
80        self
81    }
82
83    /// Set explicit maximum value for scaling.
84    ///
85    /// If not set, maximum is auto-detected from data.
86    #[must_use]
87    pub fn max(mut self, max: f64) -> Self {
88        self.max = Some(max);
89        self
90    }
91
92    /// Set min and max bounds together.
93    #[must_use]
94    pub fn bounds(mut self, min: f64, max: f64) -> Self {
95        self.min = Some(min);
96        self.max = Some(max);
97        self
98    }
99
100    /// Set the base style (foreground color, etc.).
101    #[must_use]
102    pub fn style(mut self, style: Style) -> Self {
103        self.style = style;
104        self
105    }
106
107    /// Set a color gradient from low to high values.
108    ///
109    /// Low values get `low_color`, high values get `high_color`,
110    /// with linear interpolation between.
111    #[must_use]
112    pub fn gradient(mut self, low_color: PackedRgba, high_color: PackedRgba) -> Self {
113        self.gradient = Some((low_color, high_color));
114        self
115    }
116
117    /// Set the baseline value.
118    ///
119    /// Values at or below baseline show as empty space.
120    /// Default is 0.0.
121    #[must_use]
122    pub fn baseline(mut self, baseline: f64) -> Self {
123        self.baseline = baseline;
124        self
125    }
126
127    /// Compute the min/max bounds from data or explicit settings.
128    fn compute_bounds(&self) -> (f64, f64) {
129        let data_min = self
130            .min
131            .unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min));
132        let data_max = self
133            .max
134            .unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
135
136        // Ensure min <= max; handle edge cases
137        let min = if data_min.is_finite() { data_min } else { 0.0 };
138        let max = if data_max.is_finite() { data_max } else { 1.0 };
139
140        if min >= max {
141            // All values are the same; create a range around the value
142            (min - 0.5, max + 0.5)
143        } else {
144            (min, max)
145        }
146    }
147
148    /// Map a value to a bar index (0-8).
149    fn value_to_bar_index(&self, value: f64, min: f64, max: f64) -> usize {
150        if !value.is_finite() {
151            return 0;
152        }
153
154        if value <= self.baseline {
155            return 0;
156        }
157
158        let range = max - min;
159        if range <= 0.0 {
160            return 4; // Middle bar for flat data
161        }
162
163        let normalized = (value - min) / range;
164        let clamped = normalized.clamp(0.0, 1.0);
165        // Map 0.0 -> 0, 1.0 -> 8
166        (clamped * 8.0).round() as usize
167    }
168
169    /// Interpolate between two colors based on t (0.0 to 1.0).
170    fn lerp_color(low: PackedRgba, high: PackedRgba, t: f64) -> PackedRgba {
171        let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) } as f32;
172        let r = (low.r() as f32 * (1.0 - t) + high.r() as f32 * t).round() as u8;
173        let g = (low.g() as f32 * (1.0 - t) + high.g() as f32 * t).round() as u8;
174        let b = (low.b() as f32 * (1.0 - t) + high.b() as f32 * t).round() as u8;
175        let a = (low.a() as f32 * (1.0 - t) + high.a() as f32 * t).round() as u8;
176        PackedRgba::rgba(r, g, b, a)
177    }
178
179    /// Render the sparkline as a string (for testing/debugging).
180    pub fn render_to_string(&self) -> String {
181        if self.data.is_empty() {
182            return String::new();
183        }
184
185        let (min, max) = self.compute_bounds();
186        self.data
187            .iter()
188            .map(|&v| {
189                let idx = self.value_to_bar_index(v, min, max);
190                SPARK_CHARS[idx]
191            })
192            .collect()
193    }
194}
195
196impl Default for Sparkline<'_> {
197    fn default() -> Self {
198        Self::new(&[])
199    }
200}
201
202impl Widget for Sparkline<'_> {
203    fn render(&self, area: Rect, frame: &mut Frame) {
204        #[cfg(feature = "tracing")]
205        let _span = tracing::debug_span!(
206            "widget_render",
207            widget = "Sparkline",
208            x = area.x,
209            y = area.y,
210            w = area.width,
211            h = area.height,
212            data_len = self.data.len()
213        )
214        .entered();
215
216        if area.is_empty() {
217            return;
218        }
219
220        let deg = frame.buffer.degradation;
221
222        // Skeleton+: skip entirely
223        if !deg.render_content() {
224            return;
225        }
226
227        let base_style = if deg.apply_styling() {
228            self.style
229        } else {
230            Style::default()
231        };
232        clear_text_row(frame, area, base_style);
233
234        if self.data.is_empty() {
235            return;
236        }
237
238        let (min, max) = self.compute_bounds();
239        let range = max - min;
240
241        // How many data points can we show?
242        let display_count = (area.width as usize).min(self.data.len());
243
244        for (i, &value) in self.data.iter().take(display_count).enumerate() {
245            let x = area.x + i as u16;
246            let y = area.y;
247
248            if x >= area.right() {
249                break;
250            }
251
252            let bar_idx = self.value_to_bar_index(value, min, max);
253            let ch = SPARK_CHARS[bar_idx];
254
255            let mut cell = Cell::from_char(ch);
256
257            // Apply style
258            if deg.apply_styling() {
259                // Apply base style (fg, bg, attrs)
260                crate::apply_style(&mut cell, self.style);
261
262                // Override fg with gradient if configured
263                if let Some((low_color, high_color)) = self.gradient {
264                    let t = if range > 0.0 {
265                        (value - min) / range
266                    } else {
267                        0.5
268                    };
269                    cell.fg = Self::lerp_color(low_color, high_color, t);
270                } else if self.style.fg.is_none() {
271                    // Default to white if no style fg and no gradient
272                    cell.fg = PackedRgba::WHITE;
273                }
274            }
275
276            frame.buffer.set_fast(x, y, cell);
277        }
278    }
279}
280
281impl MeasurableWidget for Sparkline<'_> {
282    fn measure(&self, _available: Size) -> SizeConstraints {
283        if self.data.is_empty() {
284            return SizeConstraints::ZERO;
285        }
286
287        // Sparklines are always 1 row tall
288        // Width is the number of data points
289        let width = self.data.len() as u16;
290
291        SizeConstraints {
292            min: Size::new(1, 1), // At least 1 data point visible
293            preferred: Size::new(width, 1),
294            max: Some(Size::new(width, 1)), // Fixed content size
295        }
296    }
297
298    fn has_intrinsic_size(&self) -> bool {
299        !self.data.is_empty()
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use ftui_render::grapheme_pool::GraphemePool;
307
308    // --- Builder tests ---
309
310    #[test]
311    fn empty_data() {
312        let sparkline = Sparkline::new(&[]);
313        assert_eq!(sparkline.render_to_string(), "");
314    }
315
316    #[test]
317    fn single_value() {
318        let sparkline = Sparkline::new(&[5.0]);
319        // Single value maps to middle bar
320        let s = sparkline.render_to_string();
321        assert_eq!(s.chars().count(), 1);
322    }
323
324    #[test]
325    fn constant_values() {
326        let data = vec![5.0, 5.0, 5.0, 5.0];
327        let sparkline = Sparkline::new(&data);
328        let s = sparkline.render_to_string();
329        // All same height (middle bar)
330        assert_eq!(s.chars().count(), 4);
331        assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
332    }
333
334    #[test]
335    fn ascending_values() {
336        let data: Vec<f64> = (0..9).map(|i| i as f64).collect();
337        let sparkline = Sparkline::new(&data);
338        let s = sparkline.render_to_string();
339        let chars: Vec<char> = s.chars().collect();
340        // First should be lowest, last should be highest
341        assert_eq!(chars[0], ' ');
342        assert_eq!(chars[8], '█');
343    }
344
345    #[test]
346    fn descending_values() {
347        let data: Vec<f64> = (0..9).rev().map(|i| i as f64).collect();
348        let sparkline = Sparkline::new(&data);
349        let s = sparkline.render_to_string();
350        let chars: Vec<char> = s.chars().collect();
351        // First should be highest, last should be lowest
352        assert_eq!(chars[0], '█');
353        assert_eq!(chars[8], ' ');
354    }
355
356    #[test]
357    fn explicit_bounds() {
358        let data = vec![5.0, 5.0, 5.0];
359        let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
360        let s = sparkline.render_to_string();
361        // 5.0 is at 50%, should be middle bar (▄)
362        let chars: Vec<char> = s.chars().collect();
363        assert_eq!(chars[0], '▄');
364    }
365
366    #[test]
367    fn min_max_explicit() {
368        let data = vec![0.0, 50.0, 100.0];
369        let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
370        let s = sparkline.render_to_string();
371        let chars: Vec<char> = s.chars().collect();
372        assert_eq!(chars[0], ' '); // 0%
373        assert_eq!(chars[1], '▄'); // 50%
374        assert_eq!(chars[2], '█'); // 100%
375    }
376
377    #[test]
378    fn negative_values() {
379        let data = vec![-10.0, 0.0, 10.0];
380        let sparkline = Sparkline::new(&data);
381        let s = sparkline.render_to_string();
382        let chars: Vec<char> = s.chars().collect();
383        assert_eq!(chars[0], ' '); // Lowest
384        assert_eq!(chars[2], '█'); // Highest
385    }
386
387    #[test]
388    fn nan_values_handled() {
389        let data = vec![1.0, f64::NAN, 3.0];
390        let sparkline = Sparkline::new(&data);
391        let s = sparkline.render_to_string();
392        // NaN should render as empty (index 0)
393        let chars: Vec<char> = s.chars().collect();
394        assert_eq!(chars[1], ' ');
395    }
396
397    #[test]
398    fn infinity_values_handled() {
399        let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
400        let sparkline = Sparkline::new(&data);
401        let s = sparkline.render_to_string();
402        // Infinities should be clamped
403        assert_eq!(s.chars().count(), 3);
404    }
405
406    // --- Rendering tests ---
407
408    #[test]
409    fn render_empty_area() {
410        let data = vec![1.0, 2.0, 3.0];
411        let sparkline = Sparkline::new(&data);
412        let area = Rect::new(0, 0, 0, 0);
413        let mut pool = GraphemePool::new();
414        let mut frame = Frame::new(1, 1, &mut pool);
415        Widget::render(&sparkline, area, &mut frame);
416        // Should not panic
417    }
418
419    #[test]
420    fn render_basic() {
421        let data = vec![0.0, 0.5, 1.0];
422        let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
423        let area = Rect::new(0, 0, 3, 1);
424        let mut pool = GraphemePool::new();
425        let mut frame = Frame::new(3, 1, &mut pool);
426        Widget::render(&sparkline, area, &mut frame);
427
428        let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
429        let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
430        let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
431
432        assert_eq!(c0, Some(' ')); // 0%
433        assert_eq!(c1, Some('▄')); // 50%
434        assert_eq!(c2, Some('█')); // 100%
435    }
436
437    #[test]
438    fn render_truncates_to_width() {
439        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
440        let sparkline = Sparkline::new(&data);
441        let area = Rect::new(0, 0, 10, 1);
442        let mut pool = GraphemePool::new();
443        let mut frame = Frame::new(10, 1, &mut pool);
444        Widget::render(&sparkline, area, &mut frame);
445
446        // Should only render first 10 values
447        for x in 0..10 {
448            let cell = frame.buffer.get(x, 0).unwrap();
449            assert!(cell.content.as_char().is_some());
450        }
451    }
452
453    #[test]
454    fn render_with_style() {
455        let data = vec![1.0];
456        let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
457        let area = Rect::new(0, 0, 1, 1);
458        let mut pool = GraphemePool::new();
459        let mut frame = Frame::new(1, 1, &mut pool);
460        Widget::render(&sparkline, area, &mut frame);
461
462        let cell = frame.buffer.get(0, 0).unwrap();
463        assert_eq!(cell.fg, PackedRgba::GREEN);
464    }
465
466    #[test]
467    fn render_with_gradient() {
468        let data = vec![0.0, 0.5, 1.0];
469        let sparkline = Sparkline::new(&data)
470            .bounds(0.0, 1.0)
471            .gradient(PackedRgba::BLUE, PackedRgba::RED);
472        let area = Rect::new(0, 0, 3, 1);
473        let mut pool = GraphemePool::new();
474        let mut frame = Frame::new(3, 1, &mut pool);
475        Widget::render(&sparkline, area, &mut frame);
476
477        let c0 = frame.buffer.get(0, 0).unwrap();
478        let c2 = frame.buffer.get(2, 0).unwrap();
479
480        // Low value should be blue-ish
481        assert_eq!(c0.fg, PackedRgba::BLUE);
482        // High value should be red-ish
483        assert_eq!(c2.fg, PackedRgba::RED);
484    }
485
486    // --- Degradation tests ---
487
488    #[test]
489    fn degradation_skeleton_skips() {
490        use ftui_render::budget::DegradationLevel;
491
492        let data = vec![1.0, 2.0, 3.0];
493        let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
494        let area = Rect::new(0, 0, 3, 1);
495        let mut pool = GraphemePool::new();
496        let mut frame = Frame::new(3, 1, &mut pool);
497        frame.buffer.degradation = DegradationLevel::Skeleton;
498        Widget::render(&sparkline, area, &mut frame);
499
500        // All cells should be empty
501        for x in 0..3 {
502            assert!(
503                frame.buffer.get(x, 0).unwrap().is_empty(),
504                "cell at x={x} should be empty at Skeleton"
505            );
506        }
507    }
508
509    #[test]
510    fn degradation_no_styling_renders_without_color() {
511        use ftui_render::budget::DegradationLevel;
512
513        let data = vec![0.5];
514        let sparkline = Sparkline::new(&data)
515            .bounds(0.0, 1.0)
516            .style(Style::new().fg(PackedRgba::GREEN));
517        let area = Rect::new(0, 0, 1, 1);
518        let mut pool = GraphemePool::new();
519        let mut frame = Frame::new(1, 1, &mut pool);
520        frame.buffer.degradation = DegradationLevel::NoStyling;
521        Widget::render(&sparkline, area, &mut frame);
522
523        // Character should be rendered but without custom color
524        let cell = frame.buffer.get(0, 0).unwrap();
525        assert!(cell.content.as_char().is_some());
526        // fg should NOT be green since styling is disabled
527        assert_ne!(cell.fg, PackedRgba::GREEN);
528    }
529
530    #[test]
531    fn render_shorter_data_clears_stale_suffix() {
532        let long = Sparkline::new(&[0.0, 0.5, 1.0, 0.75]).bounds(0.0, 1.0);
533        let short = Sparkline::new(&[1.0]);
534        let area = Rect::new(0, 0, 4, 1);
535        let mut pool = GraphemePool::new();
536        let mut frame = Frame::new(4, 1, &mut pool);
537
538        Widget::render(&long, area, &mut frame);
539        Widget::render(&short, area, &mut frame);
540
541        let row: String = (0..4)
542            .map(|x| {
543                frame
544                    .buffer
545                    .get(x, 0)
546                    .and_then(|cell| cell.content.as_char())
547                    .unwrap_or(' ')
548            })
549            .collect();
550        assert_eq!(row, "▄   ");
551    }
552
553    #[test]
554    fn render_empty_data_clears_stale_sparkline() {
555        let long = Sparkline::new(&[0.0, 0.5, 1.0]).bounds(0.0, 1.0);
556        let empty = Sparkline::new(&[]);
557        let area = Rect::new(0, 0, 3, 1);
558        let mut pool = GraphemePool::new();
559        let mut frame = Frame::new(3, 1, &mut pool);
560
561        Widget::render(&long, area, &mut frame);
562        Widget::render(&empty, area, &mut frame);
563
564        for x in 0..3 {
565            assert_eq!(
566                frame
567                    .buffer
568                    .get(x, 0)
569                    .and_then(|cell| cell.content.as_char()),
570                Some(' ')
571            );
572        }
573    }
574
575    // --- Color interpolation tests ---
576
577    #[test]
578    fn lerp_color_endpoints() {
579        let low = PackedRgba::rgb(0, 0, 0);
580        let high = PackedRgba::rgb(255, 255, 255);
581
582        assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
583        assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
584    }
585
586    #[test]
587    fn lerp_color_midpoint() {
588        let low = PackedRgba::rgb(0, 0, 0);
589        let high = PackedRgba::rgb(255, 255, 255);
590        let mid = Sparkline::lerp_color(low, high, 0.5);
591
592        assert_eq!(mid.r(), 128);
593        assert_eq!(mid.g(), 128);
594        assert_eq!(mid.b(), 128);
595    }
596
597    #[test]
598    fn lerp_color_interpolates_alpha() {
599        let low = PackedRgba::rgba(0, 0, 0, 0);
600        let high = PackedRgba::rgba(255, 255, 255, 255);
601        let mid = Sparkline::lerp_color(low, high, 0.5);
602
603        assert_eq!(mid.r(), 128);
604        assert_eq!(mid.g(), 128);
605        assert_eq!(mid.b(), 128);
606        assert_eq!(mid.a(), 128);
607    }
608
609    // --- MeasurableWidget tests ---
610
611    #[test]
612    fn measure_empty_sparkline() {
613        let sparkline = Sparkline::new(&[]);
614        let c = sparkline.measure(Size::MAX);
615        assert_eq!(c, SizeConstraints::ZERO);
616        assert!(!sparkline.has_intrinsic_size());
617    }
618
619    #[test]
620    fn measure_single_value() {
621        let data = [5.0];
622        let sparkline = Sparkline::new(&data);
623        let c = sparkline.measure(Size::MAX);
624
625        assert_eq!(c.preferred.width, 1);
626        assert_eq!(c.preferred.height, 1);
627        assert!(sparkline.has_intrinsic_size());
628    }
629
630    #[test]
631    fn measure_multiple_values() {
632        let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
633        let sparkline = Sparkline::new(&data);
634        let c = sparkline.measure(Size::MAX);
635
636        assert_eq!(c.preferred.width, 50);
637        assert_eq!(c.preferred.height, 1);
638        assert_eq!(c.min.width, 1);
639        assert_eq!(c.min.height, 1);
640    }
641
642    #[test]
643    fn measure_max_equals_preferred() {
644        let data = [1.0, 2.0, 3.0];
645        let sparkline = Sparkline::new(&data);
646        let c = sparkline.measure(Size::MAX);
647
648        assert_eq!(c.max, Some(Size::new(3, 1)));
649    }
650}