Skip to main content

slt/
context.rs

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