Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement fuzzy finder as default filter for Select/MultiSelect #176

Merged
merged 12 commits into from
Oct 1, 2023
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>`, 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
Expand All @@ -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

Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -154,13 +154,13 @@ The default parser for [`CustomType`] prompts calls the `parse::<T>()` 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<i64>` 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(<score>_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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion inquire/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions inquire/src/prompts/multiselect/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> From<&MultiSelect<'_, T>> for MultiSelectConfig {
Expand All @@ -17,6 +19,7 @@ impl<T> 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,
}
}
}
92 changes: 63 additions & 29 deletions inquire/src/prompts/multiselect/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<SkimMatcherV2> = 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.
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<i64> {
DEFAULT_MATCHER.fuzzy_match(string_value, input)
};

#[cfg(not(feature = "fuzzy"))]
pub const DEFAULT_SCORER: Scorer<'a, T> =
&|input, _option, string_value, _idx| -> Option<i64> {
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;
Expand All @@ -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;

Expand All @@ -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(),
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading