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}