Skip to main content

slt/context/widgets_interactive/
selection.rs

1use super::*;
2
3impl Context {
4    /// Render a data table with sortable columns and row selection.
5    ///
6    /// Handles Up/Down selection when focused. Column widths are computed
7    /// automatically from header and cell content. The selected row is
8    /// highlighted with the theme's selection colors.
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 horizontal 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    pub fn tabs(&mut self, state: &mut TabsState) -> Response {
284        let colors = self.widget_theme.tabs;
285        self.tabs_colored(state, &colors)
286    }
287
288    /// Render a horizontal tab bar with custom widget colors.
289    pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
290        if state.labels.is_empty() {
291            state.selected = 0;
292            return Response::none();
293        }
294
295        state.selected = state.selected.min(state.labels.len().saturating_sub(1));
296        let old_selected = state.selected;
297        let focused = self.register_focusable();
298        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
299
300        if focused {
301            let mut consumed_indices = Vec::new();
302            for (i, key) in self.available_key_presses() {
303                match key.code {
304                    KeyCode::Left => {
305                        state.selected = if state.selected == 0 {
306                            state.labels.len().saturating_sub(1)
307                        } else {
308                            state.selected - 1
309                        };
310                        consumed_indices.push(i);
311                    }
312                    KeyCode::Right => {
313                        if !state.labels.is_empty() {
314                            state.selected = (state.selected + 1) % state.labels.len();
315                        }
316                        consumed_indices.push(i);
317                    }
318                    _ => {}
319                }
320            }
321            self.consume_indices(consumed_indices);
322        }
323
324        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
325            let mut consumed = Vec::new();
326            for (i, mouse) in clicks {
327                let mut x_offset = 0u32;
328                let rel_x = mouse.x.saturating_sub(rect.x);
329                for (idx, label) in state.labels.iter().enumerate() {
330                    let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
331                    if rel_x >= x_offset && rel_x < x_offset + tab_width {
332                        state.selected = idx;
333                        consumed.push(i);
334                        break;
335                    }
336                    x_offset += tab_width + 1;
337                }
338            }
339            self.consume_indices(consumed);
340        }
341
342        let tabs_gap = self.theme.spacing.xs();
343        self.commands
344            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
345                direction: Direction::Row,
346                gap: tabs_gap,
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. Activation fires via Enter, Space, or mouse click.
388    ///
389    /// The returned [`Response::clicked`] flag is set on activation. The button
390    /// is styled with the theme's primary color when focused and the accent
391    /// color when hovered.
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        let cb_gap = self.theme.spacing.xs();
607        self.commands
608            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
609                direction: Direction::Row,
610                gap: cb_gap,
611                align: Align::Start,
612                align_self: None,
613                justify: Justify::Start,
614                border: None,
615                border_sides: BorderSides::all(),
616                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
617                bg_color: hover_bg,
618                padding: Padding::default(),
619                margin: Margin::default(),
620                constraints: Constraints::default(),
621                title: None,
622                grow: 0,
623                group_name: None,
624            })));
625        let marker_style = if *checked {
626            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
627        } else {
628            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
629        };
630        let marker = if *checked { "[x]" } else { "[ ]" };
631        let label_text = label.into();
632        if focused {
633            let mut marker_text = String::with_capacity(2 + marker.len());
634            marker_text.push_str("▸ ");
635            marker_text.push_str(marker);
636            self.styled(marker_text, marker_style.bold());
637            self.styled(
638                label_text,
639                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
640            );
641        } else {
642            self.styled(marker, marker_style);
643            self.styled(
644                label_text,
645                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
646            );
647        }
648        self.commands.push(Command::EndContainer);
649        self.rollback.last_text_idx = None;
650
651        response.changed = *checked != old_checked;
652        response
653    }
654
655    /// Render an on/off toggle switch.
656    ///
657    /// Toggles `on` when activated via Enter, Space, or click. The switch
658    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
659    /// dim color respectively.
660    /// Render an on/off toggle switch.
661    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
662        let colors = self.widget_theme.toggle;
663        self.toggle_colored(label, on, &colors)
664    }
665
666    /// Render an on/off toggle switch with custom widget colors.
667    pub fn toggle_colored(
668        &mut self,
669        label: impl Into<String>,
670        on: &mut bool,
671        colors: &WidgetColors,
672    ) -> Response {
673        let focused = self.register_focusable();
674        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
675        let mut should_toggle = response.clicked;
676        let old_on = *on;
677
678        should_toggle |= self.consume_activation_keys(focused);
679
680        if should_toggle {
681            *on = !*on;
682        }
683
684        let hover_bg = if response.hovered || focused {
685            Some(self.theme.surface_hover)
686        } else {
687            None
688        };
689        let toggle_gap = self.theme.spacing.sm();
690        self.commands
691            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
692                direction: Direction::Row,
693                gap: toggle_gap,
694                align: Align::Start,
695                align_self: None,
696                justify: Justify::Start,
697                border: None,
698                border_sides: BorderSides::all(),
699                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
700                bg_color: hover_bg,
701                padding: Padding::default(),
702                margin: Margin::default(),
703                constraints: Constraints::default(),
704                title: None,
705                grow: 0,
706                group_name: None,
707            })));
708        let label_text = label.into();
709        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
710        let switch_style = if *on {
711            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
712        } else {
713            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
714        };
715        if focused {
716            let mut focused_label = String::with_capacity(2 + label_text.len());
717            focused_label.push_str("▸ ");
718            focused_label.push_str(&label_text);
719            self.styled(
720                focused_label,
721                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
722            );
723            self.styled(switch, switch_style.bold());
724        } else {
725            self.styled(
726                label_text,
727                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
728            );
729            self.styled(switch, switch_style);
730        }
731        self.commands.push(Command::EndContainer);
732        self.rollback.last_text_idx = None;
733
734        response.changed = *on != old_on;
735        response
736    }
737
738    // ── select / dropdown ─────────────────────────────────────────────
739
740    /// Render a dropdown select. Shows the selected item; expands on activation.
741    ///
742    /// Returns `true` when the selection changed this frame.
743    /// Render a dropdown select widget.
744    pub fn select(&mut self, state: &mut SelectState) -> Response {
745        let colors = self.widget_theme.select;
746        self.select_colored(state, &colors)
747    }
748
749    /// Render a dropdown select widget with custom widget colors.
750    pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
751        if state.items.is_empty() {
752            return Response::none();
753        }
754        state.selected = state.selected.min(state.items.len().saturating_sub(1));
755
756        let focused = self.register_focusable();
757        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
758        let old_selected = state.selected;
759
760        self.select_handle_events(state, focused, response.clicked);
761        self.select_render(state, focused, colors);
762        response.changed = state.selected != old_selected;
763        response
764    }
765
766    fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
767        if clicked {
768            state.open = !state.open;
769            if state.open {
770                state.set_cursor(state.selected);
771            }
772        }
773
774        if !focused {
775            return;
776        }
777
778        let mut consumed_indices = Vec::new();
779        for (i, key) in self.available_key_presses() {
780            if state.open {
781                match key.code {
782                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
783                        let mut cursor = state.cursor();
784                        let _ = handle_vertical_nav(
785                            &mut cursor,
786                            state.items.len().saturating_sub(1),
787                            key.code.clone(),
788                        );
789                        state.set_cursor(cursor);
790                        consumed_indices.push(i);
791                    }
792                    KeyCode::Enter | KeyCode::Char(' ') => {
793                        state.selected = state.cursor();
794                        state.open = false;
795                        consumed_indices.push(i);
796                    }
797                    KeyCode::Esc => {
798                        state.open = false;
799                        consumed_indices.push(i);
800                    }
801                    _ => {}
802                }
803            } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
804                state.open = true;
805                state.set_cursor(state.selected);
806                consumed_indices.push(i);
807            }
808        }
809        self.consume_indices(consumed_indices);
810    }
811
812    fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
813        let border_color = if focused {
814            colors.accent.unwrap_or(self.theme.primary)
815        } else {
816            colors.border.unwrap_or(self.theme.border)
817        };
818        let display_text = state
819            .items
820            .get(state.selected)
821            .cloned()
822            .unwrap_or_else(|| state.placeholder.clone());
823        let arrow = if state.open { "▲" } else { "▼" };
824
825        self.commands
826            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
827                direction: Direction::Column,
828                gap: 0,
829                align: Align::Start,
830                align_self: None,
831                justify: Justify::Start,
832                border: None,
833                border_sides: BorderSides::all(),
834                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
835                bg_color: None,
836                padding: Padding::default(),
837                margin: Margin::default(),
838                constraints: Constraints::default(),
839                title: None,
840                grow: 0,
841                group_name: None,
842            })));
843
844        self.render_select_trigger(&display_text, arrow, border_color, colors);
845
846        if state.open {
847            self.render_select_dropdown(state, colors);
848        }
849
850        self.commands.push(Command::EndContainer);
851        self.rollback.last_text_idx = None;
852    }
853
854    fn render_select_trigger(
855        &mut self,
856        display_text: &str,
857        arrow: &str,
858        border_color: Color,
859        colors: &WidgetColors,
860    ) {
861        let trig_gap = self.theme.spacing.xs();
862        let trig_h = self.theme.spacing.xs();
863        self.commands
864            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
865                direction: Direction::Row,
866                gap: trig_gap,
867                align: Align::Start,
868                align_self: None,
869                justify: Justify::Start,
870                border: Some(Border::Rounded),
871                border_sides: BorderSides::all(),
872                border_style: Style::new().fg(border_color),
873                bg_color: None,
874                padding: Padding {
875                    left: trig_h,
876                    right: trig_h,
877                    top: 0,
878                    bottom: 0,
879                },
880                margin: Margin::default(),
881                constraints: Constraints::default(),
882                title: None,
883                grow: 0,
884                group_name: None,
885            })));
886        self.skip_interaction_slot();
887        self.styled(
888            display_text,
889            Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
890        );
891        self.styled(
892            arrow,
893            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
894        );
895        self.commands.push(Command::EndContainer);
896        self.rollback.last_text_idx = None;
897    }
898
899    fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
900        for (idx, item) in state.items.iter().enumerate() {
901            let is_cursor = idx == state.cursor();
902            let style = if is_cursor {
903                Style::new()
904                    .bold()
905                    .fg(colors.accent.unwrap_or(self.theme.primary))
906            } else {
907                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
908            };
909            let prefix = if is_cursor { "▸ " } else { "  " };
910            let mut row = String::with_capacity(prefix.len() + item.len());
911            row.push_str(prefix);
912            row.push_str(item);
913            self.styled(row, style);
914        }
915    }
916
917    // ── radio ────────────────────────────────────────────────────────
918
919    /// Render a radio button group. Returns `true` when selection changed.
920    /// Render a radio button group.
921    pub fn radio(&mut self, state: &mut RadioState) -> Response {
922        let colors = self.widget_theme.radio;
923        self.radio_colored(state, &colors)
924    }
925
926    /// Render a radio button group with custom widget colors.
927    pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
928        if state.items.is_empty() {
929            return Response::none();
930        }
931        state.selected = state.selected.min(state.items.len().saturating_sub(1));
932        let focused = self.register_focusable();
933        let old_selected = state.selected;
934
935        if focused {
936            let mut consumed_indices = Vec::new();
937            for (i, key) in self.available_key_presses() {
938                match key.code {
939                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
940                        let _ = handle_vertical_nav(
941                            &mut state.selected,
942                            state.items.len().saturating_sub(1),
943                            key.code.clone(),
944                        );
945                        consumed_indices.push(i);
946                    }
947                    KeyCode::Enter | KeyCode::Char(' ') => {
948                        consumed_indices.push(i);
949                    }
950                    _ => {}
951                }
952            }
953            self.consume_indices(consumed_indices);
954        }
955
956        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
957
958        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
959            let mut consumed = Vec::new();
960            for (i, mouse) in clicks {
961                let clicked_idx = (mouse.y - rect.y) as usize;
962                if clicked_idx < state.items.len() {
963                    state.selected = clicked_idx;
964                    consumed.push(i);
965                }
966            }
967            self.consume_indices(consumed);
968        }
969
970        self.commands
971            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
972                direction: Direction::Column,
973                gap: 0,
974                align: Align::Start,
975                align_self: None,
976                justify: Justify::Start,
977                border: None,
978                border_sides: BorderSides::all(),
979                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
980                bg_color: None,
981                padding: Padding::default(),
982                margin: Margin::default(),
983                constraints: Constraints::default(),
984                title: None,
985                grow: 0,
986                group_name: None,
987            })));
988
989        for (idx, item) in state.items.iter().enumerate() {
990            let is_selected = idx == state.selected;
991            let marker = if is_selected { "●" } else { "○" };
992            let style = if is_selected {
993                if focused {
994                    Style::new()
995                        .bold()
996                        .fg(colors.accent.unwrap_or(self.theme.primary))
997                } else {
998                    Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
999                }
1000            } else {
1001                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1002            };
1003            let prefix = if focused && idx == state.selected {
1004                "▸ "
1005            } else {
1006                "  "
1007            };
1008            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1009            row.push_str(prefix);
1010            row.push_str(marker);
1011            row.push(' ');
1012            row.push_str(item);
1013            self.styled(row, style);
1014        }
1015
1016        self.commands.push(Command::EndContainer);
1017        self.rollback.last_text_idx = None;
1018        response.changed = state.selected != old_selected;
1019        response
1020    }
1021
1022    // ── multi-select ─────────────────────────────────────────────────
1023
1024    /// Render a multi-select list. Space toggles, Up/Down navigates.
1025    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1026        if state.items.is_empty() {
1027            return Response::none();
1028        }
1029        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1030        let focused = self.register_focusable();
1031        let old_selected = state.selected.clone();
1032
1033        if focused {
1034            let mut consumed_indices = Vec::new();
1035            for (i, key) in self.available_key_presses() {
1036                match key.code {
1037                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1038                        let _ = handle_vertical_nav(
1039                            &mut state.cursor,
1040                            state.items.len().saturating_sub(1),
1041                            key.code.clone(),
1042                        );
1043                        consumed_indices.push(i);
1044                    }
1045                    KeyCode::Char(' ') | KeyCode::Enter => {
1046                        state.toggle(state.cursor);
1047                        consumed_indices.push(i);
1048                    }
1049                    _ => {}
1050                }
1051            }
1052            self.consume_indices(consumed_indices);
1053        }
1054
1055        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1056
1057        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1058            let mut consumed = Vec::new();
1059            for (i, mouse) in clicks {
1060                let clicked_idx = (mouse.y - rect.y) as usize;
1061                if clicked_idx < state.items.len() {
1062                    state.toggle(clicked_idx);
1063                    state.cursor = clicked_idx;
1064                    consumed.push(i);
1065                }
1066            }
1067            self.consume_indices(consumed);
1068        }
1069
1070        self.commands
1071            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1072                direction: Direction::Column,
1073                gap: 0,
1074                align: Align::Start,
1075                align_self: None,
1076                justify: Justify::Start,
1077                border: None,
1078                border_sides: BorderSides::all(),
1079                border_style: Style::new().fg(self.theme.border),
1080                bg_color: None,
1081                padding: Padding::default(),
1082                margin: Margin::default(),
1083                constraints: Constraints::default(),
1084                title: None,
1085                grow: 0,
1086                group_name: None,
1087            })));
1088
1089        for (idx, item) in state.items.iter().enumerate() {
1090            let checked = state.selected.contains(&idx);
1091            let marker = if checked { "[x]" } else { "[ ]" };
1092            let is_cursor = idx == state.cursor;
1093            let style = if is_cursor && focused {
1094                Style::new().bold().fg(self.theme.primary)
1095            } else if checked {
1096                Style::new().fg(self.theme.success)
1097            } else {
1098                Style::new().fg(self.theme.text)
1099            };
1100            let prefix = if is_cursor && focused { "▸ " } else { "  " };
1101            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1102            row.push_str(prefix);
1103            row.push_str(marker);
1104            row.push(' ');
1105            row.push_str(item);
1106            self.styled(row, style);
1107        }
1108
1109        self.commands.push(Command::EndContainer);
1110        self.rollback.last_text_idx = None;
1111        response.changed = state.selected != old_selected;
1112        response
1113    }
1114
1115    // ── tree ─────────────────────────────────────────────────────────
1116}