Skip to main content

slt/context/
widgets_viz.rs

1use super::*;
2
3struct VerticalBarLayout {
4    chart_height: usize,
5    bar_width: usize,
6    value_labels: Vec<String>,
7    col_width: usize,
8    bar_units: Vec<usize>,
9}
10
11impl Context {
12    /// Render a horizontal bar chart from `(label, value)` pairs.
13    ///
14    /// Bars are normalized against the largest value and rendered with `█` up to
15    /// `max_width` characters.
16    ///
17    /// # Example
18    ///
19    /// ```no_run
20    /// # slt::run(|ui: &mut slt::Context| {
21    /// let data = [
22    ///     ("Sales", 160.0),
23    ///     ("Revenue", 120.0),
24    ///     ("Users", 220.0),
25    ///     ("Costs", 60.0),
26    /// ];
27    /// ui.bar_chart(&data, 24);
28    /// # });
29    /// ```
30    ///
31    /// For styled bars with per-bar colors, see [`bar_chart_styled`](Self::bar_chart_styled).
32    pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> Response {
33        if data.is_empty() {
34            return Response::none();
35        }
36
37        let max_label_width = data
38            .iter()
39            .map(|(label, _)| UnicodeWidthStr::width(*label))
40            .max()
41            .unwrap_or(0);
42        let max_value = data
43            .iter()
44            .map(|(_, value)| *value)
45            .fold(f64::NEG_INFINITY, f64::max);
46        let denom = if max_value > 0.0 { max_value } else { 1.0 };
47
48        self.interaction_count += 1;
49        self.commands.push(Command::BeginContainer {
50            direction: Direction::Column,
51            gap: 0,
52            align: Align::Start,
53            align_self: None,
54            justify: Justify::Start,
55            border: None,
56            border_sides: BorderSides::all(),
57            border_style: Style::new().fg(self.theme.border),
58            bg_color: None,
59            padding: Padding::default(),
60            margin: Margin::default(),
61            constraints: Constraints::default(),
62            title: None,
63            grow: 0,
64            group_name: None,
65        });
66
67        for (label, value) in data {
68            let label_width = UnicodeWidthStr::width(*label);
69            let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
70            let normalized = (*value / denom).clamp(0.0, 1.0);
71            let bar = Self::horizontal_bar_text(normalized, max_width);
72
73            self.interaction_count += 1;
74            self.commands.push(Command::BeginContainer {
75                direction: Direction::Row,
76                gap: 1,
77                align: Align::Start,
78                align_self: None,
79                justify: Justify::Start,
80                border: None,
81                border_sides: BorderSides::all(),
82                border_style: Style::new().fg(self.theme.border),
83                bg_color: None,
84                padding: Padding::default(),
85                margin: Margin::default(),
86                constraints: Constraints::default(),
87                title: None,
88                grow: 0,
89                group_name: None,
90            });
91            self.styled(
92                format!("{label}{label_padding}"),
93                Style::new().fg(self.theme.text),
94            );
95            self.styled(bar, Style::new().fg(self.theme.primary));
96            self.styled(
97                format_compact_number(*value),
98                Style::new().fg(self.theme.text_dim),
99            );
100            self.commands.push(Command::EndContainer);
101            self.last_text_idx = None;
102        }
103
104        self.commands.push(Command::EndContainer);
105        self.last_text_idx = None;
106
107        Response::none()
108    }
109
110    /// Render a styled bar chart with per-bar colors, grouping, and direction control.
111    ///
112    /// # Example
113    /// ```ignore
114    /// # slt::run(|ui: &mut slt::Context| {
115    /// use slt::{Bar, Color};
116    /// let bars = vec![
117    ///     Bar::new("Q1", 32.0).color(Color::Cyan),
118    ///     Bar::new("Q2", 46.0).color(Color::Green),
119    ///     Bar::new("Q3", 28.0).color(Color::Yellow),
120    ///     Bar::new("Q4", 54.0).color(Color::Red),
121    /// ];
122    /// ui.bar_chart_styled(&bars, 30, slt::BarDirection::Horizontal);
123    /// # });
124    /// ```
125    pub fn bar_chart_styled(
126        &mut self,
127        bars: &[Bar],
128        max_width: u32,
129        direction: BarDirection,
130    ) -> Response {
131        self.bar_chart_with(
132            bars,
133            |config| {
134                config.direction(direction);
135            },
136            max_width,
137        )
138    }
139
140    pub fn bar_chart_with(
141        &mut self,
142        bars: &[Bar],
143        configure: impl FnOnce(&mut BarChartConfig),
144        max_size: u32,
145    ) -> Response {
146        if bars.is_empty() {
147            return Response::none();
148        }
149
150        let (config, denom) = self.bar_chart_styled_layout(bars, configure);
151        self.bar_chart_styled_render(bars, max_size, denom, &config);
152
153        Response::none()
154    }
155
156    fn bar_chart_styled_layout(
157        &self,
158        bars: &[Bar],
159        configure: impl FnOnce(&mut BarChartConfig),
160    ) -> (BarChartConfig, f64) {
161        let mut config = BarChartConfig::default();
162        configure(&mut config);
163
164        let auto_max = bars
165            .iter()
166            .map(|bar| bar.value)
167            .fold(f64::NEG_INFINITY, f64::max);
168        let max_value = config.max_value.unwrap_or(auto_max);
169        let denom = if max_value > 0.0 { max_value } else { 1.0 };
170
171        (config, denom)
172    }
173
174    fn bar_chart_styled_render(
175        &mut self,
176        bars: &[Bar],
177        max_size: u32,
178        denom: f64,
179        config: &BarChartConfig,
180    ) {
181        match config.direction {
182            BarDirection::Horizontal => {
183                self.render_horizontal_styled_bars(bars, max_size, denom, config.bar_gap)
184            }
185            BarDirection::Vertical => self.render_vertical_styled_bars(
186                bars,
187                max_size,
188                denom,
189                config.bar_width,
190                config.bar_gap,
191            ),
192        }
193    }
194
195    fn render_horizontal_styled_bars(
196        &mut self,
197        bars: &[Bar],
198        max_width: u32,
199        denom: f64,
200        bar_gap: u16,
201    ) {
202        let max_label_width = bars
203            .iter()
204            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
205            .max()
206            .unwrap_or(0);
207
208        self.interaction_count += 1;
209        self.commands.push(Command::BeginContainer {
210            direction: Direction::Column,
211            gap: bar_gap as u32,
212            align: Align::Start,
213            align_self: None,
214            justify: Justify::Start,
215            border: None,
216            border_sides: BorderSides::all(),
217            border_style: Style::new().fg(self.theme.border),
218            bg_color: None,
219            padding: Padding::default(),
220            margin: Margin::default(),
221            constraints: Constraints::default(),
222            title: None,
223            grow: 0,
224            group_name: None,
225        });
226
227        for bar in bars {
228            self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
229        }
230
231        self.commands.push(Command::EndContainer);
232        self.last_text_idx = None;
233    }
234
235    fn render_horizontal_styled_bar_row(
236        &mut self,
237        bar: &Bar,
238        max_label_width: usize,
239        max_width: u32,
240        denom: f64,
241    ) {
242        let label_width = UnicodeWidthStr::width(bar.label.as_str());
243        let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
244        let normalized = (bar.value / denom).clamp(0.0, 1.0);
245        let bar_text = Self::horizontal_bar_text(normalized, max_width);
246        let color = bar.color.unwrap_or(self.theme.primary);
247
248        self.interaction_count += 1;
249        self.commands.push(Command::BeginContainer {
250            direction: Direction::Row,
251            gap: 1,
252            align: Align::Start,
253            align_self: None,
254            justify: Justify::Start,
255            border: None,
256            border_sides: BorderSides::all(),
257            border_style: Style::new().fg(self.theme.border),
258            bg_color: None,
259            padding: Padding::default(),
260            margin: Margin::default(),
261            constraints: Constraints::default(),
262            title: None,
263            grow: 0,
264            group_name: None,
265        });
266        self.styled(
267            format!("{}{label_padding}", bar.label),
268            Style::new().fg(self.theme.text),
269        );
270        self.styled(bar_text, Style::new().fg(color));
271        self.styled(
272            Self::bar_display_value(bar),
273            bar.value_style
274                .unwrap_or(Style::new().fg(self.theme.text_dim)),
275        );
276        self.commands.push(Command::EndContainer);
277        self.last_text_idx = None;
278    }
279
280    fn render_vertical_styled_bars(
281        &mut self,
282        bars: &[Bar],
283        max_height: u32,
284        denom: f64,
285        bar_width: u16,
286        bar_gap: u16,
287    ) {
288        let layout = self.compute_vertical_bar_layout(bars, max_height, denom, bar_width);
289
290        self.interaction_count += 1;
291        self.commands.push(Command::BeginContainer {
292            direction: Direction::Column,
293            gap: 0,
294            align: Align::Start,
295            align_self: None,
296            justify: Justify::Start,
297            border: None,
298            border_sides: BorderSides::all(),
299            border_style: Style::new().fg(self.theme.border),
300            bg_color: None,
301            padding: Padding::default(),
302            margin: Margin::default(),
303            constraints: Constraints::default(),
304            title: None,
305            grow: 0,
306            group_name: None,
307        });
308
309        self.render_vertical_bar_body(
310            bars,
311            &layout.bar_units,
312            layout.chart_height,
313            layout.col_width,
314            layout.bar_width,
315            bar_gap,
316            &layout.value_labels,
317        );
318        self.render_vertical_bar_labels(bars, layout.col_width, bar_gap);
319
320        self.commands.push(Command::EndContainer);
321        self.last_text_idx = None;
322    }
323
324    fn compute_vertical_bar_layout(
325        &self,
326        bars: &[Bar],
327        max_height: u32,
328        denom: f64,
329        bar_width: u16,
330    ) -> VerticalBarLayout {
331        let chart_height = max_height.max(1) as usize;
332        let bar_width = bar_width.max(1) as usize;
333        let value_labels: Vec<String> = bars.iter().map(Self::bar_display_value).collect();
334        let label_width = bars
335            .iter()
336            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
337            .max()
338            .unwrap_or(1);
339        let value_width = value_labels
340            .iter()
341            .map(|value| UnicodeWidthStr::width(value.as_str()))
342            .max()
343            .unwrap_or(1);
344        let col_width = bar_width.max(label_width.max(value_width).max(1));
345        let bar_units: Vec<usize> = bars
346            .iter()
347            .map(|bar| {
348                ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
349            })
350            .collect();
351
352        VerticalBarLayout {
353            chart_height,
354            bar_width,
355            value_labels,
356            col_width,
357            bar_units,
358        }
359    }
360
361    #[allow(clippy::too_many_arguments)]
362    fn render_vertical_bar_body(
363        &mut self,
364        bars: &[Bar],
365        bar_units: &[usize],
366        chart_height: usize,
367        col_width: usize,
368        bar_width: usize,
369        bar_gap: u16,
370        value_labels: &[String],
371    ) {
372        const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
373
374        // Pre-compute the topmost filled row for each bar (for value label placement).
375        let top_rows: Vec<usize> = bar_units
376            .iter()
377            .map(|units| {
378                if *units == 0 {
379                    usize::MAX
380                } else {
381                    (*units - 1) / 8
382                }
383            })
384            .collect();
385
386        for row in (0..chart_height).rev() {
387            self.interaction_count += 1;
388            self.commands.push(Command::BeginContainer {
389                direction: Direction::Row,
390                gap: bar_gap as u32,
391                align: Align::Start,
392                align_self: None,
393                justify: Justify::Start,
394                border: None,
395                border_sides: BorderSides::all(),
396                border_style: Style::new().fg(self.theme.border),
397                bg_color: None,
398                padding: Padding::default(),
399                margin: Margin::default(),
400                constraints: Constraints::default(),
401                title: None,
402                grow: 0,
403                group_name: None,
404            });
405
406            let row_base = row * 8;
407            for (i, (bar, units)) in bars.iter().zip(bar_units.iter()).enumerate() {
408                let color = bar.color.unwrap_or(self.theme.primary);
409
410                if *units <= row_base {
411                    // Value label one row above the bar top (plain text, no bg).
412                    if top_rows[i] != usize::MAX && row == top_rows[i] + 1 {
413                        let label = &value_labels[i];
414                        let centered = Self::center_and_truncate_text(label, col_width);
415                        self.styled(
416                            centered,
417                            bar.value_style.unwrap_or(Style::new().fg(color).bold()),
418                        );
419                    } else {
420                        let empty = " ".repeat(col_width);
421                        self.styled(empty, Style::new());
422                    }
423                    continue;
424                }
425
426                if row == top_rows[i] && top_rows[i] + 1 >= chart_height {
427                    let label = &value_labels[i];
428                    let centered = Self::center_and_truncate_text(label, col_width);
429                    self.styled(
430                        centered,
431                        bar.value_style.unwrap_or(Style::new().fg(color).bold()),
432                    );
433                    continue;
434                }
435
436                let delta = *units - row_base;
437                let fill = if delta >= 8 {
438                    '█'
439                } else {
440                    FRACTION_BLOCKS[delta]
441                };
442                let fill_text = fill.to_string().repeat(bar_width);
443                let centered_fill = center_text(&fill_text, col_width);
444                self.styled(centered_fill, Style::new().fg(color));
445            }
446
447            self.commands.push(Command::EndContainer);
448            self.last_text_idx = None;
449        }
450    }
451
452    fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize, bar_gap: u16) {
453        self.interaction_count += 1;
454        self.commands.push(Command::BeginContainer {
455            direction: Direction::Row,
456            gap: bar_gap as u32,
457            align: Align::Start,
458            align_self: None,
459            justify: Justify::Start,
460            border: None,
461            border_sides: BorderSides::all(),
462            border_style: Style::new().fg(self.theme.border),
463            bg_color: None,
464            padding: Padding::default(),
465            margin: Margin::default(),
466            constraints: Constraints::default(),
467            title: None,
468            grow: 0,
469            group_name: None,
470        });
471        for bar in bars {
472            self.styled(
473                Self::center_and_truncate_text(&bar.label, col_width),
474                Style::new().fg(self.theme.text),
475            );
476        }
477        self.commands.push(Command::EndContainer);
478        self.last_text_idx = None;
479    }
480
481    /// Render a grouped bar chart.
482    ///
483    /// Each group contains multiple bars rendered side by side. Useful for
484    /// comparing categories across groups (e.g., quarterly revenue by product).
485    ///
486    /// # Example
487    /// ```ignore
488    /// # slt::run(|ui: &mut slt::Context| {
489    /// use slt::{Bar, BarGroup, Color};
490    /// let groups = vec![
491    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
492    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
493    /// ];
494    /// ui.bar_chart_grouped(&groups, 40);
495    /// # });
496    /// ```
497    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
498        self.bar_chart_grouped_with(groups, |_| {}, max_width)
499    }
500
501    pub fn bar_chart_grouped_with(
502        &mut self,
503        groups: &[BarGroup],
504        configure: impl FnOnce(&mut BarChartConfig),
505        max_size: u32,
506    ) -> Response {
507        if groups.is_empty() {
508            return Response::none();
509        }
510
511        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
512        if all_bars.is_empty() {
513            return Response::none();
514        }
515
516        let mut config = BarChartConfig::default();
517        configure(&mut config);
518
519        let auto_max = all_bars
520            .iter()
521            .map(|bar| bar.value)
522            .fold(f64::NEG_INFINITY, f64::max);
523        let max_value = config.max_value.unwrap_or(auto_max);
524        let denom = if max_value > 0.0 { max_value } else { 1.0 };
525
526        match config.direction {
527            BarDirection::Horizontal => {
528                self.render_grouped_horizontal_bars(groups, max_size, denom, &config)
529            }
530            BarDirection::Vertical => {
531                self.render_grouped_vertical_bars(groups, max_size, denom, &config)
532            }
533        }
534
535        Response::none()
536    }
537
538    fn render_grouped_horizontal_bars(
539        &mut self,
540        groups: &[BarGroup],
541        max_width: u32,
542        denom: f64,
543        config: &BarChartConfig,
544    ) {
545        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
546        let max_label_width = all_bars
547            .iter()
548            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
549            .max()
550            .unwrap_or(0);
551
552        self.interaction_count += 1;
553        self.commands.push(Command::BeginContainer {
554            direction: Direction::Column,
555            gap: config.group_gap as u32,
556            align: Align::Start,
557            align_self: None,
558            justify: Justify::Start,
559            border: None,
560            border_sides: BorderSides::all(),
561            border_style: Style::new().fg(self.theme.border),
562            bg_color: None,
563            padding: Padding::default(),
564            margin: Margin::default(),
565            constraints: Constraints::default(),
566            title: None,
567            grow: 0,
568            group_name: None,
569        });
570
571        for group in groups {
572            self.interaction_count += 1;
573            self.commands.push(Command::BeginContainer {
574                direction: Direction::Column,
575                gap: config.bar_gap as u32,
576                align: Align::Start,
577                align_self: None,
578                justify: Justify::Start,
579                border: None,
580                border_sides: BorderSides::all(),
581                border_style: Style::new().fg(self.theme.border),
582                bg_color: None,
583                padding: Padding::default(),
584                margin: Margin::default(),
585                constraints: Constraints::default(),
586                title: None,
587                grow: 0,
588                group_name: None,
589            });
590
591            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
592
593            for bar in &group.bars {
594                let label_width = UnicodeWidthStr::width(bar.label.as_str());
595                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
596                let normalized = (bar.value / denom).clamp(0.0, 1.0);
597                let bar_text = Self::horizontal_bar_text(normalized, max_width);
598
599                self.interaction_count += 1;
600                self.commands.push(Command::BeginContainer {
601                    direction: Direction::Row,
602                    gap: 1,
603                    align: Align::Start,
604                    align_self: None,
605                    justify: Justify::Start,
606                    border: None,
607                    border_sides: BorderSides::all(),
608                    border_style: Style::new().fg(self.theme.border),
609                    bg_color: None,
610                    padding: Padding::default(),
611                    margin: Margin::default(),
612                    constraints: Constraints::default(),
613                    title: None,
614                    grow: 0,
615                    group_name: None,
616                });
617                self.styled(
618                    format!("  {}{label_padding}", bar.label),
619                    Style::new().fg(self.theme.text),
620                );
621                self.styled(
622                    bar_text,
623                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
624                );
625                self.styled(
626                    Self::bar_display_value(bar),
627                    bar.value_style
628                        .unwrap_or(Style::new().fg(self.theme.text_dim)),
629                );
630                self.commands.push(Command::EndContainer);
631                self.last_text_idx = None;
632            }
633
634            self.commands.push(Command::EndContainer);
635            self.last_text_idx = None;
636        }
637
638        self.commands.push(Command::EndContainer);
639        self.last_text_idx = None;
640    }
641
642    fn render_grouped_vertical_bars(
643        &mut self,
644        groups: &[BarGroup],
645        max_height: u32,
646        denom: f64,
647        config: &BarChartConfig,
648    ) {
649        self.interaction_count += 1;
650        self.commands.push(Command::BeginContainer {
651            direction: Direction::Column,
652            gap: config.group_gap as u32,
653            align: Align::Start,
654            align_self: None,
655            justify: Justify::Start,
656            border: None,
657            border_sides: BorderSides::all(),
658            border_style: Style::new().fg(self.theme.border),
659            bg_color: None,
660            padding: Padding::default(),
661            margin: Margin::default(),
662            constraints: Constraints::default(),
663            title: None,
664            grow: 0,
665            group_name: None,
666        });
667
668        for group in groups {
669            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
670            if !group.bars.is_empty() {
671                self.render_vertical_styled_bars(
672                    &group.bars,
673                    max_height,
674                    denom,
675                    config.bar_width,
676                    config.bar_gap,
677                );
678            }
679        }
680
681        self.commands.push(Command::EndContainer);
682        self.last_text_idx = None;
683    }
684
685    fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
686        let filled = (normalized.clamp(0.0, 1.0) * max_width as f64).round() as usize;
687        "█".repeat(filled)
688    }
689
690    fn bar_display_value(bar: &Bar) -> String {
691        bar.text_value
692            .clone()
693            .unwrap_or_else(|| format_compact_number(bar.value))
694    }
695
696    fn center_and_truncate_text(text: &str, width: usize) -> String {
697        if width == 0 {
698            return String::new();
699        }
700
701        let mut out = String::new();
702        let mut used = 0usize;
703        for ch in text.chars() {
704            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
705            if used + cw > width {
706                break;
707            }
708            out.push(ch);
709            used += cw;
710        }
711        center_text(&out, width)
712    }
713
714    /// Render a single-line sparkline from numeric data.
715    ///
716    /// Uses the last `width` points (or fewer if the data is shorter) and maps
717    /// each point to one of `▁▂▃▄▅▆▇█`.
718    ///
719    /// # Example
720    ///
721    /// ```no_run
722    /// # slt::run(|ui: &mut slt::Context| {
723    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
724    /// ui.sparkline(&samples, 16);
725    /// # });
726    /// ```
727    ///
728    /// For per-point colors and missing values, see [`sparkline_styled`](Self::sparkline_styled).
729    pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
730        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
731
732        let w = width as usize;
733        if data.is_empty() || w == 0 {
734            return Response::none();
735        }
736
737        let points: Vec<f64> = if data.len() >= w {
738            data[data.len() - w..].to_vec()
739        } else if data.len() == 1 {
740            vec![data[0]; w]
741        } else {
742            (0..w)
743                .map(|i| {
744                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
745                    let idx = t.floor() as usize;
746                    let frac = t - idx as f64;
747                    if idx + 1 < data.len() {
748                        data[idx] * (1.0 - frac) + data[idx + 1] * frac
749                    } else {
750                        data[idx.min(data.len() - 1)]
751                    }
752                })
753                .collect()
754        };
755
756        let min = points.iter().copied().fold(f64::INFINITY, f64::min);
757        let max = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
758        let range = max - min;
759
760        let line: String = points
761            .iter()
762            .map(|&value| {
763                let normalized = if range == 0.0 {
764                    0.5
765                } else {
766                    (value - min) / range
767                };
768                let idx = (normalized * 7.0).round() as usize;
769                BLOCKS[idx.min(7)]
770            })
771            .collect();
772
773        self.styled(line, Style::new().fg(self.theme.primary));
774        Response::none()
775    }
776
777    /// Render a sparkline with per-point colors.
778    ///
779    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
780    /// Use `f64::NAN` for absent values (rendered as spaces).
781    ///
782    /// # Example
783    /// ```ignore
784    /// # slt::run(|ui: &mut slt::Context| {
785    /// use slt::Color;
786    /// let data: Vec<(f64, Option<Color>)> = vec![
787    ///     (12.0, Some(Color::Green)),
788    ///     (9.0, Some(Color::Red)),
789    ///     (14.0, Some(Color::Green)),
790    ///     (f64::NAN, None),
791    ///     (18.0, Some(Color::Cyan)),
792    /// ];
793    /// ui.sparkline_styled(&data, 16);
794    /// # });
795    /// ```
796    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
797        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
798
799        let w = width as usize;
800        if data.is_empty() || w == 0 {
801            return Response::none();
802        }
803
804        let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
805            data[data.len() - w..].to_vec()
806        } else if data.len() == 1 {
807            vec![data[0]; w]
808        } else {
809            (0..w)
810                .map(|i| {
811                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
812                    let idx = t.floor() as usize;
813                    let frac = t - idx as f64;
814                    let nearest = if frac < 0.5 {
815                        idx
816                    } else {
817                        (idx + 1).min(data.len() - 1)
818                    };
819                    let color = data[nearest].1;
820                    let (v1, _) = data[idx];
821                    let (v2, _) = data[(idx + 1).min(data.len() - 1)];
822                    let value = if v1.is_nan() || v2.is_nan() {
823                        if frac < 0.5 {
824                            v1
825                        } else {
826                            v2
827                        }
828                    } else {
829                        v1 * (1.0 - frac) + v2 * frac
830                    };
831                    (value, color)
832                })
833                .collect()
834        };
835
836        let mut finite_values = window
837            .iter()
838            .map(|(value, _)| *value)
839            .filter(|value| !value.is_nan());
840        let Some(first) = finite_values.next() else {
841            self.styled(
842                " ".repeat(window.len()),
843                Style::new().fg(self.theme.text_dim),
844            );
845            return Response::none();
846        };
847
848        let mut min = first;
849        let mut max = first;
850        for value in finite_values {
851            min = f64::min(min, value);
852            max = f64::max(max, value);
853        }
854        let range = max - min;
855
856        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
857        for (value, color) in &window {
858            if value.is_nan() {
859                cells.push((' ', self.theme.text_dim));
860                continue;
861            }
862
863            let normalized = if range == 0.0 {
864                0.5
865            } else {
866                ((*value - min) / range).clamp(0.0, 1.0)
867            };
868            let idx = (normalized * 7.0).round() as usize;
869            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
870        }
871
872        self.interaction_count += 1;
873        self.commands.push(Command::BeginContainer {
874            direction: Direction::Row,
875            gap: 0,
876            align: Align::Start,
877            align_self: None,
878            justify: Justify::Start,
879            border: None,
880            border_sides: BorderSides::all(),
881            border_style: Style::new().fg(self.theme.border),
882            bg_color: None,
883            padding: Padding::default(),
884            margin: Margin::default(),
885            constraints: Constraints::default(),
886            title: None,
887            grow: 0,
888            group_name: None,
889        });
890
891        let mut seg = String::new();
892        let mut seg_color = cells[0].1;
893        for (ch, color) in cells {
894            if color != seg_color {
895                self.styled(seg, Style::new().fg(seg_color));
896                seg = String::new();
897                seg_color = color;
898            }
899            seg.push(ch);
900        }
901        if !seg.is_empty() {
902            self.styled(seg, Style::new().fg(seg_color));
903        }
904
905        self.commands.push(Command::EndContainer);
906        self.last_text_idx = None;
907
908        Response::none()
909    }
910
911    /// Render a multi-row line chart using braille characters.
912    ///
913    /// `width` and `height` are terminal cell dimensions. Internally this uses
914    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
915    ///
916    /// # Example
917    ///
918    /// ```ignore
919    /// # slt::run(|ui: &mut slt::Context| {
920    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
921    /// ui.line_chart(&data, 40, 8);
922    /// # });
923    /// ```
924    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
925        self.line_chart_colored(data, width, height, self.theme.primary)
926    }
927
928    /// Render a multi-row line chart using a custom color.
929    pub fn line_chart_colored(
930        &mut self,
931        data: &[f64],
932        width: u32,
933        height: u32,
934        color: Color,
935    ) -> Response {
936        self.render_line_chart_internal(data, width, height, color, false)
937    }
938
939    /// Render a multi-row area chart using the primary theme color.
940    pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
941        self.area_chart_colored(data, width, height, self.theme.primary)
942    }
943
944    /// Render a multi-row area chart using a custom color.
945    pub fn area_chart_colored(
946        &mut self,
947        data: &[f64],
948        width: u32,
949        height: u32,
950        color: Color,
951    ) -> Response {
952        self.render_line_chart_internal(data, width, height, color, true)
953    }
954
955    fn render_line_chart_internal(
956        &mut self,
957        data: &[f64],
958        width: u32,
959        height: u32,
960        color: Color,
961        fill: bool,
962    ) -> Response {
963        if data.is_empty() || width == 0 || height == 0 {
964            return Response::none();
965        }
966
967        let cols = width as usize;
968        let rows = height as usize;
969        let px_w = cols * 2;
970        let px_h = rows * 4;
971
972        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
973        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
974        let range = if (max - min).abs() < f64::EPSILON {
975            1.0
976        } else {
977            max - min
978        };
979
980        let points: Vec<usize> = (0..px_w)
981            .map(|px| {
982                let data_idx = if px_w <= 1 {
983                    0.0
984                } else {
985                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
986                };
987                let idx = data_idx.floor() as usize;
988                let frac = data_idx - idx as f64;
989                let value = if idx + 1 < data.len() {
990                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
991                } else {
992                    data[idx.min(data.len() - 1)]
993                };
994
995                let normalized = (value - min) / range;
996                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
997                py.min(px_h - 1)
998            })
999            .collect();
1000
1001        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
1002        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
1003
1004        let mut grid = vec![vec![0u32; cols]; rows];
1005
1006        for i in 0..points.len() {
1007            let px = i;
1008            let py = points[i];
1009            let char_col = px / 2;
1010            let char_row = py / 4;
1011            let sub_col = px % 2;
1012            let sub_row = py % 4;
1013
1014            if char_col < cols && char_row < rows {
1015                grid[char_row][char_col] |= if sub_col == 0 {
1016                    LEFT_BITS[sub_row]
1017                } else {
1018                    RIGHT_BITS[sub_row]
1019                };
1020            }
1021
1022            if i + 1 < points.len() {
1023                let py_next = points[i + 1];
1024                let (y_start, y_end) = if py <= py_next {
1025                    (py, py_next)
1026                } else {
1027                    (py_next, py)
1028                };
1029                for y in y_start..=y_end {
1030                    let cell_row = y / 4;
1031                    let sub_y = y % 4;
1032                    if char_col < cols && cell_row < rows {
1033                        grid[cell_row][char_col] |= if sub_col == 0 {
1034                            LEFT_BITS[sub_y]
1035                        } else {
1036                            RIGHT_BITS[sub_y]
1037                        };
1038                    }
1039                }
1040            }
1041
1042            if fill {
1043                for y in py..px_h {
1044                    let cell_row = y / 4;
1045                    let sub_y = y % 4;
1046                    if char_col < cols && cell_row < rows {
1047                        grid[cell_row][char_col] |= if sub_col == 0 {
1048                            LEFT_BITS[sub_y]
1049                        } else {
1050                            RIGHT_BITS[sub_y]
1051                        };
1052                    }
1053                }
1054            }
1055        }
1056
1057        let style = Style::new().fg(color);
1058        for row in grid {
1059            let line: String = row
1060                .iter()
1061                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1062                .collect();
1063            self.styled(line, style);
1064        }
1065
1066        Response::none()
1067    }
1068
1069    /// Render an OHLC candlestick chart.
1070    pub fn candlestick(
1071        &mut self,
1072        candles: &[Candle],
1073        up_color: Color,
1074        down_color: Color,
1075    ) -> Response {
1076        if candles.is_empty() {
1077            return Response::none();
1078        }
1079
1080        let candles = candles.to_vec();
1081        self.container().grow(1).draw(move |buf, rect| {
1082            let w = rect.width as usize;
1083            let h = rect.height as usize;
1084            if w < 2 || h < 2 {
1085                return;
1086            }
1087
1088            let mut lo = f64::INFINITY;
1089            let mut hi = f64::NEG_INFINITY;
1090            for c in &candles {
1091                if c.low.is_finite() {
1092                    lo = lo.min(c.low);
1093                }
1094                if c.high.is_finite() {
1095                    hi = hi.max(c.high);
1096                }
1097            }
1098
1099            if !lo.is_finite() || !hi.is_finite() {
1100                return;
1101            }
1102
1103            let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1104            let map_y = |v: f64| -> usize {
1105                let t = ((v - lo) / range).clamp(0.0, 1.0);
1106                ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1107            };
1108
1109            for (i, c) in candles.iter().enumerate() {
1110                if !c.open.is_finite()
1111                    || !c.high.is_finite()
1112                    || !c.low.is_finite()
1113                    || !c.close.is_finite()
1114                {
1115                    continue;
1116                }
1117
1118                let x0 = i * w / candles.len();
1119                let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1120                if x0 >= w {
1121                    continue;
1122                }
1123                let xm = (x0 + x1) / 2;
1124                let color = if c.close >= c.open {
1125                    up_color
1126                } else {
1127                    down_color
1128                };
1129
1130                let wt = map_y(c.high);
1131                let wb = map_y(c.low);
1132                for row in wt..=wb.min(h - 1) {
1133                    buf.set_char(
1134                        rect.x + xm as u32,
1135                        rect.y + row as u32,
1136                        '│',
1137                        Style::new().fg(color),
1138                    );
1139                }
1140
1141                let bt = map_y(c.open.max(c.close));
1142                let bb = map_y(c.open.min(c.close));
1143                for row in bt..=bb.min(h - 1) {
1144                    for col in x0..=x1.min(w - 1) {
1145                        buf.set_char(
1146                            rect.x + col as u32,
1147                            rect.y + row as u32,
1148                            '█',
1149                            Style::new().fg(color),
1150                        );
1151                    }
1152                }
1153            }
1154        });
1155
1156        Response::none()
1157    }
1158
1159    /// Render a heatmap from a 2D data grid.
1160    ///
1161    /// Each cell maps to a block character with color intensity:
1162    /// low values -> dim/dark, high values -> bright/saturated.
1163    ///
1164    /// # Arguments
1165    /// * `data` - Row-major 2D grid (outer = rows, inner = columns)
1166    /// * `width` - Widget width in terminal cells
1167    /// * `height` - Widget height in terminal cells
1168    /// * `low_color` - Color for minimum values
1169    /// * `high_color` - Color for maximum values
1170    pub fn heatmap(
1171        &mut self,
1172        data: &[Vec<f64>],
1173        width: u32,
1174        height: u32,
1175        low_color: Color,
1176        high_color: Color,
1177    ) -> Response {
1178        fn blend_color(a: Color, b: Color, t: f64) -> Color {
1179            let t = t.clamp(0.0, 1.0);
1180            match (a, b) {
1181                (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1182                    (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1183                    (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1184                    (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1185                ),
1186                _ => {
1187                    if t > 0.5 {
1188                        b
1189                    } else {
1190                        a
1191                    }
1192                }
1193            }
1194        }
1195
1196        if data.is_empty() || width == 0 || height == 0 {
1197            return Response::none();
1198        }
1199
1200        let data_rows = data.len();
1201        let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1202        if max_data_cols == 0 {
1203            return Response::none();
1204        }
1205
1206        let mut min_value = f64::INFINITY;
1207        let mut max_value = f64::NEG_INFINITY;
1208        for row in data {
1209            for value in row {
1210                if value.is_finite() {
1211                    min_value = min_value.min(*value);
1212                    max_value = max_value.max(*value);
1213                }
1214            }
1215        }
1216
1217        if !min_value.is_finite() || !max_value.is_finite() {
1218            return Response::none();
1219        }
1220
1221        let range = max_value - min_value;
1222        let zero_range = range.abs() < f64::EPSILON;
1223        let cols = width as usize;
1224        let rows = height as usize;
1225
1226        for row_idx in 0..rows {
1227            let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1228            let source_row = &data[data_row_idx];
1229            let source_cols = source_row.len();
1230
1231            self.interaction_count += 1;
1232            self.commands.push(Command::BeginContainer {
1233                direction: Direction::Row,
1234                gap: 0,
1235                align: Align::Start,
1236                align_self: None,
1237                justify: Justify::Start,
1238                border: None,
1239                border_sides: BorderSides::all(),
1240                border_style: Style::new().fg(self.theme.border),
1241                bg_color: None,
1242                padding: Padding::default(),
1243                margin: Margin::default(),
1244                constraints: Constraints::default(),
1245                title: None,
1246                grow: 0,
1247                group_name: None,
1248            });
1249
1250            let mut segment = String::new();
1251            let mut segment_color: Option<Color> = None;
1252
1253            for col_idx in 0..cols {
1254                let normalized = if source_cols == 0 {
1255                    0.0
1256                } else {
1257                    let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1258                    let value = source_row[data_col_idx];
1259
1260                    if !value.is_finite() {
1261                        0.0
1262                    } else if zero_range {
1263                        0.5
1264                    } else {
1265                        ((value - min_value) / range).clamp(0.0, 1.0)
1266                    }
1267                };
1268
1269                let color = blend_color(low_color, high_color, normalized);
1270
1271                match segment_color {
1272                    Some(current) if current == color => {
1273                        segment.push('█');
1274                    }
1275                    Some(current) => {
1276                        self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1277                        segment.push('█');
1278                        segment_color = Some(color);
1279                    }
1280                    None => {
1281                        segment.push('█');
1282                        segment_color = Some(color);
1283                    }
1284                }
1285            }
1286
1287            if let Some(color) = segment_color {
1288                self.styled(segment, Style::new().fg(color));
1289            }
1290
1291            self.commands.push(Command::EndContainer);
1292            self.last_text_idx = None;
1293        }
1294
1295        Response::none()
1296    }
1297
1298    /// Render a braille drawing canvas.
1299    ///
1300    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
1301    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
1302    /// `height*4` pixel resolution.
1303    ///
1304    /// # Example
1305    ///
1306    /// ```ignore
1307    /// # slt::run(|ui: &mut slt::Context| {
1308    /// ui.canvas(40, 10, |cv| {
1309    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
1310    ///     cv.circle(40, 20, 15);
1311    /// });
1312    /// # });
1313    /// ```
1314    pub fn canvas(
1315        &mut self,
1316        width: u32,
1317        height: u32,
1318        draw: impl FnOnce(&mut CanvasContext),
1319    ) -> Response {
1320        if width == 0 || height == 0 {
1321            return Response::none();
1322        }
1323
1324        let mut canvas = CanvasContext::new(width as usize, height as usize);
1325        draw(&mut canvas);
1326
1327        for segments in canvas.render() {
1328            self.interaction_count += 1;
1329            self.commands.push(Command::BeginContainer {
1330                direction: Direction::Row,
1331                gap: 0,
1332                align: Align::Start,
1333                align_self: None,
1334                justify: Justify::Start,
1335                border: None,
1336                border_sides: BorderSides::all(),
1337                border_style: Style::new(),
1338                bg_color: None,
1339                padding: Padding::default(),
1340                margin: Margin::default(),
1341                constraints: Constraints::default(),
1342                title: None,
1343                grow: 0,
1344                group_name: None,
1345            });
1346            for (text, color) in segments {
1347                let c = if color == Color::Reset {
1348                    self.theme.primary
1349                } else {
1350                    color
1351                };
1352                self.styled(text, Style::new().fg(c));
1353            }
1354            self.commands.push(Command::EndContainer);
1355            self.last_text_idx = None;
1356        }
1357
1358        Response::none()
1359    }
1360
1361    /// Render a multi-series chart with axes, legend, and auto-scaling.
1362    ///
1363    /// `width` and `height` must be non-zero. For dynamic sizing, read terminal
1364    /// dimensions first (for example via `ui.width()` / `ui.height()`) and pass
1365    /// the computed values to this method.
1366    pub fn chart(
1367        &mut self,
1368        configure: impl FnOnce(&mut ChartBuilder),
1369        width: u32,
1370        height: u32,
1371    ) -> Response {
1372        if width == 0 || height == 0 {
1373            return Response::none();
1374        }
1375
1376        let axis_style = Style::new().fg(self.theme.text_dim);
1377        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1378        configure(&mut builder);
1379
1380        let config = builder.build();
1381        let rows = render_chart(&config);
1382
1383        for row in rows {
1384            self.interaction_count += 1;
1385            self.commands.push(Command::BeginContainer {
1386                direction: Direction::Row,
1387                gap: 0,
1388                align: Align::Start,
1389                align_self: None,
1390                justify: Justify::Start,
1391                border: None,
1392                border_sides: BorderSides::all(),
1393                border_style: Style::new().fg(self.theme.border),
1394                bg_color: None,
1395                padding: Padding::default(),
1396                margin: Margin::default(),
1397                constraints: Constraints::default(),
1398                title: None,
1399                grow: 0,
1400                group_name: None,
1401            });
1402            for (text, style) in row.segments {
1403                self.styled(text, style);
1404            }
1405            self.commands.push(Command::EndContainer);
1406            self.last_text_idx = None;
1407        }
1408
1409        Response::none()
1410    }
1411
1412    /// Renders a scatter plot.
1413    ///
1414    /// Each point is a (x, y) tuple. Uses braille markers.
1415    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1416        self.chart(
1417            |c| {
1418                c.scatter(data);
1419                c.grid(true);
1420            },
1421            width,
1422            height,
1423        )
1424    }
1425
1426    /// Render a histogram from raw data with auto-binning.
1427    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1428        self.histogram_with(data, |_| {}, width, height)
1429    }
1430
1431    /// Render a histogram with configuration options.
1432    pub fn histogram_with(
1433        &mut self,
1434        data: &[f64],
1435        configure: impl FnOnce(&mut HistogramBuilder),
1436        width: u32,
1437        height: u32,
1438    ) -> Response {
1439        if width == 0 || height == 0 {
1440            return Response::none();
1441        }
1442
1443        let mut options = HistogramBuilder::default();
1444        configure(&mut options);
1445        let axis_style = Style::new().fg(self.theme.text_dim);
1446        let config = build_histogram_config(data, &options, width, height, axis_style);
1447        let rows = render_chart(&config);
1448
1449        for row in rows {
1450            self.interaction_count += 1;
1451            self.commands.push(Command::BeginContainer {
1452                direction: Direction::Row,
1453                gap: 0,
1454                align: Align::Start,
1455                align_self: None,
1456                justify: Justify::Start,
1457                border: None,
1458                border_sides: BorderSides::all(),
1459                border_style: Style::new().fg(self.theme.border),
1460                bg_color: None,
1461                padding: Padding::default(),
1462                margin: Margin::default(),
1463                constraints: Constraints::default(),
1464                title: None,
1465                grow: 0,
1466                group_name: None,
1467            });
1468            for (text, style) in row.segments {
1469                self.styled(text, style);
1470            }
1471            self.commands.push(Command::EndContainer);
1472            self.last_text_idx = None;
1473        }
1474
1475        Response::none()
1476    }
1477}