Skip to main content

tui/components/
select_list.rs

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    /// Select the item at `index`. No-op if out of bounds or not selectable.
61    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    /// Reselect by name/identity using a predicate. Falls back to first selectable item.
68    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}