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