diff --git a/CHANGELOG.md b/CHANGELOG.md index b7614d82..c2ac811c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ - Add strict clippy lints to improve code consistency and readability. - Expand workflow clippy task to lint all-features in workspace. - Add docs badge to readme. +- **Breaking** The Select and Multiselect Filter now scores input and is now expected to return an `Option`, making it possible to order/rank the list of options. [#176](https://github.com/mikaelmello/inquire/pull/176) + `None`: Will not be displayed in the list of options. + `Some(score)`: score determines the order of options, higher score, higher on the list of options. +- Implement fuzzy search as default on Select and MultiSelect prompts. [#176](https://github.com/mikaelmello/inquire/pull/176) +- Add new option on Select/MultiSelect prompts allowing to reset selection to the first item on filter-input changes. [#176](https://github.com/mikaelmello/inquire/pull/176) - Keybindings Ctrl-p and Ctrl-n added for Up and Down actions ### Fixes @@ -27,6 +32,7 @@ - Raised MSRV to 1.63 due to requirements in downstream dependencies. - MSRV is now explicitly set in the package definition. - Replaced `lazy_static` with `once_cell` as `once_cell::sync::Lazy` is being standardized and `lazy_static` is not actively maintained anymore. +- Added `fuzzy-matcher` as an optional dependency for fuzzy filtering in Select and MultiSelect prompts [#176](https://github.com/mikaelmello/inquire/pull/176) ## [0.6.2] - 2023-05-07 diff --git a/README.md b/README.md index 7045fe56..21ca6522 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ It provides several different prompts in order to interactively ask the user for - Help messages; - Autocompletion for [`Text`] prompts; - Confirmation messages for [`Password`] prompts; - - Custom list filters for Select and [`MultiSelect`] prompts; + - Custom list filters for [`Select`] and [`MultiSelect`] prompts; - Custom parsers for [`Confirm`] and [`CustomType`] prompts; - Custom extensions for files created by [`Editor`] prompts; - and many others! @@ -154,13 +154,13 @@ The default parser for [`CustomType`] prompts calls the `parse::()` method on In the [demo](#demo) you can see this behavior in action with the _amount_ (CustomType) prompt. -## Filtering +## Scoring -Filtering is applicable to two prompts: [`Select`] and [`MultiSelect`]. They provide the user the ability to filter the options based on their text input. This is specially useful when there are a lot of options for the user to choose from, allowing them to quickly find their expected options. +Scoring is applicable to two prompts: [`Select`] and [`MultiSelect`]. They provide the user the ability to sort and filter the list of options based on their text input. This is specially useful when there are a lot of options for the user to choose from, allowing them to quickly find their expected options. -Filter functions receive three arguments: the current user input, the option string value and the option index. They must return a `bool` value indicating whether the option should be part of the results or not. +Scoring functions receive four arguments: the current user input, the option, the option string value and the option index. They must return a `Option` value indicating whether the option should be part of the results or not. -The default filter function does a naive case-insensitive comparison between the option string value and the current user input, returning `true` if the option string value contains the user input as a substring. +The default scoring function calculates a match value with the current user input and each option using SkimV2 from [fuzzy_matcher](https://crates.io/crates/fuzzy-matcher), resulting in fuzzy searching and filtering, returning `Some(_i64)` if SkimV2 detects a match. In the [demo](#demo) you can see this behavior in action with the _account_ (Select) and _tags_ (MultiSelect) prompts. @@ -313,7 +313,7 @@ Like all others, this prompt also allows you to customize several aspects of it: - Prints the selected option string value by default. - **Page size**: Number of options displayed at once, 7 by default. - **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. -- **Filter function**: Function that defines if an option is displayed or not based on the current filter input. +- **Scoring function**: Function that defines the order of options and if an option is displayed or not based on the current user input. ## MultiSelect @@ -344,7 +344,7 @@ Customizable options: - No validators are on by default. - **Page size**: Number of options displayed at once, 7 by default. - **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. -- **Filter function**: Function that defines if an option is displayed or not based on the current filter input. +- **Scoring function**: Function that defines the order of options and if an option is displayed or not based on the current user input. - **Keep filter flag**: Whether the current filter input should be cleared or not after a selection is made. Defaults to true. ## Editor diff --git a/inquire/Cargo.toml b/inquire/Cargo.toml index a7a457e7..57c969d4 100644 --- a/inquire/Cargo.toml +++ b/inquire/Cargo.toml @@ -20,11 +20,12 @@ path = "src/lib.rs" doctest = true [features] -default = ["macros", "crossterm", "one-liners"] +default = ["macros", "crossterm", "one-liners", "fuzzy"] macros = [] one-liners = [] date = ["chrono"] editor = ["tempfile"] +fuzzy = ["fuzzy-matcher"] [package.metadata.docs.rs] all-features = true @@ -41,6 +42,8 @@ chrono = { version = "0.4", optional = true } tempfile = { version = "3", optional = true } +fuzzy-matcher = { version = "0.3.7", default-features = false, optional = true } + bitflags = "2" dyn-clone = "1" newline-converter = "0.3" diff --git a/inquire/src/prompts/multiselect/config.rs b/inquire/src/prompts/multiselect/config.rs index a5250b64..9601969f 100644 --- a/inquire/src/prompts/multiselect/config.rs +++ b/inquire/src/prompts/multiselect/config.rs @@ -9,6 +9,8 @@ pub struct MultiSelectConfig { pub page_size: usize, /// Whether to keep the filter text when an option is selected. pub keep_filter: bool, + /// Whether to reset the cursor to the first option on filter input change. + pub reset_cursor: bool, } impl From<&MultiSelect<'_, T>> for MultiSelectConfig { @@ -17,6 +19,7 @@ impl From<&MultiSelect<'_, T>> for MultiSelectConfig { vim_mode: value.vim_mode, page_size: value.page_size, keep_filter: value.keep_filter, + reset_cursor: value.reset_cursor, } } } diff --git a/inquire/src/prompts/multiselect/mod.rs b/inquire/src/prompts/multiselect/mod.rs index 025ee52c..e59c0a69 100644 --- a/inquire/src/prompts/multiselect/mod.rs +++ b/inquire/src/prompts/multiselect/mod.rs @@ -16,13 +16,19 @@ use crate::{ list_option::ListOption, prompts::prompt::Prompt, terminal::get_default_terminal, - type_aliases::Filter, + type_aliases::Scorer, ui::{Backend, MultiSelectBackend, RenderConfig}, validator::MultiOptionValidator, }; use self::prompt::MultiSelectPrompt; +#[cfg(feature = "fuzzy")] +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +#[cfg(feature = "fuzzy")] +use once_cell::sync::Lazy; +#[cfg(feature = "fuzzy")] +static DEFAULT_MATCHER: Lazy = Lazy::new(|| SkimMatcherV2::default().ignore_case()); /// Prompt suitable for when you need the user to select many options (including none if applicable) among a list of them. /// /// The user can select (or deselect) the current highlighted option by pressing space, clean all selections by pressing the left arrow and select all options by pressing the right arrow. @@ -45,7 +51,7 @@ use self::prompt::MultiSelectPrompt; /// - No validators are on by default. /// - **Page size**: Number of options displayed at once, 7 by default. /// - **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. -/// - **Filter function**: Function that defines if an option is displayed or not based on the current filter input. +/// - **Scorer function**: Function that defines the order of options and if displayed as all. /// - **Keep filter flag**: Whether the current filter input should be cleared or not after a selection is made. Defaults to true. /// /// # Example @@ -77,9 +83,14 @@ pub struct MultiSelect<'a, T> { /// Starting cursor index of the selection. pub starting_cursor: usize, - /// Function called with the current user input to filter the provided + /// Reset cursor position to first option on filter input change. + /// Defaults to true. + pub reset_cursor: bool, + + /// Function called with the current user input to score the provided /// options. - pub filter: Filter<'a, T>, + /// The list of options is sorted in descending order (highest score first) + pub scorer: Scorer<'a, T>, /// Whether the current filter typed by the user is kept or cleaned after a selection is made. pub keep_filter: bool, @@ -134,34 +145,44 @@ where .join(", ") }; - /// Default filter function, which checks if the current filter value is a substring of the option value. - /// If it is, the option is displayed. + /// Default scoring function, which will create a score for the current option using the input value. + /// The return will be sorted in Descending order, leaving options with None as a score. /// /// # Examples /// /// ``` /// use inquire::MultiSelect; /// - /// let filter = MultiSelect::<&str>::DEFAULT_FILTER; - /// assert_eq!(false, filter("sa", &"New York", "New York", 0)); - /// assert_eq!(true, filter("sa", &"Sacramento", "Sacramento", 1)); - /// assert_eq!(true, filter("sa", &"Kansas", "Kansas", 2)); - /// assert_eq!(true, filter("sa", &"Mesa", "Mesa", 3)); - /// assert_eq!(false, filter("sa", &"Phoenix", "Phoenix", 4)); - /// assert_eq!(false, filter("sa", &"Philadelphia", "Philadelphia", 5)); - /// assert_eq!(true, filter("sa", &"San Antonio", "San Antonio", 6)); - /// assert_eq!(true, filter("sa", &"San Diego", "San Diego", 7)); - /// assert_eq!(false, filter("sa", &"Dallas", "Dallas", 8)); - /// assert_eq!(true, filter("sa", &"San Francisco", "San Francisco", 9)); - /// assert_eq!(false, filter("sa", &"Austin", "Austin", 10)); - /// assert_eq!(false, filter("sa", &"Jacksonville", "Jacksonville", 11)); - /// assert_eq!(true, filter("sa", &"San Jose", "San Jose", 12)); + /// let scorer = MultiSelect::<&str>::DEFAULT_SCORER; + /// assert_eq!(None, scorer("sa", &"New York", "New York", 0)); + /// assert_eq!(Some(49), scorer("sa", &"Sacramento", "Sacramento", 1)); + /// assert_eq!(Some(35), scorer("sa", &"Kansas", "Kansas", 2)); + /// assert_eq!(Some(35), scorer("sa", &"Mesa", "Mesa", 3)); + /// assert_eq!(None, scorer("sa", &"Phoenix", "Phoenix", 4)); + /// assert_eq!(None, scorer("sa", &"Philadelphia", "Philadelphia", 5)); + /// assert_eq!(Some(49), scorer("sa", &"San Antonio", "San Antonio", 6)); + /// assert_eq!(Some(49), scorer("sa", &"San Diego", "San Diego", 7)); + /// assert_eq!(None, scorer("sa", &"Dallas", "Dallas", 8)); + /// assert_eq!(Some(49), scorer("sa", &"San Francisco", "San Francisco", 9)); + /// assert_eq!(None, scorer("sa", &"Austin", "Austin", 10)); + /// assert_eq!(None, scorer("sa", &"Jacksonville", "Jacksonville", 11)); + /// assert_eq!(Some(49), scorer("sa", &"San Jose", "San Jose", 12)); /// ``` - pub const DEFAULT_FILTER: Filter<'a, T> = &|filter, _, string_value, _| -> bool { - let filter = filter.to_lowercase(); - - string_value.to_lowercase().contains(&filter) - }; + #[cfg(feature = "fuzzy")] + pub const DEFAULT_SCORER: Scorer<'a, T> = + &|input, _option, string_value, _idx| -> Option { + DEFAULT_MATCHER.fuzzy_match(string_value, input) + }; + + #[cfg(not(feature = "fuzzy"))] + pub const DEFAULT_SCORER: Scorer<'a, T> = + &|input, _option, string_value, _idx| -> Option { + let filter = input.to_lowercase(); + match string_value.to_lowercase().contains(&filter) { + true => Some(0), + false => None, + } + }; /// Default page size, equal to the global default page size [config::DEFAULT_PAGE_SIZE] pub const DEFAULT_PAGE_SIZE: usize = crate::config::DEFAULT_PAGE_SIZE; @@ -172,6 +193,10 @@ where /// Default starting cursor index. pub const DEFAULT_STARTING_CURSOR: usize = 0; + /// Default cursor behaviour on filter input change. + /// Defaults to true. + pub const DEFAULT_RESET_CURSOR: bool = true; + /// Default behavior of keeping or cleaning the current filter value. pub const DEFAULT_KEEP_FILTER: bool = true; @@ -189,8 +214,9 @@ where page_size: Self::DEFAULT_PAGE_SIZE, vim_mode: Self::DEFAULT_VIM_MODE, starting_cursor: Self::DEFAULT_STARTING_CURSOR, + reset_cursor: Self::DEFAULT_RESET_CURSOR, keep_filter: Self::DEFAULT_KEEP_FILTER, - filter: Self::DEFAULT_FILTER, + scorer: Self::DEFAULT_SCORER, formatter: Self::DEFAULT_FORMATTER, validator: None, render_config: get_configuration(), @@ -227,9 +253,9 @@ where self } - /// Sets the filter function. - pub fn with_filter(mut self, filter: Filter<'a, T>) -> Self { - self.filter = filter; + /// Sets the scoring function. + pub fn with_scorer(mut self, scorer: Scorer<'a, T>) -> Self { + self.scorer = scorer; self } @@ -276,6 +302,14 @@ where self } + /// Sets the reset_cursor behaviour. + /// Will reset cursor to first option on filter input change. + /// Defaults to true. + pub fn with_reset_cursor(mut self, reset_cursor: bool) -> Self { + self.reset_cursor = reset_cursor; + self + } + /// Sets the provided color theme to this prompt. /// /// Note: The default render config considers if the NO_COLOR environment variable diff --git a/inquire/src/prompts/multiselect/prompt.rs b/inquire/src/prompts/multiselect/prompt.rs index d568c2b9..a2699145 100644 --- a/inquire/src/prompts/multiselect/prompt.rs +++ b/inquire/src/prompts/multiselect/prompt.rs @@ -1,4 +1,4 @@ -use std::{collections::BTreeSet, fmt::Display}; +use std::{cmp::Reverse, collections::BTreeSet, fmt::Display}; use crate::{ error::InquireResult, @@ -6,7 +6,7 @@ use crate::{ input::{Input, InputActionResult}, list_option::ListOption, prompts::prompt::{ActionResult, Prompt}, - type_aliases::Filter, + type_aliases::Scorer, ui::MultiSelectBackend, utils::paginate, validator::{ErrorMessage, MultiOptionValidator, Validation}, @@ -24,8 +24,8 @@ pub struct MultiSelectPrompt<'a, T> { cursor_index: usize, checked: BTreeSet, input: Input, - filtered_options: Vec, - filter: Filter<'a, T>, + scored_options: Vec, + scorer: Scorer<'a, T>, formatter: MultiOptionFormatter<'a, T>, validator: Option>>, error: Option, @@ -54,7 +54,7 @@ where } let string_options = mso.options.iter().map(T::to_string).collect(); - let filtered_options = (0..mso.options.len()).collect(); + let scored_options = (0..mso.options.len()).collect(); let checked_options = mso .default .as_ref() @@ -71,11 +71,11 @@ where config: (&mso).into(), options: mso.options, string_options, - filtered_options, + scored_options, help_message: mso.help_message, cursor_index: mso.starting_cursor, input: Input::new(), - filter: mso.filter, + scorer: mso.scorer, formatter: mso.formatter, validator: mso.validator, error: None, @@ -83,16 +83,20 @@ where }) } - fn filter_options(&self) -> Vec { + fn score_options(&self) -> Vec<(usize, i64)> { self.options .iter() .enumerate() - .filter_map(|(i, opt)| match self.input.content() { - val if val.is_empty() => Some(i), - val if (self.filter)(val, opt, self.string_options.get(i).unwrap(), i) => Some(i), - _ => None, + .filter_map(|(i, opt)| { + (self.scorer)( + self.input.content(), + opt, + self.string_options.get(i).unwrap(), + i, + ) + .map(|score| (i, score)) }) - .collect() + .collect::>() } fn move_cursor_up(&mut self, qty: usize, wrap: bool) -> ActionResult { @@ -100,7 +104,7 @@ where let after_wrap = qty.saturating_sub(self.cursor_index); self.cursor_index .checked_sub(qty) - .unwrap_or_else(|| self.filtered_options.len().saturating_sub(after_wrap)) + .unwrap_or_else(|| self.scored_options.len().saturating_sub(after_wrap)) } else { self.cursor_index.saturating_sub(qty) }; @@ -111,13 +115,13 @@ where fn move_cursor_down(&mut self, qty: usize, wrap: bool) -> ActionResult { let mut new_position = self.cursor_index.saturating_add(qty); - if new_position >= self.filtered_options.len() { - new_position = if self.filtered_options.is_empty() { + if new_position >= self.scored_options.len() { + new_position = if self.scored_options.is_empty() { 0 } else if wrap { - new_position % self.filtered_options.len() + new_position % self.scored_options.len() } else { - self.filtered_options.len().saturating_sub(1) + self.scored_options.len().saturating_sub(1) } } @@ -134,7 +138,7 @@ where } fn toggle_cursor_selection(&mut self) -> ActionResult { - let idx = match self.filtered_options.get(self.cursor_index) { + let idx = match self.scored_options.get(self.cursor_index) { Some(val) => val, None => return ActionResult::Clean, }; @@ -236,7 +240,7 @@ where MultiSelectPromptAction::ToggleCurrentOption => self.toggle_cursor_selection(), MultiSelectPromptAction::SelectAll => { self.checked.clear(); - for idx in &self.filtered_options { + for idx in &self.scored_options { self.checked.insert(*idx); } @@ -259,11 +263,15 @@ where let result = self.input.handle(input_action); if let InputActionResult::ContentChanged = result { - let options = self.filter_options(); - self.filtered_options = options; - if self.filtered_options.len() <= self.cursor_index { + let mut options = self.score_options(); + options.sort_unstable_by_key(|(_idx, score)| Reverse(*score)); + + self.scored_options = options.into_iter().map(|(idx, _)| idx).collect(); + if self.config.reset_cursor { + let _ = self.update_cursor_position(0); + } else if self.scored_options.len() <= self.cursor_index { let _ = self - .update_cursor_position(self.filtered_options.len().saturating_sub(1)); + .update_cursor_position(self.scored_options.len().saturating_sub(1)); } } @@ -284,7 +292,7 @@ where backend.render_multiselect_prompt(prompt, &self.input)?; let choices = self - .filtered_options + .scored_options .iter() .cloned() .map(|i| ListOption::new(i, self.options.get(i).unwrap())) diff --git a/inquire/src/prompts/select/config.rs b/inquire/src/prompts/select/config.rs index 12ccfbda..7e8ea697 100644 --- a/inquire/src/prompts/select/config.rs +++ b/inquire/src/prompts/select/config.rs @@ -7,6 +7,8 @@ pub struct SelectConfig { pub vim_mode: bool, /// Page size of the list of options. pub page_size: usize, + /// Whether to reset the cursor to the first option on filter input change. + pub reset_cursor: bool, } impl From<&Select<'_, T>> for SelectConfig { @@ -14,6 +16,7 @@ impl From<&Select<'_, T>> for SelectConfig { Self { vim_mode: value.vim_mode, page_size: value.page_size, + reset_cursor: value.reset_cursor, } } } diff --git a/inquire/src/prompts/select/mod.rs b/inquire/src/prompts/select/mod.rs index be538bf0..43d0164f 100644 --- a/inquire/src/prompts/select/mod.rs +++ b/inquire/src/prompts/select/mod.rs @@ -6,7 +6,6 @@ mod prompt; mod test; pub use action::*; - use std::fmt::Display; use crate::{ @@ -16,12 +15,18 @@ use crate::{ list_option::ListOption, prompts::prompt::Prompt, terminal::get_default_terminal, - type_aliases::Filter, + type_aliases::Scorer, ui::{Backend, RenderConfig, SelectBackend}, }; use self::prompt::SelectPrompt; +#[cfg(feature = "fuzzy")] +use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +#[cfg(feature = "fuzzy")] +use once_cell::sync::Lazy; +#[cfg(feature = "fuzzy")] +static DEFAULT_MATCHER: Lazy = Lazy::new(|| SkimMatcherV2::default().ignore_case()); /// Prompt suitable for when you need the user to select one option among many. /// /// The user can select and submit the current highlighted option by pressing enter. @@ -43,7 +48,7 @@ use self::prompt::SelectPrompt; /// - Prints the selected option string value by default. /// - **Page size**: Number of options displayed at once, 7 by default. /// - **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. -/// - **Filter function**: Function that defines if an option is displayed or not based on the current filter input. +/// - **Scorer function**: Function that defines the order of options and if displayed as all. /// /// # Example /// @@ -84,9 +89,13 @@ pub struct Select<'a, T> { /// Starting cursor index of the selection. pub starting_cursor: usize, - /// Function called with the current user input to filter the provided + /// Reset cursor position to first option on filter input change. + /// Defaults to true. + pub reset_cursor: bool, + + /// Function called with the current user input to score the provided /// options. - pub filter: Filter<'a, T>, + pub scorer: Scorer<'a, T>, /// Function that formats the user input and presents it to the user as the final rendering of the prompt. pub formatter: OptionFormatter<'a, T>, @@ -121,34 +130,44 @@ where /// ``` pub const DEFAULT_FORMATTER: OptionFormatter<'a, T> = &|ans| ans.to_string(); - /// Default filter function, which checks if the current filter value is a substring of the option value. - /// If it is, the option is displayed. + /// Default scoring function, which will create a score for the current option using the input value. + /// The return will be sorted in Descending order, leaving options with None as a score. /// /// # Examples /// /// ``` /// use inquire::Select; /// - /// let filter = Select::<&str>::DEFAULT_FILTER; - /// assert_eq!(false, filter("sa", &"New York", "New York", 0)); - /// assert_eq!(true, filter("sa", &"Sacramento", "Sacramento", 1)); - /// assert_eq!(true, filter("sa", &"Kansas", "Kansas", 2)); - /// assert_eq!(true, filter("sa", &"Mesa", "Mesa", 3)); - /// assert_eq!(false, filter("sa", &"Phoenix", "Phoenix", 4)); - /// assert_eq!(false, filter("sa", &"Philadelphia", "Philadelphia", 5)); - /// assert_eq!(true, filter("sa", &"San Antonio", "San Antonio", 6)); - /// assert_eq!(true, filter("sa", &"San Diego", "San Diego", 7)); - /// assert_eq!(false, filter("sa", &"Dallas", "Dallas", 8)); - /// assert_eq!(true, filter("sa", &"San Francisco", "San Francisco", 9)); - /// assert_eq!(false, filter("sa", &"Austin", "Austin", 10)); - /// assert_eq!(false, filter("sa", &"Jacksonville", "Jacksonville", 11)); - /// assert_eq!(true, filter("sa", &"San Jose", "San Jose", 12)); + /// let scorer = Select::<&str>::DEFAULT_SCORER; + /// assert_eq!(None, scorer("sa", &"New York", "New York", 0)); + /// assert_eq!(Some(49), scorer("sa", &"Sacramento", "Sacramento", 1)); + /// assert_eq!(Some(35), scorer("sa", &"Kansas", "Kansas", 2)); + /// assert_eq!(Some(35), scorer("sa", &"Mesa", "Mesa", 3)); + /// assert_eq!(None, scorer("sa", &"Phoenix", "Phoenix", 4)); + /// assert_eq!(None, scorer("sa", &"Philadelphia", "Philadelphia", 5)); + /// assert_eq!(Some(49), scorer("sa", &"San Antonio", "San Antonio", 6)); + /// assert_eq!(Some(49), scorer("sa", &"San Diego", "San Diego", 7)); + /// assert_eq!(None, scorer("sa", &"Dallas", "Dallas", 8)); + /// assert_eq!(Some(49), scorer("sa", &"San Francisco", "San Francisco", 9)); + /// assert_eq!(None, scorer("sa", &"Austin", "Austin", 10)); + /// assert_eq!(None, scorer("sa", &"Jacksonville", "Jacksonville", 11)); + /// assert_eq!(Some(49), scorer("sa", &"San Jose", "San Jose", 12)); /// ``` - pub const DEFAULT_FILTER: Filter<'a, T> = &|filter, _, string_value, _| -> bool { - let filter = filter.to_lowercase(); - - string_value.to_lowercase().contains(&filter) - }; + #[cfg(feature = "fuzzy")] + pub const DEFAULT_SCORER: Scorer<'a, T> = + &|input, _option, string_value, _idx| -> Option { + DEFAULT_MATCHER.fuzzy_match(string_value, input) + }; + + #[cfg(not(feature = "fuzzy"))] + pub const DEFAULT_SCORER: Scorer<'a, T> = + &|input, _option, string_value, _idx| -> Option { + let filter = input.to_lowercase(); + match string_value.to_lowercase().contains(&filter) { + true => Some(0), + false => None, + } + }; /// Default page size. pub const DEFAULT_PAGE_SIZE: usize = crate::config::DEFAULT_PAGE_SIZE; @@ -159,6 +178,10 @@ where /// Default starting cursor index. pub const DEFAULT_STARTING_CURSOR: usize = 0; + /// Default cursor behaviour on filter input change. + /// Defaults to true. + pub const DEFAULT_RESET_CURSOR: bool = true; + /// Default help message. pub const DEFAULT_HELP_MESSAGE: Option<&'a str> = Some("↑↓ to move, enter to select, type to filter"); @@ -172,7 +195,8 @@ where page_size: Self::DEFAULT_PAGE_SIZE, vim_mode: Self::DEFAULT_VIM_MODE, starting_cursor: Self::DEFAULT_STARTING_CURSOR, - filter: Self::DEFAULT_FILTER, + reset_cursor: Self::DEFAULT_RESET_CURSOR, + scorer: Self::DEFAULT_SCORER, formatter: Self::DEFAULT_FORMATTER, render_config: get_configuration(), } @@ -202,9 +226,9 @@ where self } - /// Sets the filter function. - pub fn with_filter(mut self, filter: Filter<'a, T>) -> Self { - self.filter = filter; + /// Sets the scoring function. + pub fn with_scorer(mut self, scorer: Scorer<'a, T>) -> Self { + self.scorer = scorer; self } @@ -220,6 +244,14 @@ where self } + /// Sets the reset_cursor behaviour. + /// Will reset cursor to first option on filter input change. + /// Defaults to true. + pub fn with_reset_cursor(mut self, reset_cursor: bool) -> Self { + self.reset_cursor = reset_cursor; + self + } + /// Sets the provided color theme to this prompt. /// /// Note: The default render config considers if the NO_COLOR environment variable diff --git a/inquire/src/prompts/select/prompt.rs b/inquire/src/prompts/select/prompt.rs index efb0dffa..fa8f53cf 100644 --- a/inquire/src/prompts/select/prompt.rs +++ b/inquire/src/prompts/select/prompt.rs @@ -1,4 +1,4 @@ -use std::fmt::Display; +use std::{cmp::Reverse, fmt::Display}; use crate::{ error::InquireResult, @@ -6,7 +6,7 @@ use crate::{ input::{Input, InputActionResult}, list_option::ListOption, prompts::prompt::{ActionResult, Prompt}, - type_aliases::Filter, + type_aliases::Scorer, ui::SelectBackend, utils::paginate, InquireError, Select, @@ -19,11 +19,11 @@ pub struct SelectPrompt<'a, T> { config: SelectConfig, options: Vec, string_options: Vec, - filtered_options: Vec, + scored_options: Vec, help_message: Option<&'a str>, cursor_index: usize, input: Input, - filter: Filter<'a, T>, + scorer: Scorer<'a, T>, formatter: OptionFormatter<'a, T>, } @@ -47,32 +47,36 @@ where } let string_options = so.options.iter().map(T::to_string).collect(); - let filtered_options = (0..so.options.len()).collect(); + let scored_options = (0..so.options.len()).collect(); Ok(Self { message: so.message, config: (&so).into(), options: so.options, string_options, - filtered_options, + scored_options, help_message: so.help_message, cursor_index: so.starting_cursor, input: Input::new(), - filter: so.filter, + scorer: so.scorer, formatter: so.formatter, }) } - fn filter_options(&self) -> Vec { + fn score_options(&self) -> Vec<(usize, i64)> { self.options .iter() .enumerate() - .filter_map(|(i, opt)| match self.input.content() { - val if val.is_empty() => Some(i), - val if (self.filter)(val, opt, self.string_options.get(i).unwrap(), i) => Some(i), - _ => None, + .filter_map(|(i, opt)| { + (self.scorer)( + self.input.content(), + opt, + self.string_options.get(i).unwrap(), + i, + ) + .map(|score| (i, score)) }) - .collect() + .collect::>() } fn move_cursor_up(&mut self, qty: usize, wrap: bool) -> ActionResult { @@ -80,7 +84,7 @@ where let after_wrap = qty.saturating_sub(self.cursor_index); self.cursor_index .checked_sub(qty) - .unwrap_or_else(|| self.filtered_options.len().saturating_sub(after_wrap)) + .unwrap_or_else(|| self.scored_options.len().saturating_sub(after_wrap)) } else { self.cursor_index.saturating_sub(qty) }; @@ -91,13 +95,13 @@ where fn move_cursor_down(&mut self, qty: usize, wrap: bool) -> ActionResult { let mut new_position = self.cursor_index.saturating_add(qty); - if new_position >= self.filtered_options.len() { - new_position = if self.filtered_options.is_empty() { + if new_position >= self.scored_options.len() { + new_position = if self.scored_options.is_empty() { 0 } else if wrap { - new_position % self.filtered_options.len() + new_position % self.scored_options.len() } else { - self.filtered_options.len().saturating_sub(1) + self.scored_options.len().saturating_sub(1) } } @@ -114,14 +118,14 @@ where } fn has_answer_highlighted(&mut self) -> bool { - self.filtered_options.get(self.cursor_index).is_some() + self.scored_options.get(self.cursor_index).is_some() } fn get_final_answer(&mut self) -> ListOption { // should only be called after current cursor index is validated // on has_answer_highlighted - let index = *self.filtered_options.get(self.cursor_index).unwrap(); + let index = *self.scored_options.get(self.cursor_index).unwrap(); let value = self.options.swap_remove(index); ListOption::new(index, value) @@ -170,11 +174,15 @@ where let result = self.input.handle(input_action); if let InputActionResult::ContentChanged = result { - let options = self.filter_options(); - self.filtered_options = options; - if self.filtered_options.len() <= self.cursor_index { + let mut options = self.score_options(); + options.sort_unstable_by_key(|(_idx, score)| Reverse(*score)); + + self.scored_options = options.into_iter().map(|(idx, _)| idx).collect(); + if self.config.reset_cursor { + let _ = self.update_cursor_position(0); + } else if self.scored_options.len() <= self.cursor_index { let _ = self - .update_cursor_position(self.filtered_options.len().saturating_sub(1)); + .update_cursor_position(self.scored_options.len().saturating_sub(1)); } } @@ -191,7 +199,7 @@ where backend.render_select_prompt(prompt, &self.input)?; let choices = self - .filtered_options + .scored_options .iter() .cloned() .map(|i| ListOption::new(i, self.options.get(i).unwrap())) diff --git a/inquire/src/type_aliases.rs b/inquire/src/type_aliases.rs index 6093bf09..bcc4e7e7 100644 --- a/inquire/src/type_aliases.rs +++ b/inquire/src/type_aliases.rs @@ -2,7 +2,7 @@ use crate::error::CustomUserError; -/// Type alias to represent the function used to filter options. +/// Type alias to represent the function used to Score and filter options. /// /// The function receives: /// - Current user input, filter value @@ -10,33 +10,68 @@ use crate::error::CustomUserError; /// - String value of the current option /// - Index of the current option in the original list /// -/// The return type should be whether the current option should be displayed to the user. +/// The return type should be a score determining the order options should be displayed to the user. +/// The greater the score, the higher on the list it will be displayed. /// /// # Examples /// /// ``` -/// use inquire::type_aliases::Filter; -/// -/// let filter: Filter = &|filter, _, string_value, _| -> bool { -/// let filter = filter.to_lowercase(); -/// -/// string_value.to_lowercase().starts_with(&filter) -/// }; -/// assert_eq!(false, filter("san", "New York", "New York", 0)); -/// assert_eq!(false, filter("san", "Los Angeles", "Los Angeles", 1)); -/// assert_eq!(false, filter("san", "Chicago", "Chicago", 2)); -/// assert_eq!(false, filter("san", "Houston", "Houston", 3)); -/// assert_eq!(false, filter("san", "Phoenix", "Phoenix", 4)); -/// assert_eq!(false, filter("san", "Philadelphia", "Philadelphia", 5)); -/// assert_eq!(true, filter("san", "San Antonio", "San Antonio", 6)); -/// assert_eq!(true, filter("san", "San Diego", "San Diego", 7)); -/// assert_eq!(false, filter("san", "Dallas", "Dallas", 8)); -/// assert_eq!(true, filter("san", "San Francisco", "San Francisco", 9)); -/// assert_eq!(false, filter("san", "Austin", "Austin", 10)); -/// assert_eq!(false, filter("san", "Jacksonville", "Jacksonville", 11)); -/// assert_eq!(true, filter("san", "San Jose", "San Jose", 12)); +/// use inquire::type_aliases::Scorer; +/// +/// // Implement simpler 'contains' filter that maintains the current order +/// // If all scores are the same, no sorting will occur +/// let filter: Scorer = +/// &|input, _option, string_value, _idx| -> Option { +/// let filter = input.to_lowercase(); +/// match string_value.to_lowercase().contains(&filter) { +/// true => Some(0), +/// false => None, +/// } +/// }; +/// +/// assert_eq!(None, filter("sa", "New York", "New York", 0)); +/// assert_eq!(None, filter("sa", "Los Angeles", "Los Angeles", 1)); +/// assert_eq!(None, filter("sa", "Chicago", "Chicago", 2)); +/// assert_eq!(None, filter("sa", "Houston", "Houston", 3)); +/// assert_eq!(None, filter("sa", "Phoenix", "Phoenix", 4)); +/// assert_eq!(None, filter("sa", "Philadelphia", "Philadelphia", 5)); +/// assert_eq!(Some(0), filter("sa", "San Antonio", "San Antonio", 6)); +/// assert_eq!(Some(0), filter("sa", "San Diego", "San Diego", 7)); +/// assert_eq!(None, filter("sa", "Dallas", "Dallas", 8)); +/// assert_eq!(Some(0), filter("sa", "San Francisco", "San Francisco", 9)); +/// assert_eq!(None, filter("sa", "Austin", "Austin", 10)); +/// assert_eq!(None, filter("sa", "Jacksonville", "Jacksonville", 11)); +/// assert_eq!(Some(0), filter("sa", "San Jose", "San Jose", 12)); +///``` +/// +/// +/// +/// Default implementation for fuzzy search (almost) +///``` +/// use inquire::type_aliases::Scorer; +/// use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher}; +/// +/// pub const DEFAULT_SCORER: Scorer = +/// &|input, _option, string_value, _idx| -> Option { +/// let matcher = SkimMatcherV2::default().ignore_case(); +/// matcher.fuzzy_match(string_value, input) +/// }; +/// +/// assert_eq!(None, DEFAULT_SCORER("sa", &"New York", "New York", 0)); +/// assert_eq!(Some(49), DEFAULT_SCORER("sa", &"Sacramento", "Sacramento", 1)); +/// assert_eq!(Some(35), DEFAULT_SCORER("sa", &"Kansas", "Kansas", 2)); +/// assert_eq!(Some(35), DEFAULT_SCORER("sa", &"Mesa", "Mesa", 3)); +/// assert_eq!(None, DEFAULT_SCORER("sa", &"Phoenix", "Phoenix", 4)); +/// assert_eq!(None, DEFAULT_SCORER("sa", &"Philadelphia", "Philadelphia", 5)); +/// assert_eq!(Some(49), DEFAULT_SCORER("sa", &"San Antonio", "San Antonio", 6)); +/// assert_eq!(Some(49), DEFAULT_SCORER("sa", &"San Diego", "San Diego", 7)); +/// assert_eq!(None, DEFAULT_SCORER("sa", &"Dallas", "Dallas", 8)); +/// assert_eq!(Some(49), DEFAULT_SCORER("sa", &"San Francisco", "San Francisco", 9)); +/// assert_eq!(None, DEFAULT_SCORER("sa", &"Austin", "Austin", 10)); +/// assert_eq!(None, DEFAULT_SCORER("sa", &"Jacksonville", "Jacksonville", 11)); +/// assert_eq!(Some(49), DEFAULT_SCORER("sa", &"San Jose", "San Jose", 12)); /// ``` -pub type Filter<'a, T> = &'a dyn Fn(&str, &T, &str, usize) -> bool; +pub type Scorer<'a, T> = &'a dyn Fn(&str, &T, &str, usize) -> Option; /// Type alias to represent the function used to retrieve text input suggestions. /// The function receives the current input and should return a collection of strings