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};
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    pub fn new(data: &'a [f64]) -> Self {
63        Self {
64            data,
65            min: None,
66            max: None,
67            style: Style::default(),
68            gradient: None,
69            baseline: 0.0,
70        }
71    }
72
73    /// Set explicit minimum value for scaling.
74    ///
75    /// If not set, minimum is auto-detected from data.
76    pub fn min(mut self, min: f64) -> Self {
77        self.min = Some(min);
78        self
79    }
80
81    /// Set explicit maximum value for scaling.
82    ///
83    /// If not set, maximum is auto-detected from data.
84    pub fn max(mut self, max: f64) -> Self {
85        self.max = Some(max);
86        self
87    }
88
89    /// Set min and max bounds together.
90    pub fn bounds(mut self, min: f64, max: f64) -> Self {
91        self.min = Some(min);
92        self.max = Some(max);
93        self
94    }
95
96    /// Set the base style (foreground color, etc.).
97    pub fn style(mut self, style: Style) -> Self {
98        self.style = style;
99        self
100    }
101
102    /// Set a color gradient from low to high values.
103    ///
104    /// Low values get `low_color`, high values get `high_color`,
105    /// with linear interpolation between.
106    pub fn gradient(mut self, low_color: PackedRgba, high_color: PackedRgba) -> Self {
107        self.gradient = Some((low_color, high_color));
108        self
109    }
110
111    /// Set the baseline value.
112    ///
113    /// Values at or below baseline show as empty space.
114    /// Default is 0.0.
115    pub fn baseline(mut self, baseline: f64) -> Self {
116        self.baseline = baseline;
117        self
118    }
119
120    /// Compute the min/max bounds from data or explicit settings.
121    fn compute_bounds(&self) -> (f64, f64) {
122        let data_min = self
123            .min
124            .unwrap_or_else(|| self.data.iter().copied().fold(f64::INFINITY, f64::min));
125        let data_max = self
126            .max
127            .unwrap_or_else(|| self.data.iter().copied().fold(f64::NEG_INFINITY, f64::max));
128
129        // Ensure min <= max; handle edge cases
130        let min = if data_min.is_finite() { data_min } else { 0.0 };
131        let max = if data_max.is_finite() { data_max } else { 1.0 };
132
133        if min >= max {
134            // All values are the same; create a range around the value
135            (min - 0.5, max + 0.5)
136        } else {
137            (min, max)
138        }
139    }
140
141    /// Map a value to a bar index (0-8).
142    fn value_to_bar_index(&self, value: f64, min: f64, max: f64) -> usize {
143        if !value.is_finite() {
144            return 0;
145        }
146
147        let range = max - min;
148        if range <= 0.0 {
149            return 4; // Middle bar for flat data
150        }
151
152        let normalized = (value - min) / range;
153        let clamped = normalized.clamp(0.0, 1.0);
154        // Map 0.0 -> 0, 1.0 -> 8
155        (clamped * 8.0).round() as usize
156    }
157
158    /// Interpolate between two colors based on t (0.0 to 1.0).
159    fn lerp_color(low: PackedRgba, high: PackedRgba, t: f64) -> PackedRgba {
160        let t = t.clamp(0.0, 1.0) as f32;
161        let r = (low.r() as f32 * (1.0 - t) + high.r() as f32 * t).round() as u8;
162        let g = (low.g() as f32 * (1.0 - t) + high.g() as f32 * t).round() as u8;
163        let b = (low.b() as f32 * (1.0 - t) + high.b() as f32 * t).round() as u8;
164        PackedRgba::rgb(r, g, b)
165    }
166
167    /// Render the sparkline as a string (for testing/debugging).
168    pub fn render_to_string(&self) -> String {
169        if self.data.is_empty() {
170            return String::new();
171        }
172
173        let (min, max) = self.compute_bounds();
174        self.data
175            .iter()
176            .map(|&v| {
177                let idx = self.value_to_bar_index(v, min, max);
178                SPARK_CHARS[idx]
179            })
180            .collect()
181    }
182}
183
184impl Default for Sparkline<'_> {
185    fn default() -> Self {
186        Self::new(&[])
187    }
188}
189
190impl Widget for Sparkline<'_> {
191    fn render(&self, area: Rect, frame: &mut Frame) {
192        #[cfg(feature = "tracing")]
193        let _span = tracing::debug_span!(
194            "widget_render",
195            widget = "Sparkline",
196            x = area.x,
197            y = area.y,
198            w = area.width,
199            h = area.height,
200            data_len = self.data.len()
201        )
202        .entered();
203
204        if area.is_empty() || self.data.is_empty() {
205            return;
206        }
207
208        let deg = frame.buffer.degradation;
209
210        // Skeleton+: skip entirely
211        if !deg.render_content() {
212            return;
213        }
214
215        let (min, max) = self.compute_bounds();
216        let range = max - min;
217
218        // How many data points can we show?
219        let display_count = (area.width as usize).min(self.data.len());
220
221        for (i, &value) in self.data.iter().take(display_count).enumerate() {
222            let x = area.x + i as u16;
223            let y = area.y;
224
225            if x >= area.right() {
226                break;
227            }
228
229            let bar_idx = self.value_to_bar_index(value, min, max);
230            let ch = SPARK_CHARS[bar_idx];
231
232            let mut cell = Cell::from_char(ch);
233
234            // Apply style
235            if deg.apply_styling() {
236                // Apply base style (fg, bg, attrs)
237                crate::apply_style(&mut cell, self.style);
238
239                // Override fg with gradient if configured
240                if let Some((low_color, high_color)) = self.gradient {
241                    let t = if range > 0.0 {
242                        (value - min) / range
243                    } else {
244                        0.5
245                    };
246                    cell.fg = Self::lerp_color(low_color, high_color, t);
247                } else if self.style.fg.is_none() {
248                    // Default to white if no style fg and no gradient
249                    cell.fg = PackedRgba::WHITE;
250                }
251            }
252
253            frame.buffer.set(x, y, cell);
254        }
255    }
256}
257
258impl MeasurableWidget for Sparkline<'_> {
259    fn measure(&self, _available: Size) -> SizeConstraints {
260        if self.data.is_empty() {
261            return SizeConstraints::ZERO;
262        }
263
264        // Sparklines are always 1 row tall
265        // Width is the number of data points
266        let width = self.data.len() as u16;
267
268        SizeConstraints {
269            min: Size::new(1, 1), // At least 1 data point visible
270            preferred: Size::new(width, 1),
271            max: Some(Size::new(width, 1)), // Fixed content size
272        }
273    }
274
275    fn has_intrinsic_size(&self) -> bool {
276        !self.data.is_empty()
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use ftui_render::grapheme_pool::GraphemePool;
284
285    // --- Builder tests ---
286
287    #[test]
288    fn empty_data() {
289        let sparkline = Sparkline::new(&[]);
290        assert_eq!(sparkline.render_to_string(), "");
291    }
292
293    #[test]
294    fn single_value() {
295        let sparkline = Sparkline::new(&[5.0]);
296        // Single value maps to middle bar
297        let s = sparkline.render_to_string();
298        assert_eq!(s.chars().count(), 1);
299    }
300
301    #[test]
302    fn constant_values() {
303        let data = vec![5.0, 5.0, 5.0, 5.0];
304        let sparkline = Sparkline::new(&data);
305        let s = sparkline.render_to_string();
306        // All same height (middle bar)
307        assert_eq!(s.chars().count(), 4);
308        assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
309    }
310
311    #[test]
312    fn ascending_values() {
313        let data: Vec<f64> = (0..9).map(|i| i as f64).collect();
314        let sparkline = Sparkline::new(&data);
315        let s = sparkline.render_to_string();
316        let chars: Vec<char> = s.chars().collect();
317        // First should be lowest, last should be highest
318        assert_eq!(chars[0], ' ');
319        assert_eq!(chars[8], '█');
320    }
321
322    #[test]
323    fn descending_values() {
324        let data: Vec<f64> = (0..9).rev().map(|i| i as f64).collect();
325        let sparkline = Sparkline::new(&data);
326        let s = sparkline.render_to_string();
327        let chars: Vec<char> = s.chars().collect();
328        // First should be highest, last should be lowest
329        assert_eq!(chars[0], '█');
330        assert_eq!(chars[8], ' ');
331    }
332
333    #[test]
334    fn explicit_bounds() {
335        let data = vec![5.0, 5.0, 5.0];
336        let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
337        let s = sparkline.render_to_string();
338        // 5.0 is at 50%, should be middle bar (▄)
339        let chars: Vec<char> = s.chars().collect();
340        assert_eq!(chars[0], '▄');
341    }
342
343    #[test]
344    fn min_max_explicit() {
345        let data = vec![0.0, 50.0, 100.0];
346        let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
347        let s = sparkline.render_to_string();
348        let chars: Vec<char> = s.chars().collect();
349        assert_eq!(chars[0], ' '); // 0%
350        assert_eq!(chars[1], '▄'); // 50%
351        assert_eq!(chars[2], '█'); // 100%
352    }
353
354    #[test]
355    fn negative_values() {
356        let data = vec![-10.0, 0.0, 10.0];
357        let sparkline = Sparkline::new(&data);
358        let s = sparkline.render_to_string();
359        let chars: Vec<char> = s.chars().collect();
360        assert_eq!(chars[0], ' '); // Lowest
361        assert_eq!(chars[2], '█'); // Highest
362    }
363
364    #[test]
365    fn nan_values_handled() {
366        let data = vec![1.0, f64::NAN, 3.0];
367        let sparkline = Sparkline::new(&data);
368        let s = sparkline.render_to_string();
369        // NaN should render as empty (index 0)
370        let chars: Vec<char> = s.chars().collect();
371        assert_eq!(chars[1], ' ');
372    }
373
374    #[test]
375    fn infinity_values_handled() {
376        let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
377        let sparkline = Sparkline::new(&data);
378        let s = sparkline.render_to_string();
379        // Infinities should be clamped
380        assert_eq!(s.chars().count(), 3);
381    }
382
383    // --- Rendering tests ---
384
385    #[test]
386    fn render_empty_area() {
387        let data = vec![1.0, 2.0, 3.0];
388        let sparkline = Sparkline::new(&data);
389        let area = Rect::new(0, 0, 0, 0);
390        let mut pool = GraphemePool::new();
391        let mut frame = Frame::new(1, 1, &mut pool);
392        Widget::render(&sparkline, area, &mut frame);
393        // Should not panic
394    }
395
396    #[test]
397    fn render_basic() {
398        let data = vec![0.0, 0.5, 1.0];
399        let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
400        let area = Rect::new(0, 0, 3, 1);
401        let mut pool = GraphemePool::new();
402        let mut frame = Frame::new(3, 1, &mut pool);
403        Widget::render(&sparkline, area, &mut frame);
404
405        let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
406        let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
407        let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
408
409        assert_eq!(c0, Some(' ')); // 0%
410        assert_eq!(c1, Some('▄')); // 50%
411        assert_eq!(c2, Some('█')); // 100%
412    }
413
414    #[test]
415    fn render_truncates_to_width() {
416        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
417        let sparkline = Sparkline::new(&data);
418        let area = Rect::new(0, 0, 10, 1);
419        let mut pool = GraphemePool::new();
420        let mut frame = Frame::new(10, 1, &mut pool);
421        Widget::render(&sparkline, area, &mut frame);
422
423        // Should only render first 10 values
424        for x in 0..10 {
425            let cell = frame.buffer.get(x, 0).unwrap();
426            assert!(cell.content.as_char().is_some());
427        }
428    }
429
430    #[test]
431    fn render_with_style() {
432        let data = vec![1.0];
433        let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
434        let area = Rect::new(0, 0, 1, 1);
435        let mut pool = GraphemePool::new();
436        let mut frame = Frame::new(1, 1, &mut pool);
437        Widget::render(&sparkline, area, &mut frame);
438
439        let cell = frame.buffer.get(0, 0).unwrap();
440        assert_eq!(cell.fg, PackedRgba::GREEN);
441    }
442
443    #[test]
444    fn render_with_gradient() {
445        let data = vec![0.0, 0.5, 1.0];
446        let sparkline = Sparkline::new(&data)
447            .bounds(0.0, 1.0)
448            .gradient(PackedRgba::BLUE, PackedRgba::RED);
449        let area = Rect::new(0, 0, 3, 1);
450        let mut pool = GraphemePool::new();
451        let mut frame = Frame::new(3, 1, &mut pool);
452        Widget::render(&sparkline, area, &mut frame);
453
454        let c0 = frame.buffer.get(0, 0).unwrap();
455        let c2 = frame.buffer.get(2, 0).unwrap();
456
457        // Low value should be blue-ish
458        assert_eq!(c0.fg, PackedRgba::BLUE);
459        // High value should be red-ish
460        assert_eq!(c2.fg, PackedRgba::RED);
461    }
462
463    // --- Degradation tests ---
464
465    #[test]
466    fn degradation_skeleton_skips() {
467        use ftui_render::budget::DegradationLevel;
468
469        let data = vec![1.0, 2.0, 3.0];
470        let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
471        let area = Rect::new(0, 0, 3, 1);
472        let mut pool = GraphemePool::new();
473        let mut frame = Frame::new(3, 1, &mut pool);
474        frame.buffer.degradation = DegradationLevel::Skeleton;
475        Widget::render(&sparkline, area, &mut frame);
476
477        // All cells should be empty
478        for x in 0..3 {
479            assert!(
480                frame.buffer.get(x, 0).unwrap().is_empty(),
481                "cell at x={x} should be empty at Skeleton"
482            );
483        }
484    }
485
486    #[test]
487    fn degradation_no_styling_renders_without_color() {
488        use ftui_render::budget::DegradationLevel;
489
490        let data = vec![0.5];
491        let sparkline = Sparkline::new(&data)
492            .bounds(0.0, 1.0)
493            .style(Style::new().fg(PackedRgba::GREEN));
494        let area = Rect::new(0, 0, 1, 1);
495        let mut pool = GraphemePool::new();
496        let mut frame = Frame::new(1, 1, &mut pool);
497        frame.buffer.degradation = DegradationLevel::NoStyling;
498        Widget::render(&sparkline, area, &mut frame);
499
500        // Character should be rendered but without custom color
501        let cell = frame.buffer.get(0, 0).unwrap();
502        assert!(cell.content.as_char().is_some());
503        // fg should NOT be green since styling is disabled
504        assert_ne!(cell.fg, PackedRgba::GREEN);
505    }
506
507    // --- Color interpolation tests ---
508
509    #[test]
510    fn lerp_color_endpoints() {
511        let low = PackedRgba::rgb(0, 0, 0);
512        let high = PackedRgba::rgb(255, 255, 255);
513
514        assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
515        assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
516    }
517
518    #[test]
519    fn lerp_color_midpoint() {
520        let low = PackedRgba::rgb(0, 0, 0);
521        let high = PackedRgba::rgb(255, 255, 255);
522        let mid = Sparkline::lerp_color(low, high, 0.5);
523
524        assert_eq!(mid.r(), 128);
525        assert_eq!(mid.g(), 128);
526        assert_eq!(mid.b(), 128);
527    }
528
529    // --- MeasurableWidget tests ---
530
531    #[test]
532    fn measure_empty_sparkline() {
533        let sparkline = Sparkline::new(&[]);
534        let c = sparkline.measure(Size::MAX);
535        assert_eq!(c, SizeConstraints::ZERO);
536        assert!(!sparkline.has_intrinsic_size());
537    }
538
539    #[test]
540    fn measure_single_value() {
541        let data = [5.0];
542        let sparkline = Sparkline::new(&data);
543        let c = sparkline.measure(Size::MAX);
544
545        assert_eq!(c.preferred.width, 1);
546        assert_eq!(c.preferred.height, 1);
547        assert!(sparkline.has_intrinsic_size());
548    }
549
550    #[test]
551    fn measure_multiple_values() {
552        let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
553        let sparkline = Sparkline::new(&data);
554        let c = sparkline.measure(Size::MAX);
555
556        assert_eq!(c.preferred.width, 50);
557        assert_eq!(c.preferred.height, 1);
558        assert_eq!(c.min.width, 1);
559        assert_eq!(c.min.height, 1);
560    }
561
562    #[test]
563    fn measure_max_equals_preferred() {
564        let data = [1.0, 2.0, 3.0];
565        let sparkline = Sparkline::new(&data);
566        let c = sparkline.measure(Size::MAX);
567
568        assert_eq!(c.max, Some(Size::new(3, 1)));
569    }
570}