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