Skip to main content

tui/components/
multi_select.rs

1use crossterm::event::KeyCode;
2
3use super::select_option::SelectOption;
4use crate::components::{Component, Event, ViewContext};
5use crate::line::Line;
6use crate::rendering::frame::Frame;
7use crate::style::Style;
8
9/// Multi-select from a list of options, rendered as checkboxes with a cursor.
10pub struct MultiSelect {
11    pub options: Vec<SelectOption>,
12    pub selected: Vec<bool>,
13    pub cursor: usize,
14}
15
16impl MultiSelect {
17    pub fn new(options: Vec<SelectOption>, selected: Vec<bool>) -> Self {
18        Self { cursor: 0, options, selected }
19    }
20
21    pub fn to_json(&self) -> serde_json::Value {
22        let values: Vec<serde_json::Value> = self
23            .options
24            .iter()
25            .zip(self.selected.iter())
26            .filter(|&(_, &s)| s)
27            .map(|(o, _)| serde_json::Value::String(o.value.clone()))
28            .collect();
29        serde_json::Value::Array(values)
30    }
31
32    fn render_inline(&self, context: &ViewContext) -> Line {
33        let chosen: Vec<&str> =
34            self.options.iter().zip(self.selected.iter()).filter(|&(_, &s)| s).map(|(o, _)| o.title.as_str()).collect();
35
36        if chosen.is_empty() {
37            Line::styled("(none)", context.theme.muted())
38        } else {
39            Line::styled(chosen.join(", "), context.theme.info())
40        }
41    }
42
43    fn render_options(&self, context: &ViewContext) -> Vec<Line> {
44        self.options
45            .iter()
46            .enumerate()
47            .map(|(j, opt)| {
48                let marker = if self.selected[j] { "[x] " } else { "[ ] " };
49                let is_cursor = j == self.cursor;
50                let style = if is_cursor {
51                    Style::fg(context.theme.primary()).bold()
52                } else if self.selected[j] {
53                    Style::fg(context.theme.primary())
54                } else {
55                    Style::default()
56                };
57                let desc = opt.description.as_deref().map(|d| format!(" - {d}")).unwrap_or_default();
58                Line::with_style(format!("{marker}{}{desc}", opt.title), style)
59            })
60            .collect()
61    }
62}
63
64impl Component for MultiSelect {
65    type Message = ();
66
67    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
68        let Event::Key(key) = event else {
69            return None;
70        };
71        if self.options.is_empty() {
72            return None;
73        }
74
75        match key.code {
76            KeyCode::Char(' ') => {
77                self.selected[self.cursor] = !self.selected[self.cursor];
78                Some(vec![])
79            }
80            KeyCode::Up => {
81                self.cursor = (self.cursor + self.options.len() - 1) % self.options.len();
82                Some(vec![])
83            }
84            KeyCode::Down => {
85                self.cursor = (self.cursor + 1) % self.options.len();
86                Some(vec![])
87            }
88            _ => None,
89        }
90    }
91
92    fn render(&mut self, context: &ViewContext) -> Frame {
93        Frame::new(self.render_field(context, true))
94    }
95}
96
97impl MultiSelect {
98    pub fn render_field(&self, context: &ViewContext, focused: bool) -> Vec<Line> {
99        if focused { self.render_options(context) } else { vec![self.render_inline(context)] }
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crossterm::event::{KeyEvent, KeyModifiers};
107
108    fn key(code: KeyCode) -> KeyEvent {
109        KeyEvent::new(code, KeyModifiers::NONE)
110    }
111
112    fn sample() -> MultiSelect {
113        MultiSelect::new(
114            vec![
115                SelectOption { value: "a".into(), title: "Alpha".into(), description: None },
116                SelectOption { value: "b".into(), title: "Beta".into(), description: None },
117                SelectOption { value: "c".into(), title: "Gamma".into(), description: None },
118            ],
119            vec![false, false, false],
120        )
121    }
122
123    #[tokio::test]
124    async fn space_toggles_at_cursor() {
125        let mut ms = sample();
126        ms.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;
127        assert!(ms.selected[0]);
128        ms.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;
129        assert!(!ms.selected[0]);
130    }
131
132    #[tokio::test]
133    async fn cursor_moves_with_arrows() {
134        let mut ms = sample();
135        ms.on_event(&Event::Key(key(KeyCode::Down))).await;
136        assert_eq!(ms.cursor, 1);
137        ms.on_event(&Event::Key(key(KeyCode::Char(' ')))).await;
138        assert!(ms.selected[1]);
139    }
140
141    #[test]
142    fn to_json_returns_selected_values() {
143        let mut ms = sample();
144        ms.selected[0] = true;
145        ms.selected[2] = true;
146        assert_eq!(ms.to_json(), serde_json::json!(["a", "c"]));
147    }
148
149    #[test]
150    fn to_json_empty_selection() {
151        let ms = sample();
152        assert_eq!(ms.to_json(), serde_json::json!([]));
153    }
154
155    #[tokio::test]
156    async fn cursor_wraps() {
157        let mut ms = sample();
158        ms.on_event(&Event::Key(key(KeyCode::Up))).await;
159        assert_eq!(ms.cursor, 2); // wraps to end
160    }
161
162    #[tokio::test]
163    async fn left_right_ignored() {
164        let mut ms = sample();
165        let outcome = ms.on_event(&Event::Key(key(KeyCode::Right))).await;
166        assert!(outcome.is_none());
167        assert_eq!(ms.cursor, 0);
168        let outcome = ms.on_event(&Event::Key(key(KeyCode::Left))).await;
169        assert!(outcome.is_none());
170        assert_eq!(ms.cursor, 0);
171    }
172}