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