inquire/prompts/
select.rs

1use std::fmt::Display;
2
3use crate::{
4    config::{self, get_configuration},
5    error::{InquireError, InquireResult},
6    formatter::OptionFormatter,
7    input::Input,
8    list_option::ListOption,
9    terminal::get_default_terminal,
10    type_aliases::Filter,
11    ui::{Backend, Key, KeyModifiers, RenderConfig, SelectBackend},
12    utils::paginate,
13};
14
15/// Prompt suitable for when you need the user to select one option among many.
16///
17/// The user can select and submit the current highlighted option by pressing enter.
18///
19/// This prompt requires a prompt message and a **non-empty** `Vec` of options to be displayed to the user. The options can be of any type as long as they implement the `Display` trait. It is required that the `Vec` is moved to the prompt, as the prompt will return the selected option (`Vec` element) after the user submits.
20/// - If the list is empty, the prompt operation will fail with an `InquireError::InvalidConfiguration` error.
21///
22/// This prompt does not support custom validators because of its nature. A submission always selects exactly one of the options. If this option was not supposed to be selected or is invalid in some way, it probably should not be included in the options list.
23///
24/// The options are paginated in order to provide a smooth experience to the user, with the default page size being 7. The user can move from the options and the pages will be updated accordingly, including moving from the last to the first options (or vice-versa).
25///
26/// Like all others, this prompt also allows you to customize several aspects of it:
27///
28/// - **Prompt message**: Required when creating the prompt.
29/// - **Options list**: Options displayed to the user. Must be **non-empty**.
30/// - **Starting cursor**: Index of the cursor when the prompt is first rendered. Default is 0 (first option). If the index is out-of-range of the option list, the prompt will fail with an [`InquireError::InvalidConfiguration`] error.
31/// - **Help message**: Message displayed at the line below the prompt.
32/// - **Formatter**: Custom formatter in case you need to pre-process the user input before showing it as the final answer.
33///   - Prints the selected option string value by default.
34/// - **Page size**: Number of options displayed at once, 7 by default.
35/// - **Display option indexes**: On long lists, it might be helpful to display the indexes of the options to the user. Via the `RenderConfig`, you can set the display mode of the indexes as a prefix of an option. The default configuration is `None`, to not render any index when displaying the options.
36/// - **Filter function**: Function that defines if an option is displayed or not based on the current filter input.
37///
38/// # Example
39///
40/// ```no_run
41/// use inquire::{error::InquireError, Select};
42///
43/// let options: Vec<&str> = vec!["Banana", "Apple", "Strawberry", "Grapes",
44///     "Lemon", "Tangerine", "Watermelon", "Orange", "Pear", "Avocado", "Pineapple",
45/// ];
46///
47/// let ans: Result<&str, InquireError> = Select::new("What's your favorite fruit?", options).prompt();
48///
49/// match ans {
50///     Ok(choice) => println!("{}! That's mine too!", choice),
51///     Err(_) => println!("There was an error, please try again"),
52/// }
53/// ```
54///
55/// [`InquireError::InvalidConfiguration`]: crate::error::InquireError::InvalidConfiguration
56#[derive(Clone)]
57pub struct Select<'a, T> {
58    /// Message to be presented to the user.
59    pub message: &'a str,
60
61    /// Options displayed to the user.
62    pub options: Vec<T>,
63
64    /// Help message to be presented to the user.
65    pub help_message: Option<&'a str>,
66
67    /// Page size of the options displayed to the user.
68    pub page_size: usize,
69
70    /// Whether vim mode is enabled. When enabled, the user can
71    /// navigate through the options using hjkl.
72    pub vim_mode: bool,
73
74    /// Starting cursor index of the selection.
75    pub starting_cursor: usize,
76
77    /// Function called with the current user input to filter the provided
78    /// options.
79    pub filter: Filter<'a, T>,
80
81    /// Function that formats the user input and presents it to the user as the final rendering of the prompt.
82    pub formatter: OptionFormatter<'a, T>,
83
84    /// RenderConfig to apply to the rendered interface.
85    ///
86    /// Note: The default render config considers if the NO_COLOR environment variable
87    /// is set to decide whether to render the colored config or the empty one.
88    ///
89    /// When overriding the config in a prompt, NO_COLOR is no longer considered and your
90    /// config is treated as the only source of truth. If you want to customize colors
91    /// and still suport NO_COLOR, you will have to do this on your end.
92    pub render_config: RenderConfig,
93}
94
95impl<'a, T> Select<'a, T>
96where
97    T: Display,
98{
99    /// String formatter used by default in [Select](crate::Select) prompts.
100    /// Simply prints the string value contained in the selected option.
101    ///
102    /// # Examples
103    ///
104    /// ```
105    /// use inquire::list_option::ListOption;
106    /// use inquire::Select;
107    ///
108    /// let formatter = Select::<&str>::DEFAULT_FORMATTER;
109    /// assert_eq!(String::from("First option"), formatter(ListOption::new(0, &"First option")));
110    /// assert_eq!(String::from("First option"), formatter(ListOption::new(11, &"First option")));
111    /// ```
112    pub const DEFAULT_FORMATTER: OptionFormatter<'a, T> = &|ans| ans.to_string();
113
114    /// Default filter function, which checks if the current filter value is a substring of the option value.
115    /// If it is, the option is displayed.
116    ///
117    /// # Examples
118    ///
119    /// ```
120    /// use inquire::Select;
121    ///
122    /// let filter = Select::<&str>::DEFAULT_FILTER;
123    /// assert_eq!(false, filter("sa", &"New York",      "New York",      0));
124    /// assert_eq!(true,  filter("sa", &"Sacramento",    "Sacramento",    1));
125    /// assert_eq!(true,  filter("sa", &"Kansas",        "Kansas",        2));
126    /// assert_eq!(true,  filter("sa", &"Mesa",          "Mesa",          3));
127    /// assert_eq!(false, filter("sa", &"Phoenix",       "Phoenix",       4));
128    /// assert_eq!(false, filter("sa", &"Philadelphia",  "Philadelphia",  5));
129    /// assert_eq!(true,  filter("sa", &"San Antonio",   "San Antonio",   6));
130    /// assert_eq!(true,  filter("sa", &"San Diego",     "San Diego",     7));
131    /// assert_eq!(false, filter("sa", &"Dallas",        "Dallas",        8));
132    /// assert_eq!(true,  filter("sa", &"San Francisco", "San Francisco", 9));
133    /// assert_eq!(false, filter("sa", &"Austin",        "Austin",       10));
134    /// assert_eq!(false, filter("sa", &"Jacksonville",  "Jacksonville", 11));
135    /// assert_eq!(true,  filter("sa", &"San Jose",      "San Jose",     12));
136    /// ```
137    pub const DEFAULT_FILTER: Filter<'a, T> = &|filter, _, string_value, _| -> bool {
138        let filter = filter.to_lowercase();
139
140        string_value.to_lowercase().contains(&filter)
141    };
142
143    /// Default page size.
144    pub const DEFAULT_PAGE_SIZE: usize = config::DEFAULT_PAGE_SIZE;
145
146    /// Default value of vim mode.
147    pub const DEFAULT_VIM_MODE: bool = config::DEFAULT_VIM_MODE;
148
149    /// Default starting cursor index.
150    pub const DEFAULT_STARTING_CURSOR: usize = 0;
151
152    /// Default help message.
153    pub const DEFAULT_HELP_MESSAGE: Option<&'a str> =
154        Some("↑↓ to move, enter to select, type to filter");
155
156    /// Creates a [Select] with the provided message and options, along with default configuration values.
157    pub fn new(message: &'a str, options: Vec<T>) -> Self {
158        Self {
159            message,
160            options,
161            help_message: Self::DEFAULT_HELP_MESSAGE,
162            page_size: Self::DEFAULT_PAGE_SIZE,
163            vim_mode: Self::DEFAULT_VIM_MODE,
164            starting_cursor: Self::DEFAULT_STARTING_CURSOR,
165            filter: Self::DEFAULT_FILTER,
166            formatter: Self::DEFAULT_FORMATTER,
167            render_config: get_configuration(),
168        }
169    }
170
171    /// Sets the help message of the prompt.
172    pub fn with_help_message(mut self, message: &'a str) -> Self {
173        self.help_message = Some(message);
174        self
175    }
176
177    /// Removes the set help message.
178    pub fn without_help_message(mut self) -> Self {
179        self.help_message = None;
180        self
181    }
182
183    /// Sets the page size.
184    pub fn with_page_size(mut self, page_size: usize) -> Self {
185        self.page_size = page_size;
186        self
187    }
188
189    /// Enables or disables vim_mode.
190    pub fn with_vim_mode(mut self, vim_mode: bool) -> Self {
191        self.vim_mode = vim_mode;
192        self
193    }
194
195    /// Sets the filter function.
196    pub fn with_filter(mut self, filter: Filter<'a, T>) -> Self {
197        self.filter = filter;
198        self
199    }
200
201    /// Sets the formatter.
202    pub fn with_formatter(mut self, formatter: OptionFormatter<'a, T>) -> Self {
203        self.formatter = formatter;
204        self
205    }
206
207    /// Sets the starting cursor index.
208    pub fn with_starting_cursor(mut self, starting_cursor: usize) -> Self {
209        self.starting_cursor = starting_cursor;
210        self
211    }
212
213    /// Sets the provided color theme to this prompt.
214    ///
215    /// Note: The default render config considers if the NO_COLOR environment variable
216    /// is set to decide whether to render the colored config or the empty one.
217    ///
218    /// When overriding the config in a prompt, NO_COLOR is no longer considered and your
219    /// config is treated as the only source of truth. If you want to customize colors
220    /// and still suport NO_COLOR, you will have to do this on your end.
221    pub fn with_render_config(mut self, render_config: RenderConfig) -> Self {
222        self.render_config = render_config;
223        self
224    }
225
226    /// Parses the provided behavioral and rendering options and prompts
227    /// the CLI user for input according to the defined rules.
228    ///
229    /// Returns the owned object selected by the user.
230    pub fn prompt(self) -> InquireResult<T> {
231        self.raw_prompt().map(|op| op.value)
232    }
233
234    /// Parses the provided behavioral and rendering options and prompts
235    /// the CLI user for input according to the defined rules.
236    ///
237    /// This method is intended for flows where the user skipping/cancelling
238    /// the prompt - by pressing ESC - is considered normal behavior. In this case,
239    /// it does not return `Err(InquireError::OperationCanceled)`, but `Ok(None)`.
240    ///
241    /// Meanwhile, if the user does submit an answer, the method wraps the return
242    /// type with `Some`.
243    pub fn prompt_skippable(self) -> InquireResult<Option<T>> {
244        match self.prompt() {
245            Ok(answer) => Ok(Some(answer)),
246            Err(InquireError::OperationCanceled) => Ok(None),
247            Err(err) => Err(err),
248        }
249    }
250
251    /// Parses the provided behavioral and rendering options and prompts
252    /// the CLI user for input according to the defined rules.
253    ///
254    /// Returns a [`ListOption`](crate::list_option::ListOption) containing
255    /// the index of the selection and the owned object selected by the user.
256    pub fn raw_prompt(self) -> InquireResult<ListOption<T>> {
257        let terminal = get_default_terminal()?;
258        let mut backend = Backend::new(terminal, self.render_config)?;
259        self.prompt_with_backend(&mut backend)
260    }
261
262    pub(crate) fn prompt_with_backend<B: SelectBackend>(
263        self,
264        backend: &mut B,
265    ) -> InquireResult<ListOption<T>> {
266        SelectPrompt::new(self)?.prompt(backend)
267    }
268}
269
270struct SelectPrompt<'a, T> {
271    message: &'a str,
272    options: Vec<T>,
273    string_options: Vec<String>,
274    filtered_options: Vec<usize>,
275    help_message: Option<&'a str>,
276    vim_mode: bool,
277    cursor_index: usize,
278    page_size: usize,
279    input: Input,
280    filter: Filter<'a, T>,
281    formatter: OptionFormatter<'a, T>,
282}
283
284impl<'a, T> SelectPrompt<'a, T>
285where
286    T: Display,
287{
288    fn new(so: Select<'a, T>) -> InquireResult<Self> {
289        if so.options.is_empty() {
290            return Err(InquireError::InvalidConfiguration(
291                "Available options can not be empty".into(),
292            ));
293        }
294
295        if so.starting_cursor >= so.options.len() {
296            return Err(InquireError::InvalidConfiguration(format!(
297                "Starting cursor index {} is out-of-bounds for length {} of options",
298                so.starting_cursor,
299                &so.options.len()
300            )));
301        }
302
303        let string_options = so.options.iter().map(T::to_string).collect();
304        let filtered_options = (0..so.options.len()).collect();
305
306        Ok(Self {
307            message: so.message,
308            options: so.options,
309            string_options,
310            filtered_options,
311            help_message: so.help_message,
312            vim_mode: so.vim_mode,
313            cursor_index: so.starting_cursor,
314            page_size: so.page_size,
315            input: Input::new(),
316            filter: so.filter,
317            formatter: so.formatter,
318        })
319    }
320
321    fn filter_options(&self) -> Vec<usize> {
322        self.options
323            .iter()
324            .enumerate()
325            .filter_map(|(i, opt)| match self.input.content() {
326                val if val.is_empty() => Some(i),
327                val if (self.filter)(val, opt, self.string_options.get(i).unwrap(), i) => Some(i),
328                _ => None,
329            })
330            .collect()
331    }
332
333    fn move_cursor_up(&mut self, qty: usize, wrap: bool) {
334        if wrap {
335            let after_wrap = qty.saturating_sub(self.cursor_index);
336            self.cursor_index = self
337                .cursor_index
338                .checked_sub(qty)
339                .unwrap_or_else(|| self.filtered_options.len().saturating_sub(after_wrap))
340        } else {
341            self.cursor_index = self.cursor_index.saturating_sub(qty);
342        }
343    }
344
345    fn move_cursor_down(&mut self, qty: usize, wrap: bool) {
346        self.cursor_index = self.cursor_index.saturating_add(qty);
347
348        if self.cursor_index >= self.filtered_options.len() {
349            self.cursor_index = if self.filtered_options.is_empty() {
350                0
351            } else if wrap {
352                self.cursor_index % self.filtered_options.len()
353            } else {
354                self.filtered_options.len().saturating_sub(1)
355            }
356        }
357    }
358
359    fn on_change(&mut self, key: Key) {
360        match key {
361            Key::Up(KeyModifiers::NONE) => self.move_cursor_up(1, true),
362            Key::Char('k', KeyModifiers::NONE) if self.vim_mode => self.move_cursor_up(1, true),
363            Key::PageUp => self.move_cursor_up(self.page_size, false),
364            Key::Home => self.move_cursor_up(usize::MAX, false),
365
366            Key::Down(KeyModifiers::NONE) => self.move_cursor_down(1, true),
367            Key::Char('j', KeyModifiers::NONE) if self.vim_mode => self.move_cursor_down(1, true),
368            Key::PageDown => self.move_cursor_down(self.page_size, false),
369            Key::End => self.move_cursor_down(usize::MAX, false),
370
371            key => {
372                let dirty = self.input.handle_key(key);
373
374                if dirty {
375                    let options = self.filter_options();
376                    if options.len() <= self.cursor_index {
377                        self.cursor_index = options.len().saturating_sub(1);
378                    }
379                    self.filtered_options = options;
380                }
381            }
382        };
383    }
384
385    fn has_answer_highlighted(&mut self) -> bool {
386        self.filtered_options.get(self.cursor_index).is_some()
387    }
388
389    fn get_final_answer(&mut self) -> ListOption<T> {
390        // should only be called after current cursor index is validated
391        // on has_answer_highlighted
392
393        let index = *self.filtered_options.get(self.cursor_index).unwrap();
394        let value = self.options.swap_remove(index);
395
396        ListOption::new(index, value)
397    }
398
399    fn render<B: SelectBackend>(&mut self, backend: &mut B) -> InquireResult<()> {
400        let prompt = &self.message;
401
402        backend.frame_setup()?;
403
404        backend.render_select_prompt(prompt, &self.input)?;
405
406        let choices = self
407            .filtered_options
408            .iter()
409            .cloned()
410            .map(|i| ListOption::new(i, self.options.get(i).unwrap()))
411            .collect::<Vec<ListOption<&T>>>();
412
413        let page = paginate(self.page_size, &choices, Some(self.cursor_index));
414
415        backend.render_options(page)?;
416
417        if let Some(help_message) = self.help_message {
418            backend.render_help_message(help_message)?;
419        }
420
421        backend.frame_finish()?;
422
423        Ok(())
424    }
425
426    fn prompt<B: SelectBackend>(mut self, backend: &mut B) -> InquireResult<ListOption<T>> {
427        loop {
428            self.render(backend)?;
429
430            let key = backend.read_key()?;
431
432            match key {
433                Key::Interrupt => interrupt_prompt!(),
434                Key::Cancel => cancel_prompt!(backend, self.message),
435                Key::Submit => match self.has_answer_highlighted() {
436                    true => break,
437                    false => {}
438                },
439                key => self.on_change(key),
440            }
441        }
442
443        let final_answer = self.get_final_answer();
444        let formatted = (self.formatter)(final_answer.as_ref());
445
446        finish_prompt_with_answer!(backend, self.message, &formatted, final_answer);
447    }
448}
449
450#[cfg(test)]
451#[cfg(feature = "crossterm")]
452mod test {
453    use crate::{
454        formatter::OptionFormatter,
455        list_option::ListOption,
456        terminal::crossterm::CrosstermTerminal,
457        ui::{Backend, RenderConfig},
458        Select,
459    };
460    use crossterm::event::{KeyCode, KeyEvent};
461
462    #[test]
463    /// Tests that a closure that actually closes on a variable can be used
464    /// as a Select formatter.
465    fn closure_formatter() {
466        let read: Vec<KeyEvent> = vec![KeyCode::Down, KeyCode::Enter]
467            .into_iter()
468            .map(KeyEvent::from)
469            .collect();
470        let mut read = read.iter();
471
472        let formatted = String::from("Thanks!");
473        let formatter: OptionFormatter<i32> = &|_| formatted.clone();
474
475        let options = vec![1, 2, 3];
476
477        let mut write: Vec<u8> = Vec::new();
478        let terminal = CrosstermTerminal::new_with_io(&mut write, &mut read);
479        let mut backend = Backend::new(terminal, RenderConfig::default()).unwrap();
480
481        let ans = Select::new("Question", options)
482            .with_formatter(formatter)
483            .prompt_with_backend(&mut backend)
484            .unwrap();
485
486        assert_eq!(ListOption::new(1, 2), ans);
487    }
488
489    #[test]
490    // Anti-regression test: https://github.com/mikaelmello/inquire/issues/29
491    fn enter_arrow_on_empty_list_does_not_panic() {
492        let read: Vec<KeyEvent> = [
493            KeyCode::Char('9'),
494            KeyCode::Enter,
495            KeyCode::Backspace,
496            KeyCode::Char('3'),
497            KeyCode::Enter,
498        ]
499        .iter()
500        .map(|c| KeyEvent::from(*c))
501        .collect();
502
503        let mut read = read.iter();
504
505        let options = vec![1, 2, 3];
506
507        let mut write: Vec<u8> = Vec::new();
508        let terminal = CrosstermTerminal::new_with_io(&mut write, &mut read);
509        let mut backend = Backend::new(terminal, RenderConfig::default()).unwrap();
510
511        let ans = Select::new("Question", options)
512            .prompt_with_backend(&mut backend)
513            .unwrap();
514
515        assert_eq!(ListOption::new(2, 3), ans);
516    }
517
518    #[test]
519    // Anti-regression test: https://github.com/mikaelmello/inquire/issues/30
520    fn down_arrow_on_empty_list_does_not_panic() {
521        let read: Vec<KeyEvent> = [
522            KeyCode::Char('9'),
523            KeyCode::Down,
524            KeyCode::Backspace,
525            KeyCode::Char('3'),
526            KeyCode::Down,
527            KeyCode::Backspace,
528            KeyCode::Enter,
529        ]
530        .iter()
531        .map(|c| KeyEvent::from(*c))
532        .collect();
533
534        let mut read = read.iter();
535
536        let options = vec![1, 2, 3];
537
538        let mut write: Vec<u8> = Vec::new();
539        let terminal = CrosstermTerminal::new_with_io(&mut write, &mut read);
540        let mut backend = Backend::new(terminal, RenderConfig::default()).unwrap();
541
542        let ans = Select::new("Question", options)
543            .prompt_with_backend(&mut backend)
544            .unwrap();
545
546        assert_eq!(ListOption::new(0, 1), ans);
547    }
548}