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}