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