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