cli_ui/prompt/
multiselect.rs1use super::core::{Frame, Key, Prompt, RenderCtx, Step};
4use super::error::{PromptError, Result};
5use super::limit_options::limit_options;
6use super::theme;
7
8#[derive(Clone)]
9struct MsOption {
10 value: String,
11 label: String,
12 hint: Option<String>,
13 checked: bool,
14}
15
16#[derive(Debug, Clone)]
18pub struct MultiSelected {
19 items: Vec<(usize, String)>,
20}
21
22impl MultiSelected {
23 pub fn iter(&self) -> impl Iterator<Item = (usize, &str)> {
26 self.items.iter().map(|(i, v)| (*i, v.as_str()))
27 }
28 pub fn indices(&self) -> Vec<usize> {
30 self.items.iter().map(|(i, _)| *i).collect()
31 }
32 pub fn values(&self) -> Vec<String> {
35 self.items.iter().map(|(_, v)| v.clone()).collect()
36 }
37 pub fn is_empty(&self) -> bool {
39 self.items.is_empty()
40 }
41 pub fn len(&self) -> usize {
43 self.items.len()
44 }
45}
46
47pub struct MultiSelectPrompt {
49 label: String,
50 options: Vec<MsOption>,
51 required: bool,
52 prompt_hint: Option<String>,
53 max_items: usize,
54 cur: usize,
55}
56
57pub fn multiselect(label: impl Into<String>) -> MultiSelectPrompt {
70 MultiSelectPrompt {
71 label: label.into(),
72 options: Vec::new(),
73 required: false,
74 prompt_hint: None,
75 max_items: 10,
76 cur: 0,
77 }
78}
79
80impl MultiSelectPrompt {
81 pub fn option(mut self, value: impl Into<String>, label: impl Into<String>) -> Self {
84 self.options.push(MsOption {
85 value: value.into(),
86 label: label.into(),
87 hint: None,
88 checked: false,
89 });
90 self
91 }
92 pub fn hint(mut self, text: impl Into<String>) -> Self {
94 if let Some(last) = self.options.last_mut() {
95 last.hint = Some(text.into());
96 }
97 self
98 }
99 pub fn checked(mut self) -> Self {
101 if let Some(last) = self.options.last_mut() {
102 last.checked = true;
103 }
104 self
105 }
106 pub fn prompt_hint(mut self, t: impl Into<String>) -> Self {
108 self.prompt_hint = Some(t.into());
109 self
110 }
111 pub fn required(mut self, r: bool) -> Self {
113 self.required = r;
114 self
115 }
116 pub fn max_items(mut self, n: usize) -> Self {
118 self.max_items = n.max(1);
119 self
120 }
121
122 pub fn run(self) -> Result<MultiSelected> {
124 assert!(!self.options.is_empty(), "multiselect: no options added");
125 super::core::run(self)
126 }
127
128 fn collect(&self) -> MultiSelected {
129 MultiSelected {
130 items: self
131 .options
132 .iter()
133 .enumerate()
134 .filter(|(_, o)| o.checked)
135 .map(|(i, o)| (i, o.value.clone()))
136 .collect(),
137 }
138 }
139}
140
141impl Prompt for MultiSelectPrompt {
142 type Output = MultiSelected;
143
144 fn handle(&mut self, key: Key) -> Step<MultiSelected> {
145 let vim = super::settings::get().vim_keys;
146 let last = self.options.len() - 1;
147 match key {
148 Key::Up | Key::Char('k') if vim => {
149 if self.cur > 0 {
150 self.cur -= 1;
151 }
152 Step::Continue
153 }
154 Key::Down | Key::Char('j') if vim => {
155 if self.cur < last {
156 self.cur += 1;
157 }
158 Step::Continue
159 }
160 Key::Up => {
161 if self.cur > 0 {
162 self.cur -= 1;
163 }
164 Step::Continue
165 }
166 Key::Down => {
167 if self.cur < last {
168 self.cur += 1;
169 }
170 Step::Continue
171 }
172 Key::Char(' ') => {
173 self.options[self.cur].checked ^= true;
174 Step::Continue
175 }
176 Key::Char('q') if vim => Step::Cancel,
177 Key::Enter => {
178 let result = self.collect();
179 if self.required && result.is_empty() {
180 Step::Reject("Select at least one option".into())
181 } else {
182 Step::Submit(result)
183 }
184 }
185 Key::Escape | Key::Interrupt => Step::Cancel,
186 _ => Step::Continue,
187 }
188 }
189
190 fn render(&self, _ctx: RenderCtx) -> Frame {
191 let mut f: Frame = Vec::with_capacity(self.max_items + 5);
192 f.push(theme::label(&self.label));
193 if let Some(ref h) = self.prompt_hint {
194 f.push(theme::hint(h));
195 }
196 f.push(theme::hint(""));
197 let vp = limit_options(self.options.len(), self.cur, self.max_items);
198 if vp.above > 0 {
199 f.push(theme::hint(&format!("↑ {} more", vp.above)));
200 }
201 for i in vp.start..vp.end {
202 let opt = &self.options[i];
203 f.push(theme::multi_option(
204 i == self.cur,
205 opt.checked,
206 &opt.label,
207 opt.hint.as_deref(),
208 ));
209 }
210 if vp.below > 0 {
211 f.push(theme::hint(&format!("↓ {} more", vp.below)));
212 }
213 f.push(theme::hint("Space to toggle · Enter to confirm"));
214 f.push(theme::frame_bot(None));
215 f
216 }
217
218 fn render_answered(&self, value: &MultiSelected) -> Frame {
219 let summary: Vec<&str> = value
220 .items
221 .iter()
222 .map(|(i, _)| self.options[*i].label.as_str())
223 .collect();
224 let val = if summary.is_empty() {
225 "none".to_string()
226 } else {
227 summary.join(", ")
228 };
229 theme::answered(&self.label, &val)
230 .split("\r\n")
231 .map(String::from)
232 .collect()
233 }
234
235 fn run_fallback(self) -> Result<MultiSelected> {
236 use std::io::Write;
237 let mut out = std::io::stderr();
238 writeln!(out, " {}", self.label).map_err(PromptError::Io)?;
239 for (i, opt) in self.options.iter().enumerate() {
240 let mark = if opt.checked { "●" } else { "○" };
241 match &opt.hint {
242 Some(h) => writeln!(out, " {}. {} {} ({})", i + 1, mark, opt.label, h),
243 None => writeln!(out, " {}. {} {}", i + 1, mark, opt.label),
244 }
245 .map_err(PromptError::Io)?;
246 }
247 writeln!(out, " Enter numbers separated by spaces:").map_err(PromptError::Io)?;
248 out.flush().map_err(PromptError::Io)?;
249 let line = super::engine::fallback::read_line_raw()?;
250 let indices: Vec<usize> = line
251 .split_whitespace()
252 .filter_map(|t| t.parse::<usize>().ok())
253 .filter(|&n| n >= 1 && n <= self.options.len())
254 .map(|n| n - 1)
255 .collect();
256 Ok(MultiSelected {
257 items: indices
258 .into_iter()
259 .map(|i| (i, self.options[i].value.clone()))
260 .collect(),
261 })
262 }
263}