Skip to main content

tui/components/
select_list.rs

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}