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 { s.underline() } else { s }
522            } else {
523                Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
524            };
525            let mut tab = String::with_capacity(label.len() + 4);
526            tab.push_str("[ ");
527            tab.push_str(label);
528            tab.push_str(" ]");
529            self.styled(tab, style);
530        }
531        self.commands.push(Command::EndContainer);
532        self.rollback.last_text_idx = None;
533
534        response.changed = state.selected != old_selected;
535        response
536    }
537
538    /// Render a standalone paginator, decoupled from any list or table.
539    ///
540    /// Consumes Left/`h`/PageUp (previous page) and Right/`l`/PageDown (next
541    /// page) when focused, and consumes those key events when handled. Clicking
542    /// a dot (in [`PaginatorStyle::Dots`]) jumps to that page; clicking the
543    /// left/right half of the counter (in [`PaginatorStyle::Arabic`]) goes to
544    /// the previous/next page. [`Response::changed`] is `true` iff the page
545    /// changed this frame.
546    ///
547    /// Pass a `&mut PaginatorState` each frame and use
548    /// [`PaginatorState::page_bounds`] to slice your own data.
549    ///
550    /// # Example
551    ///
552    /// ```no_run
553    /// use slt::PaginatorState;
554    ///
555    /// let mut state = PaginatorState::new(42, 10);
556    /// # slt::run(move |ui: &mut slt::Context| {
557    /// ui.paginator(&mut state);
558    /// # });
559    /// ```
560    pub fn paginator(&mut self, state: &mut PaginatorState) -> Response {
561        // Reuse the tabs WidgetColors slot until a dedicated paginator slot lands.
562        let colors = self.widget_theme.tabs;
563        self.paginator_colored(state, &colors)
564    }
565
566    /// Render a standalone paginator with custom widget colors.
567    ///
568    /// Behaves exactly like [`Context::paginator`] but draws with the provided
569    /// [`WidgetColors`] instead of the theme defaults.
570    ///
571    /// # Example
572    ///
573    /// ```no_run
574    /// use slt::{Color, PaginatorState, WidgetColors};
575    ///
576    /// let mut state = PaginatorState::new(20, 5);
577    /// let colors = WidgetColors {
578    ///     accent: Some(Color::Cyan),
579    ///     ..WidgetColors::default()
580    /// };
581    /// # slt::run(move |ui: &mut slt::Context| {
582    /// ui.paginator_colored(&mut state, &colors);
583    /// # });
584    /// ```
585    pub fn paginator_colored(
586        &mut self,
587        state: &mut PaginatorState,
588        colors: &WidgetColors,
589    ) -> Response {
590        state.page = state.page.min(state.total_pages().saturating_sub(1));
591        let old_page = state.page;
592
593        let focused = self.register_focusable();
594        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
595
596        if focused {
597            let mut consumed_indices = Vec::new();
598            for (i, key) in self.available_key_presses() {
599                match key.code {
600                    KeyCode::Left | KeyCode::Char('h') | KeyCode::PageUp => {
601                        state.prev_page();
602                        consumed_indices.push(i);
603                    }
604                    KeyCode::Right | KeyCode::Char('l') | KeyCode::PageDown => {
605                        state.next_page();
606                        consumed_indices.push(i);
607                    }
608                    _ => {}
609                }
610            }
611            self.consume_indices(consumed_indices);
612        }
613
614        let total_pages = state.total_pages();
615        // Dots style overflows past 12 pages, so fall back to the compact counter.
616        let use_dots =
617            matches!(state.style, PaginatorStyle::Dots) && total_pages <= PAGINATOR_MAX_DOTS;
618
619        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
620            let mut consumed = Vec::new();
621            for (i, mouse) in clicks {
622                if mouse.y != rect.y {
623                    continue;
624                }
625                let rel_x = mouse.x.saturating_sub(rect.x);
626                if use_dots {
627                    // Dots render with no inter-glyph gap, so dot `n` is at column `n`.
628                    let target = rel_x as usize;
629                    if target < total_pages {
630                        state.set_page(target);
631                        consumed.push(i);
632                    }
633                } else {
634                    // Counter: left half -> prev, right half -> next.
635                    let label = format!("{}/{}", state.page + 1, total_pages);
636                    let width = UnicodeWidthStr::width(label.as_str()) as u32;
637                    if rel_x < width {
638                        if rel_x < width / 2 {
639                            state.prev_page();
640                        } else {
641                            state.next_page();
642                        }
643                        consumed.push(i);
644                    }
645                }
646            }
647            self.consume_indices(consumed);
648        }
649
650        self.commands
651            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
652                direction: Direction::Row,
653                gap: 0,
654                align: Align::Start,
655                align_self: None,
656                justify: Justify::Start,
657                border: None,
658                border_sides: BorderSides::all(),
659                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
660                bg_color: None,
661                padding: Padding::default(),
662                margin: Margin::default(),
663                constraints: Constraints::default(),
664                title: None,
665                grow: 0,
666                group_name: None,
667            })));
668
669        if use_dots {
670            let active_color = colors.accent.unwrap_or(self.theme.primary);
671            let inactive_color = colors.fg.unwrap_or(self.theme.text_dim);
672            for page in 0..total_pages {
673                let (glyph, color) = if page == state.page {
674                    ("●", active_color)
675                } else {
676                    ("○", inactive_color)
677                };
678                let style = if page == state.page && focused {
679                    Style::new().fg(color).bold()
680                } else {
681                    Style::new().fg(color)
682                };
683                self.styled(glyph, style);
684            }
685        } else {
686            let label = format!("{}/{}", state.page + 1, total_pages);
687            let style = Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim));
688            self.styled(label, style);
689        }
690
691        self.commands.push(Command::EndContainer);
692        self.rollback.last_text_idx = None;
693
694        response.changed = state.page != old_page;
695        response
696    }
697
698    /// Render a clickable button. Activation fires via Enter, Space, or mouse click.
699    ///
700    /// The returned [`Response::clicked`] flag is set on activation. The button
701    /// is styled with the theme's primary color when focused and the accent
702    /// color when hovered.
703    pub fn button(&mut self, label: impl Into<String>) -> Response {
704        let colors = self.widget_theme.button;
705        self.button_colored(label, &colors)
706    }
707
708    /// Render a clickable button with custom widget colors.
709    pub fn button_colored(&mut self, label: impl Into<String>, colors: &WidgetColors) -> Response {
710        let focused = self.register_focusable();
711        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
712
713        let activated = response.clicked || self.consume_activation_keys(focused);
714
715        let hovered = response.hovered;
716        let base_fg = colors.fg.unwrap_or(self.theme.text);
717        let accent = colors.accent.unwrap_or(self.theme.accent);
718        let base_bg = colors.bg.unwrap_or(self.theme.surface_hover);
719        let style = if focused {
720            Style::new().fg(accent).bold()
721        } else if hovered {
722            Style::new().fg(accent)
723        } else {
724            Style::new().fg(base_fg)
725        };
726        let has_custom_bg = colors.bg.is_some();
727        let bg_color = if has_custom_bg || hovered || focused {
728            Some(base_bg)
729        } else {
730            None
731        };
732
733        self.commands
734            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
735                direction: Direction::Row,
736                gap: 0,
737                align: Align::Start,
738                align_self: None,
739                justify: Justify::Start,
740                border: None,
741                border_sides: BorderSides::all(),
742                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
743                bg_color,
744                padding: Padding::default(),
745                margin: Margin::default(),
746                constraints: Constraints::default(),
747                title: None,
748                grow: 0,
749                group_name: None,
750            })));
751        let raw_label = label.into();
752        let mut label_text = String::with_capacity(raw_label.len() + 4);
753        label_text.push_str("[ ");
754        label_text.push_str(&raw_label);
755        label_text.push_str(" ]");
756        self.styled(label_text, style);
757        self.commands.push(Command::EndContainer);
758        self.rollback.last_text_idx = None;
759
760        response.clicked = activated;
761        response
762    }
763
764    /// Render a styled button variant. Returns `true` when activated.
765    ///
766    /// Use [`ButtonVariant::Primary`] for call-to-action, [`ButtonVariant::Danger`]
767    /// for destructive actions, or [`ButtonVariant::Outline`] for secondary actions.
768    pub fn button_with(&mut self, label: impl Into<String>, variant: ButtonVariant) -> Response {
769        let focused = self.register_focusable();
770        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
771
772        let activated = response.clicked || self.consume_activation_keys(focused);
773
774        let label = label.into();
775        let hover_bg = if response.hovered || focused {
776            Some(self.theme.surface_hover)
777        } else {
778            None
779        };
780        let (text, style, bg_color, border) = match variant {
781            ButtonVariant::Default => {
782                let style = if focused {
783                    Style::new().fg(self.theme.primary).bold()
784                } else if response.hovered {
785                    Style::new().fg(self.theme.accent)
786                } else {
787                    Style::new().fg(self.theme.text)
788                };
789                let mut text = String::with_capacity(label.len() + 4);
790                text.push_str("[ ");
791                text.push_str(&label);
792                text.push_str(" ]");
793                (text, style, hover_bg, None)
794            }
795            ButtonVariant::Primary => {
796                let style = if focused {
797                    Style::new().fg(self.theme.bg).bg(self.theme.primary).bold()
798                } else if response.hovered {
799                    Style::new().fg(self.theme.bg).bg(self.theme.accent)
800                } else {
801                    Style::new().fg(self.theme.bg).bg(self.theme.primary)
802                };
803                let mut text = String::with_capacity(label.len() + 2);
804                text.push(' ');
805                text.push_str(&label);
806                text.push(' ');
807                (text, style, hover_bg, None)
808            }
809            ButtonVariant::Danger => {
810                let style = if focused {
811                    Style::new().fg(self.theme.bg).bg(self.theme.error).bold()
812                } else if response.hovered {
813                    Style::new().fg(self.theme.bg).bg(self.theme.warning)
814                } else {
815                    Style::new().fg(self.theme.bg).bg(self.theme.error)
816                };
817                let mut text = String::with_capacity(label.len() + 2);
818                text.push(' ');
819                text.push_str(&label);
820                text.push(' ');
821                (text, style, hover_bg, None)
822            }
823            ButtonVariant::Outline => {
824                let border_color = if focused {
825                    self.theme.primary
826                } else if response.hovered {
827                    self.theme.accent
828                } else {
829                    self.theme.border
830                };
831                let style = if focused {
832                    Style::new().fg(self.theme.primary).bold()
833                } else if response.hovered {
834                    Style::new().fg(self.theme.accent)
835                } else {
836                    Style::new().fg(self.theme.text)
837                };
838                (
839                    {
840                        let mut text = String::with_capacity(label.len() + 2);
841                        text.push(' ');
842                        text.push_str(&label);
843                        text.push(' ');
844                        text
845                    },
846                    style,
847                    hover_bg,
848                    Some((Border::Rounded, Style::new().fg(border_color))),
849                )
850            }
851        };
852
853        let (btn_border, btn_border_style) = border.unwrap_or((Border::Rounded, Style::new()));
854        self.commands
855            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
856                direction: Direction::Row,
857                gap: 0,
858                align: Align::Center,
859                align_self: None,
860                justify: Justify::Center,
861                border: if border.is_some() {
862                    Some(btn_border)
863                } else {
864                    None
865                },
866                border_sides: BorderSides::all(),
867                border_style: btn_border_style,
868                bg_color,
869                padding: Padding::default(),
870                margin: Margin::default(),
871                constraints: Constraints::default(),
872                title: None,
873                grow: 0,
874                group_name: None,
875            })));
876        self.styled(text, style);
877        self.commands.push(Command::EndContainer);
878        self.rollback.last_text_idx = None;
879
880        response.clicked = activated;
881        response
882    }
883
884    /// Render a checkbox. Toggles the bool on Enter, Space, or click.
885    ///
886    /// The checked state is shown with the theme's success color. When focused,
887    /// a `▸` prefix is added.
888    /// Render a checkbox toggle.
889    pub fn checkbox(&mut self, label: impl Into<String>, checked: &mut bool) -> Response {
890        let colors = self.widget_theme.checkbox;
891        self.checkbox_colored(label, checked, &colors)
892    }
893
894    /// Render a checkbox toggle with custom widget colors.
895    pub fn checkbox_colored(
896        &mut self,
897        label: impl Into<String>,
898        checked: &mut bool,
899        colors: &WidgetColors,
900    ) -> Response {
901        let focused = self.register_focusable();
902        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
903        let mut should_toggle = response.clicked;
904        let old_checked = *checked;
905
906        should_toggle |= self.consume_activation_keys(focused);
907
908        if should_toggle {
909            *checked = !*checked;
910        }
911
912        let hover_bg = if response.hovered || focused {
913            Some(self.theme.surface_hover)
914        } else {
915            None
916        };
917        let cb_gap = self.theme.spacing.xs();
918        self.commands
919            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
920                direction: Direction::Row,
921                gap: cb_gap as i32,
922                align: Align::Start,
923                align_self: None,
924                justify: Justify::Start,
925                border: None,
926                border_sides: BorderSides::all(),
927                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
928                bg_color: hover_bg,
929                padding: Padding::default(),
930                margin: Margin::default(),
931                constraints: Constraints::default(),
932                title: None,
933                grow: 0,
934                group_name: None,
935            })));
936        let marker_style = if *checked {
937            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
938        } else {
939            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
940        };
941        let marker = if *checked { "[x]" } else { "[ ]" };
942        let label_text = label.into();
943        if focused {
944            let mut marker_text = String::with_capacity(2 + marker.len());
945            marker_text.push_str("▸ ");
946            marker_text.push_str(marker);
947            self.styled(marker_text, marker_style.bold());
948            self.styled(
949                label_text,
950                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
951            );
952        } else {
953            self.styled(marker, marker_style);
954            self.styled(
955                label_text,
956                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
957            );
958        }
959        self.commands.push(Command::EndContainer);
960        self.rollback.last_text_idx = None;
961
962        response.changed = *checked != old_checked;
963        response
964    }
965
966    /// Render an on/off toggle switch.
967    ///
968    /// Toggles `on` when activated via Enter, Space, or click. The switch
969    /// renders as `●━━ ON` or `━━● OFF` colored with the theme's success or
970    /// dim color respectively.
971    /// Render an on/off toggle switch.
972    pub fn toggle(&mut self, label: impl Into<String>, on: &mut bool) -> Response {
973        let colors = self.widget_theme.toggle;
974        self.toggle_colored(label, on, &colors)
975    }
976
977    /// Render an on/off toggle switch with custom widget colors.
978    pub fn toggle_colored(
979        &mut self,
980        label: impl Into<String>,
981        on: &mut bool,
982        colors: &WidgetColors,
983    ) -> Response {
984        let focused = self.register_focusable();
985        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
986        let mut should_toggle = response.clicked;
987        let old_on = *on;
988
989        should_toggle |= self.consume_activation_keys(focused);
990
991        if should_toggle {
992            *on = !*on;
993        }
994
995        let hover_bg = if response.hovered || focused {
996            Some(self.theme.surface_hover)
997        } else {
998            None
999        };
1000        let toggle_gap = self.theme.spacing.sm();
1001        self.commands
1002            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1003                direction: Direction::Row,
1004                gap: toggle_gap as i32,
1005                align: Align::Start,
1006                align_self: None,
1007                justify: Justify::Start,
1008                border: None,
1009                border_sides: BorderSides::all(),
1010                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1011                bg_color: hover_bg,
1012                padding: Padding::default(),
1013                margin: Margin::default(),
1014                constraints: Constraints::default(),
1015                title: None,
1016                grow: 0,
1017                group_name: None,
1018            })));
1019        let label_text = label.into();
1020        let switch = if *on { "●━━ ON" } else { "━━● OFF" };
1021        let switch_style = if *on {
1022            Style::new().fg(colors.accent.unwrap_or(self.theme.success))
1023        } else {
1024            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1025        };
1026        if focused {
1027            let mut focused_label = String::with_capacity(2 + label_text.len());
1028            focused_label.push_str("▸ ");
1029            focused_label.push_str(&label_text);
1030            self.styled(
1031                focused_label,
1032                Style::new().fg(colors.fg.unwrap_or(self.theme.text)).bold(),
1033            );
1034            self.styled(switch, switch_style.bold());
1035        } else {
1036            self.styled(
1037                label_text,
1038                Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1039            );
1040            self.styled(switch, switch_style);
1041        }
1042        self.commands.push(Command::EndContainer);
1043        self.rollback.last_text_idx = None;
1044
1045        response.changed = *on != old_on;
1046        response
1047    }
1048
1049    // ── select / dropdown ─────────────────────────────────────────────
1050
1051    /// Render a dropdown select. Shows the selected item; expands on activation.
1052    ///
1053    /// Returns `true` when the selection changed this frame.
1054    /// Render a dropdown select widget.
1055    pub fn select(&mut self, state: &mut SelectState) -> Response {
1056        let colors = self.widget_theme.select;
1057        self.select_colored(state, &colors)
1058    }
1059
1060    /// Render a dropdown select widget with custom widget colors.
1061    pub fn select_colored(&mut self, state: &mut SelectState, colors: &WidgetColors) -> Response {
1062        if state.items.is_empty() {
1063            return Response::none();
1064        }
1065        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1066
1067        let focused = self.register_focusable();
1068        let (_interaction_id, mut response) = self.begin_widget_interaction(focused);
1069        let old_selected = state.selected;
1070
1071        self.select_handle_events(state, focused, response.clicked);
1072        // Keep the cursor within the filtered subset before rendering.
1073        if state.open {
1074            let flen = state.filtered_indices().len();
1075            let cur = state.cursor();
1076            if flen == 0 {
1077                state.set_cursor(0);
1078            } else if cur >= flen {
1079                state.set_cursor(flen - 1);
1080            }
1081        }
1082        self.select_render(state, focused, colors);
1083        response.changed = state.selected != old_selected;
1084        response
1085    }
1086
1087    fn select_handle_events(&mut self, state: &mut SelectState, focused: bool, clicked: bool) {
1088        if clicked {
1089            state.open = !state.open;
1090            if state.open {
1091                state.filter.clear();
1092                state.set_cursor(state.selected);
1093            }
1094        }
1095
1096        if !focused {
1097            return;
1098        }
1099
1100        let mut consumed_indices = Vec::new();
1101        for (i, key) in self.available_key_presses() {
1102            if state.open {
1103                // Cursor indexes into the filtered subset (not `items`); arrow
1104                // keys navigate, printable keys type into the filter.
1105                let filtered_len = state.filtered_indices().len();
1106                match key.code {
1107                    KeyCode::Up => {
1108                        state.set_cursor(state.cursor().saturating_sub(1));
1109                        consumed_indices.push(i);
1110                    }
1111                    KeyCode::Down => {
1112                        if filtered_len > 0 {
1113                            let next = (state.cursor() + 1).min(filtered_len - 1);
1114                            state.set_cursor(next);
1115                        }
1116                        consumed_indices.push(i);
1117                    }
1118                    KeyCode::Enter => {
1119                        if let Some(&real) = state.filtered_indices().get(state.cursor()) {
1120                            state.selected = real;
1121                        }
1122                        state.open = false;
1123                        state.filter.clear();
1124                        consumed_indices.push(i);
1125                    }
1126                    KeyCode::Esc => {
1127                        // First Esc clears a non-empty query; a second closes.
1128                        if state.filter.is_empty() {
1129                            state.open = false;
1130                        } else {
1131                            state.filter.clear();
1132                            state.set_cursor(0);
1133                        }
1134                        consumed_indices.push(i);
1135                    }
1136                    KeyCode::Backspace => {
1137                        state.filter.pop();
1138                        state.set_cursor(0);
1139                        consumed_indices.push(i);
1140                    }
1141                    KeyCode::Char(c) => {
1142                        // Printable keys (including space, 'j', 'k') type into the
1143                        // filter — arrows remain the only navigation while open.
1144                        state.filter.push(c);
1145                        state.set_cursor(0);
1146                        consumed_indices.push(i);
1147                    }
1148                    _ => {}
1149                }
1150            } else if matches!(key.code, KeyCode::Enter | KeyCode::Char(' ')) {
1151                state.open = true;
1152                state.filter.clear();
1153                state.set_cursor(state.selected);
1154                consumed_indices.push(i);
1155            }
1156        }
1157        self.consume_indices(consumed_indices);
1158    }
1159
1160    fn select_render(&mut self, state: &SelectState, focused: bool, colors: &WidgetColors) {
1161        let border_color = if focused {
1162            colors.accent.unwrap_or(self.theme.primary)
1163        } else {
1164            colors.border.unwrap_or(self.theme.border)
1165        };
1166        let display_text = state
1167            .items
1168            .get(state.selected)
1169            .cloned()
1170            .unwrap_or_else(|| state.placeholder.clone());
1171        let arrow = if state.open { "▲" } else { "▼" };
1172
1173        self.commands
1174            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1175                direction: Direction::Column,
1176                gap: 0,
1177                align: Align::Start,
1178                align_self: None,
1179                justify: Justify::Start,
1180                border: None,
1181                border_sides: BorderSides::all(),
1182                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1183                bg_color: None,
1184                padding: Padding::default(),
1185                margin: Margin::default(),
1186                constraints: Constraints::default(),
1187                title: None,
1188                grow: 0,
1189                group_name: None,
1190            })));
1191
1192        self.render_select_trigger(&display_text, arrow, border_color, colors);
1193
1194        if state.open {
1195            self.render_select_dropdown(state, colors);
1196        }
1197
1198        self.commands.push(Command::EndContainer);
1199        self.rollback.last_text_idx = None;
1200    }
1201
1202    fn render_select_trigger(
1203        &mut self,
1204        display_text: &str,
1205        arrow: &str,
1206        border_color: Color,
1207        colors: &WidgetColors,
1208    ) {
1209        let trig_gap = self.theme.spacing.xs();
1210        let trig_h = self.theme.spacing.xs();
1211        self.commands
1212            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1213                direction: Direction::Row,
1214                gap: trig_gap as i32,
1215                align: Align::Start,
1216                align_self: None,
1217                justify: Justify::Start,
1218                border: Some(Border::Rounded),
1219                border_sides: BorderSides::all(),
1220                border_style: Style::new().fg(border_color),
1221                bg_color: None,
1222                padding: Padding {
1223                    left: trig_h,
1224                    right: trig_h,
1225                    top: 0,
1226                    bottom: 0,
1227                },
1228                margin: Margin::default(),
1229                constraints: Constraints::default(),
1230                title: None,
1231                grow: 0,
1232                group_name: None,
1233            })));
1234        self.skip_interaction_slot();
1235        self.styled(
1236            display_text,
1237            Style::new().fg(colors.fg.unwrap_or(self.theme.text)),
1238        );
1239        self.styled(
1240            arrow,
1241            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim)),
1242        );
1243        self.commands.push(Command::EndContainer);
1244        self.rollback.last_text_idx = None;
1245    }
1246
1247    fn render_select_dropdown(&mut self, state: &SelectState, colors: &WidgetColors) {
1248        let filtered = state.filtered_indices();
1249
1250        // Show the active query so typing has visible feedback.
1251        if !state.filter.is_empty() {
1252            let dim = self.theme.text_dim;
1253            let mut q = String::with_capacity(state.filter.len() + 1);
1254            q.push('/');
1255            q.push_str(&state.filter);
1256            self.styled(q, Style::new().fg(dim).italic());
1257        }
1258
1259        if filtered.is_empty() {
1260            let dim = self.theme.text_dim;
1261            self.styled("  (no matches)".to_string(), Style::new().fg(dim).dim());
1262            return;
1263        }
1264
1265        let cursor = state.cursor();
1266        for (pos, &idx) in filtered.iter().enumerate() {
1267            let item = &state.items[idx];
1268            let is_cursor = pos == cursor;
1269            let style = if is_cursor {
1270                Style::new()
1271                    .bold()
1272                    .fg(colors.accent.unwrap_or(self.theme.primary))
1273            } else {
1274                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1275            };
1276            let prefix = if is_cursor { "▸ " } else { "  " };
1277            let mut row = String::with_capacity(prefix.len() + item.len());
1278            row.push_str(prefix);
1279            row.push_str(item);
1280            self.styled(row, style);
1281        }
1282    }
1283
1284    // ── radio ────────────────────────────────────────────────────────
1285
1286    /// Render a radio button group. Returns `true` when selection changed.
1287    /// Render a radio button group.
1288    pub fn radio(&mut self, state: &mut RadioState) -> Response {
1289        let colors = self.widget_theme.radio;
1290        self.radio_colored(state, &colors)
1291    }
1292
1293    /// Render a radio button group with custom widget colors.
1294    pub fn radio_colored(&mut self, state: &mut RadioState, colors: &WidgetColors) -> Response {
1295        if state.items.is_empty() {
1296            return Response::none();
1297        }
1298        state.selected = state.selected.min(state.items.len().saturating_sub(1));
1299        let focused = self.register_focusable();
1300        let old_selected = state.selected;
1301
1302        if focused {
1303            let mut consumed_indices = Vec::new();
1304            for (i, key) in self.available_key_presses() {
1305                match key.code {
1306                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1307                        let _ = handle_vertical_nav(
1308                            &mut state.selected,
1309                            state.items.len().saturating_sub(1),
1310                            key.code.clone(),
1311                        );
1312                        consumed_indices.push(i);
1313                    }
1314                    KeyCode::Enter | KeyCode::Char(' ') => {
1315                        consumed_indices.push(i);
1316                    }
1317                    _ => {}
1318                }
1319            }
1320            self.consume_indices(consumed_indices);
1321        }
1322
1323        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1324
1325        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1326            let mut consumed = Vec::new();
1327            for (i, mouse) in clicks {
1328                let clicked_idx = (mouse.y - rect.y) as usize;
1329                if clicked_idx < state.items.len() {
1330                    state.selected = clicked_idx;
1331                    consumed.push(i);
1332                }
1333            }
1334            self.consume_indices(consumed);
1335        }
1336
1337        self.commands
1338            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1339                direction: Direction::Column,
1340                gap: 0,
1341                align: Align::Start,
1342                align_self: None,
1343                justify: Justify::Start,
1344                border: None,
1345                border_sides: BorderSides::all(),
1346                border_style: Style::new().fg(colors.border.unwrap_or(self.theme.border)),
1347                bg_color: None,
1348                padding: Padding::default(),
1349                margin: Margin::default(),
1350                constraints: Constraints::default(),
1351                title: None,
1352                grow: 0,
1353                group_name: None,
1354            })));
1355
1356        for (idx, item) in state.items.iter().enumerate() {
1357            let is_selected = idx == state.selected;
1358            let marker = if is_selected { "●" } else { "○" };
1359            let style = if is_selected {
1360                if focused {
1361                    Style::new()
1362                        .bold()
1363                        .fg(colors.accent.unwrap_or(self.theme.primary))
1364                } else {
1365                    Style::new().fg(colors.accent.unwrap_or(self.theme.primary))
1366                }
1367            } else {
1368                Style::new().fg(colors.fg.unwrap_or(self.theme.text))
1369            };
1370            let prefix = if focused && idx == state.selected {
1371                "▸ "
1372            } else {
1373                "  "
1374            };
1375            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1376            row.push_str(prefix);
1377            row.push_str(marker);
1378            row.push(' ');
1379            row.push_str(item);
1380            self.styled(row, style);
1381        }
1382
1383        self.commands.push(Command::EndContainer);
1384        self.rollback.last_text_idx = None;
1385        response.changed = state.selected != old_selected;
1386        response
1387    }
1388
1389    // ── multi-select ─────────────────────────────────────────────────
1390
1391    /// Render a multi-select list. Space toggles, Up/Down navigates.
1392    pub fn multi_select(&mut self, state: &mut MultiSelectState) -> Response {
1393        if state.items.is_empty() {
1394            return Response::none();
1395        }
1396        state.cursor = state.cursor.min(state.items.len().saturating_sub(1));
1397        let focused = self.register_focusable();
1398        let old_selected = state.selected.clone();
1399
1400        if focused {
1401            let mut consumed_indices = Vec::new();
1402            for (i, key) in self.available_key_presses() {
1403                match key.code {
1404                    KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => {
1405                        let _ = handle_vertical_nav(
1406                            &mut state.cursor,
1407                            state.items.len().saturating_sub(1),
1408                            key.code.clone(),
1409                        );
1410                        consumed_indices.push(i);
1411                    }
1412                    KeyCode::Char(' ') | KeyCode::Enter => {
1413                        state.toggle(state.cursor);
1414                        consumed_indices.push(i);
1415                    }
1416                    _ => {}
1417                }
1418            }
1419            self.consume_indices(consumed_indices);
1420        }
1421
1422        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1423
1424        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1425            let mut consumed = Vec::new();
1426            for (i, mouse) in clicks {
1427                let clicked_idx = (mouse.y - rect.y) as usize;
1428                if clicked_idx < state.items.len() {
1429                    state.toggle(clicked_idx);
1430                    state.cursor = clicked_idx;
1431                    consumed.push(i);
1432                }
1433            }
1434            self.consume_indices(consumed);
1435        }
1436
1437        self.commands
1438            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1439                direction: Direction::Column,
1440                gap: 0,
1441                align: Align::Start,
1442                align_self: None,
1443                justify: Justify::Start,
1444                border: None,
1445                border_sides: BorderSides::all(),
1446                border_style: Style::new().fg(self.theme.border),
1447                bg_color: None,
1448                padding: Padding::default(),
1449                margin: Margin::default(),
1450                constraints: Constraints::default(),
1451                title: None,
1452                grow: 0,
1453                group_name: None,
1454            })));
1455
1456        for (idx, item) in state.items.iter().enumerate() {
1457            let checked = state.selected.contains(&idx);
1458            let marker = if checked { "[x]" } else { "[ ]" };
1459            let is_cursor = idx == state.cursor;
1460            let style = if is_cursor && focused {
1461                Style::new().bold().fg(self.theme.primary)
1462            } else if checked {
1463                Style::new().fg(self.theme.success)
1464            } else {
1465                Style::new().fg(self.theme.text)
1466            };
1467            let prefix = if is_cursor && focused { "▸ " } else { "  " };
1468            let mut row = String::with_capacity(prefix.len() + marker.len() + item.len() + 1);
1469            row.push_str(prefix);
1470            row.push_str(marker);
1471            row.push(' ');
1472            row.push_str(item);
1473            self.styled(row, style);
1474        }
1475
1476        self.commands.push(Command::EndContainer);
1477        self.rollback.last_text_idx = None;
1478        response.changed = state.selected != old_selected;
1479        response
1480    }
1481
1482    // ── color picker ───────────────────────────────────────────────────
1483
1484    /// Render an interactive color picker over the [`Color`] model.
1485    ///
1486    /// Shows a grid of color swatches plus an optional hex-entry field. When
1487    /// focused, the arrow keys / `hjkl` move the 2D swatch cursor (clamped at
1488    /// the grid edges), `Tab` toggles between palette and hex entry, and
1489    /// `Enter` / `Space` confirms the current color. Returns `changed` on the
1490    /// exact frames where the selected [`Color`] differs from the previous
1491    /// frame. Read the chosen color back via
1492    /// [`ColorPickerState::selected`](crate::widgets::ColorPickerState::selected).
1493    ///
1494    /// Each swatch is emitted with a full-RGB background; the terminal backend
1495    /// downsamples it to the active [`ColorDepth`](crate::ColorDepth) on flush,
1496    /// so the picker degrades correctly on 256-color, 16-color, and no-color
1497    /// terminals. Uses the theme's `color_picker` slot for border and cursor
1498    /// colors; override per-call with
1499    /// [`color_picker_colored`](Self::color_picker_colored).
1500    ///
1501    /// # Example
1502    ///
1503    /// ```no_run
1504    /// # use slt::widgets::ColorPickerState;
1505    /// # slt::run(|ui: &mut slt::Context| {
1506    /// let mut picker = ColorPickerState::tailwind();
1507    /// if ui.color_picker(&mut picker).changed {
1508    ///     let chosen = picker.selected();
1509    ///     let _ = chosen;
1510    /// }
1511    /// # });
1512    /// ```
1513    pub fn color_picker(&mut self, state: &mut ColorPickerState) -> Response {
1514        let colors = self.widget_theme.color_picker;
1515        self.color_picker_colored(state, &colors)
1516    }
1517
1518    /// Render a color picker with custom [`WidgetColors`].
1519    ///
1520    /// Behaves exactly like [`color_picker`](Self::color_picker) but draws the
1521    /// border, cursor highlight, and hex field with the supplied colors instead
1522    /// of the theme's `color_picker` slot.
1523    ///
1524    /// # Example
1525    ///
1526    /// ```no_run
1527    /// # use slt::widgets::ColorPickerState;
1528    /// # use slt::{Color, WidgetColors};
1529    /// # slt::run(|ui: &mut slt::Context| {
1530    /// let mut picker = ColorPickerState::tailwind();
1531    /// let theme = WidgetColors::new().accent(Color::Cyan);
1532    /// ui.color_picker_colored(&mut picker, &theme);
1533    /// # });
1534    /// ```
1535    pub fn color_picker_colored(
1536        &mut self,
1537        state: &mut ColorPickerState,
1538        colors: &WidgetColors,
1539    ) -> Response {
1540        if state.colors.is_empty() {
1541            return Response::none();
1542        }
1543        let columns = state.columns.max(1);
1544        state.selected = state.selected.min(state.colors.len() - 1);
1545
1546        let focused = self.register_focusable();
1547        let (interaction_id, mut response) = self.begin_widget_interaction(focused);
1548        let old_color = state.selected();
1549
1550        self.color_picker_handle_keys(state, focused, columns);
1551        self.color_picker_handle_clicks(state, interaction_id, columns);
1552        self.color_picker_render(state, focused, columns, colors);
1553
1554        response.changed = state.selected() != old_color;
1555        response
1556    }
1557
1558    fn color_picker_handle_keys(
1559        &mut self,
1560        state: &mut ColorPickerState,
1561        focused: bool,
1562        columns: usize,
1563    ) {
1564        if !focused {
1565            return;
1566        }
1567        let len = state.colors.len();
1568        let mut consumed_indices = Vec::new();
1569        for (i, key) in self.available_key_presses() {
1570            match state.mode {
1571                PickerMode::Palette => match key.code {
1572                    KeyCode::Left | KeyCode::Char('h') => {
1573                        if !state.selected.is_multiple_of(columns) {
1574                            state.selected -= 1;
1575                        }
1576                        consumed_indices.push(i);
1577                    }
1578                    KeyCode::Right | KeyCode::Char('l') => {
1579                        if state.selected % columns < columns - 1 && state.selected + 1 < len {
1580                            state.selected += 1;
1581                        }
1582                        consumed_indices.push(i);
1583                    }
1584                    KeyCode::Up | KeyCode::Char('k') => {
1585                        if state.selected >= columns {
1586                            state.selected -= columns;
1587                        }
1588                        consumed_indices.push(i);
1589                    }
1590                    KeyCode::Down | KeyCode::Char('j') => {
1591                        if state.selected + columns < len {
1592                            state.selected += columns;
1593                        }
1594                        consumed_indices.push(i);
1595                    }
1596                    KeyCode::Tab => {
1597                        state.mode = PickerMode::Hex;
1598                        consumed_indices.push(i);
1599                    }
1600                    KeyCode::Enter | KeyCode::Char(' ') => {
1601                        consumed_indices.push(i);
1602                    }
1603                    _ => {}
1604                },
1605                PickerMode::Hex => match key.code {
1606                    KeyCode::Tab => {
1607                        state.mode = PickerMode::Palette;
1608                        consumed_indices.push(i);
1609                    }
1610                    KeyCode::Enter => {
1611                        consumed_indices.push(i);
1612                    }
1613                    KeyCode::Char(ch) => {
1614                        let index =
1615                            byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1616                        state.hex_input.value.insert(index, ch);
1617                        state.hex_input.cursor += 1;
1618                        color_picker_validate_hex(&mut state.hex_input);
1619                        consumed_indices.push(i);
1620                    }
1621                    KeyCode::Backspace => {
1622                        if state.hex_input.cursor > 0 {
1623                            let start = byte_index_for_char(
1624                                &state.hex_input.value,
1625                                state.hex_input.cursor - 1,
1626                            );
1627                            let end =
1628                                byte_index_for_char(&state.hex_input.value, state.hex_input.cursor);
1629                            state.hex_input.value.replace_range(start..end, "");
1630                            state.hex_input.cursor -= 1;
1631                        }
1632                        color_picker_validate_hex(&mut state.hex_input);
1633                        consumed_indices.push(i);
1634                    }
1635                    _ => {}
1636                },
1637            }
1638        }
1639        self.consume_indices(consumed_indices);
1640    }
1641
1642    fn color_picker_handle_clicks(
1643        &mut self,
1644        state: &mut ColorPickerState,
1645        interaction_id: usize,
1646        columns: usize,
1647    ) {
1648        if let Some((rect, clicks)) = self.left_clicks_for_interaction(interaction_id) {
1649            // The interaction rect spans the whole bordered container; the
1650            // swatch grid starts inside the top border and the left
1651            // border + x-padding. Offset clicks back into grid space.
1652            let grid_x0 = rect.x + GRID_X_OFFSET;
1653            let grid_y0 = rect.y + GRID_Y_OFFSET;
1654            let rows = state.colors.len().div_ceil(columns);
1655            let mut consumed = Vec::new();
1656            for (i, mouse) in clicks {
1657                if mouse.x < grid_x0 || mouse.y < grid_y0 {
1658                    continue;
1659                }
1660                let row = (mouse.y - grid_y0) as usize;
1661                let col = (mouse.x - grid_x0) as usize / SWATCH_WIDTH;
1662                if row < rows && col < columns {
1663                    let idx = row * columns + col;
1664                    if idx < state.colors.len() {
1665                        state.mode = PickerMode::Palette;
1666                        state.selected = idx;
1667                        consumed.push(i);
1668                    }
1669                }
1670            }
1671            self.consume_indices(consumed);
1672        }
1673    }
1674
1675    fn color_picker_render(
1676        &mut self,
1677        state: &ColorPickerState,
1678        focused: bool,
1679        columns: usize,
1680        colors: &WidgetColors,
1681    ) {
1682        let border_color = if focused {
1683            colors.accent.unwrap_or(self.theme.primary)
1684        } else {
1685            colors.border.unwrap_or(self.theme.border)
1686        };
1687        let text_color = colors.fg.unwrap_or(self.theme.text);
1688
1689        self.commands
1690            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1691                direction: Direction::Column,
1692                gap: 0,
1693                align: Align::Start,
1694                align_self: None,
1695                justify: Justify::Start,
1696                border: Some(Border::Rounded),
1697                border_sides: BorderSides::all(),
1698                border_style: Style::new().fg(border_color),
1699                bg_color: None,
1700                padding: Padding::xy(1, 0),
1701                margin: Margin::default(),
1702                constraints: Constraints::default(),
1703                title: None,
1704                grow: 0,
1705                group_name: None,
1706            })));
1707
1708        // Swatch grid: one Row container per grid row, one cell per swatch.
1709        let rows = state.colors.len().div_ceil(columns);
1710        for row in 0..rows {
1711            self.commands
1712                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1713                    direction: Direction::Row,
1714                    gap: 0,
1715                    align: Align::Start,
1716                    align_self: None,
1717                    justify: Justify::Start,
1718                    border: None,
1719                    border_sides: BorderSides::all(),
1720                    border_style: Style::new(),
1721                    bg_color: None,
1722                    padding: Padding::default(),
1723                    margin: Margin::default(),
1724                    constraints: Constraints::default(),
1725                    title: None,
1726                    grow: 0,
1727                    group_name: None,
1728                })));
1729            for col in 0..columns {
1730                let idx = row * columns + col;
1731                let Some(&swatch) = state.colors.get(idx) else {
1732                    break;
1733                };
1734                let is_cursor = idx == state.selected && state.mode == PickerMode::Palette;
1735                let marker = if is_cursor { '▣' } else { ' ' };
1736                let mut cell = String::with_capacity(SWATCH_WIDTH);
1737                cell.push(' ');
1738                cell.push(marker);
1739                cell.push(' ');
1740                // Full-RGB bg; the terminal flush downsamples per ColorDepth.
1741                // contrast_fg keeps the cursor marker legible on any swatch.
1742                let mut style = Style::new().bg(swatch).fg(Color::contrast_fg(swatch));
1743                if is_cursor {
1744                    style = style.bold();
1745                }
1746                self.styled(cell, style);
1747            }
1748            self.commands.push(Command::EndContainer);
1749            self.rollback.last_text_idx = None;
1750        }
1751
1752        // Selected color readout: a `#RRGGBB` label keeps the picker legible
1753        // under `ColorDepth::NoColor`, where no background color is emitted.
1754        let selected = state.selected();
1755        let label = color_hex_label(selected).unwrap_or_else(|| "selected".to_string());
1756        let mut readout = String::with_capacity(label.len() + 3);
1757        readout.push_str("▸ ");
1758        readout.push_str(&label);
1759        self.styled(readout, Style::new().fg(text_color).bold());
1760
1761        // Hex entry line. The embedded field shows the typed value (or its
1762        // placeholder); a `✗` flag surfaces the text-input validation error
1763        // path on malformed input without panicking.
1764        let hex_active = state.mode == PickerMode::Hex;
1765        let hex_display = if state.hex_input.value.is_empty() {
1766            state.hex_input.placeholder.clone()
1767        } else {
1768            state.hex_input.value.clone()
1769        };
1770        let mut hex_line = String::with_capacity(hex_display.len() + 6);
1771        hex_line.push_str(if hex_active { "▸ hex " } else { "  hex " });
1772        hex_line.push_str(&hex_display);
1773        if state.hex_input.validation_error.is_some() {
1774            hex_line.push_str(" ✗");
1775        }
1776        let hex_style = if hex_active {
1777            Style::new()
1778                .fg(colors.accent.unwrap_or(self.theme.primary))
1779                .bold()
1780        } else {
1781            Style::new().fg(colors.fg.unwrap_or(self.theme.text_dim))
1782        };
1783        self.styled(hex_line, hex_style);
1784
1785        self.commands.push(Command::EndContainer);
1786        self.rollback.last_text_idx = None;
1787    }
1788
1789    // ── tree ─────────────────────────────────────────────────────────
1790}
1791
1792/// Display width in cells of one color-picker swatch (` ▣ ` / `   `).
1793const SWATCH_WIDTH: usize = 3;
1794
1795/// Horizontal offset from the picker's interaction rect to the swatch grid:
1796/// the rounded left border (1) plus the container's left x-padding (1).
1797const GRID_X_OFFSET: u32 = 2;
1798
1799/// Vertical offset from the picker's interaction rect to the swatch grid:
1800/// the rounded top border (1); the container has no top padding.
1801const GRID_Y_OFFSET: u32 = 1;
1802
1803/// Validate the hex-entry field, setting/clearing its `validation_error`.
1804///
1805/// An empty field is treated as "not yet entered" (no error). Any non-empty
1806/// value that does not parse as `#RRGGBB` / `#RGB` records an error so the
1807/// widget can surface the text-input validation path.
1808fn color_picker_validate_hex(input: &mut TextInputState) {
1809    if input.value.is_empty() {
1810        input.validation_error = None;
1811    } else if parse_hex_color(&input.value).is_none() {
1812        input.validation_error = Some("invalid hex".to_string());
1813    } else {
1814        input.validation_error = None;
1815    }
1816}