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