diff --git a/Cargo.toml b/Cargo.toml index cdb3c7b..63d51d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ regex = { version = "^1", optional = true } crossbeam-channel = "^0.5" parking_lot = "0.12.1" arboard = { version = "3", optional = true, default-features = false, features = ["wayland-data-control"] } +rustyline = { version = "18.0.0", default-features = false, features = ["custom-bindings"] } [features] search = [ "regex" ] diff --git a/src/core/commands.rs b/src/core/commands.rs index 7dc3b32..bb76293 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -8,7 +8,7 @@ use std::fmt::Debug; use crate::{ ExitStrategy, LineNumbers, hooks::{Hook, HookCallback}, - input::{InputClassifier, InputEvent}, + input::{self, InputEvent, hashed_event_register::EventWrapper}, minus_core::utils::display::AppendStyle, }; @@ -29,6 +29,19 @@ pub enum IoCommand { FetchSearchQuery, } +#[derive(Debug, PartialEq, Eq)] +pub enum InputType { + KeyMouse(Vec), + AllKeys, + AllMouses, + Resize, + Wild, +} + +#[cfg(feature = "search")] +pub type IncrementalSearchCondition = + Box bool + Send + Sync + 'static>; + /// Different events that can be encountered while the pager is running #[non_exhaustive] #[allow(private_interfaces)] @@ -52,14 +65,17 @@ pub enum Command { // Configuration options SetExitStrategy(ExitStrategy), - SetInputClassifier(Box), AddExitCallback(Box), AddHook(Hook, u64, HookCallback), RemoveHook(Hook, u64), #[cfg(feature = "static_output")] SetRunNoOverflow(bool), #[cfg(feature = "search")] - IncrementalSearchCondition(Box bool + Send + Sync + 'static>), + SetIncrementalSearchCondition(IncrementalSearchCondition), + + // Input + AddInputBinding(InputType, input::InputEventBoxed), + RemoveInputBinding(InputType), Io(IoCommand), } @@ -77,12 +93,15 @@ impl PartialEq for Command { (Self::SetExitStrategy(d1), Self::SetExitStrategy(d2)) => d1 == d2, #[cfg(feature = "static_output")] (Self::SetRunNoOverflow(d1), Self::SetRunNoOverflow(d2)) => d1 == d2, - (Self::SetInputClassifier(_), Self::SetInputClassifier(_)) - | (Self::AddExitCallback(_), Self::AddExitCallback(_)) + (Self::AddExitCallback(_), Self::AddExitCallback(_)) | (Self::AddHook(..), Self::AddHook(..)) => true, (Self::RemoveHook(h1, id1), Self::RemoveHook(h2, id2)) => h1 == h2 && id1 == id2, #[cfg(feature = "search")] - (Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true, + (Self::SetIncrementalSearchCondition(_), Self::SetIncrementalSearchCondition(_)) => { + true + } + (Self::AddInputBinding(et_a, _), Self::AddInputBinding(et_b, _)) => et_a == et_b, + (Self::RemoveInputBinding(et_a), Self::RemoveInputBinding(et_b)) => et_a == et_b, (Self::Io(a), Self::Io(b)) => a == b, _ => false, } @@ -99,16 +118,17 @@ impl Debug for Command { Self::SetLineNumbers(ln) => write!(f, "SetLineNumbers({ln:?})"), Self::LineWrapping(lw) => write!(f, "LineWrapping({lw:?})"), Self::SetExitStrategy(es) => write!(f, "SetExitStrategy({es:?})"), - Self::SetInputClassifier(_) => write!(f, "SetInputClassifier"), Self::ShowPrompt(show) => write!(f, "ShowPrompt({show:?})"), #[cfg(feature = "search")] - Self::IncrementalSearchCondition(_) => write!(f, "IncrementalSearchCondition"), + Self::SetIncrementalSearchCondition(_) => write!(f, "IncrementalSearchCondition"), Self::AddExitCallback(_) => write!(f, "AddExitCallback"), Self::AddHook(h, id, _) => write!(f, "AddHook({h:?}, {id})"), Self::RemoveHook(h, id) => write!(f, "RemoveHook({h:?}, {id})"), #[cfg(feature = "static_output")] Self::SetRunNoOverflow(val) => write!(f, "SetRunNoOverflow({val:?})"), Self::UserInput(input) => write!(f, "UserInput({input:?})"), + Self::AddInputBinding(et, _) => write!(f, "AddInputBinding({et:?})"), + Self::RemoveInputBinding(et) => write!(f, "RemoveInputBinding({et:?})"), Self::FollowOutput(follow_output) => write!(f, "FollowOutput({follow_output:?})"), Self::Io(c) => write!(f, "Io({c:?})"), } diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index da149e3..b9559f0 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -10,9 +10,9 @@ use parking_lot::{Condvar, Mutex}; use super::CommandQueue; use super::commands::{Command, IoCommand}; use super::utils::display::{self, AppendStyle}; -use crate::ExitStrategy; #[cfg(feature = "search")] use crate::search; +use crate::{ExitStrategy, minus_core::commands::InputType}; use crate::{PagerState, error::MinusError, hooks::Hook, input::InputEvent}; /// Respond based on the type of command @@ -23,8 +23,6 @@ use crate::{PagerState, error::MinusError, hooks::Hook, input::InputEvent}; /// - Call search related functions #[cfg_attr(not(feature = "search"), allow(unused_mut))] #[allow(clippy::too_many_lines)] -// TODO: Remove it in next major release -#[allow(deprecated)] pub fn handle_event( ev: Command, p: &mut PagerState, @@ -171,69 +169,18 @@ pub fn handle_event( command_queue.push_back(Command::Io(IoCommand::FetchSearchQuery)); } #[cfg(feature = "search")] - Command::UserInput(InputEvent::NextMatch | InputEvent::MoveToNextMatch(1)) - if p.search_state.search_term.is_some() => - { + Command::UserInput(InputEvent::GoToMatch(n)) if p.search_state.search_term.is_some() => { // Move to next search match after the current upper_mark - let position_of_next_match = - search::next_nth_match(&p.search_state.search_idx, p.upper_mark, 1); - if let Some(pnm) = position_of_next_match { - p.search_state.search_mark = pnm; - let upper_mark = *p - .search_state - .search_idx - .iter() - .nth(p.search_state.search_mark) - .unwrap(); - command_queue.push_back(Command::Io(IoCommand::SetUpperMark(upper_mark))); - p.format_prompt(); - command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); - } - } - #[cfg(feature = "search")] - Command::UserInput(InputEvent::PrevMatch | InputEvent::MoveToPrevMatch(1)) - if p.search_state.search_term.is_some() => - { - // If no matches, return immediately - if p.search_state.search_idx.is_empty() { - return; - } - // Decrement the s_mark and get the preceding index - p.search_state.search_mark = p.search_state.search_mark.saturating_sub(1); - if let Some(y) = p - .search_state - .search_idx - .iter() - .nth(p.search_state.search_mark) - { - // If the index is less than or equal to the upper_mark, then set y to the new upper_mark - if *y < p.upper_mark { - command_queue.push_back(Command::UserInput(InputEvent::UpdateUpperMark(*y))); - p.format_prompt(); - command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); - } - } - } - #[cfg(feature = "search")] - Command::UserInput(InputEvent::MoveToNextMatch(n)) - if p.search_state.search_term.is_some() => - { - // Move to next nth search match after the current upper_mark - let position_of_next_match = - search::next_nth_match(&p.search_state.search_idx, p.upper_mark, n); - if let Some(pnm) = position_of_next_match { - p.search_state.search_mark = pnm; + let match_pos = + search::nth_match(&p.search_state.search_idx, p.upper_mark, n, p.search_mode); + if let Some(pm) = match_pos { + p.search_state.search_mark = pm; let mut upper_mark = *p .search_state .search_idx .iter() .nth(p.search_state.search_mark) .unwrap(); - - // Ensure there is enough text available after location corresponding to - // position_of_next_match so that we can display a pagefull of data. If not, - // reduce it so that a pagefull of text can be accommodated. - // NOTE: Add 1 to total number of lines to avoid off-by-one errors while p.upper_mark.saturating_add(p.rows) > p.screen.formatted_lines_count().saturating_add(1) { @@ -245,36 +192,12 @@ pub fn handle_event( .nth(p.search_state.search_mark) .unwrap(); } - command_queue - .push_back(Command::UserInput(InputEvent::UpdateUpperMark(upper_mark))); + + command_queue.push_back(Command::Io(IoCommand::SetUpperMark(upper_mark))); p.format_prompt(); command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); } } - #[cfg(feature = "search")] - Command::UserInput(InputEvent::MoveToPrevMatch(n)) - if p.search_state.search_term.is_some() => - { - // If no matches, return immediately - if p.search_state.search_idx.is_empty() { - return; - } - // Decrement the s_mark and get the preceding index - p.search_state.search_mark = p.search_state.search_mark.saturating_sub(n); - if let Some(y) = p - .search_state - .search_idx - .iter() - .nth(p.search_state.search_mark) - { - // If the index is less than or equal to the upper_mark, then set y to the new upper_mark - if *y < p.upper_mark { - command_queue.push_back(Command::Io(IoCommand::SetUpperMark(*y))); - p.format_prompt(); - command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); - } - } - } Command::UserInput(InputEvent::HorizontalScroll(val)) => { p.screen.line_wrapping = val; @@ -341,8 +264,9 @@ pub fn handle_event( #[cfg(feature = "static_output")] Command::SetRunNoOverflow(val) => p.run_no_overflow = val, #[cfg(feature = "search")] - Command::IncrementalSearchCondition(cb) => p.search_state.incremental_search_condition = cb, - Command::SetInputClassifier(clf) => p.input_classifier = clf, + Command::SetIncrementalSearchCondition(cb) => { + p.search_state.incremental_search_condition = cb; + } Command::AddExitCallback(cb) => p.exit_callbacks.push(cb), Command::AddHook(hook, id, cb) => p.hooks.add_callback(hook, id, cb), Command::RemoveHook(hook, id) => { @@ -358,6 +282,19 @@ pub fn handle_event( p.format_prompt(); command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); } + Command::AddInputBinding(et, cb) => match et { + InputType::KeyMouse(desc) => p.input_register.map_km_parsed(desc, cb), + InputType::Resize => p.input_register.map_resize(cb), + InputType::Wild => p.input_register.map_wild_event(cb), + InputType::AllKeys | InputType::AllMouses => unreachable!(), + }, + Command::RemoveInputBinding(et) => match et { + InputType::KeyMouse(desc) => p.input_register.clear_km_parsed(&desc), + InputType::AllKeys => p.input_register.clear_all_keys(), + InputType::AllMouses => p.input_register.clear_all_mouse(), + InputType::Resize => p.input_register.clear_resize(), + InputType::Wild => p.input_register.clear_wild_event(), + }, Command::UserInput(_) => {} Command::Io(_) => unreachable!(), } diff --git a/src/core/init.rs b/src/core/init.rs index 9b1a2ea..dfaeddc 100644 --- a/src/core/init.rs +++ b/src/core/init.rs @@ -75,7 +75,7 @@ use super::{CommandQueue, RUNMODE, utils::display::draw_for_change}; /// [`event reader`]: event_reader #[allow(clippy::module_name_repetitions)] #[allow(clippy::too_many_lines)] -pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusError> { +pub fn init_core(pager: Pager, rm: RunMode) -> std::result::Result<(), MinusError> { #[cfg(not(test))] let mut out = stdout(); #[cfg(test)] @@ -144,8 +144,8 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr let ps_mutex = Arc::new(Mutex::new(ps)); - let evtx = pager.tx.clone(); - let rx = pager.rx.clone(); + let evtx = pager.tx; + let rx = pager.rx; let p1 = ps_mutex.clone(); @@ -344,7 +344,7 @@ fn event_reader( let ev = event::read().map_err(|e| MinusError::HandleEvent(e.into()))?; let mut guard = ps.lock(); // Get the events - let input = guard.input_classifier.classify_input(ev, &guard); + let input = guard.input_register.classify_input(ev, &guard); if let Some(iev) = input { if !matches!(iev, InputEvent::Number(_)) { guard.prefix_num.clear(); diff --git a/src/dynamic_pager.rs b/src/dynamic_pager.rs index 1b4d3f7..ae3bd5b 100644 --- a/src/dynamic_pager.rs +++ b/src/dynamic_pager.rs @@ -14,7 +14,6 @@ use crate::minus_core::init; /// # Errors /// The function will return with an error if it encounters a error during paging. #[cfg_attr(docsrs, doc(cfg(feature = "dynamic_output")))] -#[allow(clippy::needless_pass_by_value)] pub fn dynamic_paging(pager: Pager) -> Result<(), MinusError> { - init::init_core(&pager, crate::RunMode::Dynamic) + init::init_core(pager, crate::RunMode::Dynamic) } diff --git a/src/input/definitions/keydefs.rs b/src/input/definitions/keydefs.rs index 3593543..c8c1b2a 100644 --- a/src/input/definitions/keydefs.rs +++ b/src/input/definitions/keydefs.rs @@ -55,6 +55,12 @@ impl Default for KeySeq { } } +/// Parse a key input description +/// +/// # Panics +/// This function will panic if the description is not valid. See the [`input`](crate::input) module +/// docs on how to write descriptions. +#[must_use] pub fn parse_key_event(text: &str) -> KeyEvent { let token_list = super::parse_tokens(text); @@ -73,8 +79,7 @@ impl KeySeq { token_iter.next(); assert!( !(token_iter.peek() == Some(&&Token::Separator)), - "'{}': Multiple separators found consecutively", - text + "'{text}': Multiple separators found consecutively", ); } Token::SingleChar(c) => { @@ -83,30 +88,29 @@ impl KeySeq { if token_iter.next() == Some(&Token::Separator) { assert!( !ks.modifiers.contains(*m), - "'{}': Multiple instances of same modifier given", - text + "'{text}': Multiple instances of same modifier given", ); ks.modifiers.insert(*m); } else if ks.code.is_none() { ks.code = Some(KeyCode::Char(*c)); } else { - panic!("'{}' Invalid key input sequence given", text); + panic!("'{text}' Invalid key input sequence given"); } } else if ks.code.is_none() { ks.code = Some(KeyCode::Char(*c)); } else { - panic!("'{}': Invalid key input sequence given", text); + panic!("'{text}': Invalid key input sequence given"); } } Token::MultipleChar(c) => { let c = c.to_ascii_lowercase().clone(); SPECIAL_KEYS.get(c.as_str()).map_or_else( - || panic!("'{}': Invalid key input sequence given", text), + || panic!("'{text}': Invalid key input sequence given"), |key| { if ks.code.is_none() { ks.code = Some(*key); } else { - panic!("'{}': Invalid key input sequence given", text); + panic!("'{text}': Invalid key input sequence given"); } }, ); diff --git a/src/input/definitions/mod.rs b/src/input/definitions/mod.rs index 30e2198..d338b60 100644 --- a/src/input/definitions/mod.rs +++ b/src/input/definitions/mod.rs @@ -1,5 +1,3 @@ -#![allow(clippy::uninlined_format_args)] - pub mod keydefs; pub mod mousedefs; @@ -9,14 +7,12 @@ use std::{collections::HashMap, sync::LazyLock}; fn parse_tokens(mut text: &str) -> Vec { assert!( text.is_ascii(), - "'{}': Non ascii sequence found in input sequence", - text + "'{text}': Non ascii sequence found in input sequence", ); text = text.trim(); assert!( text.chars().any(|c| !c.is_whitespace()), - "'{}': Whitespace character found in input sequence", - text + "'{text}': Whitespace character found in input sequence", ); let mut token_list = Vec::with_capacity(text.len()); diff --git a/src/input/definitions/mousedefs.rs b/src/input/definitions/mousedefs.rs index 90ec8cf..62b6f2b 100644 --- a/src/input/definitions/mousedefs.rs +++ b/src/input/definitions/mousedefs.rs @@ -25,6 +25,12 @@ static MOUSE_ACTIONS: LazyLock> = LazyLock::new(|| map }); +/// Parse a mouse input description +/// +/// # Panics +/// This function will panic if the description is not valid. See the [`input`](crate::input) module +/// docs on how to write descriptions. +#[must_use] pub fn parse_mouse_event(text: &str) -> MouseEvent { let token_list = super::parse_tokens(text); gen_mouse_event_from_tokenlist(&token_list, text) @@ -42,26 +48,24 @@ fn gen_mouse_event_from_tokenlist(token_list: &[Token], text: &str) -> MouseEven token_iter.next(); assert!( !(token_iter.peek() == Some(&&Token::Separator)), - "'{}': Multiple separators found consecutively", - text + "'{text}': Multiple separators found consecutively", ); } Token::SingleChar(c) => { token_iter.next(); MODIFIERS.get(c).map_or_else( || { - panic!("'{}': Invalid keymodifier '{}' given", text, c); + panic!("'{text}': Invalid keymodifier '{c}' given"); }, |m| { if token_iter.next() == Some(&Token::Separator) { assert!( !modifiers.contains(*m), - "'{}': Multiple instances of same modifier given", - text + "'{text}': Multiple instances of same modifier given", ); modifiers.insert(*m); } else { - panic!("'{}' Invalid key input sequence given", text); + panic!("'{text}' Invalid key input sequence given"); } }, ); @@ -69,12 +73,12 @@ fn gen_mouse_event_from_tokenlist(token_list: &[Token], text: &str) -> MouseEven Token::MultipleChar(c) => { let c = c.to_ascii_lowercase().clone(); MOUSE_ACTIONS.get(c.as_str()).map_or_else( - || panic!("'{}': Invalid key input sequence given", text), + || panic!("'{text}': Invalid key input sequence given"), |k| { if kind.is_none() { kind = Some(*k); } else { - panic!("'{}': Invalid key input sequence given", text); + panic!("'{text}': Invalid key input sequence given"); } }, ); @@ -83,7 +87,7 @@ fn gen_mouse_event_from_tokenlist(token_list: &[Token], text: &str) -> MouseEven } } MouseEvent { - kind: kind.unwrap_or_else(|| panic!("No MouseEventKind found for '{}", text)), + kind: kind.unwrap_or_else(|| panic!("No MouseEventKind found for '{text}")), modifiers, row: 0, column: 0, diff --git a/src/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index a4a9b0a..4ae8581 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -1,16 +1,14 @@ //! Provides the [`HashedEventRegister`] and related items //! -//! This module holds the [`HashedEventRegister`] which is a [`HashMap`] that stores events and their associated -//! callbacks. When the user does an action on the terminal, the event is scanned and matched against this register. -//! If their is a match related to that event, the associated callback is called +//! This module holds the [`HashedEventRegister`] which is a [`HashMap`] that stores events and +//! their associated callbacks. When the user does an action on the terminal, the event is scanned +//! and matched against this register. If their is a match related to that event, the associated +//! callback is called -use super::{InputClassifier, InputEvent}; +use super::InputEvent; use crate::PagerState; use crossterm::event::{Event, MouseEvent}; -use std::{ - collections::HashMap, collections::hash_map::RandomState, hash::BuildHasher, hash::Hash, - sync::Arc, -}; +use std::{collections::HashMap, collections::hash_map::RandomState, hash::Hash, sync::Arc}; /// A convenient type for the return type of [`HashedEventRegister::get`] type EventReturnType = Arc InputEvent + Send + Sync>; @@ -19,8 +17,8 @@ type EventReturnType = Arc InputEvent + Send + Syn // EVENTWRAPPER TYPE // ////////////////////////////// -#[derive(Clone, Eq)] -enum EventWrapper { +#[derive(Clone, Debug, Eq)] +pub enum EventWrapper { ExactMatchEvent(Event), WildEvent, } @@ -89,44 +87,24 @@ impl Hash for EventWrapper { /// Each item is a key value pair, where the key is a event and it's value is a callback. When a /// event occurs, it is matched inside and when the related match is found, it's related callback /// is called. -pub struct HashedEventRegister(HashMap); +pub struct HashedEventRegister(HashMap); -impl HashedEventRegister { - /// Create a new [`HashedEventRegister`] with the default hasher - #[must_use] - pub fn with_default_hasher() -> Self { - Self::new(RandomState::new()) - } -} - -impl Default for HashedEventRegister { +impl Default for HashedEventRegister { /// Create a new [`HashedEventRegister`] with the default hasher and insert the default bindings fn default() -> Self { - let mut event_register = Self::new(RandomState::new()); + let mut event_register = Self::new(); super::generate_default_bindings(&mut event_register); event_register } } -impl InputClassifier for HashedEventRegister -where - S: BuildHasher, -{ - fn classify_input(&self, ev: Event, ps: &crate::PagerState) -> Option { - self.get(&ev).map(|c| c(ev, ps)) - } -} - // #################### // GENERAL FUNCTIONS // #################### -impl HashedEventRegister -where - S: BuildHasher, -{ - /// Create a new `HashedEventRegister` with the Hasher `s` - pub fn new(s: S) -> Self { - Self(HashMap::with_hasher(s)) +impl HashedEventRegister { + /// Create a new `HashedEventRegister` + pub fn new() -> Self { + Self(HashMap::new()) } /// Adds a callback to handle all events that failed to match @@ -138,7 +116,7 @@ where /// /// This is also helpful when you need to do some action, like sending a message when the user /// presses wrong keyboard/mouse buttons. - pub fn insert_wild_event_matcher( + pub fn map_wild_event( &mut self, cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, ) { @@ -152,24 +130,7 @@ where } /// Adds a callback for handling resize events - /// - /// # Example - /// These are from the original sources - /// ``` - /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event::Event}; - /// - /// let mut input_register = HashedEventRegister::default(); - /// - /// input_register.add_resize_event(|ev, _| { - /// let (cols, rows) = if let Event::Resize(cols, rows) = ev { - /// (cols, rows) - /// } else { - /// unreachable!(); - /// }; - /// InputEvent::UpdateTermArea(cols as usize, rows as usize) - /// }); - /// ``` - pub fn add_resize_event( + pub fn map_resize( &mut self, cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, ) { @@ -181,122 +142,44 @@ where } /// Removes the currently active resize event callback - pub fn remove_resize_event(&mut self) { + pub fn clear_resize(&mut self) { self.0 .remove(&EventWrapper::ExactMatchEvent(Event::Resize(0, 0))); } -} -// ############################### -// KEYBOARD SPECIFIC FUNCTIONS -// ############################### -impl HashedEventRegister -where - S: BuildHasher, -{ - /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb` - /// - /// You should prefer using the [`add_key_events_checked`](HashedEventRegister::add_key_events_checked) - /// over this one. - /// - /// # Example - /// ``` - /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; - /// - /// let mut input_register = HashedEventRegister::default(); - /// - /// input_register.add_key_events(&["down"], |_, ps| { - /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(1)) - /// }); - /// ``` - pub fn add_key_events( - &mut self, - desc: &[&str], - cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, - ) { - let v = Arc::new(cb); - for k in desc { - self.0.insert( - Event::Key(super::definitions::keydefs::parse_key_event(k)).into(), - v.clone(), - ); - } + /// Removes the currently active wild event callback + pub fn clear_wild_event(&mut self) { + self.0.remove(&EventWrapper::WildEvent); } - /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb`. - /// - /// Prefer using this over [`add_key_events`](HashedEventRegister::add_key_events). - /// - /// # Panics - /// - /// This will panic if you the keybinding has been previously defined, unless the `remap` - /// is set to true. This helps preventing accidental overrides of your keybindings. - /// - /// # Example - /// ```should_panic - /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; - /// - /// let mut input_register = HashedEventRegister::default(); - /// - /// input_register.add_key_events_checked(&["down"], |_, ps| { - /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(1)) - /// }, false); - /// ``` - pub fn add_key_events_checked( + pub(crate) fn classify_input(&self, ev: Event, ps: &crate::PagerState) -> Option { + self.get(&ev).map(|c| c(ev, ps)) + } + + pub fn map_km_parsed( &mut self, - desc: &[&str], + desc: Vec, cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, - remap: bool, ) { let v = Arc::new(cb); for k in desc { - let def: EventWrapper = - Event::Key(super::definitions::keydefs::parse_key_event(k)).into(); - assert!(self.0.contains_key(&def) && remap, ""); - self.0.insert(def, v.clone()); + self.0.insert(k, v.clone()); } } - /// Removes the callback associated with the all the elements of `desc`. - /// - /// ``` - /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; - /// - /// let mut input_register = HashedEventRegister::default(); - /// - /// input_register.remove_key_events(&["down"]) - /// ``` - pub fn remove_key_events(&mut self, desc: &[&str]) { + pub fn clear_km_parsed(&mut self, desc: &[EventWrapper]) { for k in desc { - self.0 - .remove(&Event::Key(super::definitions::keydefs::parse_key_event(k)).into()); + self.0.remove(k); } } } // ############################### -// MOUSE SPECIFIC FUNCTIONS +// KEYBOARD SPECIFIC FUNCTIONS // ############################### -impl HashedEventRegister -where - S: BuildHasher, -{ - /// Add all elemnts of `desc` as mouse bindings that minus should respond to with the callback `cb` - /// - /// You should prefer using the [`add_mouse_events_checked`](HashedEventRegister::add_mouse_events_checked) - /// over this one. - /// - /// # Example - /// ``` - /// use minus::input::{InputEvent, HashedEventRegister}; - /// - /// let mut input_register = HashedEventRegister::default(); - /// - /// input_register.add_mouse_events(&["scroll:down"], |_, ps| { - /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5)) - /// }); - /// ``` - pub fn add_mouse_events( +impl HashedEventRegister { + /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb` + pub fn map_keys( &mut self, desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, @@ -304,58 +187,45 @@ where let v = Arc::new(cb); for k in desc { self.0.insert( - Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(), + Event::Key(super::definitions::keydefs::parse_key_event(k)).into(), v.clone(), ); } } - /// Add all elemnts of `desc` as mouse bindings that minus should respond to with the callback `cb`. - /// - /// Prefer using this over [`add_mouse_events`](HashedEventRegister::add_mouse_events). - /// - /// # Panics - /// This will panic if you the keybinding has been previously defined, unless the `remap` - /// is set to true. This helps preventing accidental overrides of your keybindings. - /// - /// # Example - /// ```should_panic - /// use minus::input::{InputEvent, HashedEventRegister}; - /// - /// let mut input_register = HashedEventRegister::default(); - /// - /// input_register.add_mouse_events_checked(&["scroll:down"], |_, ps| { - /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5)) - /// }, false); - /// ``` - pub fn add_mouse_events_checked( + /// Clear all keyboard bindings + pub fn clear_all_keys(&mut self) { + self.0.retain(|k, _| { + !matches!( + k, + EventWrapper::ExactMatchEvent(Event::Key(..)) | EventWrapper::WildEvent + ) + }); + } +} + +// ############################### +// MOUSE SPECIFIC FUNCTIONS +// ############################### +impl HashedEventRegister { + /// Add all elemnts of `desc` as mouse bindings that minus should respond to with the callback `cb` + pub fn map_mouse( &mut self, desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, - remap: bool, ) { let v = Arc::new(cb); for k in desc { - let def: EventWrapper = - Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(); - assert!(self.0.contains_key(&def) && remap, ""); - self.0.insert(def, v.clone()); + self.0.insert( + Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(), + v.clone(), + ); } } - /// Removes the callback associated with the all the elements of `desc`. - /// - /// ``` - /// use minus::input::{InputEvent, HashedEventRegister, crossterm_event}; - /// - /// let mut input_register = HashedEventRegister::default(); - /// - /// input_register.remove_mouse_events(&["scroll:down"]) - /// ``` - pub fn remove_mouse_events(&mut self, mouse: &[&str]) { - for k in mouse { - self.0 - .remove(&Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into()); - } + /// Clear all mouse bindings + pub fn clear_all_mouse(&mut self) { + self.0 + .retain(|k, _| !matches!(k, EventWrapper::ExactMatchEvent(Event::Mouse(..)))); } } diff --git a/src/input/mod.rs b/src/input/mod.rs index f52fff7..eb91026 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,46 +1,5 @@ //! Manage keyboard/mouse-bindings while running `minus`. //! -//! > **Terminology in this module**: We will call any keyboard/mouse event from the terminal as a **binding** -//! > and its associated predefined action as **callback**. -//! -//! There are two ways to define binding in minus as you will see below. -//! -//! # Newer (Recommended) Method -//! ## Description -//! This method offers a much improved and ergonomic API for defining bindings and callbacks. -//! You use the [`HashedEventRegister`] for registering bindings and their associated callback. -//! It provides functions like [`add_key_events`](HashedEventRegister::add_key_events) and -//! [`add_mouse_events`](HashedEventRegister::add_mouse_events) which take `&[&str]` as its first -//! argument and a callback `cb` as its second argument and maps all `&str` in the `&[&str]` to -//! same callback function `cb`. Each `&str` of the `&[&str]` contains a description of the -//! key/mouse binding needed to activate it. For example `c-c` means pressing a `Ctrl+c` on the -//! keyboard. See [Writing Binding Descriptions](#writing-binding-descriptions) to know more on -//! writing these descriptions. -// -//! ## Example -//! ``` -//! use minus::input::{InputEvent, HashedEventRegister, crossterm_event::Event}; -//! -//! let mut input_register = HashedEventRegister::default(); -//! -//! input_register.add_key_events(&["down"], |_, ps| { -//! InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(1)) -//! }); -//! -//! input_register.add_mouse_events(&["scroll:up"], |_, ps| { -//! InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5)) -//! }); -//! -//! input_register.add_resize_event(|ev, _| { -//! let (cols, rows) = if let Event::Resize(cols, rows) = ev { -//! (cols, rows) -//! } else { -//! unreachable!(); -//! }; -//! InputEvent::UpdateTermArea(cols as usize, rows as usize) -//! }); -//! ``` -//! //! ## Writing Binding Descriptions //! ### Defining Keybindings //! The general syntax for defining keybindings is `[MODIFIER]-[MODIFIER]-[MODIFIER]-{SINGLE KEY}` @@ -87,112 +46,20 @@ //! events that you can possibly register, not all of them are correctly registered by crossterm //! itself. For example minus corrctly parses `c-s-h` as `ctrl+shift-h` but crossterm //! categorically recognizes it as `ctrl+h` when reading events from the terminal. -//! -//! # Legacy method -//! This method relies heavily on the [`InputClassifier`] trait and end-applications were needed to -//! manually copy the [default definitions](DefaultInputClassifier) and make the required -//! modifications yourself in this method. This lead to very messy and error-prone system for -//! defining bindings and also required application authors to bring in the the underlying -//! [crossterm](https://docs.rs/crossterm/latest) crate to define the events. -//! -//! ## Example -//! ``` -//! use minus::{input::{InputEvent, InputClassifier}, Pager, PagerState}; -//! use crossterm::event::{Event, KeyEvent, KeyCode, KeyModifiers}; -//! -//! struct CustomInputClassifier; -//! impl InputClassifier for CustomInputClassifier { -//! fn classify_input( -//! &self, -//! ev: Event, -//! ps: &PagerState -//! ) -> Option { -//! match ev { -//! Event::Key(KeyEvent { -//! code: KeyCode::Up, -//! modifiers: KeyModifiers::NONE, -//! .. -//! }) -//! | Event::Key(KeyEvent { -//! code: KeyCode::Char('j'), -//! modifiers: KeyModifiers::NONE, -//! .. -//! }) => Some(InputEvent::UpdateUpperMark -//! (ps.upper_mark.saturating_sub(1))), -//! _ => None -//! } -//! } -//! } -//! -//! let mut pager = Pager::new(); -//! pager.set_input_classifier( -//! Box::new(CustomInputClassifier) -//! ); -//! ``` -//! -//! **NOTE:** Although you can define almost every combination of bindings that crossterm supports, -//! not all of them are correctly registered by crossterm itself. For example you can define -//! ```text -//! Event::Key(KeyEvent { -//! code: KeyCode::Char(`h`), -//! modifiers: KeyModifiers::CONTROL | KeyModifiers::SHIFT, -//! .. -//! }) -//! ``` -//! but crossterm will not match to it as crossterm -//! recognizes a `ctrl+shift+h` as `ctrl+h` when reading events from the terminal. -//! -//! # Custom Actions on User Events -//! -//! Sometimes you want to execute arbitrary code when a key/mouse action is pressed like fetching -//! more data from a server but not necessarily sending it to minus. In these types of scenarios, -//! you can leverage [`InputEvent::Ignore`]. When this is returned by a callback -//! function, minus will execute your code but not do anything special for the event on its part. -//! ```no_test -//! input_register.add_key_events(&["f"], |_, ps| { -//! fetch_data_from_server(...); -//! InputEvent::Ignore -//! }); -//! ``` -//! It can be used with the legacy method too. -//! ```no_test -//! struct CustomInputClassifier; -//! impl InputClassifier for CustomInputClassifier { -//! fn classify_input( -//! &self, -//! ev: Event, -//! ps: &PagerState -//! ) -> Option { -//! match ev { -//! Event::Key(KeyEvent { -//! code: KeyCode::Char('f'), -//! modifiers: KeyModifiers::NONE, -//! .. -//! }) => { -//! fetch_data_from_server(...); -//! InputEvent::Ignore -//! }, -//! _ => None -//! } -//! } -//! } -//! ``` pub(crate) mod definitions; pub(crate) mod hashed_event_register; pub use crossterm::event as crossterm_event; +pub use definitions::{keydefs::parse_key_event, mousedefs::parse_mouse_event}; #[cfg(feature = "search")] use crate::search::SearchMode; use crate::{LineNumbers, PagerState}; -use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent}; +pub(crate) use hashed_event_register::HashedEventRegister; -#[cfg_attr( - docsrs, - deprecated = "See [#163](https://github.com/AMythicDev/minus/pull/163)." -)] -pub use hashed_event_register::HashedEventRegister; +pub type InputEventBoxed = Box InputEvent + Send + Sync + 'static>; /// Events handled by the `minus` pager. #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -237,26 +104,8 @@ pub enum InputEvent { /// `/`, Searching for certain pattern of text #[cfg(feature = "search")] Search(SearchMode), - /// Get to the next match in forward mode - /// - /// **WARNING: This has been deprecated in favour of `MoveToNextMatch`. This will likely be - /// removed in the next major release.** - #[cfg(feature = "search")] - #[deprecated = "Use [InputEvent::MoveToNextMatch(1)](InputEvent::MoveToNextMatch) for the same effect."] - NextMatch, - /// Get to the previous match in forward mode - /// - /// **WARNING: This has been deprecated in favour of `MoveToPrevMatch`. This will likely be - /// removed in the next major release.** - #[deprecated = "Use [InputEvent::MoveToPrevMatch(1)](InputEvent::MoveToPrevMatch) for the same effect."] - #[cfg(feature = "search")] - PrevMatch, - /// Move to the next nth match in the given direction #[cfg(feature = "search")] - MoveToNextMatch(usize), - /// Move to the previous nth match in the given direction - #[cfg(feature = "search")] - MoveToPrevMatch(usize), + GoToMatch(isize), /// Control follow mode. /// /// When set to true, minus ensures that the user's screen always follows the end part of the @@ -267,21 +116,6 @@ pub enum InputEvent { FollowOutput(bool), } -/// Classifies the input and returns the appropriate [`InputEvent`] -/// -/// If you are using the newer method for input definition, you don't need to take care of this. -/// -/// If you are using the legacy method, see the sources of [`DefaultInputClassifier`] on how to -/// inplement this trait. -#[allow(clippy::module_name_repetitions)] -#[cfg_attr( - docsrs, - deprecated = "See [#163](https://github.com/AMythicDev/minus/pull/163)." -)] -pub trait InputClassifier { - fn classify_input(&self, ev: Event, ps: &PagerState) -> Option; -} - /// Insert the default set of actions into the [`HashedEventRegister`] #[allow(clippy::module_name_repetitions)] #[cfg_attr( @@ -289,24 +123,21 @@ pub trait InputClassifier { deprecated = "See [#163](https://github.com/AMythicDev/minus/pull/163)." )] #[allow(clippy::too_many_lines)] -pub fn generate_default_bindings(map: &mut HashedEventRegister) -where - S: std::hash::BuildHasher, -{ - map.add_key_events(&["q", "c-c"], |_, _| InputEvent::Exit); +pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { + map.map_keys(&["q", "c-c"], |_, _| InputEvent::Exit); - map.add_key_events(&["up", "k"], |_, ps| { + map.map_keys(&["up", "k"], |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(position)) }); - map.add_key_events(&["down", "j"], |_, ps| { + map.map_keys(&["down", "j"], |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(position)) }); - map.add_key_events(&["c-f"], |_, ps| { + map.map_keys(&["c-f"], |_, ps| { InputEvent::FollowOutput(!ps.follow_output) }); - map.add_key_events(&["enter"], |_, ps| { + map.map_keys(&["enter"], |_, ps| { if ps.message.is_some() { InputEvent::RestorePrompt } else { @@ -314,17 +145,17 @@ where InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(position)) } }); - map.add_key_events(&["u", "c-u"], |_, ps| { + map.map_keys(&["u", "c-u"], |_, ps| { let half_screen = ps.rows / 2; InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(half_screen)) }); - map.add_key_events(&["d", "c-d"], |_, ps| { + map.map_keys(&["d", "c-d"], |_, ps| { let half_screen = ps.rows / 2; InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(half_screen)) }); - map.add_key_events(&["g", "home"], |_, _| InputEvent::UpdateUpperMark(0)); + map.map_keys(&["g", "home"], |_, _| InputEvent::UpdateUpperMark(0)); - map.add_key_events(&["s-g", "G"], |_, ps| { + map.map_keys(&["s-g", "G"], |_, ps| { let mut position = ps .prefix_num .parse::() @@ -344,57 +175,56 @@ where .unwrap_or(&(usize::MAX - 1)); InputEvent::UpdateUpperMark(row_to_go) }); - map.add_key_events(&["pageup"], |_, ps| { + map.map_keys(&["pageup"], |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(ps.rows - 1)) }); - map.add_key_events(&["pagedown", "space"], |_, ps| { + map.map_keys(&["pagedown", "space"], |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(ps.rows - 1)) }); - map.add_key_events(&["c-l"], |_, ps| { + map.map_keys(&["c-l"], |_, ps| { InputEvent::UpdateLineNumber(!ps.line_numbers) }); - map.add_key_events(&["end"], |_, _| InputEvent::UpdateUpperMark(usize::MAX - 1)); + map.map_keys(&["end"], |_, _| InputEvent::UpdateUpperMark(usize::MAX - 1)); #[cfg(feature = "search")] { - map.add_key_events(&["/"], |_, _| InputEvent::Search(SearchMode::Forward)); - map.add_key_events(&["?"], |_, _| InputEvent::Search(SearchMode::Reverse)); - map.add_key_events(&["n"], |_, ps| { - let position = ps.prefix_num.parse::().unwrap_or(1); - + map.map_keys(&["/"], |_, _| InputEvent::Search(SearchMode::Forward)); + map.map_keys(&["?"], |_, _| InputEvent::Search(SearchMode::Reverse)); + map.map_keys(&["n"], |_, ps| { + let position = ps.prefix_num.parse::().unwrap_or(1); if ps.search_state.search_mode == SearchMode::Forward { - InputEvent::MoveToNextMatch(position) + InputEvent::GoToMatch(position) } else if ps.search_state.search_mode == SearchMode::Reverse { - InputEvent::MoveToPrevMatch(position) + InputEvent::GoToMatch(-position) } else { InputEvent::Ignore } }); - map.add_key_events(&["p", "s-n"], |_, ps| { - let position = ps.prefix_num.parse::().unwrap_or(1); + map.map_keys(&["p", "s-n"], |_, ps| { + let position = ps.prefix_num.parse::().unwrap_or(1); if ps.search_state.search_mode == SearchMode::Forward { - InputEvent::MoveToPrevMatch(position) + InputEvent::GoToMatch(-position) } else if ps.search_state.search_mode == SearchMode::Reverse { - InputEvent::MoveToNextMatch(position) + InputEvent::GoToMatch(position) } else { InputEvent::Ignore } }); } - map.add_mouse_events(&["scroll:up"], |_, ps| { + map.map_mouse(&["scroll:up"], |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5)) }); - map.add_mouse_events(&["scroll:down"], |_, ps| { + map.map_mouse(&["scroll:down"], |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(5)) }); - map.add_mouse_events(&["left:down"], |ev, _| { + map.map_mouse(&["left:down"], |ev, _| { let Event::Mouse(MouseEvent { column, row, .. }) = ev else { unreachable!(); }; InputEvent::StartSelection { x: column, y: row } }); - map.add_mouse_events(&["left:drag"], |ev, _| { + map.map_mouse(&["left:drag"], |ev, _| { let Event::Mouse(MouseEvent { column, row, .. }) = ev else { unreachable!(); }; @@ -403,31 +233,31 @@ where #[cfg(feature = "clipboard")] { - map.add_mouse_events(&["left:up"], |_, _| InputEvent::CopySelection); - map.add_key_events(&["y"], |_, _| InputEvent::CopySelection); + map.map_mouse(&["left:up"], |_, _| InputEvent::CopySelection); + map.map_keys(&["y"], |_, _| InputEvent::CopySelection); } - map.add_key_events(&["c-s-h", "c-h"], |_, ps| { + map.map_keys(&["c-s-h", "c-h"], |_, ps| { InputEvent::HorizontalScroll(!ps.screen.line_wrapping) }); - map.add_key_events(&["h", "left"], |_, ps| { + map.map_keys(&["h", "left"], |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateLeftMark(ps.left_mark.saturating_sub(position)) }); - map.add_key_events(&["l", "right"], |_, ps| { + map.map_keys(&["l", "right"], |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateLeftMark(ps.left_mark.saturating_add(position)) }); // TODO: Add keybindings for left right scrolling - map.add_resize_event(|ev, _| { + map.map_resize(|ev, _| { let Event::Resize(cols, rows) = ev else { unreachable!(); }; InputEvent::UpdateTermArea(cols as usize, rows as usize) }); - map.insert_wild_event_matcher(|ev, _| { + map.map_wild_event(|ev, _| { if let Event::Key(KeyEvent { code: KeyCode::Char(c), modifiers: KeyModifiers::NONE, @@ -445,252 +275,5 @@ where }); } -/// The default set of input definitions -/// -/// **This is kept only for legacy purposes and may not be well updated with all the latest changes** -#[cfg_attr( - docsrs, - deprecated = "See [#163](https://github.com/AMythicDev/minus/pull/163)." -)] -pub struct DefaultInputClassifier; - -impl InputClassifier for DefaultInputClassifier { - #[allow(clippy::too_many_lines)] - fn classify_input(&self, ev: Event, ps: &PagerState) -> Option { - #[allow(clippy::unnested_or_patterns)] - match ev { - // Scroll up by one. - Event::Key(KeyEvent { - code, - modifiers: KeyModifiers::NONE, - .. - }) if code == KeyCode::Up || code == KeyCode::Char('k') => { - let position = ps.prefix_num.parse::().unwrap_or(1); - Some(InputEvent::UpdateUpperMark( - ps.upper_mark.saturating_sub(position), - )) - } - - // Scroll down by one. - Event::Key(KeyEvent { - code, - modifiers: KeyModifiers::NONE, - .. - }) if code == KeyCode::Down || code == KeyCode::Char('j') => { - let position = ps.prefix_num.parse::().unwrap_or(1); - Some(InputEvent::UpdateUpperMark( - ps.upper_mark.saturating_add(position), - )) - } - - // Toggle output following - Event::Key(KeyEvent { - code, - modifiers: KeyModifiers::CONTROL, - .. - }) if code == KeyCode::Char('f') => Some(InputEvent::FollowOutput(!ps.follow_output)), - - // For number keys - Event::Key(KeyEvent { - code: KeyCode::Char(c), - modifiers: KeyModifiers::NONE, - .. - }) if c.is_ascii_digit() => Some(InputEvent::Number(c)), - - // Enter key - Event::Key(KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - }) => { - if ps.message.is_some() { - Some(InputEvent::RestorePrompt) - } else { - let position = ps.prefix_num.parse::().unwrap_or(1); - Some(InputEvent::UpdateUpperMark( - ps.upper_mark.saturating_add(position), - )) - } - } - - // Scroll up by half screen height. - Event::Key(KeyEvent { - code: KeyCode::Char('u'), - modifiers, - .. - }) if modifiers == KeyModifiers::CONTROL || modifiers == KeyModifiers::NONE => { - let half_screen = ps.rows / 2; - Some(InputEvent::UpdateUpperMark( - ps.upper_mark.saturating_sub(half_screen), - )) - } - // Scroll down by half screen height. - Event::Key(KeyEvent { - code: KeyCode::Char('d'), - modifiers, - .. - }) if modifiers == KeyModifiers::CONTROL || modifiers == KeyModifiers::NONE => { - let half_screen = ps.rows / 2; - Some(InputEvent::UpdateUpperMark( - ps.upper_mark.saturating_add(half_screen), - )) - } - - // Mouse scroll up/down - Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - .. - }) => Some(InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5))), - Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollDown, - .. - }) => Some(InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(5))), - // Go to top. - Event::Key(KeyEvent { - code: KeyCode::Char('g'), - modifiers: KeyModifiers::NONE, - .. - }) => Some(InputEvent::UpdateUpperMark(0)), - // Go to bottom. - Event::Key(KeyEvent { - code: KeyCode::Char('g'), - modifiers: KeyModifiers::SHIFT, - .. - }) - | Event::Key(KeyEvent { - code: KeyCode::Char('G'), - modifiers: KeyModifiers::SHIFT, - .. - }) - | Event::Key(KeyEvent { - code: KeyCode::Char('G'), - modifiers: KeyModifiers::NONE, - .. - }) => { - let mut position = ps - .prefix_num - .parse::() - .unwrap_or(usize::MAX) - // Reduce 1 here, because line numbering starts from 1 - // while upper_mark starts from 0 - .saturating_sub(1); - if position == 0 { - position = usize::MAX; - } - Some(InputEvent::UpdateUpperMark(position)) - } - - // Page Up/Down - Event::Key(KeyEvent { - code: KeyCode::PageUp, - modifiers: KeyModifiers::NONE, - .. - }) => Some(InputEvent::UpdateUpperMark( - ps.upper_mark.saturating_sub(ps.rows - 1), - )), - Event::Key(KeyEvent { - code: c, - modifiers: KeyModifiers::NONE, - .. - }) if c == KeyCode::PageDown || c == KeyCode::Char(' ') => Some( - InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(ps.rows - 1)), - ), - - // Resize event from the terminal. - Event::Resize(cols, rows) => { - Some(InputEvent::UpdateTermArea(cols as usize, rows as usize)) - } - // Switch line number display. - Event::Key(KeyEvent { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::CONTROL, - .. - }) => Some(InputEvent::UpdateLineNumber(!ps.line_numbers)), - - // Quit. - Event::Key(KeyEvent { - code: KeyCode::Char('q'), - modifiers: KeyModifiers::NONE, - .. - }) - | Event::Key(KeyEvent { - code: KeyCode::Char('c'), - modifiers: KeyModifiers::CONTROL, - .. - }) => Some(InputEvent::Exit), - - // Horizontal scrolling - Event::Key(KeyEvent { - code: KeyCode::Char('h'), - modifiers, - .. - }) if modifiers == KeyModifiers::CONTROL.intersection(KeyModifiers::SHIFT) => { - Some(InputEvent::HorizontalScroll(!ps.screen.line_wrapping)) - } - - Event::Key(KeyEvent { - code: KeyCode::Char('h'), - modifiers: KeyModifiers::NONE, - .. - }) - | Event::Key(KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - .. - }) => Some(InputEvent::UpdateLeftMark(ps.left_mark.saturating_sub(1))), - Event::Key(KeyEvent { - code: KeyCode::Char('l'), - modifiers: KeyModifiers::NONE, - .. - }) - | Event::Key(KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - .. - }) => Some(InputEvent::UpdateLeftMark(ps.left_mark.saturating_add(1))), - - // Search - #[cfg(feature = "search")] - Event::Key(KeyEvent { - code: KeyCode::Char('/'), - modifiers: KeyModifiers::NONE, - .. - }) => Some(InputEvent::Search(SearchMode::Forward)), - #[cfg(feature = "search")] - Event::Key(KeyEvent { - code: KeyCode::Char('?'), - modifiers: KeyModifiers::NONE, - .. - }) => Some(InputEvent::Search(SearchMode::Reverse)), - #[cfg(feature = "search")] - Event::Key(KeyEvent { - code: KeyCode::Char('n'), - modifiers: KeyModifiers::NONE, - .. - }) => { - let position = ps.prefix_num.parse::().unwrap_or(1); - if ps.search_state.search_mode == SearchMode::Reverse { - Some(InputEvent::MoveToPrevMatch(position)) - } else { - Some(InputEvent::MoveToNextMatch(position)) - } - } - #[cfg(feature = "search")] - Event::Key(KeyEvent { - code: KeyCode::Char('p'), - modifiers: KeyModifiers::NONE, - .. - }) => { - let position = ps.prefix_num.parse::().unwrap_or(1); - if ps.search_state.search_mode == SearchMode::Reverse { - Some(InputEvent::MoveToNextMatch(position)) - } else { - Some(InputEvent::MoveToPrevMatch(position)) - } - } - _ => None, - } - } -} #[cfg(test)] mod tests; diff --git a/src/input/tests.rs b/src/input/tests.rs index c8d00a3..c84987c 100644 --- a/src/input/tests.rs +++ b/src/input/tests.rs @@ -9,7 +9,7 @@ use crossterm::event::{ // versions // TODO: Remove this later in favour of how handle_event should actually be called fn handle_input(ev: Event, p: &PagerState) -> Option { - p.input_classifier.classify_input(ev, p) + p.input_register.classify_input(ev, p) } // Keyboard navigation @@ -202,7 +202,7 @@ fn test_restore_prompt() { // therefore upper_mark += 1 assert_eq!( Some(InputEvent::RestorePrompt), - pager.input_classifier.classify_input(ev, &pager) + pager.input_register.classify_input(ev, &pager) ); } } @@ -453,12 +453,12 @@ fn test_search_bindings() { }); assert_eq!( - pager.input_classifier.classify_input(next_event, &pager), - Some(InputEvent::MoveToNextMatch(1)) + pager.input_register.classify_input(next_event, &pager), + Some(InputEvent::GoToMatch(1)) ); assert_eq!( - pager.input_classifier.classify_input(prev_event, &pager), - Some(InputEvent::MoveToPrevMatch(1)) + pager.input_register.classify_input(prev_event, &pager), + Some(InputEvent::GoToMatch(-1)) ); } @@ -479,12 +479,12 @@ fn test_search_bindings() { }); assert_eq!( - pager.input_classifier.classify_input(next_event, &pager), - Some(InputEvent::MoveToPrevMatch(1)) + pager.input_register.classify_input(next_event, &pager), + Some(InputEvent::GoToMatch(-1)) ); assert_eq!( - pager.input_classifier.classify_input(prev_event, &pager), - Some(InputEvent::MoveToNextMatch(1)) + pager.input_register.classify_input(prev_event, &pager), + Some(InputEvent::GoToMatch(1)) ); } } diff --git a/src/lib.rs b/src/lib.rs index 1eff884..2dfd1a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -161,7 +161,7 @@ //! | p | Go to the next previous match (alternate keybinding) | //! //! End-applications are free to change these bindings to better suit their needs. See docs for -//! [`Pager::set_input_classifier`] function and [`input`] module. +//! `map_*` and `clear_*` functions in [Pager] on how to customize them. //! //! ## Key Bindings Available at Search Prompt //! diff --git a/src/pager.rs b/src/pager.rs index c90613e..56d9fe6 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -4,14 +4,16 @@ use crate::{ ExitStrategy, LineNumbers, error::MinusError, hooks::{Hook, HookCallback}, - input, - minus_core::commands::Command, + input::{InputEvent, definitions, hashed_event_register::EventWrapper}, + minus_core::commands::{Command, InputType}, + state::PagerState, }; use crossbeam_channel::{Receiver, Sender}; +use crossterm::event::Event; use std::fmt; #[cfg(feature = "search")] -use crate::search::SearchOpts; +use crate::minus_core::commands::IncrementalSearchCondition; /// A communication bridge between the main application and the pager. /// @@ -245,54 +247,108 @@ impl Pager { Ok(self.tx.send(Command::LineWrapping(!value))?) } - /// Set a custom input classifer type. + /// Map key bindings to a callback /// - /// An input classifier type is a type that implements the [`InputClassifier`] - /// trait. It only has one required function, [`InputClassifier::classify_input`] - /// which matches user input events and maps them to a [`InputEvent`]s. - /// When the pager encounters a user input, it calls the input classifier with - /// the event and [`PagerState`] as parameters. + /// This function maps a list of key descriptions to a callback function. /// - /// Previously, whenever any application wanted to change the default key/mouse bindings - /// they neededd to create a new type, implement the [`InputClassifier`] type by copying and - /// pasting the default minus's implementation of it available in the [`DefaultInputClassifier`] - /// and change the parts they wanted to change. This is not only unergonomic but also - /// extreemely prone to bugs. Hence a newer and much simpler method was developed. - /// This method is still allowed to avoid breaking backwards compatiblity but will be dropped - /// in the next major release. + /// # Panics + /// This function panics if any item of `desc` does not follow the syntax for defining key + /// events as described in the [input](crate::input) module. + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + /// + /// # Example + /// ``` + /// use minus::Pager; + /// use minus::input::InputEvent; /// - /// With the newer method, minus already provides a type called [`HashedEventRegister`] - /// which implementing the [`InputClassifier`] and is based on a - /// [`HashMap`] storing all the key/mouse bindings and its associated callback function. - /// This allows easy addition/updation/deletion of the default bindings with simple functions - /// like [`HashedEventRegister::add_key_events`] and [`HashedEventRegister::add_mouse_events`] + /// let pager = Pager::new(); + /// pager.map_keys(vec!["c-c", "q"], |_, _| InputEvent::Exit).unwrap(); + /// ``` + pub fn map_keys(&self, desc: I, cb: C) -> Result<(), MinusError> + where + I: IntoIterator, + S: AsRef, + C: Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + { + let desc_strs = desc + .into_iter() + .map(|s| Event::Key(definitions::keydefs::parse_key_event(s.as_ref())).into()) + .collect::>(); + Ok(self.tx.send(Command::AddInputBinding( + InputType::KeyMouse(desc_strs), + Box::new(cb), + ))?) + } + + /// Map mouse bindings to a callback /// - /// See the [`input`] module for information about implementing it. + /// This function maps a list of mouse descriptions to a callback function. + /// + /// # Panics + /// This function panics if any item of `desc` does not follow the syntax for defining mouse + /// events as described in the [input](crate::input) module. /// /// # Errors /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data /// could not be sent to the receiver /// - /// [`HashedEventRegister::add_key_events`]: input::HashedEventRegister::add_key_events - /// [`HashedEventRegister::add_mouse_events`]: input::HashedEventRegister::add_mouse_events - /// [`HashMap`]: std::collections::HashMap - /// [`PagerState`]: crate::state::PagerState - /// [`InputEvent`]: input::InputEvent - /// [`InputClassifier`]: input::InputClassifier - /// [`InputClassifier::classify_input`]: input::InputClassifier - /// [`HashedEventRegister`]: input::HashedEventRegister - /// [`DefaultInputClassifier`]: input::DefaultInputClassifier - pub fn set_input_classifier( - &self, - handler: Box, - ) -> Result<(), MinusError> { - Ok(self.tx.send(Command::SetInputClassifier(handler))?) + /// # Example + /// ``` + /// use minus::Pager; + /// use minus::input::InputEvent; + /// + /// let pager = Pager::new(); + /// pager.map_mouses(vec!["scroll:up"], |_, ps| { + /// InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(5)) + /// }).unwrap(); + /// ``` + pub fn map_mouses(&self, desc: I, cb: C) -> Result<(), MinusError> + where + I: IntoIterator, + S: AsRef, + C: Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + { + let desc_strs = desc + .into_iter() + .map(|s| Event::Mouse(definitions::mousedefs::parse_mouse_event(s.as_ref())).into()) + .collect::>(); + Ok(self.tx.send(Command::AddInputBinding( + InputType::KeyMouse(desc_strs), + Box::new(cb), + ))?) } - /// Adds a function that will be called when the user quits the pager + /// Map resize event to a callback /// - /// Multiple functions can be stored for calling when the user quits. These functions - /// run sequentially in the order they were added + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + /// + /// # Example + /// ``` + /// use minus::Pager; + /// use minus::input::InputEvent; + /// use minus::input::crossterm_event::Event; + /// + /// let pager = Pager::new(); + /// pager.map_resize(|ev, _| { + /// let Event::Resize(cols, rows) = ev else { unreachable!() }; + /// InputEvent::UpdateTermArea(cols as usize, rows as usize) + /// }).unwrap(); + /// ``` + pub fn map_resize(&self, cb: C) -> Result<(), MinusError> + where + C: Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + { + Ok(self + .tx + .send(Command::AddInputBinding(InputType::Resize, Box::new(cb)))?) + } + + /// Map all events that fail to match any other binding to a callback /// /// # Errors /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data @@ -301,23 +357,150 @@ impl Pager { /// # Example /// ``` /// use minus::Pager; + /// use minus::input::InputEvent; /// - /// fn hello() { - /// println!("Hello"); - /// } + /// let pager = Pager::new(); + /// pager.map_wild_event(|_, _| InputEvent::Ignore).unwrap(); + /// ``` + pub fn map_wild_event(&self, cb: C) -> Result<(), MinusError> + where + C: Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + { + Ok(self + .tx + .send(Command::AddInputBinding(InputType::Wild, Box::new(cb)))?) + } + + /// Clear key bindings + /// + /// If any element of `desc` contains `*`, all key bindings will be cleared. + /// + /// # Panics + /// This function panics if any item of `desc` does not follow the syntax for defining key + /// events as described in the [input](crate::input) module. + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + /// + /// # Example + /// ``` + /// use minus::Pager; /// /// let pager = Pager::new(); - /// pager.add_exit_callback(Box::new(hello)).expect("Failed to communicate with the pager"); + /// pager.clear_keys(["q"]).unwrap(); + /// // Clear all key bindings + /// pager.clear_keys(["*"]).unwrap(); /// ``` - #[deprecated( - since = "5.7.0", - note = "Add a callback for [PostPagerExit](crate::hooks::Hook::PostPagerExit) hook. See [hooks](crate::hooks) for more info." - )] - pub fn add_exit_callback( - &self, - cb: Box, - ) -> Result<(), MinusError> { - Ok(self.tx.send(Command::AddExitCallback(cb))?) + pub fn clear_keys(&self, desc: I) -> Result<(), MinusError> + where + I: IntoIterator, + S: AsRef, + { + let mut all = false; + let mut desc_vec = Vec::new(); + for s in desc { + let s = s.as_ref(); + if s.contains('*') { + all = true; + break; + } + desc_vec.push(Event::Key(definitions::keydefs::parse_key_event(s)).into()); + } + + if all { + Ok(self + .tx + .send(Command::RemoveInputBinding(InputType::AllKeys))?) + } else { + Ok(self + .tx + .send(Command::RemoveInputBinding(InputType::KeyMouse(desc_vec)))?) + } + } + + /// Clear mouse bindings + /// + /// If any element of `desc` contains `*`, all mouse bindings will be cleared. + /// + /// # Panics + /// This function panics if any item of `desc` does not follow the syntax for defining mouse + /// events as described in the [input](crate::input) module. + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + /// + /// # Example + /// ``` + /// use minus::Pager; + /// + /// let pager = Pager::new(); + /// pager.clear_mouse(["scroll:up"]).unwrap(); + /// // Clear all mouse bindings + /// pager.clear_mouse(["*"]).unwrap(); + /// ``` + pub fn clear_mouse(&self, desc: I) -> Result<(), MinusError> + where + I: IntoIterator, + S: AsRef, + { + let mut all = false; + let mut desc_vec = Vec::new(); + for s in desc { + let s = s.as_ref(); + if s.contains('*') { + all = true; + break; + } + desc_vec.push(Event::Mouse(definitions::mousedefs::parse_mouse_event(s)).into()); + } + + if all { + Ok(self + .tx + .send(Command::RemoveInputBinding(InputType::AllMouses))?) + } else { + Ok(self + .tx + .send(Command::RemoveInputBinding(InputType::KeyMouse(desc_vec)))?) + } + } + + /// Clear resize event callback + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + /// + /// # Example + /// ``` + /// use minus::Pager; + /// + /// let pager = Pager::new(); + /// pager.clear_resize().unwrap(); + /// ``` + pub fn clear_resize(&self) -> Result<(), MinusError> { + Ok(self + .tx + .send(Command::RemoveInputBinding(InputType::Resize))?) + } + + /// Clear wild event callback + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + /// + /// # Example + /// ``` + /// use minus::Pager; + /// + /// let pager = Pager::new(); + /// pager.clear_wild_event().unwrap(); + /// ``` + pub fn clear_wild_event(&self) -> Result<(), MinusError> { + Ok(self.tx.send(Command::RemoveInputBinding(InputType::Wild))?) } /// Add a function to be called when a specific [`Hook`] is triggered @@ -359,9 +542,9 @@ impl Pager { #[cfg_attr(docsrs, doc(cfg(feature = "search")))] pub fn set_incremental_search_condition( &self, - cb: Box bool + Send + Sync + 'static>, + cb: IncrementalSearchCondition, ) -> crate::Result { - self.tx.send(Command::IncrementalSearchCondition(cb))?; + self.tx.send(Command::SetIncrementalSearchCondition(cb))?; Ok(()) } @@ -391,6 +574,33 @@ impl Pager { Ok(()) } + /// Adds a function that will be called when the user quits the pager + /// + /// Multiple functions can be stored for calling when the user quits. These functions + /// run sequentially in the order they were added + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + /// + /// # Example + /// ``` + /// use minus::Pager; + /// + /// fn hello() { + /// println!("Hello"); + /// } + /// + /// let pager = Pager::new(); + /// pager.add_exit_callback(Box::new(hello)).expect("Failed to communicate with the pager"); + /// ``` + pub fn add_exit_callback( + &self, + cb: Box, + ) -> Result<(), MinusError> { + Ok(self.tx.send(Command::AddExitCallback(cb))?) + } + /// Configures follow output /// /// When set to true, minus ensures that the user's screen always follows the end part of the @@ -400,8 +610,8 @@ impl Pager { /// this is used to control it from the application's side. /// /// # Errors - /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data - /// could not be sent to the mus's receiving end + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if + /// the data could not be sent to the mus's receiving end /// /// # Example /// ``` diff --git a/src/search.rs b/src/search.rs index 3471382..1ed57d1 100644 --- a/src/search.rs +++ b/src/search.rs @@ -16,13 +16,14 @@ //! and then reuses those results when the search query is confirmed by pressing `Enter`. This //! approach eliminates the need to re run the search of text after confirming the query. //! -//! Running Incremental search can be controlled by a function. The function should take -//! reference to [`SearchOpts`] as the only argument and return a bool as output. This way we can impose a -//! condition so that incremental search does not get really resource intensive for really vague queries -//! This also allows applications can control whether they want incremental search to run. -//! By default minus uses a default condition where incremental search runs only when length of search -//! query is greater than 1 and number of screen lines (lines obtained after taking care of wrapping, -//! mapped to a single row on the terminal) is greater than 5000. +//! Running Incremental search can be controlled by a function. The function should take reference +//! to [`SearchOpts`] and `&str` containing the currently entered query as arguments and return a bool +//! as output. This way we can impose a condition so that incremental search does not get really +//! resource intensive for really vague queries This also allows applications can control whether +//! they want incremental search to run. By default minus uses a default condition where incremental +//! search runs only when length of search query is greater than 1 and number of screen lines (lines +//! obtained after taking care of wrapping, mapped to a single row on the terminal) is greater than +//! 5000. //! //! Applications can override this condition with the help of //! [`Pager::set_incremental_search_condition`](crate::pager::Pager::set_incremental_search_condition) function. @@ -33,47 +34,47 @@ //! use minus::{Pager, search::SearchOpts}; //! //! let pager = Pager::new(); -//! pager.set_incremental_search_condition(Box::new(|so: &SearchOpts| so.string.len() > 1)).unwrap(); +//! pager.set_incremental_search_condition(Box::new(|_, line: &str| line.len() > 1)).unwrap(); //! ``` //! To completely disable incremental search, set the condition to false //! ``` //! use minus::{Pager, search::SearchOpts}; //! //! let pager = Pager::new(); -//! pager.set_incremental_search_condition(Box::new(|_| false)).unwrap(); +//! pager.set_incremental_search_condition(Box::new(|_, _| false)).unwrap(); //! ``` //! Similarly to always run incremental search, set the condition to true //! ``` //! use minus::{Pager, search::SearchOpts}; //! //! let pager = Pager::new(); -//! pager.set_incremental_search_condition(Box::new(|_| true)).unwrap(); +//! pager.set_incremental_search_condition(Box::new(|_, _| true)).unwrap(); //! ``` -#![allow(unused_imports)] use crate::minus_core::utils::{LinesRowMap, display, term}; use crate::screen::Screen; use crate::{LineNumbers, PagerState}; -use crate::{error::MinusError, input::HashedEventRegister, minus_core::utils, screen}; +use crate::{error::MinusError, minus_core::utils, screen}; use crossterm::{ - cursor::{self, MoveTo}, - event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + cursor, style::Attribute, terminal::{Clear, ClearType}, }; use regex::Regex; -use std::borrow::Cow; +use rustyline::completion::Completer; +use rustyline::highlight::{CmdKind, Highlighter}; +use rustyline::hint::Hinter; +use rustyline::validate::{ValidationContext, ValidationResult, Validator}; +use rustyline::{Context, Editor, Helper, error::ReadlineError}; use std::collections::BTreeSet; use std::{ - convert::{TryFrom, TryInto}, + borrow::Cow, + convert::TryInto, fmt, io::Write, - sync::LazyLock, - time::Duration, + sync::{LazyLock, Mutex}, }; -use std::collections::hash_map::RandomState; - static INVERT: LazyLock = LazyLock::new(|| Attribute::Reverse.to_string()); static NORMAL: LazyLock = LazyLock::new(|| Attribute::NoReverse.to_string()); static ANSI_REGEX: LazyLock = LazyLock::new(|| { @@ -81,10 +82,6 @@ static ANSI_REGEX: LazyLock = LazyLock::new(|| { .unwrap() }); -static WORD: LazyLock = LazyLock::new(|| { - Regex::new(r#"([\w_]+)|([-?~@#!$%^&*()-+={}\[\]:;\\|'/?<>.,"]+)|\W"#).unwrap() -}); - #[derive(Clone, Copy, Debug, Default, Eq)] #[cfg_attr(docsrs, doc(cfg(feature = "search")))] #[allow(clippy::module_name_repetitions)] @@ -114,21 +111,8 @@ impl PartialEq for SearchMode { /// this #[allow(clippy::module_name_repetitions)] pub struct SearchOpts<'a> { - /// A [`crossterm Event`](Event) on which to respond - pub ev: Option, - /// Current string query - pub string: String, - /// Status of the input prompt. See [`InputStatus`] - pub input_status: InputStatus, - /// Specifies the terminal column number that the cursor on at the prompt site. - /// It can range between 1 and `string.len() + 1` - pub cursor_position: u16, /// Direction of search. See [`SearchMode`]. pub search_mode: SearchMode, - /// Column numbers where each new word start - pub word_index: Vec, - /// Search character, either `/` or `?` depending on [`SearchMode`] - pub search_char: char, /// Number of rows available in the terminal pub rows: u16, /// Number of cols available in the terminal @@ -179,23 +163,9 @@ impl IncrementalSearchOpts<'_> { #[allow(clippy::fallible_impl_from)] impl<'a> From<&'a PagerState> for SearchOpts<'a> { fn from(ps: &'a PagerState) -> Self { - let search_char = if ps.search_state.search_mode == SearchMode::Forward { - '/' - } else if ps.search_state.search_mode == SearchMode::Reverse { - '?' - } else { - unreachable!(); - }; - let incremental_search_options = IncrementalSearchOpts::from(ps); Self { - ev: None, - string: String::with_capacity(200), - input_status: InputStatus::Active, - cursor_position: 1, - word_index: Vec::with_capacity(200), - search_char, rows: ps.rows.try_into().unwrap(), cols: ps.cols.try_into().unwrap(), incremental_search_options: Some(incremental_search_options), @@ -205,26 +175,6 @@ impl<'a> From<&'a PagerState> for SearchOpts<'a> { } } -/// Status of the search prompt -#[derive(Debug, Eq, PartialEq, Clone)] -pub enum InputStatus { - /// Closed due to confirmation of search query using `Enter` - Confirmed, - /// Closed due to abortion using `Esc` - Cancelled, - /// Search prompt is open - Active, -} - -impl InputStatus { - /// Returns true if the input prompt is closed either by confirming the query or by cancelling - /// he search - #[must_use] - pub const fn done(&self) -> bool { - matches!(self, Self::Cancelled | Self::Confirmed) - } -} - /// Return type of [`fetch_input`] pub(crate) struct FetchInputResult { /// Original search query @@ -404,11 +354,12 @@ fn incremental_preview<'a>( fn run_incremental_search<'a, F, O>( out: &mut O, so: &'a SearchOpts<'a>, + line: &'a str, incremental_search_condition: F, ) -> crate::Result<()> where O: Write, - F: Fn(&'a SearchOpts) -> bool, + F: Fn(&'a SearchOpts, &'a str) -> bool, { let Some(iso) = so.incremental_search_options.as_ref() else { return Ok(()); @@ -419,7 +370,7 @@ where let initial_left_mark = iso.initial_left_mark; // Check if we can continue forward with incremental search - let should_proceed = so.compiled_regex.is_some() && incremental_search_condition(so); + let should_proceed = so.compiled_regex.is_some() && incremental_search_condition(so, line); // **Screen resetting**: // This is an important bit when running incremental search.It reset the terminal screen to @@ -471,205 +422,74 @@ where Ok(()) } -/// Respond to keyboard events -/// -/// This souuld be called exactly once for each event by [`fetch_input`] -#[allow(clippy::too_many_lines)] -fn handle_key_press( - out: &mut O, - so: &mut SearchOpts<'_>, - incremental_search_condition: F, -) -> crate::Result -where - O: Write, - F: Fn(&SearchOpts<'_>) -> bool, -{ - // Bounds between which our cursor can move - const FIRST_AVAILABLE_COLUMN: u16 = 1; - let last_available_column: u16 = so.string.len().saturating_add(1).try_into().unwrap(); +// HACK: GET the bare `Write` trait to be `Send` + `Sync` without leaving the lock +struct ThreadSafeWriter<'a>(*mut (dyn Write + 'a)); - // If no event is present, abort - if so.ev.is_none() { - return Ok(()); +unsafe impl Send for ThreadSafeWriter<'_> {} +unsafe impl Sync for ThreadSafeWriter<'_> {} + +impl Write for ThreadSafeWriter<'_> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + unsafe { (*self.0).write(buf) } + } + fn flush(&mut self) -> std::io::Result<()> { + unsafe { (*self.0).flush() } } +} - let populate_word_index = |so: &mut SearchOpts<'_>| { - so.word_index = WORD - .find_iter(&so.string) - .map(|c| c.start().saturating_add(1).try_into().unwrap()) - .collect::>(); - }; +struct SearchHelper<'a> { + out: Mutex>, + search_opts: Mutex>, + incremental_search_condition: &'a (dyn Fn(&SearchOpts, &str) -> bool + Send + Sync), +} - let refresh_display = |out: &mut O, so: &mut SearchOpts<'_>| -> Result<(), MinusError> { - // Cache the compiled regex if the regex is valid - so.compiled_regex = Regex::new(&so.string).ok(); +impl Helper for SearchHelper<'_> {} - run_incremental_search(out, so, incremental_search_condition)?; +impl Highlighter for SearchHelper<'_> { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + let mut out = self.out.lock().unwrap(); + let mut so = self.search_opts.lock().unwrap(); - // Update prompt - term::move_cursor(out, 0, so.rows, false)?; - write!( - out, - "\r{}{}{}", - Clear(ClearType::CurrentLine), - so.search_char, - so.string, - )?; - Ok(()) - }; - match so.ev.as_ref().unwrap() { - Event::Key(KeyEvent { kind, .. }) if *kind != KeyEventKind::Press => (), - // If Esc is pressed, cancel the search and also make sure that the search query is - // ")cleared - Event::Key(KeyEvent { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - .. - }) => { - so.string.clear(); - so.input_status = InputStatus::Cancelled; - } - Event::Key(KeyEvent { - code: KeyCode::Backspace, - modifiers: KeyModifiers::NONE, - .. - }) => { - // On backspace, remove the last character just before the cursor from the so.string - // But if we are at very first character, do nothing. - if so.cursor_position == FIRST_AVAILABLE_COLUMN { - return Ok(()); - } - so.cursor_position = so.cursor_position.saturating_sub(1); - so.string - .remove(so.cursor_position.saturating_sub(1).into()); - populate_word_index(so); - // Update the line - refresh_display(out, so)?; - term::move_cursor(out, so.cursor_position, so.rows, false)?; - out.flush()?; - } - Event::Key(KeyEvent { - code: KeyCode::Delete, - modifiers: KeyModifiers::NONE, - .. - }) => { - // On delete, remove the character under the cursor from the so.string - // But if we are at the column right after the last character, do nothing. - if so.cursor_position >= last_available_column { - return Ok(()); - } - so.cursor_position = so.cursor_position.saturating_sub(1); - so.string - .remove(>::into(so.cursor_position)); - populate_word_index(so); - so.cursor_position = so.cursor_position.saturating_add(1); - // Update the line - refresh_display(out, so)?; - term::move_cursor(out, so.cursor_position, so.rows, false)?; - out.flush()?; - } - Event::Key(KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - .. - }) => { - so.input_status = InputStatus::Confirmed; - } - Event::Key(KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::NONE, - .. - }) => { - if so.cursor_position == FIRST_AVAILABLE_COLUMN { - return Ok(()); - } - so.cursor_position = so.cursor_position.saturating_sub(1); - term::move_cursor(out, so.cursor_position, so.rows, true)?; - } - Event::Key(KeyEvent { - code: KeyCode::Left, - modifiers: KeyModifiers::CONTROL, - .. - }) => { - // Find the column number where a word starts which is exactly before the current - // cursor position - // If we can't find any such column, jump to the very first available column - so.cursor_position = *so - .word_index - .iter() - .rfind(|c| c < &&so.cursor_position) - .unwrap_or(&FIRST_AVAILABLE_COLUMN); - term::move_cursor(out, so.cursor_position, so.rows, true)?; - } - Event::Key(KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::NONE, - .. - }) => { - if so.cursor_position >= last_available_column { - return Ok(()); - } - so.cursor_position = so.cursor_position.saturating_add(1); - term::move_cursor(out, so.cursor_position, so.rows, true)?; - } - Event::Key(KeyEvent { - code: KeyCode::Right, - modifiers: KeyModifiers::CONTROL, - .. - }) => { - // Find the column number where a word starts which is exactly after the current - // cursor position - // If we can't find any such column, jump to the very last available column - so.cursor_position = *so - .word_index - .iter() - .find(|c| c > &&so.cursor_position) - .unwrap_or(&last_available_column); - term::move_cursor(out, so.cursor_position, so.rows, true)?; - } - Event::Key(KeyEvent { - code: KeyCode::Home, - modifiers: KeyModifiers::NONE, - .. - }) => { - so.cursor_position = 1; - term::move_cursor(out, 1, so.rows, true)?; - } - Event::Key(KeyEvent { - code: KeyCode::End, - modifiers: KeyModifiers::NONE, - .. - }) => { - so.cursor_position = so.string.len().saturating_add(1).try_into().unwrap(); - term::move_cursor(out, so.cursor_position, so.rows, true)?; - } - Event::Key(KeyEvent { - code: KeyCode::Char(c), - modifiers: KeyModifiers::NONE, - .. - }) => { - // For any character key, without a modifier, insert it into so.string before - // current cursor position and update the line - so.string - .insert(so.cursor_position.saturating_sub(1).into(), *c); - populate_word_index(so); - refresh_display(out, so)?; - so.cursor_position = so.cursor_position.saturating_add(1); - term::move_cursor(out, so.cursor_position, so.rows, false)?; - out.flush()?; - } - _ => return Ok(()), + so.compiled_regex = Regex::new(line).ok(); + + let _ = run_incremental_search(&mut *out, &so, line, self.incremental_search_condition); + + let _ = term::move_cursor(&mut *out, 0, so.rows, false); + let _ = out.flush(); + drop(out); + drop(so); + + Cow::Borrowed(line) + } + + fn highlight_char(&self, _line: &str, _pos: usize, _forced: CmdKind) -> bool { + true + } +} + +impl Validator for SearchHelper<'_> { + fn validate(&self, _ctx: &mut ValidationContext) -> rustyline::Result { + Ok(ValidationResult::Valid(None)) + } + fn validate_while_typing(&self) -> bool { + false + } +} + +impl Hinter for SearchHelper<'_> { + type Hint = String; + fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + None } - Ok(()) +} + +impl Completer for SearchHelper<'_> { + type Candidate = String; } /// Fetch the search query /// -/// The function will change the prompt to `/` for Forward search or `?` for Reverse search. -/// Next it fetches and handles all events from the terminal screen until [`SearchOpts::input_status`] isn't -/// set to either [`InputStatus::Cancelled`] or [`InputStatus::Confirmed`] by pressing `Esc` or -/// `Enter` respectively. -/// Finally we return +/// Uses rustyline for prompt input. #[cfg(feature = "search")] pub(crate) fn fetch_input( out: &mut impl std::io::Write, @@ -677,60 +497,59 @@ pub(crate) fn fetch_input( ) -> Result { // Set the search character to show at column 0 let search_char = if ps.search_state.search_mode == SearchMode::Forward { - '/' + "/" } else { - '?' + "?" }; // Initial setup // - Place the cursor at the beginning of prompt line // - Clear the prompt - // - Write the search character and // - Show the cursor term::move_cursor(out, 0, ps.rows.try_into().unwrap(), false)?; + write!(out, "{}{}", Clear(ClearType::CurrentLine), cursor::Show)?; + crossterm::execute!(out, crossterm::event::DisableMouseCapture)?; + out.flush()?; + + let mut readline = Editor::, _>::new().unwrap(); + let search_opts = SearchOpts::from(ps); + let writer_ptr: *mut dyn std::io::Write = std::ptr::from_mut(out); + readline.set_helper(Some(SearchHelper { + out: Mutex::new(ThreadSafeWriter(writer_ptr)), + search_opts: Mutex::new(search_opts), + incremental_search_condition: &*ps.search_state.incremental_search_condition, + })); + + let prompt = readline.readline(search_char); + + // Teardown: almost opposite of setup + let helper = readline.helper_mut().unwrap(); + let mut out_lock = helper.out.lock().unwrap(); + term::move_cursor(&mut *out_lock, 0, ps.rows.try_into().unwrap(), false)?; write!( - out, - "{}{}{}", + &mut *out_lock, + "{}{}", Clear(ClearType::CurrentLine), - search_char, - cursor::Show + cursor::Hide )?; - out.flush()?; - - let mut search_opts = SearchOpts::from(ps); - - // Fetch events from the terminal and handle them - loop { - if event::poll(Duration::from_millis(100)).map_err(|e| MinusError::HandleEvent(e.into()))? { - let ev = event::read().map_err(|e| MinusError::HandleEvent(e.into()))?; - search_opts.ev = Some(ev); - handle_key_press( - out, - &mut search_opts, - &ps.search_state.incremental_search_condition, - )?; - search_opts.ev = None; + crossterm::execute!(&mut *out_lock, crossterm::event::EnableMouseCapture)?; + out_lock.flush()?; + drop(out_lock); + + match prompt { + Ok(str) => { + let mut so = helper.search_opts.lock().unwrap(); + Ok(FetchInputResult { + compiled_regex: so.compiled_regex.take(), + string: str, + }) } - if search_opts.input_status.done() { - break; + Err(ReadlineError::Io(e)) => Err(MinusError::from(e)), + Err(ReadlineError::Errno(_) | ReadlineError::Signal(_)) => todo!(), + Err(ReadlineError::Interrupted | ReadlineError::Eof | _) => { + Ok(FetchInputResult::new_empty()) } } - // Teardown: almost opposite of setup - term::move_cursor(out, 0, ps.rows.try_into().unwrap(), false)?; - write!(out, "{}{}", Clear(ClearType::CurrentLine), cursor::Hide)?; - out.flush()?; - - let fetch_input_result = match search_opts.input_status { - InputStatus::Active => unreachable!(), - InputStatus::Cancelled => FetchInputResult::new_empty(), - // When the query is confirmed, return the actual query along with everything that is valid - // in the cache - InputStatus::Confirmed => FetchInputResult { - string: search_opts.string, - compiled_regex: search_opts.compiled_regex, - }, - }; - Ok(fetch_input_result) } pub(crate) fn highlight_matches_args<'a, 'b>( @@ -876,359 +695,163 @@ impl fmt::Display for HighlightMatchesArgs<'_, '_> { } } -/// Return a index of an element from `search_idx` that will contain a search match and -/// will be after the `upper_mark` -/// -/// `jump` denotes how many indexes to jump through. For example if `search_idx` is -/// `[5, 17, 25, 34, 42]` and `upper_mark` is at 7 and `jump` is set to 1 then this will -/// return `Some(1)` which is the index of 17. If `n `is set to 3 it will return -/// `Some(3)` which is index of 34. +/// Returns the position of the nth search match relative to `upper_mark`. /// -/// If `jump` causes the index to overflow the length of the `search_idx`, the function will set it -/// to wrap to the start of `search_idx`. Also if `search_idx` is empty, this will simply return None. -/// -/// Setting `jump` equal to 0 causes a slight change in behaviour: it will also return the index of -/// element if that element is equal to the current upper mark. In the above example lets say that -/// `upper_mark` is at 17 and `jump` is set to 0 then this will return `Some(1)` as the -/// `upper_mark` and element at index are equal i.e 17. +/// This function will return the index of the nth search match present in +/// [`PagerState::search_state::search_idx`] relative to `upper_mark`. +/// - If `jump` is a strictly positive value, the returned index will be strictly `jump` matches +/// ahead `upper_mark`. +/// - If `jump` is a strictly negative value, the returned index will be strictly `jump` matches +/// before.`upper_mark`. +/// - If `jump` is 0, the returned index will be at `upper_mark` if it contains a search match or +/// the next match immediately after `upper_mark` (after in this context depends on the direction). #[must_use] -pub(crate) fn next_nth_match( +pub(crate) fn nth_match( search_idx: &BTreeSet, upper_mark: usize, - jump: usize, + jump: isize, + direction: SearchMode, ) -> Option { if search_idx.is_empty() { return None; } - // Find the index of the match that's exactly after the upper_mark. - // If there isn't one, wrap to the first match in the file. - let nearest_idx = search_idx.iter().position(|i| { - if jump == 0 { - *i >= upper_mark - } else { - *i > upper_mark - } - }); + let nearest_idx = match (jump, direction) { + (1.., _) => search_idx.iter().position(|i| *i > upper_mark), + (0, SearchMode::Forward) => search_idx.iter().position(|i| *i >= upper_mark), + (0, SearchMode::Reverse) => search_idx.iter().rposition(|i| *i <= upper_mark), + (..=-1, _) => search_idx.iter().rposition(|i| *i < upper_mark), + (_, SearchMode::Unknown) => unreachable!(), + }; - let start_idx = nearest_idx.unwrap_or(0); - let position_of_next_match = if jump == 0 { - start_idx - } else { - start_idx.saturating_add(jump - 1) % search_idx.len() + let last_idx = search_idx.len().saturating_sub(1).cast_signed(); + let fallback_idx = match (jump, direction) { + (1.., _) | (0, SearchMode::Forward) => last_idx, + (..=-1, _) | (0, SearchMode::Reverse) => 0, + (_, SearchMode::Unknown) => unreachable!(), }; + let mut start_idx = nearest_idx.map_or(fallback_idx, usize::cast_signed); + if jump > 0 { + start_idx += jump - 1; + } else if jump < 0 { + start_idx += jump + 1; + } + + start_idx = start_idx.clamp(0, last_idx); - Some(position_of_next_match) + Some(start_idx.cast_unsigned()) } #[cfg(test)] mod tests { - mod input_handling { - use crate::{ - SearchMode, - search::{InputStatus, SearchOpts, handle_key_press}, - }; - use crossterm::{ - cursor::MoveTo, - event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}, - terminal::{Clear, ClearType}, - }; - use std::{convert::TryInto, io::Write}; - - fn new_search_opts(sm: SearchMode) -> SearchOpts<'static> { - let search_char = match sm { - SearchMode::Forward => '/', - SearchMode::Reverse => '?', - SearchMode::Unknown => unreachable!(), - }; - - SearchOpts { - ev: None, - string: String::with_capacity(200), - input_status: InputStatus::Active, - cursor_position: 1, - word_index: Vec::with_capacity(200), - search_char, - rows: 25, - cols: 100, - incremental_search_options: None, - compiled_regex: None, - search_mode: sm, - } - } - - const fn make_event_from_keycode(kc: KeyCode) -> Event { - Event::Key(KeyEvent { - code: kc, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - state: KeyEventState::NONE, - }) - } - - fn pretest_setup_forward_search() -> (SearchOpts<'static>, Vec, u16, &'static str) { - const QUERY_STRING: &str = "this is@complex-text_search?query"; // length = 33 - #[allow(clippy::cast_possible_truncation)] - let last_movable_column: u16 = (QUERY_STRING.len() as u16) + 1; // 34 + use std::collections::BTreeSet; - let mut search_opts = new_search_opts(SearchMode::Forward); - let mut out = Vec::with_capacity(1500); + use super::SearchMode; - for c in QUERY_STRING.chars() { - search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c))); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - } - assert_eq!(search_opts.cursor_position, last_movable_column); - (search_opts, out, last_movable_column, QUERY_STRING) - } - - #[test] - fn input_sequential_text() { - let mut search_opts = new_search_opts(SearchMode::Forward); - let mut out = Vec::with_capacity(1500); - for (i, c) in "text search matches".chars().enumerate() { - search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c))); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.input_status, InputStatus::Active); - assert_eq!(search_opts.cursor_position as usize, i + 2); - } - search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.word_index, vec![1, 5, 6, 12, 13]); - assert_eq!(&search_opts.string, "text search matches"); - assert_eq!(search_opts.input_status, InputStatus::Confirmed); - } - - #[test] - fn input_complex_sequential_text() { - let mut search_opts = new_search_opts(SearchMode::Forward); - let mut out = Vec::with_capacity(1500); - for (i, c) in "this is@complex-text_search?query".chars().enumerate() { - search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c))); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.input_status, InputStatus::Active); - assert_eq!(search_opts.cursor_position as usize, i + 2); - } - search_opts.ev = Some(make_event_from_keycode(KeyCode::Enter)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.word_index, vec![1, 5, 6, 8, 9, 16, 17, 28, 29]); - assert_eq!(&search_opts.string, "this is@complex-text_search?query"); - assert_eq!(search_opts.input_status, InputStatus::Confirmed); - } - - #[test] - fn home_end_keys() { - // Setup - let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search(); - - search_opts.ev = Some(make_event_from_keycode(KeyCode::Home)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position as usize, 1); - - search_opts.ev = Some(make_event_from_keycode(KeyCode::End)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, last_movable_column); - } - - #[test] - fn basic_left_arrow_movement() { - const FIRST_MOVABLE_COLUMN: u16 = 1; - let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search(); - let query_string_length = last_movable_column - 1; - - // We are currently at the very next column to the last char - - // Check functionality of left arrow key - // Pressing left arrow moves the cursor towards the beginning of string until it - // reaches the first char after which pressing it further would not have any effect - for i in (FIRST_MOVABLE_COLUMN..=query_string_length).rev() { - search_opts.ev = Some(make_event_from_keycode(KeyCode::Left)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, i); - } - // Pressing Left arrow any more will not make any effect - search_opts.ev = Some(make_event_from_keycode(KeyCode::Left)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, FIRST_MOVABLE_COLUMN); - } - - #[test] - fn basic_right_arrow_movement() { - // Setup - let (mut search_opts, mut out, last_movable_column, _) = pretest_setup_forward_search(); - // Go to the 1st char - search_opts.ev = Some(make_event_from_keycode(KeyCode::Home)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - - // Check functionality of right arrow key - // Pressing right arrow moves the cursor towards the end of string until it - // reaches the very next column to the last char after which pressing it further would not have any effect - for i in 2..=last_movable_column { - search_opts.ev = Some(make_event_from_keycode(KeyCode::Right)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, i); - } - // Pressing right arrow any more will not make any effect - search_opts.ev = Some(make_event_from_keycode(KeyCode::Right)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, last_movable_column); - } + fn search_idx() -> std::collections::BTreeSet { + BTreeSet::from([2, 10, 15, 17, 50]) + } - #[test] - fn right_jump_by_word() { - const JUMP_COLUMNS: [u16; 10] = [1, 5, 6, 8, 9, 16, 17, 28, 29, LAST_MOVABLE_COLUMN]; - // Setup - let (mut search_opts, mut out, _last_movable_column, _) = - pretest_setup_forward_search(); - // LAST_MOVABLE_COLUMN = _last_movable_column = 34 - #[allow(clippy::items_after_statements)] - const LAST_MOVABLE_COLUMN: u16 = 34; - - // Go to the 1st char - search_opts.ev = Some(make_event_from_keycode(KeyCode::Home)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - - let ev = Event::Key(KeyEvent { - code: KeyCode::Right, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::CONTROL, - state: KeyEventState::NONE, - }); - - // Jump right word by word - for i in &JUMP_COLUMNS[1..] { - search_opts.ev = Some(ev.clone()); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, *i); - } - // Pressing ctrl+right will not do anything any keep the cursor at the very next column - // to the last char - search_opts.ev = Some(ev); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, LAST_MOVABLE_COLUMN); - } + #[test] + fn nth_match_returns_none_for_empty_search_index() { + let search_idx = std::collections::BTreeSet::new(); + assert_eq!( + super::nth_match(&search_idx, 10, 1, SearchMode::Forward), + None + ); + } - #[test] - fn left_jump_by_word() { - const JUMP_COLUMNS: [u16; 10] = [1, 5, 6, 8, 9, 16, 17, 28, 29, LAST_MOVABLE_COLUMN]; - // Setup - let (mut search_opts, mut out, _last_movable_column, _) = - pretest_setup_forward_search(); - // LAST_MOVABLE_COLUMN = _last_movable_column = 34 - #[allow(clippy::items_after_statements)] - const LAST_MOVABLE_COLUMN: u16 = 34; - - // We are currently at the very next column to the last char - let ev = Event::Key(KeyEvent { - code: KeyCode::Left, - kind: KeyEventKind::Press, - modifiers: KeyModifiers::CONTROL, - state: KeyEventState::NONE, - }); - - // Jump right word by word - for i in (JUMP_COLUMNS[..(JUMP_COLUMNS.len() - 1)]).iter().rev() { - search_opts.ev = Some(ev.clone()); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, *i); - } - // Pressing ctrl+left will not do anything and keep the cursor at the very first column - search_opts.ev = Some(ev); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.cursor_position, JUMP_COLUMNS[0]); - } + #[test] + fn nth_match_zero_jump_forward_returns_match_at_upper_mark() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 10, 0, SearchMode::Forward), + Some(1) + ); + } - #[test] - fn esc_key() { - let (mut search_opts, mut out, _, _) = pretest_setup_forward_search(); + #[test] + fn nth_match_zero_jump_forward_returns_next_match_after_upper_mark() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 11, 0, SearchMode::Forward), + Some(2) + ); + } - search_opts.ev = Some(make_event_from_keycode(KeyCode::Esc)); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - assert_eq!(search_opts.input_status, InputStatus::Cancelled); - } + #[test] + fn nth_match_zero_jump_reverse_returns_match_at_upper_mark() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 10, 0, SearchMode::Reverse), + Some(1) + ); + } - #[test] - fn forward_sequential_text_input_screen_data() { - let (search_opts, out, _last_movable_column, query_string) = - pretest_setup_forward_search(); - - let mut result_out = Vec::with_capacity(1500); - - // Try to recreate the behaviour of handle_key_press when new char is entered - let mut string = String::with_capacity(query_string.len()); - let mut cursor_position: u16 = 1; - for c in query_string.chars() { - string.push(c); - cursor_position = cursor_position.saturating_add(1); - write!( - result_out, - "{move_to_prompt}\r{clear_line}/{string}{move_to_position}", - move_to_prompt = MoveTo(0, search_opts.rows), - clear_line = Clear(ClearType::CurrentLine), - move_to_position = MoveTo(cursor_position, search_opts.rows), - ) - .unwrap(); - } - assert_eq!(out, result_out); - } + #[test] + fn nth_match_zero_jump_reverse_returns_previous_match_before_upper_mark() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 11, 0, SearchMode::Reverse), + Some(1) + ); + } - #[test] - fn backward_sequential_text_input_screen_data() { - const QUERY_STRING: &str = "this is@complex-text_search?query"; // length = 33 - #[allow(clippy::cast_possible_truncation)] - const LAST_MOVABLE_COLUMN: u16 = (QUERY_STRING.len() as u16) + 1; // 34 + #[test] + fn nth_match_positive_jump_moves_strictly_ahead_of_upper_mark() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 10, 1, SearchMode::Forward), + Some(2) + ); + assert_eq!( + super::nth_match(&search_idx, 10, 2, SearchMode::Forward), + Some(3) + ); + } - let mut search_opts = new_search_opts(SearchMode::Reverse); - let mut out = Vec::with_capacity(1500); + #[test] + fn nth_match_negative_jump_moves_strictly_before_upper_mark() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 17, -1, SearchMode::Reverse), + Some(2) + ); + assert_eq!( + super::nth_match(&search_idx, 17, -2, SearchMode::Reverse), + Some(1) + ); + } - for c in QUERY_STRING.chars() { - search_opts.ev = Some(make_event_from_keycode(KeyCode::Char(c))); - handle_key_press(&mut out, &mut search_opts, |_| false).unwrap(); - } - assert_eq!(search_opts.cursor_position, LAST_MOVABLE_COLUMN); - - let mut result_out = Vec::with_capacity(1500); - - // Try to recreate the behaviour of handle_key_press when new char is entered - let mut string = String::with_capacity(QUERY_STRING.len()); - let mut cursor_position: u16 = 1; - for c in QUERY_STRING.chars() { - string.push(c); - cursor_position = cursor_position.saturating_add(1); - write!( - result_out, - "{move_to_prompt}\r{clear_line}?{string}{move_to_position}", - move_to_prompt = MoveTo(0, search_opts.rows), - clear_line = Clear(ClearType::CurrentLine), - move_to_position = MoveTo(cursor_position, search_opts.rows), - ) - .unwrap(); - } - assert_eq!(out, result_out); - } + #[test] + fn nth_match_clamps_to_first_match_when_reverse_search_has_no_previous_match() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 1, 0, SearchMode::Reverse), + Some(0) + ); + assert_eq!( + super::nth_match(&search_idx, 1, -1, SearchMode::Reverse), + Some(0) + ); } #[test] - fn test_next_match() { - // A sample index for mocking actual search index matches - let search_idx = std::collections::BTreeSet::from([2, 10, 15, 17, 50]); - let mut upper_mark = 0; - let mut search_mark; - for (i, v) in search_idx.iter().enumerate() { - search_mark = super::next_nth_match(&search_idx, upper_mark, 1); - assert_eq!(search_mark, Some(i)); - let next_upper_mark = *search_idx.iter().nth(search_mark.unwrap()).unwrap(); - assert_eq!(next_upper_mark, *v); - upper_mark = next_upper_mark; - } + fn nth_match_clamps_to_last_match_when_forward_search_has_no_later_match() { + let search_idx = search_idx(); + assert_eq!( + super::nth_match(&search_idx, 100, 0, SearchMode::Forward), + Some(4) + ); + assert_eq!( + super::nth_match(&search_idx, 100, 1, SearchMode::Forward), + Some(4) + ); } #[allow(clippy::trivial_regex)] mod highlighting { - use std::collections::BTreeSet; - - use crate::PagerState; - use crate::search::{INVERT, NORMAL, highlight_line_matches, next_nth_match}; + use crate::search::{INVERT, NORMAL, highlight_line_matches}; use crossterm::style::Attribute; use regex::Regex; diff --git a/src/state.rs b/src/state.rs index ba41500..93990f2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,13 +2,16 @@ #![allow(dead_code)] #[cfg(feature = "search")] -use crate::search::{SearchMode, SearchOpts, next_nth_match}; +use crate::{ + minus_core::commands::IncrementalSearchCondition, + search::{SearchMode, SearchOpts, nth_match}, +}; use crate::{ LineNumbers, error::{MinusError, TermError}, hooks::{Hook, Hooks}, - input::{self, HashedEventRegister}, + input::HashedEventRegister, minus_core::{ self, CommandQueue, utils::{ @@ -24,7 +27,6 @@ use parking_lot::Mutex; use std::collections::BTreeSet; use std::{ borrow::Cow, - collections::hash_map::RandomState, convert::TryInto, io::stdout, sync::{Arc, atomic::AtomicBool}, @@ -53,15 +55,15 @@ pub struct SearchState { /// Function to run before running an incremental search. /// /// If the function returns a `false`, the incremental search is cancelled. - pub(crate) incremental_search_condition: - Box bool + Send + Sync + 'static>, + pub(crate) incremental_search_condition: IncrementalSearchCondition, } #[cfg(feature = "search")] impl Default for SearchState { fn default() -> Self { - let incremental_search_condition = Box::new(|so: &SearchOpts| { - so.string.len() > 1 + let incremental_search_condition = Box::new(|so: &SearchOpts, line: &str| { + line.len() > 1 + // TODO: Do perf tests after [pr:#159] and check if this can be lifted off && so .incremental_search_options .as_ref() @@ -88,13 +90,6 @@ pub struct Selection { /// Holds all information and configuration about the pager during /// its run time. -/// -/// This type is exposed so that end-applications can implement the -/// [`InputClassifier`](input::InputClassifier) trait which requires the `PagerState` to be passed -/// as a parameter -/// -/// Various fields are made public so that their values can be accessed while implementing the -/// trait. #[allow(clippy::module_name_repetitions)] pub struct PagerState { /// Configuration for line numbers. See [`LineNumbers`] @@ -143,8 +138,8 @@ pub struct PagerState { pub selection: Option, /// The prompt displayed at the bottom wrapped to available terminal width pub(crate) prompt: String, - /// The input classifier to be called when a input is detected - pub(crate) input_classifier: Box, + /// Callbacks to run when inputs from user are received + pub(crate) input_register: HashedEventRegister, /// Functions to run when the pager quits pub(crate) exit_callbacks: Vec>, /// Callbacks for hooks @@ -195,7 +190,7 @@ impl PagerState { prompt, running: &minus_core::RUNMODE, left_mark: 0, - input_classifier: Box::>::default(), + input_register: HashedEventRegister::default(), exit_callbacks: Vec::with_capacity(5), hooks: Hooks::new(), message: None, @@ -270,8 +265,13 @@ impl PagerState { #[cfg(feature = "search")] { self.search_state.search_idx = format_result.append_search_idx; - self.search_state.search_mark = - next_nth_match(&self.search_state.search_idx, self.upper_mark, 0).unwrap_or(0); + self.search_state.search_mark = nth_match( + &self.search_state.search_idx, + self.upper_mark, + 0, + self.search_mode, + ) + .unwrap_or(0); } self.lines_to_row_map = format_result.lines_to_row_map; self.screen.max_line_length = format_result.max_line_length; diff --git a/src/static_pager.rs b/src/static_pager.rs index 308ae70..130981b 100644 --- a/src/static_pager.rs +++ b/src/static_pager.rs @@ -24,7 +24,6 @@ use crate::{Pager, error::MinusError}; /// # Errors /// The function will return with an error if it encounters a error during paging. #[cfg_attr(docsrs, doc(cfg(feature = "static_output")))] -#[allow(clippy::needless_pass_by_value)] pub fn page_all(pager: Pager) -> Result<(), MinusError> { - init::init_core(&pager, crate::RunMode::Static) + init::init_core(pager, crate::RunMode::Static) }