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 { 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); }
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}