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)]
9
pub 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)]
20
pub struct SelectWidget {
21
    options: Vec<SelectOption>,
22
    focused: usize,
23
    pub open: bool,
24
    query: String,
25
}
26

            
27
impl SelectWidget {
28
    #[must_use]
29
296
    pub fn new() -> Self {
30
296
        Self {
31
296
            options: Vec::new(),
32
296
            focused: 0,
33
296
            open: false,
34
296
            query: String::new(),
35
296
        }
36
296
    }
37

            
38
    /// Replace the option list, reset focus to 0, and clear any query.
39
287
    pub fn set_options(&mut self, options: Vec<SelectOption>) {
40
287
        self.options = options;
41
287
        self.focused = 0;
42
287
        self.query.clear();
43
287
    }
44

            
45
    /// Indices into `options` whose label contains `query` case-insensitively.
46
    /// An empty query returns all indices.
47
    #[must_use]
48
223
    pub fn matches(&self) -> Vec<usize> {
49
223
        if self.query.is_empty() {
50
199
            return (0..self.options.len()).collect();
51
24
        }
52
24
        let q = self.query.to_lowercase();
53
24
        self.options
54
24
            .iter()
55
24
            .enumerate()
56
68
            .filter(|(_, o)| o.label.to_lowercase().contains(&q))
57
24
            .map(|(i, _)| i)
58
24
            .collect()
59
223
    }
60

            
61
    /// The `id` of the focused filtered option, or `""` when no match exists.
62
    #[must_use]
63
128
    pub fn value(&self) -> &str {
64
128
        let m = self.matches();
65
128
        m.get(self.focused)
66
128
            .map(|&idx| self.options[idx].id.as_str())
67
128
            .unwrap_or("")
68
128
    }
69

            
70
    /// The display label of the focused filtered option, or `""` when no match.
71
    #[must_use]
72
43
    pub fn display(&self) -> &str {
73
43
        let m = self.matches();
74
43
        m.get(self.focused)
75
43
            .map(|&idx| self.options[idx].label.as_str())
76
43
            .unwrap_or("")
77
43
    }
78

            
79
    /// Number of options currently held (unfiltered).
80
    #[must_use]
81
21
    pub fn option_count(&self) -> usize {
82
21
        self.options.len()
83
21
    }
84

            
85
    /// The raw option list, for rendering.
86
    #[must_use]
87
5
    pub fn options(&self) -> &[SelectOption] {
88
5
        &self.options
89
5
    }
90

            
91
    /// The active type-ahead query string.
92
    #[must_use]
93
11
    pub fn query(&self) -> &str {
94
11
        &self.query
95
11
    }
96

            
97
    /// Index of the focused entry within the filtered set (i.e. `matches()`).
98
    #[must_use]
99
5
    pub fn focused_in_filtered(&self) -> usize {
100
5
        self.focused
101
5
    }
102

            
103
    /// Advance focus by one step within the filtered set, wrapping around.
104
33
    pub fn next(&mut self) {
105
33
        let n = self.matches().len();
106
33
        if n == 0 {
107
2
            return;
108
31
        }
109
31
        self.focused = (self.focused + 1) % n;
110
33
    }
111

            
112
    /// Retreat focus by one step within the filtered set, wrapping around.
113
6
    pub fn prev(&mut self) {
114
6
        let n = self.matches().len();
115
6
        if n == 0 {
116
2
            return;
117
4
        }
118
4
        self.focused = (self.focused + n - 1) % n;
119
6
    }
120

            
121
    /// Append a character to the type-ahead query and reset focus to 0.
122
13
    pub fn filter_push(&mut self, c: char) {
123
13
        self.query.push(c);
124
13
        self.focused = 0;
125
13
    }
126

            
127
    /// Remove the last character from the type-ahead query and reset focus to 0.
128
2
    pub fn filter_pop(&mut self) {
129
2
        self.query.pop();
130
2
        self.focused = 0;
131
2
    }
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
3
    pub fn confirm(&mut self) {
137
3
        let m = self.matches();
138
3
        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
1
            return;
142
        };
143
2
        self.focused = raw_idx;
144
2
        self.query.clear();
145
2
        self.open = false;
146
3
    }
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
41
    pub fn set_options_preserving_id(&mut self, options: Vec<SelectOption>) {
151
41
        let current_id = self.value().to_string();
152
41
        self.query.clear();
153
41
        self.options = options;
154
41
        self.focused = self
155
41
            .options
156
41
            .iter()
157
83
            .position(|o| o.id == current_id)
158
41
            .unwrap_or(0);
159
41
    }
160
}
161

            
162
impl Default for SelectWidget {
163
    fn default() -> Self {
164
        Self::new()
165
    }
166
}
167

            
168
#[cfg(test)]
169
mod tests {
170
    use super::*;
171

            
172
15
    fn make_opts(n: usize) -> Vec<SelectOption> {
173
15
        (0..n)
174
15
            .map(|i| SelectOption {
175
41
                id: format!("id-{i}"),
176
41
                label: format!("Label {i}"),
177
41
            })
178
15
            .collect()
179
15
    }
180

            
181
    #[test]
182
1
    fn empty_widget_returns_empty_strings() {
183
1
        let w = SelectWidget::new();
184
1
        assert_eq!(w.value(), "");
185
1
        assert_eq!(w.display(), "");
186
1
    }
187

            
188
    #[test]
189
1
    fn zero_match_confirm_is_noop_and_keeps_query() {
190
1
        let mut w = SelectWidget::new();
191
1
        w.set_options(make_opts(3));
192
3
        for c in "zzz".chars() {
193
3
            w.filter_push(c);
194
3
        }
195
1
        assert!(w.matches().is_empty(), "query matches nothing");
196
1
        w.confirm();
197
1
        assert_eq!(
198
1
            w.value(),
199
            "",
200
            "confirm with no match must not silently select option 0"
201
        );
202
1
        assert_eq!(
203
1
            w.query(),
204
            "zzz",
205
            "query kept so the user can fix the filter"
206
        );
207
1
    }
208

            
209
    #[test]
210
1
    fn value_returns_focused_id() {
211
1
        let mut w = SelectWidget::new();
212
1
        w.set_options(make_opts(3));
213
1
        assert_eq!(w.value(), "id-0");
214
1
        w.next();
215
1
        assert_eq!(w.value(), "id-1");
216
1
    }
217

            
218
    #[test]
219
1
    fn display_returns_focused_label() {
220
1
        let mut w = SelectWidget::new();
221
1
        w.set_options(make_opts(3));
222
1
        assert_eq!(w.display(), "Label 0");
223
1
        w.next();
224
1
        assert_eq!(w.display(), "Label 1");
225
1
    }
226

            
227
    #[test]
228
1
    fn next_wraps_around() {
229
1
        let mut w = SelectWidget::new();
230
1
        w.set_options(make_opts(3));
231
1
        w.next();
232
1
        w.next(); // 0 → 1 → 2
233
1
        assert_eq!(w.value(), "id-2");
234
1
        w.next(); // wraps to 0
235
1
        assert_eq!(w.value(), "id-0");
236
1
    }
237

            
238
    #[test]
239
1
    fn prev_wraps_around() {
240
1
        let mut w = SelectWidget::new();
241
1
        w.set_options(make_opts(3));
242
1
        w.prev(); // 0 wraps to 2
243
1
        assert_eq!(w.value(), "id-2");
244
1
        w.prev(); // 2 → 1
245
1
        assert_eq!(w.value(), "id-1");
246
1
    }
247

            
248
    #[test]
249
1
    fn next_and_prev_noop_on_empty() {
250
1
        let mut w = SelectWidget::new();
251
1
        w.next();
252
1
        w.prev();
253
1
        assert_eq!(w.value(), "");
254
1
    }
255

            
256
    #[test]
257
1
    fn set_options_resets_focus() {
258
1
        let mut w = SelectWidget::new();
259
1
        w.set_options(make_opts(3));
260
1
        w.next();
261
1
        w.next();
262
1
        w.set_options(make_opts(2));
263
1
        assert_eq!(w.value(), "id-0");
264
1
    }
265

            
266
    #[test]
267
1
    fn set_options_preserving_id_keeps_selected_uuid() {
268
1
        let mut w = SelectWidget::new();
269
1
        w.set_options(vec![SelectOption {
270
1
            id: "uuid-42".to_string(),
271
1
            label: "uuid-42".to_string(),
272
1
        }]);
273
1
        assert_eq!(w.value(), "uuid-42");
274
1
        let full_list = vec![
275
1
            SelectOption {
276
1
                id: "uuid-10".to_string(),
277
1
                label: "First".to_string(),
278
1
            },
279
1
            SelectOption {
280
1
                id: "uuid-42".to_string(),
281
1
                label: "Second (proper label)".to_string(),
282
1
            },
283
1
            SelectOption {
284
1
                id: "uuid-99".to_string(),
285
1
                label: "Third".to_string(),
286
1
            },
287
        ];
288
1
        w.set_options_preserving_id(full_list);
289
1
        assert_eq!(
290
1
            w.value(),
291
            "uuid-42",
292
            "uuid preserved after set_options_preserving_id"
293
        );
294
1
        assert_eq!(
295
1
            w.display(),
296
            "Second (proper label)",
297
            "label updated to full label"
298
        );
299
1
    }
300

            
301
    #[test]
302
1
    fn set_options_preserving_id_falls_back_to_zero_when_not_found() {
303
1
        let mut w = SelectWidget::new();
304
1
        w.set_options(vec![SelectOption {
305
1
            id: "old".to_string(),
306
1
            label: "Old".to_string(),
307
1
        }]);
308
1
        let new_list = vec![
309
1
            SelectOption {
310
1
                id: "new-a".to_string(),
311
1
                label: "A".to_string(),
312
1
            },
313
1
            SelectOption {
314
1
                id: "new-b".to_string(),
315
1
                label: "B".to_string(),
316
1
            },
317
        ];
318
1
        w.set_options_preserving_id(new_list);
319
1
        assert_eq!(w.value(), "new-a");
320
1
    }
321

            
322
    #[test]
323
1
    fn confirm_closes_open_dropdown() {
324
1
        let mut w = SelectWidget::new();
325
1
        w.set_options(make_opts(2));
326
1
        w.open = true;
327
1
        w.confirm();
328
1
        assert!(!w.open);
329
1
    }
330

            
331
    #[test]
332
1
    fn filter_push_narrows_matches() {
333
1
        let mut w = SelectWidget::new();
334
1
        w.set_options(vec![
335
1
            SelectOption {
336
1
                id: "a".to_string(),
337
1
                label: "Apple".to_string(),
338
1
            },
339
1
            SelectOption {
340
1
                id: "b".to_string(),
341
1
                label: "Grape".to_string(),
342
1
            },
343
1
            SelectOption {
344
1
                id: "c".to_string(),
345
1
                label: "Apricot".to_string(),
346
1
            },
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
1
        w.filter_push('i');
351
1
        let m = w.matches();
352
1
        assert_eq!(m.len(), 1, "only Apricot matches 'i'");
353
1
        assert_eq!(w.value(), "c", "first (only) match is Apricot");
354
1
    }
355

            
356
    #[test]
357
1
    fn filter_next_wraps_within_filtered_set() {
358
1
        let mut w = SelectWidget::new();
359
1
        w.set_options(vec![
360
1
            SelectOption {
361
1
                id: "id-0".to_string(),
362
1
                label: "Sword".to_string(),
363
1
            },
364
1
            SelectOption {
365
1
                id: "id-1".to_string(),
366
1
                label: "Tuna".to_string(),
367
1
            },
368
1
            SelectOption {
369
1
                id: "id-2".to_string(),
370
1
                label: "Squid".to_string(),
371
1
            },
372
        ]);
373
        // 's' matches "Sword" (idx 0) and "Squid" (idx 2), not "Tuna"
374
1
        w.filter_push('s');
375
1
        assert_eq!(w.value(), "id-0", "first match is Sword");
376
1
        w.next();
377
1
        assert_eq!(w.value(), "id-2", "second match is Squid");
378
1
        w.next();
379
1
        assert_eq!(w.value(), "id-0", "wraps back to first match");
380
1
    }
381

            
382
    #[test]
383
1
    fn filter_pop_widens_matches() {
384
1
        let mut w = SelectWidget::new();
385
1
        w.set_options(make_opts(3));
386
1
        w.filter_push('1'); // only "Label 1" matches
387
1
        assert_eq!(w.matches().len(), 1);
388
1
        w.filter_pop();
389
1
        assert_eq!(w.matches().len(), 3, "empty query matches all");
390
1
    }
391

            
392
    #[test]
393
1
    fn set_options_clears_query() {
394
1
        let mut w = SelectWidget::new();
395
1
        w.set_options(make_opts(3));
396
1
        w.filter_push('x');
397
1
        assert_eq!(w.query(), "x");
398
1
        w.set_options(make_opts(2));
399
1
        assert_eq!(w.query(), "", "set_options must clear query");
400
1
    }
401

            
402
    #[test]
403
1
    fn set_options_preserving_id_clears_query() {
404
1
        let mut w = SelectWidget::new();
405
1
        w.set_options(make_opts(3));
406
1
        w.filter_push('x');
407
1
        assert_eq!(w.query(), "x");
408
1
        w.set_options_preserving_id(make_opts(2));
409
1
        assert_eq!(w.query(), "", "set_options_preserving_id must clear query");
410
1
    }
411

            
412
    #[test]
413
1
    fn zero_match_value_and_display_empty() {
414
1
        let mut w = SelectWidget::new();
415
1
        w.set_options(make_opts(3));
416
1
        w.filter_push('z'); // no "Label N" contains 'z'
417
1
        assert_eq!(w.matches().len(), 0);
418
1
        assert_eq!(w.value(), "", "zero match → value is empty string");
419
1
        assert_eq!(w.display(), "", "zero match → display is empty string");
420
1
    }
421

            
422
    #[test]
423
1
    fn zero_match_next_and_prev_no_panic() {
424
1
        let mut w = SelectWidget::new();
425
1
        w.set_options(make_opts(3));
426
1
        w.filter_push('z'); // zero matches
427
1
        w.next(); // must not panic or index out of bounds
428
1
        w.prev(); // must not panic or index out of bounds
429
1
        assert_eq!(w.value(), "");
430
1
        assert_eq!(w.display(), "");
431
1
    }
432

            
433
    #[test]
434
1
    fn confirm_resolves_filtered_selection_to_raw_index() {
435
1
        let mut w = SelectWidget::new();
436
1
        w.set_options(vec![
437
1
            SelectOption {
438
1
                id: "id-x".to_string(),
439
1
                label: "Xenon".to_string(),
440
1
            },
441
1
            SelectOption {
442
1
                id: "id-a".to_string(),
443
1
                label: "Apple".to_string(),
444
1
            },
445
1
            SelectOption {
446
1
                id: "id-b".to_string(),
447
1
                label: "Banana".to_string(),
448
1
            },
449
        ]);
450
        // 'a' matches "Apple" (idx 1) and "Banana" (idx 2); focused=0 → "Apple"
451
1
        w.filter_push('a');
452
1
        assert_eq!(w.value(), "id-a");
453
1
        w.confirm();
454
1
        assert!(!w.open);
455
1
        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
1
        assert_eq!(
459
1
            w.value(),
460
            "id-a",
461
            "selection preserved after confirm+query clear"
462
        );
463
1
    }
464
}