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        self.line_chart_colored(data, width, height, self.theme.primary)
650    }
651
652    /// Render a multi-row line chart using a custom color.
653    pub fn line_chart_colored(
654        &mut self,
655        data: &[f64],
656        width: u32,
657        height: u32,
658        color: Color,
659    ) -> Response {
660        self.render_line_chart_internal(data, width, height, color, false)
661    }
662
663    /// Render a multi-row area chart using the primary theme color.
664    pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
665        self.area_chart_colored(data, width, height, self.theme.primary)
666    }
667
668    /// Render a multi-row area chart using a custom color.
669    pub fn area_chart_colored(
670        &mut self,
671        data: &[f64],
672        width: u32,
673        height: u32,
674        color: Color,
675    ) -> Response {
676        self.render_line_chart_internal(data, width, height, color, true)
677    }
678
679    fn render_line_chart_internal(
680        &mut self,
681        data: &[f64],
682        width: u32,
683        height: u32,
684        color: Color,
685        fill: bool,
686    ) -> Response {
687        if data.is_empty() || width == 0 || height == 0 {
688            return Response::none();
689        }
690
691        let cols = width as usize;
692        let rows = height as usize;
693        let px_w = cols * 2;
694        let px_h = rows * 4;
695
696        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
697        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
698        let range = if (max - min).abs() < f64::EPSILON {
699            1.0
700        } else {
701            max - min
702        };
703
704        let points: Vec<usize> = (0..px_w)
705            .map(|px| {
706                let data_idx = if px_w <= 1 {
707                    0.0
708                } else {
709                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
710                };
711                let idx = data_idx.floor() as usize;
712                let frac = data_idx - idx as f64;
713                let value = if idx + 1 < data.len() {
714                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
715                } else {
716                    data[idx.min(data.len() - 1)]
717                };
718
719                let normalized = (value - min) / range;
720                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
721                py.min(px_h - 1)
722            })
723            .collect();
724
725        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
726        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
727
728        let mut grid = vec![vec![0u32; cols]; rows];
729
730        for i in 0..points.len() {
731            let px = i;
732            let py = points[i];
733            let char_col = px / 2;
734            let char_row = py / 4;
735            let sub_col = px % 2;
736            let sub_row = py % 4;
737
738            if char_col < cols && char_row < rows {
739                grid[char_row][char_col] |= if sub_col == 0 {
740                    LEFT_BITS[sub_row]
741                } else {
742                    RIGHT_BITS[sub_row]
743                };
744            }
745
746            if i + 1 < points.len() {
747                let py_next = points[i + 1];
748                let (y_start, y_end) = if py <= py_next {
749                    (py, py_next)
750                } else {
751                    (py_next, py)
752                };
753                for y in y_start..=y_end {
754                    let cell_row = y / 4;
755                    let sub_y = y % 4;
756                    if char_col < cols && cell_row < rows {
757                        grid[cell_row][char_col] |= if sub_col == 0 {
758                            LEFT_BITS[sub_y]
759                        } else {
760                            RIGHT_BITS[sub_y]
761                        };
762                    }
763                }
764            }
765
766            if fill {
767                for y in py..px_h {
768                    let cell_row = y / 4;
769                    let sub_y = y % 4;
770                    if char_col < cols && cell_row < rows {
771                        grid[cell_row][char_col] |= if sub_col == 0 {
772                            LEFT_BITS[sub_y]
773                        } else {
774                            RIGHT_BITS[sub_y]
775                        };
776                    }
777                }
778            }
779        }
780
781        let style = Style::new().fg(color);
782        for row in grid {
783            let line: String = row
784                .iter()
785                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
786                .collect();
787            self.styled(line, style);
788        }
789
790        Response::none()
791    }
792
793    /// Render an OHLC candlestick chart.
794    pub fn candlestick(
795        &mut self,
796        candles: &[Candle],
797        width: u32,
798        height: u32,
799        up_color: Color,
800        down_color: Color,
801    ) -> Response {
802        if candles.is_empty() || width == 0 || height == 0 {
803            return Response::none();
804        }
805
806        let cols = width as usize;
807        let rows = height as usize;
808
809        let mut min_price = f64::INFINITY;
810        let mut max_price = f64::NEG_INFINITY;
811        for candle in candles {
812            if candle.low.is_finite() {
813                min_price = min_price.min(candle.low);
814            }
815            if candle.high.is_finite() {
816                max_price = max_price.max(candle.high);
817            }
818        }
819
820        if !min_price.is_finite() || !max_price.is_finite() {
821            return Response::none();
822        }
823
824        let range = if (max_price - min_price).abs() < f64::EPSILON {
825            1.0
826        } else {
827            max_price - min_price
828        };
829        let map_row = |value: f64| -> usize {
830            let t = ((value - min_price) / range).clamp(0.0, 1.0);
831            ((1.0 - t) * (rows.saturating_sub(1)) as f64).round() as usize
832        };
833
834        let mut chars = vec![vec![' '; cols]; rows];
835        let mut colors = vec![vec![None::<Color>; cols]; rows];
836
837        for (index, candle) in candles.iter().enumerate() {
838            if !candle.open.is_finite()
839                || !candle.high.is_finite()
840                || !candle.low.is_finite()
841                || !candle.close.is_finite()
842            {
843                continue;
844            }
845
846            let x_start = index * cols / candles.len();
847            let mut x_end = ((index + 1) * cols / candles.len()).saturating_sub(1);
848            if x_end < x_start {
849                x_end = x_start;
850            }
851            if x_start >= cols {
852                continue;
853            }
854            x_end = x_end.min(cols.saturating_sub(1));
855            let wick_x = (x_start + x_end) / 2;
856
857            let high_row = map_row(candle.high);
858            let low_row = map_row(candle.low);
859            let open_row = map_row(candle.open);
860            let close_row = map_row(candle.close);
861
862            let (wick_top, wick_bottom) = if high_row <= low_row {
863                (high_row, low_row)
864            } else {
865                (low_row, high_row)
866            };
867            let color = if candle.close >= candle.open {
868                up_color
869            } else {
870                down_color
871            };
872
873            for row in wick_top..=wick_bottom.min(rows.saturating_sub(1)) {
874                chars[row][wick_x] = '│';
875                colors[row][wick_x] = Some(color);
876            }
877
878            let (body_top, body_bottom) = if open_row <= close_row {
879                (open_row, close_row)
880            } else {
881                (close_row, open_row)
882            };
883            for row in body_top..=body_bottom.min(rows.saturating_sub(1)) {
884                for col in x_start..=x_end {
885                    chars[row][col] = '█';
886                    colors[row][col] = Some(color);
887                }
888            }
889        }
890
891        for row in 0..rows {
892            self.interaction_count += 1;
893            self.commands.push(Command::BeginContainer {
894                direction: Direction::Row,
895                gap: 0,
896                align: Align::Start,
897                justify: Justify::Start,
898                border: None,
899                border_sides: BorderSides::all(),
900                border_style: Style::new().fg(self.theme.border),
901                bg_color: None,
902                padding: Padding::default(),
903                margin: Margin::default(),
904                constraints: Constraints::default(),
905                title: None,
906                grow: 0,
907                group_name: None,
908            });
909
910            let mut seg = String::new();
911            let mut seg_color = colors[row][0];
912            for col in 0..cols {
913                if colors[row][col] != seg_color {
914                    let style = if let Some(c) = seg_color {
915                        Style::new().fg(c)
916                    } else {
917                        Style::new()
918                    };
919                    self.styled(seg, style);
920                    seg = String::new();
921                    seg_color = colors[row][col];
922                }
923                seg.push(chars[row][col]);
924            }
925            if !seg.is_empty() {
926                let style = if let Some(c) = seg_color {
927                    Style::new().fg(c)
928                } else {
929                    Style::new()
930                };
931                self.styled(seg, style);
932            }
933
934            self.commands.push(Command::EndContainer);
935            self.last_text_idx = None;
936        }
937
938        Response::none()
939    }
940
941    /// Render a heatmap from a 2D data grid.
942    ///
943    /// Each cell maps to a block character with color intensity:
944    /// low values -> dim/dark, high values -> bright/saturated.
945    ///
946    /// # Arguments
947    /// * `data` - Row-major 2D grid (outer = rows, inner = columns)
948    /// * `width` - Widget width in terminal cells
949    /// * `height` - Widget height in terminal cells
950    /// * `low_color` - Color for minimum values
951    /// * `high_color` - Color for maximum values
952    pub fn heatmap(
953        &mut self,
954        data: &[Vec<f64>],
955        width: u32,
956        height: u32,
957        low_color: Color,
958        high_color: Color,
959    ) -> Response {
960        fn blend_color(a: Color, b: Color, t: f64) -> Color {
961            let t = t.clamp(0.0, 1.0);
962            match (a, b) {
963                (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
964                    (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
965                    (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
966                    (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
967                ),
968                _ => {
969                    if t > 0.5 {
970                        b
971                    } else {
972                        a
973                    }
974                }
975            }
976        }
977
978        if data.is_empty() || width == 0 || height == 0 {
979            return Response::none();
980        }
981
982        let data_rows = data.len();
983        let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
984        if max_data_cols == 0 {
985            return Response::none();
986        }
987
988        let mut min_value = f64::INFINITY;
989        let mut max_value = f64::NEG_INFINITY;
990        for row in data {
991            for value in row {
992                if value.is_finite() {
993                    min_value = min_value.min(*value);
994                    max_value = max_value.max(*value);
995                }
996            }
997        }
998
999        if !min_value.is_finite() || !max_value.is_finite() {
1000            return Response::none();
1001        }
1002
1003        let range = max_value - min_value;
1004        let zero_range = range.abs() < f64::EPSILON;
1005        let cols = width as usize;
1006        let rows = height as usize;
1007
1008        for row_idx in 0..rows {
1009            let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1010            let source_row = &data[data_row_idx];
1011            let source_cols = source_row.len();
1012
1013            self.interaction_count += 1;
1014            self.commands.push(Command::BeginContainer {
1015                direction: Direction::Row,
1016                gap: 0,
1017                align: Align::Start,
1018                justify: Justify::Start,
1019                border: None,
1020                border_sides: BorderSides::all(),
1021                border_style: Style::new().fg(self.theme.border),
1022                bg_color: None,
1023                padding: Padding::default(),
1024                margin: Margin::default(),
1025                constraints: Constraints::default(),
1026                title: None,
1027                grow: 0,
1028                group_name: None,
1029            });
1030
1031            let mut segment = String::new();
1032            let mut segment_color: Option<Color> = None;
1033
1034            for col_idx in 0..cols {
1035                let normalized = if source_cols == 0 {
1036                    0.0
1037                } else {
1038                    let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1039                    let value = source_row[data_col_idx];
1040
1041                    if !value.is_finite() {
1042                        0.0
1043                    } else if zero_range {
1044                        0.5
1045                    } else {
1046                        ((value - min_value) / range).clamp(0.0, 1.0)
1047                    }
1048                };
1049
1050                let color = blend_color(low_color, high_color, normalized);
1051
1052                match segment_color {
1053                    Some(current) if current == color => {
1054                        segment.push('█');
1055                    }
1056                    Some(current) => {
1057                        self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1058                        segment.push('█');
1059                        segment_color = Some(color);
1060                    }
1061                    None => {
1062                        segment.push('█');
1063                        segment_color = Some(color);
1064                    }
1065                }
1066            }
1067
1068            if let Some(color) = segment_color {
1069                self.styled(segment, Style::new().fg(color));
1070            }
1071
1072            self.commands.push(Command::EndContainer);
1073            self.last_text_idx = None;
1074        }
1075
1076        Response::none()
1077    }
1078
1079    /// Render a braille drawing canvas.
1080    ///
1081    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
1082    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
1083    /// `height*4` pixel resolution.
1084    ///
1085    /// # Example
1086    ///
1087    /// ```ignore
1088    /// # slt::run(|ui: &mut slt::Context| {
1089    /// ui.canvas(40, 10, |cv| {
1090    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
1091    ///     cv.circle(40, 20, 15);
1092    /// });
1093    /// # });
1094    /// ```
1095    pub fn canvas(
1096        &mut self,
1097        width: u32,
1098        height: u32,
1099        draw: impl FnOnce(&mut CanvasContext),
1100    ) -> Response {
1101        if width == 0 || height == 0 {
1102            return Response::none();
1103        }
1104
1105        let mut canvas = CanvasContext::new(width as usize, height as usize);
1106        draw(&mut canvas);
1107
1108        for segments in canvas.render() {
1109            self.interaction_count += 1;
1110            self.commands.push(Command::BeginContainer {
1111                direction: Direction::Row,
1112                gap: 0,
1113                align: Align::Start,
1114                justify: Justify::Start,
1115                border: None,
1116                border_sides: BorderSides::all(),
1117                border_style: Style::new(),
1118                bg_color: None,
1119                padding: Padding::default(),
1120                margin: Margin::default(),
1121                constraints: Constraints::default(),
1122                title: None,
1123                grow: 0,
1124                group_name: None,
1125            });
1126            for (text, color) in segments {
1127                let c = if color == Color::Reset {
1128                    self.theme.primary
1129                } else {
1130                    color
1131                };
1132                self.styled(text, Style::new().fg(c));
1133            }
1134            self.commands.push(Command::EndContainer);
1135            self.last_text_idx = None;
1136        }
1137
1138        Response::none()
1139    }
1140
1141    /// Render a multi-series chart with axes, legend, and auto-scaling.
1142    ///
1143    /// `width` and `height` must be non-zero. For dynamic sizing, read terminal
1144    /// dimensions first (for example via `ui.width()` / `ui.height()`) and pass
1145    /// the computed values to this method.
1146    pub fn chart(
1147        &mut self,
1148        configure: impl FnOnce(&mut ChartBuilder),
1149        width: u32,
1150        height: u32,
1151    ) -> Response {
1152        if width == 0 || height == 0 {
1153            return Response::none();
1154        }
1155
1156        let axis_style = Style::new().fg(self.theme.text_dim);
1157        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1158        configure(&mut builder);
1159
1160        let config = builder.build();
1161        let rows = render_chart(&config);
1162
1163        for row in rows {
1164            self.interaction_count += 1;
1165            self.commands.push(Command::BeginContainer {
1166                direction: Direction::Row,
1167                gap: 0,
1168                align: Align::Start,
1169                justify: Justify::Start,
1170                border: None,
1171                border_sides: BorderSides::all(),
1172                border_style: Style::new().fg(self.theme.border),
1173                bg_color: None,
1174                padding: Padding::default(),
1175                margin: Margin::default(),
1176                constraints: Constraints::default(),
1177                title: None,
1178                grow: 0,
1179                group_name: None,
1180            });
1181            for (text, style) in row.segments {
1182                self.styled(text, style);
1183            }
1184            self.commands.push(Command::EndContainer);
1185            self.last_text_idx = None;
1186        }
1187
1188        Response::none()
1189    }
1190
1191    /// Renders a scatter plot.
1192    ///
1193    /// Each point is a (x, y) tuple. Uses braille markers.
1194    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1195        self.chart(
1196            |c| {
1197                c.scatter(data);
1198                c.grid(true);
1199            },
1200            width,
1201            height,
1202        )
1203    }
1204
1205    /// Render a histogram from raw data with auto-binning.
1206    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1207        self.histogram_with(data, |_| {}, width, height)
1208    }
1209
1210    /// Render a histogram with configuration options.
1211    pub fn histogram_with(
1212        &mut self,
1213        data: &[f64],
1214        configure: impl FnOnce(&mut HistogramBuilder),
1215        width: u32,
1216        height: u32,
1217    ) -> Response {
1218        if width == 0 || height == 0 {
1219            return Response::none();
1220        }
1221
1222        let mut options = HistogramBuilder::default();
1223        configure(&mut options);
1224        let axis_style = Style::new().fg(self.theme.text_dim);
1225        let config = build_histogram_config(data, &options, width, height, axis_style);
1226        let rows = render_chart(&config);
1227
1228        for row in rows {
1229            self.interaction_count += 1;
1230            self.commands.push(Command::BeginContainer {
1231                direction: Direction::Row,
1232                gap: 0,
1233                align: Align::Start,
1234                justify: Justify::Start,
1235                border: None,
1236                border_sides: BorderSides::all(),
1237                border_style: Style::new().fg(self.theme.border),
1238                bg_color: None,
1239                padding: Padding::default(),
1240                margin: Margin::default(),
1241                constraints: Constraints::default(),
1242                title: None,
1243                grow: 0,
1244                group_name: None,
1245            });
1246            for (text, style) in row.segments {
1247                self.styled(text, style);
1248            }
1249            self.commands.push(Command::EndContainer);
1250            self.last_text_idx = None;
1251        }
1252
1253        Response::none()
1254    }
1255}