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