Skip to main content

wt/tui/
options.rs

1//! A reusable inline option list (the "dropdown" of selectable values shown on a
2//! pop-up field, issue #25).
3//!
4//! [`OptionList`] is pure interaction state: the owning modal seeds it with the
5//! candidate labels, drives it from key events (filter while typing, navigate
6//! with `↑/↓`, accept with `Enter`), and the view renders the matches with the
7//! cursor row highlighted. It powers both the create-worktree branch/base
8//! type-ahead and the PR-compose model/effort pickers.
9//!
10//! Two usage shapes share one widget:
11//! - *Type-ahead* (text-backed fields): [`refilter`](OptionList::refilter) on
12//!   every keystroke narrows the matches and clears the active flag, so
13//!   [`selected`](OptionList::selected) only yields a value once the user has
14//!   moved into the list with `↑/↓`. This keeps `Enter` free to submit freshly
15//!   typed text the list does not contain.
16//! - *Fixed choice* (enum fields): seed the labels, [`open`](OptionList::open),
17//!   and [`set_cursor`](OptionList::set_cursor) to the current value; the owning
18//!   modal drives the selection directly and renders the list for affordance.
19
20/// A filterable, navigable list of option labels rendered as an inline dropdown.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct OptionList {
23    /// Every candidate label, in stable display order.
24    items: Vec<String>,
25    /// Indices into `items` matching the current query, in display order.
26    matches: Vec<usize>,
27    /// The highlighted position within `matches`.
28    cursor: usize,
29    /// Whether the dropdown is shown (still gated on having matches).
30    open: bool,
31    /// Whether the user has moved into the list (`↑/↓`); only then does `Enter`
32    /// accept the highlighted item rather than the field's typed text.
33    active: bool,
34}
35
36impl OptionList {
37    /// Builds a list over `items`, initially closed with every item matching.
38    pub fn new(items: Vec<String>) -> OptionList {
39        let matches = (0..items.len()).collect();
40        OptionList {
41            items,
42            matches,
43            cursor: 0,
44            open: false,
45            active: false,
46        }
47    }
48
49    /// Requests the dropdown be shown. Visibility is still gated on there being
50    /// at least one match (see [`is_open`](OptionList::is_open)).
51    pub fn open(&mut self) {
52        self.open = true;
53    }
54
55    /// Hides the dropdown and clears the active selection.
56    pub fn close(&mut self) {
57        self.open = false;
58        self.active = false;
59    }
60
61    /// Whether the dropdown is currently visible (open with at least one match).
62    pub fn is_open(&self) -> bool {
63        self.open && !self.matches.is_empty()
64    }
65
66    /// Recomputes the matches for `query` (case-insensitive substring), resets
67    /// the cursor to the top, and clears the active flag so typing re-suggests
68    /// rather than committing to a highlighted row.
69    pub fn refilter(&mut self, query: &str) {
70        let q = query.to_lowercase();
71        self.matches = self
72            .items
73            .iter()
74            .enumerate()
75            .filter(|(_, item)| item.to_lowercase().contains(&q))
76            .map(|(i, _)| i)
77            .collect();
78        self.cursor = 0;
79        self.active = false;
80    }
81
82    /// Moves the highlight up one row (clamped) and marks the list active.
83    pub fn up(&mut self) {
84        if self.is_open() {
85            self.active = true;
86            self.cursor = self.cursor.saturating_sub(1);
87        }
88    }
89
90    /// Moves the highlight down one row (clamped) and marks the list active.
91    pub fn down(&mut self) {
92        if self.is_open() {
93            self.active = true;
94            let last = self.matches.len().saturating_sub(1);
95            self.cursor = (self.cursor + 1).min(last);
96        }
97    }
98
99    /// The highlighted label, but only once the user has engaged the list with
100    /// `↑/↓` — so a type-ahead field can tell "accept this suggestion" apart from
101    /// "submit my typed text".
102    pub fn selected(&self) -> Option<&str> {
103        if self.is_open() && self.active {
104            self.matches
105                .get(self.cursor)
106                .map(|&i| self.items[i].as_str())
107        } else {
108            None
109        }
110    }
111
112    /// Points the cursor at the match at `index` (clamped) and marks the list
113    /// active, used to seed a fixed-choice picker to its current value.
114    pub fn set_cursor(&mut self, index: usize) {
115        if !self.matches.is_empty() {
116            self.cursor = index.min(self.matches.len() - 1);
117        }
118        self.active = true;
119    }
120
121    /// The highlighted position within the matches (for rendering).
122    pub fn cursor(&self) -> usize {
123        self.cursor
124    }
125
126    /// The number of matches (for rendering windowing / "N more" hints).
127    pub fn match_count(&self) -> usize {
128        self.matches.len()
129    }
130
131    /// The match labels in display order (for rendering).
132    pub fn match_labels(&self) -> impl Iterator<Item = &str> {
133        self.matches.iter().map(move |&i| self.items[i].as_str())
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    fn list() -> OptionList {
142        OptionList::new(vec![
143            "main".into(),
144            "origin/main".into(),
145            "origin/dev".into(),
146            "feature/login".into(),
147        ])
148    }
149
150    #[test]
151    fn new_matches_everything_and_starts_closed() {
152        let ol = list();
153        assert_eq!(ol.match_count(), 4);
154        assert!(!ol.is_open());
155        assert_eq!(ol.selected(), None);
156    }
157
158    #[test]
159    fn refilter_substring_case_insensitive() {
160        let mut ol = list();
161        ol.open();
162        ol.refilter("MAIN");
163        let m: Vec<&str> = ol.match_labels().collect();
164        assert_eq!(m, vec!["main", "origin/main"]);
165        // A query matching nothing hides the dropdown even while "open".
166        ol.refilter("zzz");
167        assert_eq!(ol.match_count(), 0);
168        assert!(!ol.is_open());
169    }
170
171    #[test]
172    fn navigation_clamps_within_matches() {
173        let mut ol = list();
174        ol.open();
175        assert_eq!(ol.cursor(), 0);
176        ol.up(); // already at top
177        assert_eq!(ol.cursor(), 0);
178        ol.down();
179        ol.down();
180        assert_eq!(ol.cursor(), 2);
181        for _ in 0..10 {
182            ol.down();
183        }
184        assert_eq!(ol.cursor(), 3); // clamped to last
185    }
186
187    #[test]
188    fn selected_only_after_engaging_the_list() {
189        let mut ol = list();
190        ol.open();
191        // Open but not engaged: Enter should submit typed text, not a suggestion.
192        assert_eq!(ol.selected(), None);
193        ol.down(); // engage
194        assert_eq!(ol.selected(), Some("origin/main"));
195        // Typing re-suggests and de-activates.
196        ol.refilter("feat");
197        assert_eq!(ol.selected(), None);
198        assert_eq!(ol.match_labels().collect::<Vec<_>>(), vec!["feature/login"]);
199    }
200
201    #[test]
202    fn close_clears_open_and_active() {
203        let mut ol = list();
204        ol.open();
205        ol.down();
206        assert!(ol.selected().is_some());
207        ol.close();
208        assert!(!ol.is_open());
209        assert_eq!(ol.selected(), None);
210    }
211
212    #[test]
213    fn set_cursor_seeds_a_fixed_choice() {
214        let mut ol = OptionList::new(vec!["low".into(), "medium".into(), "high".into()]);
215        ol.open();
216        ol.set_cursor(2);
217        assert_eq!(ol.cursor(), 2);
218        assert_eq!(ol.selected(), Some("high"));
219        // Out-of-range clamps to the last match.
220        ol.set_cursor(99);
221        assert_eq!(ol.cursor(), 2);
222    }
223
224    #[test]
225    fn navigation_is_a_noop_while_closed() {
226        let mut ol = list();
227        ol.down();
228        assert_eq!(ol.cursor(), 0);
229        assert_eq!(ol.selected(), None);
230    }
231}