Skip to main content

rpi_tui/
searchable_select.rs

1//! Searchable select list — a titled select list with a fuzzy-filter input.
2//!
3//! Port of upstream's `SelectSubmenu` (`components/settings-submenu.ts`) with
4//! `searchable: true`. Typing filters the list through [`SelectList::set_filter`]
5//! while arrow keys, Enter, and Esc keep their list semantics, so the widget
6//! behaves like a plain [`SelectList`] until the user types.
7//!
8//! The wrapper owns the search [`Input`] and the inner list; callers register
9//! `on_select` / `on_cancel` on the list they pass in, exactly as they would for
10//! a bare `SelectList`.
11
12use std::any::Any;
13use std::sync::{Arc, Mutex};
14
15use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
16
17use super::component::{Component, Focusable};
18use crate::ansi::bold;
19use crate::input::Input;
20use crate::select_list::SelectList;
21use crate::spacer::Spacer;
22use crate::theme::theme;
23use crate::utils::truncate_to_width;
24
25/// A select list preceded by a search input that fuzzy-filters its items.
26pub struct SearchableSelectList {
27    title: Option<String>,
28    description: Option<String>,
29    input: Arc<Input>,
30    list: Arc<SelectList>,
31    focused: Mutex<bool>,
32}
33
34impl SearchableSelectList {
35    /// Wrap `list` with a search input. `title` / `description` are optional
36    /// header rows (upstream's `SelectSubmenu` shows both).
37    pub fn new(title: Option<&str>, description: Option<&str>, list: Arc<SelectList>) -> Self {
38        let input = Arc::new(Input::with_placeholder("Type to filter..."));
39        // Every text edit re-filters the list. `Input::on_change` fires after
40        // the state lock is released, so this can safely read the value.
41        let list_for_change = list.clone();
42        input.on_change(Arc::new(move |value: &str| {
43            list_for_change.set_filter(value);
44        }));
45        Self {
46            title: title.map(str::to_string),
47            description: description.map(str::to_string),
48            input,
49            list,
50            focused: Mutex::new(false),
51        }
52    }
53
54    /// The wrapped list. Callers register `on_select` / `on_cancel` here.
55    pub fn list(&self) -> &Arc<SelectList> {
56        &self.list
57    }
58
59    /// The search input (for tests / programmatic prefills).
60    pub fn input(&self) -> &Arc<Input> {
61        &self.input
62    }
63
64    /// Prefill the search box and apply the filter. Used when a slash command
65    /// was given a search term that did not match exactly (upstream's
66    /// `initialSearchInput`).
67    pub fn set_search_text(&self, text: &str) {
68        self.input.set_value(text);
69        self.list.set_filter(text);
70    }
71
72    /// Route a key to the list or the search box.
73    ///
74    /// List semantics win for navigation/confirm/cancel and for the Emacs-style
75    /// list bindings; everything else (printable characters, backspace,
76    /// caret movement) edits the filter.
77    pub fn handle_key(&self, key: KeyEvent) {
78        if Self::key_belongs_to_list(key, self.list.is_multi_select()) {
79            self.list.handle_key(key);
80        } else {
81            self.input.handle_key(key);
82        }
83    }
84
85    /// Whether `key` should drive the list instead of the search box.
86    fn key_belongs_to_list(key: KeyEvent, multi_select: bool) -> bool {
87        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
88        match key.code {
89            KeyCode::Esc | KeyCode::Enter | KeyCode::Up | KeyCode::Down => true,
90            KeyCode::PageUp | KeyCode::PageDown => true,
91            // Space toggles in multi-select; otherwise it is a search char.
92            KeyCode::Char(' ') => multi_select,
93            // `SelectList`'s Ctrl+K/J/P/N navigation.
94            KeyCode::Char('k') | KeyCode::Char('p') => ctrl,
95            KeyCode::Char('j') | KeyCode::Char('n') => ctrl,
96            _ => false,
97        }
98    }
99}
100
101impl Component for SearchableSelectList {
102    fn render(&self, width: usize) -> Vec<String> {
103        let colors = theme().colors;
104        let mut lines: Vec<String> = Vec::new();
105
106        if let Some(title) = &self.title {
107            let styled = colors.accent.fg(&bold(title));
108            lines.push(truncate_to_width(&styled, width, "…"));
109        }
110        if let Some(description) = &self.description {
111            if !description.is_empty() {
112                if self.title.is_some() {
113                    lines.extend(Spacer::new(1).render(width));
114                }
115                let styled = colors.muted.fg(description);
116                lines.push(truncate_to_width(&styled, width, "…"));
117            }
118        }
119
120        lines.extend(Spacer::new(1).render(width));
121        // Render the search row through `Text` so long queries wrap inside the
122        // width instead of overflowing the frame.
123        let search = self.input.render(width);
124        lines.extend(search);
125        lines.extend(Spacer::new(1).render(width));
126        lines.extend(self.list.render(width));
127        lines
128    }
129
130    fn invalidate(&self) {
131        self.input.invalidate();
132        self.list.invalidate();
133    }
134
135    fn as_any(&self) -> &dyn Any {
136        self
137    }
138}
139
140impl Focusable for SearchableSelectList {
141    fn set_focused(&self, focused: bool) {
142        if let Ok(mut guard) = self.focused.lock() {
143            *guard = focused;
144        }
145        // The search box holds the caret whenever the selector is focused, so
146        // the hardware cursor lands in the query (IME candidate positioning).
147        self.input.set_focused(focused);
148    }
149
150    fn is_focused(&self) -> bool {
151        self.focused.lock().map(|guard| *guard).unwrap_or(false)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use crate::select_list::SelectItem;
159    use crossterm::event::KeyEvent;
160
161    fn key(code: KeyCode) -> KeyEvent {
162        KeyEvent::new(code, KeyModifiers::NONE)
163    }
164
165    fn list() -> Arc<SelectList> {
166        Arc::new(SelectList::new(
167            vec![
168                SelectItem::new("claude-sonnet", "Claude Sonnet"),
169                SelectItem::new("gpt-5", "GPT-5"),
170                SelectItem::new("deepseek-chat", "DeepSeek Chat"),
171            ],
172            10,
173        ))
174    }
175
176    #[test]
177    fn typing_filters_the_list() {
178        let widget = SearchableSelectList::new(Some("Model"), None, list());
179        for ch in "deep".chars() {
180            widget.handle_key(key(KeyCode::Char(ch)));
181        }
182        assert_eq!(widget.input().get_value(), "deep");
183        let selected = widget.list().get_selected_item().expect("a match");
184        assert_eq!(selected.value, "deepseek-chat");
185    }
186
187    #[test]
188    fn backspace_restores_earlier_matches() {
189        let widget = SearchableSelectList::new(None, None, list());
190        for ch in "gpt".chars() {
191            widget.handle_key(key(KeyCode::Char(ch)));
192        }
193        assert_eq!(widget.list().get_selected_item().unwrap().value, "gpt-5");
194        widget.handle_key(key(KeyCode::Backspace));
195        assert_eq!(widget.input().get_value(), "gp");
196        // Still matching; clearing fully restores everything.
197        widget.handle_key(key(KeyCode::Backspace));
198        widget.handle_key(key(KeyCode::Backspace));
199        assert_eq!(widget.input().get_value(), "");
200        assert_eq!(
201            widget.list().get_selected_item().unwrap().value,
202            "claude-sonnet"
203        );
204    }
205
206    #[test]
207    fn navigation_keys_move_the_list_not_the_caret() {
208        let widget = SearchableSelectList::new(None, None, list());
209        widget.handle_key(key(KeyCode::Down));
210        assert_eq!(widget.list().get_selected_item().unwrap().value, "gpt-5");
211        assert_eq!(widget.input().get_value(), "");
212        widget.handle_key(key(KeyCode::Up));
213        assert_eq!(
214            widget.list().get_selected_item().unwrap().value,
215            "claude-sonnet"
216        );
217        assert_eq!(widget.input().get_value(), "");
218    }
219
220    #[test]
221    fn enter_and_esc_reach_the_list_callbacks() {
222        use std::sync::atomic::{AtomicUsize, Ordering};
223        let widget = SearchableSelectList::new(None, None, list());
224        let selected = Arc::new(AtomicUsize::new(0));
225        let selected_cb = selected.clone();
226        widget.list().on_select(Arc::new(move |_item| {
227            selected_cb.fetch_add(1, Ordering::SeqCst);
228        }));
229        let cancelled = Arc::new(AtomicUsize::new(0));
230        let cancelled_cb = cancelled.clone();
231        widget.list().on_cancel(Arc::new(move || {
232            cancelled_cb.fetch_add(1, Ordering::SeqCst);
233        }));
234
235        widget.handle_key(key(KeyCode::Enter));
236        assert_eq!(selected.load(Ordering::SeqCst), 1);
237        widget.handle_key(key(KeyCode::Esc));
238        assert_eq!(cancelled.load(Ordering::SeqCst), 1);
239    }
240
241    #[test]
242    fn search_text_matches_label_and_provider_not_just_value() {
243        // `search_text` is what enables matching a display name or provider.
244        let items = vec![SelectItem::new("claude-sonnet-5", "Claude Sonnet 5")
245            .with_search_text("anthropic anthropic/claude-sonnet-5 Claude Sonnet 5")];
246        let widget = SearchableSelectList::new(None, None, Arc::new(SelectList::new(items, 10)));
247        for ch in "anthropic".chars() {
248            widget.handle_key(key(KeyCode::Char(ch)));
249        }
250        assert!(widget.list().get_selected_item().is_some());
251    }
252
253    #[test]
254    fn set_search_text_prefills_and_filters() {
255        let widget = SearchableSelectList::new(None, None, list());
256        widget.set_search_text("gpt");
257        assert_eq!(widget.input().get_value(), "gpt");
258        assert_eq!(widget.list().get_selected_item().unwrap().value, "gpt-5");
259    }
260
261    #[test]
262    fn renders_title_description_and_search_row() {
263        let widget = SearchableSelectList::new(Some("Pick"), Some("Choose one"), list());
264        let rendered = crate::strip_ansi(&widget.render(60).join("\n"));
265        assert!(rendered.contains("Pick"), "{rendered}");
266        assert!(rendered.contains("Choose one"), "{rendered}");
267        assert!(rendered.contains("Claude Sonnet"), "{rendered}");
268    }
269}