Skip to main content

cli_ui/prompt/
multiselect.rs

1//! Multi-choice select prompt — built on the [`Prompt`] trait.
2
3use 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/// Set of options the user checked off, returned by [`multiselect`].
17#[derive(Debug, Clone)]
18pub struct MultiSelected {
19    items: Vec<(usize, String)>,
20}
21
22impl MultiSelected {
23    /// Iterate over `(index, value)` pairs for every checked option, in
24    /// the order they were added.
25    pub fn iter(&self) -> impl Iterator<Item = (usize, &str)> {
26        self.items.iter().map(|(i, v)| (*i, v.as_str()))
27    }
28    /// Zero-based indices of every checked option.
29    pub fn indices(&self) -> Vec<usize> {
30        self.items.iter().map(|(i, _)| *i).collect()
31    }
32    /// Machine values (the first argument to `.option(...)`) of every
33    /// checked option.
34    pub fn values(&self) -> Vec<String> {
35        self.items.iter().map(|(_, v)| v.clone()).collect()
36    }
37    /// `true` if the user submitted with nothing checked.
38    pub fn is_empty(&self) -> bool {
39        self.items.is_empty()
40    }
41    /// Number of checked options.
42    pub fn len(&self) -> usize {
43        self.items.len()
44    }
45}
46
47/// Builder returned by [`multiselect()`].
48pub 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
57/// Ask the user to check any number of options from a list. Space toggles,
58/// Enter submits.
59///
60/// ```no_run
61/// use cli_ui::prompt::multiselect;
62///
63/// let features = multiselect("Pick features")
64///     .option("auth",   "Authentication").checked()
65///     .option("admin",  "Admin panel")
66///     .run()?;
67/// # Ok::<(), cli_ui::prompt::PromptError>(())
68/// ```
69pub 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    /// Append an option. Chain `.hint("…")` and/or `.checked()` immediately
82    /// after to configure the option just added.
83    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    /// Attach a hint to the most recently added option.
93    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    /// Mark the most recently added option as initially checked.
100    pub fn checked(mut self) -> Self {
101        if let Some(last) = self.options.last_mut() {
102            last.checked = true;
103        }
104        self
105    }
106    /// One-line hint shown under the prompt label.
107    pub fn prompt_hint(mut self, t: impl Into<String>) -> Self {
108        self.prompt_hint = Some(t.into());
109        self
110    }
111    /// When `true`, refuse to submit until at least one option is checked.
112    pub fn required(mut self, r: bool) -> Self {
113        self.required = r;
114        self
115    }
116    /// Number of option rows visible at once.
117    pub fn max_items(mut self, n: usize) -> Self {
118        self.max_items = n.max(1);
119        self
120    }
121
122    /// Run the prompt to completion. Panics if no options were added.
123    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}