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