Skip to main content

tui/widgets/
select.rs

1//! Dropdown / list select widget.
2//!
3//! Holds an ordered list of options and a focused index. Navigation wraps
4//! around within the type-ahead filtered set. The widget submits the focused
5//! option's `id` (usually a UUID), not its display label.
6
7/// A single entry in a [`SelectWidget`] option list.
8#[derive(Debug, Clone)]
9pub struct SelectOption {
10    pub id: String,
11    pub label: String,
12}
13
14/// A scrollable pick-list widget with type-ahead filtering.
15///
16/// `focused` indexes the **filtered** set returned by [`Self::matches`], not
17/// `options` directly. When the filter matches nothing, `value()`/`display()`
18/// return `""` and `next()`/`prev()` are no-ops.
19#[derive(Debug, Clone)]
20pub struct SelectWidget {
21    options: Vec<SelectOption>,
22    focused: usize,
23    pub open: bool,
24    query: String,
25}
26
27impl SelectWidget {
28    #[must_use]
29    pub fn new() -> Self {
30        Self {
31            options: Vec::new(),
32            focused: 0,
33            open: false,
34            query: String::new(),
35        }
36    }
37
38    /// Replace the option list, reset focus to 0, and clear any query.
39    pub fn set_options(&mut self, options: Vec<SelectOption>) {
40        self.options = options;
41        self.focused = 0;
42        self.query.clear();
43    }
44
45    /// Indices into `options` whose label contains `query` case-insensitively.
46    /// An empty query returns all indices.
47    #[must_use]
48    pub fn matches(&self) -> Vec<usize> {
49        if self.query.is_empty() {
50            return (0..self.options.len()).collect();
51        }
52        let q = self.query.to_lowercase();
53        self.options
54            .iter()
55            .enumerate()
56            .filter(|(_, o)| o.label.to_lowercase().contains(&q))
57            .map(|(i, _)| i)
58            .collect()
59    }
60
61    /// The `id` of the focused filtered option, or `""` when no match exists.
62    #[must_use]
63    pub fn value(&self) -> &str {
64        let m = self.matches();
65        m.get(self.focused)
66            .map(|&idx| self.options[idx].id.as_str())
67            .unwrap_or("")
68    }
69
70    /// The display label of the focused filtered option, or `""` when no match.
71    #[must_use]
72    pub fn display(&self) -> &str {
73        let m = self.matches();
74        m.get(self.focused)
75            .map(|&idx| self.options[idx].label.as_str())
76            .unwrap_or("")
77    }
78
79    /// Number of options currently held (unfiltered).
80    #[must_use]
81    pub fn option_count(&self) -> usize {
82        self.options.len()
83    }
84
85    /// The raw option list, for rendering.
86    #[must_use]
87    pub fn options(&self) -> &[SelectOption] {
88        &self.options
89    }
90
91    /// The active type-ahead query string.
92    #[must_use]
93    pub fn query(&self) -> &str {
94        &self.query
95    }
96
97    /// Index of the focused entry within the filtered set (i.e. `matches()`).
98    #[must_use]
99    pub fn focused_in_filtered(&self) -> usize {
100        self.focused
101    }
102
103    /// Advance focus by one step within the filtered set, wrapping around.
104    pub fn next(&mut self) {
105        let n = self.matches().len();
106        if n == 0 {
107            return;
108        }
109        self.focused = (self.focused + 1) % n;
110    }
111
112    /// Retreat focus by one step within the filtered set, wrapping around.
113    pub fn prev(&mut self) {
114        let n = self.matches().len();
115        if n == 0 {
116            return;
117        }
118        self.focused = (self.focused + n - 1) % n;
119    }
120
121    /// Append a character to the type-ahead query and reset focus to 0.
122    pub fn filter_push(&mut self, c: char) {
123        self.query.push(c);
124        self.focused = 0;
125    }
126
127    /// Remove the last character from the type-ahead query and reset focus to 0.
128    pub fn filter_pop(&mut self) {
129        self.query.pop();
130        self.focused = 0;
131    }
132
133    /// Resolve the current filtered selection to the raw options index, clear
134    /// the query, and close the dropdown. Call this on SelectConfirm so the
135    /// selection survives query removal.
136    pub fn confirm(&mut self) {
137        let m = self.matches();
138        let Some(&raw_idx) = m.get(self.focused) else {
139            // Nothing matches the query — keep the filter open so the user can
140            // correct it rather than silently committing the first option.
141            return;
142        };
143        self.focused = raw_idx;
144        self.query.clear();
145        self.open = false;
146    }
147
148    /// Replace the option list, keeping the previously selected id focused if
149    /// it exists in the new list; falls back to index 0 otherwise. Clears query.
150    pub fn set_options_preserving_id(&mut self, options: Vec<SelectOption>) {
151        let current_id = self.value().to_string();
152        self.query.clear();
153        self.options = options;
154        self.focused = self
155            .options
156            .iter()
157            .position(|o| o.id == current_id)
158            .unwrap_or(0);
159    }
160}
161
162impl Default for SelectWidget {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    fn make_opts(n: usize) -> Vec<SelectOption> {
173        (0..n)
174            .map(|i| SelectOption {
175                id: format!("id-{i}"),
176                label: format!("Label {i}"),
177            })
178            .collect()
179    }
180
181    #[test]
182    fn empty_widget_returns_empty_strings() {
183        let w = SelectWidget::new();
184        assert_eq!(w.value(), "");
185        assert_eq!(w.display(), "");
186    }
187
188    #[test]
189    fn zero_match_confirm_is_noop_and_keeps_query() {
190        let mut w = SelectWidget::new();
191        w.set_options(make_opts(3));
192        for c in "zzz".chars() {
193            w.filter_push(c);
194        }
195        assert!(w.matches().is_empty(), "query matches nothing");
196        w.confirm();
197        assert_eq!(
198            w.value(),
199            "",
200            "confirm with no match must not silently select option 0"
201        );
202        assert_eq!(
203            w.query(),
204            "zzz",
205            "query kept so the user can fix the filter"
206        );
207    }
208
209    #[test]
210    fn value_returns_focused_id() {
211        let mut w = SelectWidget::new();
212        w.set_options(make_opts(3));
213        assert_eq!(w.value(), "id-0");
214        w.next();
215        assert_eq!(w.value(), "id-1");
216    }
217
218    #[test]
219    fn display_returns_focused_label() {
220        let mut w = SelectWidget::new();
221        w.set_options(make_opts(3));
222        assert_eq!(w.display(), "Label 0");
223        w.next();
224        assert_eq!(w.display(), "Label 1");
225    }
226
227    #[test]
228    fn next_wraps_around() {
229        let mut w = SelectWidget::new();
230        w.set_options(make_opts(3));
231        w.next();
232        w.next(); // 0 → 1 → 2
233        assert_eq!(w.value(), "id-2");
234        w.next(); // wraps to 0
235        assert_eq!(w.value(), "id-0");
236    }
237
238    #[test]
239    fn prev_wraps_around() {
240        let mut w = SelectWidget::new();
241        w.set_options(make_opts(3));
242        w.prev(); // 0 wraps to 2
243        assert_eq!(w.value(), "id-2");
244        w.prev(); // 2 → 1
245        assert_eq!(w.value(), "id-1");
246    }
247
248    #[test]
249    fn next_and_prev_noop_on_empty() {
250        let mut w = SelectWidget::new();
251        w.next();
252        w.prev();
253        assert_eq!(w.value(), "");
254    }
255
256    #[test]
257    fn set_options_resets_focus() {
258        let mut w = SelectWidget::new();
259        w.set_options(make_opts(3));
260        w.next();
261        w.next();
262        w.set_options(make_opts(2));
263        assert_eq!(w.value(), "id-0");
264    }
265
266    #[test]
267    fn set_options_preserving_id_keeps_selected_uuid() {
268        let mut w = SelectWidget::new();
269        w.set_options(vec![SelectOption {
270            id: "uuid-42".to_string(),
271            label: "uuid-42".to_string(),
272        }]);
273        assert_eq!(w.value(), "uuid-42");
274        let full_list = vec![
275            SelectOption {
276                id: "uuid-10".to_string(),
277                label: "First".to_string(),
278            },
279            SelectOption {
280                id: "uuid-42".to_string(),
281                label: "Second (proper label)".to_string(),
282            },
283            SelectOption {
284                id: "uuid-99".to_string(),
285                label: "Third".to_string(),
286            },
287        ];
288        w.set_options_preserving_id(full_list);
289        assert_eq!(
290            w.value(),
291            "uuid-42",
292            "uuid preserved after set_options_preserving_id"
293        );
294        assert_eq!(
295            w.display(),
296            "Second (proper label)",
297            "label updated to full label"
298        );
299    }
300
301    #[test]
302    fn set_options_preserving_id_falls_back_to_zero_when_not_found() {
303        let mut w = SelectWidget::new();
304        w.set_options(vec![SelectOption {
305            id: "old".to_string(),
306            label: "Old".to_string(),
307        }]);
308        let new_list = vec![
309            SelectOption {
310                id: "new-a".to_string(),
311                label: "A".to_string(),
312            },
313            SelectOption {
314                id: "new-b".to_string(),
315                label: "B".to_string(),
316            },
317        ];
318        w.set_options_preserving_id(new_list);
319        assert_eq!(w.value(), "new-a");
320    }
321
322    #[test]
323    fn confirm_closes_open_dropdown() {
324        let mut w = SelectWidget::new();
325        w.set_options(make_opts(2));
326        w.open = true;
327        w.confirm();
328        assert!(!w.open);
329    }
330
331    #[test]
332    fn filter_push_narrows_matches() {
333        let mut w = SelectWidget::new();
334        w.set_options(vec![
335            SelectOption {
336                id: "a".to_string(),
337                label: "Apple".to_string(),
338            },
339            SelectOption {
340                id: "b".to_string(),
341                label: "Grape".to_string(),
342            },
343            SelectOption {
344                id: "c".to_string(),
345                label: "Apricot".to_string(),
346            },
347        ]);
348        // 'p' matches "Apple" (has 'p') and "Apricot" (has 'p'), not "Grape" (has 'p' too!)
349        // Use 'i' instead: "Apricot" has 'i', "Apple" has no 'i', "Grape" has no 'i'
350        w.filter_push('i');
351        let m = w.matches();
352        assert_eq!(m.len(), 1, "only Apricot matches 'i'");
353        assert_eq!(w.value(), "c", "first (only) match is Apricot");
354    }
355
356    #[test]
357    fn filter_next_wraps_within_filtered_set() {
358        let mut w = SelectWidget::new();
359        w.set_options(vec![
360            SelectOption {
361                id: "id-0".to_string(),
362                label: "Sword".to_string(),
363            },
364            SelectOption {
365                id: "id-1".to_string(),
366                label: "Tuna".to_string(),
367            },
368            SelectOption {
369                id: "id-2".to_string(),
370                label: "Squid".to_string(),
371            },
372        ]);
373        // 's' matches "Sword" (idx 0) and "Squid" (idx 2), not "Tuna"
374        w.filter_push('s');
375        assert_eq!(w.value(), "id-0", "first match is Sword");
376        w.next();
377        assert_eq!(w.value(), "id-2", "second match is Squid");
378        w.next();
379        assert_eq!(w.value(), "id-0", "wraps back to first match");
380    }
381
382    #[test]
383    fn filter_pop_widens_matches() {
384        let mut w = SelectWidget::new();
385        w.set_options(make_opts(3));
386        w.filter_push('1'); // only "Label 1" matches
387        assert_eq!(w.matches().len(), 1);
388        w.filter_pop();
389        assert_eq!(w.matches().len(), 3, "empty query matches all");
390    }
391
392    #[test]
393    fn set_options_clears_query() {
394        let mut w = SelectWidget::new();
395        w.set_options(make_opts(3));
396        w.filter_push('x');
397        assert_eq!(w.query(), "x");
398        w.set_options(make_opts(2));
399        assert_eq!(w.query(), "", "set_options must clear query");
400    }
401
402    #[test]
403    fn set_options_preserving_id_clears_query() {
404        let mut w = SelectWidget::new();
405        w.set_options(make_opts(3));
406        w.filter_push('x');
407        assert_eq!(w.query(), "x");
408        w.set_options_preserving_id(make_opts(2));
409        assert_eq!(w.query(), "", "set_options_preserving_id must clear query");
410    }
411
412    #[test]
413    fn zero_match_value_and_display_empty() {
414        let mut w = SelectWidget::new();
415        w.set_options(make_opts(3));
416        w.filter_push('z'); // no "Label N" contains 'z'
417        assert_eq!(w.matches().len(), 0);
418        assert_eq!(w.value(), "", "zero match → value is empty string");
419        assert_eq!(w.display(), "", "zero match → display is empty string");
420    }
421
422    #[test]
423    fn zero_match_next_and_prev_no_panic() {
424        let mut w = SelectWidget::new();
425        w.set_options(make_opts(3));
426        w.filter_push('z'); // zero matches
427        w.next(); // must not panic or index out of bounds
428        w.prev(); // must not panic or index out of bounds
429        assert_eq!(w.value(), "");
430        assert_eq!(w.display(), "");
431    }
432
433    #[test]
434    fn confirm_resolves_filtered_selection_to_raw_index() {
435        let mut w = SelectWidget::new();
436        w.set_options(vec![
437            SelectOption {
438                id: "id-x".to_string(),
439                label: "Xenon".to_string(),
440            },
441            SelectOption {
442                id: "id-a".to_string(),
443                label: "Apple".to_string(),
444            },
445            SelectOption {
446                id: "id-b".to_string(),
447                label: "Banana".to_string(),
448            },
449        ]);
450        // 'a' matches "Apple" (idx 1) and "Banana" (idx 2); focused=0 → "Apple"
451        w.filter_push('a');
452        assert_eq!(w.value(), "id-a");
453        w.confirm();
454        assert!(!w.open);
455        assert_eq!(w.query(), "");
456        // After confirm, focused resolved to raw index of "Apple" (idx 1)
457        // With empty query, matches=[0,1,2], focused=1 → options[1] = "Apple"
458        assert_eq!(
459            w.value(),
460            "id-a",
461            "selection preserved after confirm+query clear"
462        );
463    }
464}