Skip to main content

slt/context/
widgets_viz.rs

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