diff --git a/benches/main/ui.rs b/benches/main/ui.rs index 91ac5df..efee73c 100644 --- a/benches/main/ui.rs +++ b/benches/main/ui.rs @@ -512,8 +512,9 @@ pub fn draw(c: &mut Criterion) { ])); channel.find("television"); // Wait for the channel to finish loading - let mut tv = - Television::new(tx, channel, config, None, false, false); + let mut tv = Television::new( + tx, channel, config, None, false, false, false, + ); for _ in 0..5 { // tick the matcher let _ = tv.channel.results(10, 0); diff --git a/television/app.rs b/television/app.rs index 81fea6d..2a0119a 100644 --- a/television/app.rs +++ b/television/app.rs @@ -16,7 +16,11 @@ use crate::{ render::{render, RenderingTask}, }; +#[allow(clippy::struct_excessive_bools)] pub struct AppOptions { + /// Whether the application should use subsring matching instead of fuzzy + /// matching. + pub exact: bool, /// Whether the application should automatically select the first entry if there is only one /// entry available. pub select_1: bool, @@ -30,6 +34,7 @@ pub struct AppOptions { impl Default for AppOptions { fn default() -> Self { Self { + exact: false, select_1: false, no_remote: false, no_help: false, @@ -39,13 +44,16 @@ impl Default for AppOptions { } impl AppOptions { + #[allow(clippy::fn_params_excessive_bools)] pub fn new( + exact: bool, select_1: bool, no_remote: bool, no_help: bool, tick_rate: f64, ) -> Self { Self { + exact, select_1, no_remote, no_help, @@ -149,6 +157,7 @@ impl App { input, options.no_remote, options.no_help, + options.exact, ); Self { diff --git a/television/cli/args.rs b/television/cli/args.rs index 9cea433..29a2543 100644 --- a/television/cli/args.rs +++ b/television/cli/args.rs @@ -99,6 +99,14 @@ pub struct Cli { #[arg(long, value_name = "STRING", verbatim_doc_comment)] pub autocomplete_prompt: Option, + /// Use substring matching instead of fuzzy matching. + /// + /// This will use substring matching instead of fuzzy matching when + /// searching for entries. This is useful when the user wants to search for + /// an exact match instead of a fuzzy match e.g. to improve performance. + #[arg(long, default_value = "false", verbatim_doc_comment)] + pub exact: bool, + /// Automatically select and output the first entry if there is only one /// entry. /// diff --git a/television/cli/mod.rs b/television/cli/mod.rs index 204ffd0..5e31731 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -31,6 +31,7 @@ pub struct PostProcessedCli { pub working_directory: Option, pub autocomplete_prompt: Option, pub keybindings: Option, + pub exact: bool, pub select_1: bool, pub no_remote: bool, pub no_help: bool, @@ -50,6 +51,7 @@ impl Default for PostProcessedCli { working_directory: None, autocomplete_prompt: None, keybindings: None, + exact: false, select_1: false, no_remote: false, no_help: false, @@ -118,6 +120,7 @@ impl From for PostProcessedCli { working_directory, autocomplete_prompt: cli.autocomplete_prompt, keybindings, + exact: cli.exact, select_1: cli.select_1, no_remote: cli.no_remote, no_help: cli.no_help, diff --git a/television/main.rs b/television/main.rs index d7ba3c6..2883f8d 100644 --- a/television/main.rs +++ b/television/main.rs @@ -66,6 +66,7 @@ async fn main() -> Result<()> { debug!("Creating application..."); let options = AppOptions::new( + args.exact, args.select_1, args.no_remote, args.no_help, diff --git a/television/matcher/config.rs b/television/matcher/config.rs index 0f6cc08..cd6da88 100644 --- a/television/matcher/config.rs +++ b/television/matcher/config.rs @@ -9,6 +9,7 @@ /// default number of threads (which corresponds to the number of available logical /// cores on the current machine). #[derive(Copy, Clone, Debug)] +#[allow(clippy::struct_excessive_bools)] pub struct Config { /// The number of threads to use for the fuzzy matcher. pub n_threads: Option, diff --git a/television/television.rs b/television/television.rs index d140ca8..a58b768 100644 --- a/television/television.rs +++ b/television/television.rs @@ -31,6 +31,12 @@ pub enum Mode { SendToChannel, } +#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)] +pub enum MatchingMode { + Substring, + Fuzzy, +} + pub struct Television { action_tx: UnboundedSender, pub config: Config, @@ -38,6 +44,7 @@ pub struct Television { pub remote_control: Option, pub mode: Mode, pub current_pattern: String, + pub matching_mode: MatchingMode, pub results_picker: Picker, pub rc_picker: Picker, pub previewer: Previewer, @@ -60,6 +67,7 @@ impl Television { input: Option, no_remote: bool, no_help: bool, + exact: bool, ) -> Self { let mut results_picker = Picker::new(input.clone()); if config.ui.input_bar_position == InputPosition::Bottom { @@ -103,6 +111,12 @@ impl Television { config.ui.show_help_bar = false; } + let matching_mode = if exact { + MatchingMode::Substring + } else { + MatchingMode::Fuzzy + }; + Self { action_tx, config, @@ -111,6 +125,7 @@ impl Television { mode: Mode::Channel, current_pattern: EMPTY_STRING.to_string(), results_picker, + matching_mode, rc_picker: Picker::default(), previewer, preview_state, @@ -182,7 +197,10 @@ impl Television { fn find(&mut self, pattern: &str) { match self.mode { Mode::Channel => { - self.channel.find(pattern); + self.channel.find( + Self::preprocess_pattern(self.matching_mode, pattern) + .as_str(), + ); } Mode::RemoteControl | Mode::SendToChannel => { self.remote_control.as_mut().unwrap().find(pattern); @@ -190,6 +208,21 @@ impl Television { } } + fn preprocess_pattern(mode: MatchingMode, pattern: &str) -> String { + if mode == MatchingMode::Substring { + return pattern + .split_ascii_whitespace() + .map(|x| { + let mut new = x.to_string(); + new.insert(0, '\''); + new + }) + .collect::>() + .join(" "); + } + pattern.to_string() + } + #[must_use] pub fn get_selected_entry(&self, mode: Option) -> Option { match mode.unwrap_or(self.mode) { @@ -660,3 +693,24 @@ impl Television { }) } } + +#[cfg(test)] +mod test { + use crate::television::{MatchingMode, Television}; + + #[test] + fn test_prompt_preprocessing() { + let one_word = "test"; + let mult_word = "this is a specific test"; + let expect_one = "'test"; + let expect_mult = "'this 'is 'a 'specific 'test"; + assert_eq!( + Television::preprocess_pattern(MatchingMode::Substring, one_word), + expect_one + ); + assert_eq!( + Television::preprocess_pattern(MatchingMode::Substring, mult_word), + expect_mult + ); + } +} diff --git a/tests/app.rs b/tests/app.rs index 64e9e85..238cf0f 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -23,6 +23,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100); fn setup_app( channel: Option, select_1: bool, + exact: bool, ) -> ( JoinHandle, tokio::sync::mpsc::UnboundedSender, @@ -41,8 +42,13 @@ fn setup_app( config.application.tick_rate = 100.0; let input = None; - let options = - AppOptions::new(select_1, false, false, config.application.tick_rate); + let options = AppOptions::new( + exact, + select_1, + false, + false, + config.application.tick_rate, + ); let mut app = App::new(chan, config, input, options); // retrieve the app's action channel handle in order to send a quit action @@ -59,7 +65,7 @@ fn setup_app( #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_does_quit() { - let (f, tx) = setup_app(None, false); + let (f, tx) = setup_app(None, false, false); // send a quit action to the app tx.send(Action::Quit).unwrap(); @@ -71,7 +77,7 @@ async fn test_app_does_quit() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_starts_normally() { - let (f, _) = setup_app(None, false); + let (f, _) = setup_app(None, false, false); // assert that the app is still running after the default timeout std::thread::sleep(DEFAULT_TIMEOUT); @@ -82,7 +88,7 @@ async fn test_app_starts_normally() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_basic_search() { - let (f, tx) = setup_app(None, false); + let (f, tx) = setup_app(None, false, false); // send actions to the app for c in "file1".chars() { @@ -111,7 +117,81 @@ async fn test_app_basic_search() { #[tokio::test(flavor = "multi_thread", worker_threads = 3)] async fn test_app_basic_search_multiselect() { - let (f, tx) = setup_app(None, false); + let (f, tx) = setup_app(None, false, false); + + // send actions to the app + for c in "file".chars() { + tx.send(Action::AddInputChar(c)).unwrap(); + } + + // select both files + tx.send(Action::ToggleSelectionDown).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + tx.send(Action::ToggleSelectionDown).unwrap(); + std::thread::sleep(Duration::from_millis(50)); + tx.send(Action::ConfirmSelection).unwrap(); + + // check the output with a timeout + let output = timeout(DEFAULT_TIMEOUT, f) + .await + .expect("app did not finish within the default timeout") + .unwrap(); + + assert!(output.selected_entries.is_some()); + assert_eq!( + output + .selected_entries + .as_ref() + .unwrap() + .iter() + .map(|e| &e.name) + .collect::>(), + HashSet::from([&"file1.txt".to_string(), &"file2.txt".to_string()]) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_exact_search_multiselect() { + let (f, tx) = setup_app(None, false, true); + + // send actions to the app + for c in "fie".chars() { + tx.send(Action::AddInputChar(c)).unwrap(); + } + + tx.send(Action::ConfirmSelection).unwrap(); + + // check the output with a timeout + let output = timeout(DEFAULT_TIMEOUT, f) + .await + .expect("app did not finish within the default timeout") + .unwrap(); + + assert!(output.selected_entries.is_some()); + assert!(&output + .selected_entries + .clone() + .unwrap() + .drain() + .next() + .unwrap() + .value + .is_none()); + assert_eq!( + output + .selected_entries + .as_ref() + .unwrap() + .iter() + .map(|e| &e.name) + .collect::>(), + HashSet::from([&"fie".to_string()]) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 3)] +async fn test_app_exact_search_positive() { + let (f, tx) = setup_app(None, false, true); // send actions to the app for c in "file".chars() { @@ -150,7 +230,7 @@ async fn test_app_exits_when_select_1_and_only_one_result() { TelevisionChannel::Stdin(television::channels::stdin::Channel::from( vec!["file1.txt".to_string()], )); - let (f, tx) = setup_app(Some(channel), true); + let (f, tx) = setup_app(Some(channel), true, false); // tick a few times to get the results for _ in 0..=10 { @@ -184,7 +264,7 @@ async fn test_app_does_not_exit_when_select_1_and_more_than_one_result() { TelevisionChannel::Stdin(television::channels::stdin::Channel::from( vec!["file1.txt".to_string(), "file2.txt".to_string()], )); - let (f, tx) = setup_app(Some(channel), true); + let (f, tx) = setup_app(Some(channel), true, false); // tick a few times to get the results for _ in 0..=10 {