Skip to main content

slt/
context.rs

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