promptuity/prompts/
multi_select.rs

1use crate::event::*;
2use crate::pagination::paginate;
3use crate::prompts::{DefaultSelectFormatter, SelectFormatter};
4use crate::{Error, Prompt, PromptBody, PromptInput, PromptState, RenderPayload};
5
6/// A struct representing an option in the [`MultiSelect`] prompt.
7#[derive(Debug, Clone)]
8pub struct MultiSelectOption<T: Default + Clone> {
9    /// The label of the option.
10    pub label: String,
11    /// The value of the option.
12    pub value: T,
13    /// The hint of the option.
14    pub hint: Option<String>,
15    /// The selected flag of the option.
16    pub selected: bool,
17}
18
19impl<T: Default + Clone> MultiSelectOption<T> {
20    /// Creates a new [`MultiSelectOption`] with the given label and value.
21    pub fn new(label: impl std::fmt::Display, value: T) -> Self {
22        Self {
23            label: label.to_string(),
24            value,
25            hint: None,
26            selected: false,
27        }
28    }
29
30    /// Sets the hint message for the option.
31    pub fn with_hint(mut self, hint: impl std::fmt::Display) -> Self {
32        self.hint = Some(hint.to_string());
33        self
34    }
35}
36
37/// A trait for formatting the [`MultiSelect`] prompt.
38///
39/// `MultiSelectFormatter` does not default-implement some of the formatting processes in the trait.  
40/// In the implementation of [`DefaultMultiSelectFormatter`], it internally uses [`DefaultSelectFormatter`] for formatting. Please refer to this when implementing custom formats.
41///
42/// # Examples
43///
44/// ```no_run
45/// use promptuity::prompts::{MultiSelect, MultiSelectOption, MultiSelectFormatter};
46///
47/// struct CustomFormatter;
48///
49/// impl MultiSelectFormatter for CustomFormatter {
50///     fn option_icon(&self, active: bool, selected: bool) -> String {
51///        if selected {
52///            "[x]".into()
53///        } else if active {
54///            " > ".into()
55///        } else {
56///            "[ ]".into()
57///        }
58///     }
59///
60///     fn option_label(&self, label: String, _active: bool, _selected: bool) -> String {
61///         label.clone()
62///     }
63///
64///     fn option_hint(&self, hint: Option<String>, active: bool, _selected: bool) -> String {
65///         if active {
66///             hint.as_ref().map_or_else(String::new, |hint| {
67///                 format!(" - {}", hint)
68///             })
69///         } else {
70///             String::new()
71///         }
72///     }
73///
74///     fn option(&self, icon: String, label: String, hint: String, _active: bool, _selected: bool) -> String {
75///         format!("{} {}{}", icon, label, hint)
76///     }
77/// }
78///
79/// let _ = MultiSelect::new("...", vec![MultiSelectOption::new("...", "...")]).with_formatter(CustomFormatter);
80/// ```
81pub trait MultiSelectFormatter {
82    /// Icons displayed for each option.
83    fn option_icon(&self, active: bool, selected: bool) -> String;
84    /// Formats the label of the option.
85    fn option_label(&self, label: String, active: bool, selected: bool) -> String;
86    /// Formats the hint message of the option.
87    fn option_hint(&self, hint: Option<String>, active: bool, selected: bool) -> String;
88    /// Formats the option.
89    fn option(
90        &self,
91        icon: String,
92        label: String,
93        hint: String,
94        active: bool,
95        selected: bool,
96    ) -> String;
97
98    /// Formats the submitted value.
99    fn submit(&self, labels: Vec<String>) -> String {
100        labels.join(", ")
101    }
102
103    /// Formats the error message for the required flag.
104    fn err_required(&self) -> String {
105        "This field is required.".into()
106    }
107
108    /// Formats errors for selections below the minimum number.
109    fn err_min(&self, min: usize) -> String {
110        format!("Please select at least {} options.", min)
111    }
112
113    /// Formats errors for selections above the maximum number.
114    fn err_max(&self, max: usize) -> String {
115        format!("Please select no more than {} options.", max)
116    }
117}
118
119/// The default formatter for [`MultiSelect`].
120pub struct DefaultMultiSelectFormatter {
121    inner: DefaultSelectFormatter,
122}
123
124impl DefaultMultiSelectFormatter {
125    #[allow(clippy::new_without_default)]
126    pub fn new() -> Self {
127        Self {
128            inner: DefaultSelectFormatter::new(),
129        }
130    }
131}
132
133impl MultiSelectFormatter for DefaultMultiSelectFormatter {
134    fn option_icon(&self, _active: bool, selected: bool) -> String {
135        self.inner.option_icon(selected)
136    }
137
138    fn option_label(&self, label: String, active: bool, _selected: bool) -> String {
139        self.inner.option_label(label, active)
140    }
141
142    fn option_hint(&self, hint: Option<String>, active: bool, _selected: bool) -> String {
143        self.inner.option_hint(hint, active)
144    }
145
146    fn option(
147        &self,
148        icon: String,
149        label: String,
150        hint: String,
151        active: bool,
152        _selected: bool,
153    ) -> String {
154        self.inner.option(icon, label, hint, active)
155    }
156}
157
158/// A prompt for selecting multiple elements from a list of options.
159///
160/// # Options
161///
162/// - **Formatter**: Customizes the prompt display. See [`MultiSelectFormatter`].
163/// - **Hint**: A message to assist with field input. Defaults to `None`.
164/// - **Required**: A flag indicating whether to allow no input.
165/// - **Minimum Selections**: The minimum number of selections required. Defaults to `0`.
166/// - **Maximum Selections**: The maximum number of selections allowed. Defaults to `usize::MAX`.
167/// - **Page Size**: The total number of options to displayed per page, used for pagination. Defaults to `8`.
168/// - **Validator**: A function to validate the value at the time of submission.
169///
170/// # Examples
171///
172/// ```no_run
173/// use promptuity::prompts::{MultiSelect, MultiSelectOption};
174///
175/// let _ = MultiSelect::new("What is your favorite color?", vec![
176///     MultiSelectOption::new("Red", "#ff0000"),
177///     MultiSelectOption::new("Green", "#00ff00").with_hint("recommended"),
178///     MultiSelectOption::new("Blue", "#0000ff"),
179/// ]).with_page_size(5);
180/// ```
181pub struct MultiSelect<T: Default + Clone> {
182    formatter: Box<dyn MultiSelectFormatter>,
183    message: String,
184    hint: Option<String>,
185    required: bool,
186    min: usize,
187    max: usize,
188    page_size: usize,
189    options: Vec<MultiSelectOption<T>>,
190    index: usize,
191}
192
193impl<T: Default + Clone> MultiSelect<T> {
194    /// Creates a new [`MultiSelect`] prompt with the given message and options.
195    pub fn new(message: impl std::fmt::Display, options: Vec<MultiSelectOption<T>>) -> Self {
196        Self {
197            formatter: Box::new(DefaultMultiSelectFormatter::new()),
198            message: message.to_string(),
199            hint: None,
200            required: true,
201            min: 0,
202            max: usize::MAX,
203            page_size: 8,
204            options,
205            index: 0,
206        }
207    }
208
209    /// Sets the formatter for the prompt.
210    pub fn with_formatter(&mut self, formatter: impl MultiSelectFormatter + 'static) -> &mut Self {
211        self.formatter = Box::new(formatter);
212        self
213    }
214
215    /// Sets the hint message for the prompt.
216    pub fn with_hint(&mut self, hint: impl std::fmt::Display) -> &mut Self {
217        self.hint = Some(hint.to_string());
218        self
219    }
220
221    /// Sets the required flag for the prompt.
222    pub fn with_required(&mut self, required: bool) -> &mut Self {
223        self.required = required;
224        self
225    }
226
227    /// Sets the minimum number of selections for the prompt.
228    pub fn with_min(&mut self, value: usize) -> &mut Self {
229        self.min = value;
230        self
231    }
232
233    /// Sets the maximum number of selections for the prompt.
234    pub fn with_max(&mut self, value: usize) -> &mut Self {
235        self.max = value;
236        self
237    }
238
239    /// Sets the page size for the prompt.
240    pub fn with_page_size(&mut self, page_size: usize) -> &mut Self {
241        self.page_size = page_size;
242        self
243    }
244
245    fn values(&mut self) -> Vec<T> {
246        self.options
247            .iter()
248            .filter_map(|option| {
249                if option.selected {
250                    Some(option.value.clone())
251                } else {
252                    None
253                }
254            })
255            .collect::<Vec<_>>()
256    }
257
258    fn map_options<F>(&mut self, f: F) -> Vec<MultiSelectOption<T>>
259    where
260        F: Fn(&MultiSelectOption<T>) -> MultiSelectOption<T>,
261    {
262        self.options.iter().map(f).collect::<Vec<_>>()
263    }
264}
265
266impl<T: Default + Clone> AsMut<MultiSelect<T>> for MultiSelect<T> {
267    fn as_mut(&mut self) -> &mut MultiSelect<T> {
268        self
269    }
270}
271
272impl<T: Default + Clone> Prompt for MultiSelect<T> {
273    type Output = Vec<T>;
274
275    fn setup(&mut self) -> Result<(), Error> {
276        if self.options.is_empty() {
277            return Err(Error::Config("options cannot be empty.".into()));
278        }
279
280        if self.min > self.max {
281            return Err(Error::Config(format!(
282                "min cannot be greater than max (min={}, max={})",
283                self.min, self.max
284            )));
285        }
286
287        Ok(())
288    }
289
290    fn handle(&mut self, code: KeyCode, modifiers: KeyModifiers) -> crate::PromptState {
291        match (code, modifiers) {
292            (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => PromptState::Cancel,
293            (KeyCode::Enter, _) => {
294                let values = self.values();
295                if values.is_empty() && self.required {
296                    PromptState::Error(self.formatter.err_required())
297                } else if values.len() < self.min {
298                    PromptState::Error(self.formatter.err_min(self.min))
299                } else if values.len() > self.max {
300                    PromptState::Error(self.formatter.err_max(self.max))
301                } else {
302                    PromptState::Submit
303                }
304            }
305            (KeyCode::Up, _)
306            | (KeyCode::Char('k'), _)
307            | (KeyCode::Char('p'), KeyModifiers::CONTROL) => {
308                self.index = self.index.saturating_sub(1);
309                PromptState::Active
310            }
311            (KeyCode::Down, _)
312            | (KeyCode::Char('j'), _)
313            | (KeyCode::Char('n'), KeyModifiers::CONTROL) => {
314                self.index = std::cmp::min(
315                    self.options.len().saturating_sub(1),
316                    self.index.saturating_add(1),
317                );
318                PromptState::Active
319            }
320            (KeyCode::Char(' '), KeyModifiers::NONE) => {
321                let mut option = self.options.get(self.index).unwrap().clone();
322                option.selected = !option.selected;
323                self.options[self.index] = option;
324                PromptState::Active
325            }
326            (KeyCode::Char('a'), KeyModifiers::NONE) => {
327                self.options = self.map_options(|option| {
328                    let mut option = option.clone();
329                    option.selected = true;
330                    option
331                });
332                PromptState::Active
333            }
334            (KeyCode::Char('i'), KeyModifiers::NONE) => {
335                self.options = self.map_options(|option| {
336                    let mut option = option.clone();
337                    option.selected = !option.selected;
338                    option
339                });
340                PromptState::Active
341            }
342            _ => PromptState::Active,
343        }
344    }
345
346    fn submit(&mut self) -> Self::Output {
347        self.values()
348    }
349
350    fn render(&mut self, state: &PromptState) -> Result<RenderPayload, String> {
351        let payload = RenderPayload::new(self.message.clone(), self.hint.clone(), None);
352
353        match state {
354            PromptState::Submit => {
355                let raw = self.formatter.submit(
356                    self.options
357                        .iter()
358                        .filter_map(|option| {
359                            if option.selected {
360                                Some(option.label.clone())
361                            } else {
362                                None
363                            }
364                        })
365                        .collect::<Vec<_>>(),
366                );
367                Ok(payload.input(PromptInput::Raw(raw)))
368            }
369
370            _ => {
371                let page = paginate(self.page_size, &self.options, self.index);
372                let options = page
373                    .items
374                    .iter()
375                    .enumerate()
376                    .map(|(i, option)| {
377                        let active = i == page.cursor;
378                        let selected = option.selected;
379                        self.formatter.option(
380                            self.formatter.option_icon(active, selected),
381                            self.formatter
382                                .option_label(option.label.clone(), active, selected),
383                            self.formatter
384                                .option_hint(option.hint.clone(), active, selected),
385                            active,
386                            selected,
387                        )
388                    })
389                    .collect::<Vec<_>>()
390                    .join("\n");
391
392                Ok(payload.body(PromptBody::Raw(options)))
393            }
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::test_prompt;
402
403    macro_rules! options {
404        ($count: expr) => {{
405            let mut options = Vec::new();
406            for i in 1..=$count {
407                options.push(MultiSelectOption::new(
408                    format!("Value{}", i),
409                    format!("value{}", i),
410                ));
411            }
412            options
413        }};
414    }
415
416    test_prompt!(
417        test_hint,
418        MultiSelect::new("test message", options!(3)).with_hint("hint message"),
419        vec![]
420    );
421
422    test_prompt!(
423        test_10_items_with_5_page_size,
424        MultiSelect::new("test message", options!(10)).with_page_size(5),
425        vec![]
426    );
427
428    test_prompt!(
429        test_option_hint,
430        MultiSelect::new(
431            "test message",
432            vec![
433                MultiSelectOption::new("Value1", "value1".to_string()).with_hint("hint1"),
434                MultiSelectOption::new("Value2", "value2".to_string()),
435                MultiSelectOption::new("Value3", "value3".to_string()).with_hint("hint3"),
436            ]
437        )
438        .with_page_size(5),
439        vec![]
440    );
441
442    test_prompt!(
443        test_move,
444        MultiSelect::new("test message", options!(10)).with_page_size(5),
445        vec![
446            (KeyCode::Char('j'), KeyModifiers::NONE),
447            (KeyCode::Char('n'), KeyModifiers::CONTROL),
448            (KeyCode::Char('k'), KeyModifiers::NONE),
449            (KeyCode::Char('p'), KeyModifiers::CONTROL),
450            (KeyCode::Down, KeyModifiers::NONE),
451            (KeyCode::Down, KeyModifiers::NONE),
452            (KeyCode::Down, KeyModifiers::NONE),
453            (KeyCode::Down, KeyModifiers::NONE),
454            (KeyCode::Down, KeyModifiers::NONE),
455            (KeyCode::Down, KeyModifiers::NONE),
456            (KeyCode::Down, KeyModifiers::NONE),
457            (KeyCode::Down, KeyModifiers::NONE),
458            (KeyCode::Down, KeyModifiers::NONE), // 10
459            (KeyCode::Down, KeyModifiers::NONE),
460            (KeyCode::Up, KeyModifiers::NONE),
461            (KeyCode::Up, KeyModifiers::NONE),
462            (KeyCode::Up, KeyModifiers::NONE),
463            (KeyCode::Up, KeyModifiers::NONE),
464            (KeyCode::Up, KeyModifiers::NONE),
465            (KeyCode::Up, KeyModifiers::NONE),
466            (KeyCode::Up, KeyModifiers::NONE),
467            (KeyCode::Up, KeyModifiers::NONE),
468            (KeyCode::Up, KeyModifiers::NONE), // 1
469            (KeyCode::Up, KeyModifiers::NONE),
470        ]
471    );
472
473    test_prompt!(
474        test_select_2_and_5,
475        MultiSelect::new("test message", options!(10)).with_page_size(5),
476        vec![
477            (KeyCode::Down, KeyModifiers::NONE),
478            (KeyCode::Char(' '), KeyModifiers::NONE),
479            (KeyCode::Down, KeyModifiers::NONE),
480            (KeyCode::Down, KeyModifiers::NONE),
481            (KeyCode::Down, KeyModifiers::NONE),
482            (KeyCode::Char(' '), KeyModifiers::NONE),
483            (KeyCode::Enter, KeyModifiers::NONE),
484        ]
485    );
486
487    test_prompt!(
488        test_select_all_and_inverse,
489        MultiSelect::new("test message", options!(5)).as_mut(),
490        vec![
491            (KeyCode::Char('a'), KeyModifiers::NONE),
492            (KeyCode::Char('i'), KeyModifiers::NONE),
493            (KeyCode::Down, KeyModifiers::NONE),
494            (KeyCode::Char(' '), KeyModifiers::NONE),
495            (KeyCode::Down, KeyModifiers::NONE),
496            (KeyCode::Char(' '), KeyModifiers::NONE),
497            (KeyCode::Char('i'), KeyModifiers::NONE),
498            (KeyCode::Enter, KeyModifiers::NONE),
499        ]
500    );
501
502    test_prompt!(
503        test_required_error,
504        MultiSelect::new("test message", options!(5)).with_required(true),
505        vec![(KeyCode::Enter, KeyModifiers::NONE)]
506    );
507
508    test_prompt!(
509        test_non_required_empty_submit,
510        MultiSelect::new("test message", options!(5)).with_required(false),
511        vec![(KeyCode::Enter, KeyModifiers::NONE)]
512    );
513
514    test_prompt!(
515        test_min_error,
516        MultiSelect::new("test message", options!(5)).with_min(2),
517        vec![
518            (KeyCode::Enter, KeyModifiers::NONE),
519            (KeyCode::Char(' '), KeyModifiers::NONE),
520            (KeyCode::Enter, KeyModifiers::NONE),
521            (KeyCode::Down, KeyModifiers::NONE),
522            (KeyCode::Char(' '), KeyModifiers::NONE),
523            (KeyCode::Enter, KeyModifiers::NONE),
524        ]
525    );
526
527    test_prompt!(
528        test_max_error,
529        MultiSelect::new("test message", options!(5)).with_max(3),
530        vec![
531            (KeyCode::Enter, KeyModifiers::NONE),
532            (KeyCode::Char(' '), KeyModifiers::NONE),
533            (KeyCode::Down, KeyModifiers::NONE),
534            (KeyCode::Char(' '), KeyModifiers::NONE),
535            (KeyCode::Down, KeyModifiers::NONE),
536            (KeyCode::Char(' '), KeyModifiers::NONE),
537            (KeyCode::Down, KeyModifiers::NONE),
538            (KeyCode::Char(' '), KeyModifiers::NONE),
539            (KeyCode::Enter, KeyModifiers::NONE),
540            (KeyCode::Char(' '), KeyModifiers::NONE),
541            (KeyCode::Enter, KeyModifiers::NONE),
542        ]
543    );
544}