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 style = if focused {
803            Style::new().fg(accent).bold()
804        } else if hovered {
805            Style::new().fg(accent)
806        } else {
807            Style::new().fg(base_fg)
808        };
809        let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
810        let hover_bg = if hovered || focused {
811            Some(base_bg)
812        } else {
813            None
814        };
815
816        self.commands.push(Command::BeginContainer {
817            direction: Direction::Row,
818            gap: 0,
819            align: Align::Start,
820            justify: Justify::Start,
821            border: None,
822            border_sides: BorderSides::all(),
823            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
824            bg_color: hover_bg,
825            padding: Padding::default(),
826            margin: Margin::default(),
827            constraints: Constraints::default(),
828            title: None,
829            grow: 0,
830            group_name: None,
831        });
832        self.styled(format!("[ {} ]", label.into()), style);
833        self.commands.push(Command::EndContainer);
834        self.last_text_idx = None;
835
836        response.clicked = activated;
837        response
838    }
839
840    /// Render a styled button variant. Returns `true` when activated.
841    ///
842    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
843    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
844    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
845        let focused = self.register_focusable();
846        let interaction_id = self.interaction_count;
847        self.interaction_count += 1;
848        let mut response = self.response_for(interaction_id);
849        response.focused = focused;
850
851        let mut activated = response.clicked;
852        if focused {
853            let mut consumed_indices = Vec::new();
854            for (i, event) in self.events.iter().enumerate() {
855                if let Event::Key(key) = event {
856                    if key.kind != KeyEventKind::Press {
857                        continue;
858                    }
859                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
860                        activated = true;
861                        consumed_indices.push(i);
862                    }
863                }
864            }
865            for index in consumed_indices {
866                self.consumed[index] = true;
867            }
868        }
869
870        let label = label.into();
871        let hover_bg = if response.hovered || focused {
872            Some(self.theme.surface_hover)
873        } else {
874            None
875        };
876        let (text, style, bg_color, border) = match variant {
877            ButtonVariant::Default => {
878                let style = if focused {
879                    Style::new().fg(self.theme.primary).bold()
880                } else if response.hovered {
881                    Style::new().fg(self.theme.accent)
882                } else {
883                    Style::new().fg(self.theme.text)
884                };
885                (format!("[ {label} ]"), style, hover_bg, None)
886            }
887            ButtonVariant::Primary => {
888                let style = if focused {
889                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
890                } else if response.hovered {
891                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
892                } else {
893                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
894                };
895                (format!(" {label} "), style, hover_bg, None)
896            }
897            ButtonVariant::Danger => {
898                let style = if focused {
899                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
900                } else if response.hovered {
901                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
902                } else {
903                    Style::new().fg(self.theme.bg).bg(self.theme.error)
904                };
905                (format!(" {label} "), style, hover_bg, None)
906            }
907            ButtonVariant::Outline => {
908                let border_color = if focused {
909                    self.theme.primary
910                } else if response.hovered {
911                    self.theme.accent
912                } else {
913                    self.theme.border
914                };
915                let style = if focused {
916                    Style::new().fg(self.theme.primary).bold()
917                } else if response.hovered {
918                    Style::new().fg(self.theme.accent)
919                } else {
920                    Style::new().fg(self.theme.text)
921                };
922                (
923                    format!(" {label} "),
924                    style,
925                    hover_bg,
926                    Some((Border::Rounded, Style::new().fg(border_color))),
927                )
928            }
929        };
930
931        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
932        self.commands.push(Command::BeginContainer {
933            direction: Direction::Row,
934            gap: 0,
935            align: Align::Center,
936            justify: Justify::Center,
937            border: if border.is_some() {
938                Some(btn_border)
939            } else {
940                None
941            },
942            border_sides: BorderSides::all(),
943            border_style: btn_border_style,
944            bg_color,
945            padding: Padding::default(),
946            margin: Margin::default(),
947            constraints: Constraints::default(),
948            title: None,
949            grow: 0,
950            group_name: None,
951        });
952        self.styled(text, style);
953        self.commands.push(Command::EndContainer);
954        self.last_text_idx = None;
955
956        response.clicked = activated;
957        response
958    }
959
960    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
961    ///
962    /// The checked state is shown with the theme's success color. When focused,
963    /// a `▸` prefix is added.
964    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
965        self.checkbox_colored(label, checked, &WidgetColors::new())
966    }
967
968    pub fn checkbox_colored(
969        &mut self,
970        label: impl Into<String>,
971        checked: &mut bool,
972        colors: &WidgetColors,
973    ) -> Response {
974        let focused = self.register_focusable();
975        let interaction_id = self.interaction_count;
976        self.interaction_count += 1;
977        let mut response = self.response_for(interaction_id);
978        response.focused = focused;
979        let mut should_toggle = response.clicked;
980        let old_checked = *checked;
981
982        if focused {
983            let mut consumed_indices = Vec::new();
984            for (i, event) in self.events.iter().enumerate() {
985                if let Event::Key(key) = event {
986                    if key.kind != KeyEventKind::Press {
987                        continue;
988                    }
989                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
990                        should_toggle = true;
991                        consumed_indices.push(i);
992                    }
993                }
994            }
995
996            for index in consumed_indices {
997                self.consumed[index] = true;
998            }
999        }
1000
1001        if should_toggle {
1002            *checked = !*checked;
1003        }
1004
1005        let hover_bg = if response.hovered || focused {
1006            Some(self.theme.surface_hover)
1007        } else {
1008            None
1009        };
1010        self.commands.push(Command::BeginContainer {
1011            direction: Direction::Row,
1012            gap: 1,
1013            align: Align::Start,
1014            justify: Justify::Start,
1015            border: None,
1016            border_sides: BorderSides::all(),
1017            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1018            bg_color: hover_bg,
1019            padding: Padding::default(),
1020            margin: Margin::default(),
1021            constraints: Constraints::default(),
1022            title: None,
1023            grow: 0,
1024            group_name: None,
1025        });
1026        let marker_style = if *checked {
1027            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1028        } else {
1029            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1030        };
1031        let marker = if *checked { "[x]" } else { "[ ]" };
1032        let label_text = label.into();
1033        if focused {
1034            self.styled(format!("▸ {marker}"), marker_style.bold());
1035            self.styled(
1036                label_text,
1037                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1038            );
1039        } else {
1040            self.styled(marker, marker_style);
1041            self.styled(
1042                label_text,
1043                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1044            );
1045        }
1046        self.commands.push(Command::EndContainer);
1047        self.last_text_idx = None;
1048
1049        response.changed = *checked != old_checked;
1050        response
1051    }
1052
1053    /// Render an on/off toggle switch.
1054    ///
1055    /// Toggles `on` when activated via Enter, Space, or click. The switch
1056    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
1057    /// dim color respectively.
1058    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
1059        self.toggle_colored(label, on, &WidgetColors::new())
1060    }
1061
1062    pub fn toggle_colored(
1063        &mut self,
1064        label: impl Into<String>,
1065        on: &mut bool,
1066        colors: &WidgetColors,
1067    ) -> Response {
1068        let focused = self.register_focusable();
1069        let interaction_id = self.interaction_count;
1070        self.interaction_count += 1;
1071        let mut response = self.response_for(interaction_id);
1072        response.focused = focused;
1073        let mut should_toggle = response.clicked;
1074        let old_on = *on;
1075
1076        if focused {
1077            let mut consumed_indices = Vec::new();
1078            for (i, event) in self.events.iter().enumerate() {
1079                if let Event::Key(key) = event {
1080                    if key.kind != KeyEventKind::Press {
1081                        continue;
1082                    }
1083                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1084                        should_toggle = true;
1085                        consumed_indices.push(i);
1086                    }
1087                }
1088            }
1089
1090            for index in consumed_indices {
1091                self.consumed[index] = true;
1092            }
1093        }
1094
1095        if should_toggle {
1096            *on = !*on;
1097        }
1098
1099        let hover_bg = if response.hovered || focused {
1100            Some(self.theme.surface_hover)
1101        } else {
1102            None
1103        };
1104        self.commands.push(Command::BeginContainer {
1105            direction: Direction::Row,
1106            gap: 2,
1107            align: Align::Start,
1108            justify: Justify::Start,
1109            border: None,
1110            border_sides: BorderSides::all(),
1111            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1112            bg_color: hover_bg,
1113            padding: Padding::default(),
1114            margin: Margin::default(),
1115            constraints: Constraints::default(),
1116            title: None,
1117            grow: 0,
1118            group_name: None,
1119        });
1120        let label_text = label.into();
1121        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1122        let switch_style = if *on {
1123            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1124        } else {
1125            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1126        };
1127        if focused {
1128            self.styled(
1129                format!("▸ {label_text}"),
1130                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1131            );
1132            self.styled(switch, switch_style.bold());
1133        } else {
1134            self.styled(
1135                label_text,
1136                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1137            );
1138            self.styled(switch, switch_style);
1139        }
1140        self.commands.push(Command::EndContainer);
1141        self.last_text_idx = None;
1142
1143        response.changed = *on != old_on;
1144        response
1145    }
1146
1147    // ── select / dropdown ─────────────────────────────────────────────
1148
1149    /// Render a dropdown select. Shows the selected item; expands on activation.
1150    ///
1151    /// Returns `true` when the selection changed this frame.
1152    pub fn select(&mut self, state: &mut SelectState) -> Response {
1153        self.select_colored(state, &WidgetColors::new())
1154    }
1155
1156    pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1157        if state.items.is_empty() {
1158            return Response::none();
1159        }
1160        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1161
1162        let focused = self.register_focusable();
1163        let interaction_id = self.interaction_count;
1164        self.interaction_count += 1;
1165        let mut response = self.response_for(interaction_id);
1166        response.focused = focused;
1167        let old_selected = state.selected;
1168
1169        if response.clicked {
1170            state.open = !state.open;
1171            if state.open {
1172                state.set_cursor(state.selected);
1173            }
1174        }
1175
1176        if focused {
1177            let mut consumed_indices = Vec::new();
1178            for (i, event) in self.events.iter().enumerate() {
1179                if self.consumed[i] {
1180                    continue;
1181                }
1182                if let Event::Key(key) = event {
1183                    if key.kind != KeyEventKind::Press {
1184                        continue;
1185                    }
1186                    if state.open {
1187                        match key.code {
1188                            KeyCode::Up
1189                            | KeyCode::Char('k')
1190                            | KeyCode::Down
1191                            | KeyCode::Char('j') => {
1192                                let mut cursor = state.cursor();
1193                                let _ = handle_vertical_nav(
1194                                    &mut cursor,
1195                                    state.items.len().saturating_sub(1),
1196                                    key.code.clone(),
1197                                );
1198                                state.set_cursor(cursor);
1199                                consumed_indices.push(i);
1200                            }
1201                            KeyCode::Enter | KeyCode::Char(' ') => {
1202                                state.selected = state.cursor();
1203                                state.open = false;
1204                                consumed_indices.push(i);
1205                            }
1206                            KeyCode::Esc => {
1207                                state.open = false;
1208                                consumed_indices.push(i);
1209                            }
1210                            _ => {}
1211                        }
1212                    } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1213                        state.open = true;
1214                        state.set_cursor(state.selected);
1215                        consumed_indices.push(i);
1216                    }
1217                }
1218            }
1219            for idx in consumed_indices {
1220                self.consumed[idx] = true;
1221            }
1222        }
1223
1224        let changed = state.selected != old_selected;
1225
1226        let border_color = if focused {
1227            colors.accent.unwrap_or(self.theme.primary)
1228        } else {
1229            colors.border.unwrap_or(self.theme.border)
1230        };
1231        let display_text = state
1232            .items
1233            .get(state.selected)
1234            .cloned()
1235            .unwrap_or_else(|| state.placeholder.clone());
1236        let arrow = if state.open { "▲" } else { "▼" };
1237
1238        self.commands.push(Command::BeginContainer {
1239            direction: Direction::Column,
1240            gap: 0,
1241            align: Align::Start,
1242            justify: Justify::Start,
1243            border: None,
1244            border_sides: BorderSides::all(),
1245            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1246            bg_color: None,
1247            padding: Padding::default(),
1248            margin: Margin::default(),
1249            constraints: Constraints::default(),
1250            title: None,
1251            grow: 0,
1252            group_name: None,
1253        });
1254
1255        self.render_select_trigger(&display_text, arrow, border_color, colors);
1256
1257        if state.open {
1258            self.render_select_dropdown(state, colors);
1259        }
1260
1261        self.commands.push(Command::EndContainer);
1262        self.last_text_idx = None;
1263        response.changed = changed;
1264        response
1265    }
1266
1267    fn render_select_trigger(
1268        &mut self,
1269        display_text: &str,
1270        arrow: &str,
1271        border_color: Color,
1272        colors: &WidgetColors,
1273    ) {
1274        self.commands.push(Command::BeginContainer {
1275            direction: Direction::Row,
1276            gap: 1,
1277            align: Align::Start,
1278            justify: Justify::Start,
1279            border: Some(Border::Rounded),
1280            border_sides: BorderSides::all(),
1281            border_style: Style::new().fg(border_color),
1282            bg_color: None,
1283            padding: Padding {
1284                left: 1,
1285                right: 1,
1286                top: 0,
1287                bottom: 0,
1288            },
1289            margin: Margin::default(),
1290            constraints: Constraints::default(),
1291            title: None,
1292            grow: 0,
1293            group_name: None,
1294        });
1295        self.interaction_count += 1;
1296        self.styled(
1297            display_text,
1298            Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1299        );
1300        self.styled(
1301            arrow,
1302            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1303        );
1304        self.commands.push(Command::EndContainer);
1305        self.last_text_idx = None;
1306    }
1307
1308    fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1309        for (idx, item) in state.items.iter().enumerate() {
1310            let is_cursor = idx == state.cursor();
1311            let style = if is_cursor {
1312                Style::new()
1313                    .bold()
1314                    .fg(colors.accent.unwrap_or(self.theme.primary))
1315            } else {
1316                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1317            };
1318            let prefix = if is_cursor { "▸ " } else { "  " };
1319            self.styled(format!("{prefix}{item}"), style);
1320        }
1321    }
1322
1323    // ── radio ────────────────────────────────────────────────────────
1324
1325    /// Render a radio button group. Returns `true` when selection changed.
1326    pub fn radio(&mut self, state: &mut RadioState) -> Response {
1327        self.radio_colored(state, &WidgetColors::new())
1328    }
1329
1330    pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1331        if state.items.is_empty() {
1332            return Response::none();
1333        }
1334        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1335        let focused = self.register_focusable();
1336        let old_selected = state.selected;
1337
1338        if focused {
1339            let mut consumed_indices = Vec::new();
1340            for (i, event) in self.events.iter().enumerate() {
1341                if self.consumed[i] {
1342                    continue;
1343                }
1344                if let Event::Key(key) = event {
1345                    if key.kind != KeyEventKind::Press {
1346                        continue;
1347                    }
1348                    match key.code {
1349                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1350                            let _ = handle_vertical_nav(
1351                                &mut state.selected,
1352                                state.items.len().saturating_sub(1),
1353                                key.code.clone(),
1354                            );
1355                            consumed_indices.push(i);
1356                        }
1357                        KeyCode::Enter | KeyCode::Char(' ') => {
1358                            consumed_indices.push(i);
1359                        }
1360                        _ => {}
1361                    }
1362                }
1363            }
1364            for idx in consumed_indices {
1365                self.consumed[idx] = true;
1366            }
1367        }
1368
1369        let interaction_id = self.interaction_count;
1370        self.interaction_count += 1;
1371        let mut response = self.response_for(interaction_id);
1372        response.focused = focused;
1373
1374        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1375            for (i, event) in self.events.iter().enumerate() {
1376                if self.consumed[i] {
1377                    continue;
1378                }
1379                if let Event::Mouse(mouse) = event {
1380                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1381                        continue;
1382                    }
1383                    let in_bounds = mouse.x >= rect.x
1384                        && mouse.x < rect.right()
1385                        && mouse.y >= rect.y
1386                        && mouse.y < rect.bottom();
1387                    if !in_bounds {
1388                        continue;
1389                    }
1390                    let clicked_idx = (mouse.y - rect.y) as usize;
1391                    if clicked_idx < state.items.len() {
1392                        state.selected = clicked_idx;
1393                        self.consumed[i] = true;
1394                    }
1395                }
1396            }
1397        }
1398
1399        self.commands.push(Command::BeginContainer {
1400            direction: Direction::Column,
1401            gap: 0,
1402            align: Align::Start,
1403            justify: Justify::Start,
1404            border: None,
1405            border_sides: BorderSides::all(),
1406            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1407            bg_color: None,
1408            padding: Padding::default(),
1409            margin: Margin::default(),
1410            constraints: Constraints::default(),
1411            title: None,
1412            grow: 0,
1413            group_name: None,
1414        });
1415
1416        for (idx, item) in state.items.iter().enumerate() {
1417            let is_selected = idx == state.selected;
1418            let marker = if is_selected { "●" } else { "○" };
1419            let style = if is_selected {
1420                if focused {
1421                    Style::new()
1422                        .bold()
1423                        .fg(colors.accent.unwrap_or(self.theme.primary))
1424                } else {
1425                    Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1426                }
1427            } else {
1428                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1429            };
1430            let prefix = if focused && idx == state.selected {
1431                "▸ "
1432            } else {
1433                "  "
1434            };
1435            self.styled(format!("{prefix}{marker} {item}"), style);
1436        }
1437
1438        self.commands.push(Command::EndContainer);
1439        self.last_text_idx = None;
1440        response.changed = state.selected != old_selected;
1441        response
1442    }
1443
1444    // ── multi-select ─────────────────────────────────────────────────
1445
1446    /// Render a multi-select list. Space toggles, Up/Down navigates.
1447    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1448        if state.items.is_empty() {
1449            return Response::none();
1450        }
1451        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1452        let focused = self.register_focusable();
1453        let old_selected = state.selected.clone();
1454
1455        if focused {
1456            let mut consumed_indices = Vec::new();
1457            for (i, event) in self.events.iter().enumerate() {
1458                if self.consumed[i] {
1459                    continue;
1460                }
1461                if let Event::Key(key) = event {
1462                    if key.kind != KeyEventKind::Press {
1463                        continue;
1464                    }
1465                    match key.code {
1466                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1467                            let _ = handle_vertical_nav(
1468                                &mut state.cursor,
1469                                state.items.len().saturating_sub(1),
1470                                key.code.clone(),
1471                            );
1472                            consumed_indices.push(i);
1473                        }
1474                        KeyCode::Char(' ') | KeyCode::Enter => {
1475                            state.toggle(state.cursor);
1476                            consumed_indices.push(i);
1477                        }
1478                        _ => {}
1479                    }
1480                }
1481            }
1482            for idx in consumed_indices {
1483                self.consumed[idx] = true;
1484            }
1485        }
1486
1487        let interaction_id = self.interaction_count;
1488        self.interaction_count += 1;
1489        let mut response = self.response_for(interaction_id);
1490        response.focused = focused;
1491
1492        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1493            for (i, event) in self.events.iter().enumerate() {
1494                if self.consumed[i] {
1495                    continue;
1496                }
1497                if let Event::Mouse(mouse) = event {
1498                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1499                        continue;
1500                    }
1501                    let in_bounds = mouse.x >= rect.x
1502                        && mouse.x < rect.right()
1503                        && mouse.y >= rect.y
1504                        && mouse.y < rect.bottom();
1505                    if !in_bounds {
1506                        continue;
1507                    }
1508                    let clicked_idx = (mouse.y - rect.y) as usize;
1509                    if clicked_idx < state.items.len() {
1510                        state.toggle(clicked_idx);
1511                        state.cursor = clicked_idx;
1512                        self.consumed[i] = true;
1513                    }
1514                }
1515            }
1516        }
1517
1518        self.commands.push(Command::BeginContainer {
1519            direction: Direction::Column,
1520            gap: 0,
1521            align: Align::Start,
1522            justify: Justify::Start,
1523            border: None,
1524            border_sides: BorderSides::all(),
1525            border_style: Style::new().fg(self.theme.border),
1526            bg_color: None,
1527            padding: Padding::default(),
1528            margin: Margin::default(),
1529            constraints: Constraints::default(),
1530            title: None,
1531            grow: 0,
1532            group_name: None,
1533        });
1534
1535        for (idx, item) in state.items.iter().enumerate() {
1536            let checked = state.selected.contains(&idx);
1537            let marker = if checked { "[x]" } else { "[ ]" };
1538            let is_cursor = idx == state.cursor;
1539            let style = if is_cursor && focused {
1540                Style::new().bold().fg(self.theme.primary)
1541            } else if checked {
1542                Style::new().fg(self.theme.success)
1543            } else {
1544                Style::new().fg(self.theme.text)
1545            };
1546            let prefix = if is_cursor && focused { "▸ " } else { "  " };
1547            self.styled(format!("{prefix}{marker} {item}"), style);
1548        }
1549
1550        self.commands.push(Command::EndContainer);
1551        self.last_text_idx = None;
1552        response.changed = state.selected != old_selected;
1553        response
1554    }
1555
1556    // ── tree ─────────────────────────────────────────────────────────
1557
1558    /// Render a tree view. Left/Right to collapse/expand, Up/Down to navigate.
1559    pub fn tree(&mut self, state: &mut TreeState) -> Response {
1560        let entries = state.flatten();
1561        if entries.is_empty() {
1562            return Response::none();
1563        }
1564        state.selected = state.selected.min(entries.len().saturating_sub(1));
1565        let old_selected = state.selected;
1566        let focused = self.register_focusable();
1567        let interaction_id = self.interaction_count;
1568        self.interaction_count += 1;
1569        let mut response = self.response_for(interaction_id);
1570        response.focused = focused;
1571        let mut changed = false;
1572
1573        if focused {
1574            let mut consumed_indices = Vec::new();
1575            for (i, event) in self.events.iter().enumerate() {
1576                if self.consumed[i] {
1577                    continue;
1578                }
1579                if let Event::Key(key) = event {
1580                    if key.kind != KeyEventKind::Press {
1581                        continue;
1582                    }
1583                    match key.code {
1584                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1585                            let max_index = state.flatten().len().saturating_sub(1);
1586                            let _ = handle_vertical_nav(
1587                                &mut state.selected,
1588                                max_index,
1589                                key.code.clone(),
1590                            );
1591                            changed = changed || state.selected != old_selected;
1592                            consumed_indices.push(i);
1593                        }
1594                        KeyCode::Right | KeyCode::Enter | KeyCode::Char(' ') => {
1595                            state.toggle_at(state.selected);
1596                            changed = true;
1597                            consumed_indices.push(i);
1598                        }
1599                        KeyCode::Left => {
1600                            let entry = &entries[state.selected.min(entries.len() - 1)];
1601                            if entry.expanded {
1602                                state.toggle_at(state.selected);
1603                                changed = true;
1604                            }
1605                            consumed_indices.push(i);
1606                        }
1607                        _ => {}
1608                    }
1609                }
1610            }
1611            for idx in consumed_indices {
1612                self.consumed[idx] = true;
1613            }
1614        }
1615
1616        self.commands.push(Command::BeginContainer {
1617            direction: Direction::Column,
1618            gap: 0,
1619            align: Align::Start,
1620            justify: Justify::Start,
1621            border: None,
1622            border_sides: BorderSides::all(),
1623            border_style: Style::new().fg(self.theme.border),
1624            bg_color: None,
1625            padding: Padding::default(),
1626            margin: Margin::default(),
1627            constraints: Constraints::default(),
1628            title: None,
1629            grow: 0,
1630            group_name: None,
1631        });
1632
1633        let entries = state.flatten();
1634        for (idx, entry) in entries.iter().enumerate() {
1635            let indent = "  ".repeat(entry.depth);
1636            let icon = if entry.is_leaf {
1637                "  "
1638            } else if entry.expanded {
1639                "▾ "
1640            } else {
1641                "▸ "
1642            };
1643            let is_selected = idx == state.selected;
1644            let style = if is_selected && focused {
1645                Style::new().bold().fg(self.theme.primary)
1646            } else if is_selected {
1647                Style::new().fg(self.theme.primary)
1648            } else {
1649                Style::new().fg(self.theme.text)
1650            };
1651            let cursor = if is_selected && focused { "▸" } else { " " };
1652            self.styled(format!("{cursor}{indent}{icon}{}", entry.label), style);
1653        }
1654
1655        self.commands.push(Command::EndContainer);
1656        self.last_text_idx = None;
1657        response.changed = changed || state.selected != old_selected;
1658        response
1659    }
1660
1661    // ── virtual list ─────────────────────────────────────────────────
1662
1663    /// Render a virtual list that only renders visible items.
1664    ///
1665    /// `total` is the number of items. `visible_height` limits how many rows
1666    /// are rendered. The closure `f` is called only for visible indices.
1667    pub fn virtual_list(
1668        &mut self,
1669        state: &mut ListState,
1670        visible_height: usize,
1671        f: impl Fn(&mut Context, usize),
1672    ) -> &mut Self {
1673        if state.items.is_empty() {
1674            return self;
1675        }
1676        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1677        let focused = self.register_focusable();
1678
1679        if focused {
1680            let mut consumed_indices = Vec::new();
1681            for (i, event) in self.events.iter().enumerate() {
1682                if self.consumed[i] {
1683                    continue;
1684                }
1685                if let Event::Key(key) = event {
1686                    if key.kind != KeyEventKind::Press {
1687                        continue;
1688                    }
1689                    match key.code {
1690                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1691                            let _ = handle_vertical_nav(
1692                                &mut state.selected,
1693                                state.items.len().saturating_sub(1),
1694                                key.code.clone(),
1695                            );
1696                            consumed_indices.push(i);
1697                        }
1698                        KeyCode::PageUp => {
1699                            state.selected = state.selected.saturating_sub(visible_height);
1700                            consumed_indices.push(i);
1701                        }
1702                        KeyCode::PageDown => {
1703                            state.selected = (state.selected + visible_height)
1704                                .min(state.items.len().saturating_sub(1));
1705                            consumed_indices.push(i);
1706                        }
1707                        KeyCode::Home => {
1708                            state.selected = 0;
1709                            consumed_indices.push(i);
1710                        }
1711                        KeyCode::End => {
1712                            state.selected = state.items.len().saturating_sub(1);
1713                            consumed_indices.push(i);
1714                        }
1715                        _ => {}
1716                    }
1717                }
1718            }
1719            for idx in consumed_indices {
1720                self.consumed[idx] = true;
1721            }
1722        }
1723
1724        let start = if state.selected >= visible_height {
1725            state.selected - visible_height + 1
1726        } else {
1727            0
1728        };
1729        let end = (start + visible_height).min(state.items.len());
1730
1731        self.interaction_count += 1;
1732        self.commands.push(Command::BeginContainer {
1733            direction: Direction::Column,
1734            gap: 0,
1735            align: Align::Start,
1736            justify: Justify::Start,
1737            border: None,
1738            border_sides: BorderSides::all(),
1739            border_style: Style::new().fg(self.theme.border),
1740            bg_color: None,
1741            padding: Padding::default(),
1742            margin: Margin::default(),
1743            constraints: Constraints::default(),
1744            title: None,
1745            grow: 0,
1746            group_name: None,
1747        });
1748
1749        if start > 0 {
1750            self.styled(
1751                format!("  ↑ {} more", start),
1752                Style::new().fg(self.theme.text_dim).dim(),
1753            );
1754        }
1755
1756        for idx in start..end {
1757            f(self, idx);
1758        }
1759
1760        let remaining = state.items.len().saturating_sub(end);
1761        if remaining > 0 {
1762            self.styled(
1763                format!("  ↓ {} more", remaining),
1764                Style::new().fg(self.theme.text_dim).dim(),
1765            );
1766        }
1767
1768        self.commands.push(Command::EndContainer);
1769        self.last_text_idx = None;
1770        self
1771    }
1772
1773    // ── command palette ──────────────────────────────────────────────
1774
1775    /// Render a command palette overlay. Returns `Some(index)` when a command is selected.
1776    pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Option<usize> {
1777        if !state.open {
1778            return None;
1779        }
1780
1781        let filtered = state.filtered_indices();
1782        let sel = state.selected().min(filtered.len().saturating_sub(1));
1783        state.set_selected(sel);
1784
1785        let mut consumed_indices = Vec::new();
1786        let mut result: Option<usize> = None;
1787
1788        for (i, event) in self.events.iter().enumerate() {
1789            if self.consumed[i] {
1790                continue;
1791            }
1792            if let Event::Key(key) = event {
1793                if key.kind != KeyEventKind::Press {
1794                    continue;
1795                }
1796                match key.code {
1797                    KeyCode::Esc => {
1798                        state.open = false;
1799                        consumed_indices.push(i);
1800                    }
1801                    KeyCode::Up => {
1802                        let s = state.selected();
1803                        state.set_selected(s.saturating_sub(1));
1804                        consumed_indices.push(i);
1805                    }
1806                    KeyCode::Down => {
1807                        let s = state.selected();
1808                        state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
1809                        consumed_indices.push(i);
1810                    }
1811                    KeyCode::Enter => {
1812                        if let Some(&cmd_idx) = filtered.get(state.selected()) {
1813                            result = Some(cmd_idx);
1814                            state.open = false;
1815                        }
1816                        consumed_indices.push(i);
1817                    }
1818                    KeyCode::Backspace => {
1819                        if state.cursor > 0 {
1820                            let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
1821                            let end_idx = byte_index_for_char(&state.input, state.cursor);
1822                            state.input.replace_range(byte_idx..end_idx, "");
1823                            state.cursor -= 1;
1824                            state.set_selected(0);
1825                        }
1826                        consumed_indices.push(i);
1827                    }
1828                    KeyCode::Char(ch) => {
1829                        let byte_idx = byte_index_for_char(&state.input, state.cursor);
1830                        state.input.insert(byte_idx, ch);
1831                        state.cursor += 1;
1832                        state.set_selected(0);
1833                        consumed_indices.push(i);
1834                    }
1835                    _ => {}
1836                }
1837            }
1838        }
1839        for idx in consumed_indices {
1840            self.consumed[idx] = true;
1841        }
1842
1843        let filtered = state.filtered_indices();
1844
1845        self.modal(|ui| {
1846            let primary = ui.theme.primary;
1847            ui.container()
1848                .border(Border::Rounded)
1849                .border_style(Style::new().fg(primary))
1850                .pad(1)
1851                .max_w(60)
1852                .col(|ui| {
1853                    let border_color = ui.theme.primary;
1854                    ui.bordered(Border::Rounded)
1855                        .border_style(Style::new().fg(border_color))
1856                        .px(1)
1857                        .col(|ui| {
1858                            let display = if state.input.is_empty() {
1859                                "Type to search...".to_string()
1860                            } else {
1861                                state.input.clone()
1862                            };
1863                            let style = if state.input.is_empty() {
1864                                Style::new().dim().fg(ui.theme.text_dim)
1865                            } else {
1866                                Style::new().fg(ui.theme.text)
1867                            };
1868                            ui.styled(display, style);
1869                        });
1870
1871                    for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
1872                        let cmd = &state.commands[cmd_idx];
1873                        let is_selected = list_idx == state.selected();
1874                        let style = if is_selected {
1875                            Style::new().bold().fg(ui.theme.primary)
1876                        } else {
1877                            Style::new().fg(ui.theme.text)
1878                        };
1879                        let prefix = if is_selected { "▸ " } else { "  " };
1880                        let shortcut_text = cmd
1881                            .shortcut
1882                            .as_deref()
1883                            .map(|s| format!("  ({s})"))
1884                            .unwrap_or_default();
1885                        ui.styled(format!("{prefix}{}{shortcut_text}", cmd.label), style);
1886                        if is_selected && !cmd.description.is_empty() {
1887                            ui.styled(
1888                                format!("    {}", cmd.description),
1889                                Style::new().dim().fg(ui.theme.text_dim),
1890                            );
1891                        }
1892                    }
1893
1894                    if filtered.is_empty() {
1895                        ui.styled(
1896                            "  No matching commands",
1897                            Style::new().dim().fg(ui.theme.text_dim),
1898                        );
1899                    }
1900                });
1901        });
1902
1903        result
1904    }
1905
1906    // ── markdown ─────────────────────────────────────────────────────
1907
1908    /// Render a markdown string with basic formatting.
1909    ///
1910    /// Supports headers (`#`), bold (`**`), italic (`*`), inline code (`` ` ``),
1911    /// unordered lists (`-`/`*`), ordered lists (`1.`), and horizontal rules (`---`).
1912    pub fn markdown(&mut self, text: &str) -> Response {
1913        self.commands.push(Command::BeginContainer {
1914            direction: Direction::Column,
1915            gap: 0,
1916            align: Align::Start,
1917            justify: Justify::Start,
1918            border: None,
1919            border_sides: BorderSides::all(),
1920            border_style: Style::new().fg(self.theme.border),
1921            bg_color: None,
1922            padding: Padding::default(),
1923            margin: Margin::default(),
1924            constraints: Constraints::default(),
1925            title: None,
1926            grow: 0,
1927            group_name: None,
1928        });
1929        self.interaction_count += 1;
1930
1931        let text_style = Style::new().fg(self.theme.text);
1932        let bold_style = Style::new().fg(self.theme.text).bold();
1933        let code_style = Style::new().fg(self.theme.accent);
1934
1935        for line in text.lines() {
1936            let trimmed = line.trim();
1937            if trimmed.is_empty() {
1938                self.text(" ");
1939                continue;
1940            }
1941            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1942                self.styled("─".repeat(40), Style::new().fg(self.theme.border).dim());
1943                continue;
1944            }
1945            if let Some(heading) = trimmed.strip_prefix("### ") {
1946                self.styled(heading, Style::new().bold().fg(self.theme.accent));
1947            } else if let Some(heading) = trimmed.strip_prefix("## ") {
1948                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
1949            } else if let Some(heading) = trimmed.strip_prefix("# ") {
1950                self.styled(heading, Style::new().bold().fg(self.theme.primary));
1951            } else if let Some(item) = trimmed
1952                .strip_prefix("- ")
1953                .or_else(|| trimmed.strip_prefix("* "))
1954            {
1955                let segs = Self::parse_inline_segments(item, text_style, bold_style, code_style);
1956                if segs.len() <= 1 {
1957                    self.styled(format!("  • {item}"), text_style);
1958                } else {
1959                    self.line(|ui| {
1960                        ui.styled("  • ", text_style);
1961                        for (s, st) in segs {
1962                            ui.styled(s, st);
1963                        }
1964                    });
1965                }
1966            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
1967                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
1968                if parts.len() == 2 {
1969                    let segs =
1970                        Self::parse_inline_segments(parts[1], text_style, bold_style, code_style);
1971                    if segs.len() <= 1 {
1972                        self.styled(format!("  {}. {}", parts[0], parts[1]), text_style);
1973                    } else {
1974                        self.line(|ui| {
1975                            ui.styled(format!("  {}. ", parts[0]), text_style);
1976                            for (s, st) in segs {
1977                                ui.styled(s, st);
1978                            }
1979                        });
1980                    }
1981                } else {
1982                    self.text(trimmed);
1983                }
1984            } else if let Some(code) = trimmed.strip_prefix("```") {
1985                let _ = code;
1986                self.styled("  ┌─code─", Style::new().fg(self.theme.border).dim());
1987            } else {
1988                let segs = Self::parse_inline_segments(trimmed, text_style, bold_style, code_style);
1989                if segs.len() <= 1 {
1990                    self.styled(trimmed, text_style);
1991                } else {
1992                    self.line(|ui| {
1993                        for (s, st) in segs {
1994                            ui.styled(s, st);
1995                        }
1996                    });
1997                }
1998            }
1999        }
2000
2001        self.commands.push(Command::EndContainer);
2002        self.last_text_idx = None;
2003        Response::none()
2004    }
2005
2006    pub(crate) fn parse_inline_segments(
2007        text: &str,
2008        base: Style,
2009        bold: Style,
2010        code: Style,
2011    ) -> Vec<(String, Style)> {
2012        let mut segments: Vec<(String, Style)> = Vec::new();
2013        let mut current = String::new();
2014        let chars: Vec<char> = text.chars().collect();
2015        let mut i = 0;
2016        while i < chars.len() {
2017            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2018                let rest: String = chars[i + 2..].iter().collect();
2019                if let Some(end) = rest.find("**") {
2020                    if !current.is_empty() {
2021                        segments.push((std::mem::take(&mut current), base));
2022                    }
2023                    let inner: String = rest[..end].to_string();
2024                    let char_count = inner.chars().count();
2025                    segments.push((inner, bold));
2026                    i += 2 + char_count + 2;
2027                    continue;
2028                }
2029            }
2030            if chars[i] == '*'
2031                && (i + 1 >= chars.len() || chars[i + 1] != '*')
2032                && (i == 0 || chars[i - 1] != '*')
2033            {
2034                let rest: String = chars[i + 1..].iter().collect();
2035                if let Some(end) = rest.find('*') {
2036                    if !current.is_empty() {
2037                        segments.push((std::mem::take(&mut current), base));
2038                    }
2039                    let inner: String = rest[..end].to_string();
2040                    let char_count = inner.chars().count();
2041                    segments.push((inner, base.italic()));
2042                    i += 1 + char_count + 1;
2043                    continue;
2044                }
2045            }
2046            if chars[i] == '`' {
2047                let rest: String = chars[i + 1..].iter().collect();
2048                if let Some(end) = rest.find('`') {
2049                    if !current.is_empty() {
2050                        segments.push((std::mem::take(&mut current), base));
2051                    }
2052                    let inner: String = rest[..end].to_string();
2053                    let char_count = inner.chars().count();
2054                    segments.push((inner, code));
2055                    i += 1 + char_count + 1;
2056                    continue;
2057                }
2058            }
2059            current.push(chars[i]);
2060            i += 1;
2061        }
2062        if !current.is_empty() {
2063            segments.push((current, base));
2064        }
2065        segments
2066    }
2067
2068    // ── key sequence ─────────────────────────────────────────────────
2069
2070    /// Check if a sequence of character keys was pressed across recent frames.
2071    ///
2072    /// Matches when each character in `seq` appears in consecutive unconsumed
2073    /// key events within this frame. For single-frame sequences only (e.g., "gg").
2074    pub fn key_seq(&self, seq: &str) -> bool {
2075        if seq.is_empty() {
2076            return false;
2077        }
2078        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2079            return false;
2080        }
2081        let target: Vec<char> = seq.chars().collect();
2082        let mut matched = 0;
2083        for (i, event) in self.events.iter().enumerate() {
2084            if self.consumed[i] {
2085                continue;
2086            }
2087            if let Event::Key(key) = event {
2088                if key.kind != KeyEventKind::Press {
2089                    continue;
2090                }
2091                if let KeyCode::Char(c) = key.code {
2092                    if c == target[matched] {
2093                        matched += 1;
2094                        if matched == target.len() {
2095                            return true;
2096                        }
2097                    } else {
2098                        matched = 0;
2099                        if c == target[0] {
2100                            matched = 1;
2101                        }
2102                    }
2103                }
2104            }
2105        }
2106        false
2107    }
2108
2109    /// Render a horizontal divider line.
2110    ///
2111    /// The line is drawn with the theme's border color and expands to fill the
2112    /// container width.
2113    pub fn separator(&mut self) -> Response {
2114        self.commands.push(Command::Text {
2115            content: "─".repeat(200),
2116            style: Style::new().fg(self.theme.border).dim(),
2117            grow: 0,
2118            align: Align::Start,
2119            wrap: false,
2120            margin: Margin::default(),
2121            constraints: Constraints::default(),
2122        });
2123        self.last_text_idx = Some(self.commands.len() - 1);
2124        Response::none()
2125    }
2126
2127    /// Render a help bar showing keybinding hints.
2128    ///
2129    /// `bindings` is a slice of `(key, action)` pairs. Keys are rendered in the
2130    /// theme's primary color; actions in the dim text color. Pairs are separated
2131    /// by a `·` character.
2132    pub fn help(&mut self, bindings: &[(&str, &str)]) -> Response {
2133        if bindings.is_empty() {
2134            return Response::none();
2135        }
2136
2137        self.interaction_count += 1;
2138        self.commands.push(Command::BeginContainer {
2139            direction: Direction::Row,
2140            gap: 2,
2141            align: Align::Start,
2142            justify: Justify::Start,
2143            border: None,
2144            border_sides: BorderSides::all(),
2145            border_style: Style::new().fg(self.theme.border),
2146            bg_color: None,
2147            padding: Padding::default(),
2148            margin: Margin::default(),
2149            constraints: Constraints::default(),
2150            title: None,
2151            grow: 0,
2152            group_name: None,
2153        });
2154        for (idx, (key, action)) in bindings.iter().enumerate() {
2155            if idx > 0 {
2156                self.styled("·", Style::new().fg(self.theme.text_dim));
2157            }
2158            self.styled(*key, Style::new().bold().fg(self.theme.primary));
2159            self.styled(*action, Style::new().fg(self.theme.text_dim));
2160        }
2161        self.commands.push(Command::EndContainer);
2162        self.last_text_idx = None;
2163
2164        Response::none()
2165    }
2166
2167    // ── events ───────────────────────────────────────────────────────
2168
2169    /// Check if a character key was pressed this frame.
2170    ///
2171    /// Returns `true` if the key event has not been consumed by another widget.
2172    pub fn key(&self, c: char) -> bool {
2173        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2174            return false;
2175        }
2176        self.events.iter().enumerate().any(|(i, e)| {
2177            !self.consumed[i]
2178                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2179        })
2180    }
2181
2182    /// Check if a specific key code was pressed this frame.
2183    ///
2184    /// Returns `true` if the key event has not been consumed by another widget.
2185    pub fn key_code(&self, code: KeyCode) -> bool {
2186        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2187            return false;
2188        }
2189        self.events.iter().enumerate().any(|(i, e)| {
2190            !self.consumed[i]
2191                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
2192        })
2193    }
2194
2195    /// Check if a character key was released this frame.
2196    ///
2197    /// Returns `true` if the key release event has not been consumed by another widget.
2198    pub fn key_release(&self, c: char) -> bool {
2199        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2200            return false;
2201        }
2202        self.events.iter().enumerate().any(|(i, e)| {
2203            !self.consumed[i]
2204                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
2205        })
2206    }
2207
2208    /// Check if a specific key code was released this frame.
2209    ///
2210    /// Returns `true` if the key release event has not been consumed by another widget.
2211    pub fn key_code_release(&self, code: KeyCode) -> bool {
2212        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2213            return false;
2214        }
2215        self.events.iter().enumerate().any(|(i, e)| {
2216            !self.consumed[i]
2217                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
2218        })
2219    }
2220
2221    /// Check for a character key press and consume the event, preventing other
2222    /// handlers from seeing it.
2223    ///
2224    /// Returns `true` if the key was found unconsumed and is now consumed.
2225    /// Unlike [`key()`](Self::key) which peeks without consuming, this claims
2226    /// exclusive ownership of the event.
2227    ///
2228    /// Call **after** widgets if you want widgets to have priority over your
2229    /// handler, or **before** widgets to intercept first.
2230    pub fn consume_key(&mut self, c: char) -> bool {
2231        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2232            return false;
2233        }
2234        for (i, event) in self.events.iter().enumerate() {
2235            if self.consumed[i] {
2236                continue;
2237            }
2238            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
2239            {
2240                self.consumed[i] = true;
2241                return true;
2242            }
2243        }
2244        false
2245    }
2246
2247    /// Check for a special key press and consume the event, preventing other
2248    /// handlers from seeing it.
2249    ///
2250    /// Returns `true` if the key was found unconsumed and is now consumed.
2251    /// Unlike [`key_code()`](Self::key_code) which peeks without consuming,
2252    /// this claims exclusive ownership of the event.
2253    ///
2254    /// Call **after** widgets if you want widgets to have priority over your
2255    /// handler, or **before** widgets to intercept first.
2256    pub fn consume_key_code(&mut self, code: KeyCode) -> bool {
2257        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2258            return false;
2259        }
2260        for (i, event) in self.events.iter().enumerate() {
2261            if self.consumed[i] {
2262                continue;
2263            }
2264            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code) {
2265                self.consumed[i] = true;
2266                return true;
2267            }
2268        }
2269        false
2270    }
2271
2272    /// Check if a character key with specific modifiers was pressed this frame.
2273    ///
2274    /// Returns `true` if the key event has not been consumed by another widget.
2275    pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
2276        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2277            return false;
2278        }
2279        self.events.iter().enumerate().any(|(i, e)| {
2280            !self.consumed[i]
2281                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
2282        })
2283    }
2284
2285    /// Return the position of a left mouse button down event this frame, if any.
2286    ///
2287    /// Returns `None` if no unconsumed mouse-down event occurred.
2288    pub fn mouse_down(&self) -> Option<(u32, u32)> {
2289        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2290            return None;
2291        }
2292        self.events.iter().enumerate().find_map(|(i, event)| {
2293            if self.consumed[i] {
2294                return None;
2295            }
2296            if let Event::Mouse(mouse) = event {
2297                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
2298                    return Some((mouse.x, mouse.y));
2299                }
2300            }
2301            None
2302        })
2303    }
2304
2305    /// Return the current mouse cursor position, if known.
2306    ///
2307    /// The position is updated on every mouse move or click event. Returns
2308    /// `None` until the first mouse event is received.
2309    pub fn mouse_pos(&self) -> Option<(u32, u32)> {
2310        self.mouse_pos
2311    }
2312
2313    /// Return the first unconsumed paste event text, if any.
2314    pub fn paste(&self) -> Option<&str> {
2315        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2316            return None;
2317        }
2318        self.events.iter().enumerate().find_map(|(i, event)| {
2319            if self.consumed[i] {
2320                return None;
2321            }
2322            if let Event::Paste(ref text) = event {
2323                return Some(text.as_str());
2324            }
2325            None
2326        })
2327    }
2328
2329    /// Check if an unconsumed scroll-up event occurred this frame.
2330    pub fn scroll_up(&self) -> bool {
2331        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2332            return false;
2333        }
2334        self.events.iter().enumerate().any(|(i, event)| {
2335            !self.consumed[i]
2336                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
2337        })
2338    }
2339
2340    /// Check if an unconsumed scroll-down event occurred this frame.
2341    pub fn scroll_down(&self) -> bool {
2342        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2343            return false;
2344        }
2345        self.events.iter().enumerate().any(|(i, event)| {
2346            !self.consumed[i]
2347                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
2348        })
2349    }
2350
2351    /// Signal the run loop to exit after this frame.
2352    pub fn quit(&mut self) {
2353        self.should_quit = true;
2354    }
2355
2356    /// Copy text to the system clipboard via OSC 52.
2357    ///
2358    /// Works transparently over SSH connections. The text is queued and
2359    /// written to the terminal after the current frame renders.
2360    ///
2361    /// Requires a terminal that supports OSC 52 (most modern terminals:
2362    /// Ghostty, kitty, WezTerm, iTerm2, Windows Terminal).
2363    pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
2364        self.clipboard_text = Some(text.into());
2365    }
2366
2367    /// Get the current theme.
2368    pub fn theme(&self) -> &Theme {
2369        &self.theme
2370    }
2371
2372    /// Change the theme for subsequent rendering.
2373    ///
2374    /// All widgets rendered after this call will use the new theme's colors.
2375    pub fn set_theme(&mut self, theme: Theme) {
2376        self.theme = theme;
2377    }
2378
2379    /// Check if dark mode is active.
2380    pub fn is_dark_mode(&self) -> bool {
2381        self.dark_mode
2382    }
2383
2384    /// Set dark mode. When true, dark_* style variants are applied.
2385    pub fn set_dark_mode(&mut self, dark: bool) {
2386        self.dark_mode = dark;
2387    }
2388
2389    // ── info ─────────────────────────────────────────────────────────
2390
2391    /// Get the terminal width in cells.
2392    pub fn width(&self) -> u32 {
2393        self.area_width
2394    }
2395
2396    /// Get the current terminal width breakpoint.
2397    ///
2398    /// Returns a [`Breakpoint`] based on the terminal width:
2399    /// - `Xs`: < 40 columns
2400    /// - `Sm`: 40-79 columns
2401    /// - `Md`: 80-119 columns
2402    /// - `Lg`: 120-159 columns
2403    /// - `Xl`: >= 160 columns
2404    ///
2405    /// Use this for responsive layouts that adapt to terminal size:
2406    /// ```no_run
2407    /// # use slt::{Breakpoint, Context};
2408    /// # slt::run(|ui: &mut Context| {
2409    /// match ui.breakpoint() {
2410    ///     Breakpoint::Xs | Breakpoint::Sm => {
2411    ///         ui.col(|ui| { ui.text("Stacked layout"); });
2412    ///     }
2413    ///     _ => {
2414    ///         ui.row(|ui| { ui.text("Side-by-side layout"); });
2415    ///     }
2416    /// }
2417    /// # });
2418    /// ```
2419    pub fn breakpoint(&self) -> Breakpoint {
2420        let w = self.area_width;
2421        if w < 40 {
2422            Breakpoint::Xs
2423        } else if w < 80 {
2424            Breakpoint::Sm
2425        } else if w < 120 {
2426            Breakpoint::Md
2427        } else if w < 160 {
2428            Breakpoint::Lg
2429        } else {
2430            Breakpoint::Xl
2431        }
2432    }
2433
2434    /// Get the terminal height in cells.
2435    pub fn height(&self) -> u32 {
2436        self.area_height
2437    }
2438
2439    /// Get the current tick count (increments each frame).
2440    ///
2441    /// Useful for animations and time-based logic. The tick starts at 0 and
2442    /// increases by 1 on every rendered frame.
2443    pub fn tick(&self) -> u64 {
2444        self.tick
2445    }
2446
2447    /// Return whether the layout debugger is enabled.
2448    ///
2449    /// The debugger is toggled with F12 at runtime.
2450    pub fn debug_enabled(&self) -> bool {
2451        self.debug
2452    }
2453}