1use crossterm::event::{KeyCode, MouseEventKind};
2
3use crate::components::{Component, Event, ViewContext};
4use crate::line::Line;
5use crate::rendering::frame::Frame;
6
7pub trait SelectItem {
8 fn render_item(&self, selected: bool, ctx: &ViewContext) -> Line;
9
10 fn is_selectable(&self) -> bool {
11 true
12 }
13}
14
15#[derive(Debug)]
16pub enum SelectListMessage {
17 Close,
18 Select(usize),
19}
20
21#[doc = include_str!("../docs/select_list.md")]
22pub struct SelectList<T: SelectItem> {
23 items: Vec<T>,
24 selected_index: Option<usize>,
25 placeholder: String,
26}
27
28impl<T: SelectItem> SelectList<T> {
29 pub fn new(items: Vec<T>, placeholder: impl Into<String>) -> Self {
30 let selected_index = first_selectable(&items);
31 Self { items, selected_index, placeholder: placeholder.into() }
32 }
33
34 pub fn items(&self) -> &[T] {
35 &self.items
36 }
37
38 pub fn items_mut(&mut self) -> &mut [T] {
39 &mut self.items
40 }
41
42 pub fn retain(&mut self, f: impl FnMut(&T) -> bool) {
43 self.items.retain(f);
44 self.reseat_selection();
45 }
46
47 pub fn selected_index(&self) -> usize {
48 self.selected_index.unwrap_or(0)
49 }
50
51 pub fn selected_item(&self) -> Option<&T> {
52 self.selected_index.and_then(|i| self.items.get(i))
53 }
54
55 pub fn set_items(&mut self, items: Vec<T>) {
56 self.items = items;
57 self.reseat_selection();
58 }
59
60 pub fn set_selected(&mut self, index: usize) {
62 if self.items.get(index).is_some_and(SelectItem::is_selectable) {
63 self.selected_index = Some(index);
64 }
65 }
66
67 pub fn select_where(&mut self, predicate: impl Fn(&T) -> bool) {
69 self.selected_index = self
70 .items
71 .iter()
72 .position(|item| item.is_selectable() && predicate(item))
73 .or_else(|| first_selectable(&self.items));
74 }
75
76 pub fn push(&mut self, item: T) {
77 let was_unselectable = self.selected_index.is_none();
78 self.items.push(item);
79 if was_unselectable {
80 self.selected_index = first_selectable(&self.items);
81 }
82 }
83
84 pub fn len(&self) -> usize {
85 self.items.len()
86 }
87
88 pub fn is_empty(&self) -> bool {
89 self.items.is_empty()
90 }
91
92 fn reseat_selection(&mut self) {
93 let target = self.selected_index.unwrap_or(0);
94 self.selected_index = self
95 .items
96 .iter()
97 .enumerate()
98 .filter_map(|(i, item)| item.is_selectable().then_some(i))
99 .min_by_key(|i| (i.abs_diff(target), *i));
100 }
101
102 fn move_selection(&mut self, delta: isize) {
103 if let Some(current) = self.selected_index
104 && let Some(next) = step_selectable(&self.items, current, delta)
105 {
106 self.selected_index = Some(next);
107 }
108 }
109}
110
111impl<T: SelectItem> Component for SelectList<T> {
112 type Message = SelectListMessage;
113
114 async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
115 if let Event::Mouse(mouse) = event {
116 return match mouse.kind {
117 MouseEventKind::ScrollUp => {
118 self.move_selection(-1);
119 Some(vec![])
120 }
121 MouseEventKind::ScrollDown => {
122 self.move_selection(1);
123 Some(vec![])
124 }
125 _ => Some(vec![]),
126 };
127 }
128 let Event::Key(key) = event else {
129 return None;
130 };
131 match key.code {
132 KeyCode::Esc => Some(vec![SelectListMessage::Close]),
133 KeyCode::Up => {
134 self.move_selection(-1);
135 Some(vec![])
136 }
137 KeyCode::Down => {
138 self.move_selection(1);
139 Some(vec![])
140 }
141 KeyCode::Enter => match self.selected_index {
142 Some(i) => Some(vec![SelectListMessage::Select(i)]),
143 None => Some(vec![]),
144 },
145 _ => Some(vec![]),
146 }
147 }
148
149 fn render(&mut self, ctx: &ViewContext) -> Frame {
150 if self.items.is_empty() {
151 return Frame::new(vec![Line::new(format!(" ({})", self.placeholder))]);
152 }
153
154 let inner = ctx.with_size((ctx.size.width.saturating_sub(2), ctx.size.height));
155 Frame::new(
156 self.items
157 .iter()
158 .enumerate()
159 .map(|(i, item)| item.render_item(Some(i) == self.selected_index, &inner).prepend(" "))
160 .collect(),
161 )
162 }
163}
164
165fn first_selectable<T: SelectItem>(items: &[T]) -> Option<usize> {
166 items.iter().position(SelectItem::is_selectable)
167}
168
169fn step_selectable<T: SelectItem>(items: &[T], current: usize, delta: isize) -> Option<usize> {
170 let selectable: Vec<usize> =
171 items.iter().enumerate().filter_map(|(i, item)| item.is_selectable().then_some(i)).collect();
172 if selectable.is_empty() {
173 return None;
174 }
175 let position = selectable.iter().position(|i| *i == current).unwrap_or(0);
176 let next = if delta < 0 {
177 position.checked_sub(1).unwrap_or(selectable.len() - 1)
178 } else {
179 (position + 1) % selectable.len()
180 };
181 selectable.get(next).copied()
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crossterm::event::{KeyEvent, KeyModifiers};
188
189 struct TestItem(String);
190
191 impl SelectItem for TestItem {
192 fn render_item(&self, _selected: bool, _ctx: &ViewContext) -> Line {
193 Line::new(self.0.clone())
194 }
195 }
196
197 fn items(names: &[&str]) -> Vec<TestItem> {
198 names.iter().map(|n| TestItem((*n).to_string())).collect()
199 }
200
201 fn key(code: KeyCode) -> Event {
202 Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
203 }
204
205 #[tokio::test]
206 async fn navigation_wraps_down() {
207 let mut list = SelectList::new(items(&["a", "b", "c"]), "empty");
208 assert_eq!(list.selected_index(), 0);
209
210 list.on_event(&key(KeyCode::Down)).await;
211 assert_eq!(list.selected_index(), 1);
212
213 list.on_event(&key(KeyCode::Down)).await;
214 list.on_event(&key(KeyCode::Down)).await;
215 assert_eq!(list.selected_index(), 0);
216 }
217
218 #[tokio::test]
219 async fn navigation_wraps_up() {
220 let mut list = SelectList::new(items(&["a", "b", "c"]), "empty");
221 list.on_event(&key(KeyCode::Up)).await;
222 assert_eq!(list.selected_index(), 2);
223 }
224
225 #[tokio::test]
226 async fn esc_emits_close() {
227 let mut list = SelectList::new(items(&["a"]), "empty");
228 let outcome = list.on_event(&key(KeyCode::Esc)).await;
229 assert!(matches!(outcome.unwrap().as_slice(), [SelectListMessage::Close]));
230 }
231
232 #[tokio::test]
233 async fn enter_emits_select_with_index() {
234 let mut list = SelectList::new(items(&["a", "b", "c"]), "empty");
235 list.on_event(&key(KeyCode::Down)).await;
236 let outcome = list.on_event(&key(KeyCode::Enter)).await;
237 match outcome.unwrap().as_slice() {
238 [SelectListMessage::Select(idx)] => assert_eq!(*idx, 1),
239 other => panic!("expected Select(1), got {other:?}"),
240 }
241 }
242
243 #[tokio::test]
244 async fn enter_on_empty_is_noop() {
245 let mut list: SelectList<TestItem> = SelectList::new(vec![], "empty");
246 let outcome = list.on_event(&key(KeyCode::Enter)).await;
247 assert!(outcome.unwrap().is_empty());
248 }
249
250 #[test]
251 fn empty_list_shows_placeholder() {
252 let mut list: SelectList<TestItem> = SelectList::new(vec![], "no items");
253 let ctx = ViewContext::new((80, 24));
254 let frame = list.render(&ctx);
255 assert_eq!(frame.lines().len(), 1);
256 assert!(frame.lines()[0].plain_text().contains("no items"));
257 }
258
259 #[test]
260 fn render_shows_selected_indicator() {
261 let mut list = SelectList::new(items(&["alpha", "beta"]), "empty");
262 let ctx = ViewContext::new((80, 24));
263 let frame = list.render(&ctx);
264 assert_eq!(frame.lines().len(), 2);
265 assert!(frame.lines()[0].plain_text().starts_with(" "));
266 assert!(frame.lines()[1].plain_text().starts_with(" "));
267 }
268
269 #[tokio::test]
270 async fn set_items_clamps_index() {
271 let mut list = SelectList::new(items(&["a", "b", "c"]), "empty");
272 list.on_event(&key(KeyCode::Down)).await;
273 list.on_event(&key(KeyCode::Down)).await;
274 assert_eq!(list.selected_index(), 2);
275
276 list.set_items(items(&["x"]));
277 assert_eq!(list.selected_index(), 0);
278 }
279
280 #[tokio::test]
281 async fn set_items_preserves_index_when_in_range() {
282 let mut list = SelectList::new(items(&["a", "b", "c"]), "empty");
283 list.on_event(&key(KeyCode::Down)).await;
284 assert_eq!(list.selected_index(), 1);
285
286 list.set_items(items(&["x", "y", "z"]));
287 assert_eq!(list.selected_index(), 1);
288 }
289
290 #[test]
291 fn push_adds_item() {
292 let mut list = SelectList::new(items(&["a"]), "empty");
293 list.push(TestItem("b".to_string()));
294 assert_eq!(list.len(), 2);
295 }
296
297 #[tokio::test]
298 async fn tick_events_are_ignored() {
299 let mut list = SelectList::new(items(&["a"]), "empty");
300 let outcome = list.on_event(&Event::Tick).await;
301 assert!(outcome.is_none());
302 }
303
304 #[tokio::test]
305 async fn mouse_scroll_moves_selection() {
306 use crossterm::event::{MouseEvent, MouseEventKind};
307 let mut list = SelectList::new(items(&["a", "b", "c"]), "empty");
308 assert_eq!(list.selected_index(), 0);
309
310 let scroll_down = Event::Mouse(MouseEvent {
311 kind: MouseEventKind::ScrollDown,
312 column: 0,
313 row: 0,
314 modifiers: KeyModifiers::NONE,
315 });
316 list.on_event(&scroll_down).await;
317 assert_eq!(list.selected_index(), 1);
318
319 let scroll_up = Event::Mouse(MouseEvent {
320 kind: MouseEventKind::ScrollUp,
321 column: 0,
322 row: 0,
323 modifiers: KeyModifiers::NONE,
324 });
325 list.on_event(&scroll_up).await;
326 assert_eq!(list.selected_index(), 0);
327 }
328
329 #[tokio::test]
330 async fn retain_removes_items_and_clamps_index() {
331 let mut list = SelectList::new(items(&["a", "b", "c"]), "empty");
332 list.on_event(&key(KeyCode::Down)).await;
333 list.on_event(&key(KeyCode::Down)).await;
334 assert_eq!(list.selected_index(), 2);
335
336 list.retain(|item| item.0 != "c");
337 assert_eq!(list.len(), 2);
338 assert_eq!(list.selected_index(), 1);
339 }
340
341 #[test]
342 fn retain_to_empty_clamps_to_zero() {
343 let mut list = SelectList::new(items(&["a"]), "empty");
344 list.retain(|_| false);
345 assert!(list.is_empty());
346 assert_eq!(list.selected_index(), 0);
347 }
348
349 #[test]
350 fn items_mut_allows_mutation_but_not_length_change() {
351 let mut list = SelectList::new(items(&["a", "b"]), "empty");
352 list.items_mut()[0] = TestItem("x".to_string());
353 assert_eq!(list.items()[0].0, "x");
354 assert_eq!(list.len(), 2);
355 }
356}