Skip to main content

slt/context/
widgets_viz.rs

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