Skip to main content

slt/context/widgets_interactive/
selection.rs

1use super::*;
2
3/// Maximum page count rendered as dots before [`Context::paginator`] falls back
4/// to the compact `{page}/{total}` counter to avoid overflowing the line.
5const PAGINATOR_MAX_DOTS: usize = 12;
6
7fn table_page_bounds(state: &TableState) -> (usize, usize) {
8    let total = state.visible_indices().len();
9    if state.page_size == 0 {
10        return (0, total);
11    }
12    let start = state.page.saturating_mul(state.page_size).min(total);
13    (
14        start,
15        start.saturating_add(table_visible_len(state)).min(total),
16    )
17}
18
19fn move_table_selection(selected: &mut usize, start: usize, end: usize, key: &KeyCode) {
20    if start >= end {
21        *selected = 0;
22        return;
23    }
24    match key {
25        KeyCode::Up | KeyCode::Char('k') => *selected = selected.saturating_sub(1).max(start),
26        KeyCode::Down | KeyCode::Char('j') => *selected = selected.saturating_add(1).min(end - 1),
27        _ => {}
28    }
29}
30
31/// Per-column cell renderer for [`Context::table_with`]: maps
32/// `(row_view_index, col_index, raw_cell)` to styled content.
33type TableCellRenderer = Box<dyn Fn(usize, usize, &str) -> (String, Style)>;
34
35impl Context {
36    /// Render a data table with sortable columns and row selection.
37    ///
38    /// Handles Up/Down selection when focused. Column widths are computed
39    /// automatically from header and cell content. The selected row is
40    /// highlighted with the theme's selection colors.
41    pub fn table(&mut self, state: &mut TableState) -> Response {
42        let colors = self.widget_theme.table;
43        self.table_colored(state, &colors)
44    }
45
46    /// Render a data table with custom widget colors.
47    pub fn table_colored(&mut self, state: &mut TableState, colors: &WidgetColors) -> Response {
48        self.table_inner(state, colors, None)
49    }
50
51    /// Render a data table with a per-column cell renderer.
52    ///
53    /// `cell` maps `(row_view_index, col_index, raw_cell)` to a
54    /// `(content, Style)` pair, letting any column carry its own foreground /
55    /// background / modifiers (a colored badge, a status label, an icon, …).
56    /// Columns whose closure returns the unchanged raw string with a default
57    /// [`Style`] fall back to the plain string-grid behavior. The closure is
58    /// `'static` (it is invoked during deferred row rendering) and is called
59    /// once per visible cell per frame.
60    ///
61    /// Sorting, filtering, pagination, width constraints, and multi-row
62    /// selection all behave exactly as in [`table`](Context::table); only the
63    /// per-cell content/style differs.
64    ///
65    /// Available since v0.21.0.
66    ///
67    /// # Example
68    ///
69    /// ```no_run
70    /// use slt::{Color, Style, widgets::TableState};
71    /// # slt::run(|ui: &mut slt::Context| {
72    /// let mut table = TableState::new(
73    ///     vec!["Service", "Status"],
74    ///     vec![vec!["api", "OK"], vec!["db", "DOWN"]],
75    /// );
76    /// ui.table_with(&mut table, |_row, col, raw| {
77    ///     if col == 1 {
78    ///         let color = if raw == "OK" { Color::Green } else { Color::Red };
79    ///         (raw.to_string(), Style::new().fg(color).bold())
80    ///     } else {
81    ///         (raw.to_string(), Style::default())
82    ///     }
83    /// });
84    /// # });
85    /// ```
86    pub fn table_with(
87        &mut self,
88        state: &mut TableState,
89        cell: impl Fn(usize, usize, &str) -> (String, Style) + 'static,
90    ) -> Response {
91        let colors = self.widget_theme.table;
92        self.table_inner(state, &colors, Some(Box::new(cell)))
93    }
94
95    fn table_inner(
96        &mut self,
97        state: &mut TableState,
98        colors: &WidgetColors,
99        cell: Option<TableCellRenderer>,
100    ) -> Response {
101        if state.is_dirty() {
102            state.recompute_widths();
103        }
104
105        let old_selected = state.selected;
106        let old_sort_column = state.sort_column;
107        let old_sort_ascending = state.sort_ascending;
108        let old_page = state.page;
109        let old_filter = state.filter().to_string();
110        let old_multi = state.multi_selected.clone();
111
112        let focused = self.register_focusable();
113        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
114
115        self.table_handle_events(state, focused, interaction_id);
116
117        if state.is_dirty() {
118            state.recompute_widths();
119        }
120        state.resolve_column_widths(self.area_width);
121
122        self.table_render(state, focused, colors, cell);
123
124        response.changed = state.selected != old_selected
125            || state.sort_column != old_sort_column
126            || state.sort_ascending != old_sort_ascending
127            || state.page != old_page
128            || state.filter() != old_filter
129            || state.multi_selected != old_multi;
130        response
131    }
132
133    fn table_handle_events(
134        &mut self,
135        state: &mut TableState,
136        focused: bool,
137        interaction_id: usize,
138    ) {
139        self.handle_table_keys(state, focused);
140
141        if state.visible_indices().is_empty() && state.headers().is_empty() {
142            return;
143        }
144
145        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
146            let mut consumed = Vec::new();
147            for (i, mouse) in clicks {
148                if mouse.y == rect.y {
149                    let rel_x = mouse.x.saturating_sub(rect.x);
150                    let mut x_offset = 0u32;
151                    for (col_idx, width) in state.column_widths().iter().enumerate() {
152                        if rel_x >= x_offset && rel_x < x_offset + *width {
153                            state.toggle_sort(col_idx);
154                            state.selected = 0;
155                            consumed.push(i);
156                            break;
157                        }
158                        x_offset += *width;
159                        if col_idx + 1 < state.column_widths().len() {
160                            x_offset += 3;
161                        }
162                    }
163                    continue;
164                }
165
166                if mouse.y < rect.y + 2 {
167                    continue;
168                }
169
170                let (page_start, page_end) = table_page_bounds(state);
171                let visible_len = page_end.saturating_sub(page_start);
172                let clicked_idx = (mouse.y - rect.y - 2) as usize;
173                if clicked_idx < visible_len {
174                    let clicked_idx = page_start + clicked_idx;
175                    state.selected = clicked_idx;
176                    if mouse.modifiers.contains(KeyModifiers::SHIFT) {
177                        let anchor = state.selection_anchor.unwrap_or(clicked_idx);
178                        state.select_range(anchor, clicked_idx);
179                    } else if mouse.modifiers.contains(KeyModifiers::CONTROL) {
180                        state.toggle_row(clicked_idx);
181                    } else {
182                        state.select_single(clicked_idx);
183                    }
184                    consumed.push(i);
185                }
186            }
187            self.consume_indices(consumed);
188        }
189    }
190
191    fn table_render(
192        &mut self,
193        state: &mut TableState,
194        focused: bool,
195        colors: &WidgetColors,
196        cell: Option<TableCellRenderer>,
197    ) {
198        let total_visible = state.visible_indices().len();
199        let page_start = if state.page_size > 0 {
200            state
201                .page
202                .saturating_mul(state.page_size)
203                .min(total_visible)
204        } else {
205            0
206        };
207        let page_end = if state.page_size > 0 {
208            (page_start + state.page_size).min(total_visible)
209        } else {
210            total_visible
211        };
212        let visible_len = page_end.saturating_sub(page_start);
213        if visible_len == 0 {
214            state.selected = 0;
215        } else {
216            state.selected = state.selected.clamp(page_start, page_end - 1);
217        }
218
219        self.commands
220            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
221                direction: Direction::Column,
222                gap: 0,
223                align: Align::Start,
224                align_self: None,
225                justify: Justify::Start,
226                border: None,
227                border_sides: BorderSides::all(),
228                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
229                bg_color: None,
230                padding: Padding::default(),
231                margin: Margin::default(),
232                constraints: Constraints::default(),
233                title: None,
234                grow: 0,
235                group_name: None,
236            })));
237
238        self.render_table_header(state, colors);
239        self.render_table_rows(state, focused, page_start, visible_len, colors, cell);
240
241        if state.page_size > 0 && state.total_pages() > 1 {
242            let current_page = (state.page + 1).to_string();
243            let total_pages = state.total_pages().to_string();
244            let mut page_text = String::with_capacity(current_page.len() + total_pages.len() + 6);
245            page_text.push_str("Page ");
246            page_text.push_str(&current_page);
247            page_text.push('/');
248            page_text.push_str(&total_pages);
249            self.styled(
250                page_text,
251                Style::new()
252                    .dim()
253                    .fg(colors.fg.unwrap_or(self.theme.text_dim)),
254            );
255        }
256
257        self.commands.push(Command::EndContainer);
258        self.rollback.last_text_idx = None;
259    }
260
261    fn handle_table_keys(&mut self, state: &mut TableState, focused: bool) {
262        if !focused || state.visible_indices().is_empty() {
263            return;
264        }
265
266        let mut consumed_indices = Vec::new();
267        for (i, key) in self.available_key_presses() {
268            let shift = key.modifiers.contains(KeyModifiers::SHIFT);
269            let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
270            let (page_start, page_end) = table_page_bounds(state);
271            if page_start < page_end {
272                state.selected = state.selected.clamp(page_start, page_end - 1);
273            }
274            match key.code {
275                // Shift+Up/Down: extend a contiguous range from the anchor.
276                KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') if shift => {
277                    let anchor = *state.selection_anchor.get_or_insert(state.selected);
278                    move_table_selection(&mut state.selected, page_start, page_end, &key.code);
279                    state.select_range(anchor, state.selected);
280                    consumed_indices.push(i);
281                }
282                // Plain Up/Down (or k/j): move the cursor only (back-compat).
283                KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
284                    move_table_selection(&mut state.selected, page_start, page_end, &key.code);
285                    consumed_indices.push(i);
286                }
287                // Ctrl+Space: toggle the focused row without clearing the set.
288                // Space: toggle the focused row (additive toggle).
289                KeyCode::Char(' ') if ctrl => {
290                    state.toggle_row(state.selected);
291                    consumed_indices.push(i);
292                }
293                KeyCode::Char(' ') => {
294                    state.toggle_row(state.selected);
295                    consumed_indices.push(i);
296                }
297                KeyCode::PageUp => {
298                    let old_page = state.page;
299                    state.prev_page();
300                    if state.page != old_page {
301                        state.selected = table_page_bounds(state).0;
302                    }
303                    consumed_indices.push(i);
304                }
305                KeyCode::PageDown => {
306                    let old_page = state.page;
307                    state.next_page();
308                    if state.page != old_page {
309                        state.selected = table_page_bounds(state).0;
310                    }
311                    consumed_indices.push(i);
312                }
313                _ => {}
314            }
315        }
316        self.consume_indices(consumed_indices);
317    }
318
319    fn render_table_header(&mut self, state: &TableState, colors: &WidgetColors) {
320        let header_cells = state
321            .headers()
322            .iter()
323            .enumerate()
324            .map(|(i, header)| {
325                if state.sort_column == Some(i) {
326                    if state.sort_ascending {
327                        let mut sorted_header = String::with_capacity(header.len() + 2);
328                        sorted_header.push_str(header);
329                        sorted_header.push_str(" ▲");
330                        sorted_header
331                    } else {
332                        let mut sorted_header = String::with_capacity(header.len() + 2);
333                        sorted_header.push_str(header);
334                        sorted_header.push_str(" ▼");
335                        sorted_header
336                    }
337                } else {
338                    header.clone()
339                }
340            })
341            .collect::<Vec<_>>();
342        let header_line = format_table_row(&header_cells, state.column_widths(), " │ ");
343        self.styled(
344            header_line,
345            Style::new().bold().fg(colors.fg.unwrap_or(self.theme.text)),
346        );
347
348        let separator = state
349            .column_widths()
350            .iter()
351            .map(|w| "─".repeat(*w as usize))
352            .collect::<Vec<_>>()
353            .join("─┼─");
354        self.text(separator);
355    }
356
357    fn render_table_rows(
358        &mut self,
359        state: &TableState,
360        focused: bool,
361        page_start: usize,
362        visible_len: usize,
363        colors: &WidgetColors,
364        cell: Option<TableCellRenderer>,
365    ) {
366        for idx in 0..visible_len {
367            let view_idx = page_start + idx;
368            let data_idx = state.visible_indices()[view_idx];
369            let Some(row) = state.row(data_idx) else {
370                continue;
371            };
372
373            // Base style for the whole row, applied to every cell unless the
374            // per-column renderer overrides it. Priority: focused cursor row >
375            // multi-selected row > zebra > plain. When `multi_selected` is empty
376            // (the default), this collapses to the pre-v0.21 behavior verbatim.
377            let base = if view_idx == state.selected {
378                let mut style = Style::new()
379                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
380                    .fg(colors.fg.unwrap_or(self.theme.selected_fg));
381                if focused {
382                    style = style.bold();
383                }
384                style
385            } else if state.is_row_selected(view_idx) {
386                // Dimmer selection background to distinguish set members from
387                // the brighter focused-cursor row.
388                Style::new()
389                    .bg(colors.accent.unwrap_or(self.theme.selected_bg))
390                    .fg(colors.fg.unwrap_or(self.theme.selected_fg))
391                    .dim()
392            } else {
393                let mut style = Style::new().fg(colors.fg.unwrap_or(self.theme.text));
394                if state.zebra {
395                    let zebra_bg = colors.bg.unwrap_or({
396                        if idx % 2 == 0 {
397                            self.theme.surface
398                        } else {
399                            self.theme.surface_hover
400                        }
401                    });
402                    style = style.bg(zebra_bg);
403                }
404                style
405            };
406
407            match &cell {
408                None => {
409                    let line = format_table_row(row, state.column_widths(), " │ ");
410                    self.styled(line, base);
411                }
412                Some(render) => {
413                    let widths = state.column_widths();
414                    let mut segments: Vec<(String, Style)> =
415                        Vec::with_capacity(widths.len().saturating_mul(2));
416                    for (col, width) in widths.iter().enumerate() {
417                        if col > 0 {
418                            segments.push((" │ ".to_string(), base));
419                        }
420                        let raw = row.get(col).map(String::as_str).unwrap_or("");
421                        let (content, cell_style) = render(view_idx, col, raw);
422                        // Overlay the per-cell style onto the row base: the cell
423                        // fg / bg win when set, modifiers are unioned. This keeps
424                        // the row selection background unless the cell overrides
425                        // it, while letting a column carry its own colored text.
426                        let mut merged = base;
427                        if cell_style.fg.is_some() {
428                            merged.fg = cell_style.fg;
429                        }
430                        if cell_style.bg.is_some() {
431                            merged.bg = cell_style.bg;
432                        }
433                        merged.modifiers |= cell_style.modifiers;
434                        let padded = clamp_table_cell(&content, *width);
435                        segments.push((padded, merged));
436                    }
437                    self.line(move |ui| {
438                        for (text, style) in segments {
439                            ui.styled(text, style);
440                        }
441                    });
442                }
443            }
444        }
445    }
446
447    /// Render a horizontal tab bar. Handles Left/Right navigation when focused.
448    ///
449    /// The active tab is rendered in the theme's primary color. If the labels
450    /// list is empty, nothing is rendered.
451    pub fn tabs(&mut self, state: &mut TabsState) -> Response {
452        let colors = self.widget_theme.tabs;
453        self.tabs_colored(state, &colors)
454    }
455
456    /// Render a horizontal tab bar with custom widget colors.
457    pub fn tabs_colored(&mut self, state: &mut TabsState, colors: &WidgetColors) -> Response {
458        if state.labels.is_empty() {
459            state.selected = 0;
460            return Response::none();
461        }
462
463        state.selected = state.selected.min(state.labels.len().saturating_sub(1));
464        let old_selected = state.selected;
465        let focused = self.register_focusable();
466        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
467
468        if focused {
469            let mut consumed_indices = Vec::new();
470            for (i, key) in self.available_key_presses() {
471                match key.code {
472                    KeyCode::Left => {
473                        state.selected = if state.selected == 0 {
474                            state.labels.len().saturating_sub(1)
475                        } else {
476                            state.selected - 1
477                        };
478                        consumed_indices.push(i);
479                    }
480                    KeyCode::Right => {
481                        if !state.labels.is_empty() {
482                            state.selected = (state.selected + 1) % state.labels.len();
483                        }
484                        consumed_indices.push(i);
485                    }
486                    _ => {}
487                }
488            }
489            self.consume_indices(consumed_indices);
490        }
491
492        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
493            let mut consumed = Vec::new();
494            for (i, mouse) in clicks {
495                let mut x_offset = 0u32;
496                let rel_x = mouse.x.saturating_sub(rect.x);
497                for (idx, label) in state.labels.iter().enumerate() {
498                    let tab_width = UnicodeWidthStr::width(label.as_str()) as u32 + 4;
499                    if rel_x >= x_offset && rel_x < x_offset + tab_width {
500                        state.selected = idx;
501                        consumed.push(i);
502                        break;
503                    }
504                    x_offset += tab_width + 1;
505                }
506            }
507            self.consume_indices(consumed);
508        }
509
510        let tabs_gap = self.theme.spacing.xs();
511        self.commands
512            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
513                direction: Direction::Row,
514                gap: tabs_gap as i32,
515                align: Align::Start,
516                align_self: None,
517                justify: Justify::Start,
518                border: None,
519                border_sides: BorderSides::all(),
520                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
521                bg_color: None,
522                padding: Padding::default(),
523                margin: Margin::default(),
524                constraints: Constraints::default(),
525                title: None,
526                grow: 0,
527                group_name: None,
528            })));
529        for (idx, label) in state.labels.iter().enumerate() {
530            let style = if idx == state.selected {
531                let s = Style::new()
532                    .fg(colors.accent.unwrap_or(self.theme.primary))
533                    .bold();
534                if focused { s.underline() } else { s }
535            } else {
536                Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
537            };
538            let mut tab = String::with_capacity(label.len() + 4);
539            tab.push_str("[ ");
540            tab.push_str(label);
541            tab.push_str(" ]");
542            self.styled(tab, style);
543        }
544        self.commands.push(Command::EndContainer);
545        self.rollback.last_text_idx = None;
546
547        response.changed = state.selected != old_selected;
548        response
549    }
550
551    /// Render a standalone paginator, decoupled from any list or table.
552    ///
553    /// Consumes Left/`h`/PageUp (previous page) and Right/`l`/PageDown (next
554    /// page) when focused, and consumes those key events when handled. Clicking
555    /// a dot (in [`PaginatorStyle::Dots`]) jumps to that page; clicking the
556    /// left/right half of the counter (in [`PaginatorStyle::Arabic`]) goes to
557    /// the previous/next page. [`Response::changed`] is `true` iff the page
558    /// changed this frame.
559    ///
560    /// Pass a `&mut PaginatorState` each frame and use
561    /// [`PaginatorState::page_bounds`] to slice your own data.
562    ///
563    /// # Example
564    ///
565    /// ```no_run
566    /// use slt::PaginatorState;
567    ///
568    /// let mut state = PaginatorState::new(42, 10);
569    /// # slt::run(move |ui: &mut slt::Context| {
570    /// ui.paginator(&mut state);
571    /// # });
572    /// ```
573    pub fn paginator(&mut self, state: &mut PaginatorState) -> Response {
574        // Reuse the tabs WidgetColors slot until a dedicated paginator slot lands.
575        let colors = self.widget_theme.tabs;
576        self.paginator_colored(state, &colors)
577    }
578
579    /// Render a standalone paginator with custom widget colors.
580    ///
581    /// Behaves exactly like [`Context::paginator`] but draws with the provided
582    /// [`WidgetColors`] instead of the theme defaults.
583    ///
584    /// # Example
585    ///
586    /// ```no_run
587    /// use slt::{Color, PaginatorState, WidgetColors};
588    ///
589    /// let mut state = PaginatorState::new(20, 5);
590    /// let colors = WidgetColors {
591    ///     accent: Some(Color::Cyan),
592    ///     ..WidgetColors::default()
593    /// };
594    /// # slt::run(move |ui: &mut slt::Context| {
595    /// ui.paginator_colored(&mut state, &colors);
596    /// # });
597    /// ```
598    pub fn paginator_colored(
599        &mut self,
600        state: &mut PaginatorState,
601        colors: &WidgetColors,
602    ) -> Response {
603        state.page = state.page.min(state.total_pages().saturating_sub(1));
604        let old_page = state.page;
605
606        let focused = self.register_focusable();
607        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
608
609        if focused {
610            let mut consumed_indices = Vec::new();
611            for (i, key) in self.available_key_presses() {
612                match key.code {
613                    KeyCode::Left | KeyCode::Char('h') | KeyCode::PageUp => {
614                        state.prev_page();
615                        consumed_indices.push(i);
616                    }
617                    KeyCode::Right | KeyCode::Char('l') | KeyCode::PageDown => {
618                        state.next_page();
619                        consumed_indices.push(i);
620                    }
621                    _ => {}
622                }
623            }
624            self.consume_indices(consumed_indices);
625        }
626
627        let total_pages = state.total_pages();
628        // Dots style overflows past 12 pages, so fall back to the compact counter.
629        let use_dots =
630            matches!(state.style, PaginatorStyle::Dots) && total_pages <= PAGINATOR_MAX_DOTS;
631
632        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
633            let mut consumed = Vec::new();
634            for (i, mouse) in clicks {
635                if mouse.y != rect.y {
636                    continue;
637                }
638                let rel_x = mouse.x.saturating_sub(rect.x);
639                if use_dots {
640                    // Dots render with no inter-glyph gap, so dot `n` is at column `n`.
641                    let target = rel_x as usize;
642                    if target < total_pages {
643                        state.set_page(target);
644                        consumed.push(i);
645                    }
646                } else {
647                    // Counter: left half -> prev, right half -> next.
648                    let label = format!("{}/{}", state.page + 1, total_pages);
649                    let width = UnicodeWidthStr::width(label.as_str()) as u32;
650                    if rel_x < width {
651                        if rel_x < width / 2 {
652                            state.prev_page();
653                        } else {
654                            state.next_page();
655                        }
656                        consumed.push(i);
657                    }
658                }
659            }
660            self.consume_indices(consumed);
661        }
662
663        self.commands
664            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
665                direction: Direction::Row,
666                gap: 0,
667                align: Align::Start,
668                align_self: None,
669                justify: Justify::Start,
670                border: None,
671                border_sides: BorderSides::all(),
672                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
673                bg_color: None,
674                padding: Padding::default(),
675                margin: Margin::default(),
676                constraints: Constraints::default(),
677                title: None,
678                grow: 0,
679                group_name: None,
680            })));
681
682        if use_dots {
683            let active_color = colors.accent.unwrap_or(self.theme.primary);
684            let inactive_color = colors.fg.unwrap_or(self.theme.text_dim);
685            for page in 0..total_pages {
686                let (glyph, color) = if page == state.page {
687                    ("●", active_color)
688                } else {
689                    ("○", inactive_color)
690                };
691                let style = if page == state.page && focused {
692                    Style::new().fg(color).bold()
693                } else {
694                    Style::new().fg(color)
695                };
696                self.styled(glyph, style);
697            }
698        } else {
699            let label = format!("{}/{}", state.page + 1, total_pages);
700            let style = Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim));
701            self.styled(label, style);
702        }
703
704        self.commands.push(Command::EndContainer);
705        self.rollback.last_text_idx = None;
706
707        response.changed = state.page != old_page;
708        response
709    }
710
711    /// Render a clickable button. Activation fires via Enter, Space, or mouse click.
712    ///
713    /// The returned [`Response::clicked`] flag is set on activation. The button
714    /// is styled with the theme's primary color when focused and the accent
715    /// color when hovered.
716    pub fn button(&mut self, label: impl Into<String>) -> Response {
717        let colors = self.widget_theme.button;
718        self.button_colored(label, &colors)
719    }
720
721    /// Render a clickable button with custom widget colors.
722    pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
723        let focused = self.register_focusable();
724        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
725
726        let activated = response.clicked || self.consume_activation_keys(focused);
727
728        let hovered = response.hovered;
729        let base_fg = colors.fg.unwrap_or(self.theme.text);
730        let accent = colors.accent.unwrap_or(self.theme.accent);
731        let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
732        let style = if focused {
733            Style::new().fg(accent).bold()
734        } else if hovered {
735            Style::new().fg(accent)
736        } else {
737            Style::new().fg(base_fg)
738        };
739        let has_custom_bg = colors.bg.is_some();
740        let bg_color = if has_custom_bg || hovered || focused {
741            Some(base_bg)
742        } else {
743            None
744        };
745
746        self.commands
747            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
748                direction: Direction::Row,
749                gap: 0,
750                align: Align::Start,
751                align_self: None,
752                justify: Justify::Start,
753                border: None,
754                border_sides: BorderSides::all(),
755                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
756                bg_color,
757                padding: Padding::default(),
758                margin: Margin::default(),
759                constraints: Constraints::default(),
760                title: None,
761                grow: 0,
762                group_name: None,
763            })));
764        let raw_label = label.into();
765        let mut label_text = String::with_capacity(raw_label.len() + 4);
766        label_text.push_str("[ ");
767        label_text.push_str(&raw_label);
768        label_text.push_str(" ]");
769        self.styled(label_text, style);
770        self.commands.push(Command::EndContainer);
771        self.rollback.last_text_idx = None;
772
773        response.clicked = activated;
774        response
775    }
776
777    /// Render a styled button variant. Returns `true` when activated.
778    ///
779    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
780    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
781    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
782        let focused = self.register_focusable();
783        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
784
785        let activated = response.clicked || self.consume_activation_keys(focused);
786
787        let label = label.into();
788        let hover_bg = if response.hovered || focused {
789            Some(self.theme.surface_hover)
790        } else {
791            None
792        };
793        let (text, style, bg_color, border) = match variant {
794            ButtonVariant::Default => {
795                let style = if focused {
796                    Style::new().fg(self.theme.primary).bold()
797                } else if response.hovered {
798                    Style::new().fg(self.theme.accent)
799                } else {
800                    Style::new().fg(self.theme.text)
801                };
802                let mut text = String::with_capacity(label.len() + 4);
803                text.push_str("[ ");
804                text.push_str(&label);
805                text.push_str(" ]");
806                (text, style, hover_bg, None)
807            }
808            ButtonVariant::Primary => {
809                let style = if focused {
810                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
811                } else if response.hovered {
812                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
813                } else {
814                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
815                };
816                let mut text = String::with_capacity(label.len() + 2);
817                text.push(' ');
818                text.push_str(&label);
819                text.push(' ');
820                (text, style, hover_bg, None)
821            }
822            ButtonVariant::Danger => {
823                let style = if focused {
824                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
825                } else if response.hovered {
826                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
827                } else {
828                    Style::new().fg(self.theme.bg).bg(self.theme.error)
829                };
830                let mut text = String::with_capacity(label.len() + 2);
831                text.push(' ');
832                text.push_str(&label);
833                text.push(' ');
834                (text, style, hover_bg, None)
835            }
836            ButtonVariant::Outline => {
837                let border_color = if focused {
838                    self.theme.primary
839                } else if response.hovered {
840                    self.theme.accent
841                } else {
842                    self.theme.border
843                };
844                let style = if focused {
845                    Style::new().fg(self.theme.primary).bold()
846                } else if response.hovered {
847                    Style::new().fg(self.theme.accent)
848                } else {
849                    Style::new().fg(self.theme.text)
850                };
851                (
852                    {
853                        let mut text = String::with_capacity(label.len() + 2);
854                        text.push(' ');
855                        text.push_str(&label);
856                        text.push(' ');
857                        text
858                    },
859                    style,
860                    hover_bg,
861                    Some((Border::Rounded, Style::new().fg(border_color))),
862                )
863            }
864        };
865
866        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
867        self.commands
868            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
869                direction: Direction::Row,
870                gap: 0,
871                align: Align::Center,
872                align_self: None,
873                justify: Justify::Center,
874                border: if border.is_some() {
875                    Some(btn_border)
876                } else {
877                    None
878                },
879                border_sides: BorderSides::all(),
880                border_style: btn_border_style,
881                bg_color,
882                padding: Padding::default(),
883                margin: Margin::default(),
884                constraints: Constraints::default(),
885                title: None,
886                grow: 0,
887                group_name: None,
888            })));
889        self.styled(text, style);
890        self.commands.push(Command::EndContainer);
891        self.rollback.last_text_idx = None;
892
893        response.clicked = activated;
894        response
895    }
896
897    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
898    ///
899    /// The checked state is shown with the theme's success color. When focused,
900    /// a `▸` prefix is added.
901    /// Render a checkbox toggle.
902    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
903        let colors = self.widget_theme.checkbox;
904        self.checkbox_colored(label, checked, &colors)
905    }
906
907    /// Render a checkbox toggle with custom widget colors.
908    pub fn checkbox_colored(
909        &mut self,
910        label: impl Into<String>,
911        checked: &mut bool,
912        colors: &WidgetColors,
913    ) -> Response {
914        let focused = self.register_focusable();
915        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
916        let mut should_toggle = response.clicked;
917        let old_checked = *checked;
918
919        should_toggle |= self.consume_activation_keys(focused);
920
921        if should_toggle {
922            *checked = !*checked;
923        }
924
925        let hover_bg = if response.hovered || focused {
926            Some(self.theme.surface_hover)
927        } else {
928            None
929        };
930        let cb_gap = self.theme.spacing.xs();
931        self.commands
932            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
933                direction: Direction::Row,
934                gap: cb_gap as i32,
935                align: Align::Start,
936                align_self: None,
937                justify: Justify::Start,
938                border: None,
939                border_sides: BorderSides::all(),
940                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
941                bg_color: hover_bg,
942                padding: Padding::default(),
943                margin: Margin::default(),
944                constraints: Constraints::default(),
945                title: None,
946                grow: 0,
947                group_name: None,
948            })));
949        let marker_style = if *checked {
950            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
951        } else {
952            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
953        };
954        let marker = if *checked { "[x]" } else { "[ ]" };
955        let label_text = label.into();
956        if focused {
957            let mut marker_text = String::with_capacity(2 + marker.len());
958            marker_text.push_str("▸ ");
959            marker_text.push_str(marker);
960            self.styled(marker_text, marker_style.bold());
961            self.styled(
962                label_text,
963                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
964            );
965        } else {
966            self.styled(marker, marker_style);
967            self.styled(
968                label_text,
969                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
970            );
971        }
972        self.commands.push(Command::EndContainer);
973        self.rollback.last_text_idx = None;
974
975        response.changed = *checked != old_checked;
976        response
977    }
978
979    /// Render an on/off toggle switch.
980    ///
981    /// Toggles `on` when activated via Enter, Space, or click. The switch
982    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
983    /// dim color respectively.
984    /// Render an on/off toggle switch.
985    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
986        let colors = self.widget_theme.toggle;
987        self.toggle_colored(label, on, &colors)
988    }
989
990    /// Render an on/off toggle switch with custom widget colors.
991    pub fn toggle_colored(
992        &mut self,
993        label: impl Into<String>,
994        on: &mut bool,
995        colors: &WidgetColors,
996    ) -> Response {
997        let focused = self.register_focusable();
998        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
999        let mut should_toggle = response.clicked;
1000        let old_on = *on;
1001
1002        should_toggle |= self.consume_activation_keys(focused);
1003
1004        if should_toggle {
1005            *on = !*on;
1006        }
1007
1008        let hover_bg = if response.hovered || focused {
1009            Some(self.theme.surface_hover)
1010        } else {
1011            None
1012        };
1013        let toggle_gap = self.theme.spacing.sm();
1014        self.commands
1015            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1016                direction: Direction::Row,
1017                gap: toggle_gap as i32,
1018                align: Align::Start,
1019                align_self: None,
1020                justify: Justify::Start,
1021                border: None,
1022                border_sides: BorderSides::all(),
1023                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1024                bg_color: hover_bg,
1025                padding: Padding::default(),
1026                margin: Margin::default(),
1027                constraints: Constraints::default(),
1028                title: None,
1029                grow: 0,
1030                group_name: None,
1031            })));
1032        let label_text = label.into();
1033        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1034        let switch_style = if *on {
1035            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1036        } else {
1037            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1038        };
1039        if focused {
1040            let mut focused_label = String::with_capacity(2 + label_text.len());
1041            focused_label.push_str("▸ ");
1042            focused_label.push_str(&label_text);
1043            self.styled(
1044                focused_label,
1045                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1046            );
1047            self.styled(switch, switch_style.bold());
1048        } else {
1049            self.styled(
1050                label_text,
1051                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1052            );
1053            self.styled(switch, switch_style);
1054        }
1055        self.commands.push(Command::EndContainer);
1056        self.rollback.last_text_idx = None;
1057
1058        response.changed = *on != old_on;
1059        response
1060    }
1061
1062    // ── select / dropdown ─────────────────────────────────────────────
1063
1064    /// Render a dropdown select. Shows the selected item; expands on activation.
1065    ///
1066    /// Returns `true` when the selection changed this frame.
1067    /// Render a dropdown select widget.
1068    pub fn select(&mut self, state: &mut SelectState) -> Response {
1069        let colors = self.widget_theme.select;
1070        self.select_colored(state, &colors)
1071    }
1072
1073    /// Render a dropdown select widget with custom widget colors.
1074    pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1075        if !state.is_empty() {
1076            state.selected = state.selected.min(state.len().saturating_sub(1));
1077        }
1078
1079        let focused = self.register_focusable();
1080        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1081        let old_selected = state.selected;
1082
1083        self.select_handle_events(state, focused, interaction_id);
1084        // Keep the cursor within the filtered subset before rendering.
1085        if state.open {
1086            let flen = state.filtered_indices().len();
1087            let cur = state.cursor();
1088            if flen == 0 {
1089                state.set_cursor(0);
1090            } else if cur >= flen {
1091                state.set_cursor(flen - 1);
1092            }
1093        }
1094        self.select_render(state, focused, colors);
1095        response.changed = state.selected != old_selected;
1096        response
1097    }
1098
1099    fn select_handle_events(
1100        &mut self,
1101        state: &mut SelectState,
1102        focused: bool,
1103        interaction_id: usize,
1104    ) {
1105        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1106            let mut consumed = Vec::new();
1107            for (event_index, mouse) in clicks {
1108                let relative_y = mouse.y.saturating_sub(rect.y) as usize;
1109                if relative_y < 3 {
1110                    if !state.is_empty() {
1111                        state.open = !state.open;
1112                        if state.open {
1113                            state.filter.clear();
1114                            state.set_cursor(state.selected);
1115                        }
1116                    }
1117                    consumed.push(event_index);
1118                    continue;
1119                }
1120
1121                if state.open {
1122                    let query_rows = usize::from(!state.filter.is_empty());
1123                    let row_start = 3 + query_rows;
1124                    if relative_y >= row_start {
1125                        let filtered = state.filtered_indices();
1126                        let row = relative_y - row_start;
1127                        if let Some(&data_index) = filtered.get(row) {
1128                            state.selected = data_index;
1129                            state.set_cursor(row);
1130                            state.open = false;
1131                            state.filter.clear();
1132                            consumed.push(event_index);
1133                        }
1134                    }
1135                }
1136            }
1137            self.consume_indices(consumed);
1138        }
1139
1140        if !focused {
1141            return;
1142        }
1143
1144        let mut consumed_indices = Vec::new();
1145        for (i, key) in self.available_key_presses() {
1146            if state.open {
1147                // Cursor indexes into the filtered subset (not `items`); arrow
1148                // keys navigate, printable keys type into the filter.
1149                let filtered_len = state.filtered_indices().len();
1150                match key.code {
1151                    KeyCode::Up => {
1152                        state.set_cursor(state.cursor().saturating_sub(1));
1153                        consumed_indices.push(i);
1154                    }
1155                    KeyCode::Down => {
1156                        if filtered_len > 0 {
1157                            let next = (state.cursor() + 1).min(filtered_len - 1);
1158                            state.set_cursor(next);
1159                        }
1160                        consumed_indices.push(i);
1161                    }
1162                    KeyCode::Enter => {
1163                        if let Some(&real) = state.filtered_indices().get(state.cursor()) {
1164                            state.selected = real;
1165                        }
1166                        state.open = false;
1167                        state.filter.clear();
1168                        consumed_indices.push(i);
1169                    }
1170                    KeyCode::Esc => {
1171                        // First Esc clears a non-empty query; a second closes.
1172                        if state.filter.is_empty() {
1173                            state.open = false;
1174                        } else {
1175                            state.filter.clear();
1176                            state.set_cursor(0);
1177                        }
1178                        consumed_indices.push(i);
1179                    }
1180                    KeyCode::Backspace => {
1181                        if let Some((byte_index, _)) =
1182                            state.filter.grapheme_indices(true).next_back()
1183                        {
1184                            state.filter.truncate(byte_index);
1185                        }
1186                        state.set_cursor(0);
1187                        consumed_indices.push(i);
1188                    }
1189                    KeyCode::Char(c) if !has_global_shortcut_modifier(key.modifiers) => {
1190                        // Printable keys (including space, 'j', 'k') type into the
1191                        // filter — arrows remain the only navigation while open.
1192                        state.filter.push(c);
1193                        state.set_cursor(0);
1194                        consumed_indices.push(i);
1195                    }
1196                    _ => {}
1197                }
1198            } else if !state.is_empty() && matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1199                state.open = true;
1200                state.filter.clear();
1201                state.set_cursor(state.selected);
1202                consumed_indices.push(i);
1203            }
1204        }
1205        if state.open {
1206            for (event_index, text) in self.available_pastes() {
1207                let inserted = text
1208                    .graphemes(true)
1209                    .filter(|cluster| {
1210                        cluster
1211                            .chars()
1212                            .all(|ch| (ch as u32) >= 0x20 && ch != '\u{7f}')
1213                    })
1214                    .collect::<String>();
1215                if !inserted.is_empty() {
1216                    state.filter.push_str(&inserted);
1217                    state.set_cursor(0);
1218                }
1219                consumed_indices.push(event_index);
1220            }
1221        }
1222        self.consume_indices(consumed_indices);
1223    }
1224
1225    fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
1226        let border_color = if focused {
1227            colors.accent.unwrap_or(self.theme.primary)
1228        } else {
1229            colors.border.unwrap_or(self.theme.border)
1230        };
1231        let display_text = state
1232            .items()
1233            .get(state.selected)
1234            .cloned()
1235            .unwrap_or_else(|| state.placeholder.clone());
1236        let arrow = if state.open { "▲" } else { "▼" };
1237
1238        self.commands
1239            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1240                direction: Direction::Column,
1241                gap: 0,
1242                align: Align::Start,
1243                align_self: None,
1244                justify: Justify::Start,
1245                border: None,
1246                border_sides: BorderSides::all(),
1247                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1248                bg_color: None,
1249                padding: Padding::default(),
1250                margin: Margin::default(),
1251                constraints: Constraints::default(),
1252                title: None,
1253                grow: 0,
1254                group_name: None,
1255            })));
1256
1257        self.render_select_trigger(&display_text, arrow, border_color, colors);
1258
1259        if state.open {
1260            self.render_select_dropdown(state, colors);
1261        }
1262
1263        self.commands.push(Command::EndContainer);
1264        self.rollback.last_text_idx = None;
1265    }
1266
1267    fn render_select_trigger(
1268        &mut self,
1269        display_text: &str,
1270        arrow: &str,
1271        border_color: Color,
1272        colors: &WidgetColors,
1273    ) {
1274        let trig_gap = self.theme.spacing.xs();
1275        let trig_h = self.theme.spacing.xs();
1276        self.commands
1277            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1278                direction: Direction::Row,
1279                gap: trig_gap as i32,
1280                align: Align::Start,
1281                align_self: None,
1282                justify: Justify::Start,
1283                border: Some(Border::Rounded),
1284                border_sides: BorderSides::all(),
1285                border_style: Style::new().fg(border_color),
1286                bg_color: None,
1287                padding: Padding {
1288                    left: trig_h,
1289                    right: trig_h,
1290                    top: 0,
1291                    bottom: 0,
1292                },
1293                margin: Margin::default(),
1294                constraints: Constraints::default(),
1295                title: None,
1296                grow: 0,
1297                group_name: None,
1298            })));
1299        self.skip_interaction_slot();
1300        self.styled(
1301            display_text,
1302            Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1303        );
1304        self.styled(
1305            arrow,
1306            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1307        );
1308        self.commands.push(Command::EndContainer);
1309        self.rollback.last_text_idx = None;
1310    }
1311
1312    fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1313        let filtered = state.filtered_indices();
1314
1315        // Show the active query so typing has visible feedback.
1316        if !state.filter.is_empty() {
1317            let dim = self.theme.text_dim;
1318            let mut q = String::with_capacity(state.filter.len() + 1);
1319            q.push('/');
1320            q.push_str(&state.filter);
1321            self.styled(q, Style::new().fg(dim).italic());
1322        }
1323
1324        if filtered.is_empty() {
1325            let dim = self.theme.text_dim;
1326            self.styled("  (no matches)".to_string(), Style::new().fg(dim).dim());
1327            return;
1328        }
1329
1330        let cursor = state.cursor();
1331        for (pos, &idx) in filtered.iter().enumerate() {
1332            let item = &state.items()[idx];
1333            let is_cursor = pos == cursor;
1334            let style = if is_cursor {
1335                Style::new()
1336                    .bold()
1337                    .fg(colors.accent.unwrap_or(self.theme.primary))
1338            } else {
1339                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1340            };
1341            let prefix = if is_cursor { "▸ " } else { "  " };
1342            let mut row = String::with_capacity(prefix.len() + item.len());
1343            row.push_str(prefix);
1344            row.push_str(item);
1345            self.styled(row, style);
1346        }
1347    }
1348
1349    // ── radio ────────────────────────────────────────────────────────
1350
1351    /// Render a radio button group. Returns `true` when selection changed.
1352    /// Render a radio button group.
1353    pub fn radio(&mut self, state: &mut RadioState) -> Response {
1354        let colors = self.widget_theme.radio;
1355        self.radio_colored(state, &colors)
1356    }
1357
1358    /// Render a radio button group with custom widget colors.
1359    pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1360        if state.items.is_empty() {
1361            return Response::none();
1362        }
1363        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1364        let focused = self.register_focusable();
1365        let old_selected = state.selected;
1366
1367        if focused {
1368            let mut consumed_indices = Vec::new();
1369            for (i, key) in self.available_key_presses() {
1370                match key.code {
1371                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1372                        let _ = handle_vertical_nav(
1373                            &mut state.selected,
1374                            state.items.len().saturating_sub(1),
1375                            key.code.clone(),
1376                        );
1377                        consumed_indices.push(i);
1378                    }
1379                    KeyCode::Enter | KeyCode::Char(' ') => {
1380                        consumed_indices.push(i);
1381                    }
1382                    _ => {}
1383                }
1384            }
1385            self.consume_indices(consumed_indices);
1386        }
1387
1388        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1389
1390        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1391            let mut consumed = Vec::new();
1392            for (i, mouse) in clicks {
1393                let clicked_idx = (mouse.y - rect.y) as usize;
1394                if clicked_idx < state.items.len() {
1395                    state.selected = clicked_idx;
1396                    consumed.push(i);
1397                }
1398            }
1399            self.consume_indices(consumed);
1400        }
1401
1402        self.commands
1403            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1404                direction: Direction::Column,
1405                gap: 0,
1406                align: Align::Start,
1407                align_self: None,
1408                justify: Justify::Start,
1409                border: None,
1410                border_sides: BorderSides::all(),
1411                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1412                bg_color: None,
1413                padding: Padding::default(),
1414                margin: Margin::default(),
1415                constraints: Constraints::default(),
1416                title: None,
1417                grow: 0,
1418                group_name: None,
1419            })));
1420
1421        for (idx, item) in state.items.iter().enumerate() {
1422            let is_selected = idx == state.selected;
1423            let marker = if is_selected { "●" } else { "○" };
1424            let style = if is_selected {
1425                if focused {
1426                    Style::new()
1427                        .bold()
1428                        .fg(colors.accent.unwrap_or(self.theme.primary))
1429                } else {
1430                    Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1431                }
1432            } else {
1433                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1434            };
1435            let prefix = if focused && idx == state.selected {
1436                "▸ "
1437            } else {
1438                "  "
1439            };
1440            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1441            row.push_str(prefix);
1442            row.push_str(marker);
1443            row.push(' ');
1444            row.push_str(item);
1445            self.styled(row, style);
1446        }
1447
1448        self.commands.push(Command::EndContainer);
1449        self.rollback.last_text_idx = None;
1450        response.changed = state.selected != old_selected;
1451        response
1452    }
1453
1454    // ── multi-select ─────────────────────────────────────────────────
1455
1456    /// Render a multi-select list. Space toggles, Up/Down navigates.
1457    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1458        if state.is_empty() {
1459            return Response::none();
1460        }
1461        state.cursor = state.cursor.min(state.len().saturating_sub(1));
1462        let focused = self.register_focusable();
1463        let old_selected = state.selected.clone();
1464
1465        if focused {
1466            let mut consumed_indices = Vec::new();
1467            for (i, key) in self.available_key_presses() {
1468                match key.code {
1469                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1470                        let max_index = state.len().saturating_sub(1);
1471                        let _ = handle_vertical_nav(&mut state.cursor, max_index, key.code.clone());
1472                        consumed_indices.push(i);
1473                    }
1474                    KeyCode::Char(' ') | KeyCode::Enter => {
1475                        state.toggle(state.cursor);
1476                        consumed_indices.push(i);
1477                    }
1478                    _ => {}
1479                }
1480            }
1481            self.consume_indices(consumed_indices);
1482        }
1483
1484        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1485
1486        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1487            let mut consumed = Vec::new();
1488            for (i, mouse) in clicks {
1489                let clicked_idx = (mouse.y - rect.y) as usize;
1490                if clicked_idx < state.len() {
1491                    state.toggle(clicked_idx);
1492                    state.cursor = clicked_idx;
1493                    consumed.push(i);
1494                }
1495            }
1496            self.consume_indices(consumed);
1497        }
1498
1499        self.commands
1500            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1501                direction: Direction::Column,
1502                gap: 0,
1503                align: Align::Start,
1504                align_self: None,
1505                justify: Justify::Start,
1506                border: None,
1507                border_sides: BorderSides::all(),
1508                border_style: Style::new().fg(self.theme.border),
1509                bg_color: None,
1510                padding: Padding::default(),
1511                margin: Margin::default(),
1512                constraints: Constraints::default(),
1513                title: None,
1514                grow: 0,
1515                group_name: None,
1516            })));
1517
1518        for (idx, item) in state.items().iter().enumerate() {
1519            let checked = state.selected.contains(&idx);
1520            let marker = if checked { "[x]" } else { "[ ]" };
1521            let is_cursor = idx == state.cursor;
1522            let style = if is_cursor && focused {
1523                Style::new().bold().fg(self.theme.primary)
1524            } else if checked {
1525                Style::new().fg(self.theme.success)
1526            } else {
1527                Style::new().fg(self.theme.text)
1528            };
1529            let prefix = if is_cursor && focused { "▸ " } else { "  " };
1530            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1531            row.push_str(prefix);
1532            row.push_str(marker);
1533            row.push(' ');
1534            row.push_str(item);
1535            self.styled(row, style);
1536        }
1537
1538        self.commands.push(Command::EndContainer);
1539        self.rollback.last_text_idx = None;
1540        response.changed = state.selected != old_selected;
1541        response
1542    }
1543
1544    // ── color picker ───────────────────────────────────────────────────
1545
1546    /// Render an interactive color picker over the [`Color`] model.
1547    ///
1548    /// Shows a grid of color swatches plus an optional hex-entry field. When
1549    /// focused, the arrow keys / `hjkl` move the 2D swatch cursor (clamped at
1550    /// the grid edges), `Tab` toggles between palette and hex entry, and
1551    /// `Enter` / `Space` confirms the current color. Returns `changed` on the
1552    /// exact frames where the selected [`Color`] differs from the previous
1553    /// frame. Read the chosen color back via
1554    /// [`ColorPickerState::selected`](crate::widgets::ColorPickerState::selected).
1555    ///
1556    /// Each swatch is emitted with a full-RGB background; the terminal backend
1557    /// downsamples it to the active [`ColorDepth`](crate::ColorDepth) on flush,
1558    /// so the picker degrades correctly on 256-color, 16-color, and no-color
1559    /// terminals. Uses the theme's `color_picker` slot for border and cursor
1560    /// colors; override per-call with
1561    /// [`color_picker_colored`](Self::color_picker_colored).
1562    ///
1563    /// # Example
1564    ///
1565    /// ```no_run
1566    /// # use slt::widgets::ColorPickerState;
1567    /// # slt::run(|ui: &mut slt::Context| {
1568    /// let mut picker = ColorPickerState::tailwind();
1569    /// if ui.color_picker(&mut picker).changed {
1570    ///     let chosen = picker.selected();
1571    ///     let _ = chosen;
1572    /// }
1573    /// # });
1574    /// ```
1575    pub fn color_picker(&mut self, state: &mut ColorPickerState) -> Response {
1576        let colors = self.widget_theme.color_picker;
1577        self.color_picker_colored(state, &colors)
1578    }
1579
1580    /// Render a color picker with custom [`WidgetColors`].
1581    ///
1582    /// Behaves exactly like [`color_picker`](Self::color_picker) but draws the
1583    /// border, cursor highlight, and hex field with the supplied colors instead
1584    /// of the theme's `color_picker` slot.
1585    ///
1586    /// # Example
1587    ///
1588    /// ```no_run
1589    /// # use slt::widgets::ColorPickerState;
1590    /// # use slt::{Color, WidgetColors};
1591    /// # slt::run(|ui: &mut slt::Context| {
1592    /// let mut picker = ColorPickerState::tailwind();
1593    /// let theme = WidgetColors::new().accent(Color::Cyan);
1594    /// ui.color_picker_colored(&mut picker, &theme);
1595    /// # });
1596    /// ```
1597    pub fn color_picker_colored(
1598        &mut self,
1599        state: &mut ColorPickerState,
1600        colors: &WidgetColors,
1601    ) -> Response {
1602        if state.colors.is_empty() {
1603            return Response::none();
1604        }
1605        let columns = state.columns.max(1);
1606        state.selected = state.selected.min(state.colors.len() - 1);
1607
1608        let focused = self.register_focusable();
1609        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1610        let old_color = state.selected();
1611
1612        self.color_picker_handle_keys(state, focused, columns);
1613        self.color_picker_handle_clicks(state, interaction_id, columns);
1614        self.color_picker_render(state, focused, columns, colors);
1615
1616        response.changed = state.selected() != old_color;
1617        response
1618    }
1619
1620    fn color_picker_handle_keys(
1621        &mut self,
1622        state: &mut ColorPickerState,
1623        focused: bool,
1624        columns: usize,
1625    ) {
1626        if !focused {
1627            return;
1628        }
1629        let len = state.colors.len();
1630        let mut consumed_indices = Vec::new();
1631        for (i, key) in self.available_key_presses() {
1632            match state.mode {
1633                PickerMode::Palette => match key.code {
1634                    KeyCode::Left | KeyCode::Char('h') => {
1635                        if !state.selected.is_multiple_of(columns) {
1636                            state.selected -= 1;
1637                        }
1638                        consumed_indices.push(i);
1639                    }
1640                    KeyCode::Right | KeyCode::Char('l') => {
1641                        if state.selected % columns < columns - 1 && state.selected + 1 < len {
1642                            state.selected += 1;
1643                        }
1644                        consumed_indices.push(i);
1645                    }
1646                    KeyCode::Up | KeyCode::Char('k') => {
1647                        if state.selected >= columns {
1648                            state.selected -= columns;
1649                        }
1650                        consumed_indices.push(i);
1651                    }
1652                    KeyCode::Down | KeyCode::Char('j') => {
1653                        if state.selected + columns < len {
1654                            state.selected += columns;
1655                        }
1656                        consumed_indices.push(i);
1657                    }
1658                    KeyCode::Tab => {
1659                        state.mode = PickerMode::Hex;
1660                        consumed_indices.push(i);
1661                    }
1662                    KeyCode::Enter | KeyCode::Char(' ') => {
1663                        consumed_indices.push(i);
1664                    }
1665                    _ => {}
1666                },
1667                PickerMode::Hex => match key.code {
1668                    KeyCode::Tab => {
1669                        state.mode = PickerMode::Palette;
1670                        consumed_indices.push(i);
1671                    }
1672                    KeyCode::Enter => {
1673                        consumed_indices.push(i);
1674                    }
1675                    KeyCode::Char(ch) => {
1676                        let index =
1677                            byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1678                        state.hex_input.value.insert(index, ch);
1679                        state.hex_input.cursor += 1;
1680                        color_picker_validate_hex(&mut state.hex_input);
1681                        consumed_indices.push(i);
1682                    }
1683                    KeyCode::Backspace => {
1684                        if state.hex_input.cursor > 0 {
1685                            let start = byte_index_for_char(
1686                                &state.hex_input.value,
1687                                state.hex_input.cursor - 1,
1688                            );
1689                            let end =
1690                                byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1691                            state.hex_input.value.replace_range(start..end, "");
1692                            state.hex_input.cursor -= 1;
1693                        }
1694                        color_picker_validate_hex(&mut state.hex_input);
1695                        consumed_indices.push(i);
1696                    }
1697                    _ => {}
1698                },
1699            }
1700        }
1701        self.consume_indices(consumed_indices);
1702    }
1703
1704    fn color_picker_handle_clicks(
1705        &mut self,
1706        state: &mut ColorPickerState,
1707        interaction_id: usize,
1708        columns: usize,
1709    ) {
1710        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1711            // The interaction rect spans the whole bordered container; the
1712            // swatch grid starts inside the top border and the left
1713            // border + x-padding. Offset clicks back into grid space.
1714            let grid_x0 = rect.x + GRID_X_OFFSET;
1715            let grid_y0 = rect.y + GRID_Y_OFFSET;
1716            let rows = state.colors.len().div_ceil(columns);
1717            let mut consumed = Vec::new();
1718            for (i, mouse) in clicks {
1719                if mouse.x < grid_x0 || mouse.y < grid_y0 {
1720                    continue;
1721                }
1722                let row = (mouse.y - grid_y0) as usize;
1723                let col = (mouse.x - grid_x0) as usize / SWATCH_WIDTH;
1724                if row < rows && col < columns {
1725                    let idx = row * columns + col;
1726                    if idx < state.colors.len() {
1727                        state.mode = PickerMode::Palette;
1728                        state.selected = idx;
1729                        consumed.push(i);
1730                    }
1731                }
1732            }
1733            self.consume_indices(consumed);
1734        }
1735    }
1736
1737    fn color_picker_render(
1738        &mut self,
1739        state: &ColorPickerState,
1740        focused: bool,
1741        columns: usize,
1742        colors: &WidgetColors,
1743    ) {
1744        let border_color = if focused {
1745            colors.accent.unwrap_or(self.theme.primary)
1746        } else {
1747            colors.border.unwrap_or(self.theme.border)
1748        };
1749        let text_color = colors.fg.unwrap_or(self.theme.text);
1750
1751        self.commands
1752            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1753                direction: Direction::Column,
1754                gap: 0,
1755                align: Align::Start,
1756                align_self: None,
1757                justify: Justify::Start,
1758                border: Some(Border::Rounded),
1759                border_sides: BorderSides::all(),
1760                border_style: Style::new().fg(border_color),
1761                bg_color: None,
1762                padding: Padding::xy(1, 0),
1763                margin: Margin::default(),
1764                constraints: Constraints::default(),
1765                title: None,
1766                grow: 0,
1767                group_name: None,
1768            })));
1769
1770        // Swatch grid: one Row container per grid row, one cell per swatch.
1771        let rows = state.colors.len().div_ceil(columns);
1772        for row in 0..rows {
1773            self.commands
1774                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1775                    direction: Direction::Row,
1776                    gap: 0,
1777                    align: Align::Start,
1778                    align_self: None,
1779                    justify: Justify::Start,
1780                    border: None,
1781                    border_sides: BorderSides::all(),
1782                    border_style: Style::new(),
1783                    bg_color: None,
1784                    padding: Padding::default(),
1785                    margin: Margin::default(),
1786                    constraints: Constraints::default(),
1787                    title: None,
1788                    grow: 0,
1789                    group_name: None,
1790                })));
1791            for col in 0..columns {
1792                let idx = row * columns + col;
1793                let Some(&swatch) = state.colors.get(idx) else {
1794                    break;
1795                };
1796                let is_cursor = idx == state.selected && state.mode == PickerMode::Palette;
1797                let marker = if is_cursor { '▣' } else { ' ' };
1798                let mut cell = String::with_capacity(SWATCH_WIDTH);
1799                cell.push(' ');
1800                cell.push(marker);
1801                cell.push(' ');
1802                // Full-RGB bg; the terminal flush downsamples per ColorDepth.
1803                // contrast_fg keeps the cursor marker legible on any swatch.
1804                let mut style = Style::new().bg(swatch).fg(Color::contrast_fg(swatch));
1805                if is_cursor {
1806                    style = style.bold();
1807                }
1808                self.styled(cell, style);
1809            }
1810            self.commands.push(Command::EndContainer);
1811            self.rollback.last_text_idx = None;
1812        }
1813
1814        // Selected color readout: a `#RRGGBB` label keeps the picker legible
1815        // under `ColorDepth::NoColor`, where no background color is emitted.
1816        let selected = state.selected();
1817        let label = color_hex_label(selected).unwrap_or_else(|| "selected".to_string());
1818        let mut readout = String::with_capacity(label.len() + 3);
1819        readout.push_str("▸ ");
1820        readout.push_str(&label);
1821        self.styled(readout, Style::new().fg(text_color).bold());
1822
1823        // Hex entry line. The embedded field shows the typed value (or its
1824        // placeholder); a `✗` flag surfaces the text-input validation error
1825        // path on malformed input without panicking.
1826        let hex_active = state.mode == PickerMode::Hex;
1827        let hex_display = if state.hex_input.value.is_empty() {
1828            state.hex_input.placeholder.clone()
1829        } else {
1830            state.hex_input.value.clone()
1831        };
1832        let mut hex_line = String::with_capacity(hex_display.len() + 6);
1833        hex_line.push_str(if hex_active { "▸ hex " } else { "  hex " });
1834        hex_line.push_str(&hex_display);
1835        if state.hex_input.validation_error.is_some() {
1836            hex_line.push_str(" ✗");
1837        }
1838        let hex_style = if hex_active {
1839            Style::new()
1840                .fg(colors.accent.unwrap_or(self.theme.primary))
1841                .bold()
1842        } else {
1843            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1844        };
1845        self.styled(hex_line, hex_style);
1846
1847        self.commands.push(Command::EndContainer);
1848        self.rollback.last_text_idx = None;
1849    }
1850
1851    // ── tree ─────────────────────────────────────────────────────────
1852}
1853
1854/// Display width in cells of one color-picker swatch (` ▣ ` / `   `).
1855const SWATCH_WIDTH: usize = 3;
1856
1857/// Horizontal offset from the picker's interaction rect to the swatch grid:
1858/// the rounded left border (1) plus the container's left x-padding (1).
1859const GRID_X_OFFSET: u32 = 2;
1860
1861/// Vertical offset from the picker's interaction rect to the swatch grid:
1862/// the rounded top border (1); the container has no top padding.
1863const GRID_Y_OFFSET: u32 = 1;
1864
1865/// Validate the hex-entry field, setting/clearing its `validation_error`.
1866///
1867/// An empty field is treated as "not yet entered" (no error). Any non-empty
1868/// value that does not parse as `#RRGGBB` / `#RGB` records an error so the
1869/// widget can surface the text-input validation path.
1870fn color_picker_validate_hex(input: &mut TextInputState) {
1871    if input.value.is_empty() {
1872        input.validation_error = None;
1873    } else if parse_hex_color(&input.value).is_none() {
1874        input.validation_error = Some("invalid hex".to_string());
1875    } else {
1876        input.validation_error = None;
1877    }
1878}