Skip to main content

slt/context/
widgets_interactive.rs

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