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            && let Some(found) = selected_label_in_nodes(&node.children, target, cursor) {
338                return Some(found);
339            }
340    }
341    None
342}
343
344// ── Command Palette ───────────────────────────────────────────────────
345
346/// A single command entry in the palette.
347#[derive(Debug, Clone)]
348pub struct PaletteCommand {
349    /// Primary command label.
350    pub label: String,
351    /// Supplemental command description.
352    pub description: String,
353    /// Optional keyboard shortcut hint.
354    pub shortcut: Option<String>,
355}
356
357impl PaletteCommand {
358    /// Create a new palette command.
359    pub fn new(label: impl Into<String>, description: impl Into<String>) -> Self {
360        Self {
361            label: label.into(),
362            description: description.into(),
363            shortcut: None,
364        }
365    }
366
367    /// Set a shortcut hint displayed alongside the command.
368    pub fn shortcut(mut self, s: impl Into<String>) -> Self {
369        self.shortcut = Some(s.into());
370        self
371    }
372}
373
374// ── Color Picker ──────────────────────────────────────────────────────
375
376/// Interaction mode of a [`ColorPickerState`].
377///
378/// Toggle between the two modes with `Tab` when the picker is focused.
379///
380/// # Example
381///
382/// ```no_run
383/// # use slt::widgets::{ColorPickerState, PickerMode};
384/// let mut picker = ColorPickerState::tailwind();
385/// assert_eq!(picker.mode, PickerMode::Palette);
386/// ```
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
388pub enum PickerMode {
389    /// Navigate the 2D swatch grid with the arrow keys / `hjkl`.
390    #[default]
391    Palette,
392    /// Enter a `#RRGGBB` or `#RGB` hex string in the embedded text field.
393    Hex,
394}
395
396/// State for an interactive color picker over the [`Color`](crate::Color) model.
397///
398/// Renders a grid of color swatches plus an optional hex-entry field. Pass a
399/// mutable reference to [`Context::color_picker`](crate::Context::color_picker)
400/// each frame; read the chosen color back via [`selected`](Self::selected).
401///
402/// Swatches are emitted with a full-RGB background; the terminal backend
403/// downsamples each cell to the active [`ColorDepth`](crate::ColorDepth) on
404/// flush (see [`Color::downsampled`](crate::Color::downsampled)), so the picker
405/// degrades correctly on 256-color, 16-color, and no-color terminals. Every
406/// `Color::Rgb` swatch also carries a `#RRGGBB` label rendered with a
407/// [`Color::contrast_fg`](crate::Color::contrast_fg) foreground, so the picker
408/// stays legible even when no background color is emitted.
409///
410/// # Example
411///
412/// ```no_run
413/// # use slt::widgets::ColorPickerState;
414/// # slt::run(|ui: &mut slt::Context| {
415/// let mut picker = ColorPickerState::tailwind();
416/// let resp = ui.color_picker(&mut picker);
417/// if resp.changed {
418///     let chosen = picker.selected();
419///     // persist `chosen` somewhere…
420/// }
421/// # });
422/// ```
423#[derive(Debug, Clone)]
424pub struct ColorPickerState {
425    /// Swatch colors laid out row-major.
426    pub colors: Vec<crate::Color>,
427    /// Number of swatches per row (minimum 1, default 8).
428    pub columns: usize,
429    /// Flat index of the selected swatch into [`colors`](Self::colors).
430    pub selected: usize,
431    /// Whether the picker is in palette-grid or hex-entry mode.
432    pub mode: PickerMode,
433    /// Backing text field used in [`PickerMode::Hex`].
434    pub hex_input: TextInputState,
435}
436
437impl ColorPickerState {
438    /// Create a picker over the given swatches with the first one selected.
439    ///
440    /// Defaults to 8 columns and [`PickerMode::Palette`]. An empty `colors`
441    /// vector is allowed; the widget renders nothing and reports no change.
442    ///
443    /// # Example
444    ///
445    /// ```no_run
446    /// # use slt::widgets::ColorPickerState;
447    /// # use slt::Color;
448    /// let picker = ColorPickerState::new(vec![
449    ///     Color::Rgb(239, 68, 68),
450    ///     Color::Rgb(59, 130, 246),
451    /// ]);
452    /// assert_eq!(picker.columns, 8);
453    /// ```
454    pub fn new(colors: Vec<crate::Color>) -> Self {
455        Self {
456            colors,
457            columns: 8,
458            selected: 0,
459            mode: PickerMode::Palette,
460            hex_input: TextInputState::with_placeholder("#RRGGBB"),
461        }
462    }
463
464    /// Build a picker from the Tailwind `c500` shades in
465    /// [`crate::palette::tailwind`].
466    ///
467    /// Includes all 22 palettes from `SLATE` through `ROSE`, in declaration
468    /// order, giving a balanced default swatch grid.
469    ///
470    /// # Example
471    ///
472    /// ```no_run
473    /// # use slt::widgets::ColorPickerState;
474    /// let picker = ColorPickerState::tailwind();
475    /// assert_eq!(picker.colors.len(), 22);
476    /// ```
477    pub fn tailwind() -> Self {
478        use crate::palette::tailwind;
479        let colors = vec![
480            tailwind::SLATE.c500,
481            tailwind::GRAY.c500,
482            tailwind::ZINC.c500,
483            tailwind::NEUTRAL.c500,
484            tailwind::STONE.c500,
485            tailwind::RED.c500,
486            tailwind::ORANGE.c500,
487            tailwind::AMBER.c500,
488            tailwind::YELLOW.c500,
489            tailwind::LIME.c500,
490            tailwind::GREEN.c500,
491            tailwind::EMERALD.c500,
492            tailwind::TEAL.c500,
493            tailwind::CYAN.c500,
494            tailwind::SKY.c500,
495            tailwind::BLUE.c500,
496            tailwind::INDIGO.c500,
497            tailwind::VIOLET.c500,
498            tailwind::PURPLE.c500,
499            tailwind::FUCHSIA.c500,
500            tailwind::PINK.c500,
501            tailwind::ROSE.c500,
502        ];
503        Self::new(colors)
504    }
505
506    /// Set the number of swatches per row (clamped to at least 1).
507    ///
508    /// # Example
509    ///
510    /// ```no_run
511    /// # use slt::widgets::ColorPickerState;
512    /// let picker = ColorPickerState::tailwind().columns(6);
513    /// assert_eq!(picker.columns, 6);
514    /// ```
515    pub fn columns(mut self, n: usize) -> Self {
516        self.columns = n.max(1);
517        self
518    }
519
520    /// Return the currently selected color.
521    ///
522    /// In [`PickerMode::Hex`] a successfully parsed `#RRGGBB` / `#RGB` value
523    /// takes precedence; otherwise the highlighted palette swatch is returned.
524    /// Falls back to [`Color::Reset`](crate::Color::Reset) when the palette is
525    /// empty and no valid hex value has been entered.
526    ///
527    /// # Example
528    ///
529    /// ```no_run
530    /// # use slt::widgets::ColorPickerState;
531    /// # use slt::Color;
532    /// let picker = ColorPickerState::new(vec![Color::Rgb(59, 130, 246)]);
533    /// assert_eq!(picker.selected(), Color::Rgb(59, 130, 246));
534    /// ```
535    pub fn selected(&self) -> crate::Color {
536        if self.mode == PickerMode::Hex
537            && let Some(c) = parse_hex_color(&self.hex_input.value) {
538                return c;
539            }
540        self.colors
541            .get(self.selected)
542            .copied()
543            .unwrap_or(crate::Color::Reset)
544    }
545}
546
547/// Parse a `#RRGGBB` or `#RGB` hex string into a [`Color::Rgb`](crate::Color).
548///
549/// Returns `None` for malformed input (wrong length, non-hex digits, missing
550/// `#`). The leading `#` is required; surrounding whitespace is trimmed.
551pub(crate) fn parse_hex_color(input: &str) -> Option<crate::Color> {
552    let s = input.trim();
553    let hex = s.strip_prefix('#')?;
554    let (r, g, b) = match hex.len() {
555        6 => {
556            let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
557            let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
558            let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
559            (r, g, b)
560        }
561        3 => {
562            // #RGB expands each nibble to a byte (e.g. `f` -> `0xff`).
563            let r = u8::from_str_radix(&hex[0..1], 16).ok()? * 0x11;
564            let g = u8::from_str_radix(&hex[1..2], 16).ok()? * 0x11;
565            let b = u8::from_str_radix(&hex[2..3], 16).ok()? * 0x11;
566            (r, g, b)
567        }
568        _ => return None,
569    };
570    Some(crate::Color::Rgb(r, g, b))
571}
572
573/// Render `color` as a `#RRGGBB` label, or `None` for non-RGB colors.
574///
575/// Used by the color picker to label swatches so they stay legible when the
576/// terminal emits no background color.
577pub(crate) fn color_hex_label(color: crate::Color) -> Option<String> {
578    match color {
579        crate::Color::Rgb(r, g, b) => Some(format!("#{r:02X}{g:02X}{b:02X}")),
580        _ => None,
581    }
582}