rpi_tui/
searchable_select.rs1use 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
25pub 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 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 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 pub fn list(&self) -> &Arc<SelectList> {
56 &self.list
57 }
58
59 pub fn input(&self) -> &Arc<Input> {
61 &self.input
62 }
63
64 pub fn set_search_text(&self, text: &str) {
68 self.input.set_value(text);
69 self.list.set_filter(text);
70 }
71
72 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 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 KeyCode::Char(' ') => multi_select,
93 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 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 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 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 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}