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    #[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 = 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() || self.data.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 (min, max) = self.compute_bounds();
228        let range = max - min;
229
230        // How many data points can we show?
231        let display_count = (area.width as usize).min(self.data.len());
232
233        for (i, &value) in self.data.iter().take(display_count).enumerate() {
234            let x = area.x + i as u16;
235            let y = area.y;
236
237            if x >= area.right() {
238                break;
239            }
240
241            let bar_idx = self.value_to_bar_index(value, min, max);
242            let ch = SPARK_CHARS[bar_idx];
243
244            let mut cell = Cell::from_char(ch);
245
246            // Apply style
247            if deg.apply_styling() {
248                // Apply base style (fg, bg, attrs)
249                crate::apply_style(&mut cell, self.style);
250
251                // Override fg with gradient if configured
252                if let Some((low_color, high_color)) = self.gradient {
253                    let t = if range > 0.0 {
254                        (value - min) / range
255                    } else {
256                        0.5
257                    };
258                    cell.fg = Self::lerp_color(low_color, high_color, t);
259                } else if self.style.fg.is_none() {
260                    // Default to white if no style fg and no gradient
261                    cell.fg = PackedRgba::WHITE;
262                }
263            }
264
265            frame.buffer.set_fast(x, y, cell);
266        }
267    }
268}
269
270impl MeasurableWidget for Sparkline<'_> {
271    fn measure(&self, _available: Size) -> SizeConstraints {
272        if self.data.is_empty() {
273            return SizeConstraints::ZERO;
274        }
275
276        // Sparklines are always 1 row tall
277        // Width is the number of data points
278        let width = self.data.len() as u16;
279
280        SizeConstraints {
281            min: Size::new(1, 1), // At least 1 data point visible
282            preferred: Size::new(width, 1),
283            max: Some(Size::new(width, 1)), // Fixed content size
284        }
285    }
286
287    fn has_intrinsic_size(&self) -> bool {
288        !self.data.is_empty()
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use ftui_render::grapheme_pool::GraphemePool;
296
297    // --- Builder tests ---
298
299    #[test]
300    fn empty_data() {
301        let sparkline = Sparkline::new(&[]);
302        assert_eq!(sparkline.render_to_string(), "");
303    }
304
305    #[test]
306    fn single_value() {
307        let sparkline = Sparkline::new(&[5.0]);
308        // Single value maps to middle bar
309        let s = sparkline.render_to_string();
310        assert_eq!(s.chars().count(), 1);
311    }
312
313    #[test]
314    fn constant_values() {
315        let data = vec![5.0, 5.0, 5.0, 5.0];
316        let sparkline = Sparkline::new(&data);
317        let s = sparkline.render_to_string();
318        // All same height (middle bar)
319        assert_eq!(s.chars().count(), 4);
320        assert!(s.chars().all(|c| c == s.chars().next().unwrap()));
321    }
322
323    #[test]
324    fn ascending_values() {
325        let data: Vec<f64> = (0..9).map(|i| i as f64).collect();
326        let sparkline = Sparkline::new(&data);
327        let s = sparkline.render_to_string();
328        let chars: Vec<char> = s.chars().collect();
329        // First should be lowest, last should be highest
330        assert_eq!(chars[0], ' ');
331        assert_eq!(chars[8], '█');
332    }
333
334    #[test]
335    fn descending_values() {
336        let data: Vec<f64> = (0..9).rev().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 highest, last should be lowest
341        assert_eq!(chars[0], '█');
342        assert_eq!(chars[8], ' ');
343    }
344
345    #[test]
346    fn explicit_bounds() {
347        let data = vec![5.0, 5.0, 5.0];
348        let sparkline = Sparkline::new(&data).bounds(0.0, 10.0);
349        let s = sparkline.render_to_string();
350        // 5.0 is at 50%, should be middle bar (▄)
351        let chars: Vec<char> = s.chars().collect();
352        assert_eq!(chars[0], '▄');
353    }
354
355    #[test]
356    fn min_max_explicit() {
357        let data = vec![0.0, 50.0, 100.0];
358        let sparkline = Sparkline::new(&data).min(0.0).max(100.0);
359        let s = sparkline.render_to_string();
360        let chars: Vec<char> = s.chars().collect();
361        assert_eq!(chars[0], ' '); // 0%
362        assert_eq!(chars[1], '▄'); // 50%
363        assert_eq!(chars[2], '█'); // 100%
364    }
365
366    #[test]
367    fn negative_values() {
368        let data = vec![-10.0, 0.0, 10.0];
369        let sparkline = Sparkline::new(&data);
370        let s = sparkline.render_to_string();
371        let chars: Vec<char> = s.chars().collect();
372        assert_eq!(chars[0], ' '); // Lowest
373        assert_eq!(chars[2], '█'); // Highest
374    }
375
376    #[test]
377    fn nan_values_handled() {
378        let data = vec![1.0, f64::NAN, 3.0];
379        let sparkline = Sparkline::new(&data);
380        let s = sparkline.render_to_string();
381        // NaN should render as empty (index 0)
382        let chars: Vec<char> = s.chars().collect();
383        assert_eq!(chars[1], ' ');
384    }
385
386    #[test]
387    fn infinity_values_handled() {
388        let data = vec![f64::NEG_INFINITY, 0.0, f64::INFINITY];
389        let sparkline = Sparkline::new(&data);
390        let s = sparkline.render_to_string();
391        // Infinities should be clamped
392        assert_eq!(s.chars().count(), 3);
393    }
394
395    // --- Rendering tests ---
396
397    #[test]
398    fn render_empty_area() {
399        let data = vec![1.0, 2.0, 3.0];
400        let sparkline = Sparkline::new(&data);
401        let area = Rect::new(0, 0, 0, 0);
402        let mut pool = GraphemePool::new();
403        let mut frame = Frame::new(1, 1, &mut pool);
404        Widget::render(&sparkline, area, &mut frame);
405        // Should not panic
406    }
407
408    #[test]
409    fn render_basic() {
410        let data = vec![0.0, 0.5, 1.0];
411        let sparkline = Sparkline::new(&data).bounds(0.0, 1.0);
412        let area = Rect::new(0, 0, 3, 1);
413        let mut pool = GraphemePool::new();
414        let mut frame = Frame::new(3, 1, &mut pool);
415        Widget::render(&sparkline, area, &mut frame);
416
417        let c0 = frame.buffer.get(0, 0).unwrap().content.as_char();
418        let c1 = frame.buffer.get(1, 0).unwrap().content.as_char();
419        let c2 = frame.buffer.get(2, 0).unwrap().content.as_char();
420
421        assert_eq!(c0, Some(' ')); // 0%
422        assert_eq!(c1, Some('▄')); // 50%
423        assert_eq!(c2, Some('█')); // 100%
424    }
425
426    #[test]
427    fn render_truncates_to_width() {
428        let data: Vec<f64> = (0..100).map(|i| i as f64).collect();
429        let sparkline = Sparkline::new(&data);
430        let area = Rect::new(0, 0, 10, 1);
431        let mut pool = GraphemePool::new();
432        let mut frame = Frame::new(10, 1, &mut pool);
433        Widget::render(&sparkline, area, &mut frame);
434
435        // Should only render first 10 values
436        for x in 0..10 {
437            let cell = frame.buffer.get(x, 0).unwrap();
438            assert!(cell.content.as_char().is_some());
439        }
440    }
441
442    #[test]
443    fn render_with_style() {
444        let data = vec![1.0];
445        let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
446        let area = Rect::new(0, 0, 1, 1);
447        let mut pool = GraphemePool::new();
448        let mut frame = Frame::new(1, 1, &mut pool);
449        Widget::render(&sparkline, area, &mut frame);
450
451        let cell = frame.buffer.get(0, 0).unwrap();
452        assert_eq!(cell.fg, PackedRgba::GREEN);
453    }
454
455    #[test]
456    fn render_with_gradient() {
457        let data = vec![0.0, 0.5, 1.0];
458        let sparkline = Sparkline::new(&data)
459            .bounds(0.0, 1.0)
460            .gradient(PackedRgba::BLUE, PackedRgba::RED);
461        let area = Rect::new(0, 0, 3, 1);
462        let mut pool = GraphemePool::new();
463        let mut frame = Frame::new(3, 1, &mut pool);
464        Widget::render(&sparkline, area, &mut frame);
465
466        let c0 = frame.buffer.get(0, 0).unwrap();
467        let c2 = frame.buffer.get(2, 0).unwrap();
468
469        // Low value should be blue-ish
470        assert_eq!(c0.fg, PackedRgba::BLUE);
471        // High value should be red-ish
472        assert_eq!(c2.fg, PackedRgba::RED);
473    }
474
475    // --- Degradation tests ---
476
477    #[test]
478    fn degradation_skeleton_skips() {
479        use ftui_render::budget::DegradationLevel;
480
481        let data = vec![1.0, 2.0, 3.0];
482        let sparkline = Sparkline::new(&data).style(Style::new().fg(PackedRgba::GREEN));
483        let area = Rect::new(0, 0, 3, 1);
484        let mut pool = GraphemePool::new();
485        let mut frame = Frame::new(3, 1, &mut pool);
486        frame.buffer.degradation = DegradationLevel::Skeleton;
487        Widget::render(&sparkline, area, &mut frame);
488
489        // All cells should be empty
490        for x in 0..3 {
491            assert!(
492                frame.buffer.get(x, 0).unwrap().is_empty(),
493                "cell at x={x} should be empty at Skeleton"
494            );
495        }
496    }
497
498    #[test]
499    fn degradation_no_styling_renders_without_color() {
500        use ftui_render::budget::DegradationLevel;
501
502        let data = vec![0.5];
503        let sparkline = Sparkline::new(&data)
504            .bounds(0.0, 1.0)
505            .style(Style::new().fg(PackedRgba::GREEN));
506        let area = Rect::new(0, 0, 1, 1);
507        let mut pool = GraphemePool::new();
508        let mut frame = Frame::new(1, 1, &mut pool);
509        frame.buffer.degradation = DegradationLevel::NoStyling;
510        Widget::render(&sparkline, area, &mut frame);
511
512        // Character should be rendered but without custom color
513        let cell = frame.buffer.get(0, 0).unwrap();
514        assert!(cell.content.as_char().is_some());
515        // fg should NOT be green since styling is disabled
516        assert_ne!(cell.fg, PackedRgba::GREEN);
517    }
518
519    // --- Color interpolation tests ---
520
521    #[test]
522    fn lerp_color_endpoints() {
523        let low = PackedRgba::rgb(0, 0, 0);
524        let high = PackedRgba::rgb(255, 255, 255);
525
526        assert_eq!(Sparkline::lerp_color(low, high, 0.0), low);
527        assert_eq!(Sparkline::lerp_color(low, high, 1.0), high);
528    }
529
530    #[test]
531    fn lerp_color_midpoint() {
532        let low = PackedRgba::rgb(0, 0, 0);
533        let high = PackedRgba::rgb(255, 255, 255);
534        let mid = Sparkline::lerp_color(low, high, 0.5);
535
536        assert_eq!(mid.r(), 128);
537        assert_eq!(mid.g(), 128);
538        assert_eq!(mid.b(), 128);
539    }
540
541    #[test]
542    fn lerp_color_interpolates_alpha() {
543        let low = PackedRgba::rgba(0, 0, 0, 0);
544        let high = PackedRgba::rgba(255, 255, 255, 255);
545        let mid = Sparkline::lerp_color(low, high, 0.5);
546
547        assert_eq!(mid.r(), 128);
548        assert_eq!(mid.g(), 128);
549        assert_eq!(mid.b(), 128);
550        assert_eq!(mid.a(), 128);
551    }
552
553    // --- MeasurableWidget tests ---
554
555    #[test]
556    fn measure_empty_sparkline() {
557        let sparkline = Sparkline::new(&[]);
558        let c = sparkline.measure(Size::MAX);
559        assert_eq!(c, SizeConstraints::ZERO);
560        assert!(!sparkline.has_intrinsic_size());
561    }
562
563    #[test]
564    fn measure_single_value() {
565        let data = [5.0];
566        let sparkline = Sparkline::new(&data);
567        let c = sparkline.measure(Size::MAX);
568
569        assert_eq!(c.preferred.width, 1);
570        assert_eq!(c.preferred.height, 1);
571        assert!(sparkline.has_intrinsic_size());
572    }
573
574    #[test]
575    fn measure_multiple_values() {
576        let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
577        let sparkline = Sparkline::new(&data);
578        let c = sparkline.measure(Size::MAX);
579
580        assert_eq!(c.preferred.width, 50);
581        assert_eq!(c.preferred.height, 1);
582        assert_eq!(c.min.width, 1);
583        assert_eq!(c.min.height, 1);
584    }
585
586    #[test]
587    fn measure_max_equals_preferred() {
588        let data = [1.0, 2.0, 3.0];
589        let sparkline = Sparkline::new(&data);
590        let c = sparkline.measure(Size::MAX);
591
592        assert_eq!(c.max, Some(Size::new(3, 1)));
593    }
594}