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
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}