dialoguer_ext/prompts/
fuzzy_select.rs

1use std::{io, ops::Rem};
2
3use console::{Key, Term};
4use fuzzy_matcher::FuzzyMatcher;
5
6use crate::{
7    theme::{render::TermThemeRenderer, SimpleTheme, Theme},
8    Result,
9};
10
11/// Renders a select prompt with fuzzy search.
12///
13/// User can use fuzzy search to limit selectable items.
14/// Interaction returns index of an item selected in the order they appear in `item` invocation or `items` slice.
15///
16/// ## Example
17///
18/// ```rust,no_run
19/// use dialoguer_ext::FuzzySelect;
20///
21/// fn main() {
22///     let items = vec!["foo", "bar", "baz"];
23///
24///     let selection = FuzzySelect::new()
25///         .with_prompt("What do you choose?")
26///         .items(&items)
27///         .interact()
28///         .unwrap();
29///
30///     println!("You chose: {}", items[selection]);
31/// }
32/// ```
33#[derive(Clone)]
34pub struct FuzzySelect<'a> {
35    default: Option<usize>,
36    items: Vec<String>,
37    prompt: String,
38    report: bool,
39    clear: bool,
40    highlight_matches: bool,
41    enable_vim_mode: bool,
42    max_length: Option<usize>,
43    theme: &'a dyn Theme,
44    /// Search string that a fuzzy search with start with.
45    /// Defaults to an empty string.
46    initial_text: String,
47}
48
49impl Default for FuzzySelect<'static> {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl FuzzySelect<'static> {
56    /// Creates a fuzzy select prompt with default theme.
57    pub fn new() -> Self {
58        Self::with_theme(&SimpleTheme)
59    }
60}
61
62impl FuzzySelect<'_> {
63    /// Sets the clear behavior of the menu.
64    ///
65    /// The default is to clear the menu.
66    pub fn clear(mut self, val: bool) -> Self {
67        self.clear = val;
68        self
69    }
70
71    /// Sets a default for the menu
72    pub fn default(mut self, val: usize) -> Self {
73        self.default = Some(val);
74        self
75    }
76
77    /// Add a single item to the fuzzy selector.
78    pub fn item<T: ToString>(mut self, item: T) -> Self {
79        self.items.push(item.to_string());
80        self
81    }
82
83    /// Adds multiple items to the fuzzy selector.
84    pub fn items<T, I>(mut self, items: I) -> Self
85    where
86        T: ToString,
87        I: IntoIterator<Item = T>,
88    {
89        self.items
90            .extend(items.into_iter().map(|item| item.to_string()));
91
92        self
93    }
94
95    /// Sets the search text that a fuzzy search starts with.
96    pub fn with_initial_text<S: Into<String>>(mut self, initial_text: S) -> Self {
97        self.initial_text = initial_text.into();
98        self
99    }
100
101    /// Prefaces the menu with a prompt.
102    ///
103    /// When a prompt is set the system also prints out a confirmation after
104    /// the fuzzy selection.
105    pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
106        self.prompt = prompt.into();
107        self
108    }
109
110    /// Indicates whether to report the selected value after interaction.
111    ///
112    /// The default is to report the selection.
113    pub fn report(mut self, val: bool) -> Self {
114        self.report = val;
115        self
116    }
117
118    /// Indicates whether to highlight matched indices
119    ///
120    /// The default is to highlight the indices
121    pub fn highlight_matches(mut self, val: bool) -> Self {
122        self.highlight_matches = val;
123        self
124    }
125
126    /// Indicated whether to allow the use of vim mode
127    ///
128    /// Vim mode can be entered by pressing Escape.
129    /// This then allows the user to navigate using hjkl.
130    ///
131    /// The default is to disable vim mode.
132    pub fn vim_mode(mut self, val: bool) -> Self {
133        self.enable_vim_mode = val;
134        self
135    }
136
137    /// Sets the maximum number of visible options.
138    ///
139    /// The default is the height of the terminal minus 2.
140    pub fn max_length(mut self, rows: usize) -> Self {
141        self.max_length = Some(rows);
142        self
143    }
144
145    /// Enables user interaction and returns the result.
146    ///
147    /// The user can select the items using 'Enter' and the index of selected item will be returned.
148    /// The dialog is rendered on stderr.
149    /// Result contains `index` of selected item if user hit 'Enter'.
150    /// This unlike [`interact_opt`](Self::interact_opt) does not allow to quit with 'Esc' or 'q'.
151    #[inline]
152    pub fn interact(self) -> Result<usize> {
153        self.interact_on(&Term::stderr())
154    }
155
156    /// Enables user interaction and returns the result.
157    ///
158    /// The user can select the items using 'Enter' and the index of selected item will be returned.
159    /// The dialog is rendered on stderr.
160    /// Result contains `Some(index)` if user hit 'Enter' or `None` if user cancelled with 'Esc' or 'q'.
161    ///
162    /// ## Example
163    ///
164    /// ```rust,no_run
165    /// use dialoguer_ext::FuzzySelect;
166    ///
167    /// fn main() {
168    ///     let items = vec!["foo", "bar", "baz"];
169    ///
170    ///     let selection = FuzzySelect::new()
171    ///         .items(&items)
172    ///         .interact_opt()
173    ///         .unwrap();
174    ///
175    ///     match selection {
176    ///         Some(index) => println!("You chose: {}", items[index]),
177    ///         None => println!("You did not choose anything.")
178    ///     }
179    /// }
180    /// ```
181    #[inline]
182    pub fn interact_opt(self) -> Result<Option<usize>> {
183        self.interact_on_opt(&Term::stderr())
184    }
185
186    /// Like [`interact`](Self::interact) but allows a specific terminal to be set.
187    #[inline]
188    pub fn interact_on(self, term: &Term) -> Result<usize> {
189        Ok(self
190            ._interact_on(term, false)?
191            .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Quit not allowed in this case"))?)
192    }
193
194    /// Like [`interact_opt`](Self::interact_opt) but allows a specific terminal to be set.
195    #[inline]
196    pub fn interact_on_opt(self, term: &Term) -> Result<Option<usize>> {
197        self._interact_on(term, true)
198    }
199
200    fn _interact_on(self, term: &Term, allow_quit: bool) -> Result<Option<usize>> {
201        // Place cursor at the end of the search term
202        let mut cursor = self.initial_text.chars().count();
203        let mut search_term = self.initial_text.to_owned();
204
205        let mut render = TermThemeRenderer::new(term, self.theme);
206        let mut sel = self.default;
207
208        let mut size_vec = Vec::new();
209        for items in self.items.iter().as_slice() {
210            let size = &items.len();
211            size_vec.push(*size);
212        }
213
214        // Fuzzy matcher
215        let matcher = fuzzy_matcher::skim::SkimMatcherV2::default();
216
217        // Subtract -2 because we need space to render the prompt.
218        let visible_term_rows = (term.size().0 as usize).max(3) - 2;
219        let visible_term_rows = self
220            .max_length
221            .unwrap_or(visible_term_rows)
222            .min(visible_term_rows);
223        // Variable used to determine if we need to scroll through the list.
224        let mut starting_row = 0;
225
226        term.hide_cursor()?;
227
228        let mut vim_mode = false;
229
230        loop {
231            let mut byte_indices = search_term
232                .char_indices()
233                .map(|(index, _)| index)
234                .collect::<Vec<_>>();
235
236            byte_indices.push(search_term.len());
237
238            render.clear()?;
239            render.fuzzy_select_prompt(self.prompt.as_str(), &search_term, byte_indices[cursor])?;
240
241            // Maps all items to a tuple of item and its match score.
242            let mut filtered_list = self
243                .items
244                .iter()
245                .map(|item| (item, matcher.fuzzy_match(item, &search_term)))
246                .filter_map(|(item, score)| score.map(|s| (item, s)))
247                .collect::<Vec<_>>();
248
249            // Renders all matching items, from best match to worst.
250            filtered_list.sort_unstable_by(|(_, s1), (_, s2)| s2.cmp(s1));
251
252            for (idx, (item, _)) in filtered_list
253                .iter()
254                .enumerate()
255                .skip(starting_row)
256                .take(visible_term_rows)
257            {
258                render.fuzzy_select_prompt_item(
259                    item,
260                    Some(idx) == sel,
261                    self.highlight_matches,
262                    &matcher,
263                    &search_term,
264                )?;
265            }
266            term.flush()?;
267
268            match (term.read_key()?, sel, vim_mode) {
269                (Key::Escape, _, false) if self.enable_vim_mode => {
270                    vim_mode = true;
271                }
272                (Key::Escape, _, false) | (Key::Char('q'), _, true) if allow_quit => {
273                    if self.clear {
274                        render.clear()?;
275                        term.flush()?;
276                    }
277                    term.show_cursor()?;
278                    return Ok(None);
279                }
280                (Key::Char('i' | 'a'), _, true) => {
281                    vim_mode = false;
282                }
283                (Key::ArrowUp | Key::BackTab, _, _) | (Key::Char('k'), _, true)
284                    if !filtered_list.is_empty() =>
285                {
286                    if sel == Some(0) {
287                        starting_row =
288                            filtered_list.len().max(visible_term_rows) - visible_term_rows;
289                    } else if sel == Some(starting_row) {
290                        starting_row -= 1;
291                    }
292                    sel = match sel {
293                        None => Some(filtered_list.len() - 1),
294                        Some(sel) => Some(
295                            ((sel as i64 - 1 + filtered_list.len() as i64)
296                                % (filtered_list.len() as i64))
297                                as usize,
298                        ),
299                    };
300                    term.flush()?;
301                }
302                (Key::ArrowDown | Key::Tab, _, _) | (Key::Char('j'), _, true)
303                    if !filtered_list.is_empty() =>
304                {
305                    sel = match sel {
306                        None => Some(0),
307                        Some(sel) => {
308                            Some((sel as u64 + 1).rem(filtered_list.len() as u64) as usize)
309                        }
310                    };
311                    if sel == Some(visible_term_rows + starting_row) {
312                        starting_row += 1;
313                    } else if sel == Some(0) {
314                        starting_row = 0;
315                    }
316                    term.flush()?;
317                }
318                (Key::ArrowLeft, _, _) | (Key::Char('h'), _, true) if cursor > 0 => {
319                    cursor -= 1;
320                    term.flush()?;
321                }
322                (Key::ArrowRight, _, _) | (Key::Char('l'), _, true)
323                    if cursor < byte_indices.len() - 1 =>
324                {
325                    cursor += 1;
326                    term.flush()?;
327                }
328                (Key::Enter, Some(sel), _) if !filtered_list.is_empty() => {
329                    if self.clear {
330                        render.clear()?;
331                    }
332
333                    if self.report {
334                        render
335                            .input_prompt_selection(self.prompt.as_str(), filtered_list[sel].0)?;
336                    }
337
338                    let sel_string = filtered_list[sel].0;
339                    let sel_string_pos_in_items =
340                        self.items.iter().position(|item| item.eq(sel_string));
341
342                    term.show_cursor()?;
343                    return Ok(sel_string_pos_in_items);
344                }
345                (Key::Backspace, _, _) if cursor > 0 => {
346                    cursor -= 1;
347                    search_term.remove(byte_indices[cursor]);
348                    term.flush()?;
349                }
350                (Key::Del, _, _) if cursor < byte_indices.len() - 1 => {
351                    search_term.remove(byte_indices[cursor]);
352                    term.flush()?;
353                }
354                (Key::Char(chr), _, _) if !chr.is_ascii_control() => {
355                    search_term.insert(byte_indices[cursor], chr);
356                    cursor += 1;
357                    term.flush()?;
358                    sel = Some(0);
359                    starting_row = 0;
360                }
361
362                _ => {}
363            }
364
365            render.clear_preserve_prompt(&size_vec)?;
366        }
367    }
368}
369
370impl<'a> FuzzySelect<'a> {
371    /// Creates a fuzzy select prompt with a specific theme.
372    ///
373    /// ## Example
374    ///
375    /// ```rust,no_run
376    /// use dialoguer_ext::{theme::ColorfulTheme, FuzzySelect};
377    ///
378    /// fn main() {
379    ///     let selection = FuzzySelect::with_theme(&ColorfulTheme::default())
380    ///         .items(&["foo", "bar", "baz"])
381    ///         .interact()
382    ///         .unwrap();
383    /// }
384    /// ```
385    pub fn with_theme(theme: &'a dyn Theme) -> Self {
386        Self {
387            default: None,
388            items: vec![],
389            prompt: "".into(),
390            report: true,
391            clear: true,
392            highlight_matches: true,
393            enable_vim_mode: false,
394            max_length: None,
395            theme,
396            initial_text: "".into(),
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_clone() {
407        let fuzzy_select = FuzzySelect::new().with_prompt("Do you want to continue?");
408
409        let _ = fuzzy_select.clone();
410    }
411
412    #[test]
413    fn test_iterator() {
414        let items = ["First", "Second", "Third"];
415        let iterator = items.iter().skip(1);
416
417        assert_eq!(FuzzySelect::new().items(iterator).items, &items[1..]);
418    }
419}