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 },
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(
2707                    &table_lines,
2708                    text_style,
2709                    bold_style,
2710                    code_style,
2711                    border_style,
2712                );
2713                table_lines.clear();
2714            }
2715
2716            if trimmed.is_empty() {
2717                self.text(" ");
2718                continue;
2719            }
2720            if trimmed == "---" || trimmed == "***" || trimmed == "___" {
2721                self.styled("─".repeat(40), border_style);
2722                continue;
2723            }
2724            if let Some(quote) = trimmed.strip_prefix("> ") {
2725                let quote_style = Style::new().fg(self.theme.text_dim).italic();
2726                let bar_style = Style::new().fg(self.theme.border);
2727                self.line(|ui| {
2728                    ui.styled("│ ", bar_style);
2729                    ui.styled(quote, quote_style);
2730                });
2731            } else if let Some(heading) = trimmed.strip_prefix("### ") {
2732                self.styled(heading, Style::new().bold().fg(self.theme.accent));
2733            } else if let Some(heading) = trimmed.strip_prefix("## ") {
2734                self.styled(heading, Style::new().bold().fg(self.theme.secondary));
2735            } else if let Some(heading) = trimmed.strip_prefix("# ") {
2736                self.styled(heading, Style::new().bold().fg(self.theme.primary));
2737            } else if let Some(item) = trimmed
2738                .strip_prefix("- ")
2739                .or_else(|| trimmed.strip_prefix("* "))
2740            {
2741                self.line_wrap(|ui| {
2742                    ui.styled("  • ", text_style);
2743                    Self::render_md_inline_into(ui, item, text_style, bold_style, code_style);
2744                });
2745            } else if trimmed.starts_with(|c: char| c.is_ascii_digit()) && trimmed.contains(". ") {
2746                let parts: Vec<&str> = trimmed.splitn(2, ". ").collect();
2747                if parts.len() == 2 {
2748                    self.line_wrap(|ui| {
2749                        let mut prefix = String::with_capacity(4 + parts[0].len());
2750                        prefix.push_str("  ");
2751                        prefix.push_str(parts[0]);
2752                        prefix.push_str(". ");
2753                        ui.styled(prefix, text_style);
2754                        Self::render_md_inline_into(
2755                            ui, parts[1], text_style, bold_style, code_style,
2756                        );
2757                    });
2758                } else {
2759                    self.text(trimmed);
2760                }
2761            } else if let Some(lang) = trimmed.strip_prefix("```") {
2762                in_code_block = true;
2763                code_block_lang = lang.trim().to_string();
2764            } else {
2765                self.render_md_inline(trimmed, text_style, bold_style, code_style);
2766            }
2767        }
2768
2769        if in_code_block && !code_block_lines.is_empty() {
2770            for cl in &code_block_lines {
2771                self.styled(cl, code_style);
2772            }
2773        }
2774
2775        // Flush any remaining table rows at end of input
2776        if !table_lines.is_empty() {
2777            self.render_markdown_table(
2778                &table_lines,
2779                text_style,
2780                bold_style,
2781                code_style,
2782                border_style,
2783            );
2784        }
2785
2786        self.commands.push(Command::EndContainer);
2787        self.last_text_idx = None;
2788        Response::none()
2789    }
2790
2791    /// Render a GFM-style pipe table collected from markdown lines.
2792    fn render_markdown_table(
2793        &mut self,
2794        lines: &[String],
2795        text_style: Style,
2796        bold_style: Style,
2797        code_style: Style,
2798        border_style: Style,
2799    ) {
2800        if lines.is_empty() {
2801            return;
2802        }
2803
2804        // Separate header, separator, and data rows
2805        let is_separator = |line: &str| -> bool {
2806            let inner = line.trim_matches('|').trim();
2807            !inner.is_empty()
2808                && inner
2809                    .chars()
2810                    .all(|c| c == '-' || c == ':' || c == '|' || c == ' ')
2811        };
2812
2813        let parse_row = |line: &str| -> Vec<String> {
2814            let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
2815            trimmed.split('|').map(|c| c.trim().to_string()).collect()
2816        };
2817
2818        let mut header: Option<Vec<String>> = None;
2819        let mut data_rows: Vec<Vec<String>> = Vec::new();
2820        let mut found_separator = false;
2821
2822        for (i, line) in lines.iter().enumerate() {
2823            if is_separator(line) {
2824                found_separator = true;
2825                continue;
2826            }
2827            if i == 0 && !found_separator {
2828                header = Some(parse_row(line));
2829            } else {
2830                data_rows.push(parse_row(line));
2831            }
2832        }
2833
2834        // If no separator found, treat first row as header anyway
2835        if !found_separator && header.is_none() && !data_rows.is_empty() {
2836            header = Some(data_rows.remove(0));
2837        }
2838
2839        // Calculate column count and widths
2840        let all_rows: Vec<&Vec<String>> = header.iter().chain(data_rows.iter()).collect();
2841        let col_count = all_rows.iter().map(|r| r.len()).max().unwrap_or(0);
2842        if col_count == 0 {
2843            return;
2844        }
2845        let mut col_widths = vec![0usize; col_count];
2846        // Strip markdown formatting for accurate display-width calculation
2847        let stripped_rows: Vec<Vec<String>> = all_rows
2848            .iter()
2849            .map(|row| row.iter().map(|c| Self::md_strip(c)).collect())
2850            .collect();
2851        for row in &stripped_rows {
2852            for (i, cell) in row.iter().enumerate() {
2853                if i < col_count {
2854                    col_widths[i] = col_widths[i].max(UnicodeWidthStr::width(cell.as_str()));
2855                }
2856            }
2857        }
2858
2859        // Top border ┌───┬───┐
2860        let mut top = String::from("┌");
2861        for (i, &w) in col_widths.iter().enumerate() {
2862            for _ in 0..w + 2 {
2863                top.push('─');
2864            }
2865            top.push(if i < col_count - 1 { '┬' } else { '┐' });
2866        }
2867        self.styled(&top, border_style);
2868
2869        // Header row │ H1 │ H2 │
2870        if let Some(ref hdr) = header {
2871            self.line(|ui| {
2872                ui.styled("│", border_style);
2873                for (i, w) in col_widths.iter().enumerate() {
2874                    let raw = hdr.get(i).map(String::as_str).unwrap_or("");
2875                    let display_text = Self::md_strip(raw);
2876                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
2877                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
2878                    ui.styled(" ", bold_style);
2879                    ui.styled(&display_text, bold_style);
2880                    ui.styled(padding, bold_style);
2881                    ui.styled(" │", border_style);
2882                }
2883            });
2884
2885            // Separator ├───┼───┤
2886            let mut sep = String::from("├");
2887            for (i, &w) in col_widths.iter().enumerate() {
2888                for _ in 0..w + 2 {
2889                    sep.push('─');
2890                }
2891                sep.push(if i < col_count - 1 { '┼' } else { '┤' });
2892            }
2893            self.styled(&sep, border_style);
2894        }
2895
2896        // Data rows — render with inline formatting (bold, italic, code, links)
2897        for row in &data_rows {
2898            self.line(|ui| {
2899                ui.styled("│", border_style);
2900                for (i, w) in col_widths.iter().enumerate() {
2901                    let raw = row.get(i).map(String::as_str).unwrap_or("");
2902                    let display_text = Self::md_strip(raw);
2903                    let cell_w = UnicodeWidthStr::width(display_text.as_str());
2904                    let padding: String = " ".repeat(w.saturating_sub(cell_w));
2905                    ui.styled(" ", text_style);
2906                    Self::render_md_inline_into(ui, raw, text_style, bold_style, code_style);
2907                    ui.styled(padding, text_style);
2908                    ui.styled(" │", border_style);
2909                }
2910            });
2911        }
2912
2913        // Bottom border └───┴───┘
2914        let mut bot = String::from("└");
2915        for (i, &w) in col_widths.iter().enumerate() {
2916            for _ in 0..w + 2 {
2917                bot.push('─');
2918            }
2919            bot.push(if i < col_count - 1 { '┴' } else { '┘' });
2920        }
2921        self.styled(&bot, border_style);
2922    }
2923
2924    pub(crate) fn parse_inline_segments(
2925        text: &str,
2926        base: Style,
2927        bold: Style,
2928        code: Style,
2929    ) -> Vec<(String, Style)> {
2930        let mut segments: Vec<(String, Style)> = Vec::new();
2931        let mut current = String::new();
2932        let chars: Vec<char> = text.chars().collect();
2933        let mut i = 0;
2934        while i < chars.len() {
2935            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
2936                let rest: String = chars[i + 2..].iter().collect();
2937                if let Some(end) = rest.find("**") {
2938                    if !current.is_empty() {
2939                        segments.push((std::mem::take(&mut current), base));
2940                    }
2941                    let inner: String = rest[..end].to_string();
2942                    let char_count = inner.chars().count();
2943                    segments.push((inner, bold));
2944                    i += 2 + char_count + 2;
2945                    continue;
2946                }
2947            }
2948            if chars[i] == '*'
2949                && (i + 1 >= chars.len() || chars[i + 1] != '*')
2950                && (i == 0 || chars[i - 1] != '*')
2951            {
2952                let rest: String = chars[i + 1..].iter().collect();
2953                if let Some(end) = rest.find('*') {
2954                    if !current.is_empty() {
2955                        segments.push((std::mem::take(&mut current), base));
2956                    }
2957                    let inner: String = rest[..end].to_string();
2958                    let char_count = inner.chars().count();
2959                    segments.push((inner, base.italic()));
2960                    i += 1 + char_count + 1;
2961                    continue;
2962                }
2963            }
2964            if chars[i] == '`' {
2965                let rest: String = chars[i + 1..].iter().collect();
2966                if let Some(end) = rest.find('`') {
2967                    if !current.is_empty() {
2968                        segments.push((std::mem::take(&mut current), base));
2969                    }
2970                    let inner: String = rest[..end].to_string();
2971                    let char_count = inner.chars().count();
2972                    segments.push((inner, code));
2973                    i += 1 + char_count + 1;
2974                    continue;
2975                }
2976            }
2977            current.push(chars[i]);
2978            i += 1;
2979        }
2980        if !current.is_empty() {
2981            segments.push((current, base));
2982        }
2983        segments
2984    }
2985
2986    /// Render a markdown line with link/image support.
2987    ///
2988    /// Parses `[text](url)` as clickable OSC 8 links and `![alt](url)` as
2989    /// image placeholders, delegating the rest to `parse_inline_segments`.
2990    fn render_md_inline(
2991        &mut self,
2992        text: &str,
2993        text_style: Style,
2994        bold_style: Style,
2995        code_style: Style,
2996    ) {
2997        let items = Self::split_md_links(text);
2998
2999        // Fast path: no links/images found
3000        if items.len() == 1 {
3001            if let MdInline::Text(ref t) = items[0] {
3002                let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
3003                if segs.len() <= 1 {
3004                    self.text(text)
3005                        .wrap()
3006                        .fg(text_style.fg.unwrap_or(Color::Reset));
3007                } else {
3008                    self.line_wrap(|ui| {
3009                        for (s, st) in segs {
3010                            ui.styled(s, st);
3011                        }
3012                    });
3013                }
3014                return;
3015            }
3016        }
3017
3018        // Mixed content — line_wrap collects both Text and Link commands
3019        self.line_wrap(|ui| {
3020            for item in &items {
3021                match item {
3022                    MdInline::Text(t) => {
3023                        let segs =
3024                            Self::parse_inline_segments(t, text_style, bold_style, code_style);
3025                        for (s, st) in segs {
3026                            ui.styled(s, st);
3027                        }
3028                    }
3029                    MdInline::Link { text, url } => {
3030                        ui.link(text.clone(), url.clone());
3031                    }
3032                    MdInline::Image { alt, .. } => {
3033                        // Render alt text only — matches md_strip() output for width consistency
3034                        ui.styled(alt.as_str(), code_style);
3035                    }
3036                }
3037            }
3038        });
3039    }
3040
3041    /// Emit inline markdown segments into an existing context.
3042    ///
3043    /// Unlike `render_md_inline` which wraps in its own `line_wrap`,
3044    /// this emits raw commands into `ui` so callers can prepend a bullet
3045    /// or prefix before calling this inside their own `line_wrap`.
3046    fn render_md_inline_into(
3047        ui: &mut Context,
3048        text: &str,
3049        text_style: Style,
3050        bold_style: Style,
3051        code_style: Style,
3052    ) {
3053        let items = Self::split_md_links(text);
3054        for item in &items {
3055            match item {
3056                MdInline::Text(t) => {
3057                    let segs = Self::parse_inline_segments(t, text_style, bold_style, code_style);
3058                    for (s, st) in segs {
3059                        ui.styled(s, st);
3060                    }
3061                }
3062                MdInline::Link { text, url } => {
3063                    ui.link(text.clone(), url.clone());
3064                }
3065                MdInline::Image { alt, .. } => {
3066                    ui.styled(alt.as_str(), code_style);
3067                }
3068            }
3069        }
3070    }
3071
3072    /// Split a markdown line into text, link, and image segments.
3073    fn split_md_links(text: &str) -> Vec<MdInline> {
3074        let chars: Vec<char> = text.chars().collect();
3075        let mut items: Vec<MdInline> = Vec::new();
3076        let mut current = String::new();
3077        let mut i = 0;
3078
3079        while i < chars.len() {
3080            // Image: ![alt](url)
3081            if chars[i] == '!' && i + 1 < chars.len() && chars[i + 1] == '[' {
3082                if let Some((alt, _url, consumed)) = Self::parse_md_bracket_paren(&chars, i + 1) {
3083                    if !current.is_empty() {
3084                        items.push(MdInline::Text(std::mem::take(&mut current)));
3085                    }
3086                    items.push(MdInline::Image { alt });
3087                    i += 1 + consumed;
3088                    continue;
3089                }
3090            }
3091            // Link: [text](url)
3092            if chars[i] == '[' {
3093                if let Some((link_text, url, consumed)) = Self::parse_md_bracket_paren(&chars, i) {
3094                    if !current.is_empty() {
3095                        items.push(MdInline::Text(std::mem::take(&mut current)));
3096                    }
3097                    items.push(MdInline::Link {
3098                        text: link_text,
3099                        url,
3100                    });
3101                    i += consumed;
3102                    continue;
3103                }
3104            }
3105            current.push(chars[i]);
3106            i += 1;
3107        }
3108        if !current.is_empty() {
3109            items.push(MdInline::Text(current));
3110        }
3111        if items.is_empty() {
3112            items.push(MdInline::Text(String::new()));
3113        }
3114        items
3115    }
3116
3117    /// Parse `[text](url)` starting at `chars[start]` which must be `[`.
3118    /// Returns `(text, url, chars_consumed)` or `None` if no match.
3119    fn parse_md_bracket_paren(chars: &[char], start: usize) -> Option<(String, String, usize)> {
3120        if start >= chars.len() || chars[start] != '[' {
3121            return None;
3122        }
3123        // Find closing ]
3124        let mut depth = 0i32;
3125        let mut bracket_end = None;
3126        for (j, &ch) in chars.iter().enumerate().skip(start) {
3127            if ch == '[' {
3128                depth += 1;
3129            } else if ch == ']' {
3130                depth -= 1;
3131                if depth == 0 {
3132                    bracket_end = Some(j);
3133                    break;
3134                }
3135            }
3136        }
3137        let bracket_end = bracket_end?;
3138        // Must be followed by (
3139        if bracket_end + 1 >= chars.len() || chars[bracket_end + 1] != '(' {
3140            return None;
3141        }
3142        // Find closing )
3143        let paren_start = bracket_end + 2;
3144        let mut paren_end = None;
3145        let mut paren_depth = 1i32;
3146        for (j, &ch) in chars.iter().enumerate().skip(paren_start) {
3147            if ch == '(' {
3148                paren_depth += 1;
3149            } else if ch == ')' {
3150                paren_depth -= 1;
3151                if paren_depth == 0 {
3152                    paren_end = Some(j);
3153                    break;
3154                }
3155            }
3156        }
3157        let paren_end = paren_end?;
3158        let text: String = chars[start + 1..bracket_end].iter().collect();
3159        let url: String = chars[paren_start..paren_end].iter().collect();
3160        let consumed = paren_end - start + 1;
3161        Some((text, url, consumed))
3162    }
3163
3164    /// Strip markdown inline formatting, returning plain display text.
3165    ///
3166    /// `**bold**` → `bold`, `*italic*` → `italic`, `` `code` `` → `code`,
3167    /// `[text](url)` → `text`, `![alt](url)` → `alt`.
3168    fn md_strip(text: &str) -> String {
3169        let mut result = String::with_capacity(text.len());
3170        let chars: Vec<char> = text.chars().collect();
3171        let mut i = 0;
3172        while i < chars.len() {
3173            // Image ![alt](url) → alt
3174            if chars[i] == '!' && i + 1 < chars.len() && chars[i + 1] == '[' {
3175                if let Some((alt, _, consumed)) = Self::parse_md_bracket_paren(&chars, i + 1) {
3176                    result.push_str(&alt);
3177                    i += 1 + consumed;
3178                    continue;
3179                }
3180            }
3181            // Link [text](url) → text
3182            if chars[i] == '[' {
3183                if let Some((link_text, _, consumed)) = Self::parse_md_bracket_paren(&chars, i) {
3184                    result.push_str(&link_text);
3185                    i += consumed;
3186                    continue;
3187                }
3188            }
3189            // Bold **text**
3190            if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
3191                let rest: String = chars[i + 2..].iter().collect();
3192                if let Some(end) = rest.find("**") {
3193                    let inner = &rest[..end];
3194                    result.push_str(inner);
3195                    i += 2 + inner.chars().count() + 2;
3196                    continue;
3197                }
3198            }
3199            // Italic *text*
3200            if chars[i] == '*'
3201                && (i + 1 >= chars.len() || chars[i + 1] != '*')
3202                && (i == 0 || chars[i - 1] != '*')
3203            {
3204                let rest: String = chars[i + 1..].iter().collect();
3205                if let Some(end) = rest.find('*') {
3206                    let inner = &rest[..end];
3207                    result.push_str(inner);
3208                    i += 1 + inner.chars().count() + 1;
3209                    continue;
3210                }
3211            }
3212            // Inline code `text`
3213            if chars[i] == '`' {
3214                let rest: String = chars[i + 1..].iter().collect();
3215                if let Some(end) = rest.find('`') {
3216                    let inner = &rest[..end];
3217                    result.push_str(inner);
3218                    i += 1 + inner.chars().count() + 1;
3219                    continue;
3220                }
3221            }
3222            result.push(chars[i]);
3223            i += 1;
3224        }
3225        result
3226    }
3227
3228    // ── key sequence ─────────────────────────────────────────────────
3229
3230    /// Check if a sequence of character keys was pressed across recent frames.
3231    ///
3232    /// Matches when each character in `seq` appears in consecutive unconsumed
3233    /// key events within this frame. For single-frame sequences only (e.g., "gg").
3234    pub fn key_seq(&self, seq: &str) -> bool {
3235        if seq.is_empty() {
3236            return false;
3237        }
3238        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3239            return false;
3240        }
3241        let target: Vec<char> = seq.chars().collect();
3242        let mut matched = 0;
3243        for (i, event) in self.events.iter().enumerate() {
3244            if self.consumed[i] {
3245                continue;
3246            }
3247            if let Event::Key(key) = event {
3248                if key.kind != KeyEventKind::Press {
3249                    continue;
3250                }
3251                if let KeyCode::Char(c) = key.code {
3252                    if c == target[matched] {
3253                        matched += 1;
3254                        if matched == target.len() {
3255                            return true;
3256                        }
3257                    } else {
3258                        matched = 0;
3259                        if c == target[0] {
3260                            matched = 1;
3261                        }
3262                    }
3263                }
3264            }
3265        }
3266        false
3267    }
3268
3269    /// Render a horizontal divider line.
3270    ///
3271    /// The line is drawn with the theme's border color and expands to fill the
3272    /// container width.
3273    pub fn separator(&mut self) -> &mut Self {
3274        self.commands.push(Command::Text {
3275            content: "─".repeat(200),
3276            style: Style::new().fg(self.theme.border).dim(),
3277            grow: 0,
3278            align: Align::Start,
3279            wrap: false,
3280            truncate: false,
3281            margin: Margin::default(),
3282            constraints: Constraints::default(),
3283        });
3284        self.last_text_idx = Some(self.commands.len() - 1);
3285        self
3286    }
3287
3288    /// Render a horizontal separator line with a custom color.
3289    pub fn separator_colored(&mut self, color: Color) -> &mut Self {
3290        self.commands.push(Command::Text {
3291            content: "─".repeat(200),
3292            style: Style::new().fg(color),
3293            grow: 0,
3294            align: Align::Start,
3295            wrap: false,
3296            truncate: false,
3297            margin: Margin::default(),
3298            constraints: Constraints::default(),
3299        });
3300        self.last_text_idx = Some(self.commands.len() - 1);
3301        self
3302    }
3303
3304    /// Render a help bar showing keybinding hints.
3305    ///
3306    /// `bindings` is a slice of `(key, action)` pairs. Keys are rendered in the
3307    /// theme's primary color; actions in the dim text color. Pairs are separated
3308    /// by a `·` character.
3309    pub fn help(&mut self, bindings: &[(&str, &str)]) -> Response {
3310        if bindings.is_empty() {
3311            return Response::none();
3312        }
3313
3314        self.interaction_count += 1;
3315        self.commands.push(Command::BeginContainer {
3316            direction: Direction::Row,
3317            gap: 2,
3318            align: Align::Start,
3319            align_self: None,
3320            justify: Justify::Start,
3321            border: None,
3322            border_sides: BorderSides::all(),
3323            border_style: Style::new().fg(self.theme.border),
3324            bg_color: None,
3325            padding: Padding::default(),
3326            margin: Margin::default(),
3327            constraints: Constraints::default(),
3328            title: None,
3329            grow: 0,
3330            group_name: None,
3331        });
3332        for (idx, (key, action)) in bindings.iter().enumerate() {
3333            if idx > 0 {
3334                self.styled("·", Style::new().fg(self.theme.text_dim));
3335            }
3336            self.styled(*key, Style::new().bold().fg(self.theme.primary));
3337            self.styled(*action, Style::new().fg(self.theme.text_dim));
3338        }
3339        self.commands.push(Command::EndContainer);
3340        self.last_text_idx = None;
3341
3342        Response::none()
3343    }
3344
3345    /// Render a help bar with custom key/description colors.
3346    pub fn help_colored(
3347        &mut self,
3348        bindings: &[(&str, &str)],
3349        key_color: Color,
3350        text_color: Color,
3351    ) -> Response {
3352        if bindings.is_empty() {
3353            return Response::none();
3354        }
3355
3356        self.interaction_count += 1;
3357        self.commands.push(Command::BeginContainer {
3358            direction: Direction::Row,
3359            gap: 2,
3360            align: Align::Start,
3361            align_self: None,
3362            justify: Justify::Start,
3363            border: None,
3364            border_sides: BorderSides::all(),
3365            border_style: Style::new().fg(self.theme.border),
3366            bg_color: None,
3367            padding: Padding::default(),
3368            margin: Margin::default(),
3369            constraints: Constraints::default(),
3370            title: None,
3371            grow: 0,
3372            group_name: None,
3373        });
3374        for (idx, (key, action)) in bindings.iter().enumerate() {
3375            if idx > 0 {
3376                self.styled("·", Style::new().fg(text_color));
3377            }
3378            self.styled(*key, Style::new().bold().fg(key_color));
3379            self.styled(*action, Style::new().fg(text_color));
3380        }
3381        self.commands.push(Command::EndContainer);
3382        self.last_text_idx = None;
3383
3384        Response::none()
3385    }
3386
3387    // ── events ───────────────────────────────────────────────────────
3388
3389    /// Check if a character key was pressed this frame.
3390    ///
3391    /// Returns `true` if the key event has not been consumed by another widget.
3392    pub fn key(&self, c: char) -> bool {
3393        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3394            return false;
3395        }
3396        self.events.iter().enumerate().any(|(i, e)| {
3397            !self.consumed[i]
3398                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
3399        })
3400    }
3401
3402    /// Check if a specific key code was pressed this frame.
3403    ///
3404    /// Returns `true` if the key event has not been consumed by another widget.
3405    /// Blocked when a modal/overlay is active and the caller is outside the overlay.
3406    /// Use [`raw_key_code`](Self::raw_key_code) for global shortcuts that must work
3407    /// regardless of modal/overlay state.
3408    pub fn key_code(&self, code: KeyCode) -> bool {
3409        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3410            return false;
3411        }
3412        self.events.iter().enumerate().any(|(i, e)| {
3413            !self.consumed[i]
3414                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
3415        })
3416    }
3417
3418    /// Check if a specific key code was pressed this frame, ignoring modal/overlay state.
3419    ///
3420    /// Unlike [`key_code`](Self::key_code), this method bypasses the modal/overlay guard
3421    /// so it works even when a modal or overlay is active. Use this for global shortcuts
3422    /// (e.g. Esc to close a modal, Ctrl+Q to quit) that must always be reachable.
3423    ///
3424    /// Returns `true` if the key event has not been consumed by another widget.
3425    pub fn raw_key_code(&self, code: KeyCode) -> bool {
3426        self.events.iter().enumerate().any(|(i, e)| {
3427            !self.consumed[i]
3428                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code)
3429        })
3430    }
3431
3432    /// Check if a character key was released this frame.
3433    ///
3434    /// Returns `true` if the key release event has not been consumed by another widget.
3435    pub fn key_release(&self, c: char) -> bool {
3436        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3437            return false;
3438        }
3439        self.events.iter().enumerate().any(|(i, e)| {
3440            !self.consumed[i]
3441                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == KeyCode::Char(c))
3442        })
3443    }
3444
3445    /// Check if a specific key code was released this frame.
3446    ///
3447    /// Returns `true` if the key release event has not been consumed by another widget.
3448    pub fn key_code_release(&self, code: KeyCode) -> bool {
3449        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3450            return false;
3451        }
3452        self.events.iter().enumerate().any(|(i, e)| {
3453            !self.consumed[i]
3454                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Release && k.code == code)
3455        })
3456    }
3457
3458    /// Check for a character key press and consume the event, preventing other
3459    /// handlers from seeing it.
3460    ///
3461    /// Returns `true` if the key was found unconsumed and is now consumed.
3462    /// Unlike [`key()`](Self::key) which peeks without consuming, this claims
3463    /// exclusive ownership of the event.
3464    ///
3465    /// Call **after** widgets if you want widgets to have priority over your
3466    /// handler, or **before** widgets to intercept first.
3467    pub fn consume_key(&mut self, c: char) -> bool {
3468        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3469            return false;
3470        }
3471        for (i, event) in self.events.iter().enumerate() {
3472            if self.consumed[i] {
3473                continue;
3474            }
3475            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c))
3476            {
3477                self.consumed[i] = true;
3478                return true;
3479            }
3480        }
3481        false
3482    }
3483
3484    /// Check for a special key press and consume the event, preventing other
3485    /// handlers from seeing it.
3486    ///
3487    /// Returns `true` if the key was found unconsumed and is now consumed.
3488    /// Unlike [`key_code()`](Self::key_code) which peeks without consuming,
3489    /// this claims exclusive ownership of the event.
3490    ///
3491    /// Call **after** widgets if you want widgets to have priority over your
3492    /// handler, or **before** widgets to intercept first.
3493    pub fn consume_key_code(&mut self, code: KeyCode) -> bool {
3494        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3495            return false;
3496        }
3497        for (i, event) in self.events.iter().enumerate() {
3498            if self.consumed[i] {
3499                continue;
3500            }
3501            if matches!(event, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == code) {
3502                self.consumed[i] = true;
3503                return true;
3504            }
3505        }
3506        false
3507    }
3508
3509    /// Check if a character key with specific modifiers was pressed this frame.
3510    ///
3511    /// Returns `true` if the key event has not been consumed by another widget.
3512    pub fn key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
3513        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3514            return false;
3515        }
3516        self.events.iter().enumerate().any(|(i, e)| {
3517            !self.consumed[i]
3518                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
3519        })
3520    }
3521
3522    /// Like [`key_mod`](Self::key_mod) but bypasses the modal/overlay guard.
3523    pub fn raw_key_mod(&self, c: char, modifiers: KeyModifiers) -> bool {
3524        self.events.iter().enumerate().any(|(i, e)| {
3525            !self.consumed[i]
3526                && matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press && k.code == KeyCode::Char(c) && k.modifiers.contains(modifiers))
3527        })
3528    }
3529
3530    /// Return the position of a left mouse button down event this frame, if any.
3531    ///
3532    /// Returns `None` if no unconsumed mouse-down event occurred.
3533    pub fn mouse_down(&self) -> Option<(u32, u32)> {
3534        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3535            return None;
3536        }
3537        self.events.iter().enumerate().find_map(|(i, event)| {
3538            if self.consumed[i] {
3539                return None;
3540            }
3541            if let Event::Mouse(mouse) = event {
3542                if matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
3543                    return Some((mouse.x, mouse.y));
3544                }
3545            }
3546            None
3547        })
3548    }
3549
3550    /// Return the current mouse cursor position, if known.
3551    ///
3552    /// The position is updated on every mouse move or click event. Returns
3553    /// `None` until the first mouse event is received.
3554    pub fn mouse_pos(&self) -> Option<(u32, u32)> {
3555        self.mouse_pos
3556    }
3557
3558    /// Return the first unconsumed paste event text, if any.
3559    pub fn paste(&self) -> Option<&str> {
3560        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3561            return None;
3562        }
3563        self.events.iter().enumerate().find_map(|(i, event)| {
3564            if self.consumed[i] {
3565                return None;
3566            }
3567            if let Event::Paste(ref text) = event {
3568                return Some(text.as_str());
3569            }
3570            None
3571        })
3572    }
3573
3574    /// Check if an unconsumed scroll-up event occurred this frame.
3575    pub fn scroll_up(&self) -> bool {
3576        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3577            return false;
3578        }
3579        self.events.iter().enumerate().any(|(i, event)| {
3580            !self.consumed[i]
3581                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollUp))
3582        })
3583    }
3584
3585    /// Check if an unconsumed scroll-down event occurred this frame.
3586    pub fn scroll_down(&self) -> bool {
3587        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3588            return false;
3589        }
3590        self.events.iter().enumerate().any(|(i, event)| {
3591            !self.consumed[i]
3592                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollDown))
3593        })
3594    }
3595
3596    /// Check if an unconsumed scroll-left event occurred this frame.
3597    pub fn scroll_left(&self) -> bool {
3598        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3599            return false;
3600        }
3601        self.events.iter().enumerate().any(|(i, event)| {
3602            !self.consumed[i]
3603                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollLeft))
3604        })
3605    }
3606
3607    /// Check if an unconsumed scroll-right event occurred this frame.
3608    pub fn scroll_right(&self) -> bool {
3609        if (self.modal_active || self.prev_modal_active) && self.overlay_depth == 0 {
3610            return false;
3611        }
3612        self.events.iter().enumerate().any(|(i, event)| {
3613            !self.consumed[i]
3614                && matches!(event, Event::Mouse(mouse) if matches!(mouse.kind, MouseKind::ScrollRight))
3615        })
3616    }
3617
3618    /// Signal the run loop to exit after this frame.
3619    pub fn quit(&mut self) {
3620        self.should_quit = true;
3621    }
3622
3623    /// Copy text to the system clipboard via OSC 52.
3624    ///
3625    /// Works transparently over SSH connections. The text is queued and
3626    /// written to the terminal after the current frame renders.
3627    ///
3628    /// Requires a terminal that supports OSC 52 (most modern terminals:
3629    /// Ghostty, kitty, WezTerm, iTerm2, Windows Terminal).
3630    pub fn copy_to_clipboard(&mut self, text: impl Into<String>) {
3631        self.clipboard_text = Some(text.into());
3632    }
3633
3634    /// Get the current theme.
3635    pub fn theme(&self) -> &Theme {
3636        &self.theme
3637    }
3638
3639    /// Change the theme for subsequent rendering.
3640    ///
3641    /// All widgets rendered after this call will use the new theme's colors.
3642    pub fn set_theme(&mut self, theme: Theme) {
3643        self.theme = theme;
3644    }
3645
3646    /// Check if dark mode is active.
3647    pub fn is_dark_mode(&self) -> bool {
3648        self.dark_mode
3649    }
3650
3651    /// Set dark mode. When true, dark_* style variants are applied.
3652    pub fn set_dark_mode(&mut self, dark: bool) {
3653        self.dark_mode = dark;
3654    }
3655
3656    // ── info ─────────────────────────────────────────────────────────
3657
3658    /// Get the terminal width in cells.
3659    pub fn width(&self) -> u32 {
3660        self.area_width
3661    }
3662
3663    /// Get the current terminal width breakpoint.
3664    ///
3665    /// Returns a [`Breakpoint`] based on the terminal width:
3666    /// - `Xs`: < 40 columns
3667    /// - `Sm`: 40-79 columns
3668    /// - `Md`: 80-119 columns
3669    /// - `Lg`: 120-159 columns
3670    /// - `Xl`: >= 160 columns
3671    ///
3672    /// Use this for responsive layouts that adapt to terminal size:
3673    /// ```no_run
3674    /// # use slt::{Breakpoint, Context};
3675    /// # slt::run(|ui: &mut Context| {
3676    /// match ui.breakpoint() {
3677    ///     Breakpoint::Xs | Breakpoint::Sm => {
3678    ///         ui.col(|ui| { ui.text("Stacked layout"); });
3679    ///     }
3680    ///     _ => {
3681    ///         ui.row(|ui| { ui.text("Side-by-side layout"); });
3682    ///     }
3683    /// }
3684    /// # });
3685    /// ```
3686    pub fn breakpoint(&self) -> Breakpoint {
3687        let w = self.area_width;
3688        if w < 40 {
3689            Breakpoint::Xs
3690        } else if w < 80 {
3691            Breakpoint::Sm
3692        } else if w < 120 {
3693            Breakpoint::Md
3694        } else if w < 160 {
3695            Breakpoint::Lg
3696        } else {
3697            Breakpoint::Xl
3698        }
3699    }
3700
3701    /// Get the terminal height in cells.
3702    pub fn height(&self) -> u32 {
3703        self.area_height
3704    }
3705
3706    /// Get the current tick count (increments each frame).
3707    ///
3708    /// Useful for animations and time-based logic. The tick starts at 0 and
3709    /// increases by 1 on every rendered frame.
3710    pub fn tick(&self) -> u64 {
3711        self.tick
3712    }
3713
3714    /// Return whether the layout debugger is enabled.
3715    ///
3716    /// The debugger is toggled with F12 at runtime.
3717    pub fn debug_enabled(&self) -> bool {
3718        self.debug
3719    }
3720}
3721
3722fn calendar_month_name(month: u32) -> &'static str {
3723    match month {
3724        1 => "Jan",
3725        2 => "Feb",
3726        3 => "Mar",
3727        4 => "Apr",
3728        5 => "May",
3729        6 => "Jun",
3730        7 => "Jul",
3731        8 => "Aug",
3732        9 => "Sep",
3733        10 => "Oct",
3734        11 => "Nov",
3735        12 => "Dec",
3736        _ => "???",
3737    }
3738}
3739
3740struct DirectoryRenderRow {
3741    depth: usize,
3742    label: String,
3743    is_leaf: bool,
3744    expanded: bool,
3745    is_last: bool,
3746    branch_mask: Vec<bool>,
3747}
3748
3749fn flatten_directory_rows(
3750    nodes: &[TreeNode],
3751    branch_mask: Vec<bool>,
3752    out: &mut Vec<DirectoryRenderRow>,
3753) {
3754    for (idx, node) in nodes.iter().enumerate() {
3755        let is_last = idx + 1 == nodes.len();
3756        out.push(DirectoryRenderRow {
3757            depth: branch_mask.len(),
3758            label: node.label.clone(),
3759            is_leaf: node.children.is_empty(),
3760            expanded: node.expanded,
3761            is_last,
3762            branch_mask: branch_mask.clone(),
3763        });
3764
3765        if node.expanded && !node.children.is_empty() {
3766            let mut next_mask = branch_mask.clone();
3767            next_mask.push(!is_last);
3768            flatten_directory_rows(&node.children, next_mask, out);
3769        }
3770    }
3771}
3772
3773fn calendar_move_cursor_by_days(state: &mut CalendarState, delta: i32) {
3774    let mut remaining = delta;
3775    while remaining != 0 {
3776        let days = CalendarState::days_in_month(state.year, state.month);
3777        if remaining > 0 {
3778            let forward = days.saturating_sub(state.cursor_day) as i32;
3779            if remaining <= forward {
3780                state.cursor_day += remaining as u32;
3781                return;
3782            }
3783
3784            remaining -= forward + 1;
3785            if state.month == 12 {
3786                state.month = 1;
3787                state.year += 1;
3788            } else {
3789                state.month += 1;
3790            }
3791            state.cursor_day = 1;
3792        } else {
3793            let backward = state.cursor_day.saturating_sub(1) as i32;
3794            if -remaining <= backward {
3795                state.cursor_day -= (-remaining) as u32;
3796                return;
3797            }
3798
3799            remaining += backward + 1;
3800            if state.month == 1 {
3801                state.month = 12;
3802                state.year -= 1;
3803            } else {
3804                state.month -= 1;
3805            }
3806            state.cursor_day = CalendarState::days_in_month(state.year, state.month);
3807        }
3808    }
3809}