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