1#[derive(Debug, Clone)]
9pub struct SelectOption {
10 pub id: String,
11 pub label: String,
12}
13
14#[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 pub fn set_options(&mut self, options: Vec<SelectOption>) {
40 self.options = options;
41 self.focused = 0;
42 self.query.clear();
43 }
44
45 #[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 #[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 #[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 #[must_use]
81 pub fn option_count(&self) -> usize {
82 self.options.len()
83 }
84
85 #[must_use]
87 pub fn options(&self) -> &[SelectOption] {
88 &self.options
89 }
90
91 #[must_use]
93 pub fn query(&self) -> &str {
94 &self.query
95 }
96
97 #[must_use]
99 pub fn focused_in_filtered(&self) -> usize {
100 self.focused
101 }
102
103 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 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 pub fn filter_push(&mut self, c: char) {
123 self.query.push(c);
124 self.focused = 0;
125 }
126
127 pub fn filter_pop(&mut self) {
129 self.query.pop();
130 self.focused = 0;
131 }
132
133 pub fn confirm(&mut self) {
137 let m = self.matches();
138 let Some(&raw_idx) = m.get(self.focused) else {
139 return;
142 };
143 self.focused = raw_idx;
144 self.query.clear();
145 self.open = false;
146 }
147
148 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(); assert_eq!(w.value(), "id-2");
234 w.next(); 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(); assert_eq!(w.value(), "id-2");
244 w.prev(); 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 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 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'); 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'); 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'); w.next(); w.prev(); 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 w.filter_push('a');
452 assert_eq!(w.value(), "id-a");
453 w.confirm();
454 assert!(!w.open);
455 assert_eq!(w.query(), "");
456 assert_eq!(
459 w.value(),
460 "id-a",
461 "selection preserved after confirm+query clear"
462 );
463 }
464}