Skip to main content

slt/context/
widgets_viz.rs

1use super::*;
2
3impl Context {
4    /// Render a horizontal bar chart from `(label, value)` pairs.
5    ///
6    /// Bars are normalized against the largest value and rendered with `█` up to
7    /// `max_width` characters.
8    ///
9    /// # Example
10    ///
11    /// ```ignore
12    /// # slt::run(|ui: &mut slt::Context| {
13    /// let data = [
14    ///     ("Sales", 160.0),
15    ///     ("Revenue", 120.0),
16    ///     ("Users", 220.0),
17    ///     ("Costs", 60.0),
18    /// ];
19    /// ui.bar_chart(&data, 24);
20    ///
21    /// For styled bars with per-bar colors, see [`bar_chart_styled`].
22    /// # });
23    /// ```
24    pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
25        if data.is_empty() {
26            return self;
27        }
28
29        let max_label_width = data
30            .iter()
31            .map(|(label, _)| UnicodeWidthStr::width(*label))
32            .max()
33            .unwrap_or(0);
34        let max_value = data
35            .iter()
36            .map(|(_, value)| *value)
37            .fold(f64::NEG_INFINITY, f64::max);
38        let denom = if max_value > 0.0 { max_value } else { 1.0 };
39
40        self.interaction_count += 1;
41        self.commands.push(Command::BeginContainer {
42            direction: Direction::Column,
43            gap: 0,
44            align: Align::Start,
45            justify: Justify::Start,
46            border: None,
47            border_sides: BorderSides::all(),
48            border_style: Style::new().fg(self.theme.border),
49            bg_color: None,
50            padding: Padding::default(),
51            margin: Margin::default(),
52            constraints: Constraints::default(),
53            title: None,
54            grow: 0,
55            group_name: None,
56        });
57
58        for (label, value) in data {
59            let label_width = UnicodeWidthStr::width(*label);
60            let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
61            let normalized = (*value / denom).clamp(0.0, 1.0);
62            let bar_len = (normalized * max_width as f64).round() as usize;
63            let bar = "█".repeat(bar_len);
64
65            self.interaction_count += 1;
66            self.commands.push(Command::BeginContainer {
67                direction: Direction::Row,
68                gap: 1,
69                align: Align::Start,
70                justify: Justify::Start,
71                border: None,
72                border_sides: BorderSides::all(),
73                border_style: Style::new().fg(self.theme.border),
74                bg_color: None,
75                padding: Padding::default(),
76                margin: Margin::default(),
77                constraints: Constraints::default(),
78                title: None,
79                grow: 0,
80                group_name: None,
81            });
82            self.styled(
83                format!("{label}{label_padding}"),
84                Style::new().fg(self.theme.text),
85            );
86            self.styled(bar, Style::new().fg(self.theme.primary));
87            self.styled(
88                format_compact_number(*value),
89                Style::new().fg(self.theme.text_dim),
90            );
91            self.commands.push(Command::EndContainer);
92            self.last_text_idx = None;
93        }
94
95        self.commands.push(Command::EndContainer);
96        self.last_text_idx = None;
97
98        self
99    }
100
101    /// Render a styled bar chart with per-bar colors, grouping, and direction control.
102    ///
103    /// # Example
104    /// ```ignore
105    /// # slt::run(|ui: &mut slt::Context| {
106    /// use slt::{Bar, Color};
107    /// let bars = vec![
108    ///     Bar::new("Q1", 32.0).color(Color::Cyan),
109    ///     Bar::new("Q2", 46.0).color(Color::Green),
110    ///     Bar::new("Q3", 28.0).color(Color::Yellow),
111    ///     Bar::new("Q4", 54.0).color(Color::Red),
112    /// ];
113    /// ui.bar_chart_styled(&bars, 30, slt::BarDirection::Horizontal);
114    /// # });
115    /// ```
116    pub fn bar_chart_styled(
117        &mut self,
118        bars: &[Bar],
119        max_width: u32,
120        direction: BarDirection,
121    ) -> &mut Self {
122        if bars.is_empty() {
123            return self;
124        }
125
126        let max_value = bars
127            .iter()
128            .map(|bar| bar.value)
129            .fold(f64::NEG_INFINITY, f64::max);
130        let denom = if max_value > 0.0 { max_value } else { 1.0 };
131
132        match direction {
133            BarDirection::Horizontal => {
134                let max_label_width = bars
135                    .iter()
136                    .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
137                    .max()
138                    .unwrap_or(0);
139
140                self.interaction_count += 1;
141                self.commands.push(Command::BeginContainer {
142                    direction: Direction::Column,
143                    gap: 0,
144                    align: Align::Start,
145                    justify: Justify::Start,
146                    border: None,
147                    border_sides: BorderSides::all(),
148                    border_style: Style::new().fg(self.theme.border),
149                    bg_color: None,
150                    padding: Padding::default(),
151                    margin: Margin::default(),
152                    constraints: Constraints::default(),
153                    title: None,
154                    grow: 0,
155                    group_name: None,
156                });
157
158                for bar in bars {
159                    let label_width = UnicodeWidthStr::width(bar.label.as_str());
160                    let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
161                    let normalized = (bar.value / denom).clamp(0.0, 1.0);
162                    let bar_len = (normalized * max_width as f64).round() as usize;
163                    let bar_text = "█".repeat(bar_len);
164                    let color = bar.color.unwrap_or(self.theme.primary);
165
166                    self.interaction_count += 1;
167                    self.commands.push(Command::BeginContainer {
168                        direction: Direction::Row,
169                        gap: 1,
170                        align: Align::Start,
171                        justify: Justify::Start,
172                        border: None,
173                        border_sides: BorderSides::all(),
174                        border_style: Style::new().fg(self.theme.border),
175                        bg_color: None,
176                        padding: Padding::default(),
177                        margin: Margin::default(),
178                        constraints: Constraints::default(),
179                        title: None,
180                        grow: 0,
181                        group_name: None,
182                    });
183                    self.styled(
184                        format!("{}{label_padding}", bar.label),
185                        Style::new().fg(self.theme.text),
186                    );
187                    self.styled(bar_text, Style::new().fg(color));
188                    self.styled(
189                        format_compact_number(bar.value),
190                        Style::new().fg(self.theme.text_dim),
191                    );
192                    self.commands.push(Command::EndContainer);
193                    self.last_text_idx = None;
194                }
195
196                self.commands.push(Command::EndContainer);
197                self.last_text_idx = None;
198            }
199            BarDirection::Vertical => {
200                const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
201
202                let chart_height = max_width.max(1) as usize;
203                let value_labels: Vec<String> = bars
204                    .iter()
205                    .map(|bar| format_compact_number(bar.value))
206                    .collect();
207                let col_width = bars
208                    .iter()
209                    .zip(value_labels.iter())
210                    .map(|(bar, value)| {
211                        UnicodeWidthStr::width(bar.label.as_str())
212                            .max(UnicodeWidthStr::width(value.as_str()))
213                            .max(1)
214                    })
215                    .max()
216                    .unwrap_or(1);
217
218                let bar_units: Vec<usize> = bars
219                    .iter()
220                    .map(|bar| {
221                        let normalized = (bar.value / denom).clamp(0.0, 1.0);
222                        (normalized * chart_height as f64 * 8.0).round() as usize
223                    })
224                    .collect();
225
226                self.interaction_count += 1;
227                self.commands.push(Command::BeginContainer {
228                    direction: Direction::Column,
229                    gap: 0,
230                    align: Align::Start,
231                    justify: Justify::Start,
232                    border: None,
233                    border_sides: BorderSides::all(),
234                    border_style: Style::new().fg(self.theme.border),
235                    bg_color: None,
236                    padding: Padding::default(),
237                    margin: Margin::default(),
238                    constraints: Constraints::default(),
239                    title: None,
240                    grow: 0,
241                    group_name: None,
242                });
243
244                self.interaction_count += 1;
245                self.commands.push(Command::BeginContainer {
246                    direction: Direction::Row,
247                    gap: 1,
248                    align: Align::Start,
249                    justify: Justify::Start,
250                    border: None,
251                    border_sides: BorderSides::all(),
252                    border_style: Style::new().fg(self.theme.border),
253                    bg_color: None,
254                    padding: Padding::default(),
255                    margin: Margin::default(),
256                    constraints: Constraints::default(),
257                    title: None,
258                    grow: 0,
259                    group_name: None,
260                });
261                for value in &value_labels {
262                    self.styled(
263                        center_text(value, col_width),
264                        Style::new().fg(self.theme.text_dim),
265                    );
266                }
267                self.commands.push(Command::EndContainer);
268                self.last_text_idx = None;
269
270                for row in (0..chart_height).rev() {
271                    self.interaction_count += 1;
272                    self.commands.push(Command::BeginContainer {
273                        direction: Direction::Row,
274                        gap: 1,
275                        align: Align::Start,
276                        justify: Justify::Start,
277                        border: None,
278                        border_sides: BorderSides::all(),
279                        border_style: Style::new().fg(self.theme.border),
280                        bg_color: None,
281                        padding: Padding::default(),
282                        margin: Margin::default(),
283                        constraints: Constraints::default(),
284                        title: None,
285                        grow: 0,
286                        group_name: None,
287                    });
288
289                    let row_base = row * 8;
290                    for (bar, units) in bars.iter().zip(bar_units.iter()) {
291                        let fill = if *units <= row_base {
292                            ' '
293                        } else {
294                            let delta = *units - row_base;
295                            if delta >= 8 {
296                                '█'
297                            } else {
298                                FRACTION_BLOCKS[delta]
299                            }
300                        };
301
302                        self.styled(
303                            center_text(&fill.to_string(), col_width),
304                            Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
305                        );
306                    }
307
308                    self.commands.push(Command::EndContainer);
309                    self.last_text_idx = None;
310                }
311
312                self.interaction_count += 1;
313                self.commands.push(Command::BeginContainer {
314                    direction: Direction::Row,
315                    gap: 1,
316                    align: Align::Start,
317                    justify: Justify::Start,
318                    border: None,
319                    border_sides: BorderSides::all(),
320                    border_style: Style::new().fg(self.theme.border),
321                    bg_color: None,
322                    padding: Padding::default(),
323                    margin: Margin::default(),
324                    constraints: Constraints::default(),
325                    title: None,
326                    grow: 0,
327                    group_name: None,
328                });
329                for bar in bars {
330                    self.styled(
331                        center_text(&bar.label, col_width),
332                        Style::new().fg(self.theme.text),
333                    );
334                }
335                self.commands.push(Command::EndContainer);
336                self.last_text_idx = None;
337
338                self.commands.push(Command::EndContainer);
339                self.last_text_idx = None;
340            }
341        }
342
343        self
344    }
345
346    /// Render a grouped bar chart.
347    ///
348    /// Each group contains multiple bars rendered side by side. Useful for
349    /// comparing categories across groups (e.g., quarterly revenue by product).
350    ///
351    /// # Example
352    /// ```ignore
353    /// # slt::run(|ui: &mut slt::Context| {
354    /// use slt::{Bar, BarGroup, Color};
355    /// let groups = vec![
356    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
357    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
358    /// ];
359    /// ui.bar_chart_grouped(&groups, 40);
360    /// # });
361    /// ```
362    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
363        if groups.is_empty() {
364            return self;
365        }
366
367        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
368        if all_bars.is_empty() {
369            return self;
370        }
371
372        let max_label_width = all_bars
373            .iter()
374            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
375            .max()
376            .unwrap_or(0);
377        let max_value = all_bars
378            .iter()
379            .map(|bar| bar.value)
380            .fold(f64::NEG_INFINITY, f64::max);
381        let denom = if max_value > 0.0 { max_value } else { 1.0 };
382
383        self.interaction_count += 1;
384        self.commands.push(Command::BeginContainer {
385            direction: Direction::Column,
386            gap: 1,
387            align: Align::Start,
388            justify: Justify::Start,
389            border: None,
390            border_sides: BorderSides::all(),
391            border_style: Style::new().fg(self.theme.border),
392            bg_color: None,
393            padding: Padding::default(),
394            margin: Margin::default(),
395            constraints: Constraints::default(),
396            title: None,
397            grow: 0,
398            group_name: None,
399        });
400
401        for group in groups {
402            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
403
404            for bar in &group.bars {
405                let label_width = UnicodeWidthStr::width(bar.label.as_str());
406                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
407                let normalized = (bar.value / denom).clamp(0.0, 1.0);
408                let bar_len = (normalized * max_width as f64).round() as usize;
409                let bar_text = "█".repeat(bar_len);
410
411                self.interaction_count += 1;
412                self.commands.push(Command::BeginContainer {
413                    direction: Direction::Row,
414                    gap: 1,
415                    align: Align::Start,
416                    justify: Justify::Start,
417                    border: None,
418                    border_sides: BorderSides::all(),
419                    border_style: Style::new().fg(self.theme.border),
420                    bg_color: None,
421                    padding: Padding::default(),
422                    margin: Margin::default(),
423                    constraints: Constraints::default(),
424                    title: None,
425                    grow: 0,
426                    group_name: None,
427                });
428                self.styled(
429                    format!("  {}{label_padding}", bar.label),
430                    Style::new().fg(self.theme.text),
431                );
432                self.styled(
433                    bar_text,
434                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
435                );
436                self.styled(
437                    format_compact_number(bar.value),
438                    Style::new().fg(self.theme.text_dim),
439                );
440                self.commands.push(Command::EndContainer);
441                self.last_text_idx = None;
442            }
443        }
444
445        self.commands.push(Command::EndContainer);
446        self.last_text_idx = None;
447
448        self
449    }
450
451    /// Render a single-line sparkline from numeric data.
452    ///
453    /// Uses the last `width` points (or fewer if the data is shorter) and maps
454    /// each point to one of `▁▂▃▄▅▆▇█`.
455    ///
456    /// # Example
457    ///
458    /// ```ignore
459    /// # slt::run(|ui: &mut slt::Context| {
460    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
461    /// ui.sparkline(&samples, 16);
462    ///
463    /// For per-point colors and missing values, see [`sparkline_styled`].
464    /// # });
465    /// ```
466    pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
467        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
468
469        let w = width as usize;
470        let window = if data.len() > w {
471            &data[data.len() - w..]
472        } else {
473            data
474        };
475
476        if window.is_empty() {
477            return self;
478        }
479
480        let min = window.iter().copied().fold(f64::INFINITY, f64::min);
481        let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
482        let range = max - min;
483
484        let line: String = window
485            .iter()
486            .map(|&value| {
487                let normalized = if range == 0.0 {
488                    0.5
489                } else {
490                    (value - min) / range
491                };
492                let idx = (normalized * 7.0).round() as usize;
493                BLOCKS[idx.min(7)]
494            })
495            .collect();
496
497        self.styled(line, Style::new().fg(self.theme.primary))
498    }
499
500    /// Render a sparkline with per-point colors.
501    ///
502    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
503    /// Use `f64::NAN` for absent values (rendered as spaces).
504    ///
505    /// # Example
506    /// ```ignore
507    /// # slt::run(|ui: &mut slt::Context| {
508    /// use slt::Color;
509    /// let data: Vec<(f64, Option<Color>)> = vec![
510    ///     (12.0, Some(Color::Green)),
511    ///     (9.0, Some(Color::Red)),
512    ///     (14.0, Some(Color::Green)),
513    ///     (f64::NAN, None),
514    ///     (18.0, Some(Color::Cyan)),
515    /// ];
516    /// ui.sparkline_styled(&data, 16);
517    /// # });
518    /// ```
519    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
520        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
521
522        let w = width as usize;
523        let window = if data.len() > w {
524            &data[data.len() - w..]
525        } else {
526            data
527        };
528
529        if window.is_empty() {
530            return self;
531        }
532
533        let mut finite_values = window
534            .iter()
535            .map(|(value, _)| *value)
536            .filter(|value| !value.is_nan());
537        let Some(first) = finite_values.next() else {
538            return self.styled(
539                " ".repeat(window.len()),
540                Style::new().fg(self.theme.text_dim),
541            );
542        };
543
544        let mut min = first;
545        let mut max = first;
546        for value in finite_values {
547            min = f64::min(min, value);
548            max = f64::max(max, value);
549        }
550        let range = max - min;
551
552        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
553        for (value, color) in window {
554            if value.is_nan() {
555                cells.push((' ', self.theme.text_dim));
556                continue;
557            }
558
559            let normalized = if range == 0.0 {
560                0.5
561            } else {
562                ((*value - min) / range).clamp(0.0, 1.0)
563            };
564            let idx = (normalized * 7.0).round() as usize;
565            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
566        }
567
568        self.interaction_count += 1;
569        self.commands.push(Command::BeginContainer {
570            direction: Direction::Row,
571            gap: 0,
572            align: Align::Start,
573            justify: Justify::Start,
574            border: None,
575            border_sides: BorderSides::all(),
576            border_style: Style::new().fg(self.theme.border),
577            bg_color: None,
578            padding: Padding::default(),
579            margin: Margin::default(),
580            constraints: Constraints::default(),
581            title: None,
582            grow: 0,
583            group_name: None,
584        });
585
586        let mut seg = String::new();
587        let mut seg_color = cells[0].1;
588        for (ch, color) in cells {
589            if color != seg_color {
590                self.styled(seg, Style::new().fg(seg_color));
591                seg = String::new();
592                seg_color = color;
593            }
594            seg.push(ch);
595        }
596        if !seg.is_empty() {
597            self.styled(seg, Style::new().fg(seg_color));
598        }
599
600        self.commands.push(Command::EndContainer);
601        self.last_text_idx = None;
602
603        self
604    }
605
606    /// Render a multi-row line chart using braille characters.
607    ///
608    /// `width` and `height` are terminal cell dimensions. Internally this uses
609    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
610    ///
611    /// # Example
612    ///
613    /// ```ignore
614    /// # slt::run(|ui: &mut slt::Context| {
615    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
616    /// ui.line_chart(&data, 40, 8);
617    /// # });
618    /// ```
619    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
620        if data.is_empty() || width == 0 || height == 0 {
621            return self;
622        }
623
624        let cols = width as usize;
625        let rows = height as usize;
626        let px_w = cols * 2;
627        let px_h = rows * 4;
628
629        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
630        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
631        let range = if (max - min).abs() < f64::EPSILON {
632            1.0
633        } else {
634            max - min
635        };
636
637        let points: Vec<usize> = (0..px_w)
638            .map(|px| {
639                let data_idx = if px_w <= 1 {
640                    0.0
641                } else {
642                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
643                };
644                let idx = data_idx.floor() as usize;
645                let frac = data_idx - idx as f64;
646                let value = if idx + 1 < data.len() {
647                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
648                } else {
649                    data[idx.min(data.len() - 1)]
650                };
651
652                let normalized = (value - min) / range;
653                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
654                py.min(px_h - 1)
655            })
656            .collect();
657
658        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
659        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
660
661        let mut grid = vec![vec![0u32; cols]; rows];
662
663        for i in 0..points.len() {
664            let px = i;
665            let py = points[i];
666            let char_col = px / 2;
667            let char_row = py / 4;
668            let sub_col = px % 2;
669            let sub_row = py % 4;
670
671            if char_col < cols && char_row < rows {
672                grid[char_row][char_col] |= if sub_col == 0 {
673                    LEFT_BITS[sub_row]
674                } else {
675                    RIGHT_BITS[sub_row]
676                };
677            }
678
679            if i + 1 < points.len() {
680                let py_next = points[i + 1];
681                let (y_start, y_end) = if py <= py_next {
682                    (py, py_next)
683                } else {
684                    (py_next, py)
685                };
686                for y in y_start..=y_end {
687                    let cell_row = y / 4;
688                    let sub_y = y % 4;
689                    if char_col < cols && cell_row < rows {
690                        grid[cell_row][char_col] |= if sub_col == 0 {
691                            LEFT_BITS[sub_y]
692                        } else {
693                            RIGHT_BITS[sub_y]
694                        };
695                    }
696                }
697            }
698        }
699
700        let style = Style::new().fg(self.theme.primary);
701        for row in grid {
702            let line: String = row
703                .iter()
704                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
705                .collect();
706            self.styled(line, style);
707        }
708
709        self
710    }
711
712    /// Render a braille drawing canvas.
713    ///
714    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
715    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
716    /// `height*4` pixel resolution.
717    ///
718    /// # Example
719    ///
720    /// ```ignore
721    /// # slt::run(|ui: &mut slt::Context| {
722    /// ui.canvas(40, 10, |cv| {
723    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
724    ///     cv.circle(40, 20, 15);
725    /// });
726    /// # });
727    /// ```
728    pub fn canvas(
729        &mut self,
730        width: u32,
731        height: u32,
732        draw: impl FnOnce(&mut CanvasContext),
733    ) -> &mut Self {
734        if width == 0 || height == 0 {
735            return self;
736        }
737
738        let mut canvas = CanvasContext::new(width as usize, height as usize);
739        draw(&mut canvas);
740
741        for segments in canvas.render() {
742            self.interaction_count += 1;
743            self.commands.push(Command::BeginContainer {
744                direction: Direction::Row,
745                gap: 0,
746                align: Align::Start,
747                justify: Justify::Start,
748                border: None,
749                border_sides: BorderSides::all(),
750                border_style: Style::new(),
751                bg_color: None,
752                padding: Padding::default(),
753                margin: Margin::default(),
754                constraints: Constraints::default(),
755                title: None,
756                grow: 0,
757                group_name: None,
758            });
759            for (text, color) in segments {
760                let c = if color == Color::Reset {
761                    self.theme.primary
762                } else {
763                    color
764                };
765                self.styled(text, Style::new().fg(c));
766            }
767            self.commands.push(Command::EndContainer);
768            self.last_text_idx = None;
769        }
770
771        self
772    }
773
774    /// Render a multi-series chart with axes, legend, and auto-scaling.
775    pub fn chart(
776        &mut self,
777        configure: impl FnOnce(&mut ChartBuilder),
778        width: u32,
779        height: u32,
780    ) -> &mut Self {
781        if width == 0 || height == 0 {
782            return self;
783        }
784
785        let axis_style = Style::new().fg(self.theme.text_dim);
786        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
787        configure(&mut builder);
788
789        let config = builder.build();
790        let rows = render_chart(&config);
791
792        for row in rows {
793            self.interaction_count += 1;
794            self.commands.push(Command::BeginContainer {
795                direction: Direction::Row,
796                gap: 0,
797                align: Align::Start,
798                justify: Justify::Start,
799                border: None,
800                border_sides: BorderSides::all(),
801                border_style: Style::new().fg(self.theme.border),
802                bg_color: None,
803                padding: Padding::default(),
804                margin: Margin::default(),
805                constraints: Constraints::default(),
806                title: None,
807                grow: 0,
808                group_name: None,
809            });
810            for (text, style) in row.segments {
811                self.styled(text, style);
812            }
813            self.commands.push(Command::EndContainer);
814            self.last_text_idx = None;
815        }
816
817        self
818    }
819
820    /// Renders a scatter plot.
821    ///
822    /// Each point is a (x, y) tuple. Uses braille markers.
823    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> &mut Self {
824        self.chart(
825            |c| {
826                c.scatter(data);
827                c.grid(true);
828            },
829            width,
830            height,
831        )
832    }
833
834    /// Render a histogram from raw data with auto-binning.
835    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
836        self.histogram_with(data, |_| {}, width, height)
837    }
838
839    /// Render a histogram with configuration options.
840    pub fn histogram_with(
841        &mut self,
842        data: &[f64],
843        configure: impl FnOnce(&mut HistogramBuilder),
844        width: u32,
845        height: u32,
846    ) -> &mut Self {
847        if width == 0 || height == 0 {
848            return self;
849        }
850
851        let mut options = HistogramBuilder::default();
852        configure(&mut options);
853        let axis_style = Style::new().fg(self.theme.text_dim);
854        let config = build_histogram_config(data, &options, width, height, axis_style);
855        let rows = render_chart(&config);
856
857        for row in rows {
858            self.interaction_count += 1;
859            self.commands.push(Command::BeginContainer {
860                direction: Direction::Row,
861                gap: 0,
862                align: Align::Start,
863                justify: Justify::Start,
864                border: None,
865                border_sides: BorderSides::all(),
866                border_style: Style::new().fg(self.theme.border),
867                bg_color: None,
868                padding: Padding::default(),
869                margin: Margin::default(),
870                constraints: Constraints::default(),
871                title: None,
872                grow: 0,
873                group_name: None,
874            });
875            for (text, style) in row.segments {
876                self.styled(text, style);
877            }
878            self.commands.push(Command::EndContainer);
879            self.last_text_idx = None;
880        }
881
882        self
883    }
884
885    /// Render children in a fixed grid with the given number of columns.
886    ///
887    /// Children are placed left-to-right, top-to-bottom. Each cell has equal
888    /// width (`area_width / cols`). Rows wrap automatically.
889    ///
890    /// # Example
891    ///
892    /// ```no_run
893    /// # slt::run(|ui: &mut slt::Context| {
894    /// ui.grid(3, |ui| {
895    ///     for i in 0..9 {
896    ///         ui.text(format!("Cell {i}"));
897    ///     }
898    /// });
899    /// # });
900    /// ```
901    pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
902        slt_assert(cols > 0, "grid() requires at least 1 column");
903        let interaction_id = self.interaction_count;
904        self.interaction_count += 1;
905        let border = self.theme.border;
906
907        self.commands.push(Command::BeginContainer {
908            direction: Direction::Column,
909            gap: 0,
910            align: Align::Start,
911            justify: Justify::Start,
912            border: None,
913            border_sides: BorderSides::all(),
914            border_style: Style::new().fg(border),
915            bg_color: None,
916            padding: Padding::default(),
917            margin: Margin::default(),
918            constraints: Constraints::default(),
919            title: None,
920            grow: 0,
921            group_name: None,
922        });
923
924        let children_start = self.commands.len();
925        f(self);
926        let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
927
928        let mut elements: Vec<Vec<Command>> = Vec::new();
929        let mut iter = child_commands.into_iter().peekable();
930        while let Some(cmd) = iter.next() {
931            match cmd {
932                Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
933                    let mut depth = 1_u32;
934                    let mut element = vec![cmd];
935                    for next in iter.by_ref() {
936                        match next {
937                            Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
938                                depth += 1;
939                            }
940                            Command::EndContainer => {
941                                depth = depth.saturating_sub(1);
942                            }
943                            _ => {}
944                        }
945                        let at_end = matches!(next, Command::EndContainer) && depth == 0;
946                        element.push(next);
947                        if at_end {
948                            break;
949                        }
950                    }
951                    elements.push(element);
952                }
953                Command::EndContainer => {}
954                _ => elements.push(vec![cmd]),
955            }
956        }
957
958        let cols = cols.max(1) as usize;
959        for row in elements.chunks(cols) {
960            self.interaction_count += 1;
961            self.commands.push(Command::BeginContainer {
962                direction: Direction::Row,
963                gap: 0,
964                align: Align::Start,
965                justify: Justify::Start,
966                border: None,
967                border_sides: BorderSides::all(),
968                border_style: Style::new().fg(border),
969                bg_color: None,
970                padding: Padding::default(),
971                margin: Margin::default(),
972                constraints: Constraints::default(),
973                title: None,
974                grow: 0,
975                group_name: None,
976            });
977
978            for element in row {
979                self.interaction_count += 1;
980                self.commands.push(Command::BeginContainer {
981                    direction: Direction::Column,
982                    gap: 0,
983                    align: Align::Start,
984                    justify: Justify::Start,
985                    border: None,
986                    border_sides: BorderSides::all(),
987                    border_style: Style::new().fg(border),
988                    bg_color: None,
989                    padding: Padding::default(),
990                    margin: Margin::default(),
991                    constraints: Constraints::default(),
992                    title: None,
993                    grow: 1,
994                    group_name: None,
995                });
996                self.commands.extend(element.iter().cloned());
997                self.commands.push(Command::EndContainer);
998            }
999
1000            self.commands.push(Command::EndContainer);
1001        }
1002
1003        self.commands.push(Command::EndContainer);
1004        self.last_text_idx = None;
1005
1006        self.response_for(interaction_id)
1007    }
1008
1009    /// Render a selectable list. Handles Up/Down (and `k`/`j`) navigation when focused.
1010    ///
1011    /// The selected item is highlighted with the theme's primary color. If the
1012    /// list is empty, nothing is rendered.
1013    pub fn list(&mut self, state: &mut ListState) -> &mut Self {
1014        let visible = state.visible_indices().to_vec();
1015        if visible.is_empty() && state.items.is_empty() {
1016            state.selected = 0;
1017            return self;
1018        }
1019
1020        if !visible.is_empty() {
1021            state.selected = state.selected.min(visible.len().saturating_sub(1));
1022        }
1023
1024        let focused = self.register_focusable();
1025        let interaction_id = self.interaction_count;
1026        self.interaction_count += 1;
1027
1028        if focused {
1029            let mut consumed_indices = Vec::new();
1030            for (i, event) in self.events.iter().enumerate() {
1031                if let Event::Key(key) = event {
1032                    if key.kind != KeyEventKind::Press {
1033                        continue;
1034                    }
1035                    match key.code {
1036                        KeyCode::Up | KeyCode::Char('k') => {
1037                            state.selected = state.selected.saturating_sub(1);
1038                            consumed_indices.push(i);
1039                        }
1040                        KeyCode::Down | KeyCode::Char('j') => {
1041                            state.selected =
1042                                (state.selected + 1).min(visible.len().saturating_sub(1));
1043                            consumed_indices.push(i);
1044                        }
1045                        _ => {}
1046                    }
1047                }
1048            }
1049
1050            for index in consumed_indices {
1051                self.consumed[index] = true;
1052            }
1053        }
1054
1055        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1056            for (i, event) in self.events.iter().enumerate() {
1057                if self.consumed[i] {
1058                    continue;
1059                }
1060                if let Event::Mouse(mouse) = event {
1061                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1062                        continue;
1063                    }
1064                    let in_bounds = mouse.x >= rect.x
1065                        && mouse.x < rect.right()
1066                        && mouse.y >= rect.y
1067                        && mouse.y < rect.bottom();
1068                    if !in_bounds {
1069                        continue;
1070                    }
1071                    let clicked_idx = (mouse.y - rect.y) as usize;
1072                    if clicked_idx < visible.len() {
1073                        state.selected = clicked_idx;
1074                        self.consumed[i] = true;
1075                    }
1076                }
1077            }
1078        }
1079
1080        self.commands.push(Command::BeginContainer {
1081            direction: Direction::Column,
1082            gap: 0,
1083            align: Align::Start,
1084            justify: Justify::Start,
1085            border: None,
1086            border_sides: BorderSides::all(),
1087            border_style: Style::new().fg(self.theme.border),
1088            bg_color: None,
1089            padding: Padding::default(),
1090            margin: Margin::default(),
1091            constraints: Constraints::default(),
1092            title: None,
1093            grow: 0,
1094            group_name: None,
1095        });
1096
1097        for (view_idx, &item_idx) in visible.iter().enumerate() {
1098            let item = &state.items[item_idx];
1099            if view_idx == state.selected {
1100                if focused {
1101                    self.styled(
1102                        format!("▸ {item}"),
1103                        Style::new().bold().fg(self.theme.primary),
1104                    );
1105                } else {
1106                    self.styled(format!("▸ {item}"), Style::new().fg(self.theme.primary));
1107                }
1108            } else {
1109                self.styled(format!("  {item}"), Style::new().fg(self.theme.text));
1110            }
1111        }
1112
1113        self.commands.push(Command::EndContainer);
1114        self.last_text_idx = None;
1115
1116        self
1117    }
1118
1119    /// Render a data table with column headers. Handles Up/Down selection when focused.
1120    ///
1121    /// Column widths are computed automatically from header and cell content.
1122    /// The selected row is highlighted with the theme's selection colors.
1123    pub fn table(&mut self, state: &mut TableState) -> &mut Self {
1124        if state.is_dirty() {
1125            state.recompute_widths();
1126        }
1127
1128        let focused = self.register_focusable();
1129        let interaction_id = self.interaction_count;
1130        self.interaction_count += 1;
1131
1132        if focused && !state.visible_indices().is_empty() {
1133            let mut consumed_indices = Vec::new();
1134            for (i, event) in self.events.iter().enumerate() {
1135                if let Event::Key(key) = event {
1136                    if key.kind != KeyEventKind::Press {
1137                        continue;
1138                    }
1139                    match key.code {
1140                        KeyCode::Up | KeyCode::Char('k') => {
1141                            let visible_len = if state.page_size > 0 {
1142                                let start = state
1143                                    .page
1144                                    .saturating_mul(state.page_size)
1145                                    .min(state.visible_indices().len());
1146                                let end =
1147                                    (start + state.page_size).min(state.visible_indices().len());
1148                                end.saturating_sub(start)
1149                            } else {
1150                                state.visible_indices().len()
1151                            };
1152                            state.selected = state.selected.min(visible_len.saturating_sub(1));
1153                            state.selected = state.selected.saturating_sub(1);
1154                            consumed_indices.push(i);
1155                        }
1156                        KeyCode::Down | KeyCode::Char('j') => {
1157                            let visible_len = if state.page_size > 0 {
1158                                let start = state
1159                                    .page
1160                                    .saturating_mul(state.page_size)
1161                                    .min(state.visible_indices().len());
1162                                let end =
1163                                    (start + state.page_size).min(state.visible_indices().len());
1164                                end.saturating_sub(start)
1165                            } else {
1166                                state.visible_indices().len()
1167                            };
1168                            state.selected =
1169                                (state.selected + 1).min(visible_len.saturating_sub(1));
1170                            consumed_indices.push(i);
1171                        }
1172                        KeyCode::PageUp => {
1173                            let old_page = state.page;
1174                            state.prev_page();
1175                            if state.page != old_page {
1176                                state.selected = 0;
1177                            }
1178                            consumed_indices.push(i);
1179                        }
1180                        KeyCode::PageDown => {
1181                            let old_page = state.page;
1182                            state.next_page();
1183                            if state.page != old_page {
1184                                state.selected = 0;
1185                            }
1186                            consumed_indices.push(i);
1187                        }
1188                        _ => {}
1189                    }
1190                }
1191            }
1192            for index in consumed_indices {
1193                self.consumed[index] = true;
1194            }
1195        }
1196
1197        if !state.visible_indices().is_empty() || !state.headers.is_empty() {
1198            if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1199                for (i, event) in self.events.iter().enumerate() {
1200                    if self.consumed[i] {
1201                        continue;
1202                    }
1203                    if let Event::Mouse(mouse) = event {
1204                        if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1205                            continue;
1206                        }
1207                        let in_bounds = mouse.x >= rect.x
1208                            && mouse.x < rect.right()
1209                            && mouse.y >= rect.y
1210                            && mouse.y < rect.bottom();
1211                        if !in_bounds {
1212                            continue;
1213                        }
1214
1215                        if mouse.y == rect.y {
1216                            let rel_x = mouse.x.saturating_sub(rect.x);
1217                            let mut x_offset = 0u32;
1218                            for (col_idx, width) in state.column_widths().iter().enumerate() {
1219                                if rel_x >= x_offset && rel_x < x_offset + *width {
1220                                    state.toggle_sort(col_idx);
1221                                    state.selected = 0;
1222                                    self.consumed[i] = true;
1223                                    break;
1224                                }
1225                                x_offset += *width;
1226                                if col_idx + 1 < state.column_widths().len() {
1227                                    x_offset += 3;
1228                                }
1229                            }
1230                            continue;
1231                        }
1232
1233                        if mouse.y < rect.y + 2 {
1234                            continue;
1235                        }
1236
1237                        let visible_len = if state.page_size > 0 {
1238                            let start = state
1239                                .page
1240                                .saturating_mul(state.page_size)
1241                                .min(state.visible_indices().len());
1242                            let end = (start + state.page_size).min(state.visible_indices().len());
1243                            end.saturating_sub(start)
1244                        } else {
1245                            state.visible_indices().len()
1246                        };
1247                        let clicked_idx = (mouse.y - rect.y - 2) as usize;
1248                        if clicked_idx < visible_len {
1249                            state.selected = clicked_idx;
1250                            self.consumed[i] = true;
1251                        }
1252                    }
1253                }
1254            }
1255        }
1256
1257        if state.is_dirty() {
1258            state.recompute_widths();
1259        }
1260
1261        let total_visible = state.visible_indices().len();
1262        let page_start = if state.page_size > 0 {
1263            state
1264                .page
1265                .saturating_mul(state.page_size)
1266                .min(total_visible)
1267        } else {
1268            0
1269        };
1270        let page_end = if state.page_size > 0 {
1271            (page_start + state.page_size).min(total_visible)
1272        } else {
1273            total_visible
1274        };
1275        let visible_len = page_end.saturating_sub(page_start);
1276        state.selected = state.selected.min(visible_len.saturating_sub(1));
1277
1278        self.commands.push(Command::BeginContainer {
1279            direction: Direction::Column,
1280            gap: 0,
1281            align: Align::Start,
1282            justify: Justify::Start,
1283            border: None,
1284            border_sides: BorderSides::all(),
1285            border_style: Style::new().fg(self.theme.border),
1286            bg_color: None,
1287            padding: Padding::default(),
1288            margin: Margin::default(),
1289            constraints: Constraints::default(),
1290            title: None,
1291            grow: 0,
1292            group_name: None,
1293        });
1294
1295        let header_cells = state
1296            .headers
1297            .iter()
1298            .enumerate()
1299            .map(|(i, header)| {
1300                if state.sort_column == Some(i) {
1301                    if state.sort_ascending {
1302                        format!("{header} ▲")
1303                    } else {
1304                        format!("{header} ▼")
1305                    }
1306                } else {
1307                    header.clone()
1308                }
1309            })
1310            .collect::<Vec<_>>();
1311        let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
1312        self.styled(header_line, Style::new().bold().fg(self.theme.text));
1313
1314        let separator = state
1315            .column_widths()
1316            .iter()
1317            .map(|w| "─".repeat(*w as usize))
1318            .collect::<Vec<_>>()
1319            .join("─┼─");
1320        self.text(separator);
1321
1322        for idx in 0..visible_len {
1323            let data_idx = state.visible_indices()[page_start + idx];
1324            let Some(row) = state.rows.get(data_idx) else {
1325                continue;
1326            };
1327            let line = format_table_row(row, state.column_widths(), " │ ");
1328            if idx == state.selected {
1329                let mut style = Style::new()
1330                    .bg(self.theme.selected_bg)
1331                    .fg(self.theme.selected_fg);
1332                if focused {
1333                    style = style.bold();
1334                }
1335                self.styled(line, style);
1336            } else {
1337                self.styled(line, Style::new().fg(self.theme.text));
1338            }
1339        }
1340
1341        if state.page_size > 0 && state.total_pages() > 1 {
1342            self.styled(
1343                format!("Page {}/{}", state.page + 1, state.total_pages()),
1344                Style::new().dim().fg(self.theme.text_dim),
1345            );
1346        }
1347
1348        self.commands.push(Command::EndContainer);
1349        self.last_text_idx = None;
1350
1351        self
1352    }
1353
1354    /// Render a tab bar. Handles Left/Right navigation when focused.
1355    ///
1356    /// The active tab is rendered in the theme's primary color. If the labels
1357    /// list is empty, nothing is rendered.
1358    pub fn tabs(&mut self, state: &mut TabsState) -> &mut Self {
1359        if state.labels.is_empty() {
1360            state.selected = 0;
1361            return self;
1362        }
1363
1364        state.selected = state.selected.min(state.labels.len().saturating_sub(1));
1365        let focused = self.register_focusable();
1366        let interaction_id = self.interaction_count;
1367
1368        if focused {
1369            let mut consumed_indices = Vec::new();
1370            for (i, event) in self.events.iter().enumerate() {
1371                if let Event::Key(key) = event {
1372                    if key.kind != KeyEventKind::Press {
1373                        continue;
1374                    }
1375                    match key.code {
1376                        KeyCode::Left => {
1377                            state.selected = if state.selected == 0 {
1378                                state.labels.len().saturating_sub(1)
1379                            } else {
1380                                state.selected - 1
1381                            };
1382                            consumed_indices.push(i);
1383                        }
1384                        KeyCode::Right => {
1385                            if !state.labels.is_empty() {
1386                                state.selected = (state.selected + 1) % state.labels.len();
1387                            }
1388                            consumed_indices.push(i);
1389                        }
1390                        _ => {}
1391                    }
1392                }
1393            }
1394
1395            for index in consumed_indices {
1396                self.consumed[index] = true;
1397            }
1398        }
1399
1400        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1401            for (i, event) in self.events.iter().enumerate() {
1402                if self.consumed[i] {
1403                    continue;
1404                }
1405                if let Event::Mouse(mouse) = event {
1406                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1407                        continue;
1408                    }
1409                    let in_bounds = mouse.x >= rect.x
1410                        && mouse.x < rect.right()
1411                        && mouse.y >= rect.y
1412                        && mouse.y < rect.bottom();
1413                    if !in_bounds {
1414                        continue;
1415                    }
1416
1417                    let mut x_offset = 0u32;
1418                    let rel_x = mouse.x - rect.x;
1419                    for (idx, label) in state.labels.iter().enumerate() {
1420                        let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
1421                        if rel_x >= x_offset && rel_x < x_offset + tab_width {
1422                            state.selected = idx;
1423                            self.consumed[i] = true;
1424                            break;
1425                        }
1426                        x_offset += tab_width + 1;
1427                    }
1428                }
1429            }
1430        }
1431
1432        self.interaction_count += 1;
1433        self.commands.push(Command::BeginContainer {
1434            direction: Direction::Row,
1435            gap: 1,
1436            align: Align::Start,
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 (idx, label) in state.labels.iter().enumerate() {
1450            let style = if idx == state.selected {
1451                let s = Style::new().fg(self.theme.primary).bold();
1452                if focused {
1453                    s.underline()
1454                } else {
1455                    s
1456                }
1457            } else {
1458                Style::new().fg(self.theme.text_dim)
1459            };
1460            self.styled(format!("[ {label} ]"), style);
1461        }
1462        self.commands.push(Command::EndContainer);
1463        self.last_text_idx = None;
1464
1465        self
1466    }
1467
1468    /// Render a clickable button. Returns `true` when activated via Enter, Space, or mouse click.
1469    ///
1470    /// The button is styled with the theme's primary color when focused and the
1471    /// accent color when hovered.
1472    pub fn button(&mut self, label: impl Into<String>) -> bool {
1473        let focused = self.register_focusable();
1474        let interaction_id = self.interaction_count;
1475        self.interaction_count += 1;
1476        let response = self.response_for(interaction_id);
1477
1478        let mut activated = response.clicked;
1479        if focused {
1480            let mut consumed_indices = Vec::new();
1481            for (i, event) in self.events.iter().enumerate() {
1482                if let Event::Key(key) = event {
1483                    if key.kind != KeyEventKind::Press {
1484                        continue;
1485                    }
1486                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1487                        activated = true;
1488                        consumed_indices.push(i);
1489                    }
1490                }
1491            }
1492
1493            for index in consumed_indices {
1494                self.consumed[index] = true;
1495            }
1496        }
1497
1498        let hovered = response.hovered;
1499        let style = if focused {
1500            Style::new().fg(self.theme.primary).bold()
1501        } else if hovered {
1502            Style::new().fg(self.theme.accent)
1503        } else {
1504            Style::new().fg(self.theme.text)
1505        };
1506        let hover_bg = if hovered || focused {
1507            Some(self.theme.surface_hover)
1508        } else {
1509            None
1510        };
1511
1512        self.commands.push(Command::BeginContainer {
1513            direction: Direction::Row,
1514            gap: 0,
1515            align: Align::Start,
1516            justify: Justify::Start,
1517            border: None,
1518            border_sides: BorderSides::all(),
1519            border_style: Style::new().fg(self.theme.border),
1520            bg_color: hover_bg,
1521            padding: Padding::default(),
1522            margin: Margin::default(),
1523            constraints: Constraints::default(),
1524            title: None,
1525            grow: 0,
1526            group_name: None,
1527        });
1528        self.styled(format!("[ {} ]", label.into()), style);
1529        self.commands.push(Command::EndContainer);
1530        self.last_text_idx = None;
1531
1532        activated
1533    }
1534
1535    /// Render a styled button variant. Returns `true` when activated.
1536    ///
1537    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
1538    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
1539    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> bool {
1540        let focused = self.register_focusable();
1541        let interaction_id = self.interaction_count;
1542        self.interaction_count += 1;
1543        let response = self.response_for(interaction_id);
1544
1545        let mut activated = response.clicked;
1546        if focused {
1547            let mut consumed_indices = Vec::new();
1548            for (i, event) in self.events.iter().enumerate() {
1549                if let Event::Key(key) = event {
1550                    if key.kind != KeyEventKind::Press {
1551                        continue;
1552                    }
1553                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1554                        activated = true;
1555                        consumed_indices.push(i);
1556                    }
1557                }
1558            }
1559            for index in consumed_indices {
1560                self.consumed[index] = true;
1561            }
1562        }
1563
1564        let label = label.into();
1565        let hover_bg = if response.hovered || focused {
1566            Some(self.theme.surface_hover)
1567        } else {
1568            None
1569        };
1570        let (text, style, bg_color, border) = match variant {
1571            ButtonVariant::Default => {
1572                let style = if focused {
1573                    Style::new().fg(self.theme.primary).bold()
1574                } else if response.hovered {
1575                    Style::new().fg(self.theme.accent)
1576                } else {
1577                    Style::new().fg(self.theme.text)
1578                };
1579                (format!("[ {label} ]"), style, hover_bg, None)
1580            }
1581            ButtonVariant::Primary => {
1582                let style = if focused {
1583                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
1584                } else if response.hovered {
1585                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
1586                } else {
1587                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
1588                };
1589                (format!(" {label} "), style, hover_bg, None)
1590            }
1591            ButtonVariant::Danger => {
1592                let style = if focused {
1593                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
1594                } else if response.hovered {
1595                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
1596                } else {
1597                    Style::new().fg(self.theme.bg).bg(self.theme.error)
1598                };
1599                (format!(" {label} "), style, hover_bg, None)
1600            }
1601            ButtonVariant::Outline => {
1602                let border_color = if focused {
1603                    self.theme.primary
1604                } else if response.hovered {
1605                    self.theme.accent
1606                } else {
1607                    self.theme.border
1608                };
1609                let style = if focused {
1610                    Style::new().fg(self.theme.primary).bold()
1611                } else if response.hovered {
1612                    Style::new().fg(self.theme.accent)
1613                } else {
1614                    Style::new().fg(self.theme.text)
1615                };
1616                (
1617                    format!(" {label} "),
1618                    style,
1619                    hover_bg,
1620                    Some((Border::Rounded, Style::new().fg(border_color))),
1621                )
1622            }
1623        };
1624
1625        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
1626        self.commands.push(Command::BeginContainer {
1627            direction: Direction::Row,
1628            gap: 0,
1629            align: Align::Center,
1630            justify: Justify::Center,
1631            border: if border.is_some() {
1632                Some(btn_border)
1633            } else {
1634                None
1635            },
1636            border_sides: BorderSides::all(),
1637            border_style: btn_border_style,
1638            bg_color,
1639            padding: Padding::default(),
1640            margin: Margin::default(),
1641            constraints: Constraints::default(),
1642            title: None,
1643            grow: 0,
1644            group_name: None,
1645        });
1646        self.styled(text, style);
1647        self.commands.push(Command::EndContainer);
1648        self.last_text_idx = None;
1649
1650        activated
1651    }
1652
1653    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
1654    ///
1655    /// The checked state is shown with the theme's success color. When focused,
1656    /// a `▸` prefix is added.
1657    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> &mut Self {
1658        let focused = self.register_focusable();
1659        let interaction_id = self.interaction_count;
1660        self.interaction_count += 1;
1661        let response = self.response_for(interaction_id);
1662        let mut should_toggle = response.clicked;
1663
1664        if focused {
1665            let mut consumed_indices = Vec::new();
1666            for (i, event) in self.events.iter().enumerate() {
1667                if let Event::Key(key) = event {
1668                    if key.kind != KeyEventKind::Press {
1669                        continue;
1670                    }
1671                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1672                        should_toggle = true;
1673                        consumed_indices.push(i);
1674                    }
1675                }
1676            }
1677
1678            for index in consumed_indices {
1679                self.consumed[index] = true;
1680            }
1681        }
1682
1683        if should_toggle {
1684            *checked = !*checked;
1685        }
1686
1687        let hover_bg = if response.hovered || focused {
1688            Some(self.theme.surface_hover)
1689        } else {
1690            None
1691        };
1692        self.commands.push(Command::BeginContainer {
1693            direction: Direction::Row,
1694            gap: 1,
1695            align: Align::Start,
1696            justify: Justify::Start,
1697            border: None,
1698            border_sides: BorderSides::all(),
1699            border_style: Style::new().fg(self.theme.border),
1700            bg_color: hover_bg,
1701            padding: Padding::default(),
1702            margin: Margin::default(),
1703            constraints: Constraints::default(),
1704            title: None,
1705            grow: 0,
1706            group_name: None,
1707        });
1708        let marker_style = if *checked {
1709            Style::new().fg(self.theme.success)
1710        } else {
1711            Style::new().fg(self.theme.text_dim)
1712        };
1713        let marker = if *checked { "[x]" } else { "[ ]" };
1714        let label_text = label.into();
1715        if focused {
1716            self.styled(format!("▸ {marker}"), marker_style.bold());
1717            self.styled(label_text, Style::new().fg(self.theme.text).bold());
1718        } else {
1719            self.styled(marker, marker_style);
1720            self.styled(label_text, Style::new().fg(self.theme.text));
1721        }
1722        self.commands.push(Command::EndContainer);
1723        self.last_text_idx = None;
1724
1725        self
1726    }
1727
1728    /// Render an on/off toggle switch.
1729    ///
1730    /// Toggles `on` when activated via Enter, Space, or click. The switch
1731    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
1732    /// dim color respectively.
1733    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> &mut Self {
1734        let focused = self.register_focusable();
1735        let interaction_id = self.interaction_count;
1736        self.interaction_count += 1;
1737        let response = self.response_for(interaction_id);
1738        let mut should_toggle = response.clicked;
1739
1740        if focused {
1741            let mut consumed_indices = Vec::new();
1742            for (i, event) in self.events.iter().enumerate() {
1743                if let Event::Key(key) = event {
1744                    if key.kind != KeyEventKind::Press {
1745                        continue;
1746                    }
1747                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1748                        should_toggle = true;
1749                        consumed_indices.push(i);
1750                    }
1751                }
1752            }
1753
1754            for index in consumed_indices {
1755                self.consumed[index] = true;
1756            }
1757        }
1758
1759        if should_toggle {
1760            *on = !*on;
1761        }
1762
1763        let hover_bg = if response.hovered || focused {
1764            Some(self.theme.surface_hover)
1765        } else {
1766            None
1767        };
1768        self.commands.push(Command::BeginContainer {
1769            direction: Direction::Row,
1770            gap: 2,
1771            align: Align::Start,
1772            justify: Justify::Start,
1773            border: None,
1774            border_sides: BorderSides::all(),
1775            border_style: Style::new().fg(self.theme.border),
1776            bg_color: hover_bg,
1777            padding: Padding::default(),
1778            margin: Margin::default(),
1779            constraints: Constraints::default(),
1780            title: None,
1781            grow: 0,
1782            group_name: None,
1783        });
1784        let label_text = label.into();
1785        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1786        let switch_style = if *on {
1787            Style::new().fg(self.theme.success)
1788        } else {
1789            Style::new().fg(self.theme.text_dim)
1790        };
1791        if focused {
1792            self.styled(
1793                format!("▸ {label_text}"),
1794                Style::new().fg(self.theme.text).bold(),
1795            );
1796            self.styled(switch, switch_style.bold());
1797        } else {
1798            self.styled(label_text, Style::new().fg(self.theme.text));
1799            self.styled(switch, switch_style);
1800        }
1801        self.commands.push(Command::EndContainer);
1802        self.last_text_idx = None;
1803
1804        self
1805    }
1806
1807    // ── select / dropdown ─────────────────────────────────────────────
1808
1809    /// Render a dropdown select. Shows the selected item; expands on activation.
1810    ///
1811    /// Returns `true` when the selection changed this frame.
1812    pub fn select(&mut self, state: &mut SelectState) -> bool {
1813        if state.items.is_empty() {
1814            return false;
1815        }
1816        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1817
1818        let focused = self.register_focusable();
1819        let interaction_id = self.interaction_count;
1820        self.interaction_count += 1;
1821        let response = self.response_for(interaction_id);
1822        let old_selected = state.selected;
1823
1824        if response.clicked {
1825            state.open = !state.open;
1826            if state.open {
1827                state.set_cursor(state.selected);
1828            }
1829        }
1830
1831        if focused {
1832            let mut consumed_indices = Vec::new();
1833            for (i, event) in self.events.iter().enumerate() {
1834                if self.consumed[i] {
1835                    continue;
1836                }
1837                if let Event::Key(key) = event {
1838                    if key.kind != KeyEventKind::Press {
1839                        continue;
1840                    }
1841                    if state.open {
1842                        match key.code {
1843                            KeyCode::Up | KeyCode::Char('k') => {
1844                                let c = state.cursor();
1845                                state.set_cursor(c.saturating_sub(1));
1846                                consumed_indices.push(i);
1847                            }
1848                            KeyCode::Down | KeyCode::Char('j') => {
1849                                let c = state.cursor();
1850                                state.set_cursor((c + 1).min(state.items.len().saturating_sub(1)));
1851                                consumed_indices.push(i);
1852                            }
1853                            KeyCode::Enter | KeyCode::Char(' ') => {
1854                                state.selected = state.cursor();
1855                                state.open = false;
1856                                consumed_indices.push(i);
1857                            }
1858                            KeyCode::Esc => {
1859                                state.open = false;
1860                                consumed_indices.push(i);
1861                            }
1862                            _ => {}
1863                        }
1864                    } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1865                        state.open = true;
1866                        state.set_cursor(state.selected);
1867                        consumed_indices.push(i);
1868                    }
1869                }
1870            }
1871            for idx in consumed_indices {
1872                self.consumed[idx] = true;
1873            }
1874        }
1875
1876        let changed = state.selected != old_selected;
1877
1878        let border_color = if focused {
1879            self.theme.primary
1880        } else {
1881            self.theme.border
1882        };
1883        let display_text = state
1884            .items
1885            .get(state.selected)
1886            .cloned()
1887            .unwrap_or_else(|| state.placeholder.clone());
1888        let arrow = if state.open { "▲" } else { "▼" };
1889
1890        self.commands.push(Command::BeginContainer {
1891            direction: Direction::Column,
1892            gap: 0,
1893            align: Align::Start,
1894            justify: Justify::Start,
1895            border: None,
1896            border_sides: BorderSides::all(),
1897            border_style: Style::new().fg(self.theme.border),
1898            bg_color: None,
1899            padding: Padding::default(),
1900            margin: Margin::default(),
1901            constraints: Constraints::default(),
1902            title: None,
1903            grow: 0,
1904            group_name: None,
1905        });
1906
1907        self.commands.push(Command::BeginContainer {
1908            direction: Direction::Row,
1909            gap: 1,
1910            align: Align::Start,
1911            justify: Justify::Start,
1912            border: Some(Border::Rounded),
1913            border_sides: BorderSides::all(),
1914            border_style: Style::new().fg(border_color),
1915            bg_color: None,
1916            padding: Padding {
1917                left: 1,
1918                right: 1,
1919                top: 0,
1920                bottom: 0,
1921            },
1922            margin: Margin::default(),
1923            constraints: Constraints::default(),
1924            title: None,
1925            grow: 0,
1926            group_name: None,
1927        });
1928        self.interaction_count += 1;
1929        self.styled(&display_text, Style::new().fg(self.theme.text));
1930        self.styled(arrow, Style::new().fg(self.theme.text_dim));
1931        self.commands.push(Command::EndContainer);
1932        self.last_text_idx = None;
1933
1934        if state.open {
1935            for (idx, item) in state.items.iter().enumerate() {
1936                let is_cursor = idx == state.cursor();
1937                let style = if is_cursor {
1938                    Style::new().bold().fg(self.theme.primary)
1939                } else {
1940                    Style::new().fg(self.theme.text)
1941                };
1942                let prefix = if is_cursor { "▸ " } else { "  " };
1943                self.styled(format!("{prefix}{item}"), style);
1944            }
1945        }
1946
1947        self.commands.push(Command::EndContainer);
1948        self.last_text_idx = None;
1949        changed
1950    }
1951
1952    // ── radio ────────────────────────────────────────────────────────
1953
1954    /// Render a radio button group. Returns `true` when selection changed.
1955    pub fn radio(&mut self, state: &mut RadioState) -> bool {
1956        if state.items.is_empty() {
1957            return false;
1958        }
1959        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1960        let focused = self.register_focusable();
1961        let old_selected = state.selected;
1962
1963        if focused {
1964            let mut consumed_indices = Vec::new();
1965            for (i, event) in self.events.iter().enumerate() {
1966                if self.consumed[i] {
1967                    continue;
1968                }
1969                if let Event::Key(key) = event {
1970                    if key.kind != KeyEventKind::Press {
1971                        continue;
1972                    }
1973                    match key.code {
1974                        KeyCode::Up | KeyCode::Char('k') => {
1975                            state.selected = state.selected.saturating_sub(1);
1976                            consumed_indices.push(i);
1977                        }
1978                        KeyCode::Down | KeyCode::Char('j') => {
1979                            state.selected =
1980                                (state.selected + 1).min(state.items.len().saturating_sub(1));
1981                            consumed_indices.push(i);
1982                        }
1983                        KeyCode::Enter | KeyCode::Char(' ') => {
1984                            consumed_indices.push(i);
1985                        }
1986                        _ => {}
1987                    }
1988                }
1989            }
1990            for idx in consumed_indices {
1991                self.consumed[idx] = true;
1992            }
1993        }
1994
1995        let interaction_id = self.interaction_count;
1996        self.interaction_count += 1;
1997
1998        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1999            for (i, event) in self.events.iter().enumerate() {
2000                if self.consumed[i] {
2001                    continue;
2002                }
2003                if let Event::Mouse(mouse) = event {
2004                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2005                        continue;
2006                    }
2007                    let in_bounds = mouse.x >= rect.x
2008                        && mouse.x < rect.right()
2009                        && mouse.y >= rect.y
2010                        && mouse.y < rect.bottom();
2011                    if !in_bounds {
2012                        continue;
2013                    }
2014                    let clicked_idx = (mouse.y - rect.y) as usize;
2015                    if clicked_idx < state.items.len() {
2016                        state.selected = clicked_idx;
2017                        self.consumed[i] = true;
2018                    }
2019                }
2020            }
2021        }
2022
2023        self.commands.push(Command::BeginContainer {
2024            direction: Direction::Column,
2025            gap: 0,
2026            align: Align::Start,
2027            justify: Justify::Start,
2028            border: None,
2029            border_sides: BorderSides::all(),
2030            border_style: Style::new().fg(self.theme.border),
2031            bg_color: None,
2032            padding: Padding::default(),
2033            margin: Margin::default(),
2034            constraints: Constraints::default(),
2035            title: None,
2036            grow: 0,
2037            group_name: None,
2038        });
2039
2040        for (idx, item) in state.items.iter().enumerate() {
2041            let is_selected = idx == state.selected;
2042            let marker = if is_selected { "●" } else { "○" };
2043            let style = if is_selected {
2044                if focused {
2045                    Style::new().bold().fg(self.theme.primary)
2046                } else {
2047                    Style::new().fg(self.theme.primary)
2048                }
2049            } else {
2050                Style::new().fg(self.theme.text)
2051            };
2052            let prefix = if focused && idx == state.selected {
2053                "▸ "
2054            } else {
2055                "  "
2056            };
2057            self.styled(format!("{prefix}{marker} {item}"), style);
2058        }
2059
2060        self.commands.push(Command::EndContainer);
2061        self.last_text_idx = None;
2062        state.selected != old_selected
2063    }
2064
2065    // ── multi-select ─────────────────────────────────────────────────
2066
2067    /// Render a multi-select list. Space toggles, Up/Down navigates.
2068    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> &mut Self {
2069        if state.items.is_empty() {
2070            return self;
2071        }
2072        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
2073        let focused = self.register_focusable();
2074
2075        if focused {
2076            let mut consumed_indices = Vec::new();
2077            for (i, event) in self.events.iter().enumerate() {
2078                if self.consumed[i] {
2079                    continue;
2080                }
2081                if let Event::Key(key) = event {
2082                    if key.kind != KeyEventKind::Press {
2083                        continue;
2084                    }
2085                    match key.code {
2086                        KeyCode::Up | KeyCode::Char('k') => {
2087                            state.cursor = state.cursor.saturating_sub(1);
2088                            consumed_indices.push(i);
2089                        }
2090                        KeyCode::Down | KeyCode::Char('j') => {
2091                            state.cursor =
2092                                (state.cursor + 1).min(state.items.len().saturating_sub(1));
2093                            consumed_indices.push(i);
2094                        }
2095                        KeyCode::Char(' ') | KeyCode::Enter => {
2096                            state.toggle(state.cursor);
2097                            consumed_indices.push(i);
2098                        }
2099                        _ => {}
2100                    }
2101                }
2102            }
2103            for idx in consumed_indices {
2104                self.consumed[idx] = true;
2105            }
2106        }
2107
2108        let interaction_id = self.interaction_count;
2109        self.interaction_count += 1;
2110
2111        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
2112            for (i, event) in self.events.iter().enumerate() {
2113                if self.consumed[i] {
2114                    continue;
2115                }
2116                if let Event::Mouse(mouse) = event {
2117                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2118                        continue;
2119                    }
2120                    let in_bounds = mouse.x >= rect.x
2121                        && mouse.x < rect.right()
2122                        && mouse.y >= rect.y
2123                        && mouse.y < rect.bottom();
2124                    if !in_bounds {
2125                        continue;
2126                    }
2127                    let clicked_idx = (mouse.y - rect.y) as usize;
2128                    if clicked_idx < state.items.len() {
2129                        state.toggle(clicked_idx);
2130                        state.cursor = clicked_idx;
2131                        self.consumed[i] = true;
2132                    }
2133                }
2134            }
2135        }
2136
2137        self.commands.push(Command::BeginContainer {
2138            direction: Direction::Column,
2139            gap: 0,
2140            align: Align::Start,
2141            justify: Justify::Start,
2142            border: None,
2143            border_sides: BorderSides::all(),
2144            border_style: Style::new().fg(self.theme.border),
2145            bg_color: None,
2146            padding: Padding::default(),
2147            margin: Margin::default(),
2148            constraints: Constraints::default(),
2149            title: None,
2150            grow: 0,
2151            group_name: None,
2152        });
2153
2154        for (idx, item) in state.items.iter().enumerate() {
2155            let checked = state.selected.contains(&idx);
2156            let marker = if checked { "[x]" } else { "[ ]" };
2157            let is_cursor = idx == state.cursor;
2158            let style = if is_cursor && focused {
2159                Style::new().bold().fg(self.theme.primary)
2160            } else if checked {
2161                Style::new().fg(self.theme.success)
2162            } else {
2163                Style::new().fg(self.theme.text)
2164            };
2165            let prefix = if is_cursor && focused { "▸ " } else { "  " };
2166            self.styled(format!("{prefix}{marker} {item}"), style);
2167        }
2168
2169        self.commands.push(Command::EndContainer);
2170        self.last_text_idx = None;
2171        self
2172    }
2173
2174    // ── tree ─────────────────────────────────────────────────────────
2175
2176    /// Render a tree view. Left/Right to collapse/expand, Up/Down to navigate.
2177    pub fn tree(&mut self, state: &mut TreeState) -> &mut Self {
2178        let entries = state.flatten();
2179        if entries.is_empty() {
2180            return self;
2181        }
2182        state.selected = state.selected.min(entries.len().saturating_sub(1));
2183        let focused = self.register_focusable();
2184
2185        if focused {
2186            let mut consumed_indices = Vec::new();
2187            for (i, event) in self.events.iter().enumerate() {
2188                if self.consumed[i] {
2189                    continue;
2190                }
2191                if let Event::Key(key) = event {
2192                    if key.kind != KeyEventKind::Press {
2193                        continue;
2194                    }
2195                    match key.code {
2196                        KeyCode::Up | KeyCode::Char('k') => {
2197                            state.selected = state.selected.saturating_sub(1);
2198                            consumed_indices.push(i);
2199                        }
2200                        KeyCode::Down | KeyCode::Char('j') => {
2201                            let max = state.flatten().len().saturating_sub(1);
2202                            state.selected = (state.selected + 1).min(max);
2203                            consumed_indices.push(i);
2204                        }
2205                        KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
2206                            state.toggle_at(state.selected);
2207                            consumed_indices.push(i);
2208                        }
2209                        KeyCode::Left => {
2210                            let entry = &entries[state.selected.min(entries.len() - 1)];
2211                            if entry.expanded {
2212                                state.toggle_at(state.selected);
2213                            }
2214                            consumed_indices.push(i);
2215                        }
2216                        _ => {}
2217                    }
2218                }
2219            }
2220            for idx in consumed_indices {
2221                self.consumed[idx] = true;
2222            }
2223        }
2224
2225        self.interaction_count += 1;
2226        self.commands.push(Command::BeginContainer {
2227            direction: Direction::Column,
2228            gap: 0,
2229            align: Align::Start,
2230            justify: Justify::Start,
2231            border: None,
2232            border_sides: BorderSides::all(),
2233            border_style: Style::new().fg(self.theme.border),
2234            bg_color: None,
2235            padding: Padding::default(),
2236            margin: Margin::default(),
2237            constraints: Constraints::default(),
2238            title: None,
2239            grow: 0,
2240            group_name: None,
2241        });
2242
2243        let entries = state.flatten();
2244        for (idx, entry) in entries.iter().enumerate() {
2245            let indent = "  ".repeat(entry.depth);
2246            let icon = if entry.is_leaf {
2247                "  "
2248            } else if entry.expanded {
2249                "▾ "
2250            } else {
2251                "▸ "
2252            };
2253            let is_selected = idx == state.selected;
2254            let style = if is_selected && focused {
2255                Style::new().bold().fg(self.theme.primary)
2256            } else if is_selected {
2257                Style::new().fg(self.theme.primary)
2258            } else {
2259                Style::new().fg(self.theme.text)
2260            };
2261            let cursor = if is_selected && focused { "▸" } else { " " };
2262            self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
2263        }
2264
2265        self.commands.push(Command::EndContainer);
2266        self.last_text_idx = None;
2267        self
2268    }
2269
2270    // ── virtual list ─────────────────────────────────────────────────
2271
2272    /// Render a virtual list that only renders visible items.
2273    ///
2274    /// `total` is the number of items. `visible_height` limits how many rows
2275    /// are rendered. The closure `f` is called only for visible indices.
2276    pub fn virtual_list(
2277        &mut self,
2278        state: &mut ListState,
2279        visible_height: usize,
2280        f: impl Fn(&mut Context, usize),
2281    ) -> &mut Self {
2282        if state.items.is_empty() {
2283            return self;
2284        }
2285        state.selected = state.selected.min(state.items.len().saturating_sub(1));
2286        let focused = self.register_focusable();
2287
2288        if focused {
2289            let mut consumed_indices = Vec::new();
2290            for (i, event) in self.events.iter().enumerate() {
2291                if self.consumed[i] {
2292                    continue;
2293                }
2294                if let Event::Key(key) = event {
2295                    if key.kind != KeyEventKind::Press {
2296                        continue;
2297                    }
2298                    match key.code {
2299                        KeyCode::Up | KeyCode::Char('k') => {
2300                            state.selected = state.selected.saturating_sub(1);
2301                            consumed_indices.push(i);
2302                        }
2303                        KeyCode::Down | KeyCode::Char('j') => {
2304                            state.selected =
2305                                (state.selected + 1).min(state.items.len().saturating_sub(1));
2306                            consumed_indices.push(i);
2307                        }
2308                        KeyCode::PageUp => {
2309                            state.selected = state.selected.saturating_sub(visible_height);
2310                            consumed_indices.push(i);
2311                        }
2312                        KeyCode::PageDown => {
2313                            state.selected = (state.selected + visible_height)
2314                                .min(state.items.len().saturating_sub(1));
2315                            consumed_indices.push(i);
2316                        }
2317                        KeyCode::Home => {
2318                            state.selected = 0;
2319                            consumed_indices.push(i);
2320                        }
2321                        KeyCode::End => {
2322                            state.selected = state.items.len().saturating_sub(1);
2323                            consumed_indices.push(i);
2324                        }
2325                        _ => {}
2326                    }
2327                }
2328            }
2329            for idx in consumed_indices {
2330                self.consumed[idx] = true;
2331            }
2332        }
2333
2334        let start = if state.selected >= visible_height {
2335            state.selected - visible_height + 1
2336        } else {
2337            0
2338        };
2339        let end = (start + visible_height).min(state.items.len());
2340
2341        self.interaction_count += 1;
2342        self.commands.push(Command::BeginContainer {
2343            direction: Direction::Column,
2344            gap: 0,
2345            align: Align::Start,
2346            justify: Justify::Start,
2347            border: None,
2348            border_sides: BorderSides::all(),
2349            border_style: Style::new().fg(self.theme.border),
2350            bg_color: None,
2351            padding: Padding::default(),
2352            margin: Margin::default(),
2353            constraints: Constraints::default(),
2354            title: None,
2355            grow: 0,
2356            group_name: None,
2357        });
2358
2359        if start > 0 {
2360            self.styled(
2361                format!("  ↑ {} more", start),
2362                Style::new().fg(self.theme.text_dim).dim(),
2363            );
2364        }
2365
2366        for idx in start..end {
2367            f(self, idx);
2368        }
2369
2370        let remaining = state.items.len().saturating_sub(end);
2371        if remaining > 0 {
2372            self.styled(
2373                format!("  ↓ {} more", remaining),
2374                Style::new().fg(self.theme.text_dim).dim(),
2375            );
2376        }
2377
2378        self.commands.push(Command::EndContainer);
2379        self.last_text_idx = None;
2380        self
2381    }
2382
2383    // ── command palette ──────────────────────────────────────────────
2384
2385    /// Render a command palette overlay. Returns `Some(index)` when a command is selected.
2386    pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
2387        if !state.open {
2388            return None;
2389        }
2390
2391        let filtered = state.filtered_indices();
2392        let sel = state.selected().min(filtered.len().saturating_sub(1));
2393        state.set_selected(sel);
2394
2395        let mut consumed_indices = Vec::new();
2396        let mut result: Option<usize> = None;
2397
2398        for (i, event) in self.events.iter().enumerate() {
2399            if self.consumed[i] {
2400                continue;
2401            }
2402            if let Event::Key(key) = event {
2403                if key.kind != KeyEventKind::Press {
2404                    continue;
2405                }
2406                match key.code {
2407                    KeyCode::Esc => {
2408                        state.open = false;
2409                        consumed_indices.push(i);
2410                    }
2411                    KeyCode::Up => {
2412                        let s = state.selected();
2413                        state.set_selected(s.saturating_sub(1));
2414                        consumed_indices.push(i);
2415                    }
2416                    KeyCode::Down => {
2417                        let s = state.selected();
2418                        state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
2419                        consumed_indices.push(i);
2420                    }
2421                    KeyCode::Enter => {
2422                        if let Some(&cmd_idx) = filtered.get(state.selected()) {
2423                            result = Some(cmd_idx);
2424                            state.open = false;
2425                        }
2426                        consumed_indices.push(i);
2427                    }
2428                    KeyCode::Backspace => {
2429                        if state.cursor > 0 {
2430                            let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
2431                            let end_idx = byte_index_for_char(&state.input, state.cursor);
2432                            state.input.replace_range(byte_idx..end_idx, "");
2433                            state.cursor -= 1;
2434                            state.set_selected(0);
2435                        }
2436                        consumed_indices.push(i);
2437                    }
2438                    KeyCode::Char(ch) => {
2439                        let byte_idx = byte_index_for_char(&state.input, state.cursor);
2440                        state.input.insert(byte_idx, ch);
2441                        state.cursor += 1;
2442                        state.set_selected(0);
2443                        consumed_indices.push(i);
2444                    }
2445                    _ => {}
2446                }
2447            }
2448        }
2449        for idx in consumed_indices {
2450            self.consumed[idx] = true;
2451        }
2452
2453        let filtered = state.filtered_indices();
2454
2455        self.modal(|ui| {
2456            let primary = ui.theme.primary;
2457            ui.container()
2458                .border(Border::Rounded)
2459                .border_style(Style::new().fg(primary))
2460                .pad(1)
2461                .max_w(60)
2462                .col(|ui| {
2463                    let border_color = ui.theme.primary;
2464                    ui.bordered(Border::Rounded)
2465                        .border_style(Style::new().fg(border_color))
2466                        .px(1)
2467                        .col(|ui| {
2468                            let display = if state.input.is_empty() {
2469                                "Type to search...".to_string()
2470                            } else {
2471                                state.input.clone()
2472                            };
2473                            let style = if state.input.is_empty() {
2474                                Style::new().dim().fg(ui.theme.text_dim)
2475                            } else {
2476                                Style::new().fg(ui.theme.text)
2477                            };
2478                            ui.styled(display, style);
2479                        });
2480
2481                    for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
2482                        let cmd = &state.commands[cmd_idx];
2483                        let is_selected = list_idx == state.selected();
2484                        let style = if is_selected {
2485                            Style::new().bold().fg(ui.theme.primary)
2486                        } else {
2487                            Style::new().fg(ui.theme.text)
2488                        };
2489                        let prefix = if is_selected { "▸ " } else { "  " };
2490                        let shortcut_text = cmd
2491                            .shortcut
2492                            .as_deref()
2493                            .map(|s| format!("  ({s})"))
2494                            .unwrap_or_default();
2495                        ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
2496                        if is_selected && !cmd.description.is_empty() {
2497                            ui.styled(
2498                                format!("    {}", cmd.description),
2499                                Style::new().dim().fg(ui.theme.text_dim),
2500                            );
2501                        }
2502                    }
2503
2504                    if filtered.is_empty() {
2505                        ui.styled(
2506                            "  No matching commands",
2507                            Style::new().dim().fg(ui.theme.text_dim),
2508                        );
2509                    }
2510                });
2511        });
2512
2513        result
2514    }
2515
2516    // ── markdown ─────────────────────────────────────────────────────
2517
2518    /// Render a markdown string with basic formatting.
2519    ///
2520    /// Supports headers (`#`), bold (`**`), italic (`*`), inline code (`` ` ``),
2521    /// unordered lists (`-`/`*`), ordered lists (`1.`), and horizontal rules (`---`).
2522    pub fn markdown(&mut self, text: &str) -> &mut Self {
2523        self.commands.push(Command::BeginContainer {
2524            direction: Direction::Column,
2525            gap: 0,
2526            align: Align::Start,
2527            justify: Justify::Start,
2528            border: None,
2529            border_sides: BorderSides::all(),
2530            border_style: Style::new().fg(self.theme.border),
2531            bg_color: None,
2532            padding: Padding::default(),
2533            margin: Margin::default(),
2534            constraints: Constraints::default(),
2535            title: None,
2536            grow: 0,
2537            group_name: None,
2538        });
2539        self.interaction_count += 1;
2540
2541        let text_style = Style::new().fg(self.theme.text);
2542        let bold_style = Style::new().fg(self.theme.text).bold();
2543        let code_style = Style::new().fg(self.theme.accent);
2544
2545        for line in text.lines() {
2546            let trimmed = line.trim();
2547            if trimmed.is_empty() {
2548                self.text(" ");
2549                continue;
2550            }
2551            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
2552                self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
2553                continue;
2554            }
2555            if let Some(heading) = trimmed.strip_prefix("### ") {
2556                self.styled(heading, Style::new().bold().fg(self.theme.accent));
2557            } else if let Some(heading) = trimmed.strip_prefix("## ") {
2558                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
2559            } else if let Some(heading) = trimmed.strip_prefix("# ") {
2560                self.styled(heading, Style::new().bold().fg(self.theme.primary));
2561            } else if let Some(item) = trimmed
2562                .strip_prefix("- ")
2563                .or_else(|| trimmed.strip_prefix("* "))
2564            {
2565                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
2566                if segs.len() <= 1 {
2567                    self.styled(format!("  • {item}"), text_style);
2568                } else {
2569                    self.line(|ui| {
2570                        ui.styled("  • ", text_style);
2571                        for (s, st) in segs {
2572                            ui.styled(s, st);
2573                        }
2574                    });
2575                }
2576            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
2577                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
2578                if parts.len() == 2 {
2579                    let segs =
2580                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
2581                    if segs.len() <= 1 {
2582                        self.styled(format!("  {}. {}", parts[0], parts[1]), text_style);
2583                    } else {
2584                        self.line(|ui| {
2585                            ui.styled(format!("  {}. ", parts[0]), text_style);
2586                            for (s, st) in segs {
2587                                ui.styled(s, st);
2588                            }
2589                        });
2590                    }
2591                } else {
2592                    self.text(trimmed);
2593                }
2594            } else if let Some(code) = trimmed.strip_prefix("```") {
2595                let _ = code;
2596                self.styled("  ┌─code─", Style::new().fg(self.theme.border).dim());
2597            } else {
2598                let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
2599                if segs.len() <= 1 {
2600                    self.styled(trimmed, text_style);
2601                } else {
2602                    self.line(|ui| {
2603                        for (s, st) in segs {
2604                            ui.styled(s, st);
2605                        }
2606                    });
2607                }
2608            }
2609        }
2610
2611        self.commands.push(Command::EndContainer);
2612        self.last_text_idx = None;
2613        self
2614    }
2615
2616    fn parse_inline_segments(
2617        text: &str,
2618        base: Style,
2619        bold: Style,
2620        code: Style,
2621    ) -> Vec<(String, Style)> {
2622        let mut segments: Vec<(String, Style)> = Vec::new();
2623        let mut current = String::new();
2624        let chars: Vec<char> = text.chars().collect();
2625        let mut i = 0;
2626        while i < chars.len() {
2627            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2628                let rest: String = chars[i + 2..].iter().collect();
2629                if let Some(end) = rest.find("**") {
2630                    if !current.is_empty() {
2631                        segments.push((std::mem::take(&mut current), base));
2632                    }
2633                    let inner: String = rest[..end].to_string();
2634                    let char_count = inner.chars().count();
2635                    segments.push((inner, bold));
2636                    i += 2 + char_count + 2;
2637                    continue;
2638                }
2639            }
2640            if chars[i] == '*'
2641                && (i + 1 >= chars.len() || chars[i + 1] != '*')
2642                && (i == 0 || chars[i - 1] != '*')
2643            {
2644                let rest: String = chars[i + 1..].iter().collect();
2645                if let Some(end) = rest.find('*') {
2646                    if !current.is_empty() {
2647                        segments.push((std::mem::take(&mut current), base));
2648                    }
2649                    let inner: String = rest[..end].to_string();
2650                    let char_count = inner.chars().count();
2651                    segments.push((inner, base.italic()));
2652                    i += 1 + char_count + 1;
2653                    continue;
2654                }
2655            }
2656            if chars[i] == '`' {
2657                let rest: String = chars[i + 1..].iter().collect();
2658                if let Some(end) = rest.find('`') {
2659                    if !current.is_empty() {
2660                        segments.push((std::mem::take(&mut current), base));
2661                    }
2662                    let inner: String = rest[..end].to_string();
2663                    let char_count = inner.chars().count();
2664                    segments.push((inner, code));
2665                    i += 1 + char_count + 1;
2666                    continue;
2667                }
2668            }
2669            current.push(chars[i]);
2670            i += 1;
2671        }
2672        if !current.is_empty() {
2673            segments.push((current, base));
2674        }
2675        segments
2676    }
2677
2678    // ── key sequence ─────────────────────────────────────────────────
2679
2680    /// Check if a sequence of character keys was pressed across recent frames.
2681    ///
2682    /// Matches when each character in `seq` appears in consecutive unconsumed
2683    /// key events within this frame. For single-frame sequences only (e.g., "gg").
2684    pub fn key_seq(&self, seq: &str) -> bool {
2685        if seq.is_empty() {
2686            return false;
2687        }
2688        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2689            return false;
2690        }
2691        let target: Vec<char> = seq.chars().collect();
2692        let mut matched = 0;
2693        for (i, event) in self.events.iter().enumerate() {
2694            if self.consumed[i] {
2695                continue;
2696            }
2697            if let Event::Key(key) = event {
2698                if key.kind != KeyEventKind::Press {
2699                    continue;
2700                }
2701                if let KeyCode::Char(c) = key.code {
2702                    if c == target[matched] {
2703                        matched += 1;
2704                        if matched == target.len() {
2705                            return true;
2706                        }
2707                    } else {
2708                        matched = 0;
2709                        if c == target[0] {
2710                            matched = 1;
2711                        }
2712                    }
2713                }
2714            }
2715        }
2716        false
2717    }
2718
2719    /// Render a horizontal divider line.
2720    ///
2721    /// The line is drawn with the theme's border color and expands to fill the
2722    /// container width.
2723    pub fn separator(&mut self) -> &mut Self {
2724        self.commands.push(Command::Text {
2725            content: "─".repeat(200),
2726            style: Style::new().fg(self.theme.border).dim(),
2727            grow: 0,
2728            align: Align::Start,
2729            wrap: false,
2730            margin: Margin::default(),
2731            constraints: Constraints::default(),
2732        });
2733        self.last_text_idx = Some(self.commands.len() - 1);
2734        self
2735    }
2736
2737    /// Render a help bar showing keybinding hints.
2738    ///
2739    /// `bindings` is a slice of `(key, action)` pairs. Keys are rendered in the
2740    /// theme's primary color; actions in the dim text color. Pairs are separated
2741    /// by a `·` character.
2742    pub fn help(&mut self, bindings: &[(&str, &str)]) -> &mut Self {
2743        if bindings.is_empty() {
2744            return self;
2745        }
2746
2747        self.interaction_count += 1;
2748        self.commands.push(Command::BeginContainer {
2749            direction: Direction::Row,
2750            gap: 2,
2751            align: Align::Start,
2752            justify: Justify::Start,
2753            border: None,
2754            border_sides: BorderSides::all(),
2755            border_style: Style::new().fg(self.theme.border),
2756            bg_color: None,
2757            padding: Padding::default(),
2758            margin: Margin::default(),
2759            constraints: Constraints::default(),
2760            title: None,
2761            grow: 0,
2762            group_name: None,
2763        });
2764        for (idx, (key, action)) in bindings.iter().enumerate() {
2765            if idx > 0 {
2766                self.styled("·", Style::new().fg(self.theme.text_dim));
2767            }
2768            self.styled(*key, Style::new().bold().fg(self.theme.primary));
2769            self.styled(*action, Style::new().fg(self.theme.text_dim));
2770        }
2771        self.commands.push(Command::EndContainer);
2772        self.last_text_idx = None;
2773
2774        self
2775    }
2776
2777    // ── events ───────────────────────────────────────────────────────
2778
2779    /// Check if a character key was pressed this frame.
2780    ///
2781    /// Returns `true` if the key event has not been consumed by another widget.
2782    pub fn key(&self, c: char) -> bool {
2783        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2784            return false;
2785        }
2786        self.events.iter().enumerate().any(|(i, e)| {
2787            !self.consumed[i]
2788                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2789        })
2790    }
2791
2792    /// Check if a specific key code was pressed this frame.
2793    ///
2794    /// Returns `true` if the key event has not been consumed by another widget.
2795    pub fn key_code(&self, code: KeyCode) -> bool {
2796        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2797            return false;
2798        }
2799        self.events.iter().enumerate().any(|(i, e)| {
2800            !self.consumed[i]
2801                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
2802        })
2803    }
2804
2805    /// Check if a character key was released this frame.
2806    ///
2807    /// Returns `true` if the key release event has not been consumed by another widget.
2808    pub fn key_release(&self, c: char) -> bool {
2809        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2810            return false;
2811        }
2812        self.events.iter().enumerate().any(|(i, e)| {
2813            !self.consumed[i]
2814                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
2815        })
2816    }
2817
2818    /// Check if a specific key code was released this frame.
2819    ///
2820    /// Returns `true` if the key release event has not been consumed by another widget.
2821    pub fn key_code_release(&self, code: KeyCode) -> bool {
2822        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2823            return false;
2824        }
2825        self.events.iter().enumerate().any(|(i, e)| {
2826            !self.consumed[i]
2827                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
2828        })
2829    }
2830
2831    /// Check if a character key with specific modifiers was pressed this frame.
2832    ///
2833    /// Returns `true` if the key event has not been consumed by another widget.
2834    pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
2835        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2836            return false;
2837        }
2838        self.events.iter().enumerate().any(|(i, e)| {
2839            !self.consumed[i]
2840                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
2841        })
2842    }
2843
2844    /// Return the position of a left mouse button down event this frame, if any.
2845    ///
2846    /// Returns `None` if no unconsumed mouse-down event occurred.
2847    pub fn mouse_down(&self) -> Option<(u32, u32)> {
2848        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2849            return None;
2850        }
2851        self.events.iter().enumerate().find_map(|(i, event)| {
2852            if self.consumed[i] {
2853                return None;
2854            }
2855            if let Event::Mouse(mouse) = event {
2856                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2857                    return Some((mouse.x, mouse.y));
2858                }
2859            }
2860            None
2861        })
2862    }
2863
2864    /// Return the current mouse cursor position, if known.
2865    ///
2866    /// The position is updated on every mouse move or click event. Returns
2867    /// `None` until the first mouse event is received.
2868    pub fn mouse_pos(&self) -> Option<(u32, u32)> {
2869        self.mouse_pos
2870    }
2871
2872    /// Return the first unconsumed paste event text, if any.
2873    pub fn paste(&self) -> Option<&str> {
2874        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2875            return None;
2876        }
2877        self.events.iter().enumerate().find_map(|(i, event)| {
2878            if self.consumed[i] {
2879                return None;
2880            }
2881            if let Event::Paste(ref text) = event {
2882                return Some(text.as_str());
2883            }
2884            None
2885        })
2886    }
2887
2888    /// Check if an unconsumed scroll-up event occurred this frame.
2889    pub fn scroll_up(&self) -> bool {
2890        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2891            return false;
2892        }
2893        self.events.iter().enumerate().any(|(i, event)| {
2894            !self.consumed[i]
2895                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
2896        })
2897    }
2898
2899    /// Check if an unconsumed scroll-down event occurred this frame.
2900    pub fn scroll_down(&self) -> bool {
2901        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2902            return false;
2903        }
2904        self.events.iter().enumerate().any(|(i, event)| {
2905            !self.consumed[i]
2906                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
2907        })
2908    }
2909
2910    /// Signal the run loop to exit after this frame.
2911    pub fn quit(&mut self) {
2912        self.should_quit = true;
2913    }
2914
2915    /// Copy text to the system clipboard via OSC 52.
2916    ///
2917    /// Works transparently over SSH connections. The text is queued and
2918    /// written to the terminal after the current frame renders.
2919    ///
2920    /// Requires a terminal that supports OSC 52 (most modern terminals:
2921    /// Ghostty, kitty, WezTerm, iTerm2, Windows Terminal).
2922    pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
2923        self.clipboard_text = Some(text.into());
2924    }
2925
2926    /// Get the current theme.
2927    pub fn theme(&self) -> &Theme {
2928        &self.theme
2929    }
2930
2931    /// Change the theme for subsequent rendering.
2932    ///
2933    /// All widgets rendered after this call will use the new theme's colors.
2934    pub fn set_theme(&mut self, theme: Theme) {
2935        self.theme = theme;
2936    }
2937
2938    /// Check if dark mode is active.
2939    pub fn is_dark_mode(&self) -> bool {
2940        self.dark_mode
2941    }
2942
2943    /// Set dark mode. When true, dark_* style variants are applied.
2944    pub fn set_dark_mode(&mut self, dark: bool) {
2945        self.dark_mode = dark;
2946    }
2947
2948    // ── info ─────────────────────────────────────────────────────────
2949
2950    /// Get the terminal width in cells.
2951    pub fn width(&self) -> u32 {
2952        self.area_width
2953    }
2954
2955    /// Get the current terminal width breakpoint.
2956    ///
2957    /// Returns a [`Breakpoint`] based on the terminal width:
2958    /// - `Xs`: < 40 columns
2959    /// - `Sm`: 40-79 columns
2960    /// - `Md`: 80-119 columns
2961    /// - `Lg`: 120-159 columns
2962    /// - `Xl`: >= 160 columns
2963    ///
2964    /// Use this for responsive layouts that adapt to terminal size:
2965    /// ```no_run
2966    /// # use slt::{Breakpoint, Context};
2967    /// # slt::run(|ui: &mut Context| {
2968    /// match ui.breakpoint() {
2969    ///     Breakpoint::Xs | Breakpoint::Sm => {
2970    ///         ui.col(|ui| { ui.text("Stacked layout"); });
2971    ///     }
2972    ///     _ => {
2973    ///         ui.row(|ui| { ui.text("Side-by-side layout"); });
2974    ///     }
2975    /// }
2976    /// # });
2977    /// ```
2978    pub fn breakpoint(&self) -> Breakpoint {
2979        let w = self.area_width;
2980        if w < 40 {
2981            Breakpoint::Xs
2982        } else if w < 80 {
2983            Breakpoint::Sm
2984        } else if w < 120 {
2985            Breakpoint::Md
2986        } else if w < 160 {
2987            Breakpoint::Lg
2988        } else {
2989            Breakpoint::Xl
2990        }
2991    }
2992
2993    /// Get the terminal height in cells.
2994    pub fn height(&self) -> u32 {
2995        self.area_height
2996    }
2997
2998    /// Get the current tick count (increments each frame).
2999    ///
3000    /// Useful for animations and time-based logic. The tick starts at 0 and
3001    /// increases by 1 on every rendered frame.
3002    pub fn tick(&self) -> u64 {
3003        self.tick
3004    }
3005
3006    /// Return whether the layout debugger is enabled.
3007    ///
3008    /// The debugger is toggled with F12 at runtime.
3009    pub fn debug_enabled(&self) -> bool {
3010        self.debug
3011    }
3012}