Skip to main content

slt/context/widgets_interactive/
selection.rs

1use super::*;
2
3impl Context {
4    /// Render a data table with column headers. Handles Up/Down selection when focused.
5    ///
6    /// Column widths are computed automatically from header and cell content.
7    /// The selected row is highlighted with the theme's selection colors.
8    /// Render a data table with sortable columns and row selection.
9    pub fn table(&mut self, state: &mut TableState) -> Response {
10        self.table_colored(state, &WidgetColors::new())
11    }
12
13    /// Render a data table with custom widget colors.
14    pub fn table_colored(&mut self, state: &mut TableState, colors: &WidgetColors) -> Response {
15        if state.is_dirty() {
16            state.recompute_widths();
17        }
18
19        let old_selected = state.selected;
20        let old_sort_column = state.sort_column;
21        let old_sort_ascending = state.sort_ascending;
22        let old_page = state.page;
23        let old_filter = state.filter.clone();
24
25        let focused = self.register_focusable();
26        let interaction_id = self.next_interaction_id();
27        let mut response = self.response_for(interaction_id);
28        response.focused = focused;
29
30        self.table_handle_events(state, focused, interaction_id);
31
32        if state.is_dirty() {
33            state.recompute_widths();
34        }
35
36        self.table_render(state, focused, colors);
37
38        response.changed = state.selected != old_selected
39            || state.sort_column != old_sort_column
40            || state.sort_ascending != old_sort_ascending
41            || state.page != old_page
42            || state.filter != old_filter;
43        response
44    }
45
46    fn table_handle_events(
47        &mut self,
48        state: &mut TableState,
49        focused: bool,
50        interaction_id: usize,
51    ) {
52        self.handle_table_keys(state, focused);
53
54        if state.visible_indices().is_empty() && state.headers.is_empty() {
55            return;
56        }
57
58        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
59            for (i, event) in self.events.iter().enumerate() {
60                if self.consumed[i] {
61                    continue;
62                }
63                if let Event::Mouse(mouse) = event {
64                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
65                        continue;
66                    }
67                    let in_bounds = mouse.x >= rect.x
68                        && mouse.x < rect.right()
69                        && mouse.y >= rect.y
70                        && mouse.y < rect.bottom();
71                    if !in_bounds {
72                        continue;
73                    }
74
75                    if mouse.y == rect.y {
76                        let rel_x = mouse.x.saturating_sub(rect.x);
77                        let mut x_offset = 0u32;
78                        for (col_idx, width) in state.column_widths().iter().enumerate() {
79                            if rel_x >= x_offset && rel_x < x_offset + *width {
80                                state.toggle_sort(col_idx);
81                                state.selected = 0;
82                                self.consumed[i] = true;
83                                break;
84                            }
85                            x_offset += *width;
86                            if col_idx + 1 < state.column_widths().len() {
87                                x_offset += 3;
88                            }
89                        }
90                        continue;
91                    }
92
93                    if mouse.y < rect.y + 2 {
94                        continue;
95                    }
96
97                    let visible_len = if state.page_size > 0 {
98                        let start = state
99                            .page
100                            .saturating_mul(state.page_size)
101                            .min(state.visible_indices().len());
102                        let end = (start + state.page_size).min(state.visible_indices().len());
103                        end.saturating_sub(start)
104                    } else {
105                        state.visible_indices().len()
106                    };
107                    let clicked_idx = (mouse.y - rect.y - 2) as usize;
108                    if clicked_idx < visible_len {
109                        state.selected = clicked_idx;
110                        self.consumed[i] = true;
111                    }
112                }
113            }
114        }
115    }
116
117    fn table_render(&mut self, state: &mut TableState, focused: bool, colors: &WidgetColors) {
118        let total_visible = state.visible_indices().len();
119        let page_start = if state.page_size > 0 {
120            state
121                .page
122                .saturating_mul(state.page_size)
123                .min(total_visible)
124        } else {
125            0
126        };
127        let page_end = if state.page_size > 0 {
128            (page_start + state.page_size).min(total_visible)
129        } else {
130            total_visible
131        };
132        let visible_len = page_end.saturating_sub(page_start);
133        state.selected = state.selected.min(visible_len.saturating_sub(1));
134
135        self.commands.push(Command::BeginContainer {
136            direction: Direction::Column,
137            gap: 0,
138            align: Align::Start,
139            align_self: None,
140            justify: Justify::Start,
141            border: None,
142            border_sides: BorderSides::all(),
143            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
144            bg_color: None,
145            padding: Padding::default(),
146            margin: Margin::default(),
147            constraints: Constraints::default(),
148            title: None,
149            grow: 0,
150            group_name: None,
151        });
152
153        self.render_table_header(state, colors);
154        self.render_table_rows(state, focused, page_start, visible_len, colors);
155
156        if state.page_size > 0 && state.total_pages() > 1 {
157            let current_page = (state.page + 1).to_string();
158            let total_pages = state.total_pages().to_string();
159            let mut page_text = String::with_capacity(current_page.len() + total_pages.len() + 6);
160            page_text.push_str("Page ");
161            page_text.push_str(&current_page);
162            page_text.push('/');
163            page_text.push_str(&total_pages);
164            self.styled(
165                page_text,
166                Style::new()
167                    .dim()
168                    .fg(colors.fg.unwrap_or(self.theme.text_dim)),
169            );
170        }
171
172        self.commands.push(Command::EndContainer);
173        self.last_text_idx = None;
174    }
175
176    fn handle_table_keys(&mut self, state: &mut TableState, focused: bool) {
177        if !focused || state.visible_indices().is_empty() {
178            return;
179        }
180
181        let mut consumed_indices = Vec::new();
182        for (i, event) in self.events.iter().enumerate() {
183            if let Event::Key(key) = event {
184                if key.kind != KeyEventKind::Press {
185                    continue;
186                }
187                match key.code {
188                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
189                        let visible_len = table_visible_len(state);
190                        state.selected = state.selected.min(visible_len.saturating_sub(1));
191                        let _ = handle_vertical_nav(
192                            &mut state.selected,
193                            visible_len.saturating_sub(1),
194                            key.code.clone(),
195                        );
196                        consumed_indices.push(i);
197                    }
198                    KeyCode::PageUp => {
199                        let old_page = state.page;
200                        state.prev_page();
201                        if state.page != old_page {
202                            state.selected = 0;
203                        }
204                        consumed_indices.push(i);
205                    }
206                    KeyCode::PageDown => {
207                        let old_page = state.page;
208                        state.next_page();
209                        if state.page != old_page {
210                            state.selected = 0;
211                        }
212                        consumed_indices.push(i);
213                    }
214                    _ => {}
215                }
216            }
217        }
218        for index in consumed_indices {
219            self.consumed[index] = true;
220        }
221    }
222
223    fn render_table_header(&mut self, state: &TableState, colors: &WidgetColors) {
224        let header_cells = state
225            .headers
226            .iter()
227            .enumerate()
228            .map(|(i, header)| {
229                if state.sort_column == Some(i) {
230                    if state.sort_ascending {
231                        let mut sorted_header = String::with_capacity(header.len() + 2);
232                        sorted_header.push_str(header);
233                        sorted_header.push_str(" ▲");
234                        sorted_header
235                    } else {
236                        let mut sorted_header = String::with_capacity(header.len() + 2);
237                        sorted_header.push_str(header);
238                        sorted_header.push_str(" ▼");
239                        sorted_header
240                    }
241                } else {
242                    header.clone()
243                }
244            })
245            .collect::<Vec<_>>();
246        let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
247        self.styled(
248            header_line,
249            Style::new().bold().fg(colors.fg.unwrap_or(self.theme.text)),
250        );
251
252        let separator = state
253            .column_widths()
254            .iter()
255            .map(|w| "─".repeat(*w as usize))
256            .collect::<Vec<_>>()
257            .join("─┼─");
258        self.text(separator);
259    }
260
261    fn render_table_rows(
262        &mut self,
263        state: &TableState,
264        focused: bool,
265        page_start: usize,
266        visible_len: usize,
267        colors: &WidgetColors,
268    ) {
269        for idx in 0..visible_len {
270            let data_idx = state.visible_indices()[page_start + idx];
271            let Some(row) = state.rows.get(data_idx) else {
272                continue;
273            };
274            let line = format_table_row(row, state.column_widths(), " │ ");
275            if idx == state.selected {
276                let mut style = Style::new()
277                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
278                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
279                if focused {
280                    style = style.bold();
281                }
282                self.styled(line, style);
283            } else {
284                let mut style = Style::new().fg(colors.fg.unwrap_or(self.theme.text));
285                if state.zebra {
286                    let zebra_bg = colors.bg.unwrap_or({
287                        if idx % 2 == 0 {
288                            self.theme.surface
289                        } else {
290                            self.theme.surface_hover
291                        }
292                    });
293                    style = style.bg(zebra_bg);
294                }
295                self.styled(line, style);
296            }
297        }
298    }
299
300    /// Render a tab bar. Handles Left/Right navigation when focused.
301    ///
302    /// The active tab is rendered in the theme's primary color. If the labels
303    /// list is empty, nothing is rendered.
304    /// Render a horizontal tab bar.
305    pub fn tabs(&mut self, state: &mut TabsState) -> Response {
306        self.tabs_colored(state, &WidgetColors::new())
307    }
308
309    /// Render a horizontal tab bar with custom widget colors.
310    pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
311        if state.labels.is_empty() {
312            state.selected = 0;
313            return Response::none();
314        }
315
316        state.selected = state.selected.min(state.labels.len().saturating_sub(1));
317        let old_selected = state.selected;
318        let focused = self.register_focusable();
319        let interaction_id = self.next_interaction_id();
320        let mut response = self.response_for(interaction_id);
321        response.focused = focused;
322
323        if focused {
324            let mut consumed_indices = Vec::new();
325            for (i, event) in self.events.iter().enumerate() {
326                if let Event::Key(key) = event {
327                    if key.kind != KeyEventKind::Press {
328                        continue;
329                    }
330                    match key.code {
331                        KeyCode::Left => {
332                            state.selected = if state.selected == 0 {
333                                state.labels.len().saturating_sub(1)
334                            } else {
335                                state.selected - 1
336                            };
337                            consumed_indices.push(i);
338                        }
339                        KeyCode::Right => {
340                            if !state.labels.is_empty() {
341                                state.selected = (state.selected + 1) % state.labels.len();
342                            }
343                            consumed_indices.push(i);
344                        }
345                        _ => {}
346                    }
347                }
348            }
349
350            for index in consumed_indices {
351                self.consumed[index] = true;
352            }
353        }
354
355        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
356            for (i, event) in self.events.iter().enumerate() {
357                if self.consumed[i] {
358                    continue;
359                }
360                if let Event::Mouse(mouse) = event {
361                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
362                        continue;
363                    }
364                    let in_bounds = mouse.x >= rect.x
365                        && mouse.x < rect.right()
366                        && mouse.y >= rect.y
367                        && mouse.y < rect.bottom();
368                    if !in_bounds {
369                        continue;
370                    }
371
372                    let mut x_offset = 0u32;
373                    let rel_x = mouse.x - rect.x;
374                    for (idx, label) in state.labels.iter().enumerate() {
375                        let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
376                        if rel_x >= x_offset && rel_x < x_offset + tab_width {
377                            state.selected = idx;
378                            self.consumed[i] = true;
379                            break;
380                        }
381                        x_offset += tab_width + 1;
382                    }
383                }
384            }
385        }
386
387        self.commands.push(Command::BeginContainer {
388            direction: Direction::Row,
389            gap: 1,
390            align: Align::Start,
391            align_self: None,
392            justify: Justify::Start,
393            border: None,
394            border_sides: BorderSides::all(),
395            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
396            bg_color: None,
397            padding: Padding::default(),
398            margin: Margin::default(),
399            constraints: Constraints::default(),
400            title: None,
401            grow: 0,
402            group_name: None,
403        });
404        for (idx, label) in state.labels.iter().enumerate() {
405            let style = if idx == state.selected {
406                let s = Style::new()
407                    .fg(colors.accent.unwrap_or(self.theme.primary))
408                    .bold();
409                if focused {
410                    s.underline()
411                } else {
412                    s
413                }
414            } else {
415                Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
416            };
417            let mut tab = String::with_capacity(label.len() + 4);
418            tab.push_str("[ ");
419            tab.push_str(label);
420            tab.push_str(" ]");
421            self.styled(tab, style);
422        }
423        self.commands.push(Command::EndContainer);
424        self.last_text_idx = None;
425
426        response.changed = state.selected != old_selected;
427        response
428    }
429
430    /// Render a clickable button. Returns `true` when activated via Enter, Space, or mouse click.
431    ///
432    /// The button is styled with the theme's primary color when focused and the
433    /// accent color when hovered.
434    /// Render a clickable button.
435    pub fn button(&mut self, label: impl Into<String>) -> Response {
436        self.button_colored(label, &WidgetColors::new())
437    }
438
439    /// Render a clickable button with custom widget colors.
440    pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
441        let focused = self.register_focusable();
442        let interaction_id = self.next_interaction_id();
443        let mut response = self.response_for(interaction_id);
444        response.focused = focused;
445
446        let mut activated = response.clicked;
447        if focused {
448            let mut consumed_indices = Vec::new();
449            for (i, event) in self.events.iter().enumerate() {
450                if let Event::Key(key) = event {
451                    if key.kind != KeyEventKind::Press {
452                        continue;
453                    }
454                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
455                        activated = true;
456                        consumed_indices.push(i);
457                    }
458                }
459            }
460
461            for index in consumed_indices {
462                self.consumed[index] = true;
463            }
464        }
465
466        let hovered = response.hovered;
467        let base_fg = colors.fg.unwrap_or(self.theme.text);
468        let accent = colors.accent.unwrap_or(self.theme.accent);
469        let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
470        let style = if focused {
471            Style::new().fg(accent).bold()
472        } else if hovered {
473            Style::new().fg(accent)
474        } else {
475            Style::new().fg(base_fg)
476        };
477        let has_custom_bg = colors.bg.is_some();
478        let bg_color = if has_custom_bg || hovered || focused {
479            Some(base_bg)
480        } else {
481            None
482        };
483
484        self.commands.push(Command::BeginContainer {
485            direction: Direction::Row,
486            gap: 0,
487            align: Align::Start,
488            align_self: None,
489            justify: Justify::Start,
490            border: None,
491            border_sides: BorderSides::all(),
492            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
493            bg_color,
494            padding: Padding::default(),
495            margin: Margin::default(),
496            constraints: Constraints::default(),
497            title: None,
498            grow: 0,
499            group_name: None,
500        });
501        let raw_label = label.into();
502        let mut label_text = String::with_capacity(raw_label.len() + 4);
503        label_text.push_str("[ ");
504        label_text.push_str(&raw_label);
505        label_text.push_str(" ]");
506        self.styled(label_text, style);
507        self.commands.push(Command::EndContainer);
508        self.last_text_idx = None;
509
510        response.clicked = activated;
511        response
512    }
513
514    /// Render a styled button variant. Returns `true` when activated.
515    ///
516    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
517    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
518    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
519        let focused = self.register_focusable();
520        let interaction_id = self.next_interaction_id();
521        let mut response = self.response_for(interaction_id);
522        response.focused = focused;
523
524        let mut activated = response.clicked;
525        if focused {
526            let mut consumed_indices = Vec::new();
527            for (i, event) in self.events.iter().enumerate() {
528                if let Event::Key(key) = event {
529                    if key.kind != KeyEventKind::Press {
530                        continue;
531                    }
532                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
533                        activated = true;
534                        consumed_indices.push(i);
535                    }
536                }
537            }
538            for index in consumed_indices {
539                self.consumed[index] = true;
540            }
541        }
542
543        let label = label.into();
544        let hover_bg = if response.hovered || focused {
545            Some(self.theme.surface_hover)
546        } else {
547            None
548        };
549        let (text, style, bg_color, border) = match variant {
550            ButtonVariant::Default => {
551                let style = if focused {
552                    Style::new().fg(self.theme.primary).bold()
553                } else if response.hovered {
554                    Style::new().fg(self.theme.accent)
555                } else {
556                    Style::new().fg(self.theme.text)
557                };
558                let mut text = String::with_capacity(label.len() + 4);
559                text.push_str("[ ");
560                text.push_str(&label);
561                text.push_str(" ]");
562                (text, style, hover_bg, None)
563            }
564            ButtonVariant::Primary => {
565                let style = if focused {
566                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
567                } else if response.hovered {
568                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
569                } else {
570                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
571                };
572                let mut text = String::with_capacity(label.len() + 2);
573                text.push(' ');
574                text.push_str(&label);
575                text.push(' ');
576                (text, style, hover_bg, None)
577            }
578            ButtonVariant::Danger => {
579                let style = if focused {
580                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
581                } else if response.hovered {
582                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
583                } else {
584                    Style::new().fg(self.theme.bg).bg(self.theme.error)
585                };
586                let mut text = String::with_capacity(label.len() + 2);
587                text.push(' ');
588                text.push_str(&label);
589                text.push(' ');
590                (text, style, hover_bg, None)
591            }
592            ButtonVariant::Outline => {
593                let border_color = if focused {
594                    self.theme.primary
595                } else if response.hovered {
596                    self.theme.accent
597                } else {
598                    self.theme.border
599                };
600                let style = if focused {
601                    Style::new().fg(self.theme.primary).bold()
602                } else if response.hovered {
603                    Style::new().fg(self.theme.accent)
604                } else {
605                    Style::new().fg(self.theme.text)
606                };
607                (
608                    {
609                        let mut text = String::with_capacity(label.len() + 2);
610                        text.push(' ');
611                        text.push_str(&label);
612                        text.push(' ');
613                        text
614                    },
615                    style,
616                    hover_bg,
617                    Some((Border::Rounded, Style::new().fg(border_color))),
618                )
619            }
620        };
621
622        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
623        self.commands.push(Command::BeginContainer {
624            direction: Direction::Row,
625            gap: 0,
626            align: Align::Center,
627            align_self: None,
628            justify: Justify::Center,
629            border: if border.is_some() {
630                Some(btn_border)
631            } else {
632                None
633            },
634            border_sides: BorderSides::all(),
635            border_style: btn_border_style,
636            bg_color,
637            padding: Padding::default(),
638            margin: Margin::default(),
639            constraints: Constraints::default(),
640            title: None,
641            grow: 0,
642            group_name: None,
643        });
644        self.styled(text, style);
645        self.commands.push(Command::EndContainer);
646        self.last_text_idx = None;
647
648        response.clicked = activated;
649        response
650    }
651
652    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
653    ///
654    /// The checked state is shown with the theme's success color. When focused,
655    /// a `▸` prefix is added.
656    /// Render a checkbox toggle.
657    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
658        self.checkbox_colored(label, checked, &WidgetColors::new())
659    }
660
661    /// Render a checkbox toggle with custom widget colors.
662    pub fn checkbox_colored(
663        &mut self,
664        label: impl Into<String>,
665        checked: &mut bool,
666        colors: &WidgetColors,
667    ) -> Response {
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        let mut should_toggle = response.clicked;
673        let old_checked = *checked;
674
675        if focused {
676            let mut consumed_indices = Vec::new();
677            for (i, event) in self.events.iter().enumerate() {
678                if let Event::Key(key) = event {
679                    if key.kind != KeyEventKind::Press {
680                        continue;
681                    }
682                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
683                        should_toggle = true;
684                        consumed_indices.push(i);
685                    }
686                }
687            }
688
689            for index in consumed_indices {
690                self.consumed[index] = true;
691            }
692        }
693
694        if should_toggle {
695            *checked = !*checked;
696        }
697
698        let hover_bg = if response.hovered || focused {
699            Some(self.theme.surface_hover)
700        } else {
701            None
702        };
703        self.commands.push(Command::BeginContainer {
704            direction: Direction::Row,
705            gap: 1,
706            align: Align::Start,
707            align_self: None,
708            justify: Justify::Start,
709            border: None,
710            border_sides: BorderSides::all(),
711            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
712            bg_color: hover_bg,
713            padding: Padding::default(),
714            margin: Margin::default(),
715            constraints: Constraints::default(),
716            title: None,
717            grow: 0,
718            group_name: None,
719        });
720        let marker_style = if *checked {
721            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
722        } else {
723            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
724        };
725        let marker = if *checked { "[x]" } else { "[ ]" };
726        let label_text = label.into();
727        if focused {
728            let mut marker_text = String::with_capacity(2 + marker.len());
729            marker_text.push_str("▸ ");
730            marker_text.push_str(marker);
731            self.styled(marker_text, marker_style.bold());
732            self.styled(
733                label_text,
734                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
735            );
736        } else {
737            self.styled(marker, marker_style);
738            self.styled(
739                label_text,
740                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
741            );
742        }
743        self.commands.push(Command::EndContainer);
744        self.last_text_idx = None;
745
746        response.changed = *checked != old_checked;
747        response
748    }
749
750    /// Render an on/off toggle switch.
751    ///
752    /// Toggles `on` when activated via Enter, Space, or click. The switch
753    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
754    /// dim color respectively.
755    /// Render an on/off toggle switch.
756    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
757        self.toggle_colored(label, on, &WidgetColors::new())
758    }
759
760    /// Render an on/off toggle switch with custom widget colors.
761    pub fn toggle_colored(
762        &mut self,
763        label: impl Into<String>,
764        on: &mut bool,
765        colors: &WidgetColors,
766    ) -> Response {
767        let focused = self.register_focusable();
768        let interaction_id = self.next_interaction_id();
769        let mut response = self.response_for(interaction_id);
770        response.focused = focused;
771        let mut should_toggle = response.clicked;
772        let old_on = *on;
773
774        if focused {
775            let mut consumed_indices = Vec::new();
776            for (i, event) in self.events.iter().enumerate() {
777                if let Event::Key(key) = event {
778                    if key.kind != KeyEventKind::Press {
779                        continue;
780                    }
781                    if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
782                        should_toggle = true;
783                        consumed_indices.push(i);
784                    }
785                }
786            }
787
788            for index in consumed_indices {
789                self.consumed[index] = true;
790            }
791        }
792
793        if should_toggle {
794            *on = !*on;
795        }
796
797        let hover_bg = if response.hovered || focused {
798            Some(self.theme.surface_hover)
799        } else {
800            None
801        };
802        self.commands.push(Command::BeginContainer {
803            direction: Direction::Row,
804            gap: 2,
805            align: Align::Start,
806            align_self: None,
807            justify: Justify::Start,
808            border: None,
809            border_sides: BorderSides::all(),
810            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
811            bg_color: hover_bg,
812            padding: Padding::default(),
813            margin: Margin::default(),
814            constraints: Constraints::default(),
815            title: None,
816            grow: 0,
817            group_name: None,
818        });
819        let label_text = label.into();
820        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
821        let switch_style = if *on {
822            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
823        } else {
824            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
825        };
826        if focused {
827            let mut focused_label = String::with_capacity(2 + label_text.len());
828            focused_label.push_str("▸ ");
829            focused_label.push_str(&label_text);
830            self.styled(
831                focused_label,
832                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
833            );
834            self.styled(switch, switch_style.bold());
835        } else {
836            self.styled(
837                label_text,
838                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
839            );
840            self.styled(switch, switch_style);
841        }
842        self.commands.push(Command::EndContainer);
843        self.last_text_idx = None;
844
845        response.changed = *on != old_on;
846        response
847    }
848
849    // ── select / dropdown ─────────────────────────────────────────────
850
851    /// Render a dropdown select. Shows the selected item; expands on activation.
852    ///
853    /// Returns `true` when the selection changed this frame.
854    /// Render a dropdown select widget.
855    pub fn select(&mut self, state: &mut SelectState) -> Response {
856        self.select_colored(state, &WidgetColors::new())
857    }
858
859    /// Render a dropdown select widget with custom widget colors.
860    pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
861        if state.items.is_empty() {
862            return Response::none();
863        }
864        state.selected = state.selected.min(state.items.len().saturating_sub(1));
865
866        let focused = self.register_focusable();
867        let interaction_id = self.next_interaction_id();
868        let mut response = self.response_for(interaction_id);
869        response.focused = focused;
870        let old_selected = state.selected;
871
872        self.select_handle_events(state, focused, response.clicked);
873        self.select_render(state, focused, colors);
874        response.changed = state.selected != old_selected;
875        response
876    }
877
878    fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
879        if clicked {
880            state.open = !state.open;
881            if state.open {
882                state.set_cursor(state.selected);
883            }
884        }
885
886        if !focused {
887            return;
888        }
889
890        let mut consumed_indices = Vec::new();
891        for (i, event) in self.events.iter().enumerate() {
892            if self.consumed[i] {
893                continue;
894            }
895            if let Event::Key(key) = event {
896                if key.kind != KeyEventKind::Press {
897                    continue;
898                }
899                if state.open {
900                    match key.code {
901                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
902                            let mut cursor = state.cursor();
903                            let _ = handle_vertical_nav(
904                                &mut cursor,
905                                state.items.len().saturating_sub(1),
906                                key.code.clone(),
907                            );
908                            state.set_cursor(cursor);
909                            consumed_indices.push(i);
910                        }
911                        KeyCode::Enter | KeyCode::Char(' ') => {
912                            state.selected = state.cursor();
913                            state.open = false;
914                            consumed_indices.push(i);
915                        }
916                        KeyCode::Esc => {
917                            state.open = false;
918                            consumed_indices.push(i);
919                        }
920                        _ => {}
921                    }
922                } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
923                    state.open = true;
924                    state.set_cursor(state.selected);
925                    consumed_indices.push(i);
926                }
927            }
928        }
929        for idx in consumed_indices {
930            self.consumed[idx] = true;
931        }
932    }
933
934    fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
935        let border_color = if focused {
936            colors.accent.unwrap_or(self.theme.primary)
937        } else {
938            colors.border.unwrap_or(self.theme.border)
939        };
940        let display_text = state
941            .items
942            .get(state.selected)
943            .cloned()
944            .unwrap_or_else(|| state.placeholder.clone());
945        let arrow = if state.open { "▲" } else { "▼" };
946
947        self.commands.push(Command::BeginContainer {
948            direction: Direction::Column,
949            gap: 0,
950            align: Align::Start,
951            align_self: None,
952            justify: Justify::Start,
953            border: None,
954            border_sides: BorderSides::all(),
955            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
956            bg_color: None,
957            padding: Padding::default(),
958            margin: Margin::default(),
959            constraints: Constraints::default(),
960            title: None,
961            grow: 0,
962            group_name: None,
963        });
964
965        self.render_select_trigger(&display_text, arrow, border_color, colors);
966
967        if state.open {
968            self.render_select_dropdown(state, colors);
969        }
970
971        self.commands.push(Command::EndContainer);
972        self.last_text_idx = None;
973    }
974
975    fn render_select_trigger(
976        &mut self,
977        display_text: &str,
978        arrow: &str,
979        border_color: Color,
980        colors: &WidgetColors,
981    ) {
982        self.commands.push(Command::BeginContainer {
983            direction: Direction::Row,
984            gap: 1,
985            align: Align::Start,
986            align_self: None,
987            justify: Justify::Start,
988            border: Some(Border::Rounded),
989            border_sides: BorderSides::all(),
990            border_style: Style::new().fg(border_color),
991            bg_color: None,
992            padding: Padding {
993                left: 1,
994                right: 1,
995                top: 0,
996                bottom: 0,
997            },
998            margin: Margin::default(),
999            constraints: Constraints::default(),
1000            title: None,
1001            grow: 0,
1002            group_name: None,
1003        });
1004        self.interaction_count += 1;
1005        self.styled(
1006            display_text,
1007            Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1008        );
1009        self.styled(
1010            arrow,
1011            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1012        );
1013        self.commands.push(Command::EndContainer);
1014        self.last_text_idx = None;
1015    }
1016
1017    fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1018        for (idx, item) in state.items.iter().enumerate() {
1019            let is_cursor = idx == state.cursor();
1020            let style = if is_cursor {
1021                Style::new()
1022                    .bold()
1023                    .fg(colors.accent.unwrap_or(self.theme.primary))
1024            } else {
1025                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1026            };
1027            let prefix = if is_cursor { "▸ " } else { "  " };
1028            let mut row = String::with_capacity(prefix.len() + item.len());
1029            row.push_str(prefix);
1030            row.push_str(item);
1031            self.styled(row, style);
1032        }
1033    }
1034
1035    // ── radio ────────────────────────────────────────────────────────
1036
1037    /// Render a radio button group. Returns `true` when selection changed.
1038    /// Render a radio button group.
1039    pub fn radio(&mut self, state: &mut RadioState) -> Response {
1040        self.radio_colored(state, &WidgetColors::new())
1041    }
1042
1043    /// Render a radio button group with custom widget colors.
1044    pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1045        if state.items.is_empty() {
1046            return Response::none();
1047        }
1048        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1049        let focused = self.register_focusable();
1050        let old_selected = state.selected;
1051
1052        if focused {
1053            let mut consumed_indices = Vec::new();
1054            for (i, event) in self.events.iter().enumerate() {
1055                if self.consumed[i] {
1056                    continue;
1057                }
1058                if let Event::Key(key) = event {
1059                    if key.kind != KeyEventKind::Press {
1060                        continue;
1061                    }
1062                    match key.code {
1063                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1064                            let _ = handle_vertical_nav(
1065                                &mut state.selected,
1066                                state.items.len().saturating_sub(1),
1067                                key.code.clone(),
1068                            );
1069                            consumed_indices.push(i);
1070                        }
1071                        KeyCode::Enter | KeyCode::Char(' ') => {
1072                            consumed_indices.push(i);
1073                        }
1074                        _ => {}
1075                    }
1076                }
1077            }
1078            for idx in consumed_indices {
1079                self.consumed[idx] = true;
1080            }
1081        }
1082
1083        let interaction_id = self.next_interaction_id();
1084        let mut response = self.response_for(interaction_id);
1085        response.focused = focused;
1086
1087        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1088            for (i, event) in self.events.iter().enumerate() {
1089                if self.consumed[i] {
1090                    continue;
1091                }
1092                if let Event::Mouse(mouse) = event {
1093                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1094                        continue;
1095                    }
1096                    let in_bounds = mouse.x >= rect.x
1097                        && mouse.x < rect.right()
1098                        && mouse.y >= rect.y
1099                        && mouse.y < rect.bottom();
1100                    if !in_bounds {
1101                        continue;
1102                    }
1103                    let clicked_idx = (mouse.y - rect.y) as usize;
1104                    if clicked_idx < state.items.len() {
1105                        state.selected = clicked_idx;
1106                        self.consumed[i] = true;
1107                    }
1108                }
1109            }
1110        }
1111
1112        self.commands.push(Command::BeginContainer {
1113            direction: Direction::Column,
1114            gap: 0,
1115            align: Align::Start,
1116            align_self: None,
1117            justify: Justify::Start,
1118            border: None,
1119            border_sides: BorderSides::all(),
1120            border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1121            bg_color: None,
1122            padding: Padding::default(),
1123            margin: Margin::default(),
1124            constraints: Constraints::default(),
1125            title: None,
1126            grow: 0,
1127            group_name: None,
1128        });
1129
1130        for (idx, item) in state.items.iter().enumerate() {
1131            let is_selected = idx == state.selected;
1132            let marker = if is_selected { "●" } else { "○" };
1133            let style = if is_selected {
1134                if focused {
1135                    Style::new()
1136                        .bold()
1137                        .fg(colors.accent.unwrap_or(self.theme.primary))
1138                } else {
1139                    Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1140                }
1141            } else {
1142                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1143            };
1144            let prefix = if focused && idx == state.selected {
1145                "▸ "
1146            } else {
1147                "  "
1148            };
1149            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1150            row.push_str(prefix);
1151            row.push_str(marker);
1152            row.push(' ');
1153            row.push_str(item);
1154            self.styled(row, style);
1155        }
1156
1157        self.commands.push(Command::EndContainer);
1158        self.last_text_idx = None;
1159        response.changed = state.selected != old_selected;
1160        response
1161    }
1162
1163    // ── multi-select ─────────────────────────────────────────────────
1164
1165    /// Render a multi-select list. Space toggles, Up/Down navigates.
1166    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1167        if state.items.is_empty() {
1168            return Response::none();
1169        }
1170        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1171        let focused = self.register_focusable();
1172        let old_selected = state.selected.clone();
1173
1174        if focused {
1175            let mut consumed_indices = Vec::new();
1176            for (i, event) in self.events.iter().enumerate() {
1177                if self.consumed[i] {
1178                    continue;
1179                }
1180                if let Event::Key(key) = event {
1181                    if key.kind != KeyEventKind::Press {
1182                        continue;
1183                    }
1184                    match key.code {
1185                        KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1186                            let _ = handle_vertical_nav(
1187                                &mut state.cursor,
1188                                state.items.len().saturating_sub(1),
1189                                key.code.clone(),
1190                            );
1191                            consumed_indices.push(i);
1192                        }
1193                        KeyCode::Char(' ') | KeyCode::Enter => {
1194                            state.toggle(state.cursor);
1195                            consumed_indices.push(i);
1196                        }
1197                        _ => {}
1198                    }
1199                }
1200            }
1201            for idx in consumed_indices {
1202                self.consumed[idx] = true;
1203            }
1204        }
1205
1206        let interaction_id = self.next_interaction_id();
1207        let mut response = self.response_for(interaction_id);
1208        response.focused = focused;
1209
1210        if let Some(rect) = self.prev_hit_map.get(interaction_id).copied() {
1211            for (i, event) in self.events.iter().enumerate() {
1212                if self.consumed[i] {
1213                    continue;
1214                }
1215                if let Event::Mouse(mouse) = event {
1216                    if !matches!(mouse.kind, MouseKind::Down(MouseButton::Left)) {
1217                        continue;
1218                    }
1219                    let in_bounds = mouse.x >= rect.x
1220                        && mouse.x < rect.right()
1221                        && mouse.y >= rect.y
1222                        && mouse.y < rect.bottom();
1223                    if !in_bounds {
1224                        continue;
1225                    }
1226                    let clicked_idx = (mouse.y - rect.y) as usize;
1227                    if clicked_idx < state.items.len() {
1228                        state.toggle(clicked_idx);
1229                        state.cursor = clicked_idx;
1230                        self.consumed[i] = true;
1231                    }
1232                }
1233            }
1234        }
1235
1236        self.commands.push(Command::BeginContainer {
1237            direction: Direction::Column,
1238            gap: 0,
1239            align: Align::Start,
1240            align_self: None,
1241            justify: Justify::Start,
1242            border: None,
1243            border_sides: BorderSides::all(),
1244            border_style: Style::new().fg(self.theme.border),
1245            bg_color: None,
1246            padding: Padding::default(),
1247            margin: Margin::default(),
1248            constraints: Constraints::default(),
1249            title: None,
1250            grow: 0,
1251            group_name: None,
1252        });
1253
1254        for (idx, item) in state.items.iter().enumerate() {
1255            let checked = state.selected.contains(&idx);
1256            let marker = if checked { "[x]" } else { "[ ]" };
1257            let is_cursor = idx == state.cursor;
1258            let style = if is_cursor && focused {
1259                Style::new().bold().fg(self.theme.primary)
1260            } else if checked {
1261                Style::new().fg(self.theme.success)
1262            } else {
1263                Style::new().fg(self.theme.text)
1264            };
1265            let prefix = if is_cursor && focused { "▸ " } else { "  " };
1266            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1267            row.push_str(prefix);
1268            row.push_str(marker);
1269            row.push(' ');
1270            row.push_str(item);
1271            self.styled(row, style);
1272        }
1273
1274        self.commands.push(Command::EndContainer);
1275        self.last_text_idx = None;
1276        response.changed = state.selected != old_selected;
1277        response
1278    }
1279
1280    // ── tree ─────────────────────────────────────────────────────────
1281}