Skip to main content

slt/context/
widgets_viz.rs

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