Skip to main content

slt/context/
widgets_interactive.rs

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