Skip to main content

slt/context/
widgets_interactive.rs

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