mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-03 01:50:12 +00:00
feat(cli): add substring matching with --exact
flag (#477)
fixes #368 @alexpasmantier I tried to parse down the exact flag to the find method, but I can't find an elegant way to do so because the channel creates its own configurations for the matcher inside of the `new` method. I started adding the exact flag to the new function of each of the channels but it just does not seem right to do so. Do you have an idea on how to improve the change? I would think of passing the config struct to the new function and exposing the config of the channel for channel transitions, but I'm not sure... 🤔 I have done it here for the stdin channel just to show you how it would look like and it works fine😄! Looking forward to hearing your thoughts on that 👍 ref issue: #368 --------- Co-authored-by: Alexandre Pasmantier <alex.pasmant@gmail.com>
This commit is contained in:
parent
ce02824f3c
commit
bbbdcb0271
@ -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);
|
||||
|
@ -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 {
|
||||
|
@ -99,6 +99,14 @@ pub struct Cli {
|
||||
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
|
||||
pub autocomplete_prompt: Option<String>,
|
||||
|
||||
/// 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.
|
||||
///
|
||||
|
@ -31,6 +31,7 @@ pub struct PostProcessedCli {
|
||||
pub working_directory: Option<String>,
|
||||
pub autocomplete_prompt: Option<String>,
|
||||
pub keybindings: Option<KeyBindings>,
|
||||
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<Cli> 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,
|
||||
|
@ -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,
|
||||
|
@ -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<usize>,
|
||||
|
@ -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<Action>,
|
||||
pub config: Config,
|
||||
@ -38,6 +44,7 @@ pub struct Television {
|
||||
pub remote_control: Option<TelevisionChannel>,
|
||||
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<String>,
|
||||
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::<Vec<String>>()
|
||||
.join(" ");
|
||||
}
|
||||
pattern.to_string()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_selected_entry(&self, mode: Option<Mode>) -> Option<Entry> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
84
tests/app.rs
84
tests/app.rs
@ -23,6 +23,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
fn setup_app(
|
||||
channel: Option<TelevisionChannel>,
|
||||
select_1: bool,
|
||||
exact: bool,
|
||||
) -> (
|
||||
JoinHandle<television::app::AppOutput>,
|
||||
tokio::sync::mpsc::UnboundedSender<Action>,
|
||||
@ -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,69 @@ 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<_>>(),
|
||||
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();
|
||||
|
||||
let selected_entries = output.selected_entries.clone();
|
||||
assert!(selected_entries.is_some());
|
||||
// should contain a single entry with the prompt
|
||||
assert!(!selected_entries.as_ref().unwrap().is_empty());
|
||||
assert_eq!(
|
||||
selected_entries.unwrap().drain().next().unwrap().name,
|
||||
"fie"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 +218,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 +252,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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user