Skip to main content

slt/
context.rs

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