Skip to main content

tui_lipan/widgets/sparkline/
mod.rs

1//! Sparkline widget.
2
3use std::sync::{Arc, Mutex, OnceLock};
4
5use crate::style::{Length, Style};
6use crate::utils::gradient::{ColorGradient, GradientRange};
7use crate::widgets::Overflow;
8
9mod layout;
10mod node;
11mod reconcile;
12
13pub use layout::measure_sparkline;
14pub use node::SparklineNode;
15pub use reconcile::reconcile_sparkline;
16
17pub(crate) use node::SparklineCacheKey;
18
19static SPARKLINE_CACHE: OnceLock<Mutex<SparklineVisualCache>> = OnceLock::new();
20
21#[derive(Clone, Debug)]
22pub(crate) struct SparklineVisualCache {
23    entries: Vec<(SparklineCacheKey, Arc<node::SparklineRenderOutput>)>,
24}
25
26impl SparklineVisualCache {
27    fn new() -> Self {
28        Self {
29            entries: Vec::new(),
30        }
31    }
32
33    fn get(&self, key: &SparklineCacheKey) -> Option<Arc<node::SparklineRenderOutput>> {
34        self.entries
35            .iter()
36            .find(|(k, _)| k == key)
37            .map(|(_, v)| Arc::clone(v))
38    }
39
40    fn insert(&mut self, key: SparklineCacheKey, value: Arc<node::SparklineRenderOutput>) {
41        if let Some(idx) = self.entries.iter().position(|(k, _)| k == &key) {
42            self.entries.remove(idx);
43        }
44        self.entries.push((key, value));
45        if self.entries.len() > 100 {
46            self.entries.remove(0);
47        }
48    }
49}
50
51pub(crate) fn get_cached_output(
52    key: &SparklineCacheKey,
53) -> Option<Arc<node::SparklineRenderOutput>> {
54    let cache_mutex = SPARKLINE_CACHE.get_or_init(|| Mutex::new(SparklineVisualCache::new()));
55    if let Ok(cache) = cache_mutex.lock() {
56        return cache.get(key);
57    }
58    None
59}
60
61pub(crate) fn insert_cached_output(
62    key: SparklineCacheKey,
63    output: Arc<node::SparklineRenderOutput>,
64) {
65    let cache_mutex = SPARKLINE_CACHE.get_or_init(|| Mutex::new(SparklineVisualCache::new()));
66    if let Ok(mut cache) = cache_mutex.lock() {
67        cache.insert(key, output);
68    }
69}
70
71pub(crate) const DEFAULT_BARS: [char; 8] = [' ', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
72pub(crate) const SHADE_BARS: [char; 5] = [' ', '░', '▒', '▓', '█'];
73pub(crate) const LINE_NORTH: u8 = 0b0001;
74pub(crate) const LINE_EAST: u8 = 0b0010;
75pub(crate) const LINE_SOUTH: u8 = 0b0100;
76pub(crate) const LINE_WEST: u8 = 0b1000;
77pub(crate) const LINE_POINT: u8 = 0b1_0000;
78
79use crate::core::element::Element;
80
81impl From<Sparkline> for Element {
82    fn from(val: Sparkline) -> Self {
83        Element::new(crate::core::element::ElementKind::Sparkline(val))
84    }
85}
86
87/// Visual mode for rendering sparkline data.
88#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
89pub enum SparklineVariant {
90    /// Amplitude bars (default).
91    #[default]
92    Bars,
93    /// Braille spike bars (pair-packed, two samples per glyph).
94    Braille,
95    /// Trend line glyphs (up/down/flat/turn).
96    Line,
97}
98
99/// Preset bar glyph ramps for `SparklineVariant::Bars`.
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
101pub enum SparklineBarsPreset {
102    /// Unicode block bars from low to high.
103    #[default]
104    Blocks,
105    /// Shade ramp from low to high.
106    Shades,
107}
108
109impl SparklineBarsPreset {
110    pub(crate) fn glyphs(self) -> &'static [char] {
111        match self {
112            Self::Blocks => &DEFAULT_BARS,
113            Self::Shades => &SHADE_BARS,
114        }
115    }
116}
117
118/// Downsampling aggregation strategy when `max_points` is set.
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
120pub enum SparklineAggregation {
121    /// Bucket average.
122    #[default]
123    Average,
124    /// Bucket minimum.
125    Min,
126    /// Bucket maximum.
127    Max,
128    /// First value in each bucket.
129    First,
130    /// Last value in each bucket.
131    Last,
132}
133
134/// Rendering policy for zero values in Bars/Braille variants.
135#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
136pub enum SparklineZeroPolicy {
137    /// Render zero as empty/background.
138    #[default]
139    Empty,
140    /// Render zero using the smallest visible glyph.
141    MinGlyph,
142}
143
144/// Preset glyph sets for `SparklineVariant::Line`.
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
146pub enum SparklineLinePreset {
147    /// Unicode line glyphs.
148    #[default]
149    Unicode,
150    /// ASCII-safe line glyphs.
151    ///
152    /// Single-row rendering uses pure 7-bit ASCII (`/\-^v`). Multi-row
153    /// rendering uses `|-_/\\+` plus `‾` (U+203E OVERLINE) for top-row
154    /// horizontals so plateaus visually hug the top of the cell; `‾` is
155    /// widely supported but is the one non-7-bit-ASCII glyph in the preset.
156    Ascii,
157}
158
159/// Glyph set for `SparklineVariant::Line`.
160#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
161pub struct SparklineLineGlyphs {
162    /// Rising trend.
163    pub rising: char,
164    /// Falling trend.
165    pub falling: char,
166    /// Flat trend.
167    pub flat: char,
168    /// Local peak (up then down).
169    pub peak: char,
170    /// Local valley (down then up).
171    pub valley: char,
172}
173
174impl SparklineLineGlyphs {
175    /// Unicode line glyphs.
176    pub const UNICODE: Self = Self {
177        rising: '╱',
178        falling: '╲',
179        flat: '─',
180        peak: '╮',
181        valley: '╰',
182    };
183
184    /// ASCII-safe line glyphs.
185    pub const ASCII: Self = Self {
186        rising: '/',
187        falling: '\\',
188        flat: '-',
189        peak: '^',
190        valley: 'v',
191    };
192}
193
194impl Default for SparklineLineGlyphs {
195    fn default() -> Self {
196        Self::UNICODE
197    }
198}
199
200impl SparklineLinePreset {
201    pub(crate) fn glyphs(self) -> SparklineLineGlyphs {
202        match self {
203            Self::Unicode => SparklineLineGlyphs::UNICODE,
204            Self::Ascii => SparklineLineGlyphs::ASCII,
205        }
206    }
207}
208
209#[derive(Clone, Copy, Debug, PartialEq, Eq)]
210pub(crate) enum PointTrend {
211    Rising,
212    Falling,
213    Flat,
214    Turn,
215}
216
217/// A compact sparkline chart for inline trend visualization.
218#[derive(Clone)]
219pub struct Sparkline {
220    /// Data points to plot.
221    pub data: Arc<[u64]>,
222    /// Minimum value for the chart range.
223    pub min: Option<u64>,
224    /// Maximum value for the chart range.
225    pub max: Option<u64>,
226    /// Custom bar glyphs.
227    pub bars: Vec<char>,
228    /// Visual variant.
229    pub variant: SparklineVariant,
230    /// Maximum points to display (enables downsampling).
231    pub max_points: Option<usize>,
232    /// Aggregation strategy for downsampling.
233    pub aggregation: SparklineAggregation,
234    /// Policy for rendering zero values.
235    pub zero_policy: SparklineZeroPolicy,
236    /// Custom line glyphs.
237    pub line_glyphs: SparklineLineGlyphs,
238    /// Chart drawing height in rows.
239    pub chart_height: u16,
240    /// Whether to mirror the X axis.
241    pub mirror_x: bool,
242    /// Whether to mirror the Y axis.
243    pub mirror_y: bool,
244    /// Base style.
245    pub style: Style,
246    /// Style for rising segments.
247    pub rising_style: Style,
248    /// Style for falling segments.
249    pub falling_style: Style,
250    /// Style for flat segments.
251    pub flat_style: Style,
252    /// Style for turning segments.
253    pub turn_style: Style,
254    /// Value-based gradient.
255    pub gradient: Option<ColorGradient>,
256    /// Row-based height gradient.
257    pub height_gradient: Option<ColorGradient>,
258    /// Value range for gradient mapping.
259    pub gradient_range: Option<GradientRange>,
260    /// Requested width.
261    /// Default: `Length::Auto`.
262    pub width: Length,
263    /// Requested height.
264    /// Default: `Length::Auto`.
265    pub height: Length,
266    /// Overflow behavior.
267    pub overflow: Overflow,
268}
269
270impl Sparkline {
271    /// Create a new sparkline.
272    pub fn new(data: impl IntoIterator<Item = u64>) -> Self {
273        Self {
274            data: data.into_iter().collect::<Vec<_>>().into(),
275            min: None,
276            max: None,
277            bars: DEFAULT_BARS.to_vec(),
278            variant: SparklineVariant::Bars,
279            max_points: None,
280            aggregation: SparklineAggregation::Average,
281            zero_policy: SparklineZeroPolicy::default(),
282            line_glyphs: SparklineLineGlyphs::default(),
283            chart_height: 1,
284            mirror_x: false,
285            mirror_y: false,
286            style: Style::default(),
287            rising_style: Style::default(),
288            falling_style: Style::default(),
289            flat_style: Style::default(),
290            turn_style: Style::default(),
291            gradient: None,
292            height_gradient: None,
293            gradient_range: None,
294            width: Length::Auto,
295            height: Length::Auto,
296            overflow: Overflow::Auto,
297        }
298    }
299
300    /// Replace data points.
301    pub fn data(mut self, data: impl IntoIterator<Item = u64>) -> Self {
302        self.data = data.into_iter().collect::<Vec<_>>().into();
303        self
304    }
305
306    /// Set data points from a shared slice.
307    pub fn data_arc(mut self, data: Arc<[u64]>) -> Self {
308        self.data = data;
309        self
310    }
311
312    /// Set minimum value (defaults to data min).
313    pub fn min(mut self, min: u64) -> Self {
314        self.min = Some(min);
315        self
316    }
317
318    /// Set maximum value (defaults to data max).
319    pub fn max(mut self, max: u64) -> Self {
320        self.max = Some(max);
321        self
322    }
323
324    /// Set visual variant.
325    pub fn variant(mut self, variant: SparklineVariant) -> Self {
326        self.variant = variant;
327        self
328    }
329
330    /// Convenience: render as a trend line.
331    pub fn line(mut self) -> Self {
332        self.variant = SparklineVariant::Line;
333        self
334    }
335
336    /// Convenience: render using pair-packed braille spike bars.
337    pub fn braille(mut self) -> Self {
338        self.variant = SparklineVariant::Braille;
339        self
340    }
341
342    /// Set custom bar glyphs (lowest to highest).
343    pub fn bars(mut self, bars: impl IntoIterator<Item = char>) -> Self {
344        self.bars = bars.into_iter().collect();
345        self
346    }
347
348    /// Set a bar glyph preset.
349    pub fn bars_preset(mut self, preset: SparklineBarsPreset) -> Self {
350        self.bars = preset.glyphs().to_vec();
351        self
352    }
353
354    /// Set line glyph preset.
355    pub fn line_preset(mut self, preset: SparklineLinePreset) -> Self {
356        self.line_glyphs = preset.glyphs();
357        self
358    }
359
360    /// Set custom line glyphs.
361    pub fn line_glyphs(mut self, glyphs: SparklineLineGlyphs) -> Self {
362        self.line_glyphs = glyphs;
363        self
364    }
365
366    /// Set chart drawing height (in text rows) for all variants.
367    ///
368    /// Values below 1 are clamped to 1.
369    pub fn chart_height(mut self, rows: u16) -> Self {
370        self.chart_height = rows.max(1);
371        self
372    }
373
374    /// Mirror chart horizontally (reverse sample/time order).
375    pub fn mirror_x(mut self, mirror: bool) -> Self {
376        self.mirror_x = mirror;
377        self
378    }
379
380    /// Mirror chart vertically (flip value direction).
381    ///
382    /// - **Braille**: fully mirrored - dots flip within each glyph cell.
383    /// - **Line**: fully mirrored - rising/falling glyph directions swap and
384    ///   multi-row grid is flipped row-wise.
385    /// - **Bars**: row order is flipped, but the default Unicode block ramp
386    ///   (`▂▃▄▅▆▇█`) only fills bottom-up - Unicode offers no matching
387    ///   top-down partial-fill ramp, so the leading partial-fill row of a bar
388    ///   still renders from the bottom. For vertically mirrored bars, prefer
389    ///   a symmetric glyph set (e.g. `SparklineBarsPreset::Shades` with
390    ///   `░▒▓█`) or switch to `SparklineVariant::Braille`.
391    pub fn mirror_y(mut self, mirror: bool) -> Self {
392        self.mirror_y = mirror;
393        self
394    }
395
396    /// Limit rendered point count by downsampling to `max_points`.
397    pub fn max_points(mut self, max_points: usize) -> Self {
398        self.max_points = Some(max_points.max(1));
399        self
400    }
401
402    /// Set downsampling aggregation strategy.
403    pub fn aggregation(mut self, aggregation: SparklineAggregation) -> Self {
404        self.aggregation = aggregation;
405        self
406    }
407
408    /// Control how zero values are rendered in Bars/Braille variants.
409    pub fn zero_policy(mut self, policy: SparklineZeroPolicy) -> Self {
410        self.zero_policy = policy;
411        self
412    }
413
414    /// Apply value-based gradient coloring.
415    pub fn gradient(mut self, gradient: ColorGradient) -> Self {
416        self.gradient = Some(gradient);
417        self
418    }
419
420    /// Apply row-based gradient coloring from top to bottom.
421    pub fn height_gradient(mut self, gradient: ColorGradient) -> Self {
422        self.height_gradient = Some(gradient);
423        self
424    }
425
426    /// Override value range used for gradient mapping.
427    pub fn gradient_range(mut self, min: u64, max: u64) -> Self {
428        self.gradient_range = Some(GradientRange::new(min, max));
429        self
430    }
431
432    /// Set base style.
433    pub fn style(mut self, style: Style) -> Self {
434        self.style = style;
435        self
436    }
437
438    /// Style for rising points.
439    pub fn rising_style(mut self, style: Style) -> Self {
440        self.rising_style = style;
441        self
442    }
443
444    /// Style for falling points.
445    pub fn falling_style(mut self, style: Style) -> Self {
446        self.falling_style = style;
447        self
448    }
449
450    /// Style for flat points.
451    pub fn flat_style(mut self, style: Style) -> Self {
452        self.flat_style = style;
453        self
454    }
455
456    /// Style for turning points (peaks/valleys).
457    pub fn turn_style(mut self, style: Style) -> Self {
458        self.turn_style = style;
459        self
460    }
461
462    /// Set width.
463    pub fn width(mut self, width: Length) -> Self {
464        self.width = width;
465        self
466    }
467
468    /// Set widget height constraint.
469    ///
470    /// This is layout height, independent from `chart_height` draw rows.
471    pub fn height(mut self, height: Length) -> Self {
472        self.height = height;
473        self
474    }
475
476    /// Set overflow behavior when the data buffer is longer than the allocated width.
477    ///
478    /// Only active for `Length::Flex`/`Length::Percent` widths and when
479    /// `max_points` is not set (explicit `max_points` always bucket-downsamples).
480    ///
481    /// - `Auto` / `ClipStart`: keep the newest samples (scrolling, default).
482    /// - `Clip` / `Ellipsis`: keep the oldest samples.
483    /// - `Wrap`: bucket-aggregate the full buffer across the width.
484    pub fn overflow(mut self, overflow: Overflow) -> Self {
485        self.overflow = overflow;
486        self
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::{Sparkline, SparklineLinePreset, SparklineVariant};
493    use crate::core::element::{Element, ElementKind};
494    use crate::style::Length;
495
496    fn into_node(el: Element) -> super::node::SparklineNode {
497        match el.kind {
498            ElementKind::Sparkline(spark) => spark.into(),
499            _ => panic!("expected sparkline element"),
500        }
501    }
502
503    #[test]
504    fn default_bars_render_expected_ramp() {
505        let node = into_node(
506            Sparkline::new([0, 1, 2, 3, 4, 5, 6, 7])
507                .min(0)
508                .max(7)
509                .into(),
510        );
511        let content: String = node.output.rows[0]
512            .iter()
513            .map(|s| s.content.as_ref())
514            .collect();
515        assert_eq!(content, " ▂▃▄▅▆▇█");
516    }
517
518    #[test]
519    fn chart_height_renders_multi_row_bars() {
520        let node = into_node(
521            Sparkline::new([0, 25, 50, 75, 100])
522                .min(0)
523                .max(100)
524                .chart_height(3)
525                .into(),
526        );
527        assert_eq!(node.output.rows.len(), 3);
528        // In primitive mode, the requested height remains Auto if not explicitly set.
529        assert_eq!(node.height, Length::Auto);
530    }
531
532    #[test]
533    fn line_variant_renders_turn_glyphs() {
534        let node = into_node(Sparkline::new([1, 3, 2, 4, 4, 1]).line().into());
535        let content: String = node.output.rows[0]
536            .iter()
537            .map(|s| s.content.as_ref())
538            .collect();
539        assert_eq!(content, "╱╮╰╱╲╲");
540    }
541
542    #[test]
543    fn braille_variant_packs_two_samples_per_cell() {
544        let node = into_node(
545            Sparkline::new([0, 1, 2, 3, 4])
546                .variant(SparklineVariant::Braille)
547                .min(0)
548                .max(4)
549                .into(),
550        );
551
552        let content: String = node.output.rows[0]
553            .iter()
554            .map(|s| s.content.as_ref())
555            .collect();
556        assert_eq!(content.chars().count(), 3);
557    }
558
559    #[test]
560    fn line_ascii_preset_is_available() {
561        let node = into_node(
562            Sparkline::new([1, 2, 1])
563                .variant(SparklineVariant::Line)
564                .line_preset(SparklineLinePreset::Ascii)
565                .into(),
566        );
567        let content: String = node.output.rows[0]
568            .iter()
569            .map(|s| s.content.as_ref())
570            .collect();
571        assert_eq!(content, "/^\\");
572    }
573
574    #[test]
575    fn mirror_y_braille_flips_fill_direction() {
576        let normal_node = into_node(
577            Sparkline::new([1])
578                .variant(SparklineVariant::Braille)
579                .min(0)
580                .max(4)
581                .into(),
582        );
583        let mirrored_node = into_node(
584            Sparkline::new([1])
585                .variant(SparklineVariant::Braille)
586                .min(0)
587                .max(4)
588                .mirror_y(true)
589                .into(),
590        );
591
592        let normal: String = normal_node.output.rows[0]
593            .iter()
594            .map(|s| s.content.as_ref())
595            .collect();
596        let mirrored: String = mirrored_node.output.rows[0]
597            .iter()
598            .map(|s| s.content.as_ref())
599            .collect();
600
601        assert_eq!(normal, "⡀");
602        assert_eq!(mirrored, "⠁");
603    }
604
605    #[test]
606    fn data_arc_preserves_shared_slice() {
607        use std::sync::Arc;
608
609        let data: Arc<[u64]> = Arc::from([1u64, 2, 3, 4]);
610        let spark = Sparkline::new([]).data_arc(Arc::clone(&data));
611        assert!(Arc::ptr_eq(&spark.data, &data));
612        assert_eq!(spark.data.as_ref(), &[1, 2, 3, 4]);
613    }
614}