Skip to main content

slt/context/
widgets_interactive.rs

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