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_with`](Self::bar_chart_with).
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.skip_interaction_slot();
49        self.commands
50            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
51                direction: Direction::Column,
52                gap: 0,
53                align: Align::Start,
54                align_self: None,
55                justify: Justify::Start,
56                border: None,
57                border_sides: BorderSides::all(),
58                border_style: Style::new().fg(self.theme.border),
59                bg_color: None,
60                padding: Padding::default(),
61                margin: Margin::default(),
62                constraints: Constraints::default(),
63                title: None,
64                grow: 0,
65                group_name: None,
66            })));
67
68        for (label, value) in data {
69            let label_width = UnicodeWidthStr::width(*label);
70            let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
71            let normalized = (*value / denom).clamp(0.0, 1.0);
72            let bar = Self::horizontal_bar_text(normalized, max_width);
73
74            self.skip_interaction_slot();
75            self.commands
76                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
77                    direction: Direction::Row,
78                    gap: 1,
79                    align: Align::Start,
80                    align_self: None,
81                    justify: Justify::Start,
82                    border: None,
83                    border_sides: BorderSides::all(),
84                    border_style: Style::new().fg(self.theme.border),
85                    bg_color: None,
86                    padding: Padding::default(),
87                    margin: Margin::default(),
88                    constraints: Constraints::default(),
89                    title: None,
90                    grow: 0,
91                    group_name: None,
92                })));
93            let mut label_text = String::with_capacity(label.len() + label_padding.len());
94            label_text.push_str(label);
95            label_text.push_str(&label_padding);
96            self.styled(label_text, Style::new().fg(self.theme.text));
97            self.styled(bar, Style::new().fg(self.theme.primary));
98            self.styled(
99                format_compact_number(*value),
100                Style::new().fg(self.theme.text_dim),
101            );
102            self.commands.push(Command::EndContainer);
103            self.rollback.last_text_idx = None;
104        }
105
106        self.commands.push(Command::EndContainer);
107        self.rollback.last_text_idx = None;
108
109        Response::none()
110    }
111
112    /// Render a bar chart with custom configuration.
113    pub fn bar_chart_with(
114        &mut self,
115        bars: &[Bar],
116        configure: impl FnOnce(&mut BarChartConfig),
117        max_size: u32,
118    ) -> Response {
119        if bars.is_empty() {
120            return Response::none();
121        }
122
123        let (config, denom) = self.bar_chart_styled_layout(bars, configure);
124        self.bar_chart_styled_render(bars, max_size, denom, &config);
125
126        Response::none()
127    }
128
129    fn bar_chart_styled_layout(
130        &self,
131        bars: &[Bar],
132        configure: impl FnOnce(&mut BarChartConfig),
133    ) -> (BarChartConfig, f64) {
134        let mut config = BarChartConfig::default();
135        configure(&mut config);
136
137        let auto_max = bars
138            .iter()
139            .map(|bar| bar.value)
140            .fold(f64::NEG_INFINITY, f64::max);
141        let max_value = config.max_value.unwrap_or(auto_max);
142        let denom = if max_value > 0.0 { max_value } else { 1.0 };
143
144        (config, denom)
145    }
146
147    fn bar_chart_styled_render(
148        &mut self,
149        bars: &[Bar],
150        max_size: u32,
151        denom: f64,
152        config: &BarChartConfig,
153    ) {
154        match config.direction {
155            BarDirection::Horizontal => {
156                self.render_horizontal_styled_bars(bars, max_size, denom, config.bar_gap)
157            }
158            BarDirection::Vertical => self.render_vertical_styled_bars(
159                bars,
160                max_size,
161                denom,
162                config.bar_width,
163                config.bar_gap,
164            ),
165        }
166    }
167
168    fn render_horizontal_styled_bars(
169        &mut self,
170        bars: &[Bar],
171        max_width: u32,
172        denom: f64,
173        bar_gap: u16,
174    ) {
175        let max_label_width = bars
176            .iter()
177            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
178            .max()
179            .unwrap_or(0);
180
181        self.skip_interaction_slot();
182        self.commands
183            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
184                direction: Direction::Column,
185                gap: bar_gap as u32,
186                align: Align::Start,
187                align_self: None,
188                justify: Justify::Start,
189                border: None,
190                border_sides: BorderSides::all(),
191                border_style: Style::new().fg(self.theme.border),
192                bg_color: None,
193                padding: Padding::default(),
194                margin: Margin::default(),
195                constraints: Constraints::default(),
196                title: None,
197                grow: 0,
198                group_name: None,
199            })));
200
201        for bar in bars {
202            self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
203        }
204
205        self.commands.push(Command::EndContainer);
206        self.rollback.last_text_idx = None;
207    }
208
209    fn render_horizontal_styled_bar_row(
210        &mut self,
211        bar: &Bar,
212        max_label_width: usize,
213        max_width: u32,
214        denom: f64,
215    ) {
216        let label_width = UnicodeWidthStr::width(bar.label.as_str());
217        let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
218        let normalized = (bar.value / denom).clamp(0.0, 1.0);
219        let bar_text = Self::horizontal_bar_text(normalized, max_width);
220        let color = bar.color.unwrap_or(self.theme.primary);
221
222        self.skip_interaction_slot();
223        self.commands
224            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
225                direction: Direction::Row,
226                gap: 1,
227                align: Align::Start,
228                align_self: None,
229                justify: Justify::Start,
230                border: None,
231                border_sides: BorderSides::all(),
232                border_style: Style::new().fg(self.theme.border),
233                bg_color: None,
234                padding: Padding::default(),
235                margin: Margin::default(),
236                constraints: Constraints::default(),
237                title: None,
238                grow: 0,
239                group_name: None,
240            })));
241        let mut label_text = String::with_capacity(bar.label.len() + label_padding.len());
242        label_text.push_str(&bar.label);
243        label_text.push_str(&label_padding);
244        self.styled(label_text, Style::new().fg(self.theme.text));
245        self.styled(bar_text, Style::new().fg(color));
246        self.styled(
247            Self::bar_display_value(bar),
248            bar.value_style
249                .unwrap_or(Style::new().fg(self.theme.text_dim)),
250        );
251        self.commands.push(Command::EndContainer);
252        self.rollback.last_text_idx = None;
253    }
254
255    fn render_vertical_styled_bars(
256        &mut self,
257        bars: &[Bar],
258        max_height: u32,
259        denom: f64,
260        bar_width: u16,
261        bar_gap: u16,
262    ) {
263        let layout = self.compute_vertical_bar_layout(bars, max_height, denom, bar_width);
264
265        self.skip_interaction_slot();
266        self.commands
267            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
268                direction: Direction::Column,
269                gap: 0,
270                align: Align::Start,
271                align_self: None,
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
285        self.render_vertical_bar_body(
286            bars,
287            &layout.bar_units,
288            layout.chart_height,
289            layout.col_width,
290            layout.bar_width,
291            bar_gap,
292            &layout.value_labels,
293        );
294        self.render_vertical_bar_labels(bars, layout.col_width, bar_gap);
295
296        self.commands.push(Command::EndContainer);
297        self.rollback.last_text_idx = None;
298    }
299
300    fn compute_vertical_bar_layout(
301        &self,
302        bars: &[Bar],
303        max_height: u32,
304        denom: f64,
305        bar_width: u16,
306    ) -> VerticalBarLayout {
307        let chart_height = max_height.max(1) as usize;
308        let bar_width = bar_width.max(1) as usize;
309        let value_labels: Vec<String> = bars.iter().map(Self::bar_display_value).collect();
310        let label_width = bars
311            .iter()
312            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
313            .max()
314            .unwrap_or(1);
315        let value_width = value_labels
316            .iter()
317            .map(|value| UnicodeWidthStr::width(value.as_str()))
318            .max()
319            .unwrap_or(1);
320        let col_width = bar_width.max(label_width.max(value_width).max(1));
321        let bar_units: Vec<usize> = bars
322            .iter()
323            .map(|bar| {
324                ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
325            })
326            .collect();
327
328        VerticalBarLayout {
329            chart_height,
330            bar_width,
331            value_labels,
332            col_width,
333            bar_units,
334        }
335    }
336
337    #[allow(clippy::too_many_arguments)]
338    fn render_vertical_bar_body(
339        &mut self,
340        bars: &[Bar],
341        bar_units: &[usize],
342        chart_height: usize,
343        col_width: usize,
344        bar_width: usize,
345        bar_gap: u16,
346        value_labels: &[String],
347    ) {
348        const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
349
350        // Pre-compute the topmost filled row for each bar (for value label placement).
351        let top_rows: Vec<usize> = bar_units
352            .iter()
353            .map(|units| {
354                if *units == 0 {
355                    usize::MAX
356                } else {
357                    (*units - 1) / 8
358                }
359            })
360            .collect();
361
362        for row in (0..chart_height).rev() {
363            self.skip_interaction_slot();
364            self.commands
365                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
366                    direction: Direction::Row,
367                    gap: bar_gap as u32,
368                    align: Align::Start,
369                    align_self: None,
370                    justify: Justify::Start,
371                    border: None,
372                    border_sides: BorderSides::all(),
373                    border_style: Style::new().fg(self.theme.border),
374                    bg_color: None,
375                    padding: Padding::default(),
376                    margin: Margin::default(),
377                    constraints: Constraints::default(),
378                    title: None,
379                    grow: 0,
380                    group_name: None,
381                })));
382
383            let row_base = row * 8;
384            for (i, (bar, units)) in bars.iter().zip(bar_units.iter()).enumerate() {
385                let color = bar.color.unwrap_or(self.theme.primary);
386
387                if *units <= row_base {
388                    // Value label one row above the bar top (plain text, no bg).
389                    if top_rows[i] != usize::MAX && row == top_rows[i] + 1 {
390                        let label = &value_labels[i];
391                        let centered = Self::center_and_truncate_text(label, col_width);
392                        self.styled(
393                            centered,
394                            bar.value_style.unwrap_or(Style::new().fg(color).bold()),
395                        );
396                    } else {
397                        let empty = " ".repeat(col_width);
398                        self.styled(empty, Style::new());
399                    }
400                    continue;
401                }
402
403                if row == top_rows[i] && top_rows[i] + 1 >= chart_height {
404                    let label = &value_labels[i];
405                    let centered = Self::center_and_truncate_text(label, col_width);
406                    self.styled(
407                        centered,
408                        bar.value_style.unwrap_or(Style::new().fg(color).bold()),
409                    );
410                    continue;
411                }
412
413                let delta = *units - row_base;
414                let fill = if delta >= 8 {
415                    '█'
416                } else {
417                    FRACTION_BLOCKS[delta]
418                };
419                let fill_text = fill.to_string().repeat(bar_width);
420                let centered_fill = center_text(&fill_text, col_width);
421                self.styled(centered_fill, Style::new().fg(color));
422            }
423
424            self.commands.push(Command::EndContainer);
425            self.rollback.last_text_idx = None;
426        }
427    }
428
429    fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize, bar_gap: u16) {
430        self.skip_interaction_slot();
431        self.commands
432            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
433                direction: Direction::Row,
434                gap: bar_gap as u32,
435                align: Align::Start,
436                align_self: None,
437                justify: Justify::Start,
438                border: None,
439                border_sides: BorderSides::all(),
440                border_style: Style::new().fg(self.theme.border),
441                bg_color: None,
442                padding: Padding::default(),
443                margin: Margin::default(),
444                constraints: Constraints::default(),
445                title: None,
446                grow: 0,
447                group_name: None,
448            })));
449        for bar in bars {
450            self.styled(
451                Self::center_and_truncate_text(&bar.label, col_width),
452                Style::new().fg(self.theme.text),
453            );
454        }
455        self.commands.push(Command::EndContainer);
456        self.rollback.last_text_idx = None;
457    }
458
459    /// Render a grouped bar chart.
460    ///
461    /// Each group contains multiple bars rendered side by side. Useful for
462    /// comparing categories across groups (e.g., quarterly revenue by product).
463    ///
464    /// # Example
465    /// ```no_run
466    /// # slt::run(|ui: &mut slt::Context| {
467    /// use slt::{Bar, BarGroup, Color};
468    /// let groups = vec![
469    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
470    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
471    /// ];
472    /// ui.bar_chart_grouped(&groups, 40);
473    /// # });
474    /// ```
475    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
476        self.bar_chart_grouped_with(groups, |_| {}, max_width)
477    }
478
479    /// Render a grouped bar chart with custom configuration.
480    pub fn bar_chart_grouped_with(
481        &mut self,
482        groups: &[BarGroup],
483        configure: impl FnOnce(&mut BarChartConfig),
484        max_size: u32,
485    ) -> Response {
486        if groups.is_empty() {
487            return Response::none();
488        }
489
490        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
491        if all_bars.is_empty() {
492            return Response::none();
493        }
494
495        let mut config = BarChartConfig::default();
496        configure(&mut config);
497
498        let auto_max = all_bars
499            .iter()
500            .map(|bar| bar.value)
501            .fold(f64::NEG_INFINITY, f64::max);
502        let max_value = config.max_value.unwrap_or(auto_max);
503        let denom = if max_value > 0.0 { max_value } else { 1.0 };
504
505        match config.direction {
506            BarDirection::Horizontal => {
507                self.render_grouped_horizontal_bars(groups, max_size, denom, &config)
508            }
509            BarDirection::Vertical => {
510                self.render_grouped_vertical_bars(groups, max_size, denom, &config)
511            }
512        }
513
514        Response::none()
515    }
516
517    fn render_grouped_horizontal_bars(
518        &mut self,
519        groups: &[BarGroup],
520        max_width: u32,
521        denom: f64,
522        config: &BarChartConfig,
523    ) {
524        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
525        let max_label_width = all_bars
526            .iter()
527            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
528            .max()
529            .unwrap_or(0);
530
531        self.skip_interaction_slot();
532        self.commands
533            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
534                direction: Direction::Column,
535                gap: config.group_gap as u32,
536                align: Align::Start,
537                align_self: None,
538                justify: Justify::Start,
539                border: None,
540                border_sides: BorderSides::all(),
541                border_style: Style::new().fg(self.theme.border),
542                bg_color: None,
543                padding: Padding::default(),
544                margin: Margin::default(),
545                constraints: Constraints::default(),
546                title: None,
547                grow: 0,
548                group_name: None,
549            })));
550
551        for group in groups {
552            self.skip_interaction_slot();
553            self.commands
554                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
555                    direction: Direction::Column,
556                    gap: config.bar_gap as u32,
557                    align: Align::Start,
558                    align_self: None,
559                    justify: Justify::Start,
560                    border: None,
561                    border_sides: BorderSides::all(),
562                    border_style: Style::new().fg(self.theme.border),
563                    bg_color: None,
564                    padding: Padding::default(),
565                    margin: Margin::default(),
566                    constraints: Constraints::default(),
567                    title: None,
568                    grow: 0,
569                    group_name: None,
570                })));
571
572            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
573
574            for bar in &group.bars {
575                let label_width = UnicodeWidthStr::width(bar.label.as_str());
576                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
577                let normalized = (bar.value / denom).clamp(0.0, 1.0);
578                let bar_text = Self::horizontal_bar_text(normalized, max_width);
579
580                self.skip_interaction_slot();
581                self.commands
582                    .push(Command::BeginContainer(Box::new(BeginContainerArgs {
583                        direction: Direction::Row,
584                        gap: 1,
585                        align: Align::Start,
586                        align_self: None,
587                        justify: Justify::Start,
588                        border: None,
589                        border_sides: BorderSides::all(),
590                        border_style: Style::new().fg(self.theme.border),
591                        bg_color: None,
592                        padding: Padding::default(),
593                        margin: Margin::default(),
594                        constraints: Constraints::default(),
595                        title: None,
596                        grow: 0,
597                        group_name: None,
598                    })));
599                let mut label_text =
600                    String::with_capacity(2 + bar.label.len() + label_padding.len());
601                label_text.push_str("  ");
602                label_text.push_str(&bar.label);
603                label_text.push_str(&label_padding);
604                self.styled(label_text, Style::new().fg(self.theme.text));
605                self.styled(
606                    bar_text,
607                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
608                );
609                self.styled(
610                    Self::bar_display_value(bar),
611                    bar.value_style
612                        .unwrap_or(Style::new().fg(self.theme.text_dim)),
613                );
614                self.commands.push(Command::EndContainer);
615                self.rollback.last_text_idx = None;
616            }
617
618            self.commands.push(Command::EndContainer);
619            self.rollback.last_text_idx = None;
620        }
621
622        self.commands.push(Command::EndContainer);
623        self.rollback.last_text_idx = None;
624    }
625
626    fn render_grouped_vertical_bars(
627        &mut self,
628        groups: &[BarGroup],
629        max_height: u32,
630        denom: f64,
631        config: &BarChartConfig,
632    ) {
633        self.skip_interaction_slot();
634        self.commands
635            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
636                direction: Direction::Column,
637                gap: config.group_gap as u32,
638                align: Align::Start,
639                align_self: None,
640                justify: Justify::Start,
641                border: None,
642                border_sides: BorderSides::all(),
643                border_style: Style::new().fg(self.theme.border),
644                bg_color: None,
645                padding: Padding::default(),
646                margin: Margin::default(),
647                constraints: Constraints::default(),
648                title: None,
649                grow: 0,
650                group_name: None,
651            })));
652
653        for group in groups {
654            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
655            if !group.bars.is_empty() {
656                self.render_vertical_styled_bars(
657                    &group.bars,
658                    max_height,
659                    denom,
660                    config.bar_width,
661                    config.bar_gap,
662                );
663            }
664        }
665
666        self.commands.push(Command::EndContainer);
667        self.rollback.last_text_idx = None;
668    }
669
670    fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
671        let filled = (normalized.clamp(0.0, 1.0) * max_width as f64).round() as usize;
672        "█".repeat(filled)
673    }
674
675    fn bar_display_value(bar: &Bar) -> String {
676        bar.text_value
677            .clone()
678            .unwrap_or_else(|| format_compact_number(bar.value))
679    }
680
681    fn center_and_truncate_text(text: &str, width: usize) -> String {
682        if width == 0 {
683            return String::new();
684        }
685
686        let mut out = String::new();
687        let mut used = 0usize;
688        for ch in text.chars() {
689            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
690            if used + cw > width {
691                break;
692            }
693            out.push(ch);
694            used += cw;
695        }
696        center_text(&out, width)
697    }
698
699    /// Render a single-line sparkline from numeric data.
700    ///
701    /// Uses the last `width` points (or fewer if the data is shorter) and maps
702    /// each point to one of `▁▂▃▄▅▆▇█`.
703    ///
704    /// # Example
705    ///
706    /// ```no_run
707    /// # slt::run(|ui: &mut slt::Context| {
708    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
709    /// ui.sparkline(&samples, 16);
710    /// # });
711    /// ```
712    ///
713    /// For per-point colors and missing values, see [`sparkline_styled`](Self::sparkline_styled).
714    pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
715        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
716
717        let w = width as usize;
718        if data.is_empty() || w == 0 {
719            return Response::none();
720        }
721
722        let points: Vec<f64> = if data.len() >= w {
723            data[data.len() - w..].to_vec()
724        } else if data.len() == 1 {
725            vec![data[0]; w]
726        } else {
727            (0..w)
728                .map(|i| {
729                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
730                    let idx = t.floor() as usize;
731                    let frac = t - idx as f64;
732                    if idx + 1 < data.len() {
733                        data[idx] * (1.0 - frac) + data[idx + 1] * frac
734                    } else {
735                        data[idx.min(data.len() - 1)]
736                    }
737                })
738                .collect()
739        };
740
741        let min = points.iter().copied().fold(f64::INFINITY, f64::min);
742        let max = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
743        let range = max - min;
744
745        let line: String = points
746            .iter()
747            .map(|&value| {
748                let normalized = if range == 0.0 {
749                    0.5
750                } else {
751                    (value - min) / range
752                };
753                let idx = (normalized * 7.0).round() as usize;
754                BLOCKS[idx.min(7)]
755            })
756            .collect();
757
758        self.styled(line, Style::new().fg(self.theme.primary));
759        Response::none()
760    }
761
762    /// Render a sparkline with per-point colors.
763    ///
764    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
765    /// Use `f64::NAN` for absent values (rendered as spaces).
766    ///
767    /// # Example
768    /// ```no_run
769    /// # slt::run(|ui: &mut slt::Context| {
770    /// use slt::Color;
771    /// let data: Vec<(f64, Option<Color>)> = vec![
772    ///     (12.0, Some(Color::Green)),
773    ///     (9.0, Some(Color::Red)),
774    ///     (14.0, Some(Color::Green)),
775    ///     (f64::NAN, None),
776    ///     (18.0, Some(Color::Cyan)),
777    /// ];
778    /// ui.sparkline_styled(&data, 16);
779    /// # });
780    /// ```
781    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
782        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
783
784        let w = width as usize;
785        if data.is_empty() || w == 0 {
786            return Response::none();
787        }
788
789        let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
790            data[data.len() - w..].to_vec()
791        } else if data.len() == 1 {
792            vec![data[0]; w]
793        } else {
794            (0..w)
795                .map(|i| {
796                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
797                    let idx = t.floor() as usize;
798                    let frac = t - idx as f64;
799                    let nearest = if frac < 0.5 {
800                        idx
801                    } else {
802                        (idx + 1).min(data.len() - 1)
803                    };
804                    let color = data[nearest].1;
805                    let (v1, _) = data[idx];
806                    let (v2, _) = data[(idx + 1).min(data.len() - 1)];
807                    let value = if v1.is_nan() || v2.is_nan() {
808                        if frac < 0.5 {
809                            v1
810                        } else {
811                            v2
812                        }
813                    } else {
814                        v1 * (1.0 - frac) + v2 * frac
815                    };
816                    (value, color)
817                })
818                .collect()
819        };
820
821        let mut finite_values = window
822            .iter()
823            .map(|(value, _)| *value)
824            .filter(|value| !value.is_nan());
825        let Some(first) = finite_values.next() else {
826            self.styled(
827                " ".repeat(window.len()),
828                Style::new().fg(self.theme.text_dim),
829            );
830            return Response::none();
831        };
832
833        let mut min = first;
834        let mut max = first;
835        for value in finite_values {
836            min = f64::min(min, value);
837            max = f64::max(max, value);
838        }
839        let range = max - min;
840
841        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
842        for (value, color) in &window {
843            if value.is_nan() {
844                cells.push((' ', self.theme.text_dim));
845                continue;
846            }
847
848            let normalized = if range == 0.0 {
849                0.5
850            } else {
851                ((*value - min) / range).clamp(0.0, 1.0)
852            };
853            let idx = (normalized * 7.0).round() as usize;
854            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
855        }
856
857        self.skip_interaction_slot();
858        self.commands
859            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
860                direction: Direction::Row,
861                gap: 0,
862                align: Align::Start,
863                align_self: None,
864                justify: Justify::Start,
865                border: None,
866                border_sides: BorderSides::all(),
867                border_style: Style::new().fg(self.theme.border),
868                bg_color: None,
869                padding: Padding::default(),
870                margin: Margin::default(),
871                constraints: Constraints::default(),
872                title: None,
873                grow: 0,
874                group_name: None,
875            })));
876
877        if cells.is_empty() {
878            self.commands.push(Command::EndContainer);
879            self.rollback.last_text_idx = None;
880            return Response::none();
881        }
882
883        let mut seg = String::new();
884        let mut seg_color = cells[0].1;
885        for (ch, color) in cells {
886            if color != seg_color {
887                self.styled(seg, Style::new().fg(seg_color));
888                seg = String::new();
889                seg_color = color;
890            }
891            seg.push(ch);
892        }
893        if !seg.is_empty() {
894            self.styled(seg, Style::new().fg(seg_color));
895        }
896
897        self.commands.push(Command::EndContainer);
898        self.rollback.last_text_idx = None;
899
900        Response::none()
901    }
902
903    /// Render a multi-row line chart using braille characters.
904    ///
905    /// `width` and `height` are terminal cell dimensions. Internally this uses
906    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
907    ///
908    /// # Example
909    ///
910    /// ```no_run
911    /// # slt::run(|ui: &mut slt::Context| {
912    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
913    /// ui.line_chart(&data, 40, 8);
914    /// # });
915    /// ```
916    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
917        self.line_chart_colored(data, width, height, self.theme.primary)
918    }
919
920    /// Render a multi-row line chart using a custom color.
921    pub fn line_chart_colored(
922        &mut self,
923        data: &[f64],
924        width: u32,
925        height: u32,
926        color: Color,
927    ) -> Response {
928        self.render_line_chart_internal(data, width, height, color, false)
929    }
930
931    /// Render a multi-row area chart using the primary theme color.
932    pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
933        self.area_chart_colored(data, width, height, self.theme.primary)
934    }
935
936    /// Render a multi-row area chart using a custom color.
937    pub fn area_chart_colored(
938        &mut self,
939        data: &[f64],
940        width: u32,
941        height: u32,
942        color: Color,
943    ) -> Response {
944        self.render_line_chart_internal(data, width, height, color, true)
945    }
946
947    fn render_line_chart_internal(
948        &mut self,
949        data: &[f64],
950        width: u32,
951        height: u32,
952        color: Color,
953        fill: bool,
954    ) -> Response {
955        if data.is_empty() || width == 0 || height == 0 {
956            return Response::none();
957        }
958
959        let cols = width as usize;
960        let rows = height as usize;
961        let px_w = cols * 2;
962        let px_h = rows * 4;
963
964        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
965        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
966        let range = if (max - min).abs() < f64::EPSILON {
967            1.0
968        } else {
969            max - min
970        };
971
972        let points: Vec<usize> = (0..px_w)
973            .map(|px| {
974                let data_idx = if px_w <= 1 {
975                    0.0
976                } else {
977                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
978                };
979                let idx = data_idx.floor() as usize;
980                let frac = data_idx - idx as f64;
981                let value = if idx + 1 < data.len() {
982                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
983                } else {
984                    data[idx.min(data.len() - 1)]
985                };
986
987                let normalized = (value - min) / range;
988                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
989                py.min(px_h - 1)
990            })
991            .collect();
992
993        // Braille dot bit masks shared with `chart::braille` (fix #114).
994        use crate::chart::{BRAILLE_LEFT_BITS as LEFT_BITS, BRAILLE_RIGHT_BITS as RIGHT_BITS};
995
996        let mut grid = vec![vec![0u32; cols]; rows];
997
998        for i in 0..points.len() {
999            let px = i;
1000            let py = points[i];
1001            let char_col = px / 2;
1002            let char_row = py / 4;
1003            let sub_col = px % 2;
1004            let sub_row = py % 4;
1005
1006            if char_col < cols && char_row < rows {
1007                grid[char_row][char_col] |= if sub_col == 0 {
1008                    LEFT_BITS[sub_row]
1009                } else {
1010                    RIGHT_BITS[sub_row]
1011                };
1012            }
1013
1014            if i + 1 < points.len() {
1015                let py_next = points[i + 1];
1016                let (y_start, y_end) = if py <= py_next {
1017                    (py, py_next)
1018                } else {
1019                    (py_next, py)
1020                };
1021                for y in y_start..=y_end {
1022                    let cell_row = y / 4;
1023                    let sub_y = y % 4;
1024                    if char_col < cols && cell_row < rows {
1025                        grid[cell_row][char_col] |= if sub_col == 0 {
1026                            LEFT_BITS[sub_y]
1027                        } else {
1028                            RIGHT_BITS[sub_y]
1029                        };
1030                    }
1031                }
1032            }
1033
1034            if fill {
1035                for y in py..px_h {
1036                    let cell_row = y / 4;
1037                    let sub_y = y % 4;
1038                    if char_col < cols && cell_row < rows {
1039                        grid[cell_row][char_col] |= if sub_col == 0 {
1040                            LEFT_BITS[sub_y]
1041                        } else {
1042                            RIGHT_BITS[sub_y]
1043                        };
1044                    }
1045                }
1046            }
1047        }
1048
1049        let style = Style::new().fg(color);
1050        for row in grid {
1051            let line: String = row
1052                .iter()
1053                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1054                .collect();
1055            self.styled(line, style);
1056        }
1057
1058        Response::none()
1059    }
1060
1061    /// Render an OHLC candlestick chart.
1062    pub fn candlestick(
1063        &mut self,
1064        candles: &[Candle],
1065        up_color: Color,
1066        down_color: Color,
1067    ) -> Response {
1068        if candles.is_empty() {
1069            return Response::none();
1070        }
1071
1072        let candles = candles.to_vec();
1073        self.container().grow(1).draw(move |buf, rect| {
1074            let w = rect.width as usize;
1075            let h = rect.height as usize;
1076            if w < 2 || h < 2 {
1077                return;
1078            }
1079
1080            let mut lo = f64::INFINITY;
1081            let mut hi = f64::NEG_INFINITY;
1082            for c in &candles {
1083                if c.low.is_finite() {
1084                    lo = lo.min(c.low);
1085                }
1086                if c.high.is_finite() {
1087                    hi = hi.max(c.high);
1088                }
1089            }
1090
1091            if !lo.is_finite() || !hi.is_finite() {
1092                return;
1093            }
1094
1095            let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1096            let map_y = |v: f64| -> usize {
1097                let t = ((v - lo) / range).clamp(0.0, 1.0);
1098                ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1099            };
1100
1101            for (i, c) in candles.iter().enumerate() {
1102                if !c.open.is_finite()
1103                    || !c.high.is_finite()
1104                    || !c.low.is_finite()
1105                    || !c.close.is_finite()
1106                {
1107                    continue;
1108                }
1109
1110                let x0 = i * w / candles.len();
1111                let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1112                if x0 >= w {
1113                    continue;
1114                }
1115                let xm = (x0 + x1) / 2;
1116                let color = if c.close >= c.open {
1117                    up_color
1118                } else {
1119                    down_color
1120                };
1121
1122                let wt = map_y(c.high);
1123                let wb = map_y(c.low);
1124                for row in wt..=wb.min(h - 1) {
1125                    buf.set_char(
1126                        rect.x + xm as u32,
1127                        rect.y + row as u32,
1128                        '│',
1129                        Style::new().fg(color),
1130                    );
1131                }
1132
1133                let bt = map_y(c.open.max(c.close));
1134                let bb = map_y(c.open.min(c.close));
1135                for row in bt..=bb.min(h - 1) {
1136                    for col in x0..=x1.min(w - 1) {
1137                        buf.set_char(
1138                            rect.x + col as u32,
1139                            rect.y + row as u32,
1140                            '█',
1141                            Style::new().fg(color),
1142                        );
1143                    }
1144                }
1145            }
1146        });
1147
1148        Response::none()
1149    }
1150
1151    /// Render a heatmap from a 2D data grid.
1152    ///
1153    /// Each cell maps to a block character with color intensity:
1154    /// low values -> dim/dark, high values -> bright/saturated.
1155    ///
1156    /// # Arguments
1157    /// * `data` - Row-major 2D grid (outer = rows, inner = columns)
1158    /// * `width` - Widget width in terminal cells
1159    /// * `height` - Widget height in terminal cells
1160    /// * `low_color` - Color for minimum values
1161    /// * `high_color` - Color for maximum values
1162    pub fn heatmap(
1163        &mut self,
1164        data: &[Vec<f64>],
1165        width: u32,
1166        height: u32,
1167        low_color: Color,
1168        high_color: Color,
1169    ) -> Response {
1170        fn blend_color(a: Color, b: Color, t: f64) -> Color {
1171            let t = t.clamp(0.0, 1.0);
1172            match (a, b) {
1173                (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1174                    (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1175                    (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1176                    (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1177                ),
1178                _ => {
1179                    if t > 0.5 {
1180                        b
1181                    } else {
1182                        a
1183                    }
1184                }
1185            }
1186        }
1187
1188        if data.is_empty() || width == 0 || height == 0 {
1189            return Response::none();
1190        }
1191
1192        let data_rows = data.len();
1193        let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1194        if max_data_cols == 0 {
1195            return Response::none();
1196        }
1197
1198        let mut min_value = f64::INFINITY;
1199        let mut max_value = f64::NEG_INFINITY;
1200        for row in data {
1201            for value in row {
1202                if value.is_finite() {
1203                    min_value = min_value.min(*value);
1204                    max_value = max_value.max(*value);
1205                }
1206            }
1207        }
1208
1209        if !min_value.is_finite() || !max_value.is_finite() {
1210            return Response::none();
1211        }
1212
1213        let range = max_value - min_value;
1214        let zero_range = range.abs() < f64::EPSILON;
1215        let cols = width as usize;
1216        let rows = height as usize;
1217
1218        for row_idx in 0..rows {
1219            let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1220            let source_row = &data[data_row_idx];
1221            let source_cols = source_row.len();
1222
1223            self.skip_interaction_slot();
1224            self.commands
1225                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1226                    direction: Direction::Row,
1227                    gap: 0,
1228                    align: Align::Start,
1229                    align_self: None,
1230                    justify: Justify::Start,
1231                    border: None,
1232                    border_sides: BorderSides::all(),
1233                    border_style: Style::new().fg(self.theme.border),
1234                    bg_color: None,
1235                    padding: Padding::default(),
1236                    margin: Margin::default(),
1237                    constraints: Constraints::default(),
1238                    title: None,
1239                    grow: 0,
1240                    group_name: None,
1241                })));
1242
1243            let mut segment = String::new();
1244            let mut segment_color: Option<Color> = None;
1245
1246            for col_idx in 0..cols {
1247                let normalized = if source_cols == 0 {
1248                    0.0
1249                } else {
1250                    let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1251                    let value = source_row[data_col_idx];
1252
1253                    if !value.is_finite() {
1254                        0.0
1255                    } else if zero_range {
1256                        0.5
1257                    } else {
1258                        ((value - min_value) / range).clamp(0.0, 1.0)
1259                    }
1260                };
1261
1262                let color = blend_color(low_color, high_color, normalized);
1263
1264                match segment_color {
1265                    Some(current) if current == color => {
1266                        segment.push('█');
1267                    }
1268                    Some(current) => {
1269                        self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1270                        segment.push('█');
1271                        segment_color = Some(color);
1272                    }
1273                    None => {
1274                        segment.push('█');
1275                        segment_color = Some(color);
1276                    }
1277                }
1278            }
1279
1280            if let Some(color) = segment_color {
1281                self.styled(segment, Style::new().fg(color));
1282            }
1283
1284            self.commands.push(Command::EndContainer);
1285            self.rollback.last_text_idx = None;
1286        }
1287
1288        Response::none()
1289    }
1290
1291    /// Render a braille drawing canvas.
1292    ///
1293    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
1294    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
1295    /// `height*4` pixel resolution.
1296    ///
1297    /// # Example
1298    ///
1299    /// ```no_run
1300    /// # slt::run(|ui: &mut slt::Context| {
1301    /// ui.canvas(40, 10, |cv| {
1302    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
1303    ///     cv.circle(40, 20, 15);
1304    /// });
1305    /// # });
1306    /// ```
1307    pub fn canvas(
1308        &mut self,
1309        width: u32,
1310        height: u32,
1311        draw: impl FnOnce(&mut CanvasContext),
1312    ) -> Response {
1313        if width == 0 || height == 0 {
1314            return Response::none();
1315        }
1316
1317        let mut canvas = CanvasContext::new(width as usize, height as usize);
1318        draw(&mut canvas);
1319
1320        for segments in canvas.render() {
1321            self.skip_interaction_slot();
1322            self.commands
1323                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1324                    direction: Direction::Row,
1325                    gap: 0,
1326                    align: Align::Start,
1327                    align_self: None,
1328                    justify: Justify::Start,
1329                    border: None,
1330                    border_sides: BorderSides::all(),
1331                    border_style: Style::new(),
1332                    bg_color: None,
1333                    padding: Padding::default(),
1334                    margin: Margin::default(),
1335                    constraints: Constraints::default(),
1336                    title: None,
1337                    grow: 0,
1338                    group_name: None,
1339                })));
1340            for (text, color) in segments {
1341                let c = if color == Color::Reset {
1342                    self.theme.primary
1343                } else {
1344                    color
1345                };
1346                self.styled(text, Style::new().fg(c));
1347            }
1348            self.commands.push(Command::EndContainer);
1349            self.rollback.last_text_idx = None;
1350        }
1351
1352        Response::none()
1353    }
1354
1355    /// Render a multi-series chart with axes, legend, and auto-scaling.
1356    ///
1357    /// `width` and `height` must be non-zero. For dynamic sizing, read terminal
1358    /// dimensions first (for example via `ui.width()` / `ui.height()`) and pass
1359    /// the computed values to this method.
1360    pub fn chart(
1361        &mut self,
1362        configure: impl FnOnce(&mut ChartBuilder),
1363        width: u32,
1364        height: u32,
1365    ) -> Response {
1366        if width == 0 || height == 0 {
1367            return Response::none();
1368        }
1369
1370        let axis_style = Style::new().fg(self.theme.text_dim);
1371        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1372        configure(&mut builder);
1373
1374        let config = builder.build();
1375        let rows = render_chart(&config);
1376
1377        for row in rows {
1378            self.skip_interaction_slot();
1379            self.commands
1380                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1381                    direction: Direction::Row,
1382                    gap: 0,
1383                    align: Align::Start,
1384                    align_self: None,
1385                    justify: Justify::Start,
1386                    border: None,
1387                    border_sides: BorderSides::all(),
1388                    border_style: Style::new().fg(self.theme.border),
1389                    bg_color: None,
1390                    padding: Padding::default(),
1391                    margin: Margin::default(),
1392                    constraints: Constraints::default(),
1393                    title: None,
1394                    grow: 0,
1395                    group_name: None,
1396                })));
1397            for (text, style) in row.segments {
1398                self.styled(text, style);
1399            }
1400            self.commands.push(Command::EndContainer);
1401            self.rollback.last_text_idx = None;
1402        }
1403
1404        Response::none()
1405    }
1406
1407    /// Renders a scatter plot.
1408    ///
1409    /// Each point is a (x, y) tuple. Uses braille markers.
1410    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1411        self.chart(
1412            |c| {
1413                c.scatter(data);
1414                c.grid(true);
1415            },
1416            width,
1417            height,
1418        )
1419    }
1420
1421    /// Render a histogram from raw data with auto-binning.
1422    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1423        self.histogram_with(data, |_| {}, width, height)
1424    }
1425
1426    /// Render a histogram with configuration options.
1427    pub fn histogram_with(
1428        &mut self,
1429        data: &[f64],
1430        configure: impl FnOnce(&mut HistogramBuilder),
1431        width: u32,
1432        height: u32,
1433    ) -> Response {
1434        if width == 0 || height == 0 {
1435            return Response::none();
1436        }
1437
1438        let mut options = HistogramBuilder::default();
1439        configure(&mut options);
1440        let axis_style = Style::new().fg(self.theme.text_dim);
1441        let config = build_histogram_config(data, &options, width, height, axis_style);
1442        let rows = render_chart(&config);
1443
1444        for row in rows {
1445            self.skip_interaction_slot();
1446            self.commands
1447                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1448                    direction: Direction::Row,
1449                    gap: 0,
1450                    align: Align::Start,
1451                    align_self: None,
1452                    justify: Justify::Start,
1453                    border: None,
1454                    border_sides: BorderSides::all(),
1455                    border_style: Style::new().fg(self.theme.border),
1456                    bg_color: None,
1457                    padding: Padding::default(),
1458                    margin: Margin::default(),
1459                    constraints: Constraints::default(),
1460                    title: None,
1461                    grow: 0,
1462                    group_name: None,
1463                })));
1464            for (text, style) in row.segments {
1465                self.styled(text, style);
1466            }
1467            self.commands.push(Command::EndContainer);
1468            self.rollback.last_text_idx = None;
1469        }
1470
1471        Response::none()
1472    }
1473
1474    #[cfg(feature = "qrcode")]
1475    /// Render a QR code using half-block characters.
1476    pub fn qr_code(&mut self, data: impl AsRef<str>) -> Response {
1477        let code = match qrcode::QrCode::new(data.as_ref()) {
1478            Ok(code) => code,
1479            Err(_) => {
1480                self.text("[QR Error]");
1481                return Response::none();
1482            }
1483        };
1484
1485        let modules_per_side = code.width();
1486        let modules = code.to_colors();
1487        let qr_side = modules_per_side + 2;
1488        let qr_width = qr_side;
1489        let qr_height = qr_side.div_ceil(2);
1490        let theme_text = self.theme.text;
1491        let theme_bg = self.theme.bg;
1492
1493        self.container()
1494            .w(qr_width as u32)
1495            .h(qr_height as u32)
1496            .draw(move |buf, rect| {
1497                let draw_w = (rect.width as usize).min(qr_width);
1498                let draw_h = (rect.height as usize).min(qr_height);
1499
1500                for row in 0..draw_h {
1501                    let upper_y = row * 2;
1502                    let lower_y = upper_y + 1;
1503
1504                    for x in 0..draw_w {
1505                        let resolve_module_color = |mx: usize, my: usize| -> Color {
1506                            let dark =
1507                                if mx == 0 || my == 0 || mx == qr_side - 1 || my == qr_side - 1 {
1508                                    false
1509                                } else {
1510                                    let inner_x = mx - 1;
1511                                    let inner_y = my - 1;
1512                                    let idx = inner_y * modules_per_side + inner_x;
1513                                    matches!(modules.get(idx), Some(qrcode::types::Color::Dark))
1514                                };
1515
1516                            if dark {
1517                                theme_text
1518                            } else {
1519                                theme_bg
1520                            }
1521                        };
1522
1523                        let upper = resolve_module_color(x, upper_y);
1524                        let lower = if lower_y < qr_side {
1525                            resolve_module_color(x, lower_y)
1526                        } else {
1527                            theme_bg
1528                        };
1529
1530                        buf.set_char(
1531                            rect.x + x as u32,
1532                            rect.y + row as u32,
1533                            '▀',
1534                            Style::new().fg(upper).bg(lower),
1535                        );
1536                    }
1537                }
1538            });
1539
1540        Response::none()
1541    }
1542
1543    /// Render a heatmap using half-block characters for 2× vertical resolution.
1544    ///
1545    /// Each terminal cell packs two data rows using `▀` with `fg` for the upper
1546    /// half and `bg` for the lower half. This doubles the effective vertical
1547    /// resolution compared to [`heatmap`](Self::heatmap).
1548    ///
1549    /// # Example
1550    ///
1551    /// ```no_run
1552    /// # slt::run(|ui: &mut slt::Context| {
1553    /// use slt::Color;
1554    /// let data: Vec<Vec<f64>> = (0..20)
1555    ///     .map(|r| (0..40).map(|c| ((r * 3 + c * 7) % 20) as f64).collect())
1556    ///     .collect();
1557    /// ui.heatmap_halfblock(&data, 40, 10, Color::Rgb(10, 10, 40), Color::Rgb(255, 80, 30));
1558    /// # });
1559    /// ```
1560    pub fn heatmap_halfblock(
1561        &mut self,
1562        data: &[Vec<f64>],
1563        width: u32,
1564        height: u32,
1565        low_color: Color,
1566        high_color: Color,
1567    ) -> Response {
1568        if data.is_empty() || width == 0 || height == 0 {
1569            return Response::none();
1570        }
1571
1572        let data_rows = data.len();
1573        let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1574        if max_data_cols == 0 {
1575            return Response::none();
1576        }
1577
1578        let mut min_value = f64::INFINITY;
1579        let mut max_value = f64::NEG_INFINITY;
1580        for row in data {
1581            for value in row {
1582                if value.is_finite() {
1583                    min_value = min_value.min(*value);
1584                    max_value = max_value.max(*value);
1585                }
1586            }
1587        }
1588
1589        if !min_value.is_finite() || !max_value.is_finite() {
1590            return Response::none();
1591        }
1592
1593        let range = max_value - min_value;
1594        let zero_range = range.abs() < f64::EPSILON;
1595
1596        let data = data.to_vec();
1597        let cols = width as usize;
1598        let rows = height as usize;
1599        // Each terminal row maps to 2 data rows
1600        let virtual_rows = rows * 2;
1601
1602        self.container().w(width).h(height).draw(move |buf, rect| {
1603            let w = rect.width as usize;
1604            let h = rect.height as usize;
1605            if w == 0 || h == 0 {
1606                return;
1607            }
1608
1609            let sample = |data_row_idx: usize, col_idx: usize| -> f64 {
1610                let src_row = &data[data_row_idx.min(data_rows.saturating_sub(1))];
1611                let src_cols = src_row.len();
1612                if src_cols == 0 {
1613                    return 0.0;
1614                }
1615                let data_col = (col_idx * src_cols / cols.max(1)).min(src_cols - 1);
1616                let v = src_row[data_col];
1617                if !v.is_finite() {
1618                    0.0
1619                } else if zero_range {
1620                    0.5
1621                } else {
1622                    ((v - min_value) / range).clamp(0.0, 1.0)
1623                }
1624            };
1625
1626            let blend = |t: f64| -> Color {
1627                let t = t.clamp(0.0, 1.0);
1628                match (low_color, high_color) {
1629                    (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1630                        (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1631                        (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1632                        (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1633                    ),
1634                    _ => {
1635                        if t > 0.5 {
1636                            high_color
1637                        } else {
1638                            low_color
1639                        }
1640                    }
1641                }
1642            };
1643
1644            for row in 0..h {
1645                let upper_data_row =
1646                    (row * 2 * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1647                let lower_data_row =
1648                    ((row * 2 + 1) * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1649
1650                for col in 0..w.min(cols) {
1651                    let upper_t = sample(upper_data_row, col);
1652                    let lower_t = sample(lower_data_row, col);
1653                    let upper_color = blend(upper_t);
1654                    let lower_color = blend(lower_t);
1655
1656                    buf.set_char(
1657                        rect.x + col as u32,
1658                        rect.y + row as u32,
1659                        '▀',
1660                        Style::new().fg(upper_color).bg(lower_color),
1661                    );
1662                }
1663            }
1664        });
1665
1666        Response::none()
1667    }
1668
1669    /// Render a candlestick chart with heavy box-drawing and half-block precision.
1670    ///
1671    /// Uses `┃` for wicks (heavier than `│`) and `▀`/`▄` at body edges for
1672    /// sub-cell vertical precision, effectively doubling the price resolution.
1673    ///
1674    /// # Example
1675    ///
1676    /// ```no_run
1677    /// # slt::run(|ui: &mut slt::Context| {
1678    /// use slt::{Candle, Color};
1679    /// let candles = vec![
1680    ///     Candle { open: 100.0, high: 108.0, low: 98.0, close: 105.0 },
1681    ///     Candle { open: 105.0, high: 112.0, low: 103.0, close: 110.0 },
1682    /// ];
1683    /// ui.candlestick_hd(&candles, Color::Rgb(38, 166, 91), Color::Rgb(234, 57, 67));
1684    /// # });
1685    /// ```
1686    pub fn candlestick_hd(
1687        &mut self,
1688        candles: &[Candle],
1689        up_color: Color,
1690        down_color: Color,
1691    ) -> Response {
1692        if candles.is_empty() {
1693            return Response::none();
1694        }
1695
1696        let candles = candles.to_vec();
1697        self.container().grow(1).draw(move |buf, rect| {
1698            let w = rect.width as usize;
1699            let h = rect.height as usize;
1700            if w < 2 || h < 2 {
1701                return;
1702            }
1703
1704            let mut lo = f64::INFINITY;
1705            let mut hi = f64::NEG_INFINITY;
1706            for c in &candles {
1707                if c.low.is_finite() {
1708                    lo = lo.min(c.low);
1709                }
1710                if c.high.is_finite() {
1711                    hi = hi.max(c.high);
1712                }
1713            }
1714            if !lo.is_finite() || !hi.is_finite() {
1715                return;
1716            }
1717
1718            let price_range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1719            let map_y = |v: f64| -> usize {
1720                let t = ((v - lo) / price_range).clamp(0.0, 1.0);
1721                ((1.0 - t) * h.saturating_sub(1) as f64).round() as usize
1722            };
1723
1724            let n = candles.len();
1725
1726            for (i, c) in candles.iter().enumerate() {
1727                if !c.open.is_finite()
1728                    || !c.high.is_finite()
1729                    || !c.low.is_finite()
1730                    || !c.close.is_finite()
1731                {
1732                    continue;
1733                }
1734
1735                // Distribute candles evenly across full width
1736                let x0 = i * w / n;
1737                let x1 = ((i + 1) * w / n).saturating_sub(1).max(x0);
1738                if x0 >= w {
1739                    continue;
1740                }
1741                // Wick at exact center of body range (inclusive)
1742                let xm = x0 + (x1 - x0) / 2;
1743                let color = if c.close >= c.open {
1744                    up_color
1745                } else {
1746                    down_color
1747                };
1748
1749                // Wick
1750                let wick_top = map_y(c.high);
1751                let wick_bot = map_y(c.low);
1752                for row in wick_top..=wick_bot.min(h - 1) {
1753                    buf.set_char(
1754                        rect.x + xm as u32,
1755                        rect.y + row as u32,
1756                        '┃',
1757                        Style::new().fg(color),
1758                    );
1759                }
1760
1761                // Body
1762                let body_top = map_y(c.open.max(c.close));
1763                let body_bot = map_y(c.open.min(c.close));
1764                for row in body_top..=body_bot.min(h - 1) {
1765                    for col in x0..=x1.min(w - 1) {
1766                        buf.set_char(
1767                            rect.x + col as u32,
1768                            rect.y + row as u32,
1769                            '█',
1770                            Style::new().fg(color),
1771                        );
1772                    }
1773                }
1774            }
1775        });
1776
1777        Response::none()
1778    }
1779
1780    /// Render a treemap using the squarified layout algorithm.
1781    ///
1782    /// Each item occupies a rectangle proportional to its value, filled with the
1783    /// item's color and labeled when space permits.
1784    ///
1785    /// # Example
1786    ///
1787    /// ```no_run
1788    /// # slt::run(|ui: &mut slt::Context| {
1789    /// use slt::{TreemapItem, Color};
1790    /// let items = vec![
1791    ///     TreemapItem::new("Rust", 40.0, Color::Cyan),
1792    ///     TreemapItem::new("Go", 25.0, Color::Blue),
1793    ///     TreemapItem::new("Python", 20.0, Color::Yellow),
1794    ///     TreemapItem::new("Java", 15.0, Color::Red),
1795    /// ];
1796    /// ui.treemap(&items);
1797    /// # });
1798    /// ```
1799    pub fn treemap(&mut self, items: &[TreemapItem]) -> Response {
1800        if items.is_empty() {
1801            return Response::none();
1802        }
1803
1804        let items = items.to_vec();
1805        self.container().grow(1).draw(move |buf, rect| {
1806            let w = rect.width as usize;
1807            let h = rect.height as usize;
1808            if w < 2 || h < 2 {
1809                return;
1810            }
1811
1812            // Filter out items that would be too small to render (< 1 cell)
1813            let total_area = w as f64 * h as f64;
1814            let total_value: f64 = items.iter().map(|i| i.value.max(0.0)).sum();
1815            let min_area_threshold = 1.0; // at least 1 cell
1816            let visible_items: Vec<&TreemapItem> = if total_value > 0.0 {
1817                items
1818                    .iter()
1819                    .filter(|item| {
1820                        item.value.max(0.0) / total_value * total_area >= min_area_threshold
1821                    })
1822                    .collect()
1823            } else {
1824                return;
1825            };
1826
1827            if visible_items.is_empty() {
1828                return;
1829            }
1830
1831            // Build filtered items for layout
1832            let filtered: Vec<TreemapItem> = visible_items.into_iter().cloned().collect();
1833            let rects = squarify_layout(&filtered, 0.0, 0.0, w as f64, h as f64);
1834
1835            for (item, r) in filtered.iter().zip(rects.iter()) {
1836                // Integer cell bounds — use round for consistent placement
1837                let x0 = r.x.round() as usize;
1838                let y0 = r.y.round() as usize;
1839                let x1 = (r.x + r.w).round() as usize;
1840                let y1 = (r.y + r.h).round() as usize;
1841
1842                let cell_w = x1.min(w).saturating_sub(x0);
1843                let cell_h = y1.min(h).saturating_sub(y0);
1844                if cell_w == 0 || cell_h == 0 {
1845                    continue;
1846                }
1847
1848                // Fill the rectangle with the item's color
1849                for row in y0..y1.min(h) {
1850                    for col in x0..x1.min(w) {
1851                        buf.set_char(
1852                            rect.x + col as u32,
1853                            rect.y + row as u32,
1854                            ' ',
1855                            Style::new().bg(item.color),
1856                        );
1857                    }
1858                }
1859
1860                let text_color = treemap_label_color(item.color);
1861
1862                // Label: truncate to fit, center in cell (unicode-safe, fix #112)
1863                if cell_w >= 2 {
1864                    let max_label_w = cell_w.saturating_sub(1);
1865                    let mut used_w = 0usize;
1866                    let mut last_byte = 0usize;
1867                    for (idx, ch) in item.label.char_indices() {
1868                        let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
1869                        if used_w + cw > max_label_w {
1870                            break;
1871                        }
1872                        used_w += cw;
1873                        last_byte = idx + ch.len_utf8();
1874                    }
1875                    let label = &item.label[..last_byte];
1876                    let label_unicode_w = UnicodeWidthStr::width(label);
1877                    let label_y = y0 + cell_h / 2;
1878                    let label_x = x0 + (cell_w.saturating_sub(label_unicode_w)) / 2;
1879                    if label_y < y1.min(h) {
1880                        for (offset, ch) in label.chars().enumerate() {
1881                            let cx = label_x + offset;
1882                            if cx < x1.min(w) {
1883                                buf.set_char(
1884                                    rect.x + cx as u32,
1885                                    rect.y + label_y as u32,
1886                                    ch,
1887                                    Style::new().fg(text_color).bg(item.color).bold(),
1888                                );
1889                            }
1890                        }
1891                    }
1892
1893                    // Value label below if space permits
1894                    if cell_h >= 3 {
1895                        let value_str = format_compact_number(item.value);
1896                        let value_y = label_y + 1;
1897                        if value_y < y1.min(h) && value_str.len() < cell_w {
1898                            let vx = x0 + (cell_w.saturating_sub(value_str.len())) / 2;
1899                            for (offset, ch) in value_str.chars().enumerate() {
1900                                let cx = vx + offset;
1901                                if cx < x1.min(w) {
1902                                    buf.set_char(
1903                                        rect.x + cx as u32,
1904                                        rect.y + value_y as u32,
1905                                        ch,
1906                                        Style::new().fg(text_color).bg(item.color).dim(),
1907                                    );
1908                                }
1909                            }
1910                        }
1911                    }
1912                }
1913            }
1914        });
1915
1916        Response::none()
1917    }
1918
1919    /// Render a stacked bar chart with custom configuration.
1920    ///
1921    /// Each group's bars are stacked on top of each other rather than placed
1922    /// side-by-side.
1923    ///
1924    /// # Example
1925    ///
1926    /// ```no_run
1927    /// # slt::run(|ui: &mut slt::Context| {
1928    /// use slt::{Bar, BarGroup, Color};
1929    /// let groups = vec![
1930    ///     BarGroup::new("2023", vec![
1931    ///         Bar::new("Rev", 100.0).color(Color::Cyan),
1932    ///         Bar::new("Cost", 60.0).color(Color::Red),
1933    ///     ]),
1934    ///     BarGroup::new("2024", vec![
1935    ///         Bar::new("Rev", 140.0).color(Color::Cyan),
1936    ///         Bar::new("Cost", 80.0).color(Color::Red),
1937    ///     ]),
1938    /// ];
1939    /// ui.bar_chart_stacked(&groups, 20);
1940    /// # });
1941    /// ```
1942    pub fn bar_chart_stacked(&mut self, groups: &[BarGroup], max_height: u32) -> Response {
1943        self.bar_chart_stacked_with(groups, |_| {}, max_height)
1944    }
1945
1946    /// Render a stacked bar chart with custom configuration.
1947    ///
1948    /// Uses [`BarChartConfig`] for bar width, gap, and max value settings.
1949    pub fn bar_chart_stacked_with(
1950        &mut self,
1951        groups: &[BarGroup],
1952        configure: impl FnOnce(&mut BarChartConfig),
1953        max_height: u32,
1954    ) -> Response {
1955        if groups.is_empty() {
1956            return Response::none();
1957        }
1958
1959        let all_bars: Vec<&Bar> = groups.iter().flat_map(|g| g.bars.iter()).collect();
1960        if all_bars.is_empty() {
1961            return Response::none();
1962        }
1963
1964        let mut config = BarChartConfig::default();
1965        config.bar_width(3).bar_gap(1);
1966        configure(&mut config);
1967
1968        // Find max stacked total
1969        let max_total: f64 = groups
1970            .iter()
1971            .map(|g| g.bars.iter().map(|b| b.value.max(0.0)).sum::<f64>())
1972            .fold(f64::NEG_INFINITY, f64::max);
1973        let denom = config.max_value.unwrap_or(max_total);
1974        let denom = if denom > 0.0 { denom } else { 1.0 };
1975
1976        let chart_height = max_height.max(1) as usize;
1977        let bar_width = config.bar_width.max(1) as usize;
1978        let gap = config.bar_gap as u32;
1979
1980        const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
1981
1982        self.skip_interaction_slot();
1983        self.commands
1984            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1985                direction: Direction::Column,
1986                gap: 0,
1987                align: Align::Start,
1988                align_self: None,
1989                justify: Justify::Start,
1990                border: None,
1991                border_sides: BorderSides::all(),
1992                border_style: Style::new().fg(self.theme.border),
1993                bg_color: None,
1994                padding: Padding::default(),
1995                margin: Margin::default(),
1996                constraints: Constraints::default(),
1997                title: None,
1998                grow: 0,
1999                group_name: None,
2000            })));
2001
2002        // Compute stacked units per group
2003        struct StackedSegment {
2004            units: usize,
2005            color: Color,
2006        }
2007        let stacked_groups: Vec<(String, Vec<StackedSegment>)> = groups
2008            .iter()
2009            .map(|g| {
2010                let segs: Vec<StackedSegment> = g
2011                    .bars
2012                    .iter()
2013                    .map(|b| {
2014                        let normalized = (b.value.max(0.0) / denom).clamp(0.0, 1.0);
2015                        StackedSegment {
2016                            units: (normalized * chart_height as f64 * 8.0).round() as usize,
2017                            color: b.color.unwrap_or(self.theme.primary),
2018                        }
2019                    })
2020                    .collect();
2021                (g.label.clone(), segs)
2022            })
2023            .collect();
2024
2025        // Render rows top to bottom
2026        for row in (0..chart_height).rev() {
2027            self.skip_interaction_slot();
2028            self.commands
2029                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2030                    direction: Direction::Row,
2031                    gap,
2032                    align: Align::Start,
2033                    align_self: None,
2034                    justify: Justify::Start,
2035                    border: None,
2036                    border_sides: BorderSides::all(),
2037                    border_style: Style::new().fg(self.theme.border),
2038                    bg_color: None,
2039                    padding: Padding::default(),
2040                    margin: Margin::default(),
2041                    constraints: Constraints::default(),
2042                    title: None,
2043                    grow: 0,
2044                    group_name: None,
2045                })));
2046
2047            let row_base = row * 8;
2048
2049            for (_label, segs) in &stacked_groups {
2050                // Find which segment covers this row
2051                let mut accumulated = 0usize;
2052                let mut cell_char = ' ';
2053                let mut cell_color = self.theme.bg;
2054
2055                for seg in segs {
2056                    let seg_bottom = accumulated;
2057                    let seg_top = accumulated + seg.units;
2058
2059                    if seg_top <= row_base {
2060                        // Segment is entirely below this row
2061                        accumulated = seg_top;
2062                        continue;
2063                    }
2064
2065                    if seg_bottom >= row_base + 8 {
2066                        // Segment is entirely above this row
2067                        break;
2068                    }
2069
2070                    // This segment covers (part of) this row
2071                    let local_bottom = seg_bottom.saturating_sub(row_base);
2072                    let local_top = (seg_top - row_base).min(8);
2073                    let fill = local_top - local_bottom;
2074
2075                    if local_bottom == 0 {
2076                        // This segment starts from the bottom of the cell
2077                        cell_char = if fill >= 8 {
2078                            '█'
2079                        } else {
2080                            FRACTION_BLOCKS[fill]
2081                        };
2082                        cell_color = seg.color;
2083                    } else {
2084                        // This segment starts partway up — just use full block
2085                        cell_char = '█';
2086                        cell_color = seg.color;
2087                    }
2088
2089                    accumulated = seg_top;
2090                }
2091
2092                let fill_text = cell_char.to_string().repeat(bar_width);
2093                self.styled(fill_text, Style::new().fg(cell_color));
2094            }
2095
2096            self.commands.push(Command::EndContainer);
2097            self.rollback.last_text_idx = None;
2098        }
2099
2100        // Labels row
2101        self.skip_interaction_slot();
2102        self.commands
2103            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2104                direction: Direction::Row,
2105                gap,
2106                align: Align::Start,
2107                align_self: None,
2108                justify: Justify::Start,
2109                border: None,
2110                border_sides: BorderSides::all(),
2111                border_style: Style::new().fg(self.theme.border),
2112                bg_color: None,
2113                padding: Padding::default(),
2114                margin: Margin::default(),
2115                constraints: Constraints::default(),
2116                title: None,
2117                grow: 0,
2118                group_name: None,
2119            })));
2120        for (label, _) in &stacked_groups {
2121            self.styled(
2122                Self::center_and_truncate_text(label, bar_width),
2123                Style::new().fg(self.theme.text),
2124            );
2125        }
2126        self.commands.push(Command::EndContainer);
2127        self.rollback.last_text_idx = None;
2128
2129        self.commands.push(Command::EndContainer);
2130        self.rollback.last_text_idx = None;
2131
2132        Response::none()
2133    }
2134}
2135
2136/// A single item in a treemap.
2137#[derive(Debug, Clone)]
2138pub struct TreemapItem {
2139    /// Display label.
2140    pub label: String,
2141    /// Numeric value determining area.
2142    pub value: f64,
2143    /// Fill color for this item's rectangle.
2144    pub color: Color,
2145}
2146
2147impl TreemapItem {
2148    /// Create a new treemap item.
2149    pub fn new(label: impl Into<String>, value: f64, color: Color) -> Self {
2150        Self {
2151            label: label.into(),
2152            value,
2153            color,
2154        }
2155    }
2156}
2157
2158/// Rectangle produced by the squarified layout.
2159#[derive(Clone)]
2160struct LayoutRect {
2161    x: f64,
2162    y: f64,
2163    w: f64,
2164    h: f64,
2165}
2166
2167/// Squarified treemap layout algorithm (Bruls, Huizing, van Wijk 2000).
2168fn squarify_layout(items: &[TreemapItem], x: f64, y: f64, w: f64, h: f64) -> Vec<LayoutRect> {
2169    if items.is_empty() || w <= 0.0 || h <= 0.0 {
2170        return Vec::new();
2171    }
2172
2173    let total: f64 = items.iter().map(|i| i.value.max(0.0)).sum();
2174    if total <= 0.0 {
2175        return items
2176            .iter()
2177            .map(|_| LayoutRect {
2178                x,
2179                y,
2180                w: 0.0,
2181                h: 0.0,
2182            })
2183            .collect();
2184    }
2185
2186    // Normalize values to fill the available area
2187    let area = w * h;
2188    let mut sorted_indices: Vec<usize> = (0..items.len()).collect();
2189    sorted_indices.sort_by(|a, b| {
2190        items[*b]
2191            .value
2192            .partial_cmp(&items[*a].value)
2193            .unwrap_or(std::cmp::Ordering::Equal)
2194    });
2195
2196    let areas: Vec<f64> = sorted_indices
2197        .iter()
2198        .map(|&i| items[i].value.max(0.0) / total * area)
2199        .collect();
2200
2201    let mut result = vec![
2202        LayoutRect {
2203            x: 0.0,
2204            y: 0.0,
2205            w: 0.0,
2206            h: 0.0,
2207        };
2208        items.len()
2209    ];
2210    squarify_recursive(&areas, &sorted_indices, x, y, w, h, &mut result);
2211    result
2212}
2213
2214fn worst_ratio(row: &[f64], side: f64) -> f64 {
2215    if row.is_empty() || side <= 0.0 {
2216        return f64::INFINITY;
2217    }
2218    let sum: f64 = row.iter().sum();
2219    let mut worst = 0.0f64;
2220    for &a in row {
2221        if a <= 0.0 {
2222            continue;
2223        }
2224        let ratio1 = (side * side * a) / (sum * sum);
2225        let ratio2 = (sum * sum) / (side * side * a);
2226        worst = worst.max(ratio1.max(ratio2));
2227    }
2228    worst
2229}
2230
2231fn squarify_recursive(
2232    areas: &[f64],
2233    indices: &[usize],
2234    x: f64,
2235    y: f64,
2236    w: f64,
2237    h: f64,
2238    result: &mut [LayoutRect],
2239) {
2240    if areas.is_empty() || w <= 0.0 || h <= 0.0 {
2241        return;
2242    }
2243
2244    if areas.len() == 1 {
2245        result[indices[0]] = LayoutRect { x, y, w, h };
2246        return;
2247    }
2248
2249    let short_side = w.min(h);
2250    let mut row: Vec<f64> = Vec::new();
2251    let mut row_indices: Vec<usize> = Vec::new();
2252
2253    for (i, &area) in areas.iter().enumerate() {
2254        let mut candidate = row.clone();
2255        candidate.push(area);
2256        if row.is_empty() || worst_ratio(&candidate, short_side) <= worst_ratio(&row, short_side) {
2257            row.push(area);
2258            row_indices.push(indices[i]);
2259        } else {
2260            // Layout the current row
2261            let row_sum: f64 = row.iter().sum();
2262            let row_fraction = row_sum / (w * h).max(f64::EPSILON);
2263
2264            if w >= h {
2265                // Lay out vertically on the left
2266                let row_w = w * row_fraction;
2267                let mut cy = y;
2268                for (j, &a) in row.iter().enumerate() {
2269                    let cell_h = if row_sum > 0.0 {
2270                        h * (a / row_sum)
2271                    } else {
2272                        0.0
2273                    };
2274                    result[row_indices[j]] = LayoutRect {
2275                        x,
2276                        y: cy,
2277                        w: row_w,
2278                        h: cell_h,
2279                    };
2280                    cy += cell_h;
2281                }
2282                squarify_recursive(
2283                    &areas[i..],
2284                    &indices[i..],
2285                    x + row_w,
2286                    y,
2287                    w - row_w,
2288                    h,
2289                    result,
2290                );
2291            } else {
2292                // Lay out horizontally on top
2293                let row_h = h * row_fraction;
2294                let mut cx = x;
2295                for (j, &a) in row.iter().enumerate() {
2296                    let cell_w = if row_sum > 0.0 {
2297                        w * (a / row_sum)
2298                    } else {
2299                        0.0
2300                    };
2301                    result[row_indices[j]] = LayoutRect {
2302                        x: cx,
2303                        y,
2304                        w: cell_w,
2305                        h: row_h,
2306                    };
2307                    cx += cell_w;
2308                }
2309                squarify_recursive(
2310                    &areas[i..],
2311                    &indices[i..],
2312                    x,
2313                    y + row_h,
2314                    w,
2315                    h - row_h,
2316                    result,
2317                );
2318            }
2319            return;
2320        }
2321    }
2322
2323    // Layout remaining row
2324    if !row.is_empty() {
2325        let row_sum: f64 = row.iter().sum();
2326        if w >= h {
2327            let mut cy = y;
2328            for (j, &a) in row.iter().enumerate() {
2329                let cell_h = if row_sum > 0.0 {
2330                    h * (a / row_sum)
2331                } else {
2332                    0.0
2333                };
2334                result[row_indices[j]] = LayoutRect {
2335                    x,
2336                    y: cy,
2337                    w,
2338                    h: cell_h,
2339                };
2340                cy += cell_h;
2341            }
2342        } else {
2343            let mut cx = x;
2344            for (j, &a) in row.iter().enumerate() {
2345                let cell_w = if row_sum > 0.0 {
2346                    w * (a / row_sum)
2347                } else {
2348                    0.0
2349                };
2350                result[row_indices[j]] = LayoutRect {
2351                    x: cx,
2352                    y,
2353                    w: cell_w,
2354                    h,
2355                };
2356                cx += cell_w;
2357            }
2358        }
2359    }
2360}
2361
2362/// Choose a contrasting label color for treemap cells.
2363fn treemap_label_color(bg: Color) -> Color {
2364    match bg {
2365        Color::Rgb(r, g, b) => {
2366            // Relative luminance (simplified)
2367            let lum = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64;
2368            if lum > 128.0 {
2369                Color::Rgb(0, 0, 0)
2370            } else {
2371                Color::Rgb(255, 255, 255)
2372            }
2373        }
2374        _ => Color::White,
2375    }
2376}
2377
2378#[cfg(all(test, feature = "qrcode"))]
2379#[test]
2380fn test_qr_code() {
2381    let mut backend = crate::TestBackend::new(60, 30);
2382    backend.render(|ui| {
2383        let _ = ui.qr_code("hello");
2384    });
2385
2386    let output = backend.to_string();
2387    assert!(output.contains('▀') || output.contains('█'));
2388}
2389
2390#[test]
2391fn treemap_cjk_label_no_panic() {
2392    use super::TreemapItem;
2393    use crate::style::Color;
2394    let mut backend = crate::TestBackend::new(20, 10);
2395    backend.render(|ui| {
2396        let _ = ui.treemap(&[
2397            TreemapItem::new("한글파일", 100.0, Color::Cyan),
2398            TreemapItem::new("English", 50.0, Color::Yellow),
2399            TreemapItem::new("🎉파티", 30.0, Color::Green),
2400        ]);
2401    });
2402    // passes if no panic
2403}