Skip to main content

slt/context/
widgets_interactive.rs

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