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