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