Skip to main content

slt/context/
widgets_interactive.rs

1use super::*;
2use crate::RichLogState;
3
4mod tree_widgets;
5
6/// Inline markdown segment — text, link, or image reference.
7enum MdInline {
8    Text(String),
9    Link { text: String, url: String },
10    Image { alt: String },
11}
12
13impl Context {
14    /// Render children in a fixed grid with the given number of columns.
15    ///
16    /// Children are placed left-to-right, top-to-bottom. Each cell has equal
17    /// width (`area_width / cols`). Rows wrap automatically.
18    ///
19    /// # Example
20    ///
21    /// ```no_run
22    /// # slt::run(|ui: &mut slt::Context| {
23    /// ui.grid(3, |ui| {
24    ///     for i in 0..9 {
25    ///         ui.text(format!("Cell {i}"));
26    ///     }
27    /// });
28    /// # });
29    /// ```
30    pub fn grid(&mut self, cols: u32, f: impl FnOnce(&mut Context)) -> Response {
31        slt_assert(cols > 0, "grid() requires at least 1 column");
32        let interaction_id = self.next_interaction_id();
33        let border = self.theme.border;
34
35        self.commands.push(Command::BeginContainer {
36            direction: Direction::Column,
37            gap: 0,
38            align: Align::Start,
39            align_self: None,
40            justify: Justify::Start,
41            border: None,
42            border_sides: BorderSides::all(),
43            border_style: Style::new().fg(border),
44            bg_color: None,
45            padding: Padding::default(),
46            margin: Margin::default(),
47            constraints: Constraints::default(),
48            title: None,
49            grow: 0,
50            group_name: None,
51        });
52
53        let children_start = self.commands.len();
54        f(self);
55        let child_commands: Vec<Command> = self.commands.drain(children_start..).collect();
56
57        let mut elements: Vec<Vec<Command>> = Vec::new();
58        let mut iter = child_commands.into_iter().peekable();
59        while let Some(cmd) = iter.next() {
60            match cmd {
61                Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
62                    let mut depth = 1_u32;
63                    let mut element = vec![cmd];
64                    for next in iter.by_ref() {
65                        match next {
66                            Command::BeginContainer { .. } | Command::BeginScrollable { .. } => {
67                                depth += 1;
68                            }
69                            Command::EndContainer => {
70                                depth = depth.saturating_sub(1);
71                            }
72                            _ => {}
73                        }
74                        let at_end = matches!(next, Command::EndContainer) && depth == 0;
75                        element.push(next);
76                        if at_end {
77                            break;
78                        }
79                    }
80                    elements.push(element);
81                }
82                Command::EndContainer => {}
83                _ => elements.push(vec![cmd]),
84            }
85        }
86
87        let cols = cols.max(1) as usize;
88        for row in elements.chunks(cols) {
89            self.interaction_count += 1;
90            self.commands.push(Command::BeginContainer {
91                direction: Direction::Row,
92                gap: 0,
93                align: Align::Start,
94                align_self: None,
95                justify: Justify::Start,
96                border: None,
97                border_sides: BorderSides::all(),
98                border_style: Style::new().fg(border),
99                bg_color: None,
100                padding: Padding::default(),
101                margin: Margin::default(),
102                constraints: Constraints::default(),
103                title: None,
104                grow: 0,
105                group_name: None,
106            });
107
108            for element in row {
109                self.interaction_count += 1;
110                self.commands.push(Command::BeginContainer {
111                    direction: Direction::Column,
112                    gap: 0,
113                    align: Align::Start,
114                    align_self: None,
115                    justify: Justify::Start,
116                    border: None,
117                    border_sides: BorderSides::all(),
118                    border_style: Style::new().fg(border),
119                    bg_color: None,
120                    padding: Padding::default(),
121                    margin: Margin::default(),
122                    constraints: Constraints::default(),
123                    title: None,
124                    grow: 1,
125                    group_name: None,
126                });
127                self.commands.extend(element.iter().cloned());
128                self.commands.push(Command::EndContainer);
129            }
130
131            self.commands.push(Command::EndContainer);
132        }
133
134        self.commands.push(Command::EndContainer);
135        self.last_text_idx = None;
136
137        self.response_for(interaction_id)
138    }
139
140    /// Render a selectable list. Handles Up/Down (and `k`/`j`) navigation when focused.
141    ///
142    /// The selected item is highlighted with the theme's primary color. If the
143    /// list is empty, nothing is rendered.
144    /// Render a navigable list widget.
145    pub fn list(&mut self, state: &mut ListState) -> Response {
146        self.list_colored(state, &WidgetColors::new())
147    }
148
149    /// Render a navigable list with custom widget colors.
150    pub fn list_colored(&mut self, state: &mut ListState, colors: &WidgetColors) -> Response {
151        let visible = state.visible_indices().to_vec();
152        if visible.is_empty() && state.items.is_empty() {
153            state.selected = 0;
154            return Response::none();
155        }
156
157        if !visible.is_empty() {
158            state.selected = state.selected.min(visible.len().saturating_sub(1));
159        }
160
161        let old_selected = state.selected;
162        let focused = self.register_focusable();
163        let interaction_id = self.next_interaction_id();
164        let mut response = self.response_for(interaction_id);
165        response.focused = focused;
166
167        if focused {
168            let mut consumed_indices = Vec::new();
169            for (i, event) in self.events.iter().enumerate() {
170                if let Event::Key(key) = event {
171                    if key.kind != KeyEventKind::Press {
172                        continue;
173                    }
174                    match key.code {
175                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
176                            let _ = handle_vertical_nav(
177                                &mut state.selected,
178                                visible.len().saturating_sub(1),
179                                key.code.clone(),
180                            );
181                            consumed_indices.push(i);
182                        }
183                        _ => {}
184                    }
185                }
186            }
187
188            for index in consumed_indices {
189                self.consumed[index] = true;
190            }
191        }
192
193        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
194            for (i, event) in self.events.iter().enumerate() {
195                if self.consumed[i] {
196                    continue;
197                }
198                if let Event::Mouse(mouse) = event {
199                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
200                        continue;
201                    }
202                    let in_bounds = mouse.x >= rect.x
203                        && mouse.x < rect.right()
204                        && mouse.y >= rect.y
205                        && mouse.y < rect.bottom();
206                    if !in_bounds {
207                        continue;
208                    }
209                    let clicked_idx = (mouse.y - rect.y) as usize;
210                    if clicked_idx < visible.len() {
211                        state.selected = clicked_idx;
212                        self.consumed[i] = true;
213                    }
214                }
215            }
216        }
217
218        self.commands.push(Command::BeginContainer {
219            direction: Direction::Column,
220            gap: 0,
221            align: Align::Start,
222            align_self: None,
223            justify: Justify::Start,
224            border: None,
225            border_sides: BorderSides::all(),
226            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
227            bg_color: None,
228            padding: Padding::default(),
229            margin: Margin::default(),
230            constraints: Constraints::default(),
231            title: None,
232            grow: 0,
233            group_name: None,
234        });
235
236        for (view_idx, &item_idx) in visible.iter().enumerate() {
237            let item = &state.items[item_idx];
238            if view_idx == state.selected {
239                let mut selected_style = Style::new()
240                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
241                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
242                if focused {
243                    selected_style = selected_style.bold();
244                }
245                let mut row = String::with_capacity(2 + item.len());
246                row.push_str("▸ ");
247                row.push_str(item);
248                self.styled(row, selected_style);
249            } else {
250                let mut row = String::with_capacity(2 + item.len());
251                row.push_str("  ");
252                row.push_str(item);
253                self.styled(row, Style::new().fg(colors.fg.unwrap_or(self.theme.text)));
254            }
255        }
256
257        self.commands.push(Command::EndContainer);
258        self.last_text_idx = None;
259
260        response.changed = state.selected != old_selected;
261        response
262    }
263
264    /// Render a calendar date picker with month navigation.
265    pub fn calendar(&mut self, state: &mut CalendarState) -> Response {
266        let focused = self.register_focusable();
267        let interaction_id = self.next_interaction_id();
268        let mut response = self.response_for(interaction_id);
269        response.focused = focused;
270
271        let month_days = CalendarState::days_in_month(state.year, state.month);
272        state.cursor_day = state.cursor_day.clamp(1, month_days);
273        if let Some(day) = state.selected_day {
274            state.selected_day = Some(day.min(month_days));
275        }
276        let old_selected = state.selected_day;
277
278        if focused {
279            let mut consumed_indices = Vec::new();
280            for (i, event) in self.events.iter().enumerate() {
281                if self.consumed[i] {
282                    continue;
283                }
284                if let Event::Key(key) = event {
285                    if key.kind != KeyEventKind::Press {
286                        continue;
287                    }
288                    match key.code {
289                        KeyCode::Left => {
290                            calendar_move_cursor_by_days(state, -1);
291                            consumed_indices.push(i);
292                        }
293                        KeyCode::Right => {
294                            calendar_move_cursor_by_days(state, 1);
295                            consumed_indices.push(i);
296                        }
297                        KeyCode::Up => {
298                            calendar_move_cursor_by_days(state, -7);
299                            consumed_indices.push(i);
300                        }
301                        KeyCode::Down => {
302                            calendar_move_cursor_by_days(state, 7);
303                            consumed_indices.push(i);
304                        }
305                        KeyCode::Char('h') => {
306                            state.prev_month();
307                            consumed_indices.push(i);
308                        }
309                        KeyCode::Char('l') => {
310                            state.next_month();
311                            consumed_indices.push(i);
312                        }
313                        KeyCode::Enter | KeyCode::Char(' ') => {
314                            state.selected_day = Some(state.cursor_day);
315                            consumed_indices.push(i);
316                        }
317                        _ => {}
318                    }
319                }
320            }
321
322            for index in consumed_indices {
323                self.consumed[index] = true;
324            }
325        }
326
327        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
328            for (i, event) in self.events.iter().enumerate() {
329                if self.consumed[i] {
330                    continue;
331                }
332                if let Event::Mouse(mouse) = event {
333                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
334                        continue;
335                    }
336                    let in_bounds = mouse.x >= rect.x
337                        && mouse.x < rect.right()
338                        && mouse.y >= rect.y
339                        && mouse.y < rect.bottom();
340                    if !in_bounds {
341                        continue;
342                    }
343
344                    let rel_x = mouse.x.saturating_sub(rect.x);
345                    let rel_y = mouse.y.saturating_sub(rect.y);
346                    if rel_y == 0 {
347                        if rel_x <= 2 {
348                            state.prev_month();
349                            self.consumed[i] = true;
350                            continue;
351                        }
352                        if rel_x + 3 >= rect.width {
353                            state.next_month();
354                            self.consumed[i] = true;
355                            continue;
356                        }
357                    }
358
359                    if !(2..8).contains(&rel_y) {
360                        continue;
361                    }
362                    if rel_x >= 21 {
363                        continue;
364                    }
365
366                    let week = rel_y - 2;
367                    let col = rel_x / 3;
368                    let day_index = week * 7 + col;
369                    let first = CalendarState::first_weekday(state.year, state.month);
370                    let days = CalendarState::days_in_month(state.year, state.month);
371                    if day_index < first {
372                        continue;
373                    }
374                    let day = day_index - first + 1;
375                    if day == 0 || day > days {
376                        continue;
377                    }
378                    state.cursor_day = day;
379                    state.selected_day = Some(day);
380                    self.consumed[i] = true;
381                }
382            }
383        }
384
385        let title = {
386            let month_name = calendar_month_name(state.month);
387            let mut s = String::with_capacity(16);
388            s.push_str(&state.year.to_string());
389            s.push(' ');
390            s.push_str(month_name);
391            s
392        };
393
394        self.commands.push(Command::BeginContainer {
395            direction: Direction::Column,
396            gap: 0,
397            align: Align::Start,
398            align_self: None,
399            justify: Justify::Start,
400            border: None,
401            border_sides: BorderSides::all(),
402            border_style: Style::new().fg(self.theme.border),
403            bg_color: None,
404            padding: Padding::default(),
405            margin: Margin::default(),
406            constraints: Constraints::default(),
407            title: None,
408            grow: 0,
409            group_name: None,
410        });
411
412        self.commands.push(Command::BeginContainer {
413            direction: Direction::Row,
414            gap: 1,
415            align: Align::Start,
416            align_self: None,
417            justify: Justify::Start,
418            border: None,
419            border_sides: BorderSides::all(),
420            border_style: Style::new().fg(self.theme.border),
421            bg_color: None,
422            padding: Padding::default(),
423            margin: Margin::default(),
424            constraints: Constraints::default(),
425            title: None,
426            grow: 0,
427            group_name: None,
428        });
429        self.styled("◀", Style::new().fg(self.theme.text));
430        self.styled(title, Style::new().bold().fg(self.theme.text));
431        self.styled("▶", Style::new().fg(self.theme.text));
432        self.commands.push(Command::EndContainer);
433
434        self.commands.push(Command::BeginContainer {
435            direction: Direction::Row,
436            gap: 0,
437            align: Align::Start,
438            align_self: None,
439            justify: Justify::Start,
440            border: None,
441            border_sides: BorderSides::all(),
442            border_style: Style::new().fg(self.theme.border),
443            bg_color: None,
444            padding: Padding::default(),
445            margin: Margin::default(),
446            constraints: Constraints::default(),
447            title: None,
448            grow: 0,
449            group_name: None,
450        });
451        for wd in ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] {
452            self.styled(
453                format!("{wd:>2} "),
454                Style::new().fg(self.theme.text_dim).bold(),
455            );
456        }
457        self.commands.push(Command::EndContainer);
458
459        let first = CalendarState::first_weekday(state.year, state.month);
460        let days = CalendarState::days_in_month(state.year, state.month);
461        for week in 0..6_u32 {
462            self.commands.push(Command::BeginContainer {
463                direction: Direction::Row,
464                gap: 0,
465                align: Align::Start,
466                align_self: None,
467                justify: Justify::Start,
468                border: None,
469                border_sides: BorderSides::all(),
470                border_style: Style::new().fg(self.theme.border),
471                bg_color: None,
472                padding: Padding::default(),
473                margin: Margin::default(),
474                constraints: Constraints::default(),
475                title: None,
476                grow: 0,
477                group_name: None,
478            });
479
480            for col in 0..7_u32 {
481                let idx = week * 7 + col;
482                if idx < first || idx >= first + days {
483                    self.styled("   ", Style::new().fg(self.theme.text_dim));
484                    continue;
485                }
486                let day = idx - first + 1;
487                let text = format!("{day:>2} ");
488                let style = if state.selected_day == Some(day) {
489                    Style::new()
490                        .bg(self.theme.selected_bg)
491                        .fg(self.theme.selected_fg)
492                } else if state.cursor_day == day {
493                    Style::new().fg(self.theme.primary).bold()
494                } else {
495                    Style::new().fg(self.theme.text)
496                };
497                self.styled(text, style);
498            }
499
500            self.commands.push(Command::EndContainer);
501        }
502
503        self.commands.push(Command::EndContainer);
504        self.last_text_idx = None;
505        response.changed = state.selected_day != old_selected;
506        response
507    }
508
509    /// Render a file system browser with directory navigation.
510    pub fn file_picker(&mut self, state: &mut FilePickerState) -> Response {
511        if state.dirty {
512            state.refresh();
513        }
514        if !state.entries.is_empty() {
515            state.selected = state.selected.min(state.entries.len().saturating_sub(1));
516        }
517
518        let focused = self.register_focusable();
519        let interaction_id = self.next_interaction_id();
520        let mut response = self.response_for(interaction_id);
521        response.focused = focused;
522        let mut file_selected = false;
523
524        if focused {
525            let mut consumed_indices = Vec::new();
526            for (i, event) in self.events.iter().enumerate() {
527                if self.consumed[i] {
528                    continue;
529                }
530                if let Event::Key(key) = event {
531                    if key.kind != KeyEventKind::Press {
532                        continue;
533                    }
534                    match key.code {
535                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
536                            if !state.entries.is_empty() {
537                                let _ = handle_vertical_nav(
538                                    &mut state.selected,
539                                    state.entries.len().saturating_sub(1),
540                                    key.code.clone(),
541                                );
542                            }
543                            consumed_indices.push(i);
544                        }
545                        KeyCode::Enter => {
546                            if let Some(entry) = state.entries.get(state.selected).cloned() {
547                                if entry.is_dir {
548                                    state.current_dir = entry.path;
549                                    state.selected = 0;
550                                    state.selected_file = None;
551                                    state.dirty = true;
552                                } else {
553                                    state.selected_file = Some(entry.path);
554                                    file_selected = true;
555                                }
556                            }
557                            consumed_indices.push(i);
558                        }
559                        KeyCode::Backspace => {
560                            if let Some(parent) =
561                                state.current_dir.parent().map(|p| p.to_path_buf())
562                            {
563                                state.current_dir = parent;
564                                state.selected = 0;
565                                state.selected_file = None;
566                                state.dirty = true;
567                            }
568                            consumed_indices.push(i);
569                        }
570                        KeyCode::Char('h') => {
571                            state.show_hidden = !state.show_hidden;
572                            state.selected = 0;
573                            state.dirty = true;
574                            consumed_indices.push(i);
575                        }
576                        KeyCode::Esc => {
577                            state.selected_file = None;
578                            consumed_indices.push(i);
579                        }
580                        _ => {}
581                    }
582                }
583            }
584
585            for index in consumed_indices {
586                self.consumed[index] = true;
587            }
588        }
589
590        if state.dirty {
591            state.refresh();
592        }
593
594        self.commands.push(Command::BeginContainer {
595            direction: Direction::Column,
596            gap: 0,
597            align: Align::Start,
598            align_self: None,
599            justify: Justify::Start,
600            border: None,
601            border_sides: BorderSides::all(),
602            border_style: Style::new().fg(self.theme.border),
603            bg_color: None,
604            padding: Padding::default(),
605            margin: Margin::default(),
606            constraints: Constraints::default(),
607            title: None,
608            grow: 0,
609            group_name: None,
610        });
611
612        let dir_text = {
613            let dir = state.current_dir.display().to_string();
614            let mut text = String::with_capacity(5 + dir.len());
615            text.push_str("Dir: ");
616            text.push_str(&dir);
617            text
618        };
619        self.styled(dir_text, Style::new().fg(self.theme.text_dim).dim());
620
621        if state.entries.is_empty() {
622            self.styled("(empty)", Style::new().fg(self.theme.text_dim).dim());
623        } else {
624            for (idx, entry) in state.entries.iter().enumerate() {
625                let icon = if entry.is_dir { "▸ " } else { "  " };
626                let row = if entry.is_dir {
627                    let mut row = String::with_capacity(icon.len() + entry.name.len());
628                    row.push_str(icon);
629                    row.push_str(&entry.name);
630                    row
631                } else {
632                    let size_text = entry.size.to_string();
633                    let mut row =
634                        String::with_capacity(icon.len() + entry.name.len() + size_text.len() + 4);
635                    row.push_str(icon);
636                    row.push_str(&entry.name);
637                    row.push_str("  ");
638                    row.push_str(&size_text);
639                    row.push_str(" B");
640                    row
641                };
642
643                let style = if idx == state.selected {
644                    if focused {
645                        Style::new().bold().fg(self.theme.primary)
646                    } else {
647                        Style::new().fg(self.theme.primary)
648                    }
649                } else {
650                    Style::new().fg(self.theme.text)
651                };
652                self.styled(row, style);
653            }
654        }
655
656        self.commands.push(Command::EndContainer);
657        self.last_text_idx = None;
658
659        response.changed = file_selected;
660        response
661    }
662
663    /// Render a data table with column headers. Handles Up/Down selection when focused.
664    ///
665    /// Column widths are computed automatically from header and cell content.
666    /// The selected row is highlighted with the theme's selection colors.
667    /// Render a data table with sortable columns and row selection.
668    pub fn table(&mut self, state: &mut TableState) -> Response {
669        self.table_colored(state, &WidgetColors::new())
670    }
671
672    /// Render a data table with custom widget colors.
673    pub fn table_colored(&mut self, state: &mut TableState, colors: &WidgetColors) -> Response {
674        if state.is_dirty() {
675            state.recompute_widths();
676        }
677
678        let old_selected = state.selected;
679        let old_sort_column = state.sort_column;
680        let old_sort_ascending = state.sort_ascending;
681        let old_page = state.page;
682        let old_filter = state.filter.clone();
683
684        let focused = self.register_focusable();
685        let interaction_id = self.next_interaction_id();
686        let mut response = self.response_for(interaction_id);
687        response.focused = focused;
688
689        self.table_handle_events(state, focused, interaction_id);
690
691        if state.is_dirty() {
692            state.recompute_widths();
693        }
694
695        self.table_render(state, focused, colors);
696
697        response.changed = state.selected != old_selected
698            || state.sort_column != old_sort_column
699            || state.sort_ascending != old_sort_ascending
700            || state.page != old_page
701            || state.filter != old_filter;
702        response
703    }
704
705    fn table_handle_events(
706        &mut self,
707        state: &mut TableState,
708        focused: bool,
709        interaction_id: usize,
710    ) {
711        self.handle_table_keys(state, focused);
712
713        if state.visible_indices().is_empty() && state.headers.is_empty() {
714            return;
715        }
716
717        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
718            for (i, event) in self.events.iter().enumerate() {
719                if self.consumed[i] {
720                    continue;
721                }
722                if let Event::Mouse(mouse) = event {
723                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
724                        continue;
725                    }
726                    let in_bounds = mouse.x >= rect.x
727                        && mouse.x < rect.right()
728                        && mouse.y >= rect.y
729                        && mouse.y < rect.bottom();
730                    if !in_bounds {
731                        continue;
732                    }
733
734                    if mouse.y == rect.y {
735                        let rel_x = mouse.x.saturating_sub(rect.x);
736                        let mut x_offset = 0u32;
737                        for (col_idx, width) in state.column_widths().iter().enumerate() {
738                            if rel_x >= x_offset && rel_x < x_offset + *width {
739                                state.toggle_sort(col_idx);
740                                state.selected = 0;
741                                self.consumed[i] = true;
742                                break;
743                            }
744                            x_offset += *width;
745                            if col_idx + 1 < state.column_widths().len() {
746                                x_offset += 3;
747                            }
748                        }
749                        continue;
750                    }
751
752                    if mouse.y < rect.y + 2 {
753                        continue;
754                    }
755
756                    let visible_len = if state.page_size > 0 {
757                        let start = state
758                            .page
759                            .saturating_mul(state.page_size)
760                            .min(state.visible_indices().len());
761                        let end = (start + state.page_size).min(state.visible_indices().len());
762                        end.saturating_sub(start)
763                    } else {
764                        state.visible_indices().len()
765                    };
766                    let clicked_idx = (mouse.y - rect.y - 2) as usize;
767                    if clicked_idx < visible_len {
768                        state.selected = clicked_idx;
769                        self.consumed[i] = true;
770                    }
771                }
772            }
773        }
774    }
775
776    fn table_render(&mut self, state: &mut TableState, focused: bool, colors: &WidgetColors) {
777        let total_visible = state.visible_indices().len();
778        let page_start = if state.page_size > 0 {
779            state
780                .page
781                .saturating_mul(state.page_size)
782                .min(total_visible)
783        } else {
784            0
785        };
786        let page_end = if state.page_size > 0 {
787            (page_start + state.page_size).min(total_visible)
788        } else {
789            total_visible
790        };
791        let visible_len = page_end.saturating_sub(page_start);
792        state.selected = state.selected.min(visible_len.saturating_sub(1));
793
794        self.commands.push(Command::BeginContainer {
795            direction: Direction::Column,
796            gap: 0,
797            align: Align::Start,
798            align_self: None,
799            justify: Justify::Start,
800            border: None,
801            border_sides: BorderSides::all(),
802            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
803            bg_color: None,
804            padding: Padding::default(),
805            margin: Margin::default(),
806            constraints: Constraints::default(),
807            title: None,
808            grow: 0,
809            group_name: None,
810        });
811
812        self.render_table_header(state, colors);
813        self.render_table_rows(state, focused, page_start, visible_len, colors);
814
815        if state.page_size > 0 && state.total_pages() > 1 {
816            let current_page = (state.page + 1).to_string();
817            let total_pages = state.total_pages().to_string();
818            let mut page_text = String::with_capacity(current_page.len() + total_pages.len() + 6);
819            page_text.push_str("Page ");
820            page_text.push_str(&current_page);
821            page_text.push('/');
822            page_text.push_str(&total_pages);
823            self.styled(
824                page_text,
825                Style::new()
826                    .dim()
827                    .fg(colors.fg.unwrap_or(self.theme.text_dim)),
828            );
829        }
830
831        self.commands.push(Command::EndContainer);
832        self.last_text_idx = None;
833    }
834
835    fn handle_table_keys(&mut self, state: &mut TableState, focused: bool) {
836        if !focused || state.visible_indices().is_empty() {
837            return;
838        }
839
840        let mut consumed_indices = Vec::new();
841        for (i, event) in self.events.iter().enumerate() {
842            if let Event::Key(key) = event {
843                if key.kind != KeyEventKind::Press {
844                    continue;
845                }
846                match key.code {
847                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
848                        let visible_len = table_visible_len(state);
849                        state.selected = state.selected.min(visible_len.saturating_sub(1));
850                        let _ = handle_vertical_nav(
851                            &mut state.selected,
852                            visible_len.saturating_sub(1),
853                            key.code.clone(),
854                        );
855                        consumed_indices.push(i);
856                    }
857                    KeyCode::PageUp => {
858                        let old_page = state.page;
859                        state.prev_page();
860                        if state.page != old_page {
861                            state.selected = 0;
862                        }
863                        consumed_indices.push(i);
864                    }
865                    KeyCode::PageDown => {
866                        let old_page = state.page;
867                        state.next_page();
868                        if state.page != old_page {
869                            state.selected = 0;
870                        }
871                        consumed_indices.push(i);
872                    }
873                    _ => {}
874                }
875            }
876        }
877        for index in consumed_indices {
878            self.consumed[index] = true;
879        }
880    }
881
882    fn render_table_header(&mut self, state: &TableState, colors: &WidgetColors) {
883        let header_cells = state
884            .headers
885            .iter()
886            .enumerate()
887            .map(|(i, header)| {
888                if state.sort_column == Some(i) {
889                    if state.sort_ascending {
890                        let mut sorted_header = String::with_capacity(header.len() + 2);
891                        sorted_header.push_str(header);
892                        sorted_header.push_str(" ▲");
893                        sorted_header
894                    } else {
895                        let mut sorted_header = String::with_capacity(header.len() + 2);
896                        sorted_header.push_str(header);
897                        sorted_header.push_str(" ▼");
898                        sorted_header
899                    }
900                } else {
901                    header.clone()
902                }
903            })
904            .collect::<Vec<_>>();
905        let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
906        self.styled(
907            header_line,
908            Style::new().bold().fg(colors.fg.unwrap_or(self.theme.text)),
909        );
910
911        let separator = state
912            .column_widths()
913            .iter()
914            .map(|w| "─".repeat(*w as usize))
915            .collect::<Vec<_>>()
916            .join("─┼─");
917        self.text(separator);
918    }
919
920    fn render_table_rows(
921        &mut self,
922        state: &TableState,
923        focused: bool,
924        page_start: usize,
925        visible_len: usize,
926        colors: &WidgetColors,
927    ) {
928        for idx in 0..visible_len {
929            let data_idx = state.visible_indices()[page_start + idx];
930            let Some(row) = state.rows.get(data_idx) else {
931                continue;
932            };
933            let line = format_table_row(row, state.column_widths(), " │ ");
934            if idx == state.selected {
935                let mut style = Style::new()
936                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
937                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
938                if focused {
939                    style = style.bold();
940                }
941                self.styled(line, style);
942            } else {
943                let mut style = Style::new().fg(colors.fg.unwrap_or(self.theme.text));
944                if state.zebra {
945                    let zebra_bg = colors.bg.unwrap_or({
946                        if idx % 2 == 0 {
947                            self.theme.surface
948                        } else {
949                            self.theme.surface_hover
950                        }
951                    });
952                    style = style.bg(zebra_bg);
953                }
954                self.styled(line, style);
955            }
956        }
957    }
958
959    /// Render a tab bar. Handles Left/Right navigation when focused.
960    ///
961    /// The active tab is rendered in the theme's primary color. If the labels
962    /// list is empty, nothing is rendered.
963    /// Render a horizontal tab bar.
964    pub fn tabs(&mut self, state: &mut TabsState) -> Response {
965        self.tabs_colored(state, &WidgetColors::new())
966    }
967
968    /// Render a horizontal tab bar with custom widget colors.
969    pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
970        if state.labels.is_empty() {
971            state.selected = 0;
972            return Response::none();
973        }
974
975        state.selected = state.selected.min(state.labels.len().saturating_sub(1));
976        let old_selected = state.selected;
977        let focused = self.register_focusable();
978        let interaction_id = self.next_interaction_id();
979        let mut response = self.response_for(interaction_id);
980        response.focused = focused;
981
982        if focused {
983            let mut consumed_indices = Vec::new();
984            for (i, event) in self.events.iter().enumerate() {
985                if let Event::Key(key) = event {
986                    if key.kind != KeyEventKind::Press {
987                        continue;
988                    }
989                    match key.code {
990                        KeyCode::Left => {
991                            state.selected = if state.selected == 0 {
992                                state.labels.len().saturating_sub(1)
993                            } else {
994                                state.selected - 1
995                            };
996                            consumed_indices.push(i);
997                        }
998                        KeyCode::Right => {
999                            if !state.labels.is_empty() {
1000                                state.selected = (state.selected + 1) % state.labels.len();
1001                            }
1002                            consumed_indices.push(i);
1003                        }
1004                        _ => {}
1005                    }
1006                }
1007            }
1008
1009            for index in consumed_indices {
1010                self.consumed[index] = true;
1011            }
1012        }
1013
1014        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1015            for (i, event) in self.events.iter().enumerate() {
1016                if self.consumed[i] {
1017                    continue;
1018                }
1019                if let Event::Mouse(mouse) = event {
1020                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1021                        continue;
1022                    }
1023                    let in_bounds = mouse.x >= rect.x
1024                        && mouse.x < rect.right()
1025                        && mouse.y >= rect.y
1026                        && mouse.y < rect.bottom();
1027                    if !in_bounds {
1028                        continue;
1029                    }
1030
1031                    let mut x_offset = 0u32;
1032                    let rel_x = mouse.x - rect.x;
1033                    for (idx, label) in state.labels.iter().enumerate() {
1034                        let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
1035                        if rel_x >= x_offset && rel_x < x_offset + tab_width {
1036                            state.selected = idx;
1037                            self.consumed[i] = true;
1038                            break;
1039                        }
1040                        x_offset += tab_width + 1;
1041                    }
1042                }
1043            }
1044        }
1045
1046        self.commands.push(Command::BeginContainer {
1047            direction: Direction::Row,
1048            gap: 1,
1049            align: Align::Start,
1050            align_self: None,
1051            justify: Justify::Start,
1052            border: None,
1053            border_sides: BorderSides::all(),
1054            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1055            bg_color: None,
1056            padding: Padding::default(),
1057            margin: Margin::default(),
1058            constraints: Constraints::default(),
1059            title: None,
1060            grow: 0,
1061            group_name: None,
1062        });
1063        for (idx, label) in state.labels.iter().enumerate() {
1064            let style = if idx == state.selected {
1065                let s = Style::new()
1066                    .fg(colors.accent.unwrap_or(self.theme.primary))
1067                    .bold();
1068                if focused {
1069                    s.underline()
1070                } else {
1071                    s
1072                }
1073            } else {
1074                Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1075            };
1076            let mut tab = String::with_capacity(label.len() + 4);
1077            tab.push_str("[ ");
1078            tab.push_str(label);
1079            tab.push_str(" ]");
1080            self.styled(tab, style);
1081        }
1082        self.commands.push(Command::EndContainer);
1083        self.last_text_idx = None;
1084
1085        response.changed = state.selected != old_selected;
1086        response
1087    }
1088
1089    /// Render a clickable button. Returns `true` when activated via Enter, Space, or mouse click.
1090    ///
1091    /// The button is styled with the theme's primary color when focused and the
1092    /// accent color when hovered.
1093    /// Render a clickable button.
1094    pub fn button(&mut self, label: impl Into<String>) -> Response {
1095        self.button_colored(label, &WidgetColors::new())
1096    }
1097
1098    /// Render a clickable button with custom widget colors.
1099    pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
1100        let focused = self.register_focusable();
1101        let interaction_id = self.next_interaction_id();
1102        let mut response = self.response_for(interaction_id);
1103        response.focused = focused;
1104
1105        let mut activated = response.clicked;
1106        if focused {
1107            let mut consumed_indices = Vec::new();
1108            for (i, event) in self.events.iter().enumerate() {
1109                if let Event::Key(key) = event {
1110                    if key.kind != KeyEventKind::Press {
1111                        continue;
1112                    }
1113                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1114                        activated = true;
1115                        consumed_indices.push(i);
1116                    }
1117                }
1118            }
1119
1120            for index in consumed_indices {
1121                self.consumed[index] = true;
1122            }
1123        }
1124
1125        let hovered = response.hovered;
1126        let base_fg = colors.fg.unwrap_or(self.theme.text);
1127        let accent = colors.accent.unwrap_or(self.theme.accent);
1128        let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
1129        let style = if focused {
1130            Style::new().fg(accent).bold()
1131        } else if hovered {
1132            Style::new().fg(accent)
1133        } else {
1134            Style::new().fg(base_fg)
1135        };
1136        let has_custom_bg = colors.bg.is_some();
1137        let bg_color = if has_custom_bg || hovered || focused {
1138            Some(base_bg)
1139        } else {
1140            None
1141        };
1142
1143        self.commands.push(Command::BeginContainer {
1144            direction: Direction::Row,
1145            gap: 0,
1146            align: Align::Start,
1147            align_self: None,
1148            justify: Justify::Start,
1149            border: None,
1150            border_sides: BorderSides::all(),
1151            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1152            bg_color,
1153            padding: Padding::default(),
1154            margin: Margin::default(),
1155            constraints: Constraints::default(),
1156            title: None,
1157            grow: 0,
1158            group_name: None,
1159        });
1160        let raw_label = label.into();
1161        let mut label_text = String::with_capacity(raw_label.len() + 4);
1162        label_text.push_str("[ ");
1163        label_text.push_str(&raw_label);
1164        label_text.push_str(" ]");
1165        self.styled(label_text, style);
1166        self.commands.push(Command::EndContainer);
1167        self.last_text_idx = None;
1168
1169        response.clicked = activated;
1170        response
1171    }
1172
1173    /// Render a styled button variant. Returns `true` when activated.
1174    ///
1175    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
1176    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
1177    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
1178        let focused = self.register_focusable();
1179        let interaction_id = self.next_interaction_id();
1180        let mut response = self.response_for(interaction_id);
1181        response.focused = focused;
1182
1183        let mut activated = response.clicked;
1184        if focused {
1185            let mut consumed_indices = Vec::new();
1186            for (i, event) in self.events.iter().enumerate() {
1187                if let Event::Key(key) = event {
1188                    if key.kind != KeyEventKind::Press {
1189                        continue;
1190                    }
1191                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1192                        activated = true;
1193                        consumed_indices.push(i);
1194                    }
1195                }
1196            }
1197            for index in consumed_indices {
1198                self.consumed[index] = true;
1199            }
1200        }
1201
1202        let label = label.into();
1203        let hover_bg = if response.hovered || focused {
1204            Some(self.theme.surface_hover)
1205        } else {
1206            None
1207        };
1208        let (text, style, bg_color, border) = match variant {
1209            ButtonVariant::Default => {
1210                let style = if focused {
1211                    Style::new().fg(self.theme.primary).bold()
1212                } else if response.hovered {
1213                    Style::new().fg(self.theme.accent)
1214                } else {
1215                    Style::new().fg(self.theme.text)
1216                };
1217                let mut text = String::with_capacity(label.len() + 4);
1218                text.push_str("[ ");
1219                text.push_str(&label);
1220                text.push_str(" ]");
1221                (text, style, hover_bg, None)
1222            }
1223            ButtonVariant::Primary => {
1224                let style = if focused {
1225                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
1226                } else if response.hovered {
1227                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
1228                } else {
1229                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
1230                };
1231                let mut text = String::with_capacity(label.len() + 2);
1232                text.push(' ');
1233                text.push_str(&label);
1234                text.push(' ');
1235                (text, style, hover_bg, None)
1236            }
1237            ButtonVariant::Danger => {
1238                let style = if focused {
1239                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
1240                } else if response.hovered {
1241                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
1242                } else {
1243                    Style::new().fg(self.theme.bg).bg(self.theme.error)
1244                };
1245                let mut text = String::with_capacity(label.len() + 2);
1246                text.push(' ');
1247                text.push_str(&label);
1248                text.push(' ');
1249                (text, style, hover_bg, None)
1250            }
1251            ButtonVariant::Outline => {
1252                let border_color = if focused {
1253                    self.theme.primary
1254                } else if response.hovered {
1255                    self.theme.accent
1256                } else {
1257                    self.theme.border
1258                };
1259                let style = if focused {
1260                    Style::new().fg(self.theme.primary).bold()
1261                } else if response.hovered {
1262                    Style::new().fg(self.theme.accent)
1263                } else {
1264                    Style::new().fg(self.theme.text)
1265                };
1266                (
1267                    {
1268                        let mut text = String::with_capacity(label.len() + 2);
1269                        text.push(' ');
1270                        text.push_str(&label);
1271                        text.push(' ');
1272                        text
1273                    },
1274                    style,
1275                    hover_bg,
1276                    Some((Border::Rounded, Style::new().fg(border_color))),
1277                )
1278            }
1279        };
1280
1281        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
1282        self.commands.push(Command::BeginContainer {
1283            direction: Direction::Row,
1284            gap: 0,
1285            align: Align::Center,
1286            align_self: None,
1287            justify: Justify::Center,
1288            border: if border.is_some() {
1289                Some(btn_border)
1290            } else {
1291                None
1292            },
1293            border_sides: BorderSides::all(),
1294            border_style: btn_border_style,
1295            bg_color,
1296            padding: Padding::default(),
1297            margin: Margin::default(),
1298            constraints: Constraints::default(),
1299            title: None,
1300            grow: 0,
1301            group_name: None,
1302        });
1303        self.styled(text, style);
1304        self.commands.push(Command::EndContainer);
1305        self.last_text_idx = None;
1306
1307        response.clicked = activated;
1308        response
1309    }
1310
1311    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
1312    ///
1313    /// The checked state is shown with the theme's success color. When focused,
1314    /// a `▸` prefix is added.
1315    /// Render a checkbox toggle.
1316    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
1317        self.checkbox_colored(label, checked, &WidgetColors::new())
1318    }
1319
1320    /// Render a checkbox toggle with custom widget colors.
1321    pub fn checkbox_colored(
1322        &mut self,
1323        label: impl Into<String>,
1324        checked: &mut bool,
1325        colors: &WidgetColors,
1326    ) -> Response {
1327        let focused = self.register_focusable();
1328        let interaction_id = self.next_interaction_id();
1329        let mut response = self.response_for(interaction_id);
1330        response.focused = focused;
1331        let mut should_toggle = response.clicked;
1332        let old_checked = *checked;
1333
1334        if focused {
1335            let mut consumed_indices = Vec::new();
1336            for (i, event) in self.events.iter().enumerate() {
1337                if let Event::Key(key) = event {
1338                    if key.kind != KeyEventKind::Press {
1339                        continue;
1340                    }
1341                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1342                        should_toggle = true;
1343                        consumed_indices.push(i);
1344                    }
1345                }
1346            }
1347
1348            for index in consumed_indices {
1349                self.consumed[index] = true;
1350            }
1351        }
1352
1353        if should_toggle {
1354            *checked = !*checked;
1355        }
1356
1357        let hover_bg = if response.hovered || focused {
1358            Some(self.theme.surface_hover)
1359        } else {
1360            None
1361        };
1362        self.commands.push(Command::BeginContainer {
1363            direction: Direction::Row,
1364            gap: 1,
1365            align: Align::Start,
1366            align_self: None,
1367            justify: Justify::Start,
1368            border: None,
1369            border_sides: BorderSides::all(),
1370            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1371            bg_color: hover_bg,
1372            padding: Padding::default(),
1373            margin: Margin::default(),
1374            constraints: Constraints::default(),
1375            title: None,
1376            grow: 0,
1377            group_name: None,
1378        });
1379        let marker_style = if *checked {
1380            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1381        } else {
1382            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1383        };
1384        let marker = if *checked { "[x]" } else { "[ ]" };
1385        let label_text = label.into();
1386        if focused {
1387            let mut marker_text = String::with_capacity(2 + marker.len());
1388            marker_text.push_str("▸ ");
1389            marker_text.push_str(marker);
1390            self.styled(marker_text, marker_style.bold());
1391            self.styled(
1392                label_text,
1393                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1394            );
1395        } else {
1396            self.styled(marker, marker_style);
1397            self.styled(
1398                label_text,
1399                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1400            );
1401        }
1402        self.commands.push(Command::EndContainer);
1403        self.last_text_idx = None;
1404
1405        response.changed = *checked != old_checked;
1406        response
1407    }
1408
1409    /// Render an on/off toggle switch.
1410    ///
1411    /// Toggles `on` when activated via Enter, Space, or click. The switch
1412    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
1413    /// dim color respectively.
1414    /// Render an on/off toggle switch.
1415    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
1416        self.toggle_colored(label, on, &WidgetColors::new())
1417    }
1418
1419    /// Render an on/off toggle switch with custom widget colors.
1420    pub fn toggle_colored(
1421        &mut self,
1422        label: impl Into<String>,
1423        on: &mut bool,
1424        colors: &WidgetColors,
1425    ) -> Response {
1426        let focused = self.register_focusable();
1427        let interaction_id = self.next_interaction_id();
1428        let mut response = self.response_for(interaction_id);
1429        response.focused = focused;
1430        let mut should_toggle = response.clicked;
1431        let old_on = *on;
1432
1433        if focused {
1434            let mut consumed_indices = Vec::new();
1435            for (i, event) in self.events.iter().enumerate() {
1436                if let Event::Key(key) = event {
1437                    if key.kind != KeyEventKind::Press {
1438                        continue;
1439                    }
1440                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1441                        should_toggle = true;
1442                        consumed_indices.push(i);
1443                    }
1444                }
1445            }
1446
1447            for index in consumed_indices {
1448                self.consumed[index] = true;
1449            }
1450        }
1451
1452        if should_toggle {
1453            *on = !*on;
1454        }
1455
1456        let hover_bg = if response.hovered || focused {
1457            Some(self.theme.surface_hover)
1458        } else {
1459            None
1460        };
1461        self.commands.push(Command::BeginContainer {
1462            direction: Direction::Row,
1463            gap: 2,
1464            align: Align::Start,
1465            align_self: None,
1466            justify: Justify::Start,
1467            border: None,
1468            border_sides: BorderSides::all(),
1469            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1470            bg_color: hover_bg,
1471            padding: Padding::default(),
1472            margin: Margin::default(),
1473            constraints: Constraints::default(),
1474            title: None,
1475            grow: 0,
1476            group_name: None,
1477        });
1478        let label_text = label.into();
1479        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1480        let switch_style = if *on {
1481            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1482        } else {
1483            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1484        };
1485        if focused {
1486            let mut focused_label = String::with_capacity(2 + label_text.len());
1487            focused_label.push_str("▸ ");
1488            focused_label.push_str(&label_text);
1489            self.styled(
1490                focused_label,
1491                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1492            );
1493            self.styled(switch, switch_style.bold());
1494        } else {
1495            self.styled(
1496                label_text,
1497                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1498            );
1499            self.styled(switch, switch_style);
1500        }
1501        self.commands.push(Command::EndContainer);
1502        self.last_text_idx = None;
1503
1504        response.changed = *on != old_on;
1505        response
1506    }
1507
1508    // ── select / dropdown ─────────────────────────────────────────────
1509
1510    /// Render a dropdown select. Shows the selected item; expands on activation.
1511    ///
1512    /// Returns `true` when the selection changed this frame.
1513    /// Render a dropdown select widget.
1514    pub fn select(&mut self, state: &mut SelectState) -> Response {
1515        self.select_colored(state, &WidgetColors::new())
1516    }
1517
1518    /// Render a dropdown select widget with custom widget colors.
1519    pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1520        if state.items.is_empty() {
1521            return Response::none();
1522        }
1523        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1524
1525        let focused = self.register_focusable();
1526        let interaction_id = self.next_interaction_id();
1527        let mut response = self.response_for(interaction_id);
1528        response.focused = focused;
1529        let old_selected = state.selected;
1530
1531        self.select_handle_events(state, focused, response.clicked);
1532        self.select_render(state, focused, colors);
1533        response.changed = state.selected != old_selected;
1534        response
1535    }
1536
1537    fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
1538        if clicked {
1539            state.open = !state.open;
1540            if state.open {
1541                state.set_cursor(state.selected);
1542            }
1543        }
1544
1545        if !focused {
1546            return;
1547        }
1548
1549        let mut consumed_indices = Vec::new();
1550        for (i, event) in self.events.iter().enumerate() {
1551            if self.consumed[i] {
1552                continue;
1553            }
1554            if let Event::Key(key) = event {
1555                if key.kind != KeyEventKind::Press {
1556                    continue;
1557                }
1558                if state.open {
1559                    match key.code {
1560                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1561                            let mut cursor = state.cursor();
1562                            let _ = handle_vertical_nav(
1563                                &mut cursor,
1564                                state.items.len().saturating_sub(1),
1565                                key.code.clone(),
1566                            );
1567                            state.set_cursor(cursor);
1568                            consumed_indices.push(i);
1569                        }
1570                        KeyCode::Enter | KeyCode::Char(' ') => {
1571                            state.selected = state.cursor();
1572                            state.open = false;
1573                            consumed_indices.push(i);
1574                        }
1575                        KeyCode::Esc => {
1576                            state.open = false;
1577                            consumed_indices.push(i);
1578                        }
1579                        _ => {}
1580                    }
1581                } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1582                    state.open = true;
1583                    state.set_cursor(state.selected);
1584                    consumed_indices.push(i);
1585                }
1586            }
1587        }
1588        for idx in consumed_indices {
1589            self.consumed[idx] = true;
1590        }
1591    }
1592
1593    fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
1594        let border_color = if focused {
1595            colors.accent.unwrap_or(self.theme.primary)
1596        } else {
1597            colors.border.unwrap_or(self.theme.border)
1598        };
1599        let display_text = state
1600            .items
1601            .get(state.selected)
1602            .cloned()
1603            .unwrap_or_else(|| state.placeholder.clone());
1604        let arrow = if state.open { "▲" } else { "▼" };
1605
1606        self.commands.push(Command::BeginContainer {
1607            direction: Direction::Column,
1608            gap: 0,
1609            align: Align::Start,
1610            align_self: None,
1611            justify: Justify::Start,
1612            border: None,
1613            border_sides: BorderSides::all(),
1614            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1615            bg_color: None,
1616            padding: Padding::default(),
1617            margin: Margin::default(),
1618            constraints: Constraints::default(),
1619            title: None,
1620            grow: 0,
1621            group_name: None,
1622        });
1623
1624        self.render_select_trigger(&display_text, arrow, border_color, colors);
1625
1626        if state.open {
1627            self.render_select_dropdown(state, colors);
1628        }
1629
1630        self.commands.push(Command::EndContainer);
1631        self.last_text_idx = None;
1632    }
1633
1634    fn render_select_trigger(
1635        &mut self,
1636        display_text: &str,
1637        arrow: &str,
1638        border_color: Color,
1639        colors: &WidgetColors,
1640    ) {
1641        self.commands.push(Command::BeginContainer {
1642            direction: Direction::Row,
1643            gap: 1,
1644            align: Align::Start,
1645            align_self: None,
1646            justify: Justify::Start,
1647            border: Some(Border::Rounded),
1648            border_sides: BorderSides::all(),
1649            border_style: Style::new().fg(border_color),
1650            bg_color: None,
1651            padding: Padding {
1652                left: 1,
1653                right: 1,
1654                top: 0,
1655                bottom: 0,
1656            },
1657            margin: Margin::default(),
1658            constraints: Constraints::default(),
1659            title: None,
1660            grow: 0,
1661            group_name: None,
1662        });
1663        self.interaction_count += 1;
1664        self.styled(
1665            display_text,
1666            Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1667        );
1668        self.styled(
1669            arrow,
1670            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1671        );
1672        self.commands.push(Command::EndContainer);
1673        self.last_text_idx = None;
1674    }
1675
1676    fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1677        for (idx, item) in state.items.iter().enumerate() {
1678            let is_cursor = idx == state.cursor();
1679            let style = if is_cursor {
1680                Style::new()
1681                    .bold()
1682                    .fg(colors.accent.unwrap_or(self.theme.primary))
1683            } else {
1684                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1685            };
1686            let prefix = if is_cursor { "▸ " } else { "  " };
1687            let mut row = String::with_capacity(prefix.len() + item.len());
1688            row.push_str(prefix);
1689            row.push_str(item);
1690            self.styled(row, style);
1691        }
1692    }
1693
1694    // ── radio ────────────────────────────────────────────────────────
1695
1696    /// Render a radio button group. Returns `true` when selection changed.
1697    /// Render a radio button group.
1698    pub fn radio(&mut self, state: &mut RadioState) -> Response {
1699        self.radio_colored(state, &WidgetColors::new())
1700    }
1701
1702    /// Render a radio button group with custom widget colors.
1703    pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1704        if state.items.is_empty() {
1705            return Response::none();
1706        }
1707        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1708        let focused = self.register_focusable();
1709        let old_selected = state.selected;
1710
1711        if focused {
1712            let mut consumed_indices = Vec::new();
1713            for (i, event) in self.events.iter().enumerate() {
1714                if self.consumed[i] {
1715                    continue;
1716                }
1717                if let Event::Key(key) = event {
1718                    if key.kind != KeyEventKind::Press {
1719                        continue;
1720                    }
1721                    match key.code {
1722                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1723                            let _ = handle_vertical_nav(
1724                                &mut state.selected,
1725                                state.items.len().saturating_sub(1),
1726                                key.code.clone(),
1727                            );
1728                            consumed_indices.push(i);
1729                        }
1730                        KeyCode::Enter | KeyCode::Char(' ') => {
1731                            consumed_indices.push(i);
1732                        }
1733                        _ => {}
1734                    }
1735                }
1736            }
1737            for idx in consumed_indices {
1738                self.consumed[idx] = true;
1739            }
1740        }
1741
1742        let interaction_id = self.next_interaction_id();
1743        let mut response = self.response_for(interaction_id);
1744        response.focused = focused;
1745
1746        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1747            for (i, event) in self.events.iter().enumerate() {
1748                if self.consumed[i] {
1749                    continue;
1750                }
1751                if let Event::Mouse(mouse) = event {
1752                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1753                        continue;
1754                    }
1755                    let in_bounds = mouse.x >= rect.x
1756                        && mouse.x < rect.right()
1757                        && mouse.y >= rect.y
1758                        && mouse.y < rect.bottom();
1759                    if !in_bounds {
1760                        continue;
1761                    }
1762                    let clicked_idx = (mouse.y - rect.y) as usize;
1763                    if clicked_idx < state.items.len() {
1764                        state.selected = clicked_idx;
1765                        self.consumed[i] = true;
1766                    }
1767                }
1768            }
1769        }
1770
1771        self.commands.push(Command::BeginContainer {
1772            direction: Direction::Column,
1773            gap: 0,
1774            align: Align::Start,
1775            align_self: None,
1776            justify: Justify::Start,
1777            border: None,
1778            border_sides: BorderSides::all(),
1779            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1780            bg_color: None,
1781            padding: Padding::default(),
1782            margin: Margin::default(),
1783            constraints: Constraints::default(),
1784            title: None,
1785            grow: 0,
1786            group_name: None,
1787        });
1788
1789        for (idx, item) in state.items.iter().enumerate() {
1790            let is_selected = idx == state.selected;
1791            let marker = if is_selected { "●" } else { "○" };
1792            let style = if is_selected {
1793                if focused {
1794                    Style::new()
1795                        .bold()
1796                        .fg(colors.accent.unwrap_or(self.theme.primary))
1797                } else {
1798                    Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1799                }
1800            } else {
1801                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1802            };
1803            let prefix = if focused && idx == state.selected {
1804                "▸ "
1805            } else {
1806                "  "
1807            };
1808            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1809            row.push_str(prefix);
1810            row.push_str(marker);
1811            row.push(' ');
1812            row.push_str(item);
1813            self.styled(row, style);
1814        }
1815
1816        self.commands.push(Command::EndContainer);
1817        self.last_text_idx = None;
1818        response.changed = state.selected != old_selected;
1819        response
1820    }
1821
1822    // ── multi-select ─────────────────────────────────────────────────
1823
1824    /// Render a multi-select list. Space toggles, Up/Down navigates.
1825    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1826        if state.items.is_empty() {
1827            return Response::none();
1828        }
1829        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1830        let focused = self.register_focusable();
1831        let old_selected = state.selected.clone();
1832
1833        if focused {
1834            let mut consumed_indices = Vec::new();
1835            for (i, event) in self.events.iter().enumerate() {
1836                if self.consumed[i] {
1837                    continue;
1838                }
1839                if let Event::Key(key) = event {
1840                    if key.kind != KeyEventKind::Press {
1841                        continue;
1842                    }
1843                    match key.code {
1844                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1845                            let _ = handle_vertical_nav(
1846                                &mut state.cursor,
1847                                state.items.len().saturating_sub(1),
1848                                key.code.clone(),
1849                            );
1850                            consumed_indices.push(i);
1851                        }
1852                        KeyCode::Char(' ') | KeyCode::Enter => {
1853                            state.toggle(state.cursor);
1854                            consumed_indices.push(i);
1855                        }
1856                        _ => {}
1857                    }
1858                }
1859            }
1860            for idx in consumed_indices {
1861                self.consumed[idx] = true;
1862            }
1863        }
1864
1865        let interaction_id = self.next_interaction_id();
1866        let mut response = self.response_for(interaction_id);
1867        response.focused = focused;
1868
1869        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1870            for (i, event) in self.events.iter().enumerate() {
1871                if self.consumed[i] {
1872                    continue;
1873                }
1874                if let Event::Mouse(mouse) = event {
1875                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1876                        continue;
1877                    }
1878                    let in_bounds = mouse.x >= rect.x
1879                        && mouse.x < rect.right()
1880                        && mouse.y >= rect.y
1881                        && mouse.y < rect.bottom();
1882                    if !in_bounds {
1883                        continue;
1884                    }
1885                    let clicked_idx = (mouse.y - rect.y) as usize;
1886                    if clicked_idx < state.items.len() {
1887                        state.toggle(clicked_idx);
1888                        state.cursor = clicked_idx;
1889                        self.consumed[i] = true;
1890                    }
1891                }
1892            }
1893        }
1894
1895        self.commands.push(Command::BeginContainer {
1896            direction: Direction::Column,
1897            gap: 0,
1898            align: Align::Start,
1899            align_self: None,
1900            justify: Justify::Start,
1901            border: None,
1902            border_sides: BorderSides::all(),
1903            border_style: Style::new().fg(self.theme.border),
1904            bg_color: None,
1905            padding: Padding::default(),
1906            margin: Margin::default(),
1907            constraints: Constraints::default(),
1908            title: None,
1909            grow: 0,
1910            group_name: None,
1911        });
1912
1913        for (idx, item) in state.items.iter().enumerate() {
1914            let checked = state.selected.contains(&idx);
1915            let marker = if checked { "[x]" } else { "[ ]" };
1916            let is_cursor = idx == state.cursor;
1917            let style = if is_cursor && focused {
1918                Style::new().bold().fg(self.theme.primary)
1919            } else if checked {
1920                Style::new().fg(self.theme.success)
1921            } else {
1922                Style::new().fg(self.theme.text)
1923            };
1924            let prefix = if is_cursor && focused { "▸ " } else { "  " };
1925            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1926            row.push_str(prefix);
1927            row.push_str(marker);
1928            row.push(' ');
1929            row.push_str(item);
1930            self.styled(row, style);
1931        }
1932
1933        self.commands.push(Command::EndContainer);
1934        self.last_text_idx = None;
1935        response.changed = state.selected != old_selected;
1936        response
1937    }
1938
1939    // ── tree ─────────────────────────────────────────────────────────
1940
1941    /// Render a scrollable rich log view with styled entries.
1942    pub fn rich_log(&mut self, state: &mut RichLogState) -> Response {
1943        let focused = self.register_focusable();
1944        let interaction_id = self.next_interaction_id();
1945        let mut response = self.response_for(interaction_id);
1946        response.focused = focused;
1947
1948        let widget_height = if response.rect.height > 0 {
1949            response.rect.height as usize
1950        } else {
1951            self.area_height as usize
1952        };
1953        let viewport_height = widget_height.saturating_sub(2);
1954        let effective_height = if viewport_height == 0 {
1955            state.entries.len().max(1)
1956        } else {
1957            viewport_height
1958        };
1959        let show_indicator = state.entries.len() > effective_height;
1960        let visible_rows = if show_indicator {
1961            effective_height.saturating_sub(1).max(1)
1962        } else {
1963            effective_height
1964        };
1965        let max_offset = state.entries.len().saturating_sub(visible_rows);
1966        if state.auto_scroll && state.scroll_offset == usize::MAX {
1967            state.scroll_offset = max_offset;
1968        } else {
1969            state.scroll_offset = state.scroll_offset.min(max_offset);
1970        }
1971        let old_offset = state.scroll_offset;
1972
1973        if focused {
1974            let mut consumed_indices = Vec::new();
1975            for (i, event) in self.events.iter().enumerate() {
1976                if self.consumed[i] {
1977                    continue;
1978                }
1979                if let Event::Key(key) = event {
1980                    if key.kind != KeyEventKind::Press {
1981                        continue;
1982                    }
1983                    match key.code {
1984                        KeyCode::Up | KeyCode::Char('k') => {
1985                            state.scroll_offset = state.scroll_offset.saturating_sub(1);
1986                            consumed_indices.push(i);
1987                        }
1988                        KeyCode::Down | KeyCode::Char('j') => {
1989                            state.scroll_offset = (state.scroll_offset + 1).min(max_offset);
1990                            consumed_indices.push(i);
1991                        }
1992                        KeyCode::PageUp => {
1993                            state.scroll_offset = state.scroll_offset.saturating_sub(10);
1994                            consumed_indices.push(i);
1995                        }
1996                        KeyCode::PageDown => {
1997                            state.scroll_offset = (state.scroll_offset + 10).min(max_offset);
1998                            consumed_indices.push(i);
1999                        }
2000                        KeyCode::Home => {
2001                            state.scroll_offset = 0;
2002                            consumed_indices.push(i);
2003                        }
2004                        KeyCode::End => {
2005                            state.scroll_offset = max_offset;
2006                            consumed_indices.push(i);
2007                        }
2008                        _ => {}
2009                    }
2010                }
2011            }
2012            for idx in consumed_indices {
2013                self.consumed[idx] = true;
2014            }
2015        }
2016
2017        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
2018            for (i, event) in self.events.iter().enumerate() {
2019                if self.consumed[i] {
2020                    continue;
2021                }
2022                if let Event::Mouse(mouse) = event {
2023                    let in_bounds = mouse.x >= rect.x
2024                        && mouse.x < rect.right()
2025                        && mouse.y >= rect.y
2026                        && mouse.y < rect.bottom();
2027                    if !in_bounds {
2028                        continue;
2029                    }
2030                    let delta = self.scroll_lines_per_event as usize;
2031                    match mouse.kind {
2032                        MouseKind::ScrollUp => {
2033                            state.scroll_offset = state.scroll_offset.saturating_sub(delta);
2034                            self.consumed[i] = true;
2035                        }
2036                        MouseKind::ScrollDown => {
2037                            state.scroll_offset = (state.scroll_offset + delta).min(max_offset);
2038                            self.consumed[i] = true;
2039                        }
2040                        _ => {}
2041                    }
2042                }
2043            }
2044        }
2045
2046        state.scroll_offset = state.scroll_offset.min(max_offset);
2047        let start = state
2048            .scroll_offset
2049            .min(state.entries.len().saturating_sub(visible_rows));
2050        let end = (start + visible_rows).min(state.entries.len());
2051
2052        self.commands.push(Command::BeginContainer {
2053            direction: Direction::Column,
2054            gap: 0,
2055            align: Align::Start,
2056            align_self: None,
2057            justify: Justify::Start,
2058            border: Some(Border::Single),
2059            border_sides: BorderSides::all(),
2060            border_style: Style::new().fg(self.theme.border),
2061            bg_color: None,
2062            padding: Padding::default(),
2063            margin: Margin::default(),
2064            constraints: Constraints::default(),
2065            title: None,
2066            grow: 0,
2067            group_name: None,
2068        });
2069
2070        for entry in state
2071            .entries
2072            .iter()
2073            .skip(start)
2074            .take(end.saturating_sub(start))
2075        {
2076            self.commands.push(Command::RichText {
2077                segments: entry.segments.clone(),
2078                wrap: false,
2079                align: Align::Start,
2080                margin: Margin::default(),
2081                constraints: Constraints::default(),
2082            });
2083        }
2084
2085        if show_indicator {
2086            let end_pos = end.min(state.entries.len());
2087            let line = format!(
2088                "{}-{} / {}",
2089                start.saturating_add(1),
2090                end_pos,
2091                state.entries.len()
2092            );
2093            self.styled(line, Style::new().dim().fg(self.theme.text_dim));
2094        }
2095
2096        self.commands.push(Command::EndContainer);
2097        self.last_text_idx = None;
2098        response.changed = state.scroll_offset != old_offset;
2099        response
2100    }
2101
2102    // ── virtual list ─────────────────────────────────────────────────
2103
2104    /// Render a virtual list that only renders visible items.
2105    ///
2106    /// `total` is the number of items. `visible_height` limits how many rows
2107    /// are rendered. The closure `f` is called only for visible indices.
2108    pub fn virtual_list(
2109        &mut self,
2110        state: &mut ListState,
2111        visible_height: usize,
2112        f: impl Fn(&mut Context, usize),
2113    ) -> Response {
2114        if state.items.is_empty() {
2115            return Response::none();
2116        }
2117        state.selected = state.selected.min(state.items.len().saturating_sub(1));
2118        let interaction_id = self.next_interaction_id();
2119        let focused = self.register_focusable();
2120        let old_selected = state.selected;
2121
2122        if focused {
2123            let mut consumed_indices = Vec::new();
2124            for (i, event) in self.events.iter().enumerate() {
2125                if self.consumed[i] {
2126                    continue;
2127                }
2128                if let Event::Key(key) = event {
2129                    if key.kind != KeyEventKind::Press {
2130                        continue;
2131                    }
2132                    match key.code {
2133                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
2134                            let _ = handle_vertical_nav(
2135                                &mut state.selected,
2136                                state.items.len().saturating_sub(1),
2137                                key.code.clone(),
2138                            );
2139                            consumed_indices.push(i);
2140                        }
2141                        KeyCode::PageUp => {
2142                            state.selected = state.selected.saturating_sub(visible_height);
2143                            consumed_indices.push(i);
2144                        }
2145                        KeyCode::PageDown => {
2146                            state.selected = (state.selected + visible_height)
2147                                .min(state.items.len().saturating_sub(1));
2148                            consumed_indices.push(i);
2149                        }
2150                        KeyCode::Home => {
2151                            state.selected = 0;
2152                            consumed_indices.push(i);
2153                        }
2154                        KeyCode::End => {
2155                            state.selected = state.items.len().saturating_sub(1);
2156                            consumed_indices.push(i);
2157                        }
2158                        _ => {}
2159                    }
2160                }
2161            }
2162            for idx in consumed_indices {
2163                self.consumed[idx] = true;
2164            }
2165        }
2166
2167        let start = if state.selected >= visible_height {
2168            state.selected - visible_height + 1
2169        } else {
2170            0
2171        };
2172        let end = (start + visible_height).min(state.items.len());
2173
2174        self.commands.push(Command::BeginContainer {
2175            direction: Direction::Column,
2176            gap: 0,
2177            align: Align::Start,
2178            align_self: None,
2179            justify: Justify::Start,
2180            border: None,
2181            border_sides: BorderSides::all(),
2182            border_style: Style::new().fg(self.theme.border),
2183            bg_color: None,
2184            padding: Padding::default(),
2185            margin: Margin::default(),
2186            constraints: Constraints::default(),
2187            title: None,
2188            grow: 0,
2189            group_name: None,
2190        });
2191
2192        if start > 0 {
2193            let hidden = start.to_string();
2194            let mut line = String::with_capacity(hidden.len() + 10);
2195            line.push_str("  ↑ ");
2196            line.push_str(&hidden);
2197            line.push_str(" more");
2198            self.styled(line, Style::new().fg(self.theme.text_dim).dim());
2199        }
2200
2201        for idx in start..end {
2202            f(self, idx);
2203        }
2204
2205        let remaining = state.items.len().saturating_sub(end);
2206        if remaining > 0 {
2207            let hidden = remaining.to_string();
2208            let mut line = String::with_capacity(hidden.len() + 10);
2209            line.push_str("  ↓ ");
2210            line.push_str(&hidden);
2211            line.push_str(" more");
2212            self.styled(line, Style::new().fg(self.theme.text_dim).dim());
2213        }
2214
2215        self.commands.push(Command::EndContainer);
2216        self.last_text_idx = None;
2217        let mut response = self.response_for(interaction_id);
2218        response.focused = focused;
2219        response.changed = state.selected != old_selected;
2220        response
2221    }
2222
2223    // ── command palette ──────────────────────────────────────────────
2224
2225    /// Render a command palette overlay.
2226    pub fn command_palette(&mut self, state: &mut CommandPaletteState) -> Response {
2227        if !state.open {
2228            return Response::none();
2229        }
2230
2231        state.last_selected = None;
2232        let interaction_id = self.next_interaction_id();
2233
2234        let filtered = state.filtered_indices();
2235        let sel = state.selected().min(filtered.len().saturating_sub(1));
2236        state.set_selected(sel);
2237
2238        let mut consumed_indices = Vec::new();
2239
2240        for (i, event) in self.events.iter().enumerate() {
2241            if self.consumed[i] {
2242                continue;
2243            }
2244            if let Event::Key(key) = event {
2245                if key.kind != KeyEventKind::Press {
2246                    continue;
2247                }
2248                match key.code {
2249                    KeyCode::Esc => {
2250                        state.open = false;
2251                        consumed_indices.push(i);
2252                    }
2253                    KeyCode::Up => {
2254                        let s = state.selected();
2255                        state.set_selected(s.saturating_sub(1));
2256                        consumed_indices.push(i);
2257                    }
2258                    KeyCode::Down => {
2259                        let s = state.selected();
2260                        state.set_selected((s + 1).min(filtered.len().saturating_sub(1)));
2261                        consumed_indices.push(i);
2262                    }
2263                    KeyCode::Enter => {
2264                        if let Some(&cmd_idx) = filtered.get(state.selected()) {
2265                            state.last_selected = Some(cmd_idx);
2266                            state.open = false;
2267                        }
2268                        consumed_indices.push(i);
2269                    }
2270                    KeyCode::Backspace => {
2271                        if state.cursor > 0 {
2272                            let byte_idx = byte_index_for_char(&state.input, state.cursor - 1);
2273                            let end_idx = byte_index_for_char(&state.input, state.cursor);
2274                            state.input.replace_range(byte_idx..end_idx, "");
2275                            state.cursor -= 1;
2276                            state.set_selected(0);
2277                        }
2278                        consumed_indices.push(i);
2279                    }
2280                    KeyCode::Char(ch) => {
2281                        let byte_idx = byte_index_for_char(&state.input, state.cursor);
2282                        state.input.insert(byte_idx, ch);
2283                        state.cursor += 1;
2284                        state.set_selected(0);
2285                        consumed_indices.push(i);
2286                    }
2287                    _ => {}
2288                }
2289            }
2290        }
2291        for idx in consumed_indices {
2292            self.consumed[idx] = true;
2293        }
2294
2295        let filtered = state.filtered_indices();
2296
2297        let _ = self.modal(|ui| {
2298            let primary = ui.theme.primary;
2299            let _ = ui
2300                .container()
2301                .border(Border::Rounded)
2302                .border_style(Style::new().fg(primary))
2303                .pad(1)
2304                .max_w(60)
2305                .col(|ui| {
2306                    let border_color = ui.theme.primary;
2307                    let _ = ui
2308                        .bordered(Border::Rounded)
2309                        .border_style(Style::new().fg(border_color))
2310                        .px(1)
2311                        .col(|ui| {
2312                            let display = if state.input.is_empty() {
2313                                "Type to search...".to_string()
2314                            } else {
2315                                state.input.clone()
2316                            };
2317                            let style = if state.input.is_empty() {
2318                                Style::new().dim().fg(ui.theme.text_dim)
2319                            } else {
2320                                Style::new().fg(ui.theme.text)
2321                            };
2322                            ui.styled(display, style);
2323                        });
2324
2325                    for (list_idx, &cmd_idx) in filtered.iter().enumerate() {
2326                        let cmd = &state.commands[cmd_idx];
2327                        let is_selected = list_idx == state.selected();
2328                        let style = if is_selected {
2329                            Style::new().bold().fg(ui.theme.primary)
2330                        } else {
2331                            Style::new().fg(ui.theme.text)
2332                        };
2333                        let prefix = if is_selected { "▸ " } else { "  " };
2334                        let shortcut_text = cmd
2335                            .shortcut
2336                            .as_deref()
2337                            .map(|s| {
2338                                let mut text = String::with_capacity(s.len() + 4);
2339                                text.push_str("  (");
2340                                text.push_str(s);
2341                                text.push(')');
2342                                text
2343                            })
2344                            .unwrap_or_default();
2345                        let mut line = String::with_capacity(
2346                            prefix.len() + cmd.label.len() + shortcut_text.len(),
2347                        );
2348                        line.push_str(prefix);
2349                        line.push_str(&cmd.label);
2350                        line.push_str(&shortcut_text);
2351                        ui.styled(line, style);
2352                        if is_selected && !cmd.description.is_empty() {
2353                            let mut desc = String::with_capacity(4 + cmd.description.len());
2354                            desc.push_str("    ");
2355                            desc.push_str(&cmd.description);
2356                            ui.styled(desc, Style::new().dim().fg(ui.theme.text_dim));
2357                        }
2358                    }
2359
2360                    if filtered.is_empty() {
2361                        ui.styled(
2362                            "  No matching commands",
2363                            Style::new().dim().fg(ui.theme.text_dim),
2364                        );
2365                    }
2366                });
2367        });
2368
2369        let mut response = self.response_for(interaction_id);
2370        response.changed = state.last_selected.is_some();
2371        response
2372    }
2373
2374    // ── markdown ─────────────────────────────────────────────────────
2375
2376    /// Render a markdown string with basic formatting.
2377    ///
2378    /// Supports headers (`#`), bold (`**`), italic (`*`), inline code (`` ` ``),
2379    /// unordered lists (`-`/`*`), ordered lists (`1.`), blockquotes (`>`),
2380    /// horizontal rules (`---`), links (`[text](url)`), image placeholders
2381    /// (`![alt](url)`), code blocks with syntax highlighting, and GFM-style
2382    /// pipe tables. Paragraph text auto-wraps to container width.
2383    pub fn markdown(&mut self, text: &str) -> Response {
2384        self.commands.push(Command::BeginContainer {
2385            direction: Direction::Column,
2386            gap: 0,
2387            align: Align::Start,
2388            align_self: None,
2389            justify: Justify::Start,
2390            border: None,
2391            border_sides: BorderSides::all(),
2392            border_style: Style::new().fg(self.theme.border),
2393            bg_color: None,
2394            padding: Padding::default(),
2395            margin: Margin::default(),
2396            constraints: Constraints::default(),
2397            title: None,
2398            grow: 0,
2399            group_name: None,
2400        });
2401        self.interaction_count += 1;
2402
2403        let text_style = Style::new().fg(self.theme.text);
2404        let bold_style = Style::new().fg(self.theme.text).bold();
2405        let code_style = Style::new().fg(self.theme.accent);
2406        let border_style = Style::new().fg(self.theme.border).dim();
2407
2408        let mut in_code_block = false;
2409        let mut code_block_lang = String::new();
2410        let mut code_block_lines: Vec<String> = Vec::new();
2411        let mut table_lines: Vec<String> = Vec::new();
2412
2413        for line in text.lines() {
2414            let trimmed = line.trim();
2415
2416            if in_code_block {
2417                if trimmed.starts_with("```") {
2418                    in_code_block = false;
2419                    let code_content = code_block_lines.join("\n");
2420                    let theme = self.theme;
2421                    let highlighted: Option<Vec<Vec<(String, Style)>>> =
2422                        crate::syntax::highlight_code(&code_content, &code_block_lang, &theme);
2423                    let _ = self.container().bg(theme.surface).p(1).col(|ui| {
2424                        if let Some(ref hl_lines) = highlighted {
2425                            for segs in hl_lines {
2426                                if segs.is_empty() {
2427                                    ui.text(" ");
2428                                } else {
2429                                    ui.line(|ui| {
2430                                        for (t, s) in segs {
2431                                            ui.styled(t, *s);
2432                                        }
2433                                    });
2434                                }
2435                            }
2436                        } else {
2437                            for cl in &code_block_lines {
2438                                ui.styled(cl, code_style);
2439                            }
2440                        }
2441                    });
2442                    code_block_lang.clear();
2443                    code_block_lines.clear();
2444                } else {
2445                    code_block_lines.push(line.to_string());
2446                }
2447                continue;
2448            }
2449
2450            // Table row detection — collect lines starting with `|`
2451            if trimmed.starts_with('|') && trimmed.matches('|').count() >= 2 {
2452                table_lines.push(trimmed.to_string());
2453                continue;
2454            }
2455            // Flush accumulated table rows when a non-table line is encountered
2456            if !table_lines.is_empty() {
2457                self.render_markdown_table(
2458                    &table_lines,
2459                    text_style,
2460                    bold_style,
2461                    code_style,
2462                    border_style,
2463                );
2464                table_lines.clear();
2465            }
2466
2467            if trimmed.is_empty() {
2468                self.text(" ");
2469                continue;
2470            }
2471            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
2472                self.styled("─".repeat(40), border_style);
2473                continue;
2474            }
2475            if let Some(quote) = trimmed.strip_prefix("> ") {
2476                let quote_style = Style::new().fg(self.theme.text_dim).italic();
2477                let bar_style = Style::new().fg(self.theme.border);
2478                self.line(|ui| {
2479                    ui.styled("│ ", bar_style);
2480                    ui.styled(quote, quote_style);
2481                });
2482            } else if let Some(heading) = trimmed.strip_prefix("### ") {
2483                self.styled(heading, Style::new().bold().fg(self.theme.accent));
2484            } else if let Some(heading) = trimmed.strip_prefix("## ") {
2485                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
2486            } else if let Some(heading) = trimmed.strip_prefix("# ") {
2487                self.styled(heading, Style::new().bold().fg(self.theme.primary));
2488            } else if let Some(item) = trimmed
2489                .strip_prefix("- ")
2490                .or_else(|| trimmed.strip_prefix("* "))
2491            {
2492                self.line_wrap(|ui| {
2493                    ui.styled("  • ", text_style);
2494                    Self::render_md_inline_into(ui, item, text_style, bold_style, code_style);
2495                });
2496            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
2497                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
2498                if parts.len() == 2 {
2499                    self.line_wrap(|ui| {
2500                        let mut prefix = String::with_capacity(4 + parts[0].len());
2501                        prefix.push_str("  ");
2502                        prefix.push_str(parts[0]);
2503                        prefix.push_str(". ");
2504                        ui.styled(prefix, text_style);
2505                        Self::render_md_inline_into(
2506                            ui, parts[1], text_style, bold_style, code_style,
2507                        );
2508                    });
2509                } else {
2510                    self.text(trimmed);
2511                }
2512            } else if let Some(lang) = trimmed.strip_prefix("```") {
2513                in_code_block = true;
2514                code_block_lang = lang.trim().to_string();
2515            } else {
2516                self.render_md_inline(trimmed, text_style, bold_style, code_style);
2517            }
2518        }
2519
2520        if in_code_block && !code_block_lines.is_empty() {
2521            for cl in &code_block_lines {
2522                self.styled(cl, code_style);
2523            }
2524        }
2525
2526        // Flush any remaining table rows at end of input
2527        if !table_lines.is_empty() {
2528            self.render_markdown_table(
2529                &table_lines,
2530                text_style,
2531                bold_style,
2532                code_style,
2533                border_style,
2534            );
2535        }
2536
2537        self.commands.push(Command::EndContainer);
2538        self.last_text_idx = None;
2539        Response::none()
2540    }
2541
2542    /// Render a GFM-style pipe table collected from markdown lines.
2543    fn render_markdown_table(
2544        &mut self,
2545        lines: &[String],
2546        text_style: Style,
2547        bold_style: Style,
2548        code_style: Style,
2549        border_style: Style,
2550    ) {
2551        if lines.is_empty() {
2552            return;
2553        }
2554
2555        // Separate header, separator, and data rows
2556        let is_separator = |line: &str| -> bool {
2557            let inner = line.trim_matches('|').trim();
2558            !inner.is_empty()
2559                && inner
2560                    .chars()
2561                    .all(|c| c == '-' || c == ':' || c == '|' || c == ' ')
2562        };
2563
2564        let parse_row = |line: &str| -> Vec<String> {
2565            let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
2566            trimmed.split('|').map(|c| c.trim().to_string()).collect()
2567        };
2568
2569        let mut header: Option<Vec<String>> = None;
2570        let mut data_rows: Vec<Vec<String>> = Vec::new();
2571        let mut found_separator = false;
2572
2573        for (i, line) in lines.iter().enumerate() {
2574            if is_separator(line) {
2575                found_separator = true;
2576                continue;
2577            }
2578            if i == 0 && !found_separator {
2579                header = Some(parse_row(line));
2580            } else {
2581                data_rows.push(parse_row(line));
2582            }
2583        }
2584
2585        // If no separator found, treat first row as header anyway
2586        if !found_separator && header.is_none() && !data_rows.is_empty() {
2587            header = Some(data_rows.remove(0));
2588        }
2589
2590        // Calculate column count and widths
2591        let all_rows: Vec<&Vec<String>> = header.iter().chain(data_rows.iter()).collect();
2592        let col_count = all_rows.iter().map(|r| r.len()).max().unwrap_or(0);
2593        if col_count == 0 {
2594            return;
2595        }
2596        let mut col_widths = vec![0usize; col_count];
2597        // Strip markdown formatting for accurate display-width calculation
2598        let stripped_rows: Vec<Vec<String>> = all_rows
2599            .iter()
2600            .map(|row| row.iter().map(|c| Self::md_strip(c)).collect())
2601            .collect();
2602        for row in &stripped_rows {
2603            for (i, cell) in row.iter().enumerate() {
2604                if i < col_count {
2605                    col_widths[i] = col_widths[i].max(UnicodeWidthStr::width(cell.as_str()));
2606                }
2607            }
2608        }
2609
2610        // Top border ┌───┬───┐
2611        let mut top = String::from("┌");
2612        for (i, &w) in col_widths.iter().enumerate() {
2613            for _ in 0..w + 2 {
2614                top.push('─');
2615            }
2616            top.push(if i < col_count - 1 { '┬' } else { '┐' });
2617        }
2618        self.styled(&top, border_style);
2619
2620        // Header row │ H1 │ H2 │
2621        if let Some(ref hdr) = header {
2622            self.line(|ui| {
2623                ui.styled("│", border_style);
2624                for (i, w) in col_widths.iter().enumerate() {
2625                    let raw = hdr.get(i).map(String::as_str).unwrap_or("");
2626                    let display_text = Self::md_strip(raw);
2627                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
2628                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
2629                    ui.styled(" ", bold_style);
2630                    ui.styled(&display_text, bold_style);
2631                    ui.styled(padding, bold_style);
2632                    ui.styled(" │", border_style);
2633                }
2634            });
2635
2636            // Separator ├───┼───┤
2637            let mut sep = String::from("├");
2638            for (i, &w) in col_widths.iter().enumerate() {
2639                for _ in 0..w + 2 {
2640                    sep.push('─');
2641                }
2642                sep.push(if i < col_count - 1 { '┼' } else { '┤' });
2643            }
2644            self.styled(&sep, border_style);
2645        }
2646
2647        // Data rows — render with inline formatting (bold, italic, code, links)
2648        for row in &data_rows {
2649            self.line(|ui| {
2650                ui.styled("│", border_style);
2651                for (i, w) in col_widths.iter().enumerate() {
2652                    let raw = row.get(i).map(String::as_str).unwrap_or("");
2653                    let display_text = Self::md_strip(raw);
2654                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
2655                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
2656                    ui.styled(" ", text_style);
2657                    Self::render_md_inline_into(ui, raw, text_style, bold_style, code_style);
2658                    ui.styled(padding, text_style);
2659                    ui.styled(" │", border_style);
2660                }
2661            });
2662        }
2663
2664        // Bottom border └───┴───┘
2665        let mut bot = String::from("└");
2666        for (i, &w) in col_widths.iter().enumerate() {
2667            for _ in 0..w + 2 {
2668                bot.push('─');
2669            }
2670            bot.push(if i < col_count - 1 { '┴' } else { '┘' });
2671        }
2672        self.styled(&bot, border_style);
2673    }
2674
2675    pub(crate) fn parse_inline_segments(
2676        text: &str,
2677        base: Style,
2678        bold: Style,
2679        code: Style,
2680    ) -> Vec<(String, Style)> {
2681        let mut segments: Vec<(String, Style)> = Vec::new();
2682        let mut current = String::new();
2683        let chars: Vec<char> = text.chars().collect();
2684        let mut i = 0;
2685        while i < chars.len() {
2686            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2687                let rest: String = chars[i + 2..].iter().collect();
2688                if let Some(end) = rest.find("**") {
2689                    if !current.is_empty() {
2690                        segments.push((std::mem::take(&mut current), base));
2691                    }
2692                    let inner: String = rest[..end].to_string();
2693                    let char_count = inner.chars().count();
2694                    segments.push((inner, bold));
2695                    i += 2 + char_count + 2;
2696                    continue;
2697                }
2698            }
2699            if chars[i] == '*'
2700                && (i + 1 >= chars.len() || chars[i + 1] != '*')
2701                && (i == 0 || chars[i - 1] != '*')
2702            {
2703                let rest: String = chars[i + 1..].iter().collect();
2704                if let Some(end) = rest.find('*') {
2705                    if !current.is_empty() {
2706                        segments.push((std::mem::take(&mut current), base));
2707                    }
2708                    let inner: String = rest[..end].to_string();
2709                    let char_count = inner.chars().count();
2710                    segments.push((inner, base.italic()));
2711                    i += 1 + char_count + 1;
2712                    continue;
2713                }
2714            }
2715            if chars[i] == '`' {
2716                let rest: String = chars[i + 1..].iter().collect();
2717                if let Some(end) = rest.find('`') {
2718                    if !current.is_empty() {
2719                        segments.push((std::mem::take(&mut current), base));
2720                    }
2721                    let inner: String = rest[..end].to_string();
2722                    let char_count = inner.chars().count();
2723                    segments.push((inner, code));
2724                    i += 1 + char_count + 1;
2725                    continue;
2726                }
2727            }
2728            current.push(chars[i]);
2729            i += 1;
2730        }
2731        if !current.is_empty() {
2732            segments.push((current, base));
2733        }
2734        segments
2735    }
2736
2737    /// Render a markdown line with link/image support.
2738    ///
2739    /// Parses `[text](url)` as clickable OSC 8 links and `![alt](url)` as
2740    /// image placeholders, delegating the rest to `parse_inline_segments`.
2741    fn render_md_inline(
2742        &mut self,
2743        text: &str,
2744        text_style: Style,
2745        bold_style: Style,
2746        code_style: Style,
2747    ) {
2748        let items = Self::split_md_links(text);
2749
2750        // Fast path: no links/images found
2751        if items.len() == 1 {
2752            if let MdInline::Text(ref t) = items[0] {
2753                let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
2754                if segs.len() <= 1 {
2755                    self.text(text)
2756                        .wrap()
2757                        .fg(text_style.fg.unwrap_or(Color::Reset));
2758                } else {
2759                    self.line_wrap(|ui| {
2760                        for (s, st) in segs {
2761                            ui.styled(s, st);
2762                        }
2763                    });
2764                }
2765                return;
2766            }
2767        }
2768
2769        // Mixed content — line_wrap collects both Text and Link commands
2770        self.line_wrap(|ui| {
2771            for item in &items {
2772                match item {
2773                    MdInline::Text(t) => {
2774                        let segs =
2775                            Self::parse_inline_segments(t, text_style, bold_style, code_style);
2776                        for (s, st) in segs {
2777                            ui.styled(s, st);
2778                        }
2779                    }
2780                    MdInline::Link { text, url } => {
2781                        ui.link(text.clone(), url.clone());
2782                    }
2783                    MdInline::Image { alt, .. } => {
2784                        // Render alt text only — matches md_strip() output for width consistency
2785                        ui.styled(alt.as_str(), code_style);
2786                    }
2787                }
2788            }
2789        });
2790    }
2791
2792    /// Emit inline markdown segments into an existing context.
2793    ///
2794    /// Unlike `render_md_inline` which wraps in its own `line_wrap`,
2795    /// this emits raw commands into `ui` so callers can prepend a bullet
2796    /// or prefix before calling this inside their own `line_wrap`.
2797    fn render_md_inline_into(
2798        ui: &mut Context,
2799        text: &str,
2800        text_style: Style,
2801        bold_style: Style,
2802        code_style: Style,
2803    ) {
2804        let items = Self::split_md_links(text);
2805        for item in &items {
2806            match item {
2807                MdInline::Text(t) => {
2808                    let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
2809                    for (s, st) in segs {
2810                        ui.styled(s, st);
2811                    }
2812                }
2813                MdInline::Link { text, url } => {
2814                    ui.link(text.clone(), url.clone());
2815                }
2816                MdInline::Image { alt, .. } => {
2817                    ui.styled(alt.as_str(), code_style);
2818                }
2819            }
2820        }
2821    }
2822
2823    /// Split a markdown line into text, link, and image segments.
2824    fn split_md_links(text: &str) -> Vec<MdInline> {
2825        let chars: Vec<char> = text.chars().collect();
2826        let mut items: Vec<MdInline> = Vec::new();
2827        let mut current = String::new();
2828        let mut i = 0;
2829
2830        while i < chars.len() {
2831            // Image: ![alt](url)
2832            if chars[i] == '!' && i + 1 < chars.len() && chars[i + 1] == '[' {
2833                if let Some((alt, _url, consumed)) = Self::parse_md_bracket_paren(&chars, i + 1) {
2834                    if !current.is_empty() {
2835                        items.push(MdInline::Text(std::mem::take(&mut current)));
2836                    }
2837                    items.push(MdInline::Image { alt });
2838                    i += 1 + consumed;
2839                    continue;
2840                }
2841            }
2842            // Link: [text](url)
2843            if chars[i] == '[' {
2844                if let Some((link_text, url, consumed)) = Self::parse_md_bracket_paren(&chars, i) {
2845                    if !current.is_empty() {
2846                        items.push(MdInline::Text(std::mem::take(&mut current)));
2847                    }
2848                    items.push(MdInline::Link {
2849                        text: link_text,
2850                        url,
2851                    });
2852                    i += consumed;
2853                    continue;
2854                }
2855            }
2856            current.push(chars[i]);
2857            i += 1;
2858        }
2859        if !current.is_empty() {
2860            items.push(MdInline::Text(current));
2861        }
2862        if items.is_empty() {
2863            items.push(MdInline::Text(String::new()));
2864        }
2865        items
2866    }
2867
2868    /// Parse `[text](url)` starting at `chars[start]` which must be `[`.
2869    /// Returns `(text, url, chars_consumed)` or `None` if no match.
2870    fn parse_md_bracket_paren(chars: &[char], start: usize) -> Option<(String, String, usize)> {
2871        if start >= chars.len() || chars[start] != '[' {
2872            return None;
2873        }
2874        // Find closing ]
2875        let mut depth = 0i32;
2876        let mut bracket_end = None;
2877        for (j, &ch) in chars.iter().enumerate().skip(start) {
2878            if ch == '[' {
2879                depth += 1;
2880            } else if ch == ']' {
2881                depth -= 1;
2882                if depth == 0 {
2883                    bracket_end = Some(j);
2884                    break;
2885                }
2886            }
2887        }
2888        let bracket_end = bracket_end?;
2889        // Must be followed by (
2890        if bracket_end + 1 >= chars.len() || chars[bracket_end + 1] != '(' {
2891            return None;
2892        }
2893        // Find closing )
2894        let paren_start = bracket_end + 2;
2895        let mut paren_end = None;
2896        let mut paren_depth = 1i32;
2897        for (j, &ch) in chars.iter().enumerate().skip(paren_start) {
2898            if ch == '(' {
2899                paren_depth += 1;
2900            } else if ch == ')' {
2901                paren_depth -= 1;
2902                if paren_depth == 0 {
2903                    paren_end = Some(j);
2904                    break;
2905                }
2906            }
2907        }
2908        let paren_end = paren_end?;
2909        let text: String = chars[start + 1..bracket_end].iter().collect();
2910        let url: String = chars[paren_start..paren_end].iter().collect();
2911        let consumed = paren_end - start + 1;
2912        Some((text, url, consumed))
2913    }
2914
2915    /// Strip markdown inline formatting, returning plain display text.
2916    ///
2917    /// `**bold**` → `bold`, `*italic*` → `italic`, `` `code` `` → `code`,
2918    /// `[text](url)` → `text`, `![alt](url)` → `alt`.
2919    fn md_strip(text: &str) -> String {
2920        let mut result = String::with_capacity(text.len());
2921        let chars: Vec<char> = text.chars().collect();
2922        let mut i = 0;
2923        while i < chars.len() {
2924            // Image ![alt](url) → alt
2925            if chars[i] == '!' && i + 1 < chars.len() && chars[i + 1] == '[' {
2926                if let Some((alt, _, consumed)) = Self::parse_md_bracket_paren(&chars, i + 1) {
2927                    result.push_str(&alt);
2928                    i += 1 + consumed;
2929                    continue;
2930                }
2931            }
2932            // Link [text](url) → text
2933            if chars[i] == '[' {
2934                if let Some((link_text, _, consumed)) = Self::parse_md_bracket_paren(&chars, i) {
2935                    result.push_str(&link_text);
2936                    i += consumed;
2937                    continue;
2938                }
2939            }
2940            // Bold **text**
2941            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2942                let rest: String = chars[i + 2..].iter().collect();
2943                if let Some(end) = rest.find("**") {
2944                    let inner = &rest[..end];
2945                    result.push_str(inner);
2946                    i += 2 + inner.chars().count() + 2;
2947                    continue;
2948                }
2949            }
2950            // Italic *text*
2951            if chars[i] == '*'
2952                && (i + 1 >= chars.len() || chars[i + 1] != '*')
2953                && (i == 0 || chars[i - 1] != '*')
2954            {
2955                let rest: String = chars[i + 1..].iter().collect();
2956                if let Some(end) = rest.find('*') {
2957                    let inner = &rest[..end];
2958                    result.push_str(inner);
2959                    i += 1 + inner.chars().count() + 1;
2960                    continue;
2961                }
2962            }
2963            // Inline code `text`
2964            if chars[i] == '`' {
2965                let rest: String = chars[i + 1..].iter().collect();
2966                if let Some(end) = rest.find('`') {
2967                    let inner = &rest[..end];
2968                    result.push_str(inner);
2969                    i += 1 + inner.chars().count() + 1;
2970                    continue;
2971                }
2972            }
2973            result.push(chars[i]);
2974            i += 1;
2975        }
2976        result
2977    }
2978
2979    // ── key sequence ─────────────────────────────────────────────────
2980
2981    /// Check if a sequence of character keys was pressed across recent frames.
2982    ///
2983    /// Matches when each character in `seq` appears in consecutive unconsumed
2984    /// key events within this frame. For single-frame sequences only (e.g., "gg").
2985    pub fn key_seq(&self, seq: &str) -> bool {
2986        if seq.is_empty() {
2987            return false;
2988        }
2989        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
2990            return false;
2991        }
2992        let target: Vec<char> = seq.chars().collect();
2993        let mut matched = 0;
2994        for (i, event) in self.events.iter().enumerate() {
2995            if self.consumed[i] {
2996                continue;
2997            }
2998            if let Event::Key(key) = event {
2999                if key.kind != KeyEventKind::Press {
3000                    continue;
3001                }
3002                if let KeyCode::Char(c) = key.code {
3003                    if c == target[matched] {
3004                        matched += 1;
3005                        if matched == target.len() {
3006                            return true;
3007                        }
3008                    } else {
3009                        matched = 0;
3010                        if c == target[0] {
3011                            matched = 1;
3012                        }
3013                    }
3014                }
3015            }
3016        }
3017        false
3018    }
3019
3020    /// Render a horizontal divider line.
3021    ///
3022    /// The line is drawn with the theme's border color and expands to fill the
3023    /// container width.
3024    pub fn separator(&mut self) -> &mut Self {
3025        self.commands.push(Command::Text {
3026            content: "─".repeat(200),
3027            cursor_offset: None,
3028            style: Style::new().fg(self.theme.border).dim(),
3029            grow: 0,
3030            align: Align::Start,
3031            wrap: false,
3032            truncate: false,
3033            margin: Margin::default(),
3034            constraints: Constraints::default(),
3035        });
3036        self.last_text_idx = Some(self.commands.len() - 1);
3037        self
3038    }
3039
3040    /// Render a horizontal separator line with a custom color.
3041    pub fn separator_colored(&mut self, color: Color) -> &mut Self {
3042        self.commands.push(Command::Text {
3043            content: "─".repeat(200),
3044            cursor_offset: None,
3045            style: Style::new().fg(color),
3046            grow: 0,
3047            align: Align::Start,
3048            wrap: false,
3049            truncate: false,
3050            margin: Margin::default(),
3051            constraints: Constraints::default(),
3052        });
3053        self.last_text_idx = Some(self.commands.len() - 1);
3054        self
3055    }
3056
3057    /// Render a help bar showing keybinding hints.
3058    ///
3059    /// `bindings` is a slice of `(key, action)` pairs. Keys are rendered in the
3060    /// theme's primary color; actions in the dim text color. Pairs are separated
3061    /// by a `·` character.
3062    pub fn help(&mut self, bindings: &[(&str, &str)]) -> Response {
3063        if bindings.is_empty() {
3064            return Response::none();
3065        }
3066
3067        self.interaction_count += 1;
3068        self.commands.push(Command::BeginContainer {
3069            direction: Direction::Row,
3070            gap: 2,
3071            align: Align::Start,
3072            align_self: None,
3073            justify: Justify::Start,
3074            border: None,
3075            border_sides: BorderSides::all(),
3076            border_style: Style::new().fg(self.theme.border),
3077            bg_color: None,
3078            padding: Padding::default(),
3079            margin: Margin::default(),
3080            constraints: Constraints::default(),
3081            title: None,
3082            grow: 0,
3083            group_name: None,
3084        });
3085        for (idx, (key, action)) in bindings.iter().enumerate() {
3086            if idx > 0 {
3087                self.styled("·", Style::new().fg(self.theme.text_dim));
3088            }
3089            self.styled(*key, Style::new().bold().fg(self.theme.primary));
3090            self.styled(*action, Style::new().fg(self.theme.text_dim));
3091        }
3092        self.commands.push(Command::EndContainer);
3093        self.last_text_idx = None;
3094
3095        Response::none()
3096    }
3097
3098    /// Render a help bar with custom key/description colors.
3099    pub fn help_colored(
3100        &mut self,
3101        bindings: &[(&str, &str)],
3102        key_color: Color,
3103        text_color: Color,
3104    ) -> Response {
3105        if bindings.is_empty() {
3106            return Response::none();
3107        }
3108
3109        self.interaction_count += 1;
3110        self.commands.push(Command::BeginContainer {
3111            direction: Direction::Row,
3112            gap: 2,
3113            align: Align::Start,
3114            align_self: None,
3115            justify: Justify::Start,
3116            border: None,
3117            border_sides: BorderSides::all(),
3118            border_style: Style::new().fg(self.theme.border),
3119            bg_color: None,
3120            padding: Padding::default(),
3121            margin: Margin::default(),
3122            constraints: Constraints::default(),
3123            title: None,
3124            grow: 0,
3125            group_name: None,
3126        });
3127        for (idx, (key, action)) in bindings.iter().enumerate() {
3128            if idx > 0 {
3129                self.styled("·", Style::new().fg(text_color));
3130            }
3131            self.styled(*key, Style::new().bold().fg(key_color));
3132            self.styled(*action, Style::new().fg(text_color));
3133        }
3134        self.commands.push(Command::EndContainer);
3135        self.last_text_idx = None;
3136
3137        Response::none()
3138    }
3139
3140    // ── events ───────────────────────────────────────────────────────
3141
3142    /// Check if a character key was pressed this frame.
3143    ///
3144    /// Returns `true` if the key event has not been consumed by another widget.
3145    pub fn key(&self, c: char) -> bool {
3146        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3147            return false;
3148        }
3149        self.events.iter().enumerate().any(|(i, e)| {
3150            !self.consumed[i]
3151                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
3152        })
3153    }
3154
3155    /// Check if a specific key code was pressed this frame.
3156    ///
3157    /// Returns `true` if the key event has not been consumed by another widget.
3158    /// Blocked when a modal/overlay is active and the caller is outside the overlay.
3159    /// Use [`raw_key_code`](Self::raw_key_code) for global shortcuts that must work
3160    /// regardless of modal/overlay state.
3161    pub fn key_code(&self, code: KeyCode) -> bool {
3162        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3163            return false;
3164        }
3165        self.events.iter().enumerate().any(|(i, e)| {
3166            !self.consumed[i]
3167                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
3168        })
3169    }
3170
3171    /// Check if a specific key code was pressed this frame, ignoring modal/overlay state.
3172    ///
3173    /// Unlike [`key_code`](Self::key_code), this method bypasses the modal/overlay guard
3174    /// so it works even when a modal or overlay is active. Use this for global shortcuts
3175    /// (e.g. Esc to close a modal, Ctrl+Q to quit) that must always be reachable.
3176    ///
3177    /// Returns `true` if the key event has not been consumed by another widget.
3178    pub fn raw_key_code(&self, code: KeyCode) -> bool {
3179        self.events.iter().enumerate().any(|(i, e)| {
3180            !self.consumed[i]
3181                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
3182        })
3183    }
3184
3185    /// Check if a character key was released this frame.
3186    ///
3187    /// Returns `true` if the key release event has not been consumed by another widget.
3188    pub fn key_release(&self, c: char) -> bool {
3189        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3190            return false;
3191        }
3192        self.events.iter().enumerate().any(|(i, e)| {
3193            !self.consumed[i]
3194                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
3195        })
3196    }
3197
3198    /// Check if a specific key code was released this frame.
3199    ///
3200    /// Returns `true` if the key release event has not been consumed by another widget.
3201    pub fn key_code_release(&self, code: KeyCode) -> bool {
3202        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3203            return false;
3204        }
3205        self.events.iter().enumerate().any(|(i, e)| {
3206            !self.consumed[i]
3207                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
3208        })
3209    }
3210
3211    /// Check for a character key press and consume the event, preventing other
3212    /// handlers from seeing it.
3213    ///
3214    /// Returns `true` if the key was found unconsumed and is now consumed.
3215    /// Unlike [`key()`](Self::key) which peeks without consuming, this claims
3216    /// exclusive ownership of the event.
3217    ///
3218    /// Call **after** widgets if you want widgets to have priority over your
3219    /// handler, or **before** widgets to intercept first.
3220    pub fn consume_key(&mut self, c: char) -> bool {
3221        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3222            return false;
3223        }
3224        for (i, event) in self.events.iter().enumerate() {
3225            if self.consumed[i] {
3226                continue;
3227            }
3228            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
3229            {
3230                self.consumed[i] = true;
3231                return true;
3232            }
3233        }
3234        false
3235    }
3236
3237    /// Check for a special key press and consume the event, preventing other
3238    /// handlers from seeing it.
3239    ///
3240    /// Returns `true` if the key was found unconsumed and is now consumed.
3241    /// Unlike [`key_code()`](Self::key_code) which peeks without consuming,
3242    /// this claims exclusive ownership of the event.
3243    ///
3244    /// Call **after** widgets if you want widgets to have priority over your
3245    /// handler, or **before** widgets to intercept first.
3246    pub fn consume_key_code(&mut self, code: KeyCode) -> bool {
3247        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3248            return false;
3249        }
3250        for (i, event) in self.events.iter().enumerate() {
3251            if self.consumed[i] {
3252                continue;
3253            }
3254            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code) {
3255                self.consumed[i] = true;
3256                return true;
3257            }
3258        }
3259        false
3260    }
3261
3262    /// Check if a character key with specific modifiers was pressed this frame.
3263    ///
3264    /// Returns `true` if the key event has not been consumed by another widget.
3265    pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
3266        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3267            return false;
3268        }
3269        self.events.iter().enumerate().any(|(i, e)| {
3270            !self.consumed[i]
3271                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
3272        })
3273    }
3274
3275    /// Like [`key_mod`](Self::key_mod) but bypasses the modal/overlay guard.
3276    pub fn raw_key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
3277        self.events.iter().enumerate().any(|(i, e)| {
3278            !self.consumed[i]
3279                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
3280        })
3281    }
3282
3283    /// Return the position of a left mouse button down event this frame, if any.
3284    ///
3285    /// Returns `None` if no unconsumed mouse-down event occurred.
3286    pub fn mouse_down(&self) -> Option<(u32, u32)> {
3287        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3288            return None;
3289        }
3290        self.events.iter().enumerate().find_map(|(i, event)| {
3291            if self.consumed[i] {
3292                return None;
3293            }
3294            if let Event::Mouse(mouse) = event {
3295                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3296                    return Some((mouse.x, mouse.y));
3297                }
3298            }
3299            None
3300        })
3301    }
3302
3303    /// Return the current mouse cursor position, if known.
3304    ///
3305    /// The position is updated on every mouse move or click event. Returns
3306    /// `None` until the first mouse event is received.
3307    pub fn mouse_pos(&self) -> Option<(u32, u32)> {
3308        self.mouse_pos
3309    }
3310
3311    /// Return the first unconsumed paste event text, if any.
3312    pub fn paste(&self) -> Option<&str> {
3313        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3314            return None;
3315        }
3316        self.events.iter().enumerate().find_map(|(i, event)| {
3317            if self.consumed[i] {
3318                return None;
3319            }
3320            if let Event::Paste(ref text) = event {
3321                return Some(text.as_str());
3322            }
3323            None
3324        })
3325    }
3326
3327    /// Check if an unconsumed scroll-up event occurred this frame.
3328    pub fn scroll_up(&self) -> bool {
3329        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3330            return false;
3331        }
3332        self.events.iter().enumerate().any(|(i, event)| {
3333            !self.consumed[i]
3334                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
3335        })
3336    }
3337
3338    /// Check if an unconsumed scroll-down event occurred this frame.
3339    pub fn scroll_down(&self) -> bool {
3340        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3341            return false;
3342        }
3343        self.events.iter().enumerate().any(|(i, event)| {
3344            !self.consumed[i]
3345                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
3346        })
3347    }
3348
3349    /// Check if an unconsumed scroll-left event occurred this frame.
3350    pub fn scroll_left(&self) -> bool {
3351        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3352            return false;
3353        }
3354        self.events.iter().enumerate().any(|(i, event)| {
3355            !self.consumed[i]
3356                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollLeft))
3357        })
3358    }
3359
3360    /// Check if an unconsumed scroll-right event occurred this frame.
3361    pub fn scroll_right(&self) -> bool {
3362        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3363            return false;
3364        }
3365        self.events.iter().enumerate().any(|(i, event)| {
3366            !self.consumed[i]
3367                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollRight))
3368        })
3369    }
3370
3371    /// Signal the run loop to exit after this frame.
3372    pub fn quit(&mut self) {
3373        self.should_quit = true;
3374    }
3375
3376    /// Copy text to the system clipboard via OSC 52.
3377    ///
3378    /// Works transparently over SSH connections. The text is queued and
3379    /// written to the terminal after the current frame renders.
3380    ///
3381    /// Requires a terminal that supports OSC 52 (most modern terminals:
3382    /// Ghostty, kitty, WezTerm, iTerm2, Windows Terminal).
3383    pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
3384        self.clipboard_text = Some(text.into());
3385    }
3386
3387    /// Get the current theme.
3388    pub fn theme(&self) -> &Theme {
3389        &self.theme
3390    }
3391
3392    /// Change the theme for subsequent rendering.
3393    ///
3394    /// All widgets rendered after this call will use the new theme's colors.
3395    pub fn set_theme(&mut self, theme: Theme) {
3396        self.theme = theme;
3397    }
3398
3399    /// Check if dark mode is active.
3400    pub fn is_dark_mode(&self) -> bool {
3401        self.dark_mode
3402    }
3403
3404    /// Set dark mode. When true, dark_* style variants are applied.
3405    pub fn set_dark_mode(&mut self, dark: bool) {
3406        self.dark_mode = dark;
3407    }
3408
3409    // ── info ─────────────────────────────────────────────────────────
3410
3411    /// Get the terminal width in cells.
3412    pub fn width(&self) -> u32 {
3413        self.area_width
3414    }
3415
3416    /// Get the current terminal width breakpoint.
3417    ///
3418    /// Returns a [`Breakpoint`] based on the terminal width:
3419    /// - `Xs`: < 40 columns
3420    /// - `Sm`: 40-79 columns
3421    /// - `Md`: 80-119 columns
3422    /// - `Lg`: 120-159 columns
3423    /// - `Xl`: >= 160 columns
3424    ///
3425    /// Use this for responsive layouts that adapt to terminal size:
3426    /// ```no_run
3427    /// # use slt::{Breakpoint, Context};
3428    /// # slt::run(|ui: &mut Context| {
3429    /// match ui.breakpoint() {
3430    ///     Breakpoint::Xs | Breakpoint::Sm => {
3431    ///         ui.col(|ui| { ui.text("Stacked layout"); });
3432    ///     }
3433    ///     _ => {
3434    ///         ui.row(|ui| { ui.text("Side-by-side layout"); });
3435    ///     }
3436    /// }
3437    /// # });
3438    /// ```
3439    pub fn breakpoint(&self) -> Breakpoint {
3440        let w = self.area_width;
3441        if w < 40 {
3442            Breakpoint::Xs
3443        } else if w < 80 {
3444            Breakpoint::Sm
3445        } else if w < 120 {
3446            Breakpoint::Md
3447        } else if w < 160 {
3448            Breakpoint::Lg
3449        } else {
3450            Breakpoint::Xl
3451        }
3452    }
3453
3454    /// Get the terminal height in cells.
3455    pub fn height(&self) -> u32 {
3456        self.area_height
3457    }
3458
3459    /// Get the current tick count (increments each frame).
3460    ///
3461    /// Useful for animations and time-based logic. The tick starts at 0 and
3462    /// increases by 1 on every rendered frame.
3463    pub fn tick(&self) -> u64 {
3464        self.tick
3465    }
3466
3467    /// Return whether the layout debugger is enabled.
3468    ///
3469    /// The debugger is toggled with F12 at runtime.
3470    pub fn debug_enabled(&self) -> bool {
3471        self.debug
3472    }
3473}
3474
3475fn calendar_month_name(month: u32) -> &'static str {
3476    match month {
3477        1 => "Jan",
3478        2 => "Feb",
3479        3 => "Mar",
3480        4 => "Apr",
3481        5 => "May",
3482        6 => "Jun",
3483        7 => "Jul",
3484        8 => "Aug",
3485        9 => "Sep",
3486        10 => "Oct",
3487        11 => "Nov",
3488        12 => "Dec",
3489        _ => "???",
3490    }
3491}
3492
3493fn calendar_move_cursor_by_days(state: &mut CalendarState, delta: i32) {
3494    let mut remaining = delta;
3495    while remaining != 0 {
3496        let days = CalendarState::days_in_month(state.year, state.month);
3497        if remaining > 0 {
3498            let forward = days.saturating_sub(state.cursor_day) as i32;
3499            if remaining <= forward {
3500                state.cursor_day += remaining as u32;
3501                return;
3502            }
3503
3504            remaining -= forward + 1;
3505            if state.month == 12 {
3506                state.month = 1;
3507                state.year += 1;
3508            } else {
3509                state.month += 1;
3510            }
3511            state.cursor_day = 1;
3512        } else {
3513            let backward = state.cursor_day.saturating_sub(1) as i32;
3514            if -remaining <= backward {
3515                state.cursor_day -= (-remaining) as u32;
3516                return;
3517            }
3518
3519            remaining += backward + 1;
3520            if state.month == 1 {
3521                state.month = 12;
3522                state.year -= 1;
3523            } else {
3524                state.month -= 1;
3525            }
3526            state.cursor_day = CalendarState::days_in_month(state.year, state.month);
3527        }
3528    }
3529}