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}