Skip to main content

slt/
context.rs

1use crate::chart::{build_histogram_config, render_chart, ChartBuilder, HistogramBuilder};
2use crate::event::{Event, KeyCode, KeyModifiers, MouseButton, MouseKind};
3use crate::layout::{Command, Direction};
4use crate::rect::Rect;
5use crate::style::{
6    Align, Border, BorderSides, Color, Constraints, Justify, Margin, Modifiers, Padding, Style,
7    Theme,
8};
9use crate::widgets::{
10    ButtonVariant, CommandPaletteState, FormField, FormState, ListState, MultiSelectState,
11    RadioState, ScrollState, SelectState, SpinnerState, TableState, TabsState, TextInputState,
12    TextareaState, ToastLevel, ToastState, TreeState,
13};
14use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
15
16#[allow(dead_code)]
17fn slt_assert(condition: bool, msg: &str) {
18    if !condition {
19        panic!("[SLT] {}", msg);
20    }
21}
22
23#[cfg(debug_assertions)]
24#[allow(dead_code)]
25fn slt_warn(msg: &str) {
26    eprintln!("\x1b[33m[SLT warning]\x1b[0m {}", msg);
27}
28
29#[cfg(not(debug_assertions))]
30#[allow(dead_code)]
31fn slt_warn(_msg: &str) {}
32
33/// Result of a container mouse interaction.
34///
35/// Returned by [`Context::col`], [`Context::row`], and [`ContainerBuilder::col`] /
36/// [`ContainerBuilder::row`] so you can react to clicks and hover without a separate
37/// event loop.
38#[derive(Debug, Clone, Copy, Default)]
39pub struct Response {
40    /// Whether the container was clicked this frame.
41    pub clicked: bool,
42    /// Whether the mouse is over the container.
43    pub hovered: bool,
44}
45
46/// Direction for bar chart rendering.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum BarDirection {
49    /// Bars grow horizontally (default, current behavior).
50    Horizontal,
51    /// Bars grow vertically from bottom to top.
52    Vertical,
53}
54
55/// A single bar in a styled bar chart.
56#[derive(Debug, Clone)]
57pub struct Bar {
58    /// Display label for this bar.
59    pub label: String,
60    /// Numeric value.
61    pub value: f64,
62    /// Bar color. If None, uses theme.primary.
63    pub color: Option<Color>,
64}
65
66impl Bar {
67    /// Create a new bar with a label and value.
68    pub fn new(label: impl Into<String>, value: f64) -> Self {
69        Self {
70            label: label.into(),
71            value,
72            color: None,
73        }
74    }
75
76    /// Set the bar color.
77    pub fn color(mut self, color: Color) -> Self {
78        self.color = Some(color);
79        self
80    }
81}
82
83/// A group of bars rendered together (for grouped bar charts).
84#[derive(Debug, Clone)]
85pub struct BarGroup {
86    /// Group label displayed below the bars.
87    pub label: String,
88    /// Bars in this group.
89    pub bars: Vec<Bar>,
90}
91
92impl BarGroup {
93    /// Create a new bar group with a label and bars.
94    pub fn new(label: impl Into<String>, bars: Vec<Bar>) -> Self {
95        Self {
96            label: label.into(),
97            bars,
98        }
99    }
100}
101
102/// Trait for creating custom widgets.
103///
104/// Implement this trait to build reusable, composable widgets with full access
105/// to the [`Context`] API — focus, events, theming, layout, and mouse interaction.
106///
107/// # Examples
108///
109/// A simple rating widget:
110///
111/// ```no_run
112/// use slt::{Context, Widget, Color};
113///
114/// struct Rating {
115///     value: u8,
116///     max: u8,
117/// }
118///
119/// impl Rating {
120///     fn new(value: u8, max: u8) -> Self {
121///         Self { value, max }
122///     }
123/// }
124///
125/// impl Widget for Rating {
126///     type Response = bool;
127///
128///     fn ui(&mut self, ui: &mut Context) -> bool {
129///         let focused = ui.register_focusable();
130///         let mut changed = false;
131///
132///         if focused {
133///             if ui.key('+') && self.value < self.max {
134///                 self.value += 1;
135///                 changed = true;
136///             }
137///             if ui.key('-') && self.value > 0 {
138///                 self.value -= 1;
139///                 changed = true;
140///             }
141///         }
142///
143///         let stars: String = (0..self.max).map(|i| {
144///             if i < self.value { '★' } else { '☆' }
145///         }).collect();
146///
147///         let color = if focused { Color::Yellow } else { Color::White };
148///         ui.styled(stars, slt::Style::new().fg(color));
149///
150///         changed
151///     }
152/// }
153///
154/// fn main() -> std::io::Result<()> {
155///     let mut rating = Rating::new(3, 5);
156///     slt::run(|ui| {
157///         if ui.key('q') { ui.quit(); }
158///         ui.text("Rate this:");
159///         ui.widget(&mut rating);
160///     })
161/// }
162/// ```
163pub trait Widget {
164    /// The value returned after rendering. Use `()` for widgets with no return,
165    /// `bool` for widgets that report changes, or [`Response`] for click/hover.
166    type Response;
167
168    /// Render the widget into the given context.
169    ///
170    /// Use [`Context::register_focusable`] to participate in Tab focus cycling,
171    /// [`Context::key`] / [`Context::key_code`] to handle keyboard input,
172    /// and [`Context::interaction`] to detect clicks and hovers.
173    fn ui(&mut self, ctx: &mut Context) -> Self::Response;
174}
175
176/// The main rendering context passed to your closure each frame.
177///
178/// Provides all methods for building UI: text, containers, widgets, and event
179/// handling. You receive a `&mut Context` on every frame and describe what to
180/// render by calling its methods. SLT collects those calls, lays them out with
181/// flexbox, diffs against the previous frame, and flushes only changed cells.
182///
183/// # Example
184///
185/// ```no_run
186/// slt::run(|ui: &mut slt::Context| {
187///     if ui.key('q') { ui.quit(); }
188///     ui.text("Hello, world!").bold();
189/// });
190/// ```
191pub struct Context {
192    pub(crate) commands: Vec<Command>,
193    pub(crate) events: Vec<Event>,
194    pub(crate) consumed: Vec<bool>,
195    pub(crate) should_quit: bool,
196    pub(crate) area_width: u32,
197    pub(crate) area_height: u32,
198    pub(crate) tick: u64,
199    pub(crate) focus_index: usize,
200    pub(crate) focus_count: usize,
201    prev_focus_count: usize,
202    scroll_count: usize,
203    prev_scroll_infos: Vec<(u32, u32)>,
204    interaction_count: usize,
205    pub(crate) prev_hit_map: Vec<Rect>,
206    _prev_focus_rects: Vec<(usize, Rect)>,
207    mouse_pos: Option<(u32, u32)>,
208    click_pos: Option<(u32, u32)>,
209    last_text_idx: Option<usize>,
210    overlay_depth: usize,
211    pub(crate) modal_active: bool,
212    prev_modal_active: bool,
213    debug: bool,
214    theme: Theme,
215}
216
217/// Fluent builder for configuring containers before calling `.col()` or `.row()`.
218///
219/// Obtain one via [`Context::container`] or [`Context::bordered`]. Chain the
220/// configuration methods you need, then finalize with `.col(|ui| { ... })` or
221/// `.row(|ui| { ... })`.
222///
223/// # Example
224///
225/// ```no_run
226/// # slt::run(|ui: &mut slt::Context| {
227/// use slt::{Border, Color};
228/// ui.container()
229///     .border(Border::Rounded)
230///     .pad(1)
231///     .grow(1)
232///     .col(|ui| {
233///         ui.text("inside a bordered, padded, growing column");
234///     });
235/// # });
236/// ```
237#[must_use = "configure and finalize with .col() or .row()"]
238pub struct ContainerBuilder<'a> {
239    ctx: &'a mut Context,
240    gap: u32,
241    align: Align,
242    justify: Justify,
243    border: Option<Border>,
244    border_sides: BorderSides,
245    border_style: Style,
246    bg_color: Option<Color>,
247    padding: Padding,
248    margin: Margin,
249    constraints: Constraints,
250    title: Option<(String, Style)>,
251    grow: u16,
252    scroll_offset: Option<u32>,
253}
254
255/// Drawing context for the [`Context::canvas`] widget.
256///
257/// Provides pixel-level drawing on a braille character grid. Each terminal
258/// cell maps to a 2x4 dot matrix, so a canvas of `width` columns x `height`
259/// rows gives `width*2` x `height*4` pixel resolution.
260/// A colored pixel in the canvas grid.
261#[derive(Debug, Clone, Copy)]
262struct CanvasPixel {
263    bits: u32,
264    color: Color,
265}
266
267/// Text label placed on the canvas.
268#[derive(Debug, Clone)]
269struct CanvasLabel {
270    x: usize,
271    y: usize,
272    text: String,
273    color: Color,
274}
275
276/// A layer in the canvas, supporting z-ordering.
277#[derive(Debug, Clone)]
278struct CanvasLayer {
279    grid: Vec<Vec<CanvasPixel>>,
280    labels: Vec<CanvasLabel>,
281}
282
283pub struct CanvasContext {
284    layers: Vec<CanvasLayer>,
285    cols: usize,
286    rows: usize,
287    px_w: usize,
288    px_h: usize,
289    current_color: Color,
290}
291
292impl CanvasContext {
293    fn new(cols: usize, rows: usize) -> Self {
294        Self {
295            layers: vec![Self::new_layer(cols, rows)],
296            cols,
297            rows,
298            px_w: cols * 2,
299            px_h: rows * 4,
300            current_color: Color::Reset,
301        }
302    }
303
304    fn new_layer(cols: usize, rows: usize) -> CanvasLayer {
305        CanvasLayer {
306            grid: vec![
307                vec![
308                    CanvasPixel {
309                        bits: 0,
310                        color: Color::Reset,
311                    };
312                    cols
313                ];
314                rows
315            ],
316            labels: Vec::new(),
317        }
318    }
319
320    fn current_layer_mut(&mut self) -> Option<&mut CanvasLayer> {
321        self.layers.last_mut()
322    }
323
324    fn dot_with_color(&mut self, x: usize, y: usize, color: Color) {
325        if x >= self.px_w || y >= self.px_h {
326            return;
327        }
328
329        let char_col = x / 2;
330        let char_row = y / 4;
331        let sub_col = x % 2;
332        let sub_row = y % 4;
333        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
334        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
335
336        let bit = if sub_col == 0 {
337            LEFT_BITS[sub_row]
338        } else {
339            RIGHT_BITS[sub_row]
340        };
341
342        if let Some(layer) = self.current_layer_mut() {
343            let cell = &mut layer.grid[char_row][char_col];
344            let new_bits = cell.bits | bit;
345            if new_bits != cell.bits {
346                cell.bits = new_bits;
347                cell.color = color;
348            }
349        }
350    }
351
352    fn dot_isize(&mut self, x: isize, y: isize) {
353        if x >= 0 && y >= 0 {
354            self.dot(x as usize, y as usize);
355        }
356    }
357
358    /// Get the pixel width of the canvas.
359    pub fn width(&self) -> usize {
360        self.px_w
361    }
362
363    /// Get the pixel height of the canvas.
364    pub fn height(&self) -> usize {
365        self.px_h
366    }
367
368    /// Set a single pixel at `(x, y)`.
369    pub fn dot(&mut self, x: usize, y: usize) {
370        self.dot_with_color(x, y, self.current_color);
371    }
372
373    /// Draw a line from `(x0, y0)` to `(x1, y1)` using Bresenham's algorithm.
374    pub fn line(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
375        let (mut x, mut y) = (x0 as isize, y0 as isize);
376        let (x1, y1) = (x1 as isize, y1 as isize);
377        let dx = (x1 - x).abs();
378        let dy = -(y1 - y).abs();
379        let sx = if x < x1 { 1 } else { -1 };
380        let sy = if y < y1 { 1 } else { -1 };
381        let mut err = dx + dy;
382
383        loop {
384            self.dot_isize(x, y);
385            if x == x1 && y == y1 {
386                break;
387            }
388            let e2 = 2 * err;
389            if e2 >= dy {
390                err += dy;
391                x += sx;
392            }
393            if e2 <= dx {
394                err += dx;
395                y += sy;
396            }
397        }
398    }
399
400    /// Draw a rectangle outline from `(x, y)` with `w` width and `h` height.
401    pub fn rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
402        if w == 0 || h == 0 {
403            return;
404        }
405
406        self.line(x, y, x + w.saturating_sub(1), y);
407        self.line(
408            x + w.saturating_sub(1),
409            y,
410            x + w.saturating_sub(1),
411            y + h.saturating_sub(1),
412        );
413        self.line(
414            x + w.saturating_sub(1),
415            y + h.saturating_sub(1),
416            x,
417            y + h.saturating_sub(1),
418        );
419        self.line(x, y + h.saturating_sub(1), x, y);
420    }
421
422    /// Draw a circle outline centered at `(cx, cy)` with radius `r`.
423    pub fn circle(&mut self, cx: usize, cy: usize, r: usize) {
424        let mut x = r as isize;
425        let mut y: isize = 0;
426        let mut err: isize = 1 - x;
427        let (cx, cy) = (cx as isize, cy as isize);
428
429        while x >= y {
430            for &(dx, dy) in &[
431                (x, y),
432                (y, x),
433                (-x, y),
434                (-y, x),
435                (x, -y),
436                (y, -x),
437                (-x, -y),
438                (-y, -x),
439            ] {
440                let px = cx + dx;
441                let py = cy + dy;
442                self.dot_isize(px, py);
443            }
444
445            y += 1;
446            if err < 0 {
447                err += 2 * y + 1;
448            } else {
449                x -= 1;
450                err += 2 * (y - x) + 1;
451            }
452        }
453    }
454
455    /// Set the drawing color for subsequent shapes.
456    pub fn set_color(&mut self, color: Color) {
457        self.current_color = color;
458    }
459
460    /// Get the current drawing color.
461    pub fn color(&self) -> Color {
462        self.current_color
463    }
464
465    /// Draw a filled rectangle.
466    pub fn filled_rect(&mut self, x: usize, y: usize, w: usize, h: usize) {
467        if w == 0 || h == 0 {
468            return;
469        }
470
471        let x_end = x.saturating_add(w).min(self.px_w);
472        let y_end = y.saturating_add(h).min(self.px_h);
473        if x >= x_end || y >= y_end {
474            return;
475        }
476
477        for yy in y..y_end {
478            self.line(x, yy, x_end.saturating_sub(1), yy);
479        }
480    }
481
482    /// Draw a filled circle.
483    pub fn filled_circle(&mut self, cx: usize, cy: usize, r: usize) {
484        let (cx, cy, r) = (cx as isize, cy as isize, r as isize);
485        for y in (cy - r)..=(cy + r) {
486            let dy = y - cy;
487            let span_sq = (r * r - dy * dy).max(0);
488            let dx = (span_sq as f64).sqrt() as isize;
489            for x in (cx - dx)..=(cx + dx) {
490                self.dot_isize(x, y);
491            }
492        }
493    }
494
495    /// Draw a triangle outline.
496    pub fn triangle(&mut self, x0: usize, y0: usize, x1: usize, y1: usize, x2: usize, y2: usize) {
497        self.line(x0, y0, x1, y1);
498        self.line(x1, y1, x2, y2);
499        self.line(x2, y2, x0, y0);
500    }
501
502    /// Draw a filled triangle.
503    pub fn filled_triangle(
504        &mut self,
505        x0: usize,
506        y0: usize,
507        x1: usize,
508        y1: usize,
509        x2: usize,
510        y2: usize,
511    ) {
512        let vertices = [
513            (x0 as isize, y0 as isize),
514            (x1 as isize, y1 as isize),
515            (x2 as isize, y2 as isize),
516        ];
517        let min_y = vertices.iter().map(|(_, y)| *y).min().unwrap_or(0);
518        let max_y = vertices.iter().map(|(_, y)| *y).max().unwrap_or(-1);
519
520        for y in min_y..=max_y {
521            let mut intersections: Vec<f64> = Vec::new();
522
523            for edge in [(0usize, 1usize), (1usize, 2usize), (2usize, 0usize)] {
524                let (x_a, y_a) = vertices[edge.0];
525                let (x_b, y_b) = vertices[edge.1];
526                if y_a == y_b {
527                    continue;
528                }
529
530                let (x_start, y_start, x_end, y_end) = if y_a < y_b {
531                    (x_a, y_a, x_b, y_b)
532                } else {
533                    (x_b, y_b, x_a, y_a)
534                };
535
536                if y < y_start || y >= y_end {
537                    continue;
538                }
539
540                let t = (y - y_start) as f64 / (y_end - y_start) as f64;
541                intersections.push(x_start as f64 + t * (x_end - x_start) as f64);
542            }
543
544            intersections.sort_by(|a, b| a.total_cmp(b));
545            let mut i = 0usize;
546            while i + 1 < intersections.len() {
547                let x_start = intersections[i].ceil() as isize;
548                let x_end = intersections[i + 1].floor() as isize;
549                for x in x_start..=x_end {
550                    self.dot_isize(x, y);
551                }
552                i += 2;
553            }
554        }
555
556        self.triangle(x0, y0, x1, y1, x2, y2);
557    }
558
559    /// Draw multiple points at once.
560    pub fn points(&mut self, pts: &[(usize, usize)]) {
561        for &(x, y) in pts {
562            self.dot(x, y);
563        }
564    }
565
566    /// Draw a polyline connecting the given points in order.
567    pub fn polyline(&mut self, pts: &[(usize, usize)]) {
568        for window in pts.windows(2) {
569            if let [(x0, y0), (x1, y1)] = window {
570                self.line(*x0, *y0, *x1, *y1);
571            }
572        }
573    }
574
575    /// Place a text label at pixel position `(x, y)`.
576    /// Text is rendered in regular characters overlaying the braille grid.
577    pub fn print(&mut self, x: usize, y: usize, text: &str) {
578        if text.is_empty() {
579            return;
580        }
581
582        let color = self.current_color;
583        if let Some(layer) = self.current_layer_mut() {
584            layer.labels.push(CanvasLabel {
585                x,
586                y,
587                text: text.to_string(),
588                color,
589            });
590        }
591    }
592
593    /// Start a new drawing layer. Shapes on later layers overlay earlier ones.
594    pub fn layer(&mut self) {
595        self.layers.push(Self::new_layer(self.cols, self.rows));
596    }
597
598    pub(crate) fn render(&self) -> Vec<Vec<(String, Color)>> {
599        let mut final_grid = vec![
600            vec![
601                CanvasPixel {
602                    bits: 0,
603                    color: Color::Reset,
604                };
605                self.cols
606            ];
607            self.rows
608        ];
609        let mut labels_overlay: Vec<Vec<Option<(char, Color)>>> =
610            vec![vec![None; self.cols]; self.rows];
611
612        for layer in &self.layers {
613            for (row, final_row) in final_grid.iter_mut().enumerate().take(self.rows) {
614                for (col, dst) in final_row.iter_mut().enumerate().take(self.cols) {
615                    let src = layer.grid[row][col];
616                    if src.bits == 0 {
617                        continue;
618                    }
619
620                    let merged = dst.bits | src.bits;
621                    if merged != dst.bits {
622                        dst.bits = merged;
623                        dst.color = src.color;
624                    }
625                }
626            }
627
628            for label in &layer.labels {
629                let row = label.y / 4;
630                if row >= self.rows {
631                    continue;
632                }
633                let start_col = label.x / 2;
634                for (offset, ch) in label.text.chars().enumerate() {
635                    let col = start_col + offset;
636                    if col >= self.cols {
637                        break;
638                    }
639                    labels_overlay[row][col] = Some((ch, label.color));
640                }
641            }
642        }
643
644        let mut lines: Vec<Vec<(String, Color)>> = Vec::with_capacity(self.rows);
645        for row in 0..self.rows {
646            let mut segments: Vec<(String, Color)> = Vec::new();
647            let mut current_color: Option<Color> = None;
648            let mut current_text = String::new();
649
650            for col in 0..self.cols {
651                let (ch, color) = if let Some((label_ch, label_color)) = labels_overlay[row][col] {
652                    (label_ch, label_color)
653                } else {
654                    let bits = final_grid[row][col].bits;
655                    let ch = char::from_u32(0x2800 + bits).unwrap_or(' ');
656                    (ch, final_grid[row][col].color)
657                };
658
659                match current_color {
660                    Some(c) if c == color => {
661                        current_text.push(ch);
662                    }
663                    Some(c) => {
664                        segments.push((std::mem::take(&mut current_text), c));
665                        current_text.push(ch);
666                        current_color = Some(color);
667                    }
668                    None => {
669                        current_text.push(ch);
670                        current_color = Some(color);
671                    }
672                }
673            }
674
675            if let Some(color) = current_color {
676                segments.push((current_text, color));
677            }
678            lines.push(segments);
679        }
680
681        lines
682    }
683}
684
685impl<'a> ContainerBuilder<'a> {
686    // ── border ───────────────────────────────────────────────────────
687
688    /// Set the border style.
689    pub fn border(mut self, border: Border) -> Self {
690        self.border = Some(border);
691        self
692    }
693
694    /// Show or hide the top border.
695    pub fn border_top(mut self, show: bool) -> Self {
696        self.border_sides.top = show;
697        self
698    }
699
700    /// Show or hide the right border.
701    pub fn border_right(mut self, show: bool) -> Self {
702        self.border_sides.right = show;
703        self
704    }
705
706    /// Show or hide the bottom border.
707    pub fn border_bottom(mut self, show: bool) -> Self {
708        self.border_sides.bottom = show;
709        self
710    }
711
712    /// Show or hide the left border.
713    pub fn border_left(mut self, show: bool) -> Self {
714        self.border_sides.left = show;
715        self
716    }
717
718    /// Set which border sides are visible.
719    pub fn border_sides(mut self, sides: BorderSides) -> Self {
720        self.border_sides = sides;
721        self
722    }
723
724    /// Set rounded border style. Shorthand for `.border(Border::Rounded)`.
725    pub fn rounded(self) -> Self {
726        self.border(Border::Rounded)
727    }
728
729    /// Set the style applied to the border characters.
730    pub fn border_style(mut self, style: Style) -> Self {
731        self.border_style = style;
732        self
733    }
734
735    pub fn bg(mut self, color: Color) -> Self {
736        self.bg_color = Some(color);
737        self
738    }
739
740    // ── padding (Tailwind: p, px, py, pt, pr, pb, pl) ───────────────
741
742    /// Set uniform padding on all sides. Alias for [`pad`](Self::pad).
743    pub fn p(self, value: u32) -> Self {
744        self.pad(value)
745    }
746
747    /// Set uniform padding on all sides.
748    pub fn pad(mut self, value: u32) -> Self {
749        self.padding = Padding::all(value);
750        self
751    }
752
753    /// Set horizontal padding (left and right).
754    pub fn px(mut self, value: u32) -> Self {
755        self.padding.left = value;
756        self.padding.right = value;
757        self
758    }
759
760    /// Set vertical padding (top and bottom).
761    pub fn py(mut self, value: u32) -> Self {
762        self.padding.top = value;
763        self.padding.bottom = value;
764        self
765    }
766
767    /// Set top padding.
768    pub fn pt(mut self, value: u32) -> Self {
769        self.padding.top = value;
770        self
771    }
772
773    /// Set right padding.
774    pub fn pr(mut self, value: u32) -> Self {
775        self.padding.right = value;
776        self
777    }
778
779    /// Set bottom padding.
780    pub fn pb(mut self, value: u32) -> Self {
781        self.padding.bottom = value;
782        self
783    }
784
785    /// Set left padding.
786    pub fn pl(mut self, value: u32) -> Self {
787        self.padding.left = value;
788        self
789    }
790
791    /// Set per-side padding using a [`Padding`] value.
792    pub fn padding(mut self, padding: Padding) -> Self {
793        self.padding = padding;
794        self
795    }
796
797    // ── margin (Tailwind: m, mx, my, mt, mr, mb, ml) ────────────────
798
799    /// Set uniform margin on all sides.
800    pub fn m(mut self, value: u32) -> Self {
801        self.margin = Margin::all(value);
802        self
803    }
804
805    /// Set horizontal margin (left and right).
806    pub fn mx(mut self, value: u32) -> Self {
807        self.margin.left = value;
808        self.margin.right = value;
809        self
810    }
811
812    /// Set vertical margin (top and bottom).
813    pub fn my(mut self, value: u32) -> Self {
814        self.margin.top = value;
815        self.margin.bottom = value;
816        self
817    }
818
819    /// Set top margin.
820    pub fn mt(mut self, value: u32) -> Self {
821        self.margin.top = value;
822        self
823    }
824
825    /// Set right margin.
826    pub fn mr(mut self, value: u32) -> Self {
827        self.margin.right = value;
828        self
829    }
830
831    /// Set bottom margin.
832    pub fn mb(mut self, value: u32) -> Self {
833        self.margin.bottom = value;
834        self
835    }
836
837    /// Set left margin.
838    pub fn ml(mut self, value: u32) -> Self {
839        self.margin.left = value;
840        self
841    }
842
843    /// Set per-side margin using a [`Margin`] value.
844    pub fn margin(mut self, margin: Margin) -> Self {
845        self.margin = margin;
846        self
847    }
848
849    // ── sizing (Tailwind: w, h, min-w, max-w, min-h, max-h) ────────
850
851    /// Set a fixed width (sets both min and max width).
852    pub fn w(mut self, value: u32) -> Self {
853        self.constraints.min_width = Some(value);
854        self.constraints.max_width = Some(value);
855        self
856    }
857
858    /// Set a fixed height (sets both min and max height).
859    pub fn h(mut self, value: u32) -> Self {
860        self.constraints.min_height = Some(value);
861        self.constraints.max_height = Some(value);
862        self
863    }
864
865    /// Set the minimum width constraint. Shorthand for [`min_width`](Self::min_width).
866    pub fn min_w(mut self, value: u32) -> Self {
867        self.constraints.min_width = Some(value);
868        self
869    }
870
871    /// Set the maximum width constraint. Shorthand for [`max_width`](Self::max_width).
872    pub fn max_w(mut self, value: u32) -> Self {
873        self.constraints.max_width = Some(value);
874        self
875    }
876
877    /// Set the minimum height constraint. Shorthand for [`min_height`](Self::min_height).
878    pub fn min_h(mut self, value: u32) -> Self {
879        self.constraints.min_height = Some(value);
880        self
881    }
882
883    /// Set the maximum height constraint. Shorthand for [`max_height`](Self::max_height).
884    pub fn max_h(mut self, value: u32) -> Self {
885        self.constraints.max_height = Some(value);
886        self
887    }
888
889    /// Set the minimum width constraint in cells.
890    pub fn min_width(mut self, value: u32) -> Self {
891        self.constraints.min_width = Some(value);
892        self
893    }
894
895    /// Set the maximum width constraint in cells.
896    pub fn max_width(mut self, value: u32) -> Self {
897        self.constraints.max_width = Some(value);
898        self
899    }
900
901    /// Set the minimum height constraint in rows.
902    pub fn min_height(mut self, value: u32) -> Self {
903        self.constraints.min_height = Some(value);
904        self
905    }
906
907    /// Set the maximum height constraint in rows.
908    pub fn max_height(mut self, value: u32) -> Self {
909        self.constraints.max_height = Some(value);
910        self
911    }
912
913    /// Set width as a percentage (1-100) of the parent container.
914    pub fn w_pct(mut self, pct: u8) -> Self {
915        self.constraints.width_pct = Some(pct.min(100));
916        self
917    }
918
919    /// Set height as a percentage (1-100) of the parent container.
920    pub fn h_pct(mut self, pct: u8) -> Self {
921        self.constraints.height_pct = Some(pct.min(100));
922        self
923    }
924
925    /// Set all size constraints at once using a [`Constraints`] value.
926    pub fn constraints(mut self, constraints: Constraints) -> Self {
927        self.constraints = constraints;
928        self
929    }
930
931    // ── flex ─────────────────────────────────────────────────────────
932
933    /// Set the gap (in cells) between child elements.
934    pub fn gap(mut self, gap: u32) -> Self {
935        self.gap = gap;
936        self
937    }
938
939    /// Set the flex-grow factor. `1` means the container expands to fill available space.
940    pub fn grow(mut self, grow: u16) -> Self {
941        self.grow = grow;
942        self
943    }
944
945    // ── alignment ───────────────────────────────────────────────────
946
947    /// Set the cross-axis alignment of child elements.
948    pub fn align(mut self, align: Align) -> Self {
949        self.align = align;
950        self
951    }
952
953    /// Center children on the cross axis. Shorthand for `.align(Align::Center)`.
954    pub fn center(self) -> Self {
955        self.align(Align::Center)
956    }
957
958    /// Set the main-axis content distribution mode.
959    pub fn justify(mut self, justify: Justify) -> Self {
960        self.justify = justify;
961        self
962    }
963
964    /// Distribute children with equal space between; first at start, last at end.
965    pub fn space_between(self) -> Self {
966        self.justify(Justify::SpaceBetween)
967    }
968
969    /// Distribute children with equal space around each child.
970    pub fn space_around(self) -> Self {
971        self.justify(Justify::SpaceAround)
972    }
973
974    /// Distribute children with equal space between all children and edges.
975    pub fn space_evenly(self) -> Self {
976        self.justify(Justify::SpaceEvenly)
977    }
978
979    // ── title ────────────────────────────────────────────────────────
980
981    /// Set a plain-text title rendered in the top border.
982    pub fn title(self, title: impl Into<String>) -> Self {
983        self.title_styled(title, Style::new())
984    }
985
986    /// Set a styled title rendered in the top border.
987    pub fn title_styled(mut self, title: impl Into<String>, style: Style) -> Self {
988        self.title = Some((title.into(), style));
989        self
990    }
991
992    // ── internal ─────────────────────────────────────────────────────
993
994    /// Set the vertical scroll offset in rows. Used internally by [`Context::scrollable`].
995    pub fn scroll_offset(mut self, offset: u32) -> Self {
996        self.scroll_offset = Some(offset);
997        self
998    }
999
1000    /// Finalize the builder as a vertical (column) container.
1001    ///
1002    /// The closure receives a `&mut Context` for rendering children.
1003    /// Returns a [`Response`] with click/hover state for this container.
1004    pub fn col(self, f: impl FnOnce(&mut Context)) -> Response {
1005        self.finish(Direction::Column, f)
1006    }
1007
1008    /// Finalize the builder as a horizontal (row) container.
1009    ///
1010    /// The closure receives a `&mut Context` for rendering children.
1011    /// Returns a [`Response`] with click/hover state for this container.
1012    pub fn row(self, f: impl FnOnce(&mut Context)) -> Response {
1013        self.finish(Direction::Row, f)
1014    }
1015
1016    /// Finalize the builder as an inline text line.
1017    ///
1018    /// Like [`row`](ContainerBuilder::row) but gap is forced to zero
1019    /// for seamless inline rendering of mixed-style text.
1020    pub fn line(mut self, f: impl FnOnce(&mut Context)) -> Response {
1021        self.gap = 0;
1022        self.finish(Direction::Row, f)
1023    }
1024
1025    fn finish(self, direction: Direction, f: impl FnOnce(&mut Context)) -> Response {
1026        let interaction_id = self.ctx.interaction_count;
1027        self.ctx.interaction_count += 1;
1028
1029        if let Some(scroll_offset) = self.scroll_offset {
1030            self.ctx.commands.push(Command::BeginScrollable {
1031                grow: self.grow,
1032                border: self.border,
1033                border_sides: self.border_sides,
1034                border_style: self.border_style,
1035                padding: self.padding,
1036                margin: self.margin,
1037                constraints: self.constraints,
1038                title: self.title,
1039                scroll_offset,
1040            });
1041        } else {
1042            self.ctx.commands.push(Command::BeginContainer {
1043                direction,
1044                gap: self.gap,
1045                align: self.align,
1046                justify: self.justify,
1047                border: self.border,
1048                border_sides: self.border_sides,
1049                border_style: self.border_style,
1050                bg_color: self.bg_color,
1051                padding: self.padding,
1052                margin: self.margin,
1053                constraints: self.constraints,
1054                title: self.title,
1055                grow: self.grow,
1056            });
1057        }
1058        f(self.ctx);
1059        self.ctx.commands.push(Command::EndContainer);
1060        self.ctx.last_text_idx = None;
1061
1062        self.ctx.response_for(interaction_id)
1063    }
1064}
1065
1066impl Context {
1067    #[allow(clippy::too_many_arguments)]
1068    pub(crate) fn new(
1069        events: Vec<Event>,
1070        width: u32,
1071        height: u32,
1072        tick: u64,
1073        mut focus_index: usize,
1074        prev_focus_count: usize,
1075        prev_scroll_infos: Vec<(u32, u32)>,
1076        prev_hit_map: Vec<Rect>,
1077        prev_focus_rects: Vec<(usize, Rect)>,
1078        debug: bool,
1079        theme: Theme,
1080        last_mouse_pos: Option<(u32, u32)>,
1081        prev_modal_active: bool,
1082    ) -> Self {
1083        let consumed = vec![false; events.len()];
1084
1085        let mut mouse_pos = last_mouse_pos;
1086        let mut click_pos = None;
1087        for event in &events {
1088            if let Event::Mouse(mouse) = event {
1089                mouse_pos = Some((mouse.x, mouse.y));
1090                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1091                    click_pos = Some((mouse.x, mouse.y));
1092                }
1093            }
1094        }
1095
1096        if let Some((mx, my)) = click_pos {
1097            let mut best: Option<(usize, u64)> = None;
1098            for &(fid, rect) in &prev_focus_rects {
1099                if mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom() {
1100                    let area = rect.width as u64 * rect.height as u64;
1101                    if best.map_or(true, |(_, ba)| area < ba) {
1102                        best = Some((fid, area));
1103                    }
1104                }
1105            }
1106            if let Some((fid, _)) = best {
1107                focus_index = fid;
1108            }
1109        }
1110
1111        Self {
1112            commands: Vec::new(),
1113            events,
1114            consumed,
1115            should_quit: false,
1116            area_width: width,
1117            area_height: height,
1118            tick,
1119            focus_index,
1120            focus_count: 0,
1121            prev_focus_count,
1122            scroll_count: 0,
1123            prev_scroll_infos,
1124            interaction_count: 0,
1125            prev_hit_map,
1126            _prev_focus_rects: prev_focus_rects,
1127            mouse_pos,
1128            click_pos,
1129            last_text_idx: None,
1130            overlay_depth: 0,
1131            modal_active: false,
1132            prev_modal_active,
1133            debug,
1134            theme,
1135        }
1136    }
1137
1138    pub(crate) fn process_focus_keys(&mut self) {
1139        for (i, event) in self.events.iter().enumerate() {
1140            if let Event::Key(key) = event {
1141                if key.code == KeyCode::Tab && !key.modifiers.contains(KeyModifiers::SHIFT) {
1142                    if self.prev_focus_count > 0 {
1143                        self.focus_index = (self.focus_index + 1) % self.prev_focus_count;
1144                    }
1145                    self.consumed[i] = true;
1146                } else if (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))
1147                    || key.code == KeyCode::BackTab
1148                {
1149                    if self.prev_focus_count > 0 {
1150                        self.focus_index = if self.focus_index == 0 {
1151                            self.prev_focus_count - 1
1152                        } else {
1153                            self.focus_index - 1
1154                        };
1155                    }
1156                    self.consumed[i] = true;
1157                }
1158            }
1159        }
1160    }
1161
1162    /// Render a custom [`Widget`].
1163    ///
1164    /// Calls [`Widget::ui`] with this context and returns the widget's response.
1165    pub fn widget<W: Widget>(&mut self, w: &mut W) -> W::Response {
1166        w.ui(self)
1167    }
1168
1169    /// Wrap child widgets in a panic boundary.
1170    ///
1171    /// If the closure panics, the panic is caught and an error message is
1172    /// rendered in place of the children. The app continues running.
1173    ///
1174    /// # Example
1175    ///
1176    /// ```no_run
1177    /// # slt::run(|ui: &mut slt::Context| {
1178    /// ui.error_boundary(|ui| {
1179    ///     ui.text("risky widget");
1180    /// });
1181    /// # });
1182    /// ```
1183    pub fn error_boundary(&mut self, f: impl FnOnce(&mut Context)) {
1184        self.error_boundary_with(f, |ui, msg| {
1185            ui.styled(
1186                format!("⚠ Error: {msg}"),
1187                Style::new().fg(ui.theme.error).bold(),
1188            );
1189        });
1190    }
1191
1192    /// Like [`error_boundary`](Self::error_boundary), but renders a custom
1193    /// fallback instead of the default error message.
1194    ///
1195    /// The fallback closure receives the panic message as a [`String`].
1196    ///
1197    /// # Example
1198    ///
1199    /// ```no_run
1200    /// # slt::run(|ui: &mut slt::Context| {
1201    /// ui.error_boundary_with(
1202    ///     |ui| {
1203    ///         ui.text("risky widget");
1204    ///     },
1205    ///     |ui, msg| {
1206    ///         ui.text(format!("Recovered from panic: {msg}"));
1207    ///     },
1208    /// );
1209    /// # });
1210    /// ```
1211    pub fn error_boundary_with(
1212        &mut self,
1213        f: impl FnOnce(&mut Context),
1214        fallback: impl FnOnce(&mut Context, String),
1215    ) {
1216        let cmd_count = self.commands.len();
1217        let last_text_idx = self.last_text_idx;
1218
1219        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1220            f(self);
1221        }));
1222
1223        match result {
1224            Ok(()) => {}
1225            Err(panic_info) => {
1226                self.commands.truncate(cmd_count);
1227                self.last_text_idx = last_text_idx;
1228
1229                let msg = if let Some(s) = panic_info.downcast_ref::<&str>() {
1230                    (*s).to_string()
1231                } else if let Some(s) = panic_info.downcast_ref::<String>() {
1232                    s.clone()
1233                } else {
1234                    "widget panicked".to_string()
1235                };
1236
1237                fallback(self, msg);
1238            }
1239        }
1240    }
1241
1242    /// Allocate a click/hover interaction slot and return the [`Response`].
1243    ///
1244    /// Use this in custom widgets to detect mouse clicks and hovers without
1245    /// wrapping content in a container. Each call reserves one slot in the
1246    /// hit-test map, so the call order must be stable across frames.
1247    pub fn interaction(&mut self) -> Response {
1248        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1249            return Response::default();
1250        }
1251        let id = self.interaction_count;
1252        self.interaction_count += 1;
1253        self.response_for(id)
1254    }
1255
1256    /// Register a widget as focusable and return whether it currently has focus.
1257    ///
1258    /// Call this in custom widgets that need keyboard focus. Each call increments
1259    /// the internal focus counter, so the call order must be stable across frames.
1260    pub fn register_focusable(&mut self) -> bool {
1261        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1262            return false;
1263        }
1264        let id = self.focus_count;
1265        self.focus_count += 1;
1266        self.commands.push(Command::FocusMarker(id));
1267        if self.prev_focus_count == 0 {
1268            return true;
1269        }
1270        self.focus_index % self.prev_focus_count == id
1271    }
1272
1273    // ── text ──────────────────────────────────────────────────────────
1274
1275    /// Render a text element. Returns `&mut Self` for style chaining.
1276    ///
1277    /// # Example
1278    ///
1279    /// ```no_run
1280    /// # slt::run(|ui: &mut slt::Context| {
1281    /// use slt::Color;
1282    /// ui.text("hello").bold().fg(Color::Cyan);
1283    /// # });
1284    /// ```
1285    pub fn text(&mut self, s: impl Into<String>) -> &mut Self {
1286        let content = s.into();
1287        self.commands.push(Command::Text {
1288            content,
1289            style: Style::new(),
1290            grow: 0,
1291            align: Align::Start,
1292            wrap: false,
1293            margin: Margin::default(),
1294            constraints: Constraints::default(),
1295        });
1296        self.last_text_idx = Some(self.commands.len() - 1);
1297        self
1298    }
1299
1300    /// Render a clickable hyperlink.
1301    ///
1302    /// The link is interactive: clicking it (or pressing Enter/Space when
1303    /// focused) opens the URL in the system browser. OSC 8 is also emitted
1304    /// for terminals that support native hyperlinks.
1305    pub fn link(&mut self, text: impl Into<String>, url: impl Into<String>) -> &mut Self {
1306        let url_str = url.into();
1307        let focused = self.register_focusable();
1308        let interaction_id = self.interaction_count;
1309        self.interaction_count += 1;
1310        let response = self.response_for(interaction_id);
1311
1312        let mut activated = response.clicked;
1313        if focused {
1314            for (i, event) in self.events.iter().enumerate() {
1315                if let Event::Key(key) = event {
1316                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1317                        activated = true;
1318                        self.consumed[i] = true;
1319                    }
1320                }
1321            }
1322        }
1323
1324        if activated {
1325            let _ = open_url(&url_str);
1326        }
1327
1328        let style = if focused {
1329            Style::new()
1330                .fg(self.theme.primary)
1331                .bg(self.theme.surface_hover)
1332                .underline()
1333                .bold()
1334        } else if response.hovered {
1335            Style::new()
1336                .fg(self.theme.accent)
1337                .bg(self.theme.surface_hover)
1338                .underline()
1339        } else {
1340            Style::new().fg(self.theme.primary).underline()
1341        };
1342
1343        self.commands.push(Command::Link {
1344            text: text.into(),
1345            url: url_str,
1346            style,
1347            margin: Margin::default(),
1348            constraints: Constraints::default(),
1349        });
1350        self.last_text_idx = Some(self.commands.len() - 1);
1351        self
1352    }
1353
1354    /// Render a text element with word-boundary wrapping.
1355    ///
1356    /// Long lines are broken at word boundaries to fit the container width.
1357    /// Style chaining works the same as [`Context::text`].
1358    pub fn text_wrap(&mut self, s: impl Into<String>) -> &mut Self {
1359        let content = s.into();
1360        self.commands.push(Command::Text {
1361            content,
1362            style: Style::new(),
1363            grow: 0,
1364            align: Align::Start,
1365            wrap: true,
1366            margin: Margin::default(),
1367            constraints: Constraints::default(),
1368        });
1369        self.last_text_idx = Some(self.commands.len() - 1);
1370        self
1371    }
1372
1373    // ── style chain (applies to last text) ───────────────────────────
1374
1375    /// Apply bold to the last rendered text element.
1376    pub fn bold(&mut self) -> &mut Self {
1377        self.modify_last_style(|s| s.modifiers |= Modifiers::BOLD);
1378        self
1379    }
1380
1381    /// Apply dim styling to the last rendered text element.
1382    ///
1383    /// Also sets the foreground color to the theme's `text_dim` color if no
1384    /// explicit foreground has been set.
1385    pub fn dim(&mut self) -> &mut Self {
1386        let text_dim = self.theme.text_dim;
1387        self.modify_last_style(|s| {
1388            s.modifiers |= Modifiers::DIM;
1389            if s.fg.is_none() {
1390                s.fg = Some(text_dim);
1391            }
1392        });
1393        self
1394    }
1395
1396    /// Apply italic to the last rendered text element.
1397    pub fn italic(&mut self) -> &mut Self {
1398        self.modify_last_style(|s| s.modifiers |= Modifiers::ITALIC);
1399        self
1400    }
1401
1402    /// Apply underline to the last rendered text element.
1403    pub fn underline(&mut self) -> &mut Self {
1404        self.modify_last_style(|s| s.modifiers |= Modifiers::UNDERLINE);
1405        self
1406    }
1407
1408    /// Apply reverse-video to the last rendered text element.
1409    pub fn reversed(&mut self) -> &mut Self {
1410        self.modify_last_style(|s| s.modifiers |= Modifiers::REVERSED);
1411        self
1412    }
1413
1414    /// Apply strikethrough to the last rendered text element.
1415    pub fn strikethrough(&mut self) -> &mut Self {
1416        self.modify_last_style(|s| s.modifiers |= Modifiers::STRIKETHROUGH);
1417        self
1418    }
1419
1420    /// Set the foreground color of the last rendered text element.
1421    pub fn fg(&mut self, color: Color) -> &mut Self {
1422        self.modify_last_style(|s| s.fg = Some(color));
1423        self
1424    }
1425
1426    /// Set the background color of the last rendered text element.
1427    pub fn bg(&mut self, color: Color) -> &mut Self {
1428        self.modify_last_style(|s| s.bg = Some(color));
1429        self
1430    }
1431
1432    /// Render a text element with an explicit [`Style`] applied immediately.
1433    ///
1434    /// Equivalent to calling `text(s)` followed by style-chain methods, but
1435    /// more concise when you already have a `Style` value.
1436    pub fn styled(&mut self, s: impl Into<String>, style: Style) -> &mut Self {
1437        self.commands.push(Command::Text {
1438            content: s.into(),
1439            style,
1440            grow: 0,
1441            align: Align::Start,
1442            wrap: false,
1443            margin: Margin::default(),
1444            constraints: Constraints::default(),
1445        });
1446        self.last_text_idx = Some(self.commands.len() - 1);
1447        self
1448    }
1449
1450    /// Enable word-boundary wrapping on the last rendered text element.
1451    pub fn wrap(&mut self) -> &mut Self {
1452        if let Some(idx) = self.last_text_idx {
1453            if let Command::Text { wrap, .. } = &mut self.commands[idx] {
1454                *wrap = true;
1455            }
1456        }
1457        self
1458    }
1459
1460    fn modify_last_style(&mut self, f: impl FnOnce(&mut Style)) {
1461        if let Some(idx) = self.last_text_idx {
1462            match &mut self.commands[idx] {
1463                Command::Text { style, .. } | Command::Link { style, .. } => f(style),
1464                _ => {}
1465            }
1466        }
1467    }
1468
1469    // ── containers ───────────────────────────────────────────────────
1470
1471    /// Create a vertical (column) container.
1472    ///
1473    /// Children are stacked top-to-bottom. Returns a [`Response`] with
1474    /// click/hover state for the container area.
1475    ///
1476    /// # Example
1477    ///
1478    /// ```no_run
1479    /// # slt::run(|ui: &mut slt::Context| {
1480    /// ui.col(|ui| {
1481    ///     ui.text("line one");
1482    ///     ui.text("line two");
1483    /// });
1484    /// # });
1485    /// ```
1486    pub fn col(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1487        self.push_container(Direction::Column, 0, f)
1488    }
1489
1490    /// Create a vertical (column) container with a gap between children.
1491    ///
1492    /// `gap` is the number of blank rows inserted between each child.
1493    pub fn col_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1494        self.push_container(Direction::Column, gap, f)
1495    }
1496
1497    /// Create a horizontal (row) container.
1498    ///
1499    /// Children are placed left-to-right. Returns a [`Response`] with
1500    /// click/hover state for the container area.
1501    ///
1502    /// # Example
1503    ///
1504    /// ```no_run
1505    /// # slt::run(|ui: &mut slt::Context| {
1506    /// ui.row(|ui| {
1507    ///     ui.text("left");
1508    ///     ui.spacer();
1509    ///     ui.text("right");
1510    /// });
1511    /// # });
1512    /// ```
1513    pub fn row(&mut self, f: impl FnOnce(&mut Context)) -> Response {
1514        self.push_container(Direction::Row, 0, f)
1515    }
1516
1517    /// Create a horizontal (row) container with a gap between children.
1518    ///
1519    /// `gap` is the number of blank columns inserted between each child.
1520    pub fn row_gap(&mut self, gap: u32, f: impl FnOnce(&mut Context)) -> Response {
1521        self.push_container(Direction::Row, gap, f)
1522    }
1523
1524    /// Render inline text with mixed styles on a single line.
1525    ///
1526    /// Unlike [`row`](Context::row), `line()` is designed for rich text —
1527    /// children are rendered as continuous inline text without gaps.
1528    ///
1529    /// # Example
1530    ///
1531    /// ```no_run
1532    /// # use slt::Color;
1533    /// # slt::run(|ui: &mut slt::Context| {
1534    /// ui.line(|ui| {
1535    ///     ui.text("Status: ");
1536    ///     ui.text("Online").bold().fg(Color::Green);
1537    /// });
1538    /// # });
1539    /// ```
1540    pub fn line(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1541        let _ = self.push_container(Direction::Row, 0, f);
1542        self
1543    }
1544
1545    /// Render inline text with mixed styles, wrapping at word boundaries.
1546    ///
1547    /// Like [`line`](Context::line), but when the combined text exceeds
1548    /// the container width it wraps across multiple lines while
1549    /// preserving per-segment styles.
1550    ///
1551    /// # Example
1552    ///
1553    /// ```no_run
1554    /// # use slt::{Color, Style};
1555    /// # slt::run(|ui: &mut slt::Context| {
1556    /// ui.line_wrap(|ui| {
1557    ///     ui.text("This is a long ");
1558    ///     ui.text("important").bold().fg(Color::Red);
1559    ///     ui.text(" message that wraps across lines");
1560    /// });
1561    /// # });
1562    /// ```
1563    pub fn line_wrap(&mut self, f: impl FnOnce(&mut Context)) -> &mut Self {
1564        let start = self.commands.len();
1565        f(self);
1566        let mut segments: Vec<(String, Style)> = Vec::new();
1567        for cmd in self.commands.drain(start..) {
1568            if let Command::Text { content, style, .. } = cmd {
1569                segments.push((content, style));
1570            }
1571        }
1572        self.commands.push(Command::RichText {
1573            segments,
1574            wrap: true,
1575            align: Align::Start,
1576            margin: Margin::default(),
1577            constraints: Constraints::default(),
1578        });
1579        self.last_text_idx = None;
1580        self
1581    }
1582
1583    pub fn modal(&mut self, f: impl FnOnce(&mut Context)) {
1584        self.commands.push(Command::BeginOverlay { modal: true });
1585        self.overlay_depth += 1;
1586        self.modal_active = true;
1587        f(self);
1588        self.overlay_depth = self.overlay_depth.saturating_sub(1);
1589        self.commands.push(Command::EndOverlay);
1590        self.last_text_idx = None;
1591    }
1592
1593    pub fn overlay(&mut self, f: impl FnOnce(&mut Context)) {
1594        self.commands.push(Command::BeginOverlay { modal: false });
1595        self.overlay_depth += 1;
1596        f(self);
1597        self.overlay_depth = self.overlay_depth.saturating_sub(1);
1598        self.commands.push(Command::EndOverlay);
1599        self.last_text_idx = None;
1600    }
1601
1602    /// Create a container with a fluent builder.
1603    ///
1604    /// Use this for borders, padding, grow, constraints, and titles. Chain
1605    /// configuration methods on the returned [`ContainerBuilder`], then call
1606    /// `.col()` or `.row()` to finalize.
1607    ///
1608    /// # Example
1609    ///
1610    /// ```no_run
1611    /// # slt::run(|ui: &mut slt::Context| {
1612    /// use slt::Border;
1613    /// ui.container()
1614    ///     .border(Border::Rounded)
1615    ///     .pad(1)
1616    ///     .title("My Panel")
1617    ///     .col(|ui| {
1618    ///         ui.text("content");
1619    ///     });
1620    /// # });
1621    /// ```
1622    pub fn container(&mut self) -> ContainerBuilder<'_> {
1623        let border = self.theme.border;
1624        ContainerBuilder {
1625            ctx: self,
1626            gap: 0,
1627            align: Align::Start,
1628            justify: Justify::Start,
1629            border: None,
1630            border_sides: BorderSides::all(),
1631            border_style: Style::new().fg(border),
1632            bg_color: None,
1633            padding: Padding::default(),
1634            margin: Margin::default(),
1635            constraints: Constraints::default(),
1636            title: None,
1637            grow: 0,
1638            scroll_offset: None,
1639        }
1640    }
1641
1642    /// Create a scrollable container. Handles wheel scroll and drag-to-scroll automatically.
1643    ///
1644    /// Pass a [`ScrollState`] to persist scroll position across frames. The state
1645    /// is updated in-place with the current scroll offset and bounds.
1646    ///
1647    /// # Example
1648    ///
1649    /// ```no_run
1650    /// # use slt::widgets::ScrollState;
1651    /// # slt::run(|ui: &mut slt::Context| {
1652    /// let mut scroll = ScrollState::new();
1653    /// ui.scrollable(&mut scroll).col(|ui| {
1654    ///     for i in 0..100 {
1655    ///         ui.text(format!("Line {i}"));
1656    ///     }
1657    /// });
1658    /// # });
1659    /// ```
1660    pub fn scrollable(&mut self, state: &mut ScrollState) -> ContainerBuilder<'_> {
1661        let index = self.scroll_count;
1662        self.scroll_count += 1;
1663        if let Some(&(ch, vh)) = self.prev_scroll_infos.get(index) {
1664            state.set_bounds(ch, vh);
1665            let max = ch.saturating_sub(vh) as usize;
1666            state.offset = state.offset.min(max);
1667        }
1668
1669        let next_id = self.interaction_count;
1670        if let Some(rect) = self.prev_hit_map.get(next_id).copied() {
1671            self.auto_scroll(&rect, state);
1672        }
1673
1674        self.container().scroll_offset(state.offset as u32)
1675    }
1676
1677    fn auto_scroll(&mut self, rect: &Rect, state: &mut ScrollState) {
1678        let mut to_consume: Vec<usize> = Vec::new();
1679
1680        for (i, event) in self.events.iter().enumerate() {
1681            if self.consumed[i] {
1682                continue;
1683            }
1684            if let Event::Mouse(mouse) = event {
1685                let in_bounds = mouse.x >= rect.x
1686                    && mouse.x < rect.right()
1687                    && mouse.y >= rect.y
1688                    && mouse.y < rect.bottom();
1689                if !in_bounds {
1690                    continue;
1691                }
1692                match mouse.kind {
1693                    MouseKind::ScrollUp => {
1694                        state.scroll_up(1);
1695                        to_consume.push(i);
1696                    }
1697                    MouseKind::ScrollDown => {
1698                        state.scroll_down(1);
1699                        to_consume.push(i);
1700                    }
1701                    MouseKind::Drag(MouseButton::Left) => {
1702                        // Left-drag is reserved for text selection.
1703                        // Scroll via mouse wheel instead.
1704                    }
1705                    _ => {}
1706                }
1707            }
1708        }
1709
1710        for i in to_consume {
1711            self.consumed[i] = true;
1712        }
1713    }
1714
1715    /// Shortcut for `container().border(border)`.
1716    ///
1717    /// Returns a [`ContainerBuilder`] pre-configured with the given border style.
1718    pub fn bordered(&mut self, border: Border) -> ContainerBuilder<'_> {
1719        self.container()
1720            .border(border)
1721            .border_sides(BorderSides::all())
1722    }
1723
1724    fn push_container(
1725        &mut self,
1726        direction: Direction,
1727        gap: u32,
1728        f: impl FnOnce(&mut Context),
1729    ) -> Response {
1730        let interaction_id = self.interaction_count;
1731        self.interaction_count += 1;
1732        let border = self.theme.border;
1733
1734        self.commands.push(Command::BeginContainer {
1735            direction,
1736            gap,
1737            align: Align::Start,
1738            justify: Justify::Start,
1739            border: None,
1740            border_sides: BorderSides::all(),
1741            border_style: Style::new().fg(border),
1742            bg_color: None,
1743            padding: Padding::default(),
1744            margin: Margin::default(),
1745            constraints: Constraints::default(),
1746            title: None,
1747            grow: 0,
1748        });
1749        f(self);
1750        self.commands.push(Command::EndContainer);
1751        self.last_text_idx = None;
1752
1753        self.response_for(interaction_id)
1754    }
1755
1756    fn response_for(&self, interaction_id: usize) -> Response {
1757        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1758            return Response::default();
1759        }
1760        if let Some(rect) = self.prev_hit_map.get(interaction_id) {
1761            let clicked = self
1762                .click_pos
1763                .map(|(mx, my)| {
1764                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1765                })
1766                .unwrap_or(false);
1767            let hovered = self
1768                .mouse_pos
1769                .map(|(mx, my)| {
1770                    mx >= rect.x && mx < rect.right() && my >= rect.y && my < rect.bottom()
1771                })
1772                .unwrap_or(false);
1773            Response { clicked, hovered }
1774        } else {
1775            Response::default()
1776        }
1777    }
1778
1779    /// Set the flex-grow factor of the last rendered text element.
1780    ///
1781    /// A value of `1` causes the element to expand and fill remaining space
1782    /// along the main axis.
1783    pub fn grow(&mut self, value: u16) -> &mut Self {
1784        if let Some(idx) = self.last_text_idx {
1785            if let Command::Text { grow, .. } = &mut self.commands[idx] {
1786                *grow = value;
1787            }
1788        }
1789        self
1790    }
1791
1792    /// Set the text alignment of the last rendered text element.
1793    pub fn align(&mut self, align: Align) -> &mut Self {
1794        if let Some(idx) = self.last_text_idx {
1795            if let Command::Text {
1796                align: text_align, ..
1797            } = &mut self.commands[idx]
1798            {
1799                *text_align = align;
1800            }
1801        }
1802        self
1803    }
1804
1805    /// Render an invisible spacer that expands to fill available space.
1806    ///
1807    /// Useful for pushing siblings to opposite ends of a row or column.
1808    pub fn spacer(&mut self) -> &mut Self {
1809        self.commands.push(Command::Spacer { grow: 1 });
1810        self.last_text_idx = None;
1811        self
1812    }
1813
1814    /// Render a form that groups input fields vertically.
1815    ///
1816    /// Use [`Context::form_field`] inside the closure to render each field.
1817    pub fn form(
1818        &mut self,
1819        state: &mut FormState,
1820        f: impl FnOnce(&mut Context, &mut FormState),
1821    ) -> &mut Self {
1822        self.col(|ui| {
1823            f(ui, state);
1824        });
1825        self
1826    }
1827
1828    /// Render a single form field with label and input.
1829    ///
1830    /// Shows a validation error below the input when present.
1831    pub fn form_field(&mut self, field: &mut FormField) -> &mut Self {
1832        self.col(|ui| {
1833            ui.styled(field.label.clone(), Style::new().bold().fg(ui.theme.text));
1834            ui.text_input(&mut field.input);
1835            if let Some(error) = field.error.as_deref() {
1836                ui.styled(error.to_string(), Style::new().dim().fg(ui.theme.error));
1837            }
1838        });
1839        self
1840    }
1841
1842    /// Render a submit button.
1843    ///
1844    /// Returns `true` when the button is clicked or activated.
1845    pub fn form_submit(&mut self, label: impl Into<String>) -> bool {
1846        self.button(label)
1847    }
1848
1849    /// Render a single-line text input. Auto-handles cursor, typing, and backspace.
1850    ///
1851    /// The widget claims focus via [`Context::register_focusable`]. When focused,
1852    /// it consumes character, backspace, arrow, Home, and End key events.
1853    ///
1854    /// # Example
1855    ///
1856    /// ```no_run
1857    /// # use slt::widgets::TextInputState;
1858    /// # slt::run(|ui: &mut slt::Context| {
1859    /// let mut input = TextInputState::with_placeholder("Search...");
1860    /// ui.text_input(&mut input);
1861    /// // input.value holds the current text
1862    /// # });
1863    /// ```
1864    pub fn text_input(&mut self, state: &mut TextInputState) -> &mut Self {
1865        slt_assert(
1866            !state.value.contains('\n'),
1867            "text_input got a newline — use textarea instead",
1868        );
1869        let focused = self.register_focusable();
1870        state.cursor = state.cursor.min(state.value.chars().count());
1871
1872        if focused {
1873            let mut consumed_indices = Vec::new();
1874            for (i, event) in self.events.iter().enumerate() {
1875                if let Event::Key(key) = event {
1876                    match key.code {
1877                        KeyCode::Char(ch) => {
1878                            if let Some(max) = state.max_length {
1879                                if state.value.chars().count() >= max {
1880                                    continue;
1881                                }
1882                            }
1883                            let index = byte_index_for_char(&state.value, state.cursor);
1884                            state.value.insert(index, ch);
1885                            state.cursor += 1;
1886                            consumed_indices.push(i);
1887                        }
1888                        KeyCode::Backspace => {
1889                            if state.cursor > 0 {
1890                                let start = byte_index_for_char(&state.value, state.cursor - 1);
1891                                let end = byte_index_for_char(&state.value, state.cursor);
1892                                state.value.replace_range(start..end, "");
1893                                state.cursor -= 1;
1894                            }
1895                            consumed_indices.push(i);
1896                        }
1897                        KeyCode::Left => {
1898                            state.cursor = state.cursor.saturating_sub(1);
1899                            consumed_indices.push(i);
1900                        }
1901                        KeyCode::Right => {
1902                            state.cursor = (state.cursor + 1).min(state.value.chars().count());
1903                            consumed_indices.push(i);
1904                        }
1905                        KeyCode::Home => {
1906                            state.cursor = 0;
1907                            consumed_indices.push(i);
1908                        }
1909                        KeyCode::Delete => {
1910                            let len = state.value.chars().count();
1911                            if state.cursor < len {
1912                                let start = byte_index_for_char(&state.value, state.cursor);
1913                                let end = byte_index_for_char(&state.value, state.cursor + 1);
1914                                state.value.replace_range(start..end, "");
1915                            }
1916                            consumed_indices.push(i);
1917                        }
1918                        KeyCode::End => {
1919                            state.cursor = state.value.chars().count();
1920                            consumed_indices.push(i);
1921                        }
1922                        _ => {}
1923                    }
1924                }
1925                if let Event::Paste(ref text) = event {
1926                    for ch in text.chars() {
1927                        if let Some(max) = state.max_length {
1928                            if state.value.chars().count() >= max {
1929                                break;
1930                            }
1931                        }
1932                        let index = byte_index_for_char(&state.value, state.cursor);
1933                        state.value.insert(index, ch);
1934                        state.cursor += 1;
1935                    }
1936                    consumed_indices.push(i);
1937                }
1938            }
1939
1940            for index in consumed_indices {
1941                self.consumed[index] = true;
1942            }
1943        }
1944
1945        let show_cursor = focused && (self.tick / 30) % 2 == 0;
1946
1947        let input_text = if state.value.is_empty() {
1948            if state.placeholder.len() > 100 {
1949                slt_warn(
1950                    "text_input placeholder is very long (>100 chars) — consider shortening it",
1951                );
1952            }
1953            state.placeholder.clone()
1954        } else {
1955            let mut rendered = String::new();
1956            for (idx, ch) in state.value.chars().enumerate() {
1957                if show_cursor && idx == state.cursor {
1958                    rendered.push('▎');
1959                }
1960                rendered.push(if state.masked { '•' } else { ch });
1961            }
1962            if show_cursor && state.cursor >= state.value.chars().count() {
1963                rendered.push('▎');
1964            }
1965            rendered
1966        };
1967        let input_style = if state.value.is_empty() {
1968            Style::new().dim().fg(self.theme.text_dim)
1969        } else {
1970            Style::new().fg(self.theme.text)
1971        };
1972
1973        let border_color = if focused {
1974            self.theme.primary
1975        } else if state.validation_error.is_some() {
1976            self.theme.error
1977        } else {
1978            self.theme.border
1979        };
1980
1981        self.bordered(Border::Rounded)
1982            .border_style(Style::new().fg(border_color))
1983            .px(1)
1984            .col(|ui| {
1985                ui.styled(input_text, input_style);
1986            });
1987
1988        if let Some(error) = state.validation_error.clone() {
1989            self.styled(
1990                format!("⚠ {error}"),
1991                Style::new().dim().fg(self.theme.error),
1992            );
1993        }
1994        self
1995    }
1996
1997    /// Render an animated spinner.
1998    ///
1999    /// The spinner advances one frame per tick. Use [`SpinnerState::dots`] or
2000    /// [`SpinnerState::line`] to create the state, then chain style methods to
2001    /// color it.
2002    pub fn spinner(&mut self, state: &SpinnerState) -> &mut Self {
2003        self.styled(
2004            state.frame(self.tick).to_string(),
2005            Style::new().fg(self.theme.primary),
2006        )
2007    }
2008
2009    /// Render toast notifications. Calls `state.cleanup(tick)` automatically.
2010    ///
2011    /// Expired messages are removed before rendering. If there are no active
2012    /// messages, nothing is rendered and `self` is returned unchanged.
2013    pub fn toast(&mut self, state: &mut ToastState) -> &mut Self {
2014        state.cleanup(self.tick);
2015        if state.messages.is_empty() {
2016            return self;
2017        }
2018
2019        self.interaction_count += 1;
2020        self.commands.push(Command::BeginContainer {
2021            direction: Direction::Column,
2022            gap: 0,
2023            align: Align::Start,
2024            justify: Justify::Start,
2025            border: None,
2026            border_sides: BorderSides::all(),
2027            border_style: Style::new().fg(self.theme.border),
2028            bg_color: None,
2029            padding: Padding::default(),
2030            margin: Margin::default(),
2031            constraints: Constraints::default(),
2032            title: None,
2033            grow: 0,
2034        });
2035        for message in state.messages.iter().rev() {
2036            let color = match message.level {
2037                ToastLevel::Info => self.theme.primary,
2038                ToastLevel::Success => self.theme.success,
2039                ToastLevel::Warning => self.theme.warning,
2040                ToastLevel::Error => self.theme.error,
2041            };
2042            self.styled(format!("  ● {}", message.text), Style::new().fg(color));
2043        }
2044        self.commands.push(Command::EndContainer);
2045        self.last_text_idx = None;
2046
2047        self
2048    }
2049
2050    /// Render a multi-line text area with the given number of visible rows.
2051    ///
2052    /// When focused, handles character input, Enter (new line), Backspace,
2053    /// arrow keys, Home, and End. The cursor is rendered as a block character.
2054    ///
2055    /// Set [`TextareaState::word_wrap`] to enable soft-wrapping at a given
2056    /// display-column width. Up/Down then navigate visual lines.
2057    pub fn textarea(&mut self, state: &mut TextareaState, visible_rows: u32) -> &mut Self {
2058        if state.lines.is_empty() {
2059            state.lines.push(String::new());
2060        }
2061        state.cursor_row = state.cursor_row.min(state.lines.len().saturating_sub(1));
2062        state.cursor_col = state
2063            .cursor_col
2064            .min(state.lines[state.cursor_row].chars().count());
2065
2066        let focused = self.register_focusable();
2067        let wrap_w = state.wrap_width.unwrap_or(u32::MAX);
2068        let wrapping = state.wrap_width.is_some();
2069
2070        let pre_vlines = textarea_build_visual_lines(&state.lines, wrap_w);
2071
2072        if focused {
2073            let mut consumed_indices = Vec::new();
2074            for (i, event) in self.events.iter().enumerate() {
2075                if let Event::Key(key) = event {
2076                    match key.code {
2077                        KeyCode::Char(ch) => {
2078                            if let Some(max) = state.max_length {
2079                                let total: usize =
2080                                    state.lines.iter().map(|line| line.chars().count()).sum();
2081                                if total >= max {
2082                                    continue;
2083                                }
2084                            }
2085                            let index = byte_index_for_char(
2086                                &state.lines[state.cursor_row],
2087                                state.cursor_col,
2088                            );
2089                            state.lines[state.cursor_row].insert(index, ch);
2090                            state.cursor_col += 1;
2091                            consumed_indices.push(i);
2092                        }
2093                        KeyCode::Enter => {
2094                            let split_index = byte_index_for_char(
2095                                &state.lines[state.cursor_row],
2096                                state.cursor_col,
2097                            );
2098                            let remainder = state.lines[state.cursor_row].split_off(split_index);
2099                            state.cursor_row += 1;
2100                            state.lines.insert(state.cursor_row, remainder);
2101                            state.cursor_col = 0;
2102                            consumed_indices.push(i);
2103                        }
2104                        KeyCode::Backspace => {
2105                            if state.cursor_col > 0 {
2106                                let start = byte_index_for_char(
2107                                    &state.lines[state.cursor_row],
2108                                    state.cursor_col - 1,
2109                                );
2110                                let end = byte_index_for_char(
2111                                    &state.lines[state.cursor_row],
2112                                    state.cursor_col,
2113                                );
2114                                state.lines[state.cursor_row].replace_range(start..end, "");
2115                                state.cursor_col -= 1;
2116                            } else if state.cursor_row > 0 {
2117                                let current = state.lines.remove(state.cursor_row);
2118                                state.cursor_row -= 1;
2119                                state.cursor_col = state.lines[state.cursor_row].chars().count();
2120                                state.lines[state.cursor_row].push_str(&current);
2121                            }
2122                            consumed_indices.push(i);
2123                        }
2124                        KeyCode::Left => {
2125                            if state.cursor_col > 0 {
2126                                state.cursor_col -= 1;
2127                            } else if state.cursor_row > 0 {
2128                                state.cursor_row -= 1;
2129                                state.cursor_col = state.lines[state.cursor_row].chars().count();
2130                            }
2131                            consumed_indices.push(i);
2132                        }
2133                        KeyCode::Right => {
2134                            let line_len = state.lines[state.cursor_row].chars().count();
2135                            if state.cursor_col < line_len {
2136                                state.cursor_col += 1;
2137                            } else if state.cursor_row + 1 < state.lines.len() {
2138                                state.cursor_row += 1;
2139                                state.cursor_col = 0;
2140                            }
2141                            consumed_indices.push(i);
2142                        }
2143                        KeyCode::Up => {
2144                            if wrapping {
2145                                let (vrow, vcol) = textarea_logical_to_visual(
2146                                    &pre_vlines,
2147                                    state.cursor_row,
2148                                    state.cursor_col,
2149                                );
2150                                if vrow > 0 {
2151                                    let (lr, lc) =
2152                                        textarea_visual_to_logical(&pre_vlines, vrow - 1, vcol);
2153                                    state.cursor_row = lr;
2154                                    state.cursor_col = lc;
2155                                }
2156                            } else if state.cursor_row > 0 {
2157                                state.cursor_row -= 1;
2158                                state.cursor_col = state
2159                                    .cursor_col
2160                                    .min(state.lines[state.cursor_row].chars().count());
2161                            }
2162                            consumed_indices.push(i);
2163                        }
2164                        KeyCode::Down => {
2165                            if wrapping {
2166                                let (vrow, vcol) = textarea_logical_to_visual(
2167                                    &pre_vlines,
2168                                    state.cursor_row,
2169                                    state.cursor_col,
2170                                );
2171                                if vrow + 1 < pre_vlines.len() {
2172                                    let (lr, lc) =
2173                                        textarea_visual_to_logical(&pre_vlines, vrow + 1, vcol);
2174                                    state.cursor_row = lr;
2175                                    state.cursor_col = lc;
2176                                }
2177                            } else if state.cursor_row + 1 < state.lines.len() {
2178                                state.cursor_row += 1;
2179                                state.cursor_col = state
2180                                    .cursor_col
2181                                    .min(state.lines[state.cursor_row].chars().count());
2182                            }
2183                            consumed_indices.push(i);
2184                        }
2185                        KeyCode::Home => {
2186                            state.cursor_col = 0;
2187                            consumed_indices.push(i);
2188                        }
2189                        KeyCode::Delete => {
2190                            let line_len = state.lines[state.cursor_row].chars().count();
2191                            if state.cursor_col < line_len {
2192                                let start = byte_index_for_char(
2193                                    &state.lines[state.cursor_row],
2194                                    state.cursor_col,
2195                                );
2196                                let end = byte_index_for_char(
2197                                    &state.lines[state.cursor_row],
2198                                    state.cursor_col + 1,
2199                                );
2200                                state.lines[state.cursor_row].replace_range(start..end, "");
2201                            } else if state.cursor_row + 1 < state.lines.len() {
2202                                let next = state.lines.remove(state.cursor_row + 1);
2203                                state.lines[state.cursor_row].push_str(&next);
2204                            }
2205                            consumed_indices.push(i);
2206                        }
2207                        KeyCode::End => {
2208                            state.cursor_col = state.lines[state.cursor_row].chars().count();
2209                            consumed_indices.push(i);
2210                        }
2211                        _ => {}
2212                    }
2213                }
2214                if let Event::Paste(ref text) = event {
2215                    for ch in text.chars() {
2216                        if ch == '\n' || ch == '\r' {
2217                            let split_index = byte_index_for_char(
2218                                &state.lines[state.cursor_row],
2219                                state.cursor_col,
2220                            );
2221                            let remainder = state.lines[state.cursor_row].split_off(split_index);
2222                            state.cursor_row += 1;
2223                            state.lines.insert(state.cursor_row, remainder);
2224                            state.cursor_col = 0;
2225                        } else {
2226                            if let Some(max) = state.max_length {
2227                                let total: usize =
2228                                    state.lines.iter().map(|l| l.chars().count()).sum();
2229                                if total >= max {
2230                                    break;
2231                                }
2232                            }
2233                            let index = byte_index_for_char(
2234                                &state.lines[state.cursor_row],
2235                                state.cursor_col,
2236                            );
2237                            state.lines[state.cursor_row].insert(index, ch);
2238                            state.cursor_col += 1;
2239                        }
2240                    }
2241                    consumed_indices.push(i);
2242                }
2243            }
2244
2245            for index in consumed_indices {
2246                self.consumed[index] = true;
2247            }
2248        }
2249
2250        let vlines = textarea_build_visual_lines(&state.lines, wrap_w);
2251        let (cursor_vrow, cursor_vcol) =
2252            textarea_logical_to_visual(&vlines, state.cursor_row, state.cursor_col);
2253
2254        if cursor_vrow < state.scroll_offset {
2255            state.scroll_offset = cursor_vrow;
2256        }
2257        if cursor_vrow >= state.scroll_offset + visible_rows as usize {
2258            state.scroll_offset = cursor_vrow + 1 - visible_rows as usize;
2259        }
2260
2261        self.interaction_count += 1;
2262        self.commands.push(Command::BeginContainer {
2263            direction: Direction::Column,
2264            gap: 0,
2265            align: Align::Start,
2266            justify: Justify::Start,
2267            border: None,
2268            border_sides: BorderSides::all(),
2269            border_style: Style::new().fg(self.theme.border),
2270            bg_color: None,
2271            padding: Padding::default(),
2272            margin: Margin::default(),
2273            constraints: Constraints::default(),
2274            title: None,
2275            grow: 0,
2276        });
2277
2278        let show_cursor = focused && (self.tick / 30) % 2 == 0;
2279        for vi in 0..visible_rows as usize {
2280            let actual_vi = state.scroll_offset + vi;
2281            let (seg_text, is_cursor_line) = if let Some(vl) = vlines.get(actual_vi) {
2282                let line = &state.lines[vl.logical_row];
2283                let text: String = line
2284                    .chars()
2285                    .skip(vl.char_start)
2286                    .take(vl.char_count)
2287                    .collect();
2288                (text, actual_vi == cursor_vrow)
2289            } else {
2290                (String::new(), false)
2291            };
2292
2293            let mut rendered = seg_text.clone();
2294            let mut style = if seg_text.is_empty() {
2295                Style::new().fg(self.theme.text_dim)
2296            } else {
2297                Style::new().fg(self.theme.text)
2298            };
2299
2300            if is_cursor_line {
2301                rendered.clear();
2302                for (idx, ch) in seg_text.chars().enumerate() {
2303                    if show_cursor && idx == cursor_vcol {
2304                        rendered.push('▎');
2305                    }
2306                    rendered.push(ch);
2307                }
2308                if show_cursor && cursor_vcol >= seg_text.chars().count() {
2309                    rendered.push('▎');
2310                }
2311                style = Style::new().fg(self.theme.text);
2312            }
2313
2314            self.styled(rendered, style);
2315        }
2316        self.commands.push(Command::EndContainer);
2317        self.last_text_idx = None;
2318
2319        self
2320    }
2321
2322    /// Render a progress bar (20 chars wide). `ratio` is clamped to `0.0..=1.0`.
2323    ///
2324    /// Uses block characters (`█` filled, `░` empty). For a custom width use
2325    /// [`Context::progress_bar`].
2326    pub fn progress(&mut self, ratio: f64) -> &mut Self {
2327        self.progress_bar(ratio, 20)
2328    }
2329
2330    /// Render a progress bar with a custom character width.
2331    ///
2332    /// `ratio` is clamped to `0.0..=1.0`. `width` is the total number of
2333    /// characters rendered.
2334    pub fn progress_bar(&mut self, ratio: f64, width: u32) -> &mut Self {
2335        let clamped = ratio.clamp(0.0, 1.0);
2336        let filled = (clamped * width as f64).round() as u32;
2337        let empty = width.saturating_sub(filled);
2338        let mut bar = String::new();
2339        for _ in 0..filled {
2340            bar.push('█');
2341        }
2342        for _ in 0..empty {
2343            bar.push('░');
2344        }
2345        self.text(bar)
2346    }
2347
2348    /// Render a horizontal bar chart from `(label, value)` pairs.
2349    ///
2350    /// Bars are normalized against the largest value and rendered with `█` up to
2351    /// `max_width` characters.
2352    ///
2353    /// # Example
2354    ///
2355    /// ```ignore
2356    /// # slt::run(|ui: &mut slt::Context| {
2357    /// let data = [
2358    ///     ("Sales", 160.0),
2359    ///     ("Revenue", 120.0),
2360    ///     ("Users", 220.0),
2361    ///     ("Costs", 60.0),
2362    /// ];
2363    /// ui.bar_chart(&data, 24);
2364    ///
2365    /// For styled bars with per-bar colors, see [`bar_chart_styled`].
2366    /// # });
2367    /// ```
2368    pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
2369        if data.is_empty() {
2370            return self;
2371        }
2372
2373        let max_label_width = data
2374            .iter()
2375            .map(|(label, _)| UnicodeWidthStr::width(*label))
2376            .max()
2377            .unwrap_or(0);
2378        let max_value = data
2379            .iter()
2380            .map(|(_, value)| *value)
2381            .fold(f64::NEG_INFINITY, f64::max);
2382        let denom = if max_value > 0.0 { max_value } else { 1.0 };
2383
2384        self.interaction_count += 1;
2385        self.commands.push(Command::BeginContainer {
2386            direction: Direction::Column,
2387            gap: 0,
2388            align: Align::Start,
2389            justify: Justify::Start,
2390            border: None,
2391            border_sides: BorderSides::all(),
2392            border_style: Style::new().fg(self.theme.border),
2393            bg_color: None,
2394            padding: Padding::default(),
2395            margin: Margin::default(),
2396            constraints: Constraints::default(),
2397            title: None,
2398            grow: 0,
2399        });
2400
2401        for (label, value) in data {
2402            let label_width = UnicodeWidthStr::width(*label);
2403            let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2404            let normalized = (*value / denom).clamp(0.0, 1.0);
2405            let bar_len = (normalized * max_width as f64).round() as usize;
2406            let bar = "█".repeat(bar_len);
2407
2408            self.interaction_count += 1;
2409            self.commands.push(Command::BeginContainer {
2410                direction: Direction::Row,
2411                gap: 1,
2412                align: Align::Start,
2413                justify: Justify::Start,
2414                border: None,
2415                border_sides: BorderSides::all(),
2416                border_style: Style::new().fg(self.theme.border),
2417                bg_color: None,
2418                padding: Padding::default(),
2419                margin: Margin::default(),
2420                constraints: Constraints::default(),
2421                title: None,
2422                grow: 0,
2423            });
2424            self.styled(
2425                format!("{label}{label_padding}"),
2426                Style::new().fg(self.theme.text),
2427            );
2428            self.styled(bar, Style::new().fg(self.theme.primary));
2429            self.styled(
2430                format_compact_number(*value),
2431                Style::new().fg(self.theme.text_dim),
2432            );
2433            self.commands.push(Command::EndContainer);
2434            self.last_text_idx = None;
2435        }
2436
2437        self.commands.push(Command::EndContainer);
2438        self.last_text_idx = None;
2439
2440        self
2441    }
2442
2443    /// Render a styled bar chart with per-bar colors, grouping, and direction control.
2444    ///
2445    /// # Example
2446    /// ```ignore
2447    /// # slt::run(|ui: &mut slt::Context| {
2448    /// use slt::{Bar, Color};
2449    /// let bars = vec![
2450    ///     Bar::new("Q1", 32.0).color(Color::Cyan),
2451    ///     Bar::new("Q2", 46.0).color(Color::Green),
2452    ///     Bar::new("Q3", 28.0).color(Color::Yellow),
2453    ///     Bar::new("Q4", 54.0).color(Color::Red),
2454    /// ];
2455    /// ui.bar_chart_styled(&bars, 30, slt::BarDirection::Horizontal);
2456    /// # });
2457    /// ```
2458    pub fn bar_chart_styled(
2459        &mut self,
2460        bars: &[Bar],
2461        max_width: u32,
2462        direction: BarDirection,
2463    ) -> &mut Self {
2464        if bars.is_empty() {
2465            return self;
2466        }
2467
2468        let max_value = bars
2469            .iter()
2470            .map(|bar| bar.value)
2471            .fold(f64::NEG_INFINITY, f64::max);
2472        let denom = if max_value > 0.0 { max_value } else { 1.0 };
2473
2474        match direction {
2475            BarDirection::Horizontal => {
2476                let max_label_width = bars
2477                    .iter()
2478                    .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2479                    .max()
2480                    .unwrap_or(0);
2481
2482                self.interaction_count += 1;
2483                self.commands.push(Command::BeginContainer {
2484                    direction: Direction::Column,
2485                    gap: 0,
2486                    align: Align::Start,
2487                    justify: Justify::Start,
2488                    border: None,
2489                    border_sides: BorderSides::all(),
2490                    border_style: Style::new().fg(self.theme.border),
2491                    bg_color: None,
2492                    padding: Padding::default(),
2493                    margin: Margin::default(),
2494                    constraints: Constraints::default(),
2495                    title: None,
2496                    grow: 0,
2497                });
2498
2499                for bar in bars {
2500                    let label_width = UnicodeWidthStr::width(bar.label.as_str());
2501                    let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2502                    let normalized = (bar.value / denom).clamp(0.0, 1.0);
2503                    let bar_len = (normalized * max_width as f64).round() as usize;
2504                    let bar_text = "█".repeat(bar_len);
2505                    let color = bar.color.unwrap_or(self.theme.primary);
2506
2507                    self.interaction_count += 1;
2508                    self.commands.push(Command::BeginContainer {
2509                        direction: Direction::Row,
2510                        gap: 1,
2511                        align: Align::Start,
2512                        justify: Justify::Start,
2513                        border: None,
2514                        border_sides: BorderSides::all(),
2515                        border_style: Style::new().fg(self.theme.border),
2516                        bg_color: None,
2517                        padding: Padding::default(),
2518                        margin: Margin::default(),
2519                        constraints: Constraints::default(),
2520                        title: None,
2521                        grow: 0,
2522                    });
2523                    self.styled(
2524                        format!("{}{label_padding}", bar.label),
2525                        Style::new().fg(self.theme.text),
2526                    );
2527                    self.styled(bar_text, Style::new().fg(color));
2528                    self.styled(
2529                        format_compact_number(bar.value),
2530                        Style::new().fg(self.theme.text_dim),
2531                    );
2532                    self.commands.push(Command::EndContainer);
2533                    self.last_text_idx = None;
2534                }
2535
2536                self.commands.push(Command::EndContainer);
2537                self.last_text_idx = None;
2538            }
2539            BarDirection::Vertical => {
2540                const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
2541
2542                let chart_height = max_width.max(1) as usize;
2543                let value_labels: Vec<String> = bars
2544                    .iter()
2545                    .map(|bar| format_compact_number(bar.value))
2546                    .collect();
2547                let col_width = bars
2548                    .iter()
2549                    .zip(value_labels.iter())
2550                    .map(|(bar, value)| {
2551                        UnicodeWidthStr::width(bar.label.as_str())
2552                            .max(UnicodeWidthStr::width(value.as_str()))
2553                            .max(1)
2554                    })
2555                    .max()
2556                    .unwrap_or(1);
2557
2558                let bar_units: Vec<usize> = bars
2559                    .iter()
2560                    .map(|bar| {
2561                        let normalized = (bar.value / denom).clamp(0.0, 1.0);
2562                        (normalized * chart_height as f64 * 8.0).round() as usize
2563                    })
2564                    .collect();
2565
2566                self.interaction_count += 1;
2567                self.commands.push(Command::BeginContainer {
2568                    direction: Direction::Column,
2569                    gap: 0,
2570                    align: Align::Start,
2571                    justify: Justify::Start,
2572                    border: None,
2573                    border_sides: BorderSides::all(),
2574                    border_style: Style::new().fg(self.theme.border),
2575                    bg_color: None,
2576                    padding: Padding::default(),
2577                    margin: Margin::default(),
2578                    constraints: Constraints::default(),
2579                    title: None,
2580                    grow: 0,
2581                });
2582
2583                self.interaction_count += 1;
2584                self.commands.push(Command::BeginContainer {
2585                    direction: Direction::Row,
2586                    gap: 1,
2587                    align: Align::Start,
2588                    justify: Justify::Start,
2589                    border: None,
2590                    border_sides: BorderSides::all(),
2591                    border_style: Style::new().fg(self.theme.border),
2592                    bg_color: None,
2593                    padding: Padding::default(),
2594                    margin: Margin::default(),
2595                    constraints: Constraints::default(),
2596                    title: None,
2597                    grow: 0,
2598                });
2599                for value in &value_labels {
2600                    self.styled(
2601                        center_text(value, col_width),
2602                        Style::new().fg(self.theme.text_dim),
2603                    );
2604                }
2605                self.commands.push(Command::EndContainer);
2606                self.last_text_idx = None;
2607
2608                for row in (0..chart_height).rev() {
2609                    self.interaction_count += 1;
2610                    self.commands.push(Command::BeginContainer {
2611                        direction: Direction::Row,
2612                        gap: 1,
2613                        align: Align::Start,
2614                        justify: Justify::Start,
2615                        border: None,
2616                        border_sides: BorderSides::all(),
2617                        border_style: Style::new().fg(self.theme.border),
2618                        bg_color: None,
2619                        padding: Padding::default(),
2620                        margin: Margin::default(),
2621                        constraints: Constraints::default(),
2622                        title: None,
2623                        grow: 0,
2624                    });
2625
2626                    let row_base = row * 8;
2627                    for (bar, units) in bars.iter().zip(bar_units.iter()) {
2628                        let fill = if *units <= row_base {
2629                            ' '
2630                        } else {
2631                            let delta = *units - row_base;
2632                            if delta >= 8 {
2633                                '█'
2634                            } else {
2635                                FRACTION_BLOCKS[delta]
2636                            }
2637                        };
2638
2639                        self.styled(
2640                            center_text(&fill.to_string(), col_width),
2641                            Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
2642                        );
2643                    }
2644
2645                    self.commands.push(Command::EndContainer);
2646                    self.last_text_idx = None;
2647                }
2648
2649                self.interaction_count += 1;
2650                self.commands.push(Command::BeginContainer {
2651                    direction: Direction::Row,
2652                    gap: 1,
2653                    align: Align::Start,
2654                    justify: Justify::Start,
2655                    border: None,
2656                    border_sides: BorderSides::all(),
2657                    border_style: Style::new().fg(self.theme.border),
2658                    bg_color: None,
2659                    padding: Padding::default(),
2660                    margin: Margin::default(),
2661                    constraints: Constraints::default(),
2662                    title: None,
2663                    grow: 0,
2664                });
2665                for bar in bars {
2666                    self.styled(
2667                        center_text(&bar.label, col_width),
2668                        Style::new().fg(self.theme.text),
2669                    );
2670                }
2671                self.commands.push(Command::EndContainer);
2672                self.last_text_idx = None;
2673
2674                self.commands.push(Command::EndContainer);
2675                self.last_text_idx = None;
2676            }
2677        }
2678
2679        self
2680    }
2681
2682    /// Render a grouped bar chart.
2683    ///
2684    /// Each group contains multiple bars rendered side by side. Useful for
2685    /// comparing categories across groups (e.g., quarterly revenue by product).
2686    ///
2687    /// # Example
2688    /// ```ignore
2689    /// # slt::run(|ui: &mut slt::Context| {
2690    /// use slt::{Bar, BarGroup, Color};
2691    /// let groups = vec![
2692    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
2693    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
2694    /// ];
2695    /// ui.bar_chart_grouped(&groups, 40);
2696    /// # });
2697    /// ```
2698    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
2699        if groups.is_empty() {
2700            return self;
2701        }
2702
2703        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
2704        if all_bars.is_empty() {
2705            return self;
2706        }
2707
2708        let max_label_width = all_bars
2709            .iter()
2710            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
2711            .max()
2712            .unwrap_or(0);
2713        let max_value = all_bars
2714            .iter()
2715            .map(|bar| bar.value)
2716            .fold(f64::NEG_INFINITY, f64::max);
2717        let denom = if max_value > 0.0 { max_value } else { 1.0 };
2718
2719        self.interaction_count += 1;
2720        self.commands.push(Command::BeginContainer {
2721            direction: Direction::Column,
2722            gap: 1,
2723            align: Align::Start,
2724            justify: Justify::Start,
2725            border: None,
2726            border_sides: BorderSides::all(),
2727            border_style: Style::new().fg(self.theme.border),
2728            bg_color: None,
2729            padding: Padding::default(),
2730            margin: Margin::default(),
2731            constraints: Constraints::default(),
2732            title: None,
2733            grow: 0,
2734        });
2735
2736        for group in groups {
2737            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
2738
2739            for bar in &group.bars {
2740                let label_width = UnicodeWidthStr::width(bar.label.as_str());
2741                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
2742                let normalized = (bar.value / denom).clamp(0.0, 1.0);
2743                let bar_len = (normalized * max_width as f64).round() as usize;
2744                let bar_text = "█".repeat(bar_len);
2745
2746                self.interaction_count += 1;
2747                self.commands.push(Command::BeginContainer {
2748                    direction: Direction::Row,
2749                    gap: 1,
2750                    align: Align::Start,
2751                    justify: Justify::Start,
2752                    border: None,
2753                    border_sides: BorderSides::all(),
2754                    border_style: Style::new().fg(self.theme.border),
2755                    bg_color: None,
2756                    padding: Padding::default(),
2757                    margin: Margin::default(),
2758                    constraints: Constraints::default(),
2759                    title: None,
2760                    grow: 0,
2761                });
2762                self.styled(
2763                    format!("  {}{label_padding}", bar.label),
2764                    Style::new().fg(self.theme.text),
2765                );
2766                self.styled(
2767                    bar_text,
2768                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
2769                );
2770                self.styled(
2771                    format_compact_number(bar.value),
2772                    Style::new().fg(self.theme.text_dim),
2773                );
2774                self.commands.push(Command::EndContainer);
2775                self.last_text_idx = None;
2776            }
2777        }
2778
2779        self.commands.push(Command::EndContainer);
2780        self.last_text_idx = None;
2781
2782        self
2783    }
2784
2785    /// Render a single-line sparkline from numeric data.
2786    ///
2787    /// Uses the last `width` points (or fewer if the data is shorter) and maps
2788    /// each point to one of `▁▂▃▄▅▆▇█`.
2789    ///
2790    /// # Example
2791    ///
2792    /// ```ignore
2793    /// # slt::run(|ui: &mut slt::Context| {
2794    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
2795    /// ui.sparkline(&samples, 16);
2796    ///
2797    /// For per-point colors and missing values, see [`sparkline_styled`].
2798    /// # });
2799    /// ```
2800    pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
2801        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2802
2803        let w = width as usize;
2804        let window = if data.len() > w {
2805            &data[data.len() - w..]
2806        } else {
2807            data
2808        };
2809
2810        if window.is_empty() {
2811            return self;
2812        }
2813
2814        let min = window.iter().copied().fold(f64::INFINITY, f64::min);
2815        let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2816        let range = max - min;
2817
2818        let line: String = window
2819            .iter()
2820            .map(|&value| {
2821                let normalized = if range == 0.0 {
2822                    0.5
2823                } else {
2824                    (value - min) / range
2825                };
2826                let idx = (normalized * 7.0).round() as usize;
2827                BLOCKS[idx.min(7)]
2828            })
2829            .collect();
2830
2831        self.styled(line, Style::new().fg(self.theme.primary))
2832    }
2833
2834    /// Render a sparkline with per-point colors.
2835    ///
2836    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
2837    /// Use `f64::NAN` for absent values (rendered as spaces).
2838    ///
2839    /// # Example
2840    /// ```ignore
2841    /// # slt::run(|ui: &mut slt::Context| {
2842    /// use slt::Color;
2843    /// let data: Vec<(f64, Option<Color>)> = vec![
2844    ///     (12.0, Some(Color::Green)),
2845    ///     (9.0, Some(Color::Red)),
2846    ///     (14.0, Some(Color::Green)),
2847    ///     (f64::NAN, None),
2848    ///     (18.0, Some(Color::Cyan)),
2849    /// ];
2850    /// ui.sparkline_styled(&data, 16);
2851    /// # });
2852    /// ```
2853    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
2854        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
2855
2856        let w = width as usize;
2857        let window = if data.len() > w {
2858            &data[data.len() - w..]
2859        } else {
2860            data
2861        };
2862
2863        if window.is_empty() {
2864            return self;
2865        }
2866
2867        let mut finite_values = window
2868            .iter()
2869            .map(|(value, _)| *value)
2870            .filter(|value| !value.is_nan());
2871        let Some(first) = finite_values.next() else {
2872            return self.styled(
2873                " ".repeat(window.len()),
2874                Style::new().fg(self.theme.text_dim),
2875            );
2876        };
2877
2878        let mut min = first;
2879        let mut max = first;
2880        for value in finite_values {
2881            min = f64::min(min, value);
2882            max = f64::max(max, value);
2883        }
2884        let range = max - min;
2885
2886        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
2887        for (value, color) in window {
2888            if value.is_nan() {
2889                cells.push((' ', self.theme.text_dim));
2890                continue;
2891            }
2892
2893            let normalized = if range == 0.0 {
2894                0.5
2895            } else {
2896                ((*value - min) / range).clamp(0.0, 1.0)
2897            };
2898            let idx = (normalized * 7.0).round() as usize;
2899            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
2900        }
2901
2902        self.interaction_count += 1;
2903        self.commands.push(Command::BeginContainer {
2904            direction: Direction::Row,
2905            gap: 0,
2906            align: Align::Start,
2907            justify: Justify::Start,
2908            border: None,
2909            border_sides: BorderSides::all(),
2910            border_style: Style::new().fg(self.theme.border),
2911            bg_color: None,
2912            padding: Padding::default(),
2913            margin: Margin::default(),
2914            constraints: Constraints::default(),
2915            title: None,
2916            grow: 0,
2917        });
2918
2919        let mut seg = String::new();
2920        let mut seg_color = cells[0].1;
2921        for (ch, color) in cells {
2922            if color != seg_color {
2923                self.styled(seg, Style::new().fg(seg_color));
2924                seg = String::new();
2925                seg_color = color;
2926            }
2927            seg.push(ch);
2928        }
2929        if !seg.is_empty() {
2930            self.styled(seg, Style::new().fg(seg_color));
2931        }
2932
2933        self.commands.push(Command::EndContainer);
2934        self.last_text_idx = None;
2935
2936        self
2937    }
2938
2939    /// Render a multi-row line chart using braille characters.
2940    ///
2941    /// `width` and `height` are terminal cell dimensions. Internally this uses
2942    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
2943    ///
2944    /// # Example
2945    ///
2946    /// ```ignore
2947    /// # slt::run(|ui: &mut slt::Context| {
2948    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
2949    /// ui.line_chart(&data, 40, 8);
2950    /// # });
2951    /// ```
2952    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
2953        if data.is_empty() || width == 0 || height == 0 {
2954            return self;
2955        }
2956
2957        let cols = width as usize;
2958        let rows = height as usize;
2959        let px_w = cols * 2;
2960        let px_h = rows * 4;
2961
2962        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
2963        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
2964        let range = if (max - min).abs() < f64::EPSILON {
2965            1.0
2966        } else {
2967            max - min
2968        };
2969
2970        let points: Vec<usize> = (0..px_w)
2971            .map(|px| {
2972                let data_idx = if px_w <= 1 {
2973                    0.0
2974                } else {
2975                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
2976                };
2977                let idx = data_idx.floor() as usize;
2978                let frac = data_idx - idx as f64;
2979                let value = if idx + 1 < data.len() {
2980                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
2981                } else {
2982                    data[idx.min(data.len() - 1)]
2983                };
2984
2985                let normalized = (value - min) / range;
2986                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
2987                py.min(px_h - 1)
2988            })
2989            .collect();
2990
2991        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
2992        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
2993
2994        let mut grid = vec![vec![0u32; cols]; rows];
2995
2996        for i in 0..points.len() {
2997            let px = i;
2998            let py = points[i];
2999            let char_col = px / 2;
3000            let char_row = py / 4;
3001            let sub_col = px % 2;
3002            let sub_row = py % 4;
3003
3004            if char_col < cols && char_row < rows {
3005                grid[char_row][char_col] |= if sub_col == 0 {
3006                    LEFT_BITS[sub_row]
3007                } else {
3008                    RIGHT_BITS[sub_row]
3009                };
3010            }
3011
3012            if i + 1 < points.len() {
3013                let py_next = points[i + 1];
3014                let (y_start, y_end) = if py <= py_next {
3015                    (py, py_next)
3016                } else {
3017                    (py_next, py)
3018                };
3019                for y in y_start..=y_end {
3020                    let cell_row = y / 4;
3021                    let sub_y = y % 4;
3022                    if char_col < cols && cell_row < rows {
3023                        grid[cell_row][char_col] |= if sub_col == 0 {
3024                            LEFT_BITS[sub_y]
3025                        } else {
3026                            RIGHT_BITS[sub_y]
3027                        };
3028                    }
3029                }
3030            }
3031        }
3032
3033        let style = Style::new().fg(self.theme.primary);
3034        for row in grid {
3035            let line: String = row
3036                .iter()
3037                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
3038                .collect();
3039            self.styled(line, style);
3040        }
3041
3042        self
3043    }
3044
3045    /// Render a braille drawing canvas.
3046    ///
3047    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
3048    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
3049    /// `height*4` pixel resolution.
3050    ///
3051    /// # Example
3052    ///
3053    /// ```ignore
3054    /// # slt::run(|ui: &mut slt::Context| {
3055    /// ui.canvas(40, 10, |cv| {
3056    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
3057    ///     cv.circle(40, 20, 15);
3058    /// });
3059    /// # });
3060    /// ```
3061    pub fn canvas(
3062        &mut self,
3063        width: u32,
3064        height: u32,
3065        draw: impl FnOnce(&mut CanvasContext),
3066    ) -> &mut Self {
3067        if width == 0 || height == 0 {
3068            return self;
3069        }
3070
3071        let mut canvas = CanvasContext::new(width as usize, height as usize);
3072        draw(&mut canvas);
3073
3074        for segments in canvas.render() {
3075            self.interaction_count += 1;
3076            self.commands.push(Command::BeginContainer {
3077                direction: Direction::Row,
3078                gap: 0,
3079                align: Align::Start,
3080                justify: Justify::Start,
3081                border: None,
3082                border_sides: BorderSides::all(),
3083                border_style: Style::new(),
3084                bg_color: None,
3085                padding: Padding::default(),
3086                margin: Margin::default(),
3087                constraints: Constraints::default(),
3088                title: None,
3089                grow: 0,
3090            });
3091            for (text, color) in segments {
3092                let c = if color == Color::Reset {
3093                    self.theme.primary
3094                } else {
3095                    color
3096                };
3097                self.styled(text, Style::new().fg(c));
3098            }
3099            self.commands.push(Command::EndContainer);
3100            self.last_text_idx = None;
3101        }
3102
3103        self
3104    }
3105
3106    /// Render a multi-series chart with axes, legend, and auto-scaling.
3107    pub fn chart(
3108        &mut self,
3109        configure: impl FnOnce(&mut ChartBuilder),
3110        width: u32,
3111        height: u32,
3112    ) -> &mut Self {
3113        if width == 0 || height == 0 {
3114            return self;
3115        }
3116
3117        let axis_style = Style::new().fg(self.theme.text_dim);
3118        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
3119        configure(&mut builder);
3120
3121        let config = builder.build();
3122        let rows = render_chart(&config);
3123
3124        for row in rows {
3125            self.interaction_count += 1;
3126            self.commands.push(Command::BeginContainer {
3127                direction: Direction::Row,
3128                gap: 0,
3129                align: Align::Start,
3130                justify: Justify::Start,
3131                border: None,
3132                border_sides: BorderSides::all(),
3133                border_style: Style::new().fg(self.theme.border),
3134                bg_color: None,
3135                padding: Padding::default(),
3136                margin: Margin::default(),
3137                constraints: Constraints::default(),
3138                title: None,
3139                grow: 0,
3140            });
3141            for (text, style) in row.segments {
3142                self.styled(text, style);
3143            }
3144            self.commands.push(Command::EndContainer);
3145            self.last_text_idx = None;
3146        }
3147
3148        self
3149    }
3150
3151    /// Render a histogram from raw data with auto-binning.
3152    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
3153        self.histogram_with(data, |_| {}, width, height)
3154    }
3155
3156    /// Render a histogram with configuration options.
3157    pub fn histogram_with(
3158        &mut self,
3159        data: &[f64],
3160        configure: impl FnOnce(&mut HistogramBuilder),
3161        width: u32,
3162        height: u32,
3163    ) -> &mut Self {
3164        if width == 0 || height == 0 {
3165            return self;
3166        }
3167
3168        let mut options = HistogramBuilder::default();
3169        configure(&mut options);
3170        let axis_style = Style::new().fg(self.theme.text_dim);
3171        let config = build_histogram_config(data, &options, width, height, axis_style);
3172        let rows = render_chart(&config);
3173
3174        for row in rows {
3175            self.interaction_count += 1;
3176            self.commands.push(Command::BeginContainer {
3177                direction: Direction::Row,
3178                gap: 0,
3179                align: Align::Start,
3180                justify: Justify::Start,
3181                border: None,
3182                border_sides: BorderSides::all(),
3183                border_style: Style::new().fg(self.theme.border),
3184                bg_color: None,
3185                padding: Padding::default(),
3186                margin: Margin::default(),
3187                constraints: Constraints::default(),
3188                title: None,
3189                grow: 0,
3190            });
3191            for (text, style) in row.segments {
3192                self.styled(text, style);
3193            }
3194            self.commands.push(Command::EndContainer);
3195            self.last_text_idx = None;
3196        }
3197
3198        self
3199    }
3200
3201    /// Render children in a fixed grid with the given number of columns.
3202    ///
3203    /// Children are placed left-to-right, top-to-bottom. Each cell has equal
3204    /// width (`area_width / cols`). Rows wrap automatically.
3205    ///
3206    /// # Example
3207    ///
3208    /// ```no_run
3209    /// # slt::run(|ui: &mut slt::Context| {
3210    /// ui.grid(3, |ui| {
3211    ///     for i in 0..9 {
3212    ///         ui.text(format!("Cell {i}"));
3213    ///     }
3214    /// });
3215    /// # });
3216    /// ```
3217    pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
3218        slt_assert(cols > 0, "grid() requires at least 1 column");
3219        let interaction_id = self.interaction_count;
3220        self.interaction_count += 1;
3221        let border = self.theme.border;
3222
3223        self.commands.push(Command::BeginContainer {
3224            direction: Direction::Column,
3225            gap: 0,
3226            align: Align::Start,
3227            justify: Justify::Start,
3228            border: None,
3229            border_sides: BorderSides::all(),
3230            border_style: Style::new().fg(border),
3231            bg_color: None,
3232            padding: Padding::default(),
3233            margin: Margin::default(),
3234            constraints: Constraints::default(),
3235            title: None,
3236            grow: 0,
3237        });
3238
3239        let children_start = self.commands.len();
3240        f(self);
3241        let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
3242
3243        let mut elements: Vec<Vec<Command>> = Vec::new();
3244        let mut iter = child_commands.into_iter().peekable();
3245        while let Some(cmd) = iter.next() {
3246            match cmd {
3247                Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3248                    let mut depth = 1_u32;
3249                    let mut element = vec![cmd];
3250                    for next in iter.by_ref() {
3251                        match next {
3252                            Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
3253                                depth += 1;
3254                            }
3255                            Command::EndContainer => {
3256                                depth = depth.saturating_sub(1);
3257                            }
3258                            _ => {}
3259                        }
3260                        let at_end = matches!(next, Command::EndContainer) && depth == 0;
3261                        element.push(next);
3262                        if at_end {
3263                            break;
3264                        }
3265                    }
3266                    elements.push(element);
3267                }
3268                Command::EndContainer => {}
3269                _ => elements.push(vec![cmd]),
3270            }
3271        }
3272
3273        let cols = cols.max(1) as usize;
3274        for row in elements.chunks(cols) {
3275            self.interaction_count += 1;
3276            self.commands.push(Command::BeginContainer {
3277                direction: Direction::Row,
3278                gap: 0,
3279                align: Align::Start,
3280                justify: Justify::Start,
3281                border: None,
3282                border_sides: BorderSides::all(),
3283                border_style: Style::new().fg(border),
3284                bg_color: None,
3285                padding: Padding::default(),
3286                margin: Margin::default(),
3287                constraints: Constraints::default(),
3288                title: None,
3289                grow: 0,
3290            });
3291
3292            for element in row {
3293                self.interaction_count += 1;
3294                self.commands.push(Command::BeginContainer {
3295                    direction: Direction::Column,
3296                    gap: 0,
3297                    align: Align::Start,
3298                    justify: Justify::Start,
3299                    border: None,
3300                    border_sides: BorderSides::all(),
3301                    border_style: Style::new().fg(border),
3302                    bg_color: None,
3303                    padding: Padding::default(),
3304                    margin: Margin::default(),
3305                    constraints: Constraints::default(),
3306                    title: None,
3307                    grow: 1,
3308                });
3309                self.commands.extend(element.iter().cloned());
3310                self.commands.push(Command::EndContainer);
3311            }
3312
3313            self.commands.push(Command::EndContainer);
3314        }
3315
3316        self.commands.push(Command::EndContainer);
3317        self.last_text_idx = None;
3318
3319        self.response_for(interaction_id)
3320    }
3321
3322    /// Render a selectable list. Handles Up/Down (and `k`/`j`) navigation when focused.
3323    ///
3324    /// The selected item is highlighted with the theme's primary color. If the
3325    /// list is empty, nothing is rendered.
3326    pub fn list(&mut self, state: &mut ListState) -> &mut Self {
3327        if state.items.is_empty() {
3328            state.selected = 0;
3329            return self;
3330        }
3331
3332        state.selected = state.selected.min(state.items.len().saturating_sub(1));
3333
3334        let focused = self.register_focusable();
3335        let interaction_id = self.interaction_count;
3336        self.interaction_count += 1;
3337
3338        if focused {
3339            let mut consumed_indices = Vec::new();
3340            for (i, event) in self.events.iter().enumerate() {
3341                if let Event::Key(key) = event {
3342                    match key.code {
3343                        KeyCode::Up | KeyCode::Char('k') => {
3344                            state.selected = state.selected.saturating_sub(1);
3345                            consumed_indices.push(i);
3346                        }
3347                        KeyCode::Down | KeyCode::Char('j') => {
3348                            state.selected =
3349                                (state.selected + 1).min(state.items.len().saturating_sub(1));
3350                            consumed_indices.push(i);
3351                        }
3352                        _ => {}
3353                    }
3354                }
3355            }
3356
3357            for index in consumed_indices {
3358                self.consumed[index] = true;
3359            }
3360        }
3361
3362        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3363            for (i, event) in self.events.iter().enumerate() {
3364                if self.consumed[i] {
3365                    continue;
3366                }
3367                if let Event::Mouse(mouse) = event {
3368                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3369                        continue;
3370                    }
3371                    let in_bounds = mouse.x >= rect.x
3372                        && mouse.x < rect.right()
3373                        && mouse.y >= rect.y
3374                        && mouse.y < rect.bottom();
3375                    if !in_bounds {
3376                        continue;
3377                    }
3378                    let clicked_idx = (mouse.y - rect.y) as usize;
3379                    if clicked_idx < state.items.len() {
3380                        state.selected = clicked_idx;
3381                        self.consumed[i] = true;
3382                    }
3383                }
3384            }
3385        }
3386
3387        self.commands.push(Command::BeginContainer {
3388            direction: Direction::Column,
3389            gap: 0,
3390            align: Align::Start,
3391            justify: Justify::Start,
3392            border: None,
3393            border_sides: BorderSides::all(),
3394            border_style: Style::new().fg(self.theme.border),
3395            bg_color: None,
3396            padding: Padding::default(),
3397            margin: Margin::default(),
3398            constraints: Constraints::default(),
3399            title: None,
3400            grow: 0,
3401        });
3402
3403        for (idx, item) in state.items.iter().enumerate() {
3404            if idx == state.selected {
3405                if focused {
3406                    self.styled(
3407                        format!("▸ {item}"),
3408                        Style::new().bold().fg(self.theme.primary),
3409                    );
3410                } else {
3411                    self.styled(format!("▸ {item}"), Style::new().fg(self.theme.primary));
3412                }
3413            } else {
3414                self.styled(format!("  {item}"), Style::new().fg(self.theme.text));
3415            }
3416        }
3417
3418        self.commands.push(Command::EndContainer);
3419        self.last_text_idx = None;
3420
3421        self
3422    }
3423
3424    /// Render a data table with column headers. Handles Up/Down selection when focused.
3425    ///
3426    /// Column widths are computed automatically from header and cell content.
3427    /// The selected row is highlighted with the theme's selection colors.
3428    pub fn table(&mut self, state: &mut TableState) -> &mut Self {
3429        if state.is_dirty() {
3430            state.recompute_widths();
3431        }
3432
3433        let focused = self.register_focusable();
3434        let interaction_id = self.interaction_count;
3435        self.interaction_count += 1;
3436
3437        if focused && !state.visible_indices().is_empty() {
3438            let mut consumed_indices = Vec::new();
3439            for (i, event) in self.events.iter().enumerate() {
3440                if let Event::Key(key) = event {
3441                    match key.code {
3442                        KeyCode::Up | KeyCode::Char('k') => {
3443                            let visible_len = if state.page_size > 0 {
3444                                let start = state
3445                                    .page
3446                                    .saturating_mul(state.page_size)
3447                                    .min(state.visible_indices().len());
3448                                let end =
3449                                    (start + state.page_size).min(state.visible_indices().len());
3450                                end.saturating_sub(start)
3451                            } else {
3452                                state.visible_indices().len()
3453                            };
3454                            state.selected = state.selected.min(visible_len.saturating_sub(1));
3455                            state.selected = state.selected.saturating_sub(1);
3456                            consumed_indices.push(i);
3457                        }
3458                        KeyCode::Down | KeyCode::Char('j') => {
3459                            let visible_len = if state.page_size > 0 {
3460                                let start = state
3461                                    .page
3462                                    .saturating_mul(state.page_size)
3463                                    .min(state.visible_indices().len());
3464                                let end =
3465                                    (start + state.page_size).min(state.visible_indices().len());
3466                                end.saturating_sub(start)
3467                            } else {
3468                                state.visible_indices().len()
3469                            };
3470                            state.selected =
3471                                (state.selected + 1).min(visible_len.saturating_sub(1));
3472                            consumed_indices.push(i);
3473                        }
3474                        KeyCode::PageUp => {
3475                            let old_page = state.page;
3476                            state.prev_page();
3477                            if state.page != old_page {
3478                                state.selected = 0;
3479                            }
3480                            consumed_indices.push(i);
3481                        }
3482                        KeyCode::PageDown => {
3483                            let old_page = state.page;
3484                            state.next_page();
3485                            if state.page != old_page {
3486                                state.selected = 0;
3487                            }
3488                            consumed_indices.push(i);
3489                        }
3490                        _ => {}
3491                    }
3492                }
3493            }
3494            for index in consumed_indices {
3495                self.consumed[index] = true;
3496            }
3497        }
3498
3499        if !state.visible_indices().is_empty() || !state.headers.is_empty() {
3500            if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3501                for (i, event) in self.events.iter().enumerate() {
3502                    if self.consumed[i] {
3503                        continue;
3504                    }
3505                    if let Event::Mouse(mouse) = event {
3506                        if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3507                            continue;
3508                        }
3509                        let in_bounds = mouse.x >= rect.x
3510                            && mouse.x < rect.right()
3511                            && mouse.y >= rect.y
3512                            && mouse.y < rect.bottom();
3513                        if !in_bounds {
3514                            continue;
3515                        }
3516
3517                        if mouse.y == rect.y {
3518                            let rel_x = mouse.x.saturating_sub(rect.x);
3519                            let mut x_offset = 0u32;
3520                            for (col_idx, width) in state.column_widths().iter().enumerate() {
3521                                if rel_x >= x_offset && rel_x < x_offset + *width {
3522                                    state.toggle_sort(col_idx);
3523                                    state.selected = 0;
3524                                    self.consumed[i] = true;
3525                                    break;
3526                                }
3527                                x_offset += *width;
3528                                if col_idx + 1 < state.column_widths().len() {
3529                                    x_offset += 3;
3530                                }
3531                            }
3532                            continue;
3533                        }
3534
3535                        if mouse.y < rect.y + 2 {
3536                            continue;
3537                        }
3538
3539                        let visible_len = if state.page_size > 0 {
3540                            let start = state
3541                                .page
3542                                .saturating_mul(state.page_size)
3543                                .min(state.visible_indices().len());
3544                            let end = (start + state.page_size).min(state.visible_indices().len());
3545                            end.saturating_sub(start)
3546                        } else {
3547                            state.visible_indices().len()
3548                        };
3549                        let clicked_idx = (mouse.y - rect.y - 2) as usize;
3550                        if clicked_idx < visible_len {
3551                            state.selected = clicked_idx;
3552                            self.consumed[i] = true;
3553                        }
3554                    }
3555                }
3556            }
3557        }
3558
3559        if state.is_dirty() {
3560            state.recompute_widths();
3561        }
3562
3563        let total_visible = state.visible_indices().len();
3564        let page_start = if state.page_size > 0 {
3565            state
3566                .page
3567                .saturating_mul(state.page_size)
3568                .min(total_visible)
3569        } else {
3570            0
3571        };
3572        let page_end = if state.page_size > 0 {
3573            (page_start + state.page_size).min(total_visible)
3574        } else {
3575            total_visible
3576        };
3577        let visible_len = page_end.saturating_sub(page_start);
3578        state.selected = state.selected.min(visible_len.saturating_sub(1));
3579
3580        self.commands.push(Command::BeginContainer {
3581            direction: Direction::Column,
3582            gap: 0,
3583            align: Align::Start,
3584            justify: Justify::Start,
3585            border: None,
3586            border_sides: BorderSides::all(),
3587            border_style: Style::new().fg(self.theme.border),
3588            bg_color: None,
3589            padding: Padding::default(),
3590            margin: Margin::default(),
3591            constraints: Constraints::default(),
3592            title: None,
3593            grow: 0,
3594        });
3595
3596        let header_cells = state
3597            .headers
3598            .iter()
3599            .enumerate()
3600            .map(|(i, header)| {
3601                if state.sort_column == Some(i) {
3602                    if state.sort_ascending {
3603                        format!("{header} ▲")
3604                    } else {
3605                        format!("{header} ▼")
3606                    }
3607                } else {
3608                    header.clone()
3609                }
3610            })
3611            .collect::<Vec<_>>();
3612        let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
3613        self.styled(header_line, Style::new().bold().fg(self.theme.text));
3614
3615        let separator = state
3616            .column_widths()
3617            .iter()
3618            .map(|w| "─".repeat(*w as usize))
3619            .collect::<Vec<_>>()
3620            .join("─┼─");
3621        self.text(separator);
3622
3623        for idx in 0..visible_len {
3624            let data_idx = state.visible_indices()[page_start + idx];
3625            let Some(row) = state.rows.get(data_idx) else {
3626                continue;
3627            };
3628            let line = format_table_row(row, state.column_widths(), " │ ");
3629            if idx == state.selected {
3630                let mut style = Style::new()
3631                    .bg(self.theme.selected_bg)
3632                    .fg(self.theme.selected_fg);
3633                if focused {
3634                    style = style.bold();
3635                }
3636                self.styled(line, style);
3637            } else {
3638                self.styled(line, Style::new().fg(self.theme.text));
3639            }
3640        }
3641
3642        if state.page_size > 0 && state.total_pages() > 1 {
3643            self.styled(
3644                format!("Page {}/{}", state.page + 1, state.total_pages()),
3645                Style::new().dim().fg(self.theme.text_dim),
3646            );
3647        }
3648
3649        self.commands.push(Command::EndContainer);
3650        self.last_text_idx = None;
3651
3652        self
3653    }
3654
3655    /// Render a tab bar. Handles Left/Right navigation when focused.
3656    ///
3657    /// The active tab is rendered in the theme's primary color. If the labels
3658    /// list is empty, nothing is rendered.
3659    pub fn tabs(&mut self, state: &mut TabsState) -> &mut Self {
3660        if state.labels.is_empty() {
3661            state.selected = 0;
3662            return self;
3663        }
3664
3665        state.selected = state.selected.min(state.labels.len().saturating_sub(1));
3666        let focused = self.register_focusable();
3667        let interaction_id = self.interaction_count;
3668
3669        if focused {
3670            let mut consumed_indices = Vec::new();
3671            for (i, event) in self.events.iter().enumerate() {
3672                if let Event::Key(key) = event {
3673                    match key.code {
3674                        KeyCode::Left => {
3675                            state.selected = if state.selected == 0 {
3676                                state.labels.len().saturating_sub(1)
3677                            } else {
3678                                state.selected - 1
3679                            };
3680                            consumed_indices.push(i);
3681                        }
3682                        KeyCode::Right => {
3683                            state.selected = (state.selected + 1) % state.labels.len();
3684                            consumed_indices.push(i);
3685                        }
3686                        _ => {}
3687                    }
3688                }
3689            }
3690
3691            for index in consumed_indices {
3692                self.consumed[index] = true;
3693            }
3694        }
3695
3696        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
3697            for (i, event) in self.events.iter().enumerate() {
3698                if self.consumed[i] {
3699                    continue;
3700                }
3701                if let Event::Mouse(mouse) = event {
3702                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3703                        continue;
3704                    }
3705                    let in_bounds = mouse.x >= rect.x
3706                        && mouse.x < rect.right()
3707                        && mouse.y >= rect.y
3708                        && mouse.y < rect.bottom();
3709                    if !in_bounds {
3710                        continue;
3711                    }
3712
3713                    let mut x_offset = 0u32;
3714                    let rel_x = mouse.x - rect.x;
3715                    for (idx, label) in state.labels.iter().enumerate() {
3716                        let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
3717                        if rel_x >= x_offset && rel_x < x_offset + tab_width {
3718                            state.selected = idx;
3719                            self.consumed[i] = true;
3720                            break;
3721                        }
3722                        x_offset += tab_width + 1;
3723                    }
3724                }
3725            }
3726        }
3727
3728        self.interaction_count += 1;
3729        self.commands.push(Command::BeginContainer {
3730            direction: Direction::Row,
3731            gap: 1,
3732            align: Align::Start,
3733            justify: Justify::Start,
3734            border: None,
3735            border_sides: BorderSides::all(),
3736            border_style: Style::new().fg(self.theme.border),
3737            bg_color: None,
3738            padding: Padding::default(),
3739            margin: Margin::default(),
3740            constraints: Constraints::default(),
3741            title: None,
3742            grow: 0,
3743        });
3744        for (idx, label) in state.labels.iter().enumerate() {
3745            let style = if idx == state.selected {
3746                let s = Style::new().fg(self.theme.primary).bold();
3747                if focused {
3748                    s.underline()
3749                } else {
3750                    s
3751                }
3752            } else {
3753                Style::new().fg(self.theme.text_dim)
3754            };
3755            self.styled(format!("[ {label} ]"), style);
3756        }
3757        self.commands.push(Command::EndContainer);
3758        self.last_text_idx = None;
3759
3760        self
3761    }
3762
3763    /// Render a clickable button. Returns `true` when activated via Enter, Space, or mouse click.
3764    ///
3765    /// The button is styled with the theme's primary color when focused and the
3766    /// accent color when hovered.
3767    pub fn button(&mut self, label: impl Into<String>) -> bool {
3768        let focused = self.register_focusable();
3769        let interaction_id = self.interaction_count;
3770        self.interaction_count += 1;
3771        let response = self.response_for(interaction_id);
3772
3773        let mut activated = response.clicked;
3774        if focused {
3775            let mut consumed_indices = Vec::new();
3776            for (i, event) in self.events.iter().enumerate() {
3777                if let Event::Key(key) = event {
3778                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3779                        activated = true;
3780                        consumed_indices.push(i);
3781                    }
3782                }
3783            }
3784
3785            for index in consumed_indices {
3786                self.consumed[index] = true;
3787            }
3788        }
3789
3790        let hovered = response.hovered;
3791        let style = if focused {
3792            Style::new().fg(self.theme.primary).bold()
3793        } else if hovered {
3794            Style::new().fg(self.theme.accent)
3795        } else {
3796            Style::new().fg(self.theme.text)
3797        };
3798        let hover_bg = if hovered || focused {
3799            Some(self.theme.surface_hover)
3800        } else {
3801            None
3802        };
3803
3804        self.commands.push(Command::BeginContainer {
3805            direction: Direction::Row,
3806            gap: 0,
3807            align: Align::Start,
3808            justify: Justify::Start,
3809            border: None,
3810            border_sides: BorderSides::all(),
3811            border_style: Style::new().fg(self.theme.border),
3812            bg_color: hover_bg,
3813            padding: Padding::default(),
3814            margin: Margin::default(),
3815            constraints: Constraints::default(),
3816            title: None,
3817            grow: 0,
3818        });
3819        self.styled(format!("[ {} ]", label.into()), style);
3820        self.commands.push(Command::EndContainer);
3821        self.last_text_idx = None;
3822
3823        activated
3824    }
3825
3826    /// Render a styled button variant. Returns `true` when activated.
3827    ///
3828    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
3829    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
3830    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> bool {
3831        let focused = self.register_focusable();
3832        let interaction_id = self.interaction_count;
3833        self.interaction_count += 1;
3834        let response = self.response_for(interaction_id);
3835
3836        let mut activated = response.clicked;
3837        if focused {
3838            let mut consumed_indices = Vec::new();
3839            for (i, event) in self.events.iter().enumerate() {
3840                if let Event::Key(key) = event {
3841                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3842                        activated = true;
3843                        consumed_indices.push(i);
3844                    }
3845                }
3846            }
3847            for index in consumed_indices {
3848                self.consumed[index] = true;
3849            }
3850        }
3851
3852        let label = label.into();
3853        let hover_bg = if response.hovered || focused {
3854            Some(self.theme.surface_hover)
3855        } else {
3856            None
3857        };
3858        let (text, style, bg_color, border) = match variant {
3859            ButtonVariant::Default => {
3860                let style = if focused {
3861                    Style::new().fg(self.theme.primary).bold()
3862                } else if response.hovered {
3863                    Style::new().fg(self.theme.accent)
3864                } else {
3865                    Style::new().fg(self.theme.text)
3866                };
3867                (format!("[ {label} ]"), style, hover_bg, None)
3868            }
3869            ButtonVariant::Primary => {
3870                let style = if focused {
3871                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
3872                } else if response.hovered {
3873                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
3874                } else {
3875                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
3876                };
3877                (format!(" {label} "), style, hover_bg, None)
3878            }
3879            ButtonVariant::Danger => {
3880                let style = if focused {
3881                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
3882                } else if response.hovered {
3883                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
3884                } else {
3885                    Style::new().fg(self.theme.bg).bg(self.theme.error)
3886                };
3887                (format!(" {label} "), style, hover_bg, None)
3888            }
3889            ButtonVariant::Outline => {
3890                let border_color = if focused {
3891                    self.theme.primary
3892                } else if response.hovered {
3893                    self.theme.accent
3894                } else {
3895                    self.theme.border
3896                };
3897                let style = if focused {
3898                    Style::new().fg(self.theme.primary).bold()
3899                } else if response.hovered {
3900                    Style::new().fg(self.theme.accent)
3901                } else {
3902                    Style::new().fg(self.theme.text)
3903                };
3904                (
3905                    format!(" {label} "),
3906                    style,
3907                    hover_bg,
3908                    Some((Border::Rounded, Style::new().fg(border_color))),
3909                )
3910            }
3911        };
3912
3913        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
3914        self.commands.push(Command::BeginContainer {
3915            direction: Direction::Row,
3916            gap: 0,
3917            align: Align::Center,
3918            justify: Justify::Center,
3919            border: if border.is_some() {
3920                Some(btn_border)
3921            } else {
3922                None
3923            },
3924            border_sides: BorderSides::all(),
3925            border_style: btn_border_style,
3926            bg_color,
3927            padding: Padding::default(),
3928            margin: Margin::default(),
3929            constraints: Constraints::default(),
3930            title: None,
3931            grow: 0,
3932        });
3933        self.styled(text, style);
3934        self.commands.push(Command::EndContainer);
3935        self.last_text_idx = None;
3936
3937        activated
3938    }
3939
3940    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
3941    ///
3942    /// The checked state is shown with the theme's success color. When focused,
3943    /// a `▸` prefix is added.
3944    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> &mut Self {
3945        let focused = self.register_focusable();
3946        let interaction_id = self.interaction_count;
3947        self.interaction_count += 1;
3948        let response = self.response_for(interaction_id);
3949        let mut should_toggle = response.clicked;
3950
3951        if focused {
3952            let mut consumed_indices = Vec::new();
3953            for (i, event) in self.events.iter().enumerate() {
3954                if let Event::Key(key) = event {
3955                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
3956                        should_toggle = true;
3957                        consumed_indices.push(i);
3958                    }
3959                }
3960            }
3961
3962            for index in consumed_indices {
3963                self.consumed[index] = true;
3964            }
3965        }
3966
3967        if should_toggle {
3968            *checked = !*checked;
3969        }
3970
3971        let hover_bg = if response.hovered || focused {
3972            Some(self.theme.surface_hover)
3973        } else {
3974            None
3975        };
3976        self.commands.push(Command::BeginContainer {
3977            direction: Direction::Row,
3978            gap: 1,
3979            align: Align::Start,
3980            justify: Justify::Start,
3981            border: None,
3982            border_sides: BorderSides::all(),
3983            border_style: Style::new().fg(self.theme.border),
3984            bg_color: hover_bg,
3985            padding: Padding::default(),
3986            margin: Margin::default(),
3987            constraints: Constraints::default(),
3988            title: None,
3989            grow: 0,
3990        });
3991        let marker_style = if *checked {
3992            Style::new().fg(self.theme.success)
3993        } else {
3994            Style::new().fg(self.theme.text_dim)
3995        };
3996        let marker = if *checked { "[x]" } else { "[ ]" };
3997        let label_text = label.into();
3998        if focused {
3999            self.styled(format!("▸ {marker}"), marker_style.bold());
4000            self.styled(label_text, Style::new().fg(self.theme.text).bold());
4001        } else {
4002            self.styled(marker, marker_style);
4003            self.styled(label_text, Style::new().fg(self.theme.text));
4004        }
4005        self.commands.push(Command::EndContainer);
4006        self.last_text_idx = None;
4007
4008        self
4009    }
4010
4011    /// Render an on/off toggle switch.
4012    ///
4013    /// Toggles `on` when activated via Enter, Space, or click. The switch
4014    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
4015    /// dim color respectively.
4016    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> &mut Self {
4017        let focused = self.register_focusable();
4018        let interaction_id = self.interaction_count;
4019        self.interaction_count += 1;
4020        let response = self.response_for(interaction_id);
4021        let mut should_toggle = response.clicked;
4022
4023        if focused {
4024            let mut consumed_indices = Vec::new();
4025            for (i, event) in self.events.iter().enumerate() {
4026                if let Event::Key(key) = event {
4027                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
4028                        should_toggle = true;
4029                        consumed_indices.push(i);
4030                    }
4031                }
4032            }
4033
4034            for index in consumed_indices {
4035                self.consumed[index] = true;
4036            }
4037        }
4038
4039        if should_toggle {
4040            *on = !*on;
4041        }
4042
4043        let hover_bg = if response.hovered || focused {
4044            Some(self.theme.surface_hover)
4045        } else {
4046            None
4047        };
4048        self.commands.push(Command::BeginContainer {
4049            direction: Direction::Row,
4050            gap: 2,
4051            align: Align::Start,
4052            justify: Justify::Start,
4053            border: None,
4054            border_sides: BorderSides::all(),
4055            border_style: Style::new().fg(self.theme.border),
4056            bg_color: hover_bg,
4057            padding: Padding::default(),
4058            margin: Margin::default(),
4059            constraints: Constraints::default(),
4060            title: None,
4061            grow: 0,
4062        });
4063        let label_text = label.into();
4064        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
4065        let switch_style = if *on {
4066            Style::new().fg(self.theme.success)
4067        } else {
4068            Style::new().fg(self.theme.text_dim)
4069        };
4070        if focused {
4071            self.styled(
4072                format!("▸ {label_text}"),
4073                Style::new().fg(self.theme.text).bold(),
4074            );
4075            self.styled(switch, switch_style.bold());
4076        } else {
4077            self.styled(label_text, Style::new().fg(self.theme.text));
4078            self.styled(switch, switch_style);
4079        }
4080        self.commands.push(Command::EndContainer);
4081        self.last_text_idx = None;
4082
4083        self
4084    }
4085
4086    // ── select / dropdown ─────────────────────────────────────────────
4087
4088    /// Render a dropdown select. Shows the selected item; expands on activation.
4089    ///
4090    /// Returns `true` when the selection changed this frame.
4091    pub fn select(&mut self, state: &mut SelectState) -> bool {
4092        if state.items.is_empty() {
4093            return false;
4094        }
4095        state.selected = state.selected.min(state.items.len().saturating_sub(1));
4096
4097        let focused = self.register_focusable();
4098        let interaction_id = self.interaction_count;
4099        self.interaction_count += 1;
4100        let response = self.response_for(interaction_id);
4101        let old_selected = state.selected;
4102
4103        if response.clicked {
4104            state.open = !state.open;
4105            if state.open {
4106                state.set_cursor(state.selected);
4107            }
4108        }
4109
4110        if focused {
4111            let mut consumed_indices = Vec::new();
4112            for (i, event) in self.events.iter().enumerate() {
4113                if self.consumed[i] {
4114                    continue;
4115                }
4116                if let Event::Key(key) = event {
4117                    if state.open {
4118                        match key.code {
4119                            KeyCode::Up | KeyCode::Char('k') => {
4120                                let c = state.cursor();
4121                                state.set_cursor(c.saturating_sub(1));
4122                                consumed_indices.push(i);
4123                            }
4124                            KeyCode::Down | KeyCode::Char('j') => {
4125                                let c = state.cursor();
4126                                state.set_cursor((c + 1).min(state.items.len().saturating_sub(1)));
4127                                consumed_indices.push(i);
4128                            }
4129                            KeyCode::Enter | KeyCode::Char(' ') => {
4130                                state.selected = state.cursor();
4131                                state.open = false;
4132                                consumed_indices.push(i);
4133                            }
4134                            KeyCode::Esc => {
4135                                state.open = false;
4136                                consumed_indices.push(i);
4137                            }
4138                            _ => {}
4139                        }
4140                    } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
4141                        state.open = true;
4142                        state.set_cursor(state.selected);
4143                        consumed_indices.push(i);
4144                    }
4145                }
4146            }
4147            for idx in consumed_indices {
4148                self.consumed[idx] = true;
4149            }
4150        }
4151
4152        let changed = state.selected != old_selected;
4153
4154        let border_color = if focused {
4155            self.theme.primary
4156        } else {
4157            self.theme.border
4158        };
4159        let display_text = state
4160            .items
4161            .get(state.selected)
4162            .cloned()
4163            .unwrap_or_else(|| state.placeholder.clone());
4164        let arrow = if state.open { "▲" } else { "▼" };
4165
4166        self.commands.push(Command::BeginContainer {
4167            direction: Direction::Column,
4168            gap: 0,
4169            align: Align::Start,
4170            justify: Justify::Start,
4171            border: None,
4172            border_sides: BorderSides::all(),
4173            border_style: Style::new().fg(self.theme.border),
4174            bg_color: None,
4175            padding: Padding::default(),
4176            margin: Margin::default(),
4177            constraints: Constraints::default(),
4178            title: None,
4179            grow: 0,
4180        });
4181
4182        self.commands.push(Command::BeginContainer {
4183            direction: Direction::Row,
4184            gap: 1,
4185            align: Align::Start,
4186            justify: Justify::Start,
4187            border: Some(Border::Rounded),
4188            border_sides: BorderSides::all(),
4189            border_style: Style::new().fg(border_color),
4190            bg_color: None,
4191            padding: Padding {
4192                left: 1,
4193                right: 1,
4194                top: 0,
4195                bottom: 0,
4196            },
4197            margin: Margin::default(),
4198            constraints: Constraints::default(),
4199            title: None,
4200            grow: 0,
4201        });
4202        self.interaction_count += 1;
4203        self.styled(&display_text, Style::new().fg(self.theme.text));
4204        self.styled(arrow, Style::new().fg(self.theme.text_dim));
4205        self.commands.push(Command::EndContainer);
4206        self.last_text_idx = None;
4207
4208        if state.open {
4209            for (idx, item) in state.items.iter().enumerate() {
4210                let is_cursor = idx == state.cursor();
4211                let style = if is_cursor {
4212                    Style::new().bold().fg(self.theme.primary)
4213                } else {
4214                    Style::new().fg(self.theme.text)
4215                };
4216                let prefix = if is_cursor { "▸ " } else { "  " };
4217                self.styled(format!("{prefix}{item}"), style);
4218            }
4219        }
4220
4221        self.commands.push(Command::EndContainer);
4222        self.last_text_idx = None;
4223        changed
4224    }
4225
4226    // ── radio ────────────────────────────────────────────────────────
4227
4228    /// Render a radio button group. Returns `true` when selection changed.
4229    pub fn radio(&mut self, state: &mut RadioState) -> bool {
4230        if state.items.is_empty() {
4231            return false;
4232        }
4233        state.selected = state.selected.min(state.items.len().saturating_sub(1));
4234        let focused = self.register_focusable();
4235        let old_selected = state.selected;
4236
4237        if focused {
4238            let mut consumed_indices = Vec::new();
4239            for (i, event) in self.events.iter().enumerate() {
4240                if self.consumed[i] {
4241                    continue;
4242                }
4243                if let Event::Key(key) = event {
4244                    match key.code {
4245                        KeyCode::Up | KeyCode::Char('k') => {
4246                            state.selected = state.selected.saturating_sub(1);
4247                            consumed_indices.push(i);
4248                        }
4249                        KeyCode::Down | KeyCode::Char('j') => {
4250                            state.selected =
4251                                (state.selected + 1).min(state.items.len().saturating_sub(1));
4252                            consumed_indices.push(i);
4253                        }
4254                        KeyCode::Enter | KeyCode::Char(' ') => {
4255                            consumed_indices.push(i);
4256                        }
4257                        _ => {}
4258                    }
4259                }
4260            }
4261            for idx in consumed_indices {
4262                self.consumed[idx] = true;
4263            }
4264        }
4265
4266        let interaction_id = self.interaction_count;
4267        self.interaction_count += 1;
4268
4269        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
4270            for (i, event) in self.events.iter().enumerate() {
4271                if self.consumed[i] {
4272                    continue;
4273                }
4274                if let Event::Mouse(mouse) = event {
4275                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
4276                        continue;
4277                    }
4278                    let in_bounds = mouse.x >= rect.x
4279                        && mouse.x < rect.right()
4280                        && mouse.y >= rect.y
4281                        && mouse.y < rect.bottom();
4282                    if !in_bounds {
4283                        continue;
4284                    }
4285                    let clicked_idx = (mouse.y - rect.y) as usize;
4286                    if clicked_idx < state.items.len() {
4287                        state.selected = clicked_idx;
4288                        self.consumed[i] = true;
4289                    }
4290                }
4291            }
4292        }
4293
4294        self.commands.push(Command::BeginContainer {
4295            direction: Direction::Column,
4296            gap: 0,
4297            align: Align::Start,
4298            justify: Justify::Start,
4299            border: None,
4300            border_sides: BorderSides::all(),
4301            border_style: Style::new().fg(self.theme.border),
4302            bg_color: None,
4303            padding: Padding::default(),
4304            margin: Margin::default(),
4305            constraints: Constraints::default(),
4306            title: None,
4307            grow: 0,
4308        });
4309
4310        for (idx, item) in state.items.iter().enumerate() {
4311            let is_selected = idx == state.selected;
4312            let marker = if is_selected { "●" } else { "○" };
4313            let style = if is_selected {
4314                if focused {
4315                    Style::new().bold().fg(self.theme.primary)
4316                } else {
4317                    Style::new().fg(self.theme.primary)
4318                }
4319            } else {
4320                Style::new().fg(self.theme.text)
4321            };
4322            let prefix = if focused && idx == state.selected {
4323                "▸ "
4324            } else {
4325                "  "
4326            };
4327            self.styled(format!("{prefix}{marker} {item}"), style);
4328        }
4329
4330        self.commands.push(Command::EndContainer);
4331        self.last_text_idx = None;
4332        state.selected != old_selected
4333    }
4334
4335    // ── multi-select ─────────────────────────────────────────────────
4336
4337    /// Render a multi-select list. Space toggles, Up/Down navigates.
4338    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> &mut Self {
4339        if state.items.is_empty() {
4340            return self;
4341        }
4342        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
4343        let focused = self.register_focusable();
4344
4345        if focused {
4346            let mut consumed_indices = Vec::new();
4347            for (i, event) in self.events.iter().enumerate() {
4348                if self.consumed[i] {
4349                    continue;
4350                }
4351                if let Event::Key(key) = event {
4352                    match key.code {
4353                        KeyCode::Up | KeyCode::Char('k') => {
4354                            state.cursor = state.cursor.saturating_sub(1);
4355                            consumed_indices.push(i);
4356                        }
4357                        KeyCode::Down | KeyCode::Char('j') => {
4358                            state.cursor =
4359                                (state.cursor + 1).min(state.items.len().saturating_sub(1));
4360                            consumed_indices.push(i);
4361                        }
4362                        KeyCode::Char(' ') | KeyCode::Enter => {
4363                            state.toggle(state.cursor);
4364                            consumed_indices.push(i);
4365                        }
4366                        _ => {}
4367                    }
4368                }
4369            }
4370            for idx in consumed_indices {
4371                self.consumed[idx] = true;
4372            }
4373        }
4374
4375        let interaction_id = self.interaction_count;
4376        self.interaction_count += 1;
4377
4378        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
4379            for (i, event) in self.events.iter().enumerate() {
4380                if self.consumed[i] {
4381                    continue;
4382                }
4383                if let Event::Mouse(mouse) = event {
4384                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
4385                        continue;
4386                    }
4387                    let in_bounds = mouse.x >= rect.x
4388                        && mouse.x < rect.right()
4389                        && mouse.y >= rect.y
4390                        && mouse.y < rect.bottom();
4391                    if !in_bounds {
4392                        continue;
4393                    }
4394                    let clicked_idx = (mouse.y - rect.y) as usize;
4395                    if clicked_idx < state.items.len() {
4396                        state.toggle(clicked_idx);
4397                        state.cursor = clicked_idx;
4398                        self.consumed[i] = true;
4399                    }
4400                }
4401            }
4402        }
4403
4404        self.commands.push(Command::BeginContainer {
4405            direction: Direction::Column,
4406            gap: 0,
4407            align: Align::Start,
4408            justify: Justify::Start,
4409            border: None,
4410            border_sides: BorderSides::all(),
4411            border_style: Style::new().fg(self.theme.border),
4412            bg_color: None,
4413            padding: Padding::default(),
4414            margin: Margin::default(),
4415            constraints: Constraints::default(),
4416            title: None,
4417            grow: 0,
4418        });
4419
4420        for (idx, item) in state.items.iter().enumerate() {
4421            let checked = state.selected.contains(&idx);
4422            let marker = if checked { "[x]" } else { "[ ]" };
4423            let is_cursor = idx == state.cursor;
4424            let style = if is_cursor && focused {
4425                Style::new().bold().fg(self.theme.primary)
4426            } else if checked {
4427                Style::new().fg(self.theme.success)
4428            } else {
4429                Style::new().fg(self.theme.text)
4430            };
4431            let prefix = if is_cursor && focused { "▸ " } else { "  " };
4432            self.styled(format!("{prefix}{marker} {item}"), style);
4433        }
4434
4435        self.commands.push(Command::EndContainer);
4436        self.last_text_idx = None;
4437        self
4438    }
4439
4440    // ── tree ─────────────────────────────────────────────────────────
4441
4442    /// Render a tree view. Left/Right to collapse/expand, Up/Down to navigate.
4443    pub fn tree(&mut self, state: &mut TreeState) -> &mut Self {
4444        let entries = state.flatten();
4445        if entries.is_empty() {
4446            return self;
4447        }
4448        state.selected = state.selected.min(entries.len().saturating_sub(1));
4449        let focused = self.register_focusable();
4450
4451        if focused {
4452            let mut consumed_indices = Vec::new();
4453            for (i, event) in self.events.iter().enumerate() {
4454                if self.consumed[i] {
4455                    continue;
4456                }
4457                if let Event::Key(key) = event {
4458                    match key.code {
4459                        KeyCode::Up | KeyCode::Char('k') => {
4460                            state.selected = state.selected.saturating_sub(1);
4461                            consumed_indices.push(i);
4462                        }
4463                        KeyCode::Down | KeyCode::Char('j') => {
4464                            let max = state.flatten().len().saturating_sub(1);
4465                            state.selected = (state.selected + 1).min(max);
4466                            consumed_indices.push(i);
4467                        }
4468                        KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
4469                            state.toggle_at(state.selected);
4470                            consumed_indices.push(i);
4471                        }
4472                        KeyCode::Left => {
4473                            let entry = &entries[state.selected.min(entries.len() - 1)];
4474                            if entry.expanded {
4475                                state.toggle_at(state.selected);
4476                            }
4477                            consumed_indices.push(i);
4478                        }
4479                        _ => {}
4480                    }
4481                }
4482            }
4483            for idx in consumed_indices {
4484                self.consumed[idx] = true;
4485            }
4486        }
4487
4488        self.interaction_count += 1;
4489        self.commands.push(Command::BeginContainer {
4490            direction: Direction::Column,
4491            gap: 0,
4492            align: Align::Start,
4493            justify: Justify::Start,
4494            border: None,
4495            border_sides: BorderSides::all(),
4496            border_style: Style::new().fg(self.theme.border),
4497            bg_color: None,
4498            padding: Padding::default(),
4499            margin: Margin::default(),
4500            constraints: Constraints::default(),
4501            title: None,
4502            grow: 0,
4503        });
4504
4505        let entries = state.flatten();
4506        for (idx, entry) in entries.iter().enumerate() {
4507            let indent = "  ".repeat(entry.depth);
4508            let icon = if entry.is_leaf {
4509                "  "
4510            } else if entry.expanded {
4511                "▾ "
4512            } else {
4513                "▸ "
4514            };
4515            let is_selected = idx == state.selected;
4516            let style = if is_selected && focused {
4517                Style::new().bold().fg(self.theme.primary)
4518            } else if is_selected {
4519                Style::new().fg(self.theme.primary)
4520            } else {
4521                Style::new().fg(self.theme.text)
4522            };
4523            let cursor = if is_selected && focused { "▸" } else { " " };
4524            self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
4525        }
4526
4527        self.commands.push(Command::EndContainer);
4528        self.last_text_idx = None;
4529        self
4530    }
4531
4532    // ── virtual list ─────────────────────────────────────────────────
4533
4534    /// Render a virtual list that only renders visible items.
4535    ///
4536    /// `total` is the number of items. `visible_height` limits how many rows
4537    /// are rendered. The closure `f` is called only for visible indices.
4538    pub fn virtual_list(
4539        &mut self,
4540        state: &mut ListState,
4541        visible_height: usize,
4542        f: impl Fn(&mut Context, usize),
4543    ) -> &mut Self {
4544        if state.items.is_empty() {
4545            return self;
4546        }
4547        state.selected = state.selected.min(state.items.len().saturating_sub(1));
4548        let focused = self.register_focusable();
4549
4550        if focused {
4551            let mut consumed_indices = Vec::new();
4552            for (i, event) in self.events.iter().enumerate() {
4553                if self.consumed[i] {
4554                    continue;
4555                }
4556                if let Event::Key(key) = event {
4557                    match key.code {
4558                        KeyCode::Up | KeyCode::Char('k') => {
4559                            state.selected = state.selected.saturating_sub(1);
4560                            consumed_indices.push(i);
4561                        }
4562                        KeyCode::Down | KeyCode::Char('j') => {
4563                            state.selected =
4564                                (state.selected + 1).min(state.items.len().saturating_sub(1));
4565                            consumed_indices.push(i);
4566                        }
4567                        KeyCode::PageUp => {
4568                            state.selected = state.selected.saturating_sub(visible_height);
4569                            consumed_indices.push(i);
4570                        }
4571                        KeyCode::PageDown => {
4572                            state.selected = (state.selected + visible_height)
4573                                .min(state.items.len().saturating_sub(1));
4574                            consumed_indices.push(i);
4575                        }
4576                        KeyCode::Home => {
4577                            state.selected = 0;
4578                            consumed_indices.push(i);
4579                        }
4580                        KeyCode::End => {
4581                            state.selected = state.items.len().saturating_sub(1);
4582                            consumed_indices.push(i);
4583                        }
4584                        _ => {}
4585                    }
4586                }
4587            }
4588            for idx in consumed_indices {
4589                self.consumed[idx] = true;
4590            }
4591        }
4592
4593        let start = if state.selected >= visible_height {
4594            state.selected - visible_height + 1
4595        } else {
4596            0
4597        };
4598        let end = (start + visible_height).min(state.items.len());
4599
4600        self.interaction_count += 1;
4601        self.commands.push(Command::BeginContainer {
4602            direction: Direction::Column,
4603            gap: 0,
4604            align: Align::Start,
4605            justify: Justify::Start,
4606            border: None,
4607            border_sides: BorderSides::all(),
4608            border_style: Style::new().fg(self.theme.border),
4609            bg_color: None,
4610            padding: Padding::default(),
4611            margin: Margin::default(),
4612            constraints: Constraints::default(),
4613            title: None,
4614            grow: 0,
4615        });
4616
4617        if start > 0 {
4618            self.styled(
4619                format!("  ↑ {} more", start),
4620                Style::new().fg(self.theme.text_dim).dim(),
4621            );
4622        }
4623
4624        for idx in start..end {
4625            f(self, idx);
4626        }
4627
4628        let remaining = state.items.len().saturating_sub(end);
4629        if remaining > 0 {
4630            self.styled(
4631                format!("  ↓ {} more", remaining),
4632                Style::new().fg(self.theme.text_dim).dim(),
4633            );
4634        }
4635
4636        self.commands.push(Command::EndContainer);
4637        self.last_text_idx = None;
4638        self
4639    }
4640
4641    // ── command palette ──────────────────────────────────────────────
4642
4643    /// Render a command palette overlay. Returns `Some(index)` when a command is selected.
4644    pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
4645        if !state.open {
4646            return None;
4647        }
4648
4649        let filtered = state.filtered_indices();
4650        let sel = state.selected().min(filtered.len().saturating_sub(1));
4651        state.set_selected(sel);
4652
4653        let mut consumed_indices = Vec::new();
4654        let mut result: Option<usize> = None;
4655
4656        for (i, event) in self.events.iter().enumerate() {
4657            if self.consumed[i] {
4658                continue;
4659            }
4660            if let Event::Key(key) = event {
4661                match key.code {
4662                    KeyCode::Esc => {
4663                        state.open = false;
4664                        consumed_indices.push(i);
4665                    }
4666                    KeyCode::Up => {
4667                        let s = state.selected();
4668                        state.set_selected(s.saturating_sub(1));
4669                        consumed_indices.push(i);
4670                    }
4671                    KeyCode::Down => {
4672                        let s = state.selected();
4673                        state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
4674                        consumed_indices.push(i);
4675                    }
4676                    KeyCode::Enter => {
4677                        if let Some(&cmd_idx) = filtered.get(state.selected()) {
4678                            result = Some(cmd_idx);
4679                            state.open = false;
4680                        }
4681                        consumed_indices.push(i);
4682                    }
4683                    KeyCode::Backspace => {
4684                        if state.cursor > 0 {
4685                            let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
4686                            let end_idx = byte_index_for_char(&state.input, state.cursor);
4687                            state.input.replace_range(byte_idx..end_idx, "");
4688                            state.cursor -= 1;
4689                            state.set_selected(0);
4690                        }
4691                        consumed_indices.push(i);
4692                    }
4693                    KeyCode::Char(ch) => {
4694                        let byte_idx = byte_index_for_char(&state.input, state.cursor);
4695                        state.input.insert(byte_idx, ch);
4696                        state.cursor += 1;
4697                        state.set_selected(0);
4698                        consumed_indices.push(i);
4699                    }
4700                    _ => {}
4701                }
4702            }
4703        }
4704        for idx in consumed_indices {
4705            self.consumed[idx] = true;
4706        }
4707
4708        let filtered = state.filtered_indices();
4709
4710        self.modal(|ui| {
4711            let primary = ui.theme.primary;
4712            ui.container()
4713                .border(Border::Rounded)
4714                .border_style(Style::new().fg(primary))
4715                .pad(1)
4716                .max_w(60)
4717                .col(|ui| {
4718                    let border_color = ui.theme.primary;
4719                    ui.bordered(Border::Rounded)
4720                        .border_style(Style::new().fg(border_color))
4721                        .px(1)
4722                        .col(|ui| {
4723                            let display = if state.input.is_empty() {
4724                                "Type to search...".to_string()
4725                            } else {
4726                                state.input.clone()
4727                            };
4728                            let style = if state.input.is_empty() {
4729                                Style::new().dim().fg(ui.theme.text_dim)
4730                            } else {
4731                                Style::new().fg(ui.theme.text)
4732                            };
4733                            ui.styled(display, style);
4734                        });
4735
4736                    for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
4737                        let cmd = &state.commands[cmd_idx];
4738                        let is_selected = list_idx == state.selected();
4739                        let style = if is_selected {
4740                            Style::new().bold().fg(ui.theme.primary)
4741                        } else {
4742                            Style::new().fg(ui.theme.text)
4743                        };
4744                        let prefix = if is_selected { "▸ " } else { "  " };
4745                        let shortcut_text = cmd
4746                            .shortcut
4747                            .as_deref()
4748                            .map(|s| format!("  ({s})"))
4749                            .unwrap_or_default();
4750                        ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
4751                        if is_selected && !cmd.description.is_empty() {
4752                            ui.styled(
4753                                format!("    {}", cmd.description),
4754                                Style::new().dim().fg(ui.theme.text_dim),
4755                            );
4756                        }
4757                    }
4758
4759                    if filtered.is_empty() {
4760                        ui.styled(
4761                            "  No matching commands",
4762                            Style::new().dim().fg(ui.theme.text_dim),
4763                        );
4764                    }
4765                });
4766        });
4767
4768        result
4769    }
4770
4771    // ── markdown ─────────────────────────────────────────────────────
4772
4773    /// Render a markdown string with basic formatting.
4774    ///
4775    /// Supports headers (`#`), bold (`**`), italic (`*`), inline code (`` ` ``),
4776    /// unordered lists (`-`/`*`), ordered lists (`1.`), and horizontal rules (`---`).
4777    pub fn markdown(&mut self, text: &str) -> &mut Self {
4778        self.commands.push(Command::BeginContainer {
4779            direction: Direction::Column,
4780            gap: 0,
4781            align: Align::Start,
4782            justify: Justify::Start,
4783            border: None,
4784            border_sides: BorderSides::all(),
4785            border_style: Style::new().fg(self.theme.border),
4786            bg_color: None,
4787            padding: Padding::default(),
4788            margin: Margin::default(),
4789            constraints: Constraints::default(),
4790            title: None,
4791            grow: 0,
4792        });
4793        self.interaction_count += 1;
4794
4795        let text_style = Style::new().fg(self.theme.text);
4796        let bold_style = Style::new().fg(self.theme.text).bold();
4797        let code_style = Style::new().fg(self.theme.accent);
4798
4799        for line in text.lines() {
4800            let trimmed = line.trim();
4801            if trimmed.is_empty() {
4802                self.text(" ");
4803                continue;
4804            }
4805            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
4806                self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
4807                continue;
4808            }
4809            if let Some(heading) = trimmed.strip_prefix("### ") {
4810                self.styled(heading, Style::new().bold().fg(self.theme.accent));
4811            } else if let Some(heading) = trimmed.strip_prefix("## ") {
4812                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
4813            } else if let Some(heading) = trimmed.strip_prefix("# ") {
4814                self.styled(heading, Style::new().bold().fg(self.theme.primary));
4815            } else if let Some(item) = trimmed
4816                .strip_prefix("- ")
4817                .or_else(|| trimmed.strip_prefix("* "))
4818            {
4819                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
4820                if segs.len() <= 1 {
4821                    self.styled(format!("  • {item}"), text_style);
4822                } else {
4823                    self.line(|ui| {
4824                        ui.styled("  • ", text_style);
4825                        for (s, st) in segs {
4826                            ui.styled(s, st);
4827                        }
4828                    });
4829                }
4830            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
4831                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
4832                if parts.len() == 2 {
4833                    let segs =
4834                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
4835                    if segs.len() <= 1 {
4836                        self.styled(format!("  {}. {}", parts[0], parts[1]), text_style);
4837                    } else {
4838                        self.line(|ui| {
4839                            ui.styled(format!("  {}. ", parts[0]), text_style);
4840                            for (s, st) in segs {
4841                                ui.styled(s, st);
4842                            }
4843                        });
4844                    }
4845                } else {
4846                    self.text(trimmed);
4847                }
4848            } else if let Some(code) = trimmed.strip_prefix("```") {
4849                let _ = code;
4850                self.styled("  ┌─code─", Style::new().fg(self.theme.border).dim());
4851            } else {
4852                let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
4853                if segs.len() <= 1 {
4854                    self.styled(trimmed, text_style);
4855                } else {
4856                    self.line(|ui| {
4857                        for (s, st) in segs {
4858                            ui.styled(s, st);
4859                        }
4860                    });
4861                }
4862            }
4863        }
4864
4865        self.commands.push(Command::EndContainer);
4866        self.last_text_idx = None;
4867        self
4868    }
4869
4870    fn parse_inline_segments(
4871        text: &str,
4872        base: Style,
4873        bold: Style,
4874        code: Style,
4875    ) -> Vec<(String, Style)> {
4876        let mut segments: Vec<(String, Style)> = Vec::new();
4877        let mut current = String::new();
4878        let chars: Vec<char> = text.chars().collect();
4879        let mut i = 0;
4880        while i < chars.len() {
4881            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
4882                if let Some(end) = text[i + 2..].find("**") {
4883                    if !current.is_empty() {
4884                        segments.push((std::mem::take(&mut current), base));
4885                    }
4886                    segments.push((text[i + 2..i + 2 + end].to_string(), bold));
4887                    i += 4 + end;
4888                    continue;
4889                }
4890            }
4891            if chars[i] == '*'
4892                && (i + 1 >= chars.len() || chars[i + 1] != '*')
4893                && (i == 0 || chars[i - 1] != '*')
4894            {
4895                if let Some(end) = text[i + 1..].find('*') {
4896                    if !current.is_empty() {
4897                        segments.push((std::mem::take(&mut current), base));
4898                    }
4899                    segments.push((text[i + 1..i + 1 + end].to_string(), base.italic()));
4900                    i += 2 + end;
4901                    continue;
4902                }
4903            }
4904            if chars[i] == '`' {
4905                if let Some(end) = text[i + 1..].find('`') {
4906                    if !current.is_empty() {
4907                        segments.push((std::mem::take(&mut current), base));
4908                    }
4909                    segments.push((text[i + 1..i + 1 + end].to_string(), code));
4910                    i += 2 + end;
4911                    continue;
4912                }
4913            }
4914            current.push(chars[i]);
4915            i += 1;
4916        }
4917        if !current.is_empty() {
4918            segments.push((current, base));
4919        }
4920        segments
4921    }
4922
4923    // ── key sequence ─────────────────────────────────────────────────
4924
4925    /// Check if a sequence of character keys was pressed across recent frames.
4926    ///
4927    /// Matches when each character in `seq` appears in consecutive unconsumed
4928    /// key events within this frame. For single-frame sequences only (e.g., "gg").
4929    pub fn key_seq(&self, seq: &str) -> bool {
4930        if seq.is_empty() {
4931            return false;
4932        }
4933        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
4934            return false;
4935        }
4936        let target: Vec<char> = seq.chars().collect();
4937        let mut matched = 0;
4938        for (i, event) in self.events.iter().enumerate() {
4939            if self.consumed[i] {
4940                continue;
4941            }
4942            if let Event::Key(key) = event {
4943                if let KeyCode::Char(c) = key.code {
4944                    if c == target[matched] {
4945                        matched += 1;
4946                        if matched == target.len() {
4947                            return true;
4948                        }
4949                    } else {
4950                        matched = 0;
4951                        if c == target[0] {
4952                            matched = 1;
4953                        }
4954                    }
4955                }
4956            }
4957        }
4958        false
4959    }
4960
4961    /// Render a horizontal divider line.
4962    ///
4963    /// The line is drawn with the theme's border color and expands to fill the
4964    /// container width.
4965    pub fn separator(&mut self) -> &mut Self {
4966        self.commands.push(Command::Text {
4967            content: "─".repeat(200),
4968            style: Style::new().fg(self.theme.border).dim(),
4969            grow: 0,
4970            align: Align::Start,
4971            wrap: false,
4972            margin: Margin::default(),
4973            constraints: Constraints::default(),
4974        });
4975        self.last_text_idx = Some(self.commands.len() - 1);
4976        self
4977    }
4978
4979    /// Render a help bar showing keybinding hints.
4980    ///
4981    /// `bindings` is a slice of `(key, action)` pairs. Keys are rendered in the
4982    /// theme's primary color; actions in the dim text color. Pairs are separated
4983    /// by a `·` character.
4984    pub fn help(&mut self, bindings: &[(&str, &str)]) -> &mut Self {
4985        if bindings.is_empty() {
4986            return self;
4987        }
4988
4989        self.interaction_count += 1;
4990        self.commands.push(Command::BeginContainer {
4991            direction: Direction::Row,
4992            gap: 2,
4993            align: Align::Start,
4994            justify: Justify::Start,
4995            border: None,
4996            border_sides: BorderSides::all(),
4997            border_style: Style::new().fg(self.theme.border),
4998            bg_color: None,
4999            padding: Padding::default(),
5000            margin: Margin::default(),
5001            constraints: Constraints::default(),
5002            title: None,
5003            grow: 0,
5004        });
5005        for (idx, (key, action)) in bindings.iter().enumerate() {
5006            if idx > 0 {
5007                self.styled("·", Style::new().fg(self.theme.text_dim));
5008            }
5009            self.styled(*key, Style::new().bold().fg(self.theme.primary));
5010            self.styled(*action, Style::new().fg(self.theme.text_dim));
5011        }
5012        self.commands.push(Command::EndContainer);
5013        self.last_text_idx = None;
5014
5015        self
5016    }
5017
5018    // ── events ───────────────────────────────────────────────────────
5019
5020    /// Check if a character key was pressed this frame.
5021    ///
5022    /// Returns `true` if the key event has not been consumed by another widget.
5023    pub fn key(&self, c: char) -> bool {
5024        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5025            return false;
5026        }
5027        self.events.iter().enumerate().any(|(i, e)| {
5028            !self.consumed[i] && matches!(e, Event::Key(k) if k.code == KeyCode::Char(c))
5029        })
5030    }
5031
5032    /// Check if a specific key code was pressed this frame.
5033    ///
5034    /// Returns `true` if the key event has not been consumed by another widget.
5035    pub fn key_code(&self, code: KeyCode) -> bool {
5036        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5037            return false;
5038        }
5039        self.events
5040            .iter()
5041            .enumerate()
5042            .any(|(i, e)| !self.consumed[i] && matches!(e, Event::Key(k) if k.code == code))
5043    }
5044
5045    /// Check if a character key with specific modifiers was pressed this frame.
5046    ///
5047    /// Returns `true` if the key event has not been consumed by another widget.
5048    pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
5049        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5050            return false;
5051        }
5052        self.events.iter().enumerate().any(|(i, e)| {
5053            !self.consumed[i]
5054                && matches!(e, Event::Key(k) if k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
5055        })
5056    }
5057
5058    /// Return the position of a left mouse button down event this frame, if any.
5059    ///
5060    /// Returns `None` if no unconsumed mouse-down event occurred.
5061    pub fn mouse_down(&self) -> Option<(u32, u32)> {
5062        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5063            return None;
5064        }
5065        self.events.iter().enumerate().find_map(|(i, event)| {
5066            if self.consumed[i] {
5067                return None;
5068            }
5069            if let Event::Mouse(mouse) = event {
5070                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
5071                    return Some((mouse.x, mouse.y));
5072                }
5073            }
5074            None
5075        })
5076    }
5077
5078    /// Return the current mouse cursor position, if known.
5079    ///
5080    /// The position is updated on every mouse move or click event. Returns
5081    /// `None` until the first mouse event is received.
5082    pub fn mouse_pos(&self) -> Option<(u32, u32)> {
5083        self.mouse_pos
5084    }
5085
5086    /// Return the first unconsumed paste event text, if any.
5087    pub fn paste(&self) -> Option<&str> {
5088        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5089            return None;
5090        }
5091        self.events.iter().enumerate().find_map(|(i, event)| {
5092            if self.consumed[i] {
5093                return None;
5094            }
5095            if let Event::Paste(ref text) = event {
5096                return Some(text.as_str());
5097            }
5098            None
5099        })
5100    }
5101
5102    /// Check if an unconsumed scroll-up event occurred this frame.
5103    pub fn scroll_up(&self) -> bool {
5104        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5105            return false;
5106        }
5107        self.events.iter().enumerate().any(|(i, event)| {
5108            !self.consumed[i]
5109                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
5110        })
5111    }
5112
5113    /// Check if an unconsumed scroll-down event occurred this frame.
5114    pub fn scroll_down(&self) -> bool {
5115        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
5116            return false;
5117        }
5118        self.events.iter().enumerate().any(|(i, event)| {
5119            !self.consumed[i]
5120                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
5121        })
5122    }
5123
5124    /// Signal the run loop to exit after this frame.
5125    pub fn quit(&mut self) {
5126        self.should_quit = true;
5127    }
5128
5129    /// Get the current theme.
5130    pub fn theme(&self) -> &Theme {
5131        &self.theme
5132    }
5133
5134    /// Change the theme for subsequent rendering.
5135    ///
5136    /// All widgets rendered after this call will use the new theme's colors.
5137    pub fn set_theme(&mut self, theme: Theme) {
5138        self.theme = theme;
5139    }
5140
5141    // ── info ─────────────────────────────────────────────────────────
5142
5143    /// Get the terminal width in cells.
5144    pub fn width(&self) -> u32 {
5145        self.area_width
5146    }
5147
5148    /// Get the terminal height in cells.
5149    pub fn height(&self) -> u32 {
5150        self.area_height
5151    }
5152
5153    /// Get the current tick count (increments each frame).
5154    ///
5155    /// Useful for animations and time-based logic. The tick starts at 0 and
5156    /// increases by 1 on every rendered frame.
5157    pub fn tick(&self) -> u64 {
5158        self.tick
5159    }
5160
5161    /// Return whether the layout debugger is enabled.
5162    ///
5163    /// The debugger is toggled with F12 at runtime.
5164    pub fn debug_enabled(&self) -> bool {
5165        self.debug
5166    }
5167}
5168
5169#[inline]
5170fn byte_index_for_char(value: &str, char_index: usize) -> usize {
5171    if char_index == 0 {
5172        return 0;
5173    }
5174    value
5175        .char_indices()
5176        .nth(char_index)
5177        .map_or(value.len(), |(idx, _)| idx)
5178}
5179
5180fn format_table_row(cells: &[String], widths: &[u32], separator: &str) -> String {
5181    let mut parts: Vec<String> = Vec::new();
5182    for (i, width) in widths.iter().enumerate() {
5183        let cell = cells.get(i).map(String::as_str).unwrap_or("");
5184        let cell_width = UnicodeWidthStr::width(cell) as u32;
5185        let padding = (*width).saturating_sub(cell_width) as usize;
5186        parts.push(format!("{cell}{}", " ".repeat(padding)));
5187    }
5188    parts.join(separator)
5189}
5190
5191fn format_compact_number(value: f64) -> String {
5192    if value.fract().abs() < f64::EPSILON {
5193        return format!("{value:.0}");
5194    }
5195
5196    let mut s = format!("{value:.2}");
5197    while s.contains('.') && s.ends_with('0') {
5198        s.pop();
5199    }
5200    if s.ends_with('.') {
5201        s.pop();
5202    }
5203    s
5204}
5205
5206fn center_text(text: &str, width: usize) -> String {
5207    let text_width = UnicodeWidthStr::width(text);
5208    if text_width >= width {
5209        return text.to_string();
5210    }
5211
5212    let total = width - text_width;
5213    let left = total / 2;
5214    let right = total - left;
5215    format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
5216}
5217
5218struct TextareaVLine {
5219    logical_row: usize,
5220    char_start: usize,
5221    char_count: usize,
5222}
5223
5224fn textarea_build_visual_lines(lines: &[String], wrap_width: u32) -> Vec<TextareaVLine> {
5225    let mut out = Vec::new();
5226    for (row, line) in lines.iter().enumerate() {
5227        if line.is_empty() || wrap_width == u32::MAX {
5228            out.push(TextareaVLine {
5229                logical_row: row,
5230                char_start: 0,
5231                char_count: line.chars().count(),
5232            });
5233            continue;
5234        }
5235        let mut seg_start = 0usize;
5236        let mut seg_chars = 0usize;
5237        let mut seg_width = 0u32;
5238        for (idx, ch) in line.chars().enumerate() {
5239            let cw = UnicodeWidthChar::width(ch).unwrap_or(0) as u32;
5240            if seg_width + cw > wrap_width && seg_chars > 0 {
5241                out.push(TextareaVLine {
5242                    logical_row: row,
5243                    char_start: seg_start,
5244                    char_count: seg_chars,
5245                });
5246                seg_start = idx;
5247                seg_chars = 0;
5248                seg_width = 0;
5249            }
5250            seg_chars += 1;
5251            seg_width += cw;
5252        }
5253        out.push(TextareaVLine {
5254            logical_row: row,
5255            char_start: seg_start,
5256            char_count: seg_chars,
5257        });
5258    }
5259    out
5260}
5261
5262fn textarea_logical_to_visual(
5263    vlines: &[TextareaVLine],
5264    logical_row: usize,
5265    logical_col: usize,
5266) -> (usize, usize) {
5267    for (i, vl) in vlines.iter().enumerate() {
5268        if vl.logical_row != logical_row {
5269            continue;
5270        }
5271        let seg_end = vl.char_start + vl.char_count;
5272        if logical_col >= vl.char_start && logical_col < seg_end {
5273            return (i, logical_col - vl.char_start);
5274        }
5275        if logical_col == seg_end {
5276            let is_last_seg = vlines
5277                .get(i + 1)
5278                .map_or(true, |next| next.logical_row != logical_row);
5279            if is_last_seg {
5280                return (i, logical_col - vl.char_start);
5281            }
5282        }
5283    }
5284    (vlines.len().saturating_sub(1), 0)
5285}
5286
5287fn textarea_visual_to_logical(
5288    vlines: &[TextareaVLine],
5289    visual_row: usize,
5290    visual_col: usize,
5291) -> (usize, usize) {
5292    if let Some(vl) = vlines.get(visual_row) {
5293        let logical_col = vl.char_start + visual_col.min(vl.char_count);
5294        (vl.logical_row, logical_col)
5295    } else {
5296        (0, 0)
5297    }
5298}
5299
5300fn open_url(url: &str) -> std::io::Result<()> {
5301    #[cfg(target_os = "macos")]
5302    {
5303        std::process::Command::new("open").arg(url).spawn()?;
5304    }
5305    #[cfg(target_os = "linux")]
5306    {
5307        std::process::Command::new("xdg-open").arg(url).spawn()?;
5308    }
5309    #[cfg(target_os = "windows")]
5310    {
5311        std::process::Command::new("cmd")
5312            .args(["/c", "start", "", url])
5313            .spawn()?;
5314    }
5315    Ok(())
5316}