Skip to main content

slt/context/
widgets_interactive.rs

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