Skip to main content

slt/context/
widgets_interactive.rs

1use super::*;
2
3impl Context {
4    /// Render children in a fixed grid with the given number of columns.
5    ///
6    /// Children are placed left-to-right, top-to-bottom. Each cell has equal
7    /// width (`area_width / cols`). Rows wrap automatically.
8    ///
9    /// # Example
10    ///
11    /// ```no_run
12    /// # slt::run(|ui: &mut slt::Context| {
13    /// ui.grid(3, |ui| {
14    ///     for i in 0..9 {
15    ///         ui.text(format!("Cell {i}"));
16    ///     }
17    /// });
18    /// # });
19    /// ```
20    pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
21        slt_assert(cols > 0, "grid() requires at least 1 column");
22        let interaction_id = self.interaction_count;
23        self.interaction_count += 1;
24        let border = self.theme.border;
25
26        self.commands.push(Command::BeginContainer {
27            direction: Direction::Column,
28            gap: 0,
29            align: Align::Start,
30            justify: Justify::Start,
31            border: None,
32            border_sides: BorderSides::all(),
33            border_style: Style::new().fg(border),
34            bg_color: None,
35            padding: Padding::default(),
36            margin: Margin::default(),
37            constraints: Constraints::default(),
38            title: None,
39            grow: 0,
40            group_name: None,
41        });
42
43        let children_start = self.commands.len();
44        f(self);
45        let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
46
47        let mut elements: Vec<Vec<Command>> = Vec::new();
48        let mut iter = child_commands.into_iter().peekable();
49        while let Some(cmd) = iter.next() {
50            match cmd {
51                Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
52                    let mut depth = 1_u32;
53                    let mut element = vec![cmd];
54                    for next in iter.by_ref() {
55                        match next {
56                            Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
57                                depth += 1;
58                            }
59                            Command::EndContainer => {
60                                depth = depth.saturating_sub(1);
61                            }
62                            _ => {}
63                        }
64                        let at_end = matches!(next, Command::EndContainer) && depth == 0;
65                        element.push(next);
66                        if at_end {
67                            break;
68                        }
69                    }
70                    elements.push(element);
71                }
72                Command::EndContainer => {}
73                _ => elements.push(vec![cmd]),
74            }
75        }
76
77        let cols = cols.max(1) as usize;
78        for row in elements.chunks(cols) {
79            self.interaction_count += 1;
80            self.commands.push(Command::BeginContainer {
81                direction: Direction::Row,
82                gap: 0,
83                align: Align::Start,
84                justify: Justify::Start,
85                border: None,
86                border_sides: BorderSides::all(),
87                border_style: Style::new().fg(border),
88                bg_color: None,
89                padding: Padding::default(),
90                margin: Margin::default(),
91                constraints: Constraints::default(),
92                title: None,
93                grow: 0,
94                group_name: None,
95            });
96
97            for element in row {
98                self.interaction_count += 1;
99                self.commands.push(Command::BeginContainer {
100                    direction: Direction::Column,
101                    gap: 0,
102                    align: Align::Start,
103                    justify: Justify::Start,
104                    border: None,
105                    border_sides: BorderSides::all(),
106                    border_style: Style::new().fg(border),
107                    bg_color: None,
108                    padding: Padding::default(),
109                    margin: Margin::default(),
110                    constraints: Constraints::default(),
111                    title: None,
112                    grow: 1,
113                    group_name: None,
114                });
115                self.commands.extend(element.iter().cloned());
116                self.commands.push(Command::EndContainer);
117            }
118
119            self.commands.push(Command::EndContainer);
120        }
121
122        self.commands.push(Command::EndContainer);
123        self.last_text_idx = None;
124
125        self.response_for(interaction_id)
126    }
127
128    /// Render a selectable list. Handles Up/Down (and `k`/`j`) navigation when focused.
129    ///
130    /// The selected item is highlighted with the theme's primary color. If the
131    /// list is empty, nothing is rendered.
132    pub fn list(&mut self, state: &mut ListState) -> &mut Self {
133        let visible = state.visible_indices().to_vec();
134        if visible.is_empty() && state.items.is_empty() {
135            state.selected = 0;
136            return self;
137        }
138
139        if !visible.is_empty() {
140            state.selected = state.selected.min(visible.len().saturating_sub(1));
141        }
142
143        let focused = self.register_focusable();
144        let interaction_id = self.interaction_count;
145        self.interaction_count += 1;
146
147        if focused {
148            let mut consumed_indices = Vec::new();
149            for (i, event) in self.events.iter().enumerate() {
150                if let Event::Key(key) = event {
151                    if key.kind != KeyEventKind::Press {
152                        continue;
153                    }
154                    match key.code {
155                        KeyCode::Up | KeyCode::Char('k') => {
156                            state.selected = state.selected.saturating_sub(1);
157                            consumed_indices.push(i);
158                        }
159                        KeyCode::Down | KeyCode::Char('j') => {
160                            state.selected =
161                                (state.selected + 1).min(visible.len().saturating_sub(1));
162                            consumed_indices.push(i);
163                        }
164                        _ => {}
165                    }
166                }
167            }
168
169            for index in consumed_indices {
170                self.consumed[index] = true;
171            }
172        }
173
174        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
175            for (i, event) in self.events.iter().enumerate() {
176                if self.consumed[i] {
177                    continue;
178                }
179                if let Event::Mouse(mouse) = event {
180                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
181                        continue;
182                    }
183                    let in_bounds = mouse.x >= rect.x
184                        && mouse.x < rect.right()
185                        && mouse.y >= rect.y
186                        && mouse.y < rect.bottom();
187                    if !in_bounds {
188                        continue;
189                    }
190                    let clicked_idx = (mouse.y - rect.y) as usize;
191                    if clicked_idx < visible.len() {
192                        state.selected = clicked_idx;
193                        self.consumed[i] = true;
194                    }
195                }
196            }
197        }
198
199        self.commands.push(Command::BeginContainer {
200            direction: Direction::Column,
201            gap: 0,
202            align: Align::Start,
203            justify: Justify::Start,
204            border: None,
205            border_sides: BorderSides::all(),
206            border_style: Style::new().fg(self.theme.border),
207            bg_color: None,
208            padding: Padding::default(),
209            margin: Margin::default(),
210            constraints: Constraints::default(),
211            title: None,
212            grow: 0,
213            group_name: None,
214        });
215
216        for (view_idx, &item_idx) in visible.iter().enumerate() {
217            let item = &state.items[item_idx];
218            if view_idx == state.selected {
219                if focused {
220                    self.styled(
221                        format!("▸ {item}"),
222                        Style::new().bold().fg(self.theme.primary),
223                    );
224                } else {
225                    self.styled(format!("▸ {item}"), Style::new().fg(self.theme.primary));
226                }
227            } else {
228                self.styled(format!("  {item}"), Style::new().fg(self.theme.text));
229            }
230        }
231
232        self.commands.push(Command::EndContainer);
233        self.last_text_idx = None;
234
235        self
236    }
237
238    /// Render a data table with column headers. Handles Up/Down selection when focused.
239    ///
240    /// Column widths are computed automatically from header and cell content.
241    /// The selected row is highlighted with the theme's selection colors.
242    pub fn table(&mut self, state: &mut TableState) -> &mut Self {
243        if state.is_dirty() {
244            state.recompute_widths();
245        }
246
247        let focused = self.register_focusable();
248        let interaction_id = self.interaction_count;
249        self.interaction_count += 1;
250
251        if focused && !state.visible_indices().is_empty() {
252            let mut consumed_indices = Vec::new();
253            for (i, event) in self.events.iter().enumerate() {
254                if let Event::Key(key) = event {
255                    if key.kind != KeyEventKind::Press {
256                        continue;
257                    }
258                    match key.code {
259                        KeyCode::Up | KeyCode::Char('k') => {
260                            let visible_len = if state.page_size > 0 {
261                                let start = state
262                                    .page
263                                    .saturating_mul(state.page_size)
264                                    .min(state.visible_indices().len());
265                                let end =
266                                    (start + state.page_size).min(state.visible_indices().len());
267                                end.saturating_sub(start)
268                            } else {
269                                state.visible_indices().len()
270                            };
271                            state.selected = state.selected.min(visible_len.saturating_sub(1));
272                            state.selected = state.selected.saturating_sub(1);
273                            consumed_indices.push(i);
274                        }
275                        KeyCode::Down | KeyCode::Char('j') => {
276                            let visible_len = if state.page_size > 0 {
277                                let start = state
278                                    .page
279                                    .saturating_mul(state.page_size)
280                                    .min(state.visible_indices().len());
281                                let end =
282                                    (start + state.page_size).min(state.visible_indices().len());
283                                end.saturating_sub(start)
284                            } else {
285                                state.visible_indices().len()
286                            };
287                            state.selected =
288                                (state.selected + 1).min(visible_len.saturating_sub(1));
289                            consumed_indices.push(i);
290                        }
291                        KeyCode::PageUp => {
292                            let old_page = state.page;
293                            state.prev_page();
294                            if state.page != old_page {
295                                state.selected = 0;
296                            }
297                            consumed_indices.push(i);
298                        }
299                        KeyCode::PageDown => {
300                            let old_page = state.page;
301                            state.next_page();
302                            if state.page != old_page {
303                                state.selected = 0;
304                            }
305                            consumed_indices.push(i);
306                        }
307                        _ => {}
308                    }
309                }
310            }
311            for index in consumed_indices {
312                self.consumed[index] = true;
313            }
314        }
315
316        if !state.visible_indices().is_empty() || !state.headers.is_empty() {
317            if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
318                for (i, event) in self.events.iter().enumerate() {
319                    if self.consumed[i] {
320                        continue;
321                    }
322                    if let Event::Mouse(mouse) = event {
323                        if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
324                            continue;
325                        }
326                        let in_bounds = mouse.x >= rect.x
327                            && mouse.x < rect.right()
328                            && mouse.y >= rect.y
329                            && mouse.y < rect.bottom();
330                        if !in_bounds {
331                            continue;
332                        }
333
334                        if mouse.y == rect.y {
335                            let rel_x = mouse.x.saturating_sub(rect.x);
336                            let mut x_offset = 0u32;
337                            for (col_idx, width) in state.column_widths().iter().enumerate() {
338                                if rel_x >= x_offset && rel_x < x_offset + *width {
339                                    state.toggle_sort(col_idx);
340                                    state.selected = 0;
341                                    self.consumed[i] = true;
342                                    break;
343                                }
344                                x_offset += *width;
345                                if col_idx + 1 < state.column_widths().len() {
346                                    x_offset += 3;
347                                }
348                            }
349                            continue;
350                        }
351
352                        if mouse.y < rect.y + 2 {
353                            continue;
354                        }
355
356                        let visible_len = if state.page_size > 0 {
357                            let start = state
358                                .page
359                                .saturating_mul(state.page_size)
360                                .min(state.visible_indices().len());
361                            let end = (start + state.page_size).min(state.visible_indices().len());
362                            end.saturating_sub(start)
363                        } else {
364                            state.visible_indices().len()
365                        };
366                        let clicked_idx = (mouse.y - rect.y - 2) as usize;
367                        if clicked_idx < visible_len {
368                            state.selected = clicked_idx;
369                            self.consumed[i] = true;
370                        }
371                    }
372                }
373            }
374        }
375
376        if state.is_dirty() {
377            state.recompute_widths();
378        }
379
380        let total_visible = state.visible_indices().len();
381        let page_start = if state.page_size > 0 {
382            state
383                .page
384                .saturating_mul(state.page_size)
385                .min(total_visible)
386        } else {
387            0
388        };
389        let page_end = if state.page_size > 0 {
390            (page_start + state.page_size).min(total_visible)
391        } else {
392            total_visible
393        };
394        let visible_len = page_end.saturating_sub(page_start);
395        state.selected = state.selected.min(visible_len.saturating_sub(1));
396
397        self.commands.push(Command::BeginContainer {
398            direction: Direction::Column,
399            gap: 0,
400            align: Align::Start,
401            justify: Justify::Start,
402            border: None,
403            border_sides: BorderSides::all(),
404            border_style: Style::new().fg(self.theme.border),
405            bg_color: None,
406            padding: Padding::default(),
407            margin: Margin::default(),
408            constraints: Constraints::default(),
409            title: None,
410            grow: 0,
411            group_name: None,
412        });
413
414        let header_cells = state
415            .headers
416            .iter()
417            .enumerate()
418            .map(|(i, header)| {
419                if state.sort_column == Some(i) {
420                    if state.sort_ascending {
421                        format!("{header} ▲")
422                    } else {
423                        format!("{header} ▼")
424                    }
425                } else {
426                    header.clone()
427                }
428            })
429            .collect::<Vec<_>>();
430        let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
431        self.styled(header_line, Style::new().bold().fg(self.theme.text));
432
433        let separator = state
434            .column_widths()
435            .iter()
436            .map(|w| "─".repeat(*w as usize))
437            .collect::<Vec<_>>()
438            .join("─┼─");
439        self.text(separator);
440
441        for idx in 0..visible_len {
442            let data_idx = state.visible_indices()[page_start + idx];
443            let Some(row) = state.rows.get(data_idx) else {
444                continue;
445            };
446            let line = format_table_row(row, state.column_widths(), " │ ");
447            if idx == state.selected {
448                let mut style = Style::new()
449                    .bg(self.theme.selected_bg)
450                    .fg(self.theme.selected_fg);
451                if focused {
452                    style = style.bold();
453                }
454                self.styled(line, style);
455            } else {
456                self.styled(line, Style::new().fg(self.theme.text));
457            }
458        }
459
460        if state.page_size > 0 && state.total_pages() > 1 {
461            self.styled(
462                format!("Page {}/{}", state.page + 1, state.total_pages()),
463                Style::new().dim().fg(self.theme.text_dim),
464            );
465        }
466
467        self.commands.push(Command::EndContainer);
468        self.last_text_idx = None;
469
470        self
471    }
472
473    /// Render a tab bar. Handles Left/Right navigation when focused.
474    ///
475    /// The active tab is rendered in the theme's primary color. If the labels
476    /// list is empty, nothing is rendered.
477    pub fn tabs(&mut self, state: &mut TabsState) -> &mut Self {
478        if state.labels.is_empty() {
479            state.selected = 0;
480            return self;
481        }
482
483        state.selected = state.selected.min(state.labels.len().saturating_sub(1));
484        let focused = self.register_focusable();
485        let interaction_id = self.interaction_count;
486
487        if focused {
488            let mut consumed_indices = Vec::new();
489            for (i, event) in self.events.iter().enumerate() {
490                if let Event::Key(key) = event {
491                    if key.kind != KeyEventKind::Press {
492                        continue;
493                    }
494                    match key.code {
495                        KeyCode::Left => {
496                            state.selected = if state.selected == 0 {
497                                state.labels.len().saturating_sub(1)
498                            } else {
499                                state.selected - 1
500                            };
501                            consumed_indices.push(i);
502                        }
503                        KeyCode::Right => {
504                            if !state.labels.is_empty() {
505                                state.selected = (state.selected + 1) % state.labels.len();
506                            }
507                            consumed_indices.push(i);
508                        }
509                        _ => {}
510                    }
511                }
512            }
513
514            for index in consumed_indices {
515                self.consumed[index] = true;
516            }
517        }
518
519        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
520            for (i, event) in self.events.iter().enumerate() {
521                if self.consumed[i] {
522                    continue;
523                }
524                if let Event::Mouse(mouse) = event {
525                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
526                        continue;
527                    }
528                    let in_bounds = mouse.x >= rect.x
529                        && mouse.x < rect.right()
530                        && mouse.y >= rect.y
531                        && mouse.y < rect.bottom();
532                    if !in_bounds {
533                        continue;
534                    }
535
536                    let mut x_offset = 0u32;
537                    let rel_x = mouse.x - rect.x;
538                    for (idx, label) in state.labels.iter().enumerate() {
539                        let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
540                        if rel_x >= x_offset && rel_x < x_offset + tab_width {
541                            state.selected = idx;
542                            self.consumed[i] = true;
543                            break;
544                        }
545                        x_offset += tab_width + 1;
546                    }
547                }
548            }
549        }
550
551        self.interaction_count += 1;
552        self.commands.push(Command::BeginContainer {
553            direction: Direction::Row,
554            gap: 1,
555            align: Align::Start,
556            justify: Justify::Start,
557            border: None,
558            border_sides: BorderSides::all(),
559            border_style: Style::new().fg(self.theme.border),
560            bg_color: None,
561            padding: Padding::default(),
562            margin: Margin::default(),
563            constraints: Constraints::default(),
564            title: None,
565            grow: 0,
566            group_name: None,
567        });
568        for (idx, label) in state.labels.iter().enumerate() {
569            let style = if idx == state.selected {
570                let s = Style::new().fg(self.theme.primary).bold();
571                if focused {
572                    s.underline()
573                } else {
574                    s
575                }
576            } else {
577                Style::new().fg(self.theme.text_dim)
578            };
579            self.styled(format!("[ {label} ]"), style);
580        }
581        self.commands.push(Command::EndContainer);
582        self.last_text_idx = None;
583
584        self
585    }
586
587    /// Render a clickable button. Returns `true` when activated via Enter, Space, or mouse click.
588    ///
589    /// The button is styled with the theme's primary color when focused and the
590    /// accent color when hovered.
591    pub fn button(&mut self, label: impl Into<String>) -> bool {
592        let focused = self.register_focusable();
593        let interaction_id = self.interaction_count;
594        self.interaction_count += 1;
595        let response = self.response_for(interaction_id);
596
597        let mut activated = response.clicked;
598        if focused {
599            let mut consumed_indices = Vec::new();
600            for (i, event) in self.events.iter().enumerate() {
601                if let Event::Key(key) = event {
602                    if key.kind != KeyEventKind::Press {
603                        continue;
604                    }
605                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
606                        activated = true;
607                        consumed_indices.push(i);
608                    }
609                }
610            }
611
612            for index in consumed_indices {
613                self.consumed[index] = true;
614            }
615        }
616
617        let hovered = response.hovered;
618        let style = if focused {
619            Style::new().fg(self.theme.primary).bold()
620        } else if hovered {
621            Style::new().fg(self.theme.accent)
622        } else {
623            Style::new().fg(self.theme.text)
624        };
625        let hover_bg = if hovered || focused {
626            Some(self.theme.surface_hover)
627        } else {
628            None
629        };
630
631        self.commands.push(Command::BeginContainer {
632            direction: Direction::Row,
633            gap: 0,
634            align: Align::Start,
635            justify: Justify::Start,
636            border: None,
637            border_sides: BorderSides::all(),
638            border_style: Style::new().fg(self.theme.border),
639            bg_color: hover_bg,
640            padding: Padding::default(),
641            margin: Margin::default(),
642            constraints: Constraints::default(),
643            title: None,
644            grow: 0,
645            group_name: None,
646        });
647        self.styled(format!("[ {} ]", label.into()), style);
648        self.commands.push(Command::EndContainer);
649        self.last_text_idx = None;
650
651        activated
652    }
653
654    /// Render a styled button variant. Returns `true` when activated.
655    ///
656    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
657    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
658    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> bool {
659        let focused = self.register_focusable();
660        let interaction_id = self.interaction_count;
661        self.interaction_count += 1;
662        let response = self.response_for(interaction_id);
663
664        let mut activated = response.clicked;
665        if focused {
666            let mut consumed_indices = Vec::new();
667            for (i, event) in self.events.iter().enumerate() {
668                if let Event::Key(key) = event {
669                    if key.kind != KeyEventKind::Press {
670                        continue;
671                    }
672                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
673                        activated = true;
674                        consumed_indices.push(i);
675                    }
676                }
677            }
678            for index in consumed_indices {
679                self.consumed[index] = true;
680            }
681        }
682
683        let label = label.into();
684        let hover_bg = if response.hovered || focused {
685            Some(self.theme.surface_hover)
686        } else {
687            None
688        };
689        let (text, style, bg_color, border) = match variant {
690            ButtonVariant::Default => {
691                let style = if focused {
692                    Style::new().fg(self.theme.primary).bold()
693                } else if response.hovered {
694                    Style::new().fg(self.theme.accent)
695                } else {
696                    Style::new().fg(self.theme.text)
697                };
698                (format!("[ {label} ]"), style, hover_bg, None)
699            }
700            ButtonVariant::Primary => {
701                let style = if focused {
702                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
703                } else if response.hovered {
704                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
705                } else {
706                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
707                };
708                (format!(" {label} "), style, hover_bg, None)
709            }
710            ButtonVariant::Danger => {
711                let style = if focused {
712                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
713                } else if response.hovered {
714                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
715                } else {
716                    Style::new().fg(self.theme.bg).bg(self.theme.error)
717                };
718                (format!(" {label} "), style, hover_bg, None)
719            }
720            ButtonVariant::Outline => {
721                let border_color = if focused {
722                    self.theme.primary
723                } else if response.hovered {
724                    self.theme.accent
725                } else {
726                    self.theme.border
727                };
728                let style = if focused {
729                    Style::new().fg(self.theme.primary).bold()
730                } else if response.hovered {
731                    Style::new().fg(self.theme.accent)
732                } else {
733                    Style::new().fg(self.theme.text)
734                };
735                (
736                    format!(" {label} "),
737                    style,
738                    hover_bg,
739                    Some((Border::Rounded, Style::new().fg(border_color))),
740                )
741            }
742        };
743
744        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
745        self.commands.push(Command::BeginContainer {
746            direction: Direction::Row,
747            gap: 0,
748            align: Align::Center,
749            justify: Justify::Center,
750            border: if border.is_some() {
751                Some(btn_border)
752            } else {
753                None
754            },
755            border_sides: BorderSides::all(),
756            border_style: btn_border_style,
757            bg_color,
758            padding: Padding::default(),
759            margin: Margin::default(),
760            constraints: Constraints::default(),
761            title: None,
762            grow: 0,
763            group_name: None,
764        });
765        self.styled(text, style);
766        self.commands.push(Command::EndContainer);
767        self.last_text_idx = None;
768
769        activated
770    }
771
772    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
773    ///
774    /// The checked state is shown with the theme's success color. When focused,
775    /// a `▸` prefix is added.
776    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> &mut Self {
777        let focused = self.register_focusable();
778        let interaction_id = self.interaction_count;
779        self.interaction_count += 1;
780        let response = self.response_for(interaction_id);
781        let mut should_toggle = response.clicked;
782
783        if focused {
784            let mut consumed_indices = Vec::new();
785            for (i, event) in self.events.iter().enumerate() {
786                if let Event::Key(key) = event {
787                    if key.kind != KeyEventKind::Press {
788                        continue;
789                    }
790                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
791                        should_toggle = true;
792                        consumed_indices.push(i);
793                    }
794                }
795            }
796
797            for index in consumed_indices {
798                self.consumed[index] = true;
799            }
800        }
801
802        if should_toggle {
803            *checked = !*checked;
804        }
805
806        let hover_bg = if response.hovered || focused {
807            Some(self.theme.surface_hover)
808        } else {
809            None
810        };
811        self.commands.push(Command::BeginContainer {
812            direction: Direction::Row,
813            gap: 1,
814            align: Align::Start,
815            justify: Justify::Start,
816            border: None,
817            border_sides: BorderSides::all(),
818            border_style: Style::new().fg(self.theme.border),
819            bg_color: hover_bg,
820            padding: Padding::default(),
821            margin: Margin::default(),
822            constraints: Constraints::default(),
823            title: None,
824            grow: 0,
825            group_name: None,
826        });
827        let marker_style = if *checked {
828            Style::new().fg(self.theme.success)
829        } else {
830            Style::new().fg(self.theme.text_dim)
831        };
832        let marker = if *checked { "[x]" } else { "[ ]" };
833        let label_text = label.into();
834        if focused {
835            self.styled(format!("▸ {marker}"), marker_style.bold());
836            self.styled(label_text, Style::new().fg(self.theme.text).bold());
837        } else {
838            self.styled(marker, marker_style);
839            self.styled(label_text, Style::new().fg(self.theme.text));
840        }
841        self.commands.push(Command::EndContainer);
842        self.last_text_idx = None;
843
844        self
845    }
846
847    /// Render an on/off toggle switch.
848    ///
849    /// Toggles `on` when activated via Enter, Space, or click. The switch
850    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
851    /// dim color respectively.
852    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> &mut Self {
853        let focused = self.register_focusable();
854        let interaction_id = self.interaction_count;
855        self.interaction_count += 1;
856        let response = self.response_for(interaction_id);
857        let mut should_toggle = response.clicked;
858
859        if focused {
860            let mut consumed_indices = Vec::new();
861            for (i, event) in self.events.iter().enumerate() {
862                if let Event::Key(key) = event {
863                    if key.kind != KeyEventKind::Press {
864                        continue;
865                    }
866                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
867                        should_toggle = true;
868                        consumed_indices.push(i);
869                    }
870                }
871            }
872
873            for index in consumed_indices {
874                self.consumed[index] = true;
875            }
876        }
877
878        if should_toggle {
879            *on = !*on;
880        }
881
882        let hover_bg = if response.hovered || focused {
883            Some(self.theme.surface_hover)
884        } else {
885            None
886        };
887        self.commands.push(Command::BeginContainer {
888            direction: Direction::Row,
889            gap: 2,
890            align: Align::Start,
891            justify: Justify::Start,
892            border: None,
893            border_sides: BorderSides::all(),
894            border_style: Style::new().fg(self.theme.border),
895            bg_color: hover_bg,
896            padding: Padding::default(),
897            margin: Margin::default(),
898            constraints: Constraints::default(),
899            title: None,
900            grow: 0,
901            group_name: None,
902        });
903        let label_text = label.into();
904        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
905        let switch_style = if *on {
906            Style::new().fg(self.theme.success)
907        } else {
908            Style::new().fg(self.theme.text_dim)
909        };
910        if focused {
911            self.styled(
912                format!("▸ {label_text}"),
913                Style::new().fg(self.theme.text).bold(),
914            );
915            self.styled(switch, switch_style.bold());
916        } else {
917            self.styled(label_text, Style::new().fg(self.theme.text));
918            self.styled(switch, switch_style);
919        }
920        self.commands.push(Command::EndContainer);
921        self.last_text_idx = None;
922
923        self
924    }
925
926    // ── select / dropdown ─────────────────────────────────────────────
927
928    /// Render a dropdown select. Shows the selected item; expands on activation.
929    ///
930    /// Returns `true` when the selection changed this frame.
931    pub fn select(&mut self, state: &mut SelectState) -> bool {
932        if state.items.is_empty() {
933            return false;
934        }
935        state.selected = state.selected.min(state.items.len().saturating_sub(1));
936
937        let focused = self.register_focusable();
938        let interaction_id = self.interaction_count;
939        self.interaction_count += 1;
940        let response = self.response_for(interaction_id);
941        let old_selected = state.selected;
942
943        if response.clicked {
944            state.open = !state.open;
945            if state.open {
946                state.set_cursor(state.selected);
947            }
948        }
949
950        if focused {
951            let mut consumed_indices = Vec::new();
952            for (i, event) in self.events.iter().enumerate() {
953                if self.consumed[i] {
954                    continue;
955                }
956                if let Event::Key(key) = event {
957                    if key.kind != KeyEventKind::Press {
958                        continue;
959                    }
960                    if state.open {
961                        match key.code {
962                            KeyCode::Up | KeyCode::Char('k') => {
963                                let c = state.cursor();
964                                state.set_cursor(c.saturating_sub(1));
965                                consumed_indices.push(i);
966                            }
967                            KeyCode::Down | KeyCode::Char('j') => {
968                                let c = state.cursor();
969                                state.set_cursor((c + 1).min(state.items.len().saturating_sub(1)));
970                                consumed_indices.push(i);
971                            }
972                            KeyCode::Enter | KeyCode::Char(' ') => {
973                                state.selected = state.cursor();
974                                state.open = false;
975                                consumed_indices.push(i);
976                            }
977                            KeyCode::Esc => {
978                                state.open = false;
979                                consumed_indices.push(i);
980                            }
981                            _ => {}
982                        }
983                    } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
984                        state.open = true;
985                        state.set_cursor(state.selected);
986                        consumed_indices.push(i);
987                    }
988                }
989            }
990            for idx in consumed_indices {
991                self.consumed[idx] = true;
992            }
993        }
994
995        let changed = state.selected != old_selected;
996
997        let border_color = if focused {
998            self.theme.primary
999        } else {
1000            self.theme.border
1001        };
1002        let display_text = state
1003            .items
1004            .get(state.selected)
1005            .cloned()
1006            .unwrap_or_else(|| state.placeholder.clone());
1007        let arrow = if state.open { "▲" } else { "▼" };
1008
1009        self.commands.push(Command::BeginContainer {
1010            direction: Direction::Column,
1011            gap: 0,
1012            align: Align::Start,
1013            justify: Justify::Start,
1014            border: None,
1015            border_sides: BorderSides::all(),
1016            border_style: Style::new().fg(self.theme.border),
1017            bg_color: None,
1018            padding: Padding::default(),
1019            margin: Margin::default(),
1020            constraints: Constraints::default(),
1021            title: None,
1022            grow: 0,
1023            group_name: None,
1024        });
1025
1026        self.commands.push(Command::BeginContainer {
1027            direction: Direction::Row,
1028            gap: 1,
1029            align: Align::Start,
1030            justify: Justify::Start,
1031            border: Some(Border::Rounded),
1032            border_sides: BorderSides::all(),
1033            border_style: Style::new().fg(border_color),
1034            bg_color: None,
1035            padding: Padding {
1036                left: 1,
1037                right: 1,
1038                top: 0,
1039                bottom: 0,
1040            },
1041            margin: Margin::default(),
1042            constraints: Constraints::default(),
1043            title: None,
1044            grow: 0,
1045            group_name: None,
1046        });
1047        self.interaction_count += 1;
1048        self.styled(&display_text, Style::new().fg(self.theme.text));
1049        self.styled(arrow, Style::new().fg(self.theme.text_dim));
1050        self.commands.push(Command::EndContainer);
1051        self.last_text_idx = None;
1052
1053        if state.open {
1054            for (idx, item) in state.items.iter().enumerate() {
1055                let is_cursor = idx == state.cursor();
1056                let style = if is_cursor {
1057                    Style::new().bold().fg(self.theme.primary)
1058                } else {
1059                    Style::new().fg(self.theme.text)
1060                };
1061                let prefix = if is_cursor { "▸ " } else { "  " };
1062                self.styled(format!("{prefix}{item}"), style);
1063            }
1064        }
1065
1066        self.commands.push(Command::EndContainer);
1067        self.last_text_idx = None;
1068        changed
1069    }
1070
1071    // ── radio ────────────────────────────────────────────────────────
1072
1073    /// Render a radio button group. Returns `true` when selection changed.
1074    pub fn radio(&mut self, state: &mut RadioState) -> bool {
1075        if state.items.is_empty() {
1076            return false;
1077        }
1078        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1079        let focused = self.register_focusable();
1080        let old_selected = state.selected;
1081
1082        if focused {
1083            let mut consumed_indices = Vec::new();
1084            for (i, event) in self.events.iter().enumerate() {
1085                if self.consumed[i] {
1086                    continue;
1087                }
1088                if let Event::Key(key) = event {
1089                    if key.kind != KeyEventKind::Press {
1090                        continue;
1091                    }
1092                    match key.code {
1093                        KeyCode::Up | KeyCode::Char('k') => {
1094                            state.selected = state.selected.saturating_sub(1);
1095                            consumed_indices.push(i);
1096                        }
1097                        KeyCode::Down | KeyCode::Char('j') => {
1098                            state.selected =
1099                                (state.selected + 1).min(state.items.len().saturating_sub(1));
1100                            consumed_indices.push(i);
1101                        }
1102                        KeyCode::Enter | KeyCode::Char(' ') => {
1103                            consumed_indices.push(i);
1104                        }
1105                        _ => {}
1106                    }
1107                }
1108            }
1109            for idx in consumed_indices {
1110                self.consumed[idx] = true;
1111            }
1112        }
1113
1114        let interaction_id = self.interaction_count;
1115        self.interaction_count += 1;
1116
1117        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1118            for (i, event) in self.events.iter().enumerate() {
1119                if self.consumed[i] {
1120                    continue;
1121                }
1122                if let Event::Mouse(mouse) = event {
1123                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1124                        continue;
1125                    }
1126                    let in_bounds = mouse.x >= rect.x
1127                        && mouse.x < rect.right()
1128                        && mouse.y >= rect.y
1129                        && mouse.y < rect.bottom();
1130                    if !in_bounds {
1131                        continue;
1132                    }
1133                    let clicked_idx = (mouse.y - rect.y) as usize;
1134                    if clicked_idx < state.items.len() {
1135                        state.selected = clicked_idx;
1136                        self.consumed[i] = true;
1137                    }
1138                }
1139            }
1140        }
1141
1142        self.commands.push(Command::BeginContainer {
1143            direction: Direction::Column,
1144            gap: 0,
1145            align: Align::Start,
1146            justify: Justify::Start,
1147            border: None,
1148            border_sides: BorderSides::all(),
1149            border_style: Style::new().fg(self.theme.border),
1150            bg_color: None,
1151            padding: Padding::default(),
1152            margin: Margin::default(),
1153            constraints: Constraints::default(),
1154            title: None,
1155            grow: 0,
1156            group_name: None,
1157        });
1158
1159        for (idx, item) in state.items.iter().enumerate() {
1160            let is_selected = idx == state.selected;
1161            let marker = if is_selected { "●" } else { "○" };
1162            let style = if is_selected {
1163                if focused {
1164                    Style::new().bold().fg(self.theme.primary)
1165                } else {
1166                    Style::new().fg(self.theme.primary)
1167                }
1168            } else {
1169                Style::new().fg(self.theme.text)
1170            };
1171            let prefix = if focused && idx == state.selected {
1172                "▸ "
1173            } else {
1174                "  "
1175            };
1176            self.styled(format!("{prefix}{marker} {item}"), style);
1177        }
1178
1179        self.commands.push(Command::EndContainer);
1180        self.last_text_idx = None;
1181        state.selected != old_selected
1182    }
1183
1184    // ── multi-select ─────────────────────────────────────────────────
1185
1186    /// Render a multi-select list. Space toggles, Up/Down navigates.
1187    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> &mut Self {
1188        if state.items.is_empty() {
1189            return self;
1190        }
1191        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1192        let focused = self.register_focusable();
1193
1194        if focused {
1195            let mut consumed_indices = Vec::new();
1196            for (i, event) in self.events.iter().enumerate() {
1197                if self.consumed[i] {
1198                    continue;
1199                }
1200                if let Event::Key(key) = event {
1201                    if key.kind != KeyEventKind::Press {
1202                        continue;
1203                    }
1204                    match key.code {
1205                        KeyCode::Up | KeyCode::Char('k') => {
1206                            state.cursor = state.cursor.saturating_sub(1);
1207                            consumed_indices.push(i);
1208                        }
1209                        KeyCode::Down | KeyCode::Char('j') => {
1210                            state.cursor =
1211                                (state.cursor + 1).min(state.items.len().saturating_sub(1));
1212                            consumed_indices.push(i);
1213                        }
1214                        KeyCode::Char(' ') | KeyCode::Enter => {
1215                            state.toggle(state.cursor);
1216                            consumed_indices.push(i);
1217                        }
1218                        _ => {}
1219                    }
1220                }
1221            }
1222            for idx in consumed_indices {
1223                self.consumed[idx] = true;
1224            }
1225        }
1226
1227        let interaction_id = self.interaction_count;
1228        self.interaction_count += 1;
1229
1230        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1231            for (i, event) in self.events.iter().enumerate() {
1232                if self.consumed[i] {
1233                    continue;
1234                }
1235                if let Event::Mouse(mouse) = event {
1236                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1237                        continue;
1238                    }
1239                    let in_bounds = mouse.x >= rect.x
1240                        && mouse.x < rect.right()
1241                        && mouse.y >= rect.y
1242                        && mouse.y < rect.bottom();
1243                    if !in_bounds {
1244                        continue;
1245                    }
1246                    let clicked_idx = (mouse.y - rect.y) as usize;
1247                    if clicked_idx < state.items.len() {
1248                        state.toggle(clicked_idx);
1249                        state.cursor = clicked_idx;
1250                        self.consumed[i] = true;
1251                    }
1252                }
1253            }
1254        }
1255
1256        self.commands.push(Command::BeginContainer {
1257            direction: Direction::Column,
1258            gap: 0,
1259            align: Align::Start,
1260            justify: Justify::Start,
1261            border: None,
1262            border_sides: BorderSides::all(),
1263            border_style: Style::new().fg(self.theme.border),
1264            bg_color: None,
1265            padding: Padding::default(),
1266            margin: Margin::default(),
1267            constraints: Constraints::default(),
1268            title: None,
1269            grow: 0,
1270            group_name: None,
1271        });
1272
1273        for (idx, item) in state.items.iter().enumerate() {
1274            let checked = state.selected.contains(&idx);
1275            let marker = if checked { "[x]" } else { "[ ]" };
1276            let is_cursor = idx == state.cursor;
1277            let style = if is_cursor && focused {
1278                Style::new().bold().fg(self.theme.primary)
1279            } else if checked {
1280                Style::new().fg(self.theme.success)
1281            } else {
1282                Style::new().fg(self.theme.text)
1283            };
1284            let prefix = if is_cursor && focused { "▸ " } else { "  " };
1285            self.styled(format!("{prefix}{marker} {item}"), style);
1286        }
1287
1288        self.commands.push(Command::EndContainer);
1289        self.last_text_idx = None;
1290        self
1291    }
1292
1293    // ── tree ─────────────────────────────────────────────────────────
1294
1295    /// Render a tree view. Left/Right to collapse/expand, Up/Down to navigate.
1296    pub fn tree(&mut self, state: &mut TreeState) -> &mut Self {
1297        let entries = state.flatten();
1298        if entries.is_empty() {
1299            return self;
1300        }
1301        state.selected = state.selected.min(entries.len().saturating_sub(1));
1302        let focused = self.register_focusable();
1303
1304        if focused {
1305            let mut consumed_indices = Vec::new();
1306            for (i, event) in self.events.iter().enumerate() {
1307                if self.consumed[i] {
1308                    continue;
1309                }
1310                if let Event::Key(key) = event {
1311                    if key.kind != KeyEventKind::Press {
1312                        continue;
1313                    }
1314                    match key.code {
1315                        KeyCode::Up | KeyCode::Char('k') => {
1316                            state.selected = state.selected.saturating_sub(1);
1317                            consumed_indices.push(i);
1318                        }
1319                        KeyCode::Down | KeyCode::Char('j') => {
1320                            let max = state.flatten().len().saturating_sub(1);
1321                            state.selected = (state.selected + 1).min(max);
1322                            consumed_indices.push(i);
1323                        }
1324                        KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
1325                            state.toggle_at(state.selected);
1326                            consumed_indices.push(i);
1327                        }
1328                        KeyCode::Left => {
1329                            let entry = &entries[state.selected.min(entries.len() - 1)];
1330                            if entry.expanded {
1331                                state.toggle_at(state.selected);
1332                            }
1333                            consumed_indices.push(i);
1334                        }
1335                        _ => {}
1336                    }
1337                }
1338            }
1339            for idx in consumed_indices {
1340                self.consumed[idx] = true;
1341            }
1342        }
1343
1344        self.interaction_count += 1;
1345        self.commands.push(Command::BeginContainer {
1346            direction: Direction::Column,
1347            gap: 0,
1348            align: Align::Start,
1349            justify: Justify::Start,
1350            border: None,
1351            border_sides: BorderSides::all(),
1352            border_style: Style::new().fg(self.theme.border),
1353            bg_color: None,
1354            padding: Padding::default(),
1355            margin: Margin::default(),
1356            constraints: Constraints::default(),
1357            title: None,
1358            grow: 0,
1359            group_name: None,
1360        });
1361
1362        let entries = state.flatten();
1363        for (idx, entry) in entries.iter().enumerate() {
1364            let indent = "  ".repeat(entry.depth);
1365            let icon = if entry.is_leaf {
1366                "  "
1367            } else if entry.expanded {
1368                "▾ "
1369            } else {
1370                "▸ "
1371            };
1372            let is_selected = idx == state.selected;
1373            let style = if is_selected && focused {
1374                Style::new().bold().fg(self.theme.primary)
1375            } else if is_selected {
1376                Style::new().fg(self.theme.primary)
1377            } else {
1378                Style::new().fg(self.theme.text)
1379            };
1380            let cursor = if is_selected && focused { "▸" } else { " " };
1381            self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
1382        }
1383
1384        self.commands.push(Command::EndContainer);
1385        self.last_text_idx = None;
1386        self
1387    }
1388
1389    // ── virtual list ─────────────────────────────────────────────────
1390
1391    /// Render a virtual list that only renders visible items.
1392    ///
1393    /// `total` is the number of items. `visible_height` limits how many rows
1394    /// are rendered. The closure `f` is called only for visible indices.
1395    pub fn virtual_list(
1396        &mut self,
1397        state: &mut ListState,
1398        visible_height: usize,
1399        f: impl Fn(&mut Context, usize),
1400    ) -> &mut Self {
1401        if state.items.is_empty() {
1402            return self;
1403        }
1404        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1405        let focused = self.register_focusable();
1406
1407        if focused {
1408            let mut consumed_indices = Vec::new();
1409            for (i, event) in self.events.iter().enumerate() {
1410                if self.consumed[i] {
1411                    continue;
1412                }
1413                if let Event::Key(key) = event {
1414                    if key.kind != KeyEventKind::Press {
1415                        continue;
1416                    }
1417                    match key.code {
1418                        KeyCode::Up | KeyCode::Char('k') => {
1419                            state.selected = state.selected.saturating_sub(1);
1420                            consumed_indices.push(i);
1421                        }
1422                        KeyCode::Down | KeyCode::Char('j') => {
1423                            state.selected =
1424                                (state.selected + 1).min(state.items.len().saturating_sub(1));
1425                            consumed_indices.push(i);
1426                        }
1427                        KeyCode::PageUp => {
1428                            state.selected = state.selected.saturating_sub(visible_height);
1429                            consumed_indices.push(i);
1430                        }
1431                        KeyCode::PageDown => {
1432                            state.selected = (state.selected + visible_height)
1433                                .min(state.items.len().saturating_sub(1));
1434                            consumed_indices.push(i);
1435                        }
1436                        KeyCode::Home => {
1437                            state.selected = 0;
1438                            consumed_indices.push(i);
1439                        }
1440                        KeyCode::End => {
1441                            state.selected = state.items.len().saturating_sub(1);
1442                            consumed_indices.push(i);
1443                        }
1444                        _ => {}
1445                    }
1446                }
1447            }
1448            for idx in consumed_indices {
1449                self.consumed[idx] = true;
1450            }
1451        }
1452
1453        let start = if state.selected >= visible_height {
1454            state.selected - visible_height + 1
1455        } else {
1456            0
1457        };
1458        let end = (start + visible_height).min(state.items.len());
1459
1460        self.interaction_count += 1;
1461        self.commands.push(Command::BeginContainer {
1462            direction: Direction::Column,
1463            gap: 0,
1464            align: Align::Start,
1465            justify: Justify::Start,
1466            border: None,
1467            border_sides: BorderSides::all(),
1468            border_style: Style::new().fg(self.theme.border),
1469            bg_color: None,
1470            padding: Padding::default(),
1471            margin: Margin::default(),
1472            constraints: Constraints::default(),
1473            title: None,
1474            grow: 0,
1475            group_name: None,
1476        });
1477
1478        if start > 0 {
1479            self.styled(
1480                format!("  ↑ {} more", start),
1481                Style::new().fg(self.theme.text_dim).dim(),
1482            );
1483        }
1484
1485        for idx in start..end {
1486            f(self, idx);
1487        }
1488
1489        let remaining = state.items.len().saturating_sub(end);
1490        if remaining > 0 {
1491            self.styled(
1492                format!("  ↓ {} more", remaining),
1493                Style::new().fg(self.theme.text_dim).dim(),
1494            );
1495        }
1496
1497        self.commands.push(Command::EndContainer);
1498        self.last_text_idx = None;
1499        self
1500    }
1501
1502    // ── command palette ──────────────────────────────────────────────
1503
1504    /// Render a command palette overlay. Returns `Some(index)` when a command is selected.
1505    pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
1506        if !state.open {
1507            return None;
1508        }
1509
1510        let filtered = state.filtered_indices();
1511        let sel = state.selected().min(filtered.len().saturating_sub(1));
1512        state.set_selected(sel);
1513
1514        let mut consumed_indices = Vec::new();
1515        let mut result: Option<usize> = None;
1516
1517        for (i, event) in self.events.iter().enumerate() {
1518            if self.consumed[i] {
1519                continue;
1520            }
1521            if let Event::Key(key) = event {
1522                if key.kind != KeyEventKind::Press {
1523                    continue;
1524                }
1525                match key.code {
1526                    KeyCode::Esc => {
1527                        state.open = false;
1528                        consumed_indices.push(i);
1529                    }
1530                    KeyCode::Up => {
1531                        let s = state.selected();
1532                        state.set_selected(s.saturating_sub(1));
1533                        consumed_indices.push(i);
1534                    }
1535                    KeyCode::Down => {
1536                        let s = state.selected();
1537                        state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
1538                        consumed_indices.push(i);
1539                    }
1540                    KeyCode::Enter => {
1541                        if let Some(&cmd_idx) = filtered.get(state.selected()) {
1542                            result = Some(cmd_idx);
1543                            state.open = false;
1544                        }
1545                        consumed_indices.push(i);
1546                    }
1547                    KeyCode::Backspace => {
1548                        if state.cursor > 0 {
1549                            let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
1550                            let end_idx = byte_index_for_char(&state.input, state.cursor);
1551                            state.input.replace_range(byte_idx..end_idx, "");
1552                            state.cursor -= 1;
1553                            state.set_selected(0);
1554                        }
1555                        consumed_indices.push(i);
1556                    }
1557                    KeyCode::Char(ch) => {
1558                        let byte_idx = byte_index_for_char(&state.input, state.cursor);
1559                        state.input.insert(byte_idx, ch);
1560                        state.cursor += 1;
1561                        state.set_selected(0);
1562                        consumed_indices.push(i);
1563                    }
1564                    _ => {}
1565                }
1566            }
1567        }
1568        for idx in consumed_indices {
1569            self.consumed[idx] = true;
1570        }
1571
1572        let filtered = state.filtered_indices();
1573
1574        self.modal(|ui| {
1575            let primary = ui.theme.primary;
1576            ui.container()
1577                .border(Border::Rounded)
1578                .border_style(Style::new().fg(primary))
1579                .pad(1)
1580                .max_w(60)
1581                .col(|ui| {
1582                    let border_color = ui.theme.primary;
1583                    ui.bordered(Border::Rounded)
1584                        .border_style(Style::new().fg(border_color))
1585                        .px(1)
1586                        .col(|ui| {
1587                            let display = if state.input.is_empty() {
1588                                "Type to search...".to_string()
1589                            } else {
1590                                state.input.clone()
1591                            };
1592                            let style = if state.input.is_empty() {
1593                                Style::new().dim().fg(ui.theme.text_dim)
1594                            } else {
1595                                Style::new().fg(ui.theme.text)
1596                            };
1597                            ui.styled(display, style);
1598                        });
1599
1600                    for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
1601                        let cmd = &state.commands[cmd_idx];
1602                        let is_selected = list_idx == state.selected();
1603                        let style = if is_selected {
1604                            Style::new().bold().fg(ui.theme.primary)
1605                        } else {
1606                            Style::new().fg(ui.theme.text)
1607                        };
1608                        let prefix = if is_selected { "▸ " } else { "  " };
1609                        let shortcut_text = cmd
1610                            .shortcut
1611                            .as_deref()
1612                            .map(|s| format!("  ({s})"))
1613                            .unwrap_or_default();
1614                        ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
1615                        if is_selected && !cmd.description.is_empty() {
1616                            ui.styled(
1617                                format!("    {}", cmd.description),
1618                                Style::new().dim().fg(ui.theme.text_dim),
1619                            );
1620                        }
1621                    }
1622
1623                    if filtered.is_empty() {
1624                        ui.styled(
1625                            "  No matching commands",
1626                            Style::new().dim().fg(ui.theme.text_dim),
1627                        );
1628                    }
1629                });
1630        });
1631
1632        result
1633    }
1634
1635    // ── markdown ─────────────────────────────────────────────────────
1636
1637    /// Render a markdown string with basic formatting.
1638    ///
1639    /// Supports headers (`#`), bold (`**`), italic (`*`), inline code (`` ` ``),
1640    /// unordered lists (`-`/`*`), ordered lists (`1.`), and horizontal rules (`---`).
1641    pub fn markdown(&mut self, text: &str) -> &mut Self {
1642        self.commands.push(Command::BeginContainer {
1643            direction: Direction::Column,
1644            gap: 0,
1645            align: Align::Start,
1646            justify: Justify::Start,
1647            border: None,
1648            border_sides: BorderSides::all(),
1649            border_style: Style::new().fg(self.theme.border),
1650            bg_color: None,
1651            padding: Padding::default(),
1652            margin: Margin::default(),
1653            constraints: Constraints::default(),
1654            title: None,
1655            grow: 0,
1656            group_name: None,
1657        });
1658        self.interaction_count += 1;
1659
1660        let text_style = Style::new().fg(self.theme.text);
1661        let bold_style = Style::new().fg(self.theme.text).bold();
1662        let code_style = Style::new().fg(self.theme.accent);
1663
1664        for line in text.lines() {
1665            let trimmed = line.trim();
1666            if trimmed.is_empty() {
1667                self.text(" ");
1668                continue;
1669            }
1670            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1671                self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
1672                continue;
1673            }
1674            if let Some(heading) = trimmed.strip_prefix("### ") {
1675                self.styled(heading, Style::new().bold().fg(self.theme.accent));
1676            } else if let Some(heading) = trimmed.strip_prefix("## ") {
1677                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
1678            } else if let Some(heading) = trimmed.strip_prefix("# ") {
1679                self.styled(heading, Style::new().bold().fg(self.theme.primary));
1680            } else if let Some(item) = trimmed
1681                .strip_prefix("- ")
1682                .or_else(|| trimmed.strip_prefix("* "))
1683            {
1684                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
1685                if segs.len() <= 1 {
1686                    self.styled(format!("  • {item}"), text_style);
1687                } else {
1688                    self.line(|ui| {
1689                        ui.styled("  • ", text_style);
1690                        for (s, st) in segs {
1691                            ui.styled(s, st);
1692                        }
1693                    });
1694                }
1695            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
1696                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
1697                if parts.len() == 2 {
1698                    let segs =
1699                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
1700                    if segs.len() <= 1 {
1701                        self.styled(format!("  {}. {}", parts[0], parts[1]), text_style);
1702                    } else {
1703                        self.line(|ui| {
1704                            ui.styled(format!("  {}. ", parts[0]), text_style);
1705                            for (s, st) in segs {
1706                                ui.styled(s, st);
1707                            }
1708                        });
1709                    }
1710                } else {
1711                    self.text(trimmed);
1712                }
1713            } else if let Some(code) = trimmed.strip_prefix("```") {
1714                let _ = code;
1715                self.styled("  ┌─code─", Style::new().fg(self.theme.border).dim());
1716            } else {
1717                let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
1718                if segs.len() <= 1 {
1719                    self.styled(trimmed, text_style);
1720                } else {
1721                    self.line(|ui| {
1722                        for (s, st) in segs {
1723                            ui.styled(s, st);
1724                        }
1725                    });
1726                }
1727            }
1728        }
1729
1730        self.commands.push(Command::EndContainer);
1731        self.last_text_idx = None;
1732        self
1733    }
1734
1735    fn parse_inline_segments(
1736        text: &str,
1737        base: Style,
1738        bold: Style,
1739        code: Style,
1740    ) -> Vec<(String, Style)> {
1741        let mut segments: Vec<(String, Style)> = Vec::new();
1742        let mut current = String::new();
1743        let chars: Vec<char> = text.chars().collect();
1744        let mut i = 0;
1745        while i < chars.len() {
1746            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
1747                let rest: String = chars[i + 2..].iter().collect();
1748                if let Some(end) = rest.find("**") {
1749                    if !current.is_empty() {
1750                        segments.push((std::mem::take(&mut current), base));
1751                    }
1752                    let inner: String = rest[..end].to_string();
1753                    let char_count = inner.chars().count();
1754                    segments.push((inner, bold));
1755                    i += 2 + char_count + 2;
1756                    continue;
1757                }
1758            }
1759            if chars[i] == '*'
1760                && (i + 1 >= chars.len() || chars[i + 1] != '*')
1761                && (i == 0 || chars[i - 1] != '*')
1762            {
1763                let rest: String = chars[i + 1..].iter().collect();
1764                if let Some(end) = rest.find('*') {
1765                    if !current.is_empty() {
1766                        segments.push((std::mem::take(&mut current), base));
1767                    }
1768                    let inner: String = rest[..end].to_string();
1769                    let char_count = inner.chars().count();
1770                    segments.push((inner, base.italic()));
1771                    i += 1 + char_count + 1;
1772                    continue;
1773                }
1774            }
1775            if chars[i] == '`' {
1776                let rest: String = chars[i + 1..].iter().collect();
1777                if let Some(end) = rest.find('`') {
1778                    if !current.is_empty() {
1779                        segments.push((std::mem::take(&mut current), base));
1780                    }
1781                    let inner: String = rest[..end].to_string();
1782                    let char_count = inner.chars().count();
1783                    segments.push((inner, code));
1784                    i += 1 + char_count + 1;
1785                    continue;
1786                }
1787            }
1788            current.push(chars[i]);
1789            i += 1;
1790        }
1791        if !current.is_empty() {
1792            segments.push((current, base));
1793        }
1794        segments
1795    }
1796
1797    // ── key sequence ─────────────────────────────────────────────────
1798
1799    /// Check if a sequence of character keys was pressed across recent frames.
1800    ///
1801    /// Matches when each character in `seq` appears in consecutive unconsumed
1802    /// key events within this frame. For single-frame sequences only (e.g., "gg").
1803    pub fn key_seq(&self, seq: &str) -> bool {
1804        if seq.is_empty() {
1805            return false;
1806        }
1807        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1808            return false;
1809        }
1810        let target: Vec<char> = seq.chars().collect();
1811        let mut matched = 0;
1812        for (i, event) in self.events.iter().enumerate() {
1813            if self.consumed[i] {
1814                continue;
1815            }
1816            if let Event::Key(key) = event {
1817                if key.kind != KeyEventKind::Press {
1818                    continue;
1819                }
1820                if let KeyCode::Char(c) = key.code {
1821                    if c == target[matched] {
1822                        matched += 1;
1823                        if matched == target.len() {
1824                            return true;
1825                        }
1826                    } else {
1827                        matched = 0;
1828                        if c == target[0] {
1829                            matched = 1;
1830                        }
1831                    }
1832                }
1833            }
1834        }
1835        false
1836    }
1837
1838    /// Render a horizontal divider line.
1839    ///
1840    /// The line is drawn with the theme's border color and expands to fill the
1841    /// container width.
1842    pub fn separator(&mut self) -> &mut Self {
1843        self.commands.push(Command::Text {
1844            content: "─".repeat(200),
1845            style: Style::new().fg(self.theme.border).dim(),
1846            grow: 0,
1847            align: Align::Start,
1848            wrap: false,
1849            margin: Margin::default(),
1850            constraints: Constraints::default(),
1851        });
1852        self.last_text_idx = Some(self.commands.len() - 1);
1853        self
1854    }
1855
1856    /// Render a help bar showing keybinding hints.
1857    ///
1858    /// `bindings` is a slice of `(key, action)` pairs. Keys are rendered in the
1859    /// theme's primary color; actions in the dim text color. Pairs are separated
1860    /// by a `·` character.
1861    pub fn help(&mut self, bindings: &[(&str, &str)]) -> &mut Self {
1862        if bindings.is_empty() {
1863            return self;
1864        }
1865
1866        self.interaction_count += 1;
1867        self.commands.push(Command::BeginContainer {
1868            direction: Direction::Row,
1869            gap: 2,
1870            align: Align::Start,
1871            justify: Justify::Start,
1872            border: None,
1873            border_sides: BorderSides::all(),
1874            border_style: Style::new().fg(self.theme.border),
1875            bg_color: None,
1876            padding: Padding::default(),
1877            margin: Margin::default(),
1878            constraints: Constraints::default(),
1879            title: None,
1880            grow: 0,
1881            group_name: None,
1882        });
1883        for (idx, (key, action)) in bindings.iter().enumerate() {
1884            if idx > 0 {
1885                self.styled("·", Style::new().fg(self.theme.text_dim));
1886            }
1887            self.styled(*key, Style::new().bold().fg(self.theme.primary));
1888            self.styled(*action, Style::new().fg(self.theme.text_dim));
1889        }
1890        self.commands.push(Command::EndContainer);
1891        self.last_text_idx = None;
1892
1893        self
1894    }
1895
1896    // ── events ───────────────────────────────────────────────────────
1897
1898    /// Check if a character key was pressed this frame.
1899    ///
1900    /// Returns `true` if the key event has not been consumed by another widget.
1901    pub fn key(&self, c: char) -> bool {
1902        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1903            return false;
1904        }
1905        self.events.iter().enumerate().any(|(i, e)| {
1906            !self.consumed[i]
1907                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
1908        })
1909    }
1910
1911    /// Check if a specific key code was pressed this frame.
1912    ///
1913    /// Returns `true` if the key event has not been consumed by another widget.
1914    pub fn key_code(&self, code: KeyCode) -> bool {
1915        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1916            return false;
1917        }
1918        self.events.iter().enumerate().any(|(i, e)| {
1919            !self.consumed[i]
1920                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
1921        })
1922    }
1923
1924    /// Check if a character key was released this frame.
1925    ///
1926    /// Returns `true` if the key release event has not been consumed by another widget.
1927    pub fn key_release(&self, c: char) -> bool {
1928        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1929            return false;
1930        }
1931        self.events.iter().enumerate().any(|(i, e)| {
1932            !self.consumed[i]
1933                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
1934        })
1935    }
1936
1937    /// Check if a specific key code was released this frame.
1938    ///
1939    /// Returns `true` if the key release event has not been consumed by another widget.
1940    pub fn key_code_release(&self, code: KeyCode) -> bool {
1941        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1942            return false;
1943        }
1944        self.events.iter().enumerate().any(|(i, e)| {
1945            !self.consumed[i]
1946                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
1947        })
1948    }
1949
1950    /// Check for a character key press and consume the event, preventing other
1951    /// handlers from seeing it.
1952    ///
1953    /// Returns `true` if the key was found unconsumed and is now consumed.
1954    /// Unlike [`key()`](Self::key) which peeks without consuming, this claims
1955    /// exclusive ownership of the event.
1956    ///
1957    /// Call **after** widgets if you want widgets to have priority over your
1958    /// handler, or **before** widgets to intercept first.
1959    pub fn consume_key(&mut self, c: char) -> bool {
1960        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1961            return false;
1962        }
1963        for (i, event) in self.events.iter().enumerate() {
1964            if self.consumed[i] {
1965                continue;
1966            }
1967            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
1968            {
1969                self.consumed[i] = true;
1970                return true;
1971            }
1972        }
1973        false
1974    }
1975
1976    /// Check for a special key press and consume the event, preventing other
1977    /// handlers from seeing it.
1978    ///
1979    /// Returns `true` if the key was found unconsumed and is now consumed.
1980    /// Unlike [`key_code()`](Self::key_code) which peeks without consuming,
1981    /// this claims exclusive ownership of the event.
1982    ///
1983    /// Call **after** widgets if you want widgets to have priority over your
1984    /// handler, or **before** widgets to intercept first.
1985    pub fn consume_key_code(&mut self, code: KeyCode) -> bool {
1986        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
1987            return false;
1988        }
1989        for (i, event) in self.events.iter().enumerate() {
1990            if self.consumed[i] {
1991                continue;
1992            }
1993            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code) {
1994                self.consumed[i] = true;
1995                return true;
1996            }
1997        }
1998        false
1999    }
2000
2001    /// Check if a character key with specific modifiers was pressed this frame.
2002    ///
2003    /// Returns `true` if the key event has not been consumed by another widget.
2004    pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
2005        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2006            return false;
2007        }
2008        self.events.iter().enumerate().any(|(i, e)| {
2009            !self.consumed[i]
2010                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
2011        })
2012    }
2013
2014    /// Return the position of a left mouse button down event this frame, if any.
2015    ///
2016    /// Returns `None` if no unconsumed mouse-down event occurred.
2017    pub fn mouse_down(&self) -> Option<(u32, u32)> {
2018        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2019            return None;
2020        }
2021        self.events.iter().enumerate().find_map(|(i, event)| {
2022            if self.consumed[i] {
2023                return None;
2024            }
2025            if let Event::Mouse(mouse) = event {
2026                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2027                    return Some((mouse.x, mouse.y));
2028                }
2029            }
2030            None
2031        })
2032    }
2033
2034    /// Return the current mouse cursor position, if known.
2035    ///
2036    /// The position is updated on every mouse move or click event. Returns
2037    /// `None` until the first mouse event is received.
2038    pub fn mouse_pos(&self) -> Option<(u32, u32)> {
2039        self.mouse_pos
2040    }
2041
2042    /// Return the first unconsumed paste event text, if any.
2043    pub fn paste(&self) -> Option<&str> {
2044        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2045            return None;
2046        }
2047        self.events.iter().enumerate().find_map(|(i, event)| {
2048            if self.consumed[i] {
2049                return None;
2050            }
2051            if let Event::Paste(ref text) = event {
2052                return Some(text.as_str());
2053            }
2054            None
2055        })
2056    }
2057
2058    /// Check if an unconsumed scroll-up event occurred this frame.
2059    pub fn scroll_up(&self) -> bool {
2060        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2061            return false;
2062        }
2063        self.events.iter().enumerate().any(|(i, event)| {
2064            !self.consumed[i]
2065                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
2066        })
2067    }
2068
2069    /// Check if an unconsumed scroll-down event occurred this frame.
2070    pub fn scroll_down(&self) -> bool {
2071        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2072            return false;
2073        }
2074        self.events.iter().enumerate().any(|(i, event)| {
2075            !self.consumed[i]
2076                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
2077        })
2078    }
2079
2080    /// Signal the run loop to exit after this frame.
2081    pub fn quit(&mut self) {
2082        self.should_quit = true;
2083    }
2084
2085    /// Copy text to the system clipboard via OSC 52.
2086    ///
2087    /// Works transparently over SSH connections. The text is queued and
2088    /// written to the terminal after the current frame renders.
2089    ///
2090    /// Requires a terminal that supports OSC 52 (most modern terminals:
2091    /// Ghostty, kitty, WezTerm, iTerm2, Windows Terminal).
2092    pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
2093        self.clipboard_text = Some(text.into());
2094    }
2095
2096    /// Get the current theme.
2097    pub fn theme(&self) -> &Theme {
2098        &self.theme
2099    }
2100
2101    /// Change the theme for subsequent rendering.
2102    ///
2103    /// All widgets rendered after this call will use the new theme's colors.
2104    pub fn set_theme(&mut self, theme: Theme) {
2105        self.theme = theme;
2106    }
2107
2108    /// Check if dark mode is active.
2109    pub fn is_dark_mode(&self) -> bool {
2110        self.dark_mode
2111    }
2112
2113    /// Set dark mode. When true, dark_* style variants are applied.
2114    pub fn set_dark_mode(&mut self, dark: bool) {
2115        self.dark_mode = dark;
2116    }
2117
2118    // ── info ─────────────────────────────────────────────────────────
2119
2120    /// Get the terminal width in cells.
2121    pub fn width(&self) -> u32 {
2122        self.area_width
2123    }
2124
2125    /// Get the current terminal width breakpoint.
2126    ///
2127    /// Returns a [`Breakpoint`] based on the terminal width:
2128    /// - `Xs`: < 40 columns
2129    /// - `Sm`: 40-79 columns
2130    /// - `Md`: 80-119 columns
2131    /// - `Lg`: 120-159 columns
2132    /// - `Xl`: >= 160 columns
2133    ///
2134    /// Use this for responsive layouts that adapt to terminal size:
2135    /// ```no_run
2136    /// # use slt::{Breakpoint, Context};
2137    /// # slt::run(|ui: &mut Context| {
2138    /// match ui.breakpoint() {
2139    ///     Breakpoint::Xs | Breakpoint::Sm => {
2140    ///         ui.col(|ui| { ui.text("Stacked layout"); });
2141    ///     }
2142    ///     _ => {
2143    ///         ui.row(|ui| { ui.text("Side-by-side layout"); });
2144    ///     }
2145    /// }
2146    /// # });
2147    /// ```
2148    pub fn breakpoint(&self) -> Breakpoint {
2149        let w = self.area_width;
2150        if w < 40 {
2151            Breakpoint::Xs
2152        } else if w < 80 {
2153            Breakpoint::Sm
2154        } else if w < 120 {
2155            Breakpoint::Md
2156        } else if w < 160 {
2157            Breakpoint::Lg
2158        } else {
2159            Breakpoint::Xl
2160        }
2161    }
2162
2163    /// Get the terminal height in cells.
2164    pub fn height(&self) -> u32 {
2165        self.area_height
2166    }
2167
2168    /// Get the current tick count (increments each frame).
2169    ///
2170    /// Useful for animations and time-based logic. The tick starts at 0 and
2171    /// increases by 1 on every rendered frame.
2172    pub fn tick(&self) -> u64 {
2173        self.tick
2174    }
2175
2176    /// Return whether the layout debugger is enabled.
2177    ///
2178    /// The debugger is toggled with F12 at runtime.
2179    pub fn debug_enabled(&self) -> bool {
2180        self.debug
2181    }
2182}