mirror of
https://github.com/alexpasmantier/television.git
synced 2025-06-07 12:05:34 +00:00
feat: add substring matching with exact flag
This commit is contained in:
parent
ce02824f3c
commit
0abeb904de
@ -512,8 +512,9 @@ pub fn draw(c: &mut Criterion) {
|
|||||||
]));
|
]));
|
||||||
channel.find("television");
|
channel.find("television");
|
||||||
// Wait for the channel to finish loading
|
// Wait for the channel to finish loading
|
||||||
let mut tv =
|
let mut tv = Television::new(
|
||||||
Television::new(tx, channel, config, None, false, false);
|
tx, channel, config, None, false, false, false,
|
||||||
|
);
|
||||||
for _ in 0..5 {
|
for _ in 0..5 {
|
||||||
// tick the matcher
|
// tick the matcher
|
||||||
let _ = tv.channel.results(10, 0);
|
let _ = tv.channel.results(10, 0);
|
||||||
|
@ -16,7 +16,11 @@ use crate::{
|
|||||||
render::{render, RenderingTask},
|
render::{render, RenderingTask},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct AppOptions {
|
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
|
/// Whether the application should automatically select the first entry if there is only one
|
||||||
/// entry available.
|
/// entry available.
|
||||||
pub select_1: bool,
|
pub select_1: bool,
|
||||||
@ -30,6 +34,7 @@ pub struct AppOptions {
|
|||||||
impl Default for AppOptions {
|
impl Default for AppOptions {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
exact: false,
|
||||||
select_1: false,
|
select_1: false,
|
||||||
no_remote: false,
|
no_remote: false,
|
||||||
no_help: false,
|
no_help: false,
|
||||||
@ -39,13 +44,16 @@ impl Default for AppOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppOptions {
|
impl AppOptions {
|
||||||
|
#[allow(clippy::fn_params_excessive_bools)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
|
exact: bool,
|
||||||
select_1: bool,
|
select_1: bool,
|
||||||
no_remote: bool,
|
no_remote: bool,
|
||||||
no_help: bool,
|
no_help: bool,
|
||||||
tick_rate: f64,
|
tick_rate: f64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
exact,
|
||||||
select_1,
|
select_1,
|
||||||
no_remote,
|
no_remote,
|
||||||
no_help,
|
no_help,
|
||||||
@ -149,6 +157,7 @@ impl App {
|
|||||||
input,
|
input,
|
||||||
options.no_remote,
|
options.no_remote,
|
||||||
options.no_help,
|
options.no_help,
|
||||||
|
options.exact,
|
||||||
);
|
);
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
|
@ -99,6 +99,14 @@ pub struct Cli {
|
|||||||
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
|
#[arg(long, value_name = "STRING", verbatim_doc_comment)]
|
||||||
pub autocomplete_prompt: Option<String>,
|
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
|
/// Automatically select and output the first entry if there is only one
|
||||||
/// entry.
|
/// entry.
|
||||||
///
|
///
|
||||||
|
@ -31,6 +31,7 @@ pub struct PostProcessedCli {
|
|||||||
pub working_directory: Option<String>,
|
pub working_directory: Option<String>,
|
||||||
pub autocomplete_prompt: Option<String>,
|
pub autocomplete_prompt: Option<String>,
|
||||||
pub keybindings: Option<KeyBindings>,
|
pub keybindings: Option<KeyBindings>,
|
||||||
|
pub exact: bool,
|
||||||
pub select_1: bool,
|
pub select_1: bool,
|
||||||
pub no_remote: bool,
|
pub no_remote: bool,
|
||||||
pub no_help: bool,
|
pub no_help: bool,
|
||||||
@ -50,6 +51,7 @@ impl Default for PostProcessedCli {
|
|||||||
working_directory: None,
|
working_directory: None,
|
||||||
autocomplete_prompt: None,
|
autocomplete_prompt: None,
|
||||||
keybindings: None,
|
keybindings: None,
|
||||||
|
exact: false,
|
||||||
select_1: false,
|
select_1: false,
|
||||||
no_remote: false,
|
no_remote: false,
|
||||||
no_help: false,
|
no_help: false,
|
||||||
@ -118,6 +120,7 @@ impl From<Cli> for PostProcessedCli {
|
|||||||
working_directory,
|
working_directory,
|
||||||
autocomplete_prompt: cli.autocomplete_prompt,
|
autocomplete_prompt: cli.autocomplete_prompt,
|
||||||
keybindings,
|
keybindings,
|
||||||
|
exact: cli.exact,
|
||||||
select_1: cli.select_1,
|
select_1: cli.select_1,
|
||||||
no_remote: cli.no_remote,
|
no_remote: cli.no_remote,
|
||||||
no_help: cli.no_help,
|
no_help: cli.no_help,
|
||||||
|
@ -66,6 +66,7 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
debug!("Creating application...");
|
debug!("Creating application...");
|
||||||
let options = AppOptions::new(
|
let options = AppOptions::new(
|
||||||
|
args.exact,
|
||||||
args.select_1,
|
args.select_1,
|
||||||
args.no_remote,
|
args.no_remote,
|
||||||
args.no_help,
|
args.no_help,
|
||||||
|
@ -9,6 +9,7 @@
|
|||||||
/// default number of threads (which corresponds to the number of available logical
|
/// default number of threads (which corresponds to the number of available logical
|
||||||
/// cores on the current machine).
|
/// cores on the current machine).
|
||||||
#[derive(Copy, Clone, Debug)]
|
#[derive(Copy, Clone, Debug)]
|
||||||
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// The number of threads to use for the fuzzy matcher.
|
/// The number of threads to use for the fuzzy matcher.
|
||||||
pub n_threads: Option<usize>,
|
pub n_threads: Option<usize>,
|
||||||
|
@ -31,6 +31,12 @@ pub enum Mode {
|
|||||||
SendToChannel,
|
SendToChannel,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(PartialEq, Copy, Clone, Hash, Eq, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum MatchingMode {
|
||||||
|
Substring,
|
||||||
|
Fuzzy,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Television {
|
pub struct Television {
|
||||||
action_tx: UnboundedSender<Action>,
|
action_tx: UnboundedSender<Action>,
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
@ -38,6 +44,7 @@ pub struct Television {
|
|||||||
pub remote_control: Option<TelevisionChannel>,
|
pub remote_control: Option<TelevisionChannel>,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub current_pattern: String,
|
pub current_pattern: String,
|
||||||
|
pub matching_mode: MatchingMode,
|
||||||
pub results_picker: Picker,
|
pub results_picker: Picker,
|
||||||
pub rc_picker: Picker,
|
pub rc_picker: Picker,
|
||||||
pub previewer: Previewer,
|
pub previewer: Previewer,
|
||||||
@ -60,6 +67,7 @@ impl Television {
|
|||||||
input: Option<String>,
|
input: Option<String>,
|
||||||
no_remote: bool,
|
no_remote: bool,
|
||||||
no_help: bool,
|
no_help: bool,
|
||||||
|
exact: bool,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let mut results_picker = Picker::new(input.clone());
|
let mut results_picker = Picker::new(input.clone());
|
||||||
if config.ui.input_bar_position == InputPosition::Bottom {
|
if config.ui.input_bar_position == InputPosition::Bottom {
|
||||||
@ -103,6 +111,12 @@ impl Television {
|
|||||||
config.ui.show_help_bar = false;
|
config.ui.show_help_bar = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let matching_mode = if exact {
|
||||||
|
MatchingMode::Substring
|
||||||
|
} else {
|
||||||
|
MatchingMode::Fuzzy
|
||||||
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
action_tx,
|
action_tx,
|
||||||
config,
|
config,
|
||||||
@ -111,6 +125,7 @@ impl Television {
|
|||||||
mode: Mode::Channel,
|
mode: Mode::Channel,
|
||||||
current_pattern: EMPTY_STRING.to_string(),
|
current_pattern: EMPTY_STRING.to_string(),
|
||||||
results_picker,
|
results_picker,
|
||||||
|
matching_mode,
|
||||||
rc_picker: Picker::default(),
|
rc_picker: Picker::default(),
|
||||||
previewer,
|
previewer,
|
||||||
preview_state,
|
preview_state,
|
||||||
@ -182,7 +197,10 @@ impl Television {
|
|||||||
fn find(&mut self, pattern: &str) {
|
fn find(&mut self, pattern: &str) {
|
||||||
match self.mode {
|
match self.mode {
|
||||||
Mode::Channel => {
|
Mode::Channel => {
|
||||||
self.channel.find(pattern);
|
self.channel.find(
|
||||||
|
Self::preprocess_pattern(self.matching_mode, pattern)
|
||||||
|
.as_str(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Mode::RemoteControl | Mode::SendToChannel => {
|
Mode::RemoteControl | Mode::SendToChannel => {
|
||||||
self.remote_control.as_mut().unwrap().find(pattern);
|
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]
|
#[must_use]
|
||||||
pub fn get_selected_entry(&self, mode: Option<Mode>) -> Option<Entry> {
|
pub fn get_selected_entry(&self, mode: Option<Mode>) -> Option<Entry> {
|
||||||
match mode.unwrap_or(self.mode) {
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
96
tests/app.rs
96
tests/app.rs
@ -23,6 +23,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_millis(100);
|
|||||||
fn setup_app(
|
fn setup_app(
|
||||||
channel: Option<TelevisionChannel>,
|
channel: Option<TelevisionChannel>,
|
||||||
select_1: bool,
|
select_1: bool,
|
||||||
|
exact: bool,
|
||||||
) -> (
|
) -> (
|
||||||
JoinHandle<television::app::AppOutput>,
|
JoinHandle<television::app::AppOutput>,
|
||||||
tokio::sync::mpsc::UnboundedSender<Action>,
|
tokio::sync::mpsc::UnboundedSender<Action>,
|
||||||
@ -41,8 +42,13 @@ fn setup_app(
|
|||||||
config.application.tick_rate = 100.0;
|
config.application.tick_rate = 100.0;
|
||||||
let input = None;
|
let input = None;
|
||||||
|
|
||||||
let options =
|
let options = AppOptions::new(
|
||||||
AppOptions::new(select_1, false, false, config.application.tick_rate);
|
exact,
|
||||||
|
select_1,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
config.application.tick_rate,
|
||||||
|
);
|
||||||
let mut app = App::new(chan, config, input, options);
|
let mut app = App::new(chan, config, input, options);
|
||||||
|
|
||||||
// retrieve the app's action channel handle in order to send a quit action
|
// 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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
||||||
async fn test_app_does_quit() {
|
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
|
// send a quit action to the app
|
||||||
tx.send(Action::Quit).unwrap();
|
tx.send(Action::Quit).unwrap();
|
||||||
@ -71,7 +77,7 @@ async fn test_app_does_quit() {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
||||||
async fn test_app_starts_normally() {
|
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
|
// assert that the app is still running after the default timeout
|
||||||
std::thread::sleep(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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
||||||
async fn test_app_basic_search() {
|
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
|
// send actions to the app
|
||||||
for c in "file1".chars() {
|
for c in "file1".chars() {
|
||||||
@ -111,7 +117,81 @@ async fn test_app_basic_search() {
|
|||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
|
||||||
async fn test_app_basic_search_multiselect() {
|
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();
|
||||||
|
|
||||||
|
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<_>>(),
|
||||||
|
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
|
// send actions to the app
|
||||||
for c in "file".chars() {
|
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(
|
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
|
||||||
vec!["file1.txt".to_string()],
|
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
|
// tick a few times to get the results
|
||||||
for _ in 0..=10 {
|
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(
|
TelevisionChannel::Stdin(television::channels::stdin::Channel::from(
|
||||||
vec!["file1.txt".to_string(), "file2.txt".to_string()],
|
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
|
// tick a few times to get the results
|
||||||
for _ in 0..=10 {
|
for _ in 0..=10 {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user