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