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