Skip to main content

slt/widgets/
selection.rs

1/// State for a dropdown select widget.
2///
3/// Renders as a single-line button showing the selected option. When activated,
4/// expands into a vertical list overlay for picking an option.
5#[derive(Debug, Clone, Default)]
6pub struct SelectState {
7    /// Selectable option labels.
8    items: Vec<String>,
9    /// Selected option index.
10    pub selected: usize,
11    /// Whether the dropdown list is currently open.
12    pub open: bool,
13    /// Placeholder text shown when `items` is empty.
14    pub placeholder: String,
15    cursor: usize,
16    /// Type-to-filter query, active only while the dropdown is open. Reset on
17    /// open/close and on selection. Printable keys append; Backspace pops.
18    /// Public so callers can pre-fill or inspect the live query.
19    pub filter: String,
20}
21
22impl SelectState {
23    /// Create select state with the provided options.
24    pub fn new(items: Vec<impl Into<String>>) -> Self {
25        Self {
26            items: items.into_iter().map(Into::into).collect(),
27            selected: 0,
28            open: false,
29            placeholder: String::new(),
30            cursor: 0,
31            filter: String::new(),
32        }
33    }
34
35    /// Return all selectable option labels.
36    pub fn items(&self) -> &[String] {
37        &self.items
38    }
39
40    /// Return the number of options.
41    pub fn len(&self) -> usize {
42        self.items.len()
43    }
44
45    /// Return whether there are no options.
46    pub fn is_empty(&self) -> bool {
47        self.items.is_empty()
48    }
49
50    /// Replace all options and clamp selection state immediately.
51    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
52        self.items = items.into_iter().map(Into::into).collect();
53        self.selected = self.selected.min(self.items.len().saturating_sub(1));
54        self.cursor = self.cursor.min(self.items.len().saturating_sub(1));
55        if self.items.is_empty() {
56            self.selected = 0;
57            self.cursor = 0;
58            self.open = false;
59        }
60    }
61
62    /// Indices of `items` that match the current type-to-filter query, in
63    /// original order. An empty query matches every item. Matching reuses the
64    /// shared fuzzy matcher so a gapped pattern (e.g. `"sf"` → `"San Francisco"`)
65    /// still hits.
66    pub(crate) fn filtered_indices(&self) -> Vec<usize> {
67        if self.filter.is_empty() {
68            return (0..self.items.len()).collect();
69        }
70        (0..self.items.len())
71            .filter(|&i| {
72                crate::widgets::CommandPaletteState::fuzzy_score(&self.filter, &self.items[i])
73                    .is_some()
74            })
75            .collect()
76    }
77
78    /// Set placeholder text shown when no item can be displayed.
79    pub fn placeholder(mut self, p: impl Into<String>) -> Self {
80        self.placeholder = p.into();
81        self
82    }
83
84    /// Returns the currently selected item label, or `None` if empty.
85    pub fn selected_item(&self) -> Option<&str> {
86        self.items.get(self.selected).map(String::as_str)
87    }
88
89    pub(crate) fn cursor(&self) -> usize {
90        self.cursor
91    }
92
93    pub(crate) fn set_cursor(&mut self, c: usize) {
94        self.cursor = c;
95    }
96}
97
98// ── Radio ─────────────────────────────────────────────────────────────
99
100/// State for a radio button group.
101///
102/// Renders a vertical list of mutually-exclusive options with `●`/`○` markers.
103#[derive(Debug, Clone, Default)]
104pub struct RadioState {
105    /// Radio option labels.
106    pub items: Vec<String>,
107    /// Selected option index.
108    pub selected: usize,
109}
110
111impl RadioState {
112    /// Create radio state with the provided options.
113    pub fn new(items: Vec<impl Into<String>>) -> Self {
114        Self {
115            items: items.into_iter().map(Into::into).collect(),
116            selected: 0,
117        }
118    }
119
120    /// Returns the currently selected option label, or `None` if empty.
121    pub fn selected_item(&self) -> Option<&str> {
122        self.items.get(self.selected).map(String::as_str)
123    }
124}
125
126// ── Multi-Select ──────────────────────────────────────────────────────
127
128/// State for a multi-select list.
129///
130/// Like [`ListState`] but allows toggling multiple items with Space.
131#[derive(Debug, Clone)]
132pub struct MultiSelectState {
133    /// Multi-select option labels.
134    items: Vec<String>,
135    /// Focused option index used for keyboard navigation.
136    pub cursor: usize,
137    /// Set of selected option indices.
138    pub selected: HashSet<usize>,
139}
140
141impl MultiSelectState {
142    /// Create multi-select state with the provided options.
143    pub fn new(items: Vec<impl Into<String>>) -> Self {
144        Self {
145            items: items.into_iter().map(Into::into).collect(),
146            cursor: 0,
147            selected: HashSet::new(),
148        }
149    }
150
151    /// Return all option labels.
152    pub fn items(&self) -> &[String] {
153        &self.items
154    }
155
156    /// Return the number of options.
157    pub fn len(&self) -> usize {
158        self.items.len()
159    }
160
161    /// Return whether there are no options.
162    pub fn is_empty(&self) -> bool {
163        self.items.is_empty()
164    }
165
166    /// Replace all options and prune stale selected indices.
167    pub fn set_items(&mut self, items: Vec<impl Into<String>>) {
168        self.items = items.into_iter().map(Into::into).collect();
169        self.selected.retain(|index| *index < self.items.len());
170        self.cursor = self.cursor.min(self.items.len().saturating_sub(1));
171        if self.items.is_empty() {
172            self.cursor = 0;
173        }
174    }
175
176    /// Return selected item labels in ascending index order.
177    pub fn selected_items(&self) -> Vec<&str> {
178        let mut indices: Vec<usize> = self.selected.iter().copied().collect();
179        indices.sort();
180        indices
181            .iter()
182            .filter_map(|&i| self.items.get(i).map(String::as_str))
183            .collect()
184    }
185
186    /// Toggle selection state for `index`.
187    pub fn toggle(&mut self, index: usize) {
188        if index >= self.items.len() {
189            return;
190        }
191        if self.selected.contains(&index) {
192            self.selected.remove(&index);
193        } else {
194            self.selected.insert(index);
195        }
196    }
197}
198
199// ── Tree ──────────────────────────────────────────────────────────────
200
201/// A node in a tree view.
202#[derive(Debug, Clone)]
203pub struct TreeNode {
204    /// Display label for this node.
205    pub label: String,
206    /// Child nodes.
207    pub children: Vec<TreeNode>,
208    /// Whether the node is expanded in the tree view.
209    pub expanded: bool,
210}
211
212impl TreeNode {
213    /// Create a collapsed tree node with no children.
214    pub fn new(label: impl Into<String>) -> Self {
215        Self {
216            label: label.into(),
217            children: Vec::new(),
218            expanded: false,
219        }
220    }
221
222    /// Mark this node as expanded.
223    pub fn expanded(mut self) -> Self {
224        self.expanded = true;
225        self
226    }
227
228    /// Set child nodes for this node.
229    pub fn children(mut self, children: Vec<TreeNode>) -> Self {
230        self.children = children;
231        self
232    }
233
234    /// Returns `true` when this node has no children.
235    pub fn is_leaf(&self) -> bool {
236        self.children.is_empty()
237    }
238
239    fn flatten(&self, depth: usize, out: &mut Vec<FlatTreeEntry>) {
240        out.push(FlatTreeEntry {
241            depth,
242            label: self.label.clone(),
243            is_leaf: self.is_leaf(),
244            expanded: self.expanded,
245        });
246        if self.expanded {
247            for child in &self.children {
248                child.flatten(depth + 1, out);
249            }
250        }
251    }
252}
253
254pub(crate) struct FlatTreeEntry {
255    pub depth: usize,
256    pub label: String,
257    pub is_leaf: bool,
258    pub expanded: bool,
259}
260
261/// State for a hierarchical tree view widget.
262#[derive(Debug, Clone)]
263pub struct TreeState {
264    /// Root nodes of the tree.
265    pub nodes: Vec<TreeNode>,
266    /// Selected row index in the flattened visible tree.
267    pub selected: usize,
268}
269
270impl TreeState {
271    /// Create tree state from root nodes.
272    pub fn new(nodes: Vec<TreeNode>) -> Self {
273        Self { nodes, selected: 0 }
274    }
275
276    pub(crate) fn flatten(&self) -> Vec<FlatTreeEntry> {
277        let mut entries = Vec::new();
278        for node in &self.nodes {
279            node.flatten(0, &mut entries);
280        }
281        entries
282    }
283
284    pub(crate) fn toggle_at(&mut self, flat_index: usize) {
285        let mut counter = 0usize;
286        Self::toggle_recursive(&mut self.nodes, flat_index, &mut counter);
287    }
288
289    fn toggle_recursive(nodes: &mut [TreeNode], target: usize, counter: &mut usize) -> bool {
290        for node in nodes.iter_mut() {
291            if *counter == target {
292                if !node.is_leaf() {
293                    node.expanded = !node.expanded;
294                }
295                return true;
296            }
297            *counter += 1;
298            if node.expanded && Self::toggle_recursive(&mut node.children, target, counter) {
299                return true;
300            }
301        }
302        false
303    }
304}
305
306/// State for the directory tree widget.
307#[derive(Debug, Clone)]
308pub struct DirectoryTreeState {
309    /// The underlying tree state (reuses existing TreeState).
310    pub tree: TreeState,
311    /// Whether to show file/folder icons.
312    pub show_icons: bool,
313}
314
315impl DirectoryTreeState {
316    /// Create directory tree state from root nodes.
317    pub fn new(nodes: Vec<TreeNode>) -> Self {
318        Self {
319            tree: TreeState::new(nodes),
320            show_icons: true,
321        }
322    }
323
324    /// Build a directory tree from slash-delimited paths.
325    pub fn from_paths(paths: &[&str]) -> Self {
326        let mut roots: Vec<TreeNode> = Vec::new();
327
328        for raw_path in paths {
329            let parts: Vec<&str> = raw_path
330                .split('/')
331                .filter(|part| !part.is_empty())
332                .collect();
333            if parts.is_empty() {
334                continue;
335            }
336            insert_path(&mut roots, &parts, 0);
337        }
338
339        Self::new(roots)
340    }
341
342    /// Return selected node label if a node is selected.
343    pub fn selected_label(&self) -> Option<&str> {
344        let mut cursor = 0usize;
345        selected_label_in_nodes(&self.tree.nodes, self.tree.selected, &mut cursor)
346    }
347}
348
349impl Default for DirectoryTreeState {
350    fn default() -> Self {
351        Self::new(Vec::<TreeNode>::new())
352    }
353}
354
355fn insert_path(nodes: &mut Vec<TreeNode>, parts: &[&str], depth: usize) {
356    let Some(label) = parts.get(depth) else {
357        return;
358    };
359
360    let is_last = depth + 1 == parts.len();
361    let idx = nodes
362        .iter()
363        .position(|node| node.label == *label)
364        .unwrap_or_else(|| {
365            let mut node = TreeNode::new(*label);
366            if !is_last {
367                node.expanded = true;
368            }
369            nodes.push(node);
370            nodes.len() - 1
371        });
372
373    if is_last {
374        return;
375    }
376
377    nodes[idx].expanded = true;
378    insert_path(&mut nodes[idx].children, parts, depth + 1);
379}
380
381fn selected_label_in_nodes<'a>(
382    nodes: &'a [TreeNode],
383    target: usize,
384    cursor: &mut usize,
385) -> Option<&'a str> {
386    for node in nodes {
387        if *cursor == target {
388            return Some(node.label.as_str());
389        }
390        *cursor += 1;
391        if node.expanded
392            && let Some(found) = selected_label_in_nodes(&node.children, target, cursor)
393        {
394            return Some(found);
395        }
396    }
397    None
398}
399
400// ── Command Palette ───────────────────────────────────────────────────
401
402/// A single command entry in the palette.
403#[derive(Debug, Clone)]
404pub struct PaletteCommand {
405    /// Primary command label.
406    pub label: String,
407    /// Supplemental command description.
408    pub description: String,
409    /// Optional keyboard shortcut hint.
410    pub shortcut: Option<String>,
411}
412
413impl PaletteCommand {
414    /// Create a new palette command.
415    pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
416        Self {
417            label: label.into(),
418            description: description.into(),
419            shortcut: None,
420        }
421    }
422
423    /// Set a shortcut hint displayed alongside the command.
424    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
425        self.shortcut = Some(s.into());
426        self
427    }
428}
429
430// ── Color Picker ──────────────────────────────────────────────────────
431
432/// Interaction mode of a [`ColorPickerState`].
433///
434/// Toggle between the two modes with `Tab` when the picker is focused.
435///
436/// # Example
437///
438/// ```no_run
439/// # use slt::widgets::{ColorPickerState, PickerMode};
440/// let mut picker = ColorPickerState::tailwind();
441/// assert_eq!(picker.mode, PickerMode::Palette);
442/// ```
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
444pub enum PickerMode {
445    /// Navigate the 2D swatch grid with the arrow keys / `hjkl`.
446    #[default]
447    Palette,
448    /// Enter a `#RRGGBB` or `#RGB` hex string in the embedded text field.
449    Hex,
450}
451
452/// State for an interactive color picker over the [`Color`](crate::Color) model.
453///
454/// Renders a grid of color swatches plus an optional hex-entry field. Pass a
455/// mutable reference to [`Context::color_picker`](crate::Context::color_picker)
456/// each frame; read the chosen color back via [`selected`](Self::selected).
457///
458/// Swatches are emitted with a full-RGB background; the terminal backend
459/// downsamples each cell to the active [`ColorDepth`](crate::ColorDepth) on
460/// flush (see [`Color::downsampled`](crate::Color::downsampled)), so the picker
461/// degrades correctly on 256-color, 16-color, and no-color terminals. Every
462/// `Color::Rgb` swatch also carries a `#RRGGBB` label rendered with a
463/// [`Color::contrast_fg`](crate::Color::contrast_fg) foreground, so the picker
464/// stays legible even when no background color is emitted.
465///
466/// # Example
467///
468/// ```no_run
469/// # use slt::widgets::ColorPickerState;
470/// # slt::run(|ui: &mut slt::Context| {
471/// let mut picker = ColorPickerState::tailwind();
472/// let resp = ui.color_picker(&mut picker);
473/// if resp.changed {
474///     let chosen = picker.selected();
475///     // persist `chosen` somewhere…
476/// }
477/// # });
478/// ```
479#[derive(Debug, Clone)]
480pub struct ColorPickerState {
481    /// Swatch colors laid out row-major.
482    pub colors: Vec<crate::Color>,
483    /// Number of swatches per row (minimum 1, default 8).
484    pub columns: usize,
485    /// Flat index of the selected swatch into [`colors`](Self::colors).
486    pub selected: usize,
487    /// Whether the picker is in palette-grid or hex-entry mode.
488    pub mode: PickerMode,
489    /// Backing text field used in [`PickerMode::Hex`].
490    pub hex_input: TextInputState,
491}
492
493impl ColorPickerState {
494    /// Create a picker over the given swatches with the first one selected.
495    ///
496    /// Defaults to 8 columns and [`PickerMode::Palette`]. An empty `colors`
497    /// vector is allowed; the widget renders nothing and reports no change.
498    ///
499    /// # Example
500    ///
501    /// ```no_run
502    /// # use slt::widgets::ColorPickerState;
503    /// # use slt::Color;
504    /// let picker = ColorPickerState::new(vec![
505    ///     Color::Rgb(239, 68, 68),
506    ///     Color::Rgb(59, 130, 246),
507    /// ]);
508    /// assert_eq!(picker.columns, 8);
509    /// ```
510    pub fn new(colors: Vec<crate::Color>) -> Self {
511        Self {
512            colors,
513            columns: 8,
514            selected: 0,
515            mode: PickerMode::Palette,
516            hex_input: TextInputState::with_placeholder("#RRGGBB"),
517        }
518    }
519
520    /// Build a picker from the Tailwind `c500` shades in
521    /// [`crate::palette::tailwind`].
522    ///
523    /// Includes all 22 palettes from `SLATE` through `ROSE`, in declaration
524    /// order, giving a balanced default swatch grid.
525    ///
526    /// # Example
527    ///
528    /// ```no_run
529    /// # use slt::widgets::ColorPickerState;
530    /// let picker = ColorPickerState::tailwind();
531    /// assert_eq!(picker.colors.len(), 22);
532    /// ```
533    pub fn tailwind() -> Self {
534        use crate::palette::tailwind;
535        let colors = vec![
536            tailwind::SLATE.c500,
537            tailwind::GRAY.c500,
538            tailwind::ZINC.c500,
539            tailwind::NEUTRAL.c500,
540            tailwind::STONE.c500,
541            tailwind::RED.c500,
542            tailwind::ORANGE.c500,
543            tailwind::AMBER.c500,
544            tailwind::YELLOW.c500,
545            tailwind::LIME.c500,
546            tailwind::GREEN.c500,
547            tailwind::EMERALD.c500,
548            tailwind::TEAL.c500,
549            tailwind::CYAN.c500,
550            tailwind::SKY.c500,
551            tailwind::BLUE.c500,
552            tailwind::INDIGO.c500,
553            tailwind::VIOLET.c500,
554            tailwind::PURPLE.c500,
555            tailwind::FUCHSIA.c500,
556            tailwind::PINK.c500,
557            tailwind::ROSE.c500,
558        ];
559        Self::new(colors)
560    }
561
562    /// Set the number of swatches per row (clamped to at least 1).
563    ///
564    /// # Example
565    ///
566    /// ```no_run
567    /// # use slt::widgets::ColorPickerState;
568    /// let picker = ColorPickerState::tailwind().columns(6);
569    /// assert_eq!(picker.columns, 6);
570    /// ```
571    pub fn columns(mut self, n: usize) -> Self {
572        self.columns = n.max(1);
573        self
574    }
575
576    /// Return the currently selected color.
577    ///
578    /// In [`PickerMode::Hex`] a successfully parsed `#RRGGBB` / `#RGB` value
579    /// takes precedence; otherwise the highlighted palette swatch is returned.
580    /// Falls back to [`Color::Reset`](crate::Color::Reset) when the palette is
581    /// empty and no valid hex value has been entered.
582    ///
583    /// # Example
584    ///
585    /// ```no_run
586    /// # use slt::widgets::ColorPickerState;
587    /// # use slt::Color;
588    /// let picker = ColorPickerState::new(vec![Color::Rgb(59, 130, 246)]);
589    /// assert_eq!(picker.selected(), Color::Rgb(59, 130, 246));
590    /// ```
591    pub fn selected(&self) -> crate::Color {
592        if self.mode == PickerMode::Hex
593            && let Some(c) = parse_hex_color(&self.hex_input.value)
594        {
595            return c;
596        }
597        self.colors
598            .get(self.selected)
599            .copied()
600            .unwrap_or(crate::Color::Reset)
601    }
602}
603
604/// Parse a `#RRGGBB` or `#RGB` hex string into a [`Color::Rgb`](crate::Color).
605///
606/// Returns `None` for malformed input (wrong length, non-hex digits, missing
607/// `#`). The leading `#` is required; surrounding whitespace is trimmed.
608pub(crate) fn parse_hex_color(input: &str) -> Option<crate::Color> {
609    let s = input.trim();
610    let hex = s.strip_prefix('#')?;
611    let (r, g, b) = match hex.len() {
612        6 => {
613            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
614            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
615            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
616            (r, g, b)
617        }
618        3 => {
619            // #RGB expands each nibble to a byte (e.g. `f` -> `0xff`).
620            let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 0x11;
621            let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 0x11;
622            let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 0x11;
623            (r, g, b)
624        }
625        _ => return None,
626    };
627    Some(crate::Color::Rgb(r, g, b))
628}
629
630/// Render `color` as a `#RRGGBB` label, or `None` for non-RGB colors.
631///
632/// Used by the color picker to label swatches so they stay legible when the
633/// terminal emits no background color.
634pub(crate) fn color_hex_label(color: crate::Color) -> Option<String> {
635    match color {
636        crate::Color::Rgb(r, g, b) => Some(format!("#{r:02X}{g:02X}{b:02X}")),
637        _ => None,
638    }
639}