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}