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_len = (normalized * max_width as f64).round() as usize;
63            let bar = "█".repeat(bar_len);
64
65            self.interaction_count += 1;
66            self.commands.push(Command::BeginContainer {
67                direction: Direction::Row,
68                gap: 1,
69                align: Align::Start,
70                justify: Justify::Start,
71                border: None,
72                border_sides: BorderSides::all(),
73                border_style: Style::new().fg(self.theme.border),
74                bg_color: None,
75                padding: Padding::default(),
76                margin: Margin::default(),
77                constraints: Constraints::default(),
78                title: None,
79                grow: 0,
80                group_name: None,
81            });
82            self.styled(
83                format!("{label}{label_padding}"),
84                Style::new().fg(self.theme.text),
85            );
86            self.styled(bar, Style::new().fg(self.theme.primary));
87            self.styled(
88                format_compact_number(*value),
89                Style::new().fg(self.theme.text_dim),
90            );
91            self.commands.push(Command::EndContainer);
92            self.last_text_idx = None;
93        }
94
95        self.commands.push(Command::EndContainer);
96        self.last_text_idx = None;
97
98        Response::none()
99    }
100
101    /// Render a styled bar chart with per-bar colors, grouping, and direction control.
102    ///
103    /// # Example
104    /// ```ignore
105    /// # slt::run(|ui: &mut slt::Context| {
106    /// use slt::{Bar, Color};
107    /// let bars = vec![
108    ///     Bar::new("Q1", 32.0).color(Color::Cyan),
109    ///     Bar::new("Q2", 46.0).color(Color::Green),
110    ///     Bar::new("Q3", 28.0).color(Color::Yellow),
111    ///     Bar::new("Q4", 54.0).color(Color::Red),
112    /// ];
113    /// ui.bar_chart_styled(&bars, 30, slt::BarDirection::Horizontal);
114    /// # });
115    /// ```
116    pub fn bar_chart_styled(
117        &mut self,
118        bars: &[Bar],
119        max_width: u32,
120        direction: BarDirection,
121    ) -> Response {
122        if bars.is_empty() {
123            return Response::none();
124        }
125
126        let max_value = bars
127            .iter()
128            .map(|bar| bar.value)
129            .fold(f64::NEG_INFINITY, f64::max);
130        let denom = if max_value > 0.0 { max_value } else { 1.0 };
131
132        match direction {
133            BarDirection::Horizontal => self.render_horizontal_styled_bars(bars, max_width, denom),
134            BarDirection::Vertical => self.render_vertical_styled_bars(bars, max_width, denom),
135        }
136
137        Response::none()
138    }
139
140    fn render_horizontal_styled_bars(&mut self, bars: &[Bar], max_width: u32, denom: f64) {
141        let max_label_width = bars
142            .iter()
143            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
144            .max()
145            .unwrap_or(0);
146
147        self.interaction_count += 1;
148        self.commands.push(Command::BeginContainer {
149            direction: Direction::Column,
150            gap: 0,
151            align: Align::Start,
152            justify: Justify::Start,
153            border: None,
154            border_sides: BorderSides::all(),
155            border_style: Style::new().fg(self.theme.border),
156            bg_color: None,
157            padding: Padding::default(),
158            margin: Margin::default(),
159            constraints: Constraints::default(),
160            title: None,
161            grow: 0,
162            group_name: None,
163        });
164
165        for bar in bars {
166            self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
167        }
168
169        self.commands.push(Command::EndContainer);
170        self.last_text_idx = None;
171    }
172
173    fn render_horizontal_styled_bar_row(
174        &mut self,
175        bar: &Bar,
176        max_label_width: usize,
177        max_width: u32,
178        denom: f64,
179    ) {
180        let label_width = UnicodeWidthStr::width(bar.label.as_str());
181        let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
182        let normalized = (bar.value / denom).clamp(0.0, 1.0);
183        let bar_len = (normalized * max_width as f64).round() as usize;
184        let bar_text = "█".repeat(bar_len);
185        let color = bar.color.unwrap_or(self.theme.primary);
186
187        self.interaction_count += 1;
188        self.commands.push(Command::BeginContainer {
189            direction: Direction::Row,
190            gap: 1,
191            align: Align::Start,
192            justify: Justify::Start,
193            border: None,
194            border_sides: BorderSides::all(),
195            border_style: Style::new().fg(self.theme.border),
196            bg_color: None,
197            padding: Padding::default(),
198            margin: Margin::default(),
199            constraints: Constraints::default(),
200            title: None,
201            grow: 0,
202            group_name: None,
203        });
204        self.styled(
205            format!("{}{label_padding}", bar.label),
206            Style::new().fg(self.theme.text),
207        );
208        self.styled(bar_text, Style::new().fg(color));
209        self.styled(
210            format_compact_number(bar.value),
211            Style::new().fg(self.theme.text_dim),
212        );
213        self.commands.push(Command::EndContainer);
214        self.last_text_idx = None;
215    }
216
217    fn render_vertical_styled_bars(&mut self, bars: &[Bar], max_width: u32, denom: f64) {
218        let chart_height = max_width.max(1) as usize;
219        let value_labels: Vec<String> = bars
220            .iter()
221            .map(|bar| format_compact_number(bar.value))
222            .collect();
223        let col_width = bars
224            .iter()
225            .zip(value_labels.iter())
226            .map(|(bar, value)| {
227                UnicodeWidthStr::width(bar.label.as_str())
228                    .max(UnicodeWidthStr::width(value.as_str()))
229                    .max(1)
230            })
231            .max()
232            .unwrap_or(1);
233        let bar_units: Vec<usize> = bars
234            .iter()
235            .map(|bar| {
236                ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
237            })
238            .collect();
239
240        self.interaction_count += 1;
241        self.commands.push(Command::BeginContainer {
242            direction: Direction::Column,
243            gap: 0,
244            align: Align::Start,
245            justify: Justify::Start,
246            border: None,
247            border_sides: BorderSides::all(),
248            border_style: Style::new().fg(self.theme.border),
249            bg_color: None,
250            padding: Padding::default(),
251            margin: Margin::default(),
252            constraints: Constraints::default(),
253            title: None,
254            grow: 0,
255            group_name: None,
256        });
257
258        self.render_vertical_bar_values(&value_labels, col_width);
259        self.render_vertical_bar_body(bars, &bar_units, chart_height, col_width);
260        self.render_vertical_bar_labels(bars, col_width);
261
262        self.commands.push(Command::EndContainer);
263        self.last_text_idx = None;
264    }
265
266    fn render_vertical_bar_values(&mut self, value_labels: &[String], col_width: usize) {
267        self.interaction_count += 1;
268        self.commands.push(Command::BeginContainer {
269            direction: Direction::Row,
270            gap: 1,
271            align: Align::Start,
272            justify: Justify::Start,
273            border: None,
274            border_sides: BorderSides::all(),
275            border_style: Style::new().fg(self.theme.border),
276            bg_color: None,
277            padding: Padding::default(),
278            margin: Margin::default(),
279            constraints: Constraints::default(),
280            title: None,
281            grow: 0,
282            group_name: None,
283        });
284        for value in value_labels {
285            self.styled(
286                center_text(value, col_width),
287                Style::new().fg(self.theme.text_dim),
288            );
289        }
290        self.commands.push(Command::EndContainer);
291        self.last_text_idx = None;
292    }
293
294    fn render_vertical_bar_body(
295        &mut self,
296        bars: &[Bar],
297        bar_units: &[usize],
298        chart_height: usize,
299        col_width: usize,
300    ) {
301        const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
302
303        for row in (0..chart_height).rev() {
304            self.interaction_count += 1;
305            self.commands.push(Command::BeginContainer {
306                direction: Direction::Row,
307                gap: 1,
308                align: Align::Start,
309                justify: Justify::Start,
310                border: None,
311                border_sides: BorderSides::all(),
312                border_style: Style::new().fg(self.theme.border),
313                bg_color: None,
314                padding: Padding::default(),
315                margin: Margin::default(),
316                constraints: Constraints::default(),
317                title: None,
318                grow: 0,
319                group_name: None,
320            });
321
322            let row_base = row * 8;
323            for (bar, units) in bars.iter().zip(bar_units.iter()) {
324                let fill = if *units <= row_base {
325                    ' '
326                } else {
327                    let delta = *units - row_base;
328                    if delta >= 8 {
329                        '█'
330                    } else {
331                        FRACTION_BLOCKS[delta]
332                    }
333                };
334                self.styled(
335                    center_text(&fill.to_string(), col_width),
336                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
337                );
338            }
339
340            self.commands.push(Command::EndContainer);
341            self.last_text_idx = None;
342        }
343    }
344
345    fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize) {
346        self.interaction_count += 1;
347        self.commands.push(Command::BeginContainer {
348            direction: Direction::Row,
349            gap: 1,
350            align: Align::Start,
351            justify: Justify::Start,
352            border: None,
353            border_sides: BorderSides::all(),
354            border_style: Style::new().fg(self.theme.border),
355            bg_color: None,
356            padding: Padding::default(),
357            margin: Margin::default(),
358            constraints: Constraints::default(),
359            title: None,
360            grow: 0,
361            group_name: None,
362        });
363        for bar in bars {
364            self.styled(
365                center_text(&bar.label, col_width),
366                Style::new().fg(self.theme.text),
367            );
368        }
369        self.commands.push(Command::EndContainer);
370        self.last_text_idx = None;
371    }
372
373    /// Render a grouped bar chart.
374    ///
375    /// Each group contains multiple bars rendered side by side. Useful for
376    /// comparing categories across groups (e.g., quarterly revenue by product).
377    ///
378    /// # Example
379    /// ```ignore
380    /// # slt::run(|ui: &mut slt::Context| {
381    /// use slt::{Bar, BarGroup, Color};
382    /// let groups = vec![
383    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
384    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
385    /// ];
386    /// ui.bar_chart_grouped(&groups, 40);
387    /// # });
388    /// ```
389    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
390        if groups.is_empty() {
391            return Response::none();
392        }
393
394        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
395        if all_bars.is_empty() {
396            return Response::none();
397        }
398
399        let max_label_width = all_bars
400            .iter()
401            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
402            .max()
403            .unwrap_or(0);
404        let max_value = all_bars
405            .iter()
406            .map(|bar| bar.value)
407            .fold(f64::NEG_INFINITY, f64::max);
408        let denom = if max_value > 0.0 { max_value } else { 1.0 };
409
410        self.interaction_count += 1;
411        self.commands.push(Command::BeginContainer {
412            direction: Direction::Column,
413            gap: 1,
414            align: Align::Start,
415            justify: Justify::Start,
416            border: None,
417            border_sides: BorderSides::all(),
418            border_style: Style::new().fg(self.theme.border),
419            bg_color: None,
420            padding: Padding::default(),
421            margin: Margin::default(),
422            constraints: Constraints::default(),
423            title: None,
424            grow: 0,
425            group_name: None,
426        });
427
428        for group in groups {
429            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
430
431            for bar in &group.bars {
432                let label_width = UnicodeWidthStr::width(bar.label.as_str());
433                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
434                let normalized = (bar.value / denom).clamp(0.0, 1.0);
435                let bar_len = (normalized * max_width as f64).round() as usize;
436                let bar_text = "█".repeat(bar_len);
437
438                self.interaction_count += 1;
439                self.commands.push(Command::BeginContainer {
440                    direction: Direction::Row,
441                    gap: 1,
442                    align: Align::Start,
443                    justify: Justify::Start,
444                    border: None,
445                    border_sides: BorderSides::all(),
446                    border_style: Style::new().fg(self.theme.border),
447                    bg_color: None,
448                    padding: Padding::default(),
449                    margin: Margin::default(),
450                    constraints: Constraints::default(),
451                    title: None,
452                    grow: 0,
453                    group_name: None,
454                });
455                self.styled(
456                    format!("  {}{label_padding}", bar.label),
457                    Style::new().fg(self.theme.text),
458                );
459                self.styled(
460                    bar_text,
461                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
462                );
463                self.styled(
464                    format_compact_number(bar.value),
465                    Style::new().fg(self.theme.text_dim),
466                );
467                self.commands.push(Command::EndContainer);
468                self.last_text_idx = None;
469            }
470        }
471
472        self.commands.push(Command::EndContainer);
473        self.last_text_idx = None;
474
475        Response::none()
476    }
477
478    /// Render a single-line sparkline from numeric data.
479    ///
480    /// Uses the last `width` points (or fewer if the data is shorter) and maps
481    /// each point to one of `▁▂▃▄▅▆▇█`.
482    ///
483    /// # Example
484    ///
485    /// ```ignore
486    /// # slt::run(|ui: &mut slt::Context| {
487    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
488    /// ui.sparkline(&samples, 16);
489    ///
490    /// For per-point colors and missing values, see [`sparkline_styled`].
491    /// # });
492    /// ```
493    pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
494        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
495
496        let w = width as usize;
497        let window = if data.len() > w {
498            &data[data.len() - w..]
499        } else {
500            data
501        };
502
503        if window.is_empty() {
504            return Response::none();
505        }
506
507        let min = window.iter().copied().fold(f64::INFINITY, f64::min);
508        let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
509        let range = max - min;
510
511        let line: String = window
512            .iter()
513            .map(|&value| {
514                let normalized = if range == 0.0 {
515                    0.5
516                } else {
517                    (value - min) / range
518                };
519                let idx = (normalized * 7.0).round() as usize;
520                BLOCKS[idx.min(7)]
521            })
522            .collect();
523
524        self.styled(line, Style::new().fg(self.theme.primary));
525        Response::none()
526    }
527
528    /// Render a sparkline with per-point colors.
529    ///
530    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
531    /// Use `f64::NAN` for absent values (rendered as spaces).
532    ///
533    /// # Example
534    /// ```ignore
535    /// # slt::run(|ui: &mut slt::Context| {
536    /// use slt::Color;
537    /// let data: Vec<(f64, Option<Color>)> = vec![
538    ///     (12.0, Some(Color::Green)),
539    ///     (9.0, Some(Color::Red)),
540    ///     (14.0, Some(Color::Green)),
541    ///     (f64::NAN, None),
542    ///     (18.0, Some(Color::Cyan)),
543    /// ];
544    /// ui.sparkline_styled(&data, 16);
545    /// # });
546    /// ```
547    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
548        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
549
550        let w = width as usize;
551        let window = if data.len() > w {
552            &data[data.len() - w..]
553        } else {
554            data
555        };
556
557        if window.is_empty() {
558            return Response::none();
559        }
560
561        let mut finite_values = window
562            .iter()
563            .map(|(value, _)| *value)
564            .filter(|value| !value.is_nan());
565        let Some(first) = finite_values.next() else {
566            self.styled(
567                " ".repeat(window.len()),
568                Style::new().fg(self.theme.text_dim),
569            );
570            return Response::none();
571        };
572
573        let mut min = first;
574        let mut max = first;
575        for value in finite_values {
576            min = f64::min(min, value);
577            max = f64::max(max, value);
578        }
579        let range = max - min;
580
581        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
582        for (value, color) in window {
583            if value.is_nan() {
584                cells.push((' ', self.theme.text_dim));
585                continue;
586            }
587
588            let normalized = if range == 0.0 {
589                0.5
590            } else {
591                ((*value - min) / range).clamp(0.0, 1.0)
592            };
593            let idx = (normalized * 7.0).round() as usize;
594            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
595        }
596
597        self.interaction_count += 1;
598        self.commands.push(Command::BeginContainer {
599            direction: Direction::Row,
600            gap: 0,
601            align: Align::Start,
602            justify: Justify::Start,
603            border: None,
604            border_sides: BorderSides::all(),
605            border_style: Style::new().fg(self.theme.border),
606            bg_color: None,
607            padding: Padding::default(),
608            margin: Margin::default(),
609            constraints: Constraints::default(),
610            title: None,
611            grow: 0,
612            group_name: None,
613        });
614
615        let mut seg = String::new();
616        let mut seg_color = cells[0].1;
617        for (ch, color) in cells {
618            if color != seg_color {
619                self.styled(seg, Style::new().fg(seg_color));
620                seg = String::new();
621                seg_color = color;
622            }
623            seg.push(ch);
624        }
625        if !seg.is_empty() {
626            self.styled(seg, Style::new().fg(seg_color));
627        }
628
629        self.commands.push(Command::EndContainer);
630        self.last_text_idx = None;
631
632        Response::none()
633    }
634
635    /// Render a multi-row line chart using braille characters.
636    ///
637    /// `width` and `height` are terminal cell dimensions. Internally this uses
638    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
639    ///
640    /// # Example
641    ///
642    /// ```ignore
643    /// # slt::run(|ui: &mut slt::Context| {
644    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
645    /// ui.line_chart(&data, 40, 8);
646    /// # });
647    /// ```
648    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
649        if data.is_empty() || width == 0 || height == 0 {
650            return Response::none();
651        }
652
653        let cols = width as usize;
654        let rows = height as usize;
655        let px_w = cols * 2;
656        let px_h = rows * 4;
657
658        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
659        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
660        let range = if (max - min).abs() < f64::EPSILON {
661            1.0
662        } else {
663            max - min
664        };
665
666        let points: Vec<usize> = (0..px_w)
667            .map(|px| {
668                let data_idx = if px_w <= 1 {
669                    0.0
670                } else {
671                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
672                };
673                let idx = data_idx.floor() as usize;
674                let frac = data_idx - idx as f64;
675                let value = if idx + 1 < data.len() {
676                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
677                } else {
678                    data[idx.min(data.len() - 1)]
679                };
680
681                let normalized = (value - min) / range;
682                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
683                py.min(px_h - 1)
684            })
685            .collect();
686
687        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
688        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
689
690        let mut grid = vec![vec![0u32; cols]; rows];
691
692        for i in 0..points.len() {
693            let px = i;
694            let py = points[i];
695            let char_col = px / 2;
696            let char_row = py / 4;
697            let sub_col = px % 2;
698            let sub_row = py % 4;
699
700            if char_col < cols && char_row < rows {
701                grid[char_row][char_col] |= if sub_col == 0 {
702                    LEFT_BITS[sub_row]
703                } else {
704                    RIGHT_BITS[sub_row]
705                };
706            }
707
708            if i + 1 < points.len() {
709                let py_next = points[i + 1];
710                let (y_start, y_end) = if py <= py_next {
711                    (py, py_next)
712                } else {
713                    (py_next, py)
714                };
715                for y in y_start..=y_end {
716                    let cell_row = y / 4;
717                    let sub_y = y % 4;
718                    if char_col < cols && cell_row < rows {
719                        grid[cell_row][char_col] |= if sub_col == 0 {
720                            LEFT_BITS[sub_y]
721                        } else {
722                            RIGHT_BITS[sub_y]
723                        };
724                    }
725                }
726            }
727        }
728
729        let style = Style::new().fg(self.theme.primary);
730        for row in grid {
731            let line: String = row
732                .iter()
733                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
734                .collect();
735            self.styled(line, style);
736        }
737
738        Response::none()
739    }
740
741    /// Render a braille drawing canvas.
742    ///
743    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
744    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
745    /// `height*4` pixel resolution.
746    ///
747    /// # Example
748    ///
749    /// ```ignore
750    /// # slt::run(|ui: &mut slt::Context| {
751    /// ui.canvas(40, 10, |cv| {
752    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
753    ///     cv.circle(40, 20, 15);
754    /// });
755    /// # });
756    /// ```
757    pub fn canvas(
758        &mut self,
759        width: u32,
760        height: u32,
761        draw: impl FnOnce(&mut CanvasContext),
762    ) -> Response {
763        if width == 0 || height == 0 {
764            return Response::none();
765        }
766
767        let mut canvas = CanvasContext::new(width as usize, height as usize);
768        draw(&mut canvas);
769
770        for segments in canvas.render() {
771            self.interaction_count += 1;
772            self.commands.push(Command::BeginContainer {
773                direction: Direction::Row,
774                gap: 0,
775                align: Align::Start,
776                justify: Justify::Start,
777                border: None,
778                border_sides: BorderSides::all(),
779                border_style: Style::new(),
780                bg_color: None,
781                padding: Padding::default(),
782                margin: Margin::default(),
783                constraints: Constraints::default(),
784                title: None,
785                grow: 0,
786                group_name: None,
787            });
788            for (text, color) in segments {
789                let c = if color == Color::Reset {
790                    self.theme.primary
791                } else {
792                    color
793                };
794                self.styled(text, Style::new().fg(c));
795            }
796            self.commands.push(Command::EndContainer);
797            self.last_text_idx = None;
798        }
799
800        Response::none()
801    }
802
803    /// Render a multi-series chart with axes, legend, and auto-scaling.
804    pub fn chart(
805        &mut self,
806        configure: impl FnOnce(&mut ChartBuilder),
807        width: u32,
808        height: u32,
809    ) -> Response {
810        if width == 0 || height == 0 {
811            return Response::none();
812        }
813
814        let axis_style = Style::new().fg(self.theme.text_dim);
815        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
816        configure(&mut builder);
817
818        let config = builder.build();
819        let rows = render_chart(&config);
820
821        for row in rows {
822            self.interaction_count += 1;
823            self.commands.push(Command::BeginContainer {
824                direction: Direction::Row,
825                gap: 0,
826                align: Align::Start,
827                justify: Justify::Start,
828                border: None,
829                border_sides: BorderSides::all(),
830                border_style: Style::new().fg(self.theme.border),
831                bg_color: None,
832                padding: Padding::default(),
833                margin: Margin::default(),
834                constraints: Constraints::default(),
835                title: None,
836                grow: 0,
837                group_name: None,
838            });
839            for (text, style) in row.segments {
840                self.styled(text, style);
841            }
842            self.commands.push(Command::EndContainer);
843            self.last_text_idx = None;
844        }
845
846        Response::none()
847    }
848
849    /// Renders a scatter plot.
850    ///
851    /// Each point is a (x, y) tuple. Uses braille markers.
852    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
853        self.chart(
854            |c| {
855                c.scatter(data);
856                c.grid(true);
857            },
858            width,
859            height,
860        )
861    }
862
863    /// Render a histogram from raw data with auto-binning.
864    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
865        self.histogram_with(data, |_| {}, width, height)
866    }
867
868    /// Render a histogram with configuration options.
869    pub fn histogram_with(
870        &mut self,
871        data: &[f64],
872        configure: impl FnOnce(&mut HistogramBuilder),
873        width: u32,
874        height: u32,
875    ) -> Response {
876        if width == 0 || height == 0 {
877            return Response::none();
878        }
879
880        let mut options = HistogramBuilder::default();
881        configure(&mut options);
882        let axis_style = Style::new().fg(self.theme.text_dim);
883        let config = build_histogram_config(data, &options, width, height, axis_style);
884        let rows = render_chart(&config);
885
886        for row in rows {
887            self.interaction_count += 1;
888            self.commands.push(Command::BeginContainer {
889                direction: Direction::Row,
890                gap: 0,
891                align: Align::Start,
892                justify: Justify::Start,
893                border: None,
894                border_sides: BorderSides::all(),
895                border_style: Style::new().fg(self.theme.border),
896                bg_color: None,
897                padding: Padding::default(),
898                margin: Margin::default(),
899                constraints: Constraints::default(),
900                title: None,
901                grow: 0,
902                group_name: None,
903            });
904            for (text, style) in row.segments {
905                self.styled(text, style);
906            }
907            self.commands.push(Command::EndContainer);
908            self.last_text_idx = None;
909        }
910
911        Response::none()
912    }
913}