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