tui/components/
multi_select.rs1use 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
9pub 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); }
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}