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