From 4312ebd61b41116549b2016cbdc50023c30979d9 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Fri, 8 May 2026 23:06:20 +0530 Subject: [PATCH 01/14] refactor(input): remove all legacy input classifier code --- src/core/commands.rs | 26 +- src/core/ev_handler.rs | 10 +- src/core/init.rs | 2 +- src/input/hashed_event_register.rs | 71 ++---- src/input/mod.rs | 366 +---------------------------- src/pager.rs | 56 +---- src/state.rs | 7 +- 7 files changed, 74 insertions(+), 464 deletions(-) diff --git a/src/core/commands.rs b/src/core/commands.rs index 7dc3b32..8e57506 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::{InputEvent, InputEventBoxed}, minus_core::utils::display::AppendStyle, }; @@ -52,7 +52,6 @@ pub enum Command { // Configuration options SetExitStrategy(ExitStrategy), - SetInputClassifier(Box), AddExitCallback(Box), AddHook(Hook, u64, HookCallback), RemoveHook(Hook, u64), @@ -61,6 +60,12 @@ pub enum Command { #[cfg(feature = "search")] IncrementalSearchCondition(Box bool + Send + Sync + 'static>), + // Input + AddKeyBinding(Vec, InputEventBoxed, bool), + RemoveKeyBinding(Vec), + AddMouseBinding(Vec, InputEventBoxed, bool), + RemoveMouseBinding(Vec), + Io(IoCommand), } @@ -77,12 +82,18 @@ 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::AddKeyBinding(a_desc, _, a_remap), Self::AddKeyBinding(b_desc, _, b_remap)) + | ( + Self::AddMouseBinding(a_desc, _, a_remap), + Self::AddMouseBinding(b_desc, _, b_remap), + ) => a_desc == b_desc && a_remap == b_remap, + (Self::RemoveKeyBinding(a), Self::RemoveKeyBinding(b)) + | (Self::RemoveMouseBinding(a), Self::RemoveMouseBinding(b)) => a == b, (Self::Io(a), Self::Io(b)) => a == b, _ => false, } @@ -99,7 +110,6 @@ 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"), @@ -110,6 +120,12 @@ impl Debug for Command { Self::SetRunNoOverflow(val) => write!(f, "SetRunNoOverflow({val:?})"), Self::UserInput(input) => write!(f, "UserInput({input:?})"), Self::FollowOutput(follow_output) => write!(f, "FollowOutput({follow_output:?})"), + Self::AddKeyBinding(desc, _, remap) => write!(f, "AddKeyBinding({desc:?}, {remap})"), + Self::AddMouseBinding(desc, _, remap) => { + write!(f, "AddMouseBinding({desc:?}, {remap})") + } + Self::RemoveKeyBinding(desc) => write!(f, "RemoveKeyBinding({desc:?})"), + Self::RemoveMouseBinding(desc) => write!(f, "RemoveMouseBinding({desc:?})"), Self::Io(c) => write!(f, "Io({c:?})"), } } diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index da149e3..62ccb55 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -342,7 +342,6 @@ pub fn handle_event( 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::AddExitCallback(cb) => p.exit_callbacks.push(cb), Command::AddHook(hook, id, cb) => p.hooks.add_callback(hook, id, cb), Command::RemoveHook(hook, id) => { @@ -360,6 +359,15 @@ pub fn handle_event( } Command::UserInput(_) => {} Command::Io(_) => unreachable!(), + // TODO: Work on this + Command::AddKeyBinding(desc, cb, remap) => { + p.event_register.add_key_events_checked(&desc, cb, remap) + } + Command::AddMouseBinding(desc, cb, remap) => { + p.event_register.add_mouse_events_checked(&desc, cb, remap) + } + Command::RemoveKeyBinding(desc) => p.event_register.remove_key_events(&desc), + Command::RemoveMouseBinding(desc) => p.event_register.remove_mouse_events(&desc), } } diff --git a/src/core/init.rs b/src/core/init.rs index 9b1a2ea..ef3292f 100644 --- a/src/core/init.rs +++ b/src/core/init.rs @@ -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.event_register.classify_input(ev, &guard); if let Some(iev) = input { if !matches!(iev, InputEvent::Number(_)) { guard.prefix_num.clear(); diff --git a/src/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index a4a9b0a..d4945af 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -4,13 +4,10 @@ //! 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>; @@ -89,44 +86,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 { - /// Create a new [`HashedEventRegister`] with the default hasher and insert the default bindings +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 with the Hasher `s` + pub fn new() -> Self { + Self(HashMap::new()) } /// Adds a callback to handle all events that failed to match @@ -185,15 +162,16 @@ where self.0 .remove(&EventWrapper::ExactMatchEvent(Event::Resize(0, 0))); } + + pub(crate) fn classify_input(&self, ev: Event, ps: &crate::PagerState) -> Option { + self.get(&ev).map(|c| c(ev, ps)) + } } // ############################### // KEYBOARD SPECIFIC FUNCTIONS // ############################### -impl HashedEventRegister -where - S: BuildHasher, -{ +impl HashedEventRegister { /// 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) @@ -244,14 +222,14 @@ where /// ``` pub fn add_key_events_checked( &mut self, - desc: &[&str], + desc: &[String], 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(); + Event::Key(super::definitions::keydefs::parse_key_event(&k)).into(); assert!(self.0.contains_key(&def) && remap, ""); self.0.insert(def, v.clone()); } @@ -266,10 +244,10 @@ where /// /// input_register.remove_key_events(&["down"]) /// ``` - pub fn remove_key_events(&mut self, desc: &[&str]) { + pub fn remove_key_events(&mut self, desc: &[String]) { for k in desc { self.0 - .remove(&Event::Key(super::definitions::keydefs::parse_key_event(k)).into()); + .remove(&Event::Key(super::definitions::keydefs::parse_key_event(&k)).into()); } } } @@ -277,10 +255,7 @@ where // ############################### // MOUSE SPECIFIC FUNCTIONS // ############################### -impl HashedEventRegister -where - S: BuildHasher, -{ +impl HashedEventRegister { /// 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) @@ -330,14 +305,14 @@ where /// ``` pub fn add_mouse_events_checked( &mut self, - desc: &[&str], + desc: &[String], 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(); + Event::Mouse(super::definitions::mousedefs::parse_mouse_event(&k)).into(); assert!(self.0.contains_key(&def) && remap, ""); self.0.insert(def, v.clone()); } @@ -352,7 +327,7 @@ where /// /// input_register.remove_mouse_events(&["scroll:down"]) /// ``` - pub fn remove_mouse_events(&mut self, mouse: &[&str]) { + pub fn remove_mouse_events(&mut self, mouse: &[String]) { for k in mouse { self.0 .remove(&Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into()); diff --git a/src/input/mod.rs b/src/input/mod.rs index f52fff7..692443b 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -87,96 +87,6 @@ //! 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; @@ -186,13 +96,10 @@ pub use crossterm::event as crossterm_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)] @@ -267,21 +174,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,10 +181,7 @@ 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, -{ +pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { map.add_key_events(&["q", "c-c"], |_, _| InputEvent::Exit); map.add_key_events(&["up", "k"], |_, ps| { @@ -445,252 +334,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/pager.rs b/src/pager.rs index c90613e..1d244a1 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -4,10 +4,12 @@ use crate::{ ExitStrategy, LineNumbers, error::MinusError, hooks::{Hook, HookCallback}, - input, + input::InputEvent, minus_core::commands::Command, + state::PagerState, }; use crossbeam_channel::{Receiver, Sender}; +use crossterm::event::Event; use std::fmt; #[cfg(feature = "search")] @@ -245,48 +247,16 @@ impl Pager { Ok(self.tx.send(Command::LineWrapping(!value))?) } - /// Set a custom input classifer type. - /// - /// 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. - /// - /// 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. - /// - /// 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`] - /// - /// See the [`input`] module for information about implementing it. - /// - /// # 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))?) + pub fn add_key_events(&self, desc: I, cb: C, remap: bool) -> Result<(), MinusError> + where + I: IntoIterator, + S: Into, + C: Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + { + let desc_strs = desc.into_iter().map(Into::into).collect::>(); + Ok(self + .tx + .send(Command::AddKeyBinding(desc_strs, Box::new(cb), remap))?) } /// Adds a function that will be called when the user quits the pager diff --git a/src/state.rs b/src/state.rs index ba41500..2d61d76 100644 --- a/src/state.rs +++ b/src/state.rs @@ -24,7 +24,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}, @@ -143,8 +142,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) event_register: HashedEventRegister, /// Functions to run when the pager quits pub(crate) exit_callbacks: Vec>, /// Callbacks for hooks @@ -195,7 +194,7 @@ impl PagerState { prompt, running: &minus_core::RUNMODE, left_mark: 0, - input_classifier: Box::>::default(), + event_register: HashedEventRegister::default(), exit_callbacks: Vec::with_capacity(5), hooks: Hooks::new(), message: None, From bc4eeead8b0a6d0102dbcb8b3dee885f4eaf5256 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Fri, 8 May 2026 22:42:27 +0530 Subject: [PATCH 02/14] feat: add map_* and clear_* functions in Pager to add callbacks --- src/core/commands.rs | 27 ++-- src/core/ev_handler.rs | 23 +-- src/core/init.rs | 2 +- src/input/hashed_event_register.rs | 154 ++++--------------- src/input/mod.rs | 89 +++-------- src/input/tests.rs | 12 +- src/lib.rs | 2 +- src/pager.rs | 233 ++++++++++++++++++++++++++--- src/state.rs | 9 +- 9 files changed, 296 insertions(+), 255 deletions(-) diff --git a/src/core/commands.rs b/src/core/commands.rs index 8e57506..42dc148 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::{InputEvent, InputEventBoxed}, + input::{self, InputEvent, hashed_event_register::EventWrapper}, minus_core::utils::display::AppendStyle, }; @@ -29,6 +29,14 @@ pub enum IoCommand { FetchSearchQuery, } +#[derive(Debug, PartialEq, Eq)] +pub enum InputType { + Key(Vec), + Mouse(Vec), + Resize, + Wild, +} + /// Different events that can be encountered while the pager is running #[non_exhaustive] #[allow(private_interfaces)] @@ -61,10 +69,8 @@ pub enum Command { IncrementalSearchCondition(Box bool + Send + Sync + 'static>), // Input - AddKeyBinding(Vec, InputEventBoxed, bool), - RemoveKeyBinding(Vec), - AddMouseBinding(Vec, InputEventBoxed, bool), - RemoveMouseBinding(Vec), + AddInputBinding(InputType, input::InputEventBoxed), + RemoveInputBinding(InputType), Io(IoCommand), } @@ -87,13 +93,8 @@ impl PartialEq for Command { (Self::RemoveHook(h1, id1), Self::RemoveHook(h2, id2)) => h1 == h2 && id1 == id2, #[cfg(feature = "search")] (Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true, - (Self::AddKeyBinding(a_desc, _, a_remap), Self::AddKeyBinding(b_desc, _, b_remap)) - | ( - Self::AddMouseBinding(a_desc, _, a_remap), - Self::AddMouseBinding(b_desc, _, b_remap), - ) => a_desc == b_desc && a_remap == b_remap, - (Self::RemoveKeyBinding(a), Self::RemoveKeyBinding(b)) - | (Self::RemoveMouseBinding(a), Self::RemoveMouseBinding(b)) => a == b, + (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, } @@ -119,6 +120,8 @@ impl Debug for Command { #[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::AddKeyBinding(desc, _, remap) => write!(f, "AddKeyBinding({desc:?}, {remap})"), Self::AddMouseBinding(desc, _, remap) => { diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 62ccb55..da25492 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 @@ -357,17 +357,20 @@ pub fn handle_event( p.format_prompt(); command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); } + Command::AddInputBinding(et, cb) => match et { + InputType::Key(desc) => p.input_register.map_keys_ev(desc, cb), + InputType::Mouse(desc) => p.input_register.map_mouse_ev(desc, cb), + InputType::Resize => p.input_register.map_resize(cb), + InputType::Wild => p.input_register.map_wild_event(cb), + }, + Command::RemoveInputBinding(et) => match et { + InputType::Key(desc) => p.input_register.clear_keys(&desc), + InputType::Mouse(desc) => p.input_register.clear_mouse(&desc), + InputType::Resize => p.input_register.clear_resize(), + InputType::Wild => p.input_register.clear_wild_event(), + }, Command::UserInput(_) => {} Command::Io(_) => unreachable!(), - // TODO: Work on this - Command::AddKeyBinding(desc, cb, remap) => { - p.event_register.add_key_events_checked(&desc, cb, remap) - } - Command::AddMouseBinding(desc, cb, remap) => { - p.event_register.add_mouse_events_checked(&desc, cb, remap) - } - Command::RemoveKeyBinding(desc) => p.event_register.remove_key_events(&desc), - Command::RemoveMouseBinding(desc) => p.event_register.remove_mouse_events(&desc), } } diff --git a/src/core/init.rs b/src/core/init.rs index ef3292f..d391542 100644 --- a/src/core/init.rs +++ b/src/core/init.rs @@ -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.event_register.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/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index d4945af..0949a23 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -1,8 +1,9 @@ //! 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::InputEvent; use crate::PagerState; @@ -16,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, } @@ -115,7 +116,7 @@ impl HashedEventRegister { /// /// 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, ) { @@ -129,24 +130,7 @@ impl HashedEventRegister { } /// 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, ) { @@ -158,11 +142,16 @@ impl HashedEventRegister { } /// 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))); } + /// Removes the currently active wild event callback + pub fn clear_wild_event(&mut self) { + self.0.remove(&EventWrapper::WildEvent); + } + pub(crate) fn classify_input(&self, ev: Event, ps: &crate::PagerState) -> Option { self.get(&ev).map(|c| c(ev, ps)) } @@ -173,21 +162,7 @@ impl HashedEventRegister { // ############################### impl HashedEventRegister { /// 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( + pub fn map_keys( &mut self, desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, @@ -201,53 +176,21 @@ impl HashedEventRegister { } } - /// 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 fn map_keys_ev( &mut self, - desc: &[String], + 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: &[String]) { + pub fn clear_keys(&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); } } } @@ -257,21 +200,7 @@ impl HashedEventRegister { // ############################### impl HashedEventRegister { /// 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( + pub fn map_mouse( &mut self, desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, @@ -285,52 +214,21 @@ impl HashedEventRegister { } } - /// 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( + pub fn map_mouse_ev( &mut self, - desc: &[String], + 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::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(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_mouse_events(&["scroll:down"]) - /// ``` - pub fn remove_mouse_events(&mut self, mouse: &[String]) { + pub fn clear_mouse(&mut self, mouse: &[EventWrapper]) { for k in mouse { - self.0 - .remove(&Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into()); + self.0.remove(k); } } } diff --git a/src/input/mod.rs b/src/input/mod.rs index 692443b..58ac291 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}` @@ -182,20 +141,20 @@ pub enum InputEvent { )] #[allow(clippy::too_many_lines)] pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { - map.add_key_events(&["q", "c-c"], |_, _| InputEvent::Exit); + 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 { @@ -203,17 +162,17 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { 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::() @@ -233,21 +192,21 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { .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| { + 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 { @@ -258,7 +217,7 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { InputEvent::Ignore } }); - map.add_key_events(&["p", "s-n"], |_, ps| { + map.map_keys(&["p", "s-n"], |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); if ps.search_state.search_mode == SearchMode::Forward { @@ -271,10 +230,10 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { }); } - 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, _| { @@ -296,27 +255,27 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { map.add_key_events(&["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, diff --git a/src/input/tests.rs b/src/input/tests.rs index c8d00a3..c6c8276 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,11 +453,11 @@ fn test_search_bindings() { }); assert_eq!( - pager.input_classifier.classify_input(next_event, &pager), + pager.input_register.classify_input(next_event, &pager), Some(InputEvent::MoveToNextMatch(1)) ); assert_eq!( - pager.input_classifier.classify_input(prev_event, &pager), + pager.input_register.classify_input(prev_event, &pager), Some(InputEvent::MoveToPrevMatch(1)) ); } @@ -479,11 +479,11 @@ fn test_search_bindings() { }); assert_eq!( - pager.input_classifier.classify_input(next_event, &pager), + pager.input_register.classify_input(next_event, &pager), Some(InputEvent::MoveToPrevMatch(1)) ); assert_eq!( - pager.input_classifier.classify_input(prev_event, &pager), + pager.input_register.classify_input(prev_event, &pager), Some(InputEvent::MoveToNextMatch(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 1d244a1..b602aac 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -4,8 +4,8 @@ use crate::{ ExitStrategy, LineNumbers, error::MinusError, hooks::{Hook, HookCallback}, - input::InputEvent, - minus_core::commands::Command, + input::{InputEvent, definitions, hashed_event_register::EventWrapper}, + minus_core::commands::{Command, InputType}, state::PagerState, }; use crossbeam_channel::{Receiver, Sender}; @@ -247,22 +247,131 @@ impl Pager { Ok(self.tx.send(Command::LineWrapping(!value))?) } - pub fn add_key_events(&self, desc: I, cb: C, remap: bool) -> Result<(), MinusError> + /// Map key bindings to a callback + /// + /// This function maps a list of key descriptions to a callback function. + /// + /// # 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; + /// + /// 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: Into, + 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::Key(desc_strs), + Box::new(cb), + ))?) + } + + /// Map mouse bindings to a callback + /// + /// 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 + /// + /// # 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::Mouse(desc_strs), + Box::new(cb), + ))?) + } + + /// Map resize event to a 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; + /// 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, { - let desc_strs = desc.into_iter().map(Into::into).collect::>(); Ok(self .tx - .send(Command::AddKeyBinding(desc_strs, Box::new(cb), remap))?) + .send(Command::AddInputBinding(InputType::Resize, Box::new(cb)))?) } - /// Adds a function that will be called when the user quits the pager + /// Map all events that fail to match any other binding 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; + /// + /// 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 /// /// # Errors /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data @@ -271,23 +380,72 @@ impl Pager { /// # Example /// ``` /// use minus::Pager; + /// use minus::input::crossterm_event::{Event, KeyEvent, KeyCode, KeyModifiers}; /// - /// fn hello() { - /// println!("Hello"); - /// } + /// let pager = Pager::new(); + /// pager.clear_keys(vec![Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)).into()]).unwrap(); + /// ``` + pub fn clear_keys(&self, desc: Vec) -> Result<(), MinusError> { + Ok(self + .tx + .send(Command::RemoveInputBinding(InputType::Key(desc)))?) + } + + /// Clear mouse bindings + /// + /// # 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 + /// + /// ``` + /// use minus::Pager; + /// + /// // let pager = Pager::new(); + /// // pager.clear_mouse(vec![...]).unwrap(); + /// ``` + pub fn clear_mouse(&self, desc: Vec) -> Result<(), MinusError> { + Ok(self + .tx + .send(Command::RemoveInputBinding(InputType::Mouse(desc)))?) + } + + /// 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.add_exit_callback(Box::new(hello)).expect("Failed to communicate with the pager"); + /// pager.clear_resize().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_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 @@ -361,6 +519,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 @@ -370,8 +555,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/state.rs b/src/state.rs index 2d61d76..713d801 100644 --- a/src/state.rs +++ b/src/state.rs @@ -87,13 +87,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,7 +136,7 @@ pub struct PagerState { /// The prompt displayed at the bottom wrapped to available terminal width pub(crate) prompt: String, /// Callbacks to run when inputs from user are received - pub(crate) event_register: HashedEventRegister, + pub(crate) input_register: HashedEventRegister, /// Functions to run when the pager quits pub(crate) exit_callbacks: Vec>, /// Callbacks for hooks From 8b397c1917ce62670b023775cefee3fbeea2b81f Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Fri, 8 May 2026 23:45:35 +0530 Subject: [PATCH 03/14] fix: remove exit_strategy field initialization --- src/state.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/state.rs b/src/state.rs index 713d801..45ec827 100644 --- a/src/state.rs +++ b/src/state.rs @@ -187,7 +187,7 @@ impl PagerState { prompt, running: &minus_core::RUNMODE, left_mark: 0, - event_register: HashedEventRegister::default(), + input_register: HashedEventRegister::default(), exit_callbacks: Vec::with_capacity(5), hooks: Hooks::new(), message: None, From 66b863f7082e84dd1afe2db72b4abe36d309fbf0 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Sat, 9 May 2026 15:25:17 +0530 Subject: [PATCH 04/14] feat(pager): allow clearing events with * --- src/core/commands.rs | 2 + src/core/ev_handler.rs | 3 ++ src/input/hashed_event_register.rs | 16 ++++++ src/pager.rs | 79 +++++++++++++++++++++++++----- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/src/core/commands.rs b/src/core/commands.rs index 42dc148..5f54ad9 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -33,6 +33,8 @@ pub enum IoCommand { pub enum InputType { Key(Vec), Mouse(Vec), + AllKeys, + AllMouses, Resize, Wild, } diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index da25492..b2459a9 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -362,10 +362,13 @@ pub fn handle_event( InputType::Mouse(desc) => p.input_register.map_mouse_ev(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::Key(desc) => p.input_register.clear_keys(&desc), InputType::Mouse(desc) => p.input_register.clear_mouse(&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(), }, diff --git a/src/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index 0949a23..7b3e9b6 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -193,6 +193,16 @@ impl HashedEventRegister { self.0.remove(k); } } + + /// Clear all keyboard bindings + pub fn clear_all_keys(&mut self) { + self.0.retain(|k, _| { + !matches!( + k, + EventWrapper::ExactMatchEvent(Event::Key(..)) | EventWrapper::WildEvent + ) + }); + } } // ############################### @@ -231,4 +241,10 @@ impl HashedEventRegister { self.0.remove(k); } } + + /// 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/pager.rs b/src/pager.rs index b602aac..b588209 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -373,6 +373,12 @@ impl Pager { /// 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 @@ -380,36 +386,85 @@ impl Pager { /// # Example /// ``` /// use minus::Pager; - /// use minus::input::crossterm_event::{Event, KeyEvent, KeyCode, KeyModifiers}; /// /// let pager = Pager::new(); - /// pager.clear_keys(vec![Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)).into()]).unwrap(); + /// pager.clear_keys(["q"]).unwrap(); + /// // Clear all key bindings + /// pager.clear_keys(["*"]).unwrap(); /// ``` - pub fn clear_keys(&self, desc: Vec) -> Result<(), MinusError> { - Ok(self - .tx - .send(Command::RemoveInputBinding(InputType::Key(desc)))?) + 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::Key(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(vec![...]).unwrap(); + /// let pager = Pager::new(); + /// pager.clear_mouse(["scroll:up"]).unwrap(); + /// // Clear all mouse bindings + /// pager.clear_mouse(["*"]).unwrap(); /// ``` - pub fn clear_mouse(&self, desc: Vec) -> Result<(), MinusError> { - Ok(self - .tx - .send(Command::RemoveInputBinding(InputType::Mouse(desc)))?) + 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::Mouse(desc_vec)))?) + } } /// Clear resize event callback From 4d4170875201dcc15d2258ccaf7bfcd41f814352 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Sat, 9 May 2026 18:27:21 +0530 Subject: [PATCH 05/14] refactor(input): merge key+mouse input type into one because they were same --- src/core/commands.rs | 3 +- src/core/ev_handler.rs | 6 ++-- src/input/definitions/keydefs.rs | 5 +++ src/input/definitions/mousedefs.rs | 5 +++ src/input/hashed_event_register.rs | 53 ++++++++++-------------------- src/input/mod.rs | 1 + src/pager.rs | 8 ++--- 7 files changed, 35 insertions(+), 46 deletions(-) diff --git a/src/core/commands.rs b/src/core/commands.rs index 5f54ad9..52777b5 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -31,8 +31,7 @@ pub enum IoCommand { #[derive(Debug, PartialEq, Eq)] pub enum InputType { - Key(Vec), - Mouse(Vec), + KeyMouse(Vec), AllKeys, AllMouses, Resize, diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index b2459a9..8b9703d 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -358,15 +358,13 @@ pub fn handle_event( command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); } Command::AddInputBinding(et, cb) => match et { - InputType::Key(desc) => p.input_register.map_keys_ev(desc, cb), - InputType::Mouse(desc) => p.input_register.map_mouse_ev(desc, cb), + 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::Key(desc) => p.input_register.clear_keys(&desc), - InputType::Mouse(desc) => p.input_register.clear_mouse(&desc), + 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(), diff --git a/src/input/definitions/keydefs.rs b/src/input/definitions/keydefs.rs index 3593543..5ab2688 100644 --- a/src/input/definitions/keydefs.rs +++ b/src/input/definitions/keydefs.rs @@ -55,6 +55,11 @@ 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. pub fn parse_key_event(text: &str) -> KeyEvent { let token_list = super::parse_tokens(text); diff --git a/src/input/definitions/mousedefs.rs b/src/input/definitions/mousedefs.rs index 90ec8cf..8e11046 100644 --- a/src/input/definitions/mousedefs.rs +++ b/src/input/definitions/mousedefs.rs @@ -25,6 +25,11 @@ 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. pub fn parse_mouse_event(text: &str) -> MouseEvent { let token_list = super::parse_tokens(text); gen_mouse_event_from_tokenlist(&token_list, text) diff --git a/src/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index 7b3e9b6..dc5500a 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -155,6 +155,23 @@ impl HashedEventRegister { 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: Vec, + cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + ) { + let v = Arc::new(cb); + for k in desc { + self.0.insert(k, v.clone()); + } + } + + pub fn clear_km_parsed(&mut self, desc: &[EventWrapper]) { + for k in desc { + self.0.remove(k); + } + } } // ############################### @@ -176,24 +193,6 @@ impl HashedEventRegister { } } - pub fn map_keys_ev( - &mut self, - desc: Vec, - cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, - ) { - let v = Arc::new(cb); - for k in desc { - self.0.insert(k, v.clone()); - } - } - - /// Removes the callback associated with the all the elements of `desc`. - pub fn clear_keys(&mut self, desc: &[EventWrapper]) { - for k in desc { - self.0.remove(k); - } - } - /// Clear all keyboard bindings pub fn clear_all_keys(&mut self) { self.0.retain(|k, _| { @@ -224,24 +223,6 @@ impl HashedEventRegister { } } - pub fn map_mouse_ev( - &mut self, - desc: Vec, - cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, - ) { - let v = Arc::new(cb); - for k in desc { - self.0.insert(k, v.clone()); - } - } - - /// Removes the callback associated with the all the elements of `desc`. - pub fn clear_mouse(&mut self, mouse: &[EventWrapper]) { - for k in mouse { - self.0.remove(k); - } - } - /// Clear all mouse bindings pub fn clear_all_mouse(&mut self) { self.0 diff --git a/src/input/mod.rs b/src/input/mod.rs index 58ac291..7e7f081 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -51,6 +51,7 @@ 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; diff --git a/src/pager.rs b/src/pager.rs index b588209..8f88820 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -278,7 +278,7 @@ impl Pager { .map(|s| Event::Key(definitions::keydefs::parse_key_event(s.as_ref())).into()) .collect::>(); Ok(self.tx.send(Command::AddInputBinding( - InputType::Key(desc_strs), + InputType::KeyMouse(desc_strs), Box::new(cb), ))?) } @@ -316,7 +316,7 @@ impl Pager { .map(|s| Event::Mouse(definitions::mousedefs::parse_mouse_event(s.as_ref())).into()) .collect::>(); Ok(self.tx.send(Command::AddInputBinding( - InputType::Mouse(desc_strs), + InputType::KeyMouse(desc_strs), Box::new(cb), ))?) } @@ -415,7 +415,7 @@ impl Pager { } else { Ok(self .tx - .send(Command::RemoveInputBinding(InputType::Key(desc_vec)))?) + .send(Command::RemoveInputBinding(InputType::KeyMouse(desc_vec)))?) } } @@ -463,7 +463,7 @@ impl Pager { } else { Ok(self .tx - .send(Command::RemoveInputBinding(InputType::Mouse(desc_vec)))?) + .send(Command::RemoveInputBinding(InputType::KeyMouse(desc_vec)))?) } } From cf87ce3568749f43aa4a63615e7a186291550aca Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Sun, 17 May 2026 22:04:31 +0530 Subject: [PATCH 06/14] refactor(search): unify MoveToNext/PrevMatch into GoToMatch --- src/core/ev_handler.rs | 89 ++++-------------------------------------- src/input/mod.rs | 33 ++++------------ src/input/tests.rs | 8 ++-- src/search.rs | 55 +++++++++++--------------- src/state.rs | 11 ++++-- 5 files changed, 48 insertions(+), 148 deletions(-) diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 8b9703d..9b82c0a 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -171,69 +171,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 +194,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; diff --git a/src/input/mod.rs b/src/input/mod.rs index 7e7f081..b6e191a 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -104,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 @@ -208,23 +190,22 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { 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); - + 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.map_keys(&["p", "s-n"], |_, ps| { - let position = ps.prefix_num.parse::().unwrap_or(1); + 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 } diff --git a/src/input/tests.rs b/src/input/tests.rs index c6c8276..c84987c 100644 --- a/src/input/tests.rs +++ b/src/input/tests.rs @@ -454,11 +454,11 @@ fn test_search_bindings() { assert_eq!( pager.input_register.classify_input(next_event, &pager), - Some(InputEvent::MoveToNextMatch(1)) + Some(InputEvent::GoToMatch(1)) ); assert_eq!( pager.input_register.classify_input(prev_event, &pager), - Some(InputEvent::MoveToPrevMatch(1)) + Some(InputEvent::GoToMatch(-1)) ); } @@ -480,11 +480,11 @@ fn test_search_bindings() { assert_eq!( pager.input_register.classify_input(next_event, &pager), - Some(InputEvent::MoveToPrevMatch(1)) + Some(InputEvent::GoToMatch(-1)) ); assert_eq!( pager.input_register.classify_input(prev_event, &pager), - Some(InputEvent::MoveToNextMatch(1)) + Some(InputEvent::GoToMatch(1)) ); } } diff --git a/src/search.rs b/src/search.rs index 3471382..6eb5563 100644 --- a/src/search.rs +++ b/src/search.rs @@ -876,53 +876,42 @@ 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. -/// -/// 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. #[must_use] -pub(crate) fn next_nth_match( +#[allow(clippy::cast_possible_truncation)] +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().position(|i| *i <= upper_mark), + (..=-1, _) => search_idx.iter().position(|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 + let mut start_idx = nearest_idx.unwrap_or(0) as isize; + let match_pos = if jump == 0 { + start_idx as usize } else { - start_idx.saturating_add(jump - 1) % search_idx.len() + start_idx += jump - 1; + start_idx = start_idx % (search_idx.len() as isize); + start_idx as usize }; - Some(position_of_next_match) + Some(match_pos) } #[cfg(test)] mod tests { + use crate::SearchMode; + mod input_handling { use crate::{ SearchMode, @@ -1215,7 +1204,7 @@ mod tests { 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); + search_mark = super::nth_match(&search_idx, upper_mark, 1, SearchMode::Forward); assert_eq!(search_mark, Some(i)); let next_upper_mark = *search_idx.iter().nth(search_mark.unwrap()).unwrap(); assert_eq!(next_upper_mark, *v); @@ -1228,7 +1217,7 @@ mod tests { 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, nth_match}; use crossterm::style::Attribute; use regex::Regex; diff --git a/src/state.rs b/src/state.rs index 45ec827..a45fa2c 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,7 @@ #![allow(dead_code)] #[cfg(feature = "search")] -use crate::search::{SearchMode, SearchOpts, next_nth_match}; +use crate::search::{SearchMode, SearchOpts, nth_match}; use crate::{ LineNumbers, @@ -262,8 +262,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; From ba591188799923a35db87f6ba3e8aee0bf6877bf Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Sun, 17 May 2026 22:42:46 +0530 Subject: [PATCH 07/14] fix: issues due to rebasing --- src/core/commands.rs | 6 ------ src/input/definitions/keydefs.rs | 1 + src/input/definitions/mousedefs.rs | 1 + src/input/hashed_event_register.rs | 4 ++-- src/input/mod.rs | 8 ++++---- src/search.rs | 15 +++++++-------- src/state.rs | 2 +- 7 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/core/commands.rs b/src/core/commands.rs index 52777b5..445c350 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -124,12 +124,6 @@ impl Debug for Command { Self::AddInputBinding(et, _) => write!(f, "AddInputBinding({et:?})"), Self::RemoveInputBinding(et) => write!(f, "RemoveInputBinding({et:?})"), Self::FollowOutput(follow_output) => write!(f, "FollowOutput({follow_output:?})"), - Self::AddKeyBinding(desc, _, remap) => write!(f, "AddKeyBinding({desc:?}, {remap})"), - Self::AddMouseBinding(desc, _, remap) => { - write!(f, "AddMouseBinding({desc:?}, {remap})") - } - Self::RemoveKeyBinding(desc) => write!(f, "RemoveKeyBinding({desc:?})"), - Self::RemoveMouseBinding(desc) => write!(f, "RemoveMouseBinding({desc:?})"), Self::Io(c) => write!(f, "Io({c:?})"), } } diff --git a/src/input/definitions/keydefs.rs b/src/input/definitions/keydefs.rs index 5ab2688..9542470 100644 --- a/src/input/definitions/keydefs.rs +++ b/src/input/definitions/keydefs.rs @@ -60,6 +60,7 @@ impl Default for KeySeq { /// # 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); diff --git a/src/input/definitions/mousedefs.rs b/src/input/definitions/mousedefs.rs index 8e11046..a6f67e5 100644 --- a/src/input/definitions/mousedefs.rs +++ b/src/input/definitions/mousedefs.rs @@ -30,6 +30,7 @@ static MOUSE_ACTIONS: LazyLock> = LazyLock::new(|| /// # 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) diff --git a/src/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index dc5500a..4ae8581 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -90,7 +90,7 @@ impl Hash for EventWrapper { pub struct HashedEventRegister(HashMap); impl Default for HashedEventRegister { - /// Create a new [HashedEventRegister] with the default hasher and insert the default bindings + /// Create a new [`HashedEventRegister`] with the default hasher and insert the default bindings fn default() -> Self { let mut event_register = Self::new(); super::generate_default_bindings(&mut event_register); @@ -102,7 +102,7 @@ impl Default for HashedEventRegister { // GENERAL FUNCTIONS // #################### impl HashedEventRegister { - /// Create a new HashedEventRegister with the Hasher `s` + /// Create a new `HashedEventRegister` pub fn new() -> Self { Self(HashMap::new()) } diff --git a/src/input/mod.rs b/src/input/mod.rs index b6e191a..eb91026 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -218,13 +218,13 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { 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!(); }; @@ -233,8 +233,8 @@ pub(crate) fn generate_default_bindings(map: &mut HashedEventRegister) { #[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.map_keys(&["c-s-h", "c-h"], |_, ps| { diff --git a/src/search.rs b/src/search.rs index 6eb5563..93f60ec 100644 --- a/src/search.rs +++ b/src/search.rs @@ -896,16 +896,15 @@ pub(crate) fn nth_match( (_, SearchMode::Unknown) => unreachable!(), }; - let mut start_idx = nearest_idx.unwrap_or(0) as isize; - let match_pos = if jump == 0 { - start_idx as usize - } else { + let mut start_idx = nearest_idx.unwrap_or(0).cast_signed(); + if jump > 0 { start_idx += jump - 1; - start_idx = start_idx % (search_idx.len() as isize); - start_idx as usize - }; + } else if jump < 0 { + start_idx += jump + 1; + } - Some(match_pos) + start_idx %= search_idx.len().cast_signed(); + Some(start_idx.cast_unsigned()) } #[cfg(test)] diff --git a/src/state.rs b/src/state.rs index a45fa2c..930a98d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -8,7 +8,7 @@ use crate::{ LineNumbers, error::{MinusError, TermError}, hooks::{Hook, Hooks}, - input::{self, HashedEventRegister}, + input::HashedEventRegister, minus_core::{ self, CommandQueue, utils::{ From 0588537ab57610802985134e9c9822d97f5c082d Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Sun, 17 May 2026 23:01:20 +0530 Subject: [PATCH 08/14] fix: add docs for the nth_match function --- src/search.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/search.rs b/src/search.rs index 93f60ec..abf4faa 100644 --- a/src/search.rs +++ b/src/search.rs @@ -876,6 +876,16 @@ impl fmt::Display for HighlightMatchesArgs<'_, '_> { } } +/// Returns the position of the nth search match relative to `upper_mark`. +/// +/// 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] #[allow(clippy::cast_possible_truncation)] pub(crate) fn nth_match( @@ -903,7 +913,8 @@ pub(crate) fn nth_match( start_idx += jump + 1; } - start_idx %= search_idx.len().cast_signed(); + start_idx = start_idx.clamp(0, search_idx.len().cast_signed()); + Some(start_idx.cast_unsigned()) } From 1e2c0d2c7beda92b540f24d49b113bd16260f7d7 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Sun, 17 May 2026 23:30:30 +0530 Subject: [PATCH 09/14] test(search): add tests for nth_match --- src/search.rs | 125 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/src/search.rs b/src/search.rs index abf4faa..bc22349 100644 --- a/src/search.rs +++ b/src/search.rs @@ -887,7 +887,6 @@ impl fmt::Display for HighlightMatchesArgs<'_, '_> { /// - 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] -#[allow(clippy::cast_possible_truncation)] pub(crate) fn nth_match( search_idx: &BTreeSet, upper_mark: usize, @@ -901,19 +900,25 @@ pub(crate) fn nth_match( 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().position(|i| *i <= upper_mark), - (..=-1, _) => 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 mut start_idx = nearest_idx.unwrap_or(0).cast_signed(); + 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, |idx| idx.cast_signed()); if jump > 0 { start_idx += jump - 1; } else if jump < 0 { start_idx += jump + 1; } - start_idx = start_idx.clamp(0, search_idx.len().cast_signed()); + start_idx = start_idx.clamp(0, last_idx); Some(start_idx.cast_unsigned()) } @@ -1207,19 +1212,105 @@ mod tests { } } + fn search_idx() -> std::collections::BTreeSet { + std::collections::BTreeSet::from([2, 10, 15, 17, 50]) + } + #[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::nth_match(&search_idx, upper_mark, 1, SearchMode::Forward); - 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_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 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 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) + ); + } + + #[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 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 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) + ); + } + + #[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) + ); + } + + #[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 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)] From 43c0b2e3b21ae96cea01a7a55c0b7ae648e30cdf Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Mon, 18 May 2026 13:55:22 +0530 Subject: [PATCH 10/14] refactor: drop some allowed clippy lints --- src/core/ev_handler.rs | 2 -- src/core/init.rs | 6 +++--- src/dynamic_pager.rs | 3 +-- src/input/definitions/keydefs.rs | 14 ++++++-------- src/input/definitions/mod.rs | 8 ++------ src/input/definitions/mousedefs.rs | 16 +++++++--------- src/search.rs | 2 +- src/static_pager.rs | 3 +-- 8 files changed, 21 insertions(+), 33 deletions(-) diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 9b82c0a..a89f6ef 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -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, diff --git a/src/core/init.rs b/src/core/init.rs index d391542..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(); 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 9542470..c8c1b2a 100644 --- a/src/input/definitions/keydefs.rs +++ b/src/input/definitions/keydefs.rs @@ -79,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) => { @@ -89,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 a6f67e5..62b6f2b 100644 --- a/src/input/definitions/mousedefs.rs +++ b/src/input/definitions/mousedefs.rs @@ -48,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"); } }, ); @@ -75,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"); } }, ); @@ -89,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/search.rs b/src/search.rs index bc22349..d54b815 100644 --- a/src/search.rs +++ b/src/search.rs @@ -911,7 +911,7 @@ pub(crate) fn nth_match( (..=-1, _) | (0, SearchMode::Reverse) => 0, (_, SearchMode::Unknown) => unreachable!(), }; - let mut start_idx = nearest_idx.map_or(fallback_idx, |idx| idx.cast_signed()); + let mut start_idx = nearest_idx.map_or(fallback_idx, usize::cast_signed); if jump > 0 { start_idx += jump - 1; } else if jump < 0 { 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) } From 3cf3368d065999c79aa5f2a075472afe76f77451 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Sun, 3 May 2026 02:20:36 +0530 Subject: [PATCH 11/14] overhaul the incremental search system * Massively faster incremental search by highlighting only the visible lines * Full buffer is only highlighted once the search query is confirmed * All searches (including incremental previews) now wrap around to the opposite direction when no match found --- src/search.rs | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/search.rs b/src/search.rs index d54b815..3b60470 100644 --- a/src/search.rs +++ b/src/search.rs @@ -393,6 +393,129 @@ fn incremental_preview<'a>( } } +fn line_matches_query(line: &str, query: &Regex) -> bool { + let stripped = ANSI_REGEX.replace_all(line, ""); + query.is_match(stripped.as_ref()) +} + +fn incremental_preview( + iso: &IncrementalSearchOpts<'_>, + query: &Regex, + cols: usize, + rows: usize, +) -> Option<(Vec, usize)> { + fn preview_line( + iso: &IncrementalSearchOpts<'_>, + query: &Regex, + cols: usize, + line_number_digits: usize, + line_idx: usize, + line: &str, + visible_lines: &mut Vec, + upper_mark: &mut Option, + writable_rows: usize, + wrapped: bool, + ) -> Option<()> { + // Skip all lines that don't have any match + if upper_mark.is_none() && !line_matches_query(line, query) { + return Some(()); + } + + let row_start = *iso.lines_to_row_map.get(line_idx).unwrap_or(&0); + let mut search_idx = BTreeSet::new(); + let mut formatted_rows = screen::formatted_line( + line, + line_number_digits, + line_idx, + iso.line_numbers, + cols, + iso.screen.line_wrapping, + row_start, + &mut search_idx, + Some(query), + ); + + if upper_mark.is_none() { + let match_row = *search_idx + .iter() + .find(|idx| wrapped || **idx >= iso.initial_upper_mark)?; + let skip_rows = match_row.saturating_sub(row_start); + *upper_mark = Some(match_row); + visible_lines.extend(formatted_rows.drain(skip_rows..)); + } else { + visible_lines.append(&mut formatted_rows); + } + + if visible_lines.len() >= writable_rows { + visible_lines.truncate(writable_rows); + } + + Some(()) + } + + let writable_rows = rows.saturating_sub(1); + if writable_rows == 0 { + return None; + } + + let start_line_idx = iso.lines_to_row_map.row_to_line(iso.initial_upper_mark)?; + let line_number_digits = crate::minus_core::utils::digits(iso.screen.line_count()); + let mut visible_lines = Vec::with_capacity(writable_rows); + let mut upper_mark = None; + + for (line_idx, line) in iso + .screen + .orig_text + .lines() + .enumerate() + .skip(start_line_idx) + { + preview_line( + iso, + query, + cols, + line_number_digits, + line_idx, + line, + &mut visible_lines, + &mut upper_mark, + writable_rows, + false, + )?; + if visible_lines.len() >= writable_rows { + break; + } + } + + if upper_mark.is_none() { + for (line_idx, line) in iso + .screen + .orig_text + .lines() + .enumerate() + .take(start_line_idx) + { + preview_line( + iso, + query, + cols, + line_number_digits, + line_idx, + line, + &mut visible_lines, + &mut upper_mark, + writable_rows, + true, + )?; + if visible_lines.len() >= writable_rows { + break; + } + } + } + + upper_mark.map(|upper_mark| (visible_lines, upper_mark)) +} + /// Runs the incremental search /// /// It will return if `Ok(SomeIncrementalSearchCache)` if there was a successful run of incremental @@ -1313,6 +1436,16 @@ mod tests { ); } + #[test] + fn test_next_match_wraps_to_top() { + let search_idx = std::collections::BTreeSet::from([2, 10, 15, 17, 50]); + + assert_eq!(super::next_nth_match(&search_idx, 60, 1), Some(0)); + assert_eq!(super::next_nth_match(&search_idx, 60, 3), Some(2)); + assert_eq!(super::next_nth_match(&search_idx, 50, 1), Some(0)); + assert_eq!(super::next_nth_match(&search_idx, 50, 0), Some(4)); + } + #[allow(clippy::trivial_regex)] mod highlighting { use std::collections::BTreeSet; From 6737f39ac33baf711d2695568e2f5e7f5b9a0c96 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Mon, 4 May 2026 02:46:12 +0530 Subject: [PATCH 12/14] feat(search)!: use rustyline for getting search query * Remove our custom rudimentary implementation of a readline prompt with rustyline. * [BREAKING] SearchOpts dropped various fields like `ev`, `string` etc. * [BREAKING] The incremental search conditiob callback function now takes an additional parameter of type `&str` that will contain the query present in the prompt --- Cargo.toml | 1 + src/core/commands.rs | 2 +- src/pager.rs | 2 +- src/search.rs | 709 ++++++++----------------------------------- src/state.rs | 7 +- 5 files changed, 135 insertions(+), 586 deletions(-) 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 445c350..0871e42 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -67,7 +67,7 @@ pub enum Command { #[cfg(feature = "static_output")] SetRunNoOverflow(bool), #[cfg(feature = "search")] - IncrementalSearchCondition(Box bool + Send + Sync + 'static>), + IncrementalSearchCondition(Box bool + Send + Sync + 'static>), // Input AddInputBinding(InputType, input::InputEventBoxed), diff --git a/src/pager.rs b/src/pager.rs index 8f88820..0c564a8 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -542,7 +542,7 @@ impl Pager { #[cfg_attr(docsrs, doc(cfg(feature = "search")))] pub fn set_incremental_search_condition( &self, - cb: Box bool + Send + Sync + 'static>, + cb: Box bool + Send + Sync + 'static>, ) -> crate::Result { self.tx.send(Command::IncrementalSearchCondition(cb))?; Ok(()) diff --git a/src/search.rs b/src/search.rs index 3b60470..6c9db80 100644 --- a/src/search.rs +++ b/src/search.rs @@ -50,30 +50,31 @@ //! 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 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`]. + /// 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,27 +175,7 @@ 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`] +/// Return type of [fetch_input] pub(crate) struct FetchInputResult { /// Original search query pub(crate) string: String, @@ -527,11 +477,12 @@ fn incremental_preview( 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(()); @@ -542,7 +493,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 @@ -594,205 +545,76 @@ 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<'a> Send for ThreadSafeWriter<'a> {} +unsafe impl<'a> Sync for ThreadSafeWriter<'a> {} + +impl<'a> Write for ThreadSafeWriter<'a> { + 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<'a> Helper for SearchHelper<'a> {} - run_incremental_search(out, so, incremental_search_condition)?; +impl<'a> Highlighter for SearchHelper<'a> { + 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()?; + so.compiled_regex = Regex::new(line).ok(); + + if let Ok(Some(cache)) = + run_incremental_search(&mut *out, &*so, line, self.incremental_search_condition) + { + so.incremental_search_cache = Some(cache); } - _ => return Ok(()), + + let _ = term::move_cursor(&mut *out, 0, so.rows, false); + let _ = out.flush(); + + Cow::Borrowed(line) } - Ok(()) + + fn highlight_char(&self, _line: &str, _pos: usize, _forced: CmdKind) -> bool { + true + } +} + +impl<'a> Validator for SearchHelper<'a> { + fn validate(&self, _ctx: &mut ValidationContext) -> rustyline::Result { + Ok(ValidationResult::Valid(None)) + } + fn validate_while_typing(&self) -> bool { + false + } +} + +impl<'a> Hinter for SearchHelper<'a> { + type Hint = String; + fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + None + } +} + +impl<'a> Completer for SearchHelper<'a> { + 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, @@ -800,60 +622,76 @@ 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 = out as *mut dyn std::io::Write; + 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(), + incremental_search_result: so.incremental_search_cache.take(), + string: str, + }) } - if search_opts.input_status.done() { - break; + Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { + let mut out_lock = helper.out.lock().unwrap(); + let so = helper.search_opts.lock().unwrap(); + if let Some(iso) = &so.incremental_search_options { + let _ = display::write_text_checked( + &mut *out_lock, + &iso.screen.formatted_lines, + iso.initial_upper_mark, + so.rows.into(), + so.cols.into(), + iso.screen.line_wrapping, + iso.initial_left_mark, + iso.line_numbers, + iso.screen.line_count(), + ); + } + Ok(FetchInputResult::new_empty()) } + Err(ReadlineError::Io(e)) => Err(MinusError::from(e)), + Err(ReadlineError::Errno(_)) | Err(ReadlineError::Signal(_)) => todo!(), + Err(_) => 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>( @@ -1048,297 +886,6 @@ pub(crate) fn nth_match( #[cfg(test)] mod tests { - use crate::SearchMode; - - 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 - - let mut search_opts = new_search_opts(SearchMode::Forward); - let mut out = Vec::with_capacity(1500); - - 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); - } - - #[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 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 esc_key() { - let (mut search_opts, mut out, _, _) = pretest_setup_forward_search(); - - 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 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 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 - - let mut search_opts = new_search_opts(SearchMode::Reverse); - let mut out = Vec::with_capacity(1500); - - 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); - } - } - - fn search_idx() -> std::collections::BTreeSet { - std::collections::BTreeSet::from([2, 10, 15, 17, 50]) - } - #[test] fn nth_match_returns_none_for_empty_search_index() { let search_idx = std::collections::BTreeSet::new(); diff --git a/src/state.rs b/src/state.rs index 930a98d..fec0df2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -53,14 +53,15 @@ pub struct SearchState { /// /// If the function returns a `false`, the incremental search is cancelled. pub(crate) incremental_search_condition: - Box bool + Send + Sync + 'static>, + Box bool + Send + Sync + 'static>, } #[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() From 4910965d409f3c826a23b8a50fe8544c6044e52f Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Mon, 4 May 2026 03:16:57 +0530 Subject: [PATCH 13/14] doc(search): fix doc tests --- src/search.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/search.rs b/src/search.rs index 6c9db80..6973fbf 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,21 +34,21 @@ //! 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(); //! ``` use crate::minus_core::utils::{LinesRowMap, display, term}; From 56010d55bc81611fcb0c7ddb13930e8b37512360 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Mon, 18 May 2026 15:56:11 +0530 Subject: [PATCH 14/14] chore: cleanup from rebase --- src/core/commands.rs | 12 ++- src/core/ev_handler.rs | 4 +- src/pager.rs | 6 +- src/search.rs | 204 ++++++----------------------------------- src/state.rs | 8 +- 5 files changed, 48 insertions(+), 186 deletions(-) diff --git a/src/core/commands.rs b/src/core/commands.rs index 0871e42..bb76293 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -38,6 +38,10 @@ pub enum InputType { 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)] @@ -67,7 +71,7 @@ pub enum Command { #[cfg(feature = "static_output")] SetRunNoOverflow(bool), #[cfg(feature = "search")] - IncrementalSearchCondition(Box bool + Send + Sync + 'static>), + SetIncrementalSearchCondition(IncrementalSearchCondition), // Input AddInputBinding(InputType, input::InputEventBoxed), @@ -93,7 +97,9 @@ impl PartialEq for Command { | (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, @@ -114,7 +120,7 @@ impl Debug for Command { Self::SetExitStrategy(es) => write!(f, "SetExitStrategy({es:?})"), 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})"), diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index a89f6ef..b9559f0 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -264,7 +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::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) => { diff --git a/src/pager.rs b/src/pager.rs index 0c564a8..56d9fe6 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -13,7 +13,7 @@ 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. /// @@ -542,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(()) } diff --git a/src/search.rs b/src/search.rs index 6973fbf..1ed57d1 100644 --- a/src/search.rs +++ b/src/search.rs @@ -17,7 +17,7 @@ //! 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] and `&str` containing the currently entered query as arguments and return a bool +//! 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 @@ -54,14 +54,13 @@ 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, 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; @@ -112,7 +111,7 @@ impl PartialEq for SearchMode { /// this #[allow(clippy::module_name_repetitions)] pub struct SearchOpts<'a> { - /// Direction of search. See [SearchMode]. + /// Direction of search. See [`SearchMode`]. pub search_mode: SearchMode, /// Number of rows available in the terminal pub rows: u16, @@ -176,7 +175,7 @@ impl<'a> From<&'a PagerState> for SearchOpts<'a> { } } -/// Return type of [fetch_input] +/// Return type of [`fetch_input`] pub(crate) struct FetchInputResult { /// Original search query pub(crate) string: String, @@ -344,129 +343,6 @@ fn incremental_preview<'a>( } } -fn line_matches_query(line: &str, query: &Regex) -> bool { - let stripped = ANSI_REGEX.replace_all(line, ""); - query.is_match(stripped.as_ref()) -} - -fn incremental_preview( - iso: &IncrementalSearchOpts<'_>, - query: &Regex, - cols: usize, - rows: usize, -) -> Option<(Vec, usize)> { - fn preview_line( - iso: &IncrementalSearchOpts<'_>, - query: &Regex, - cols: usize, - line_number_digits: usize, - line_idx: usize, - line: &str, - visible_lines: &mut Vec, - upper_mark: &mut Option, - writable_rows: usize, - wrapped: bool, - ) -> Option<()> { - // Skip all lines that don't have any match - if upper_mark.is_none() && !line_matches_query(line, query) { - return Some(()); - } - - let row_start = *iso.lines_to_row_map.get(line_idx).unwrap_or(&0); - let mut search_idx = BTreeSet::new(); - let mut formatted_rows = screen::formatted_line( - line, - line_number_digits, - line_idx, - iso.line_numbers, - cols, - iso.screen.line_wrapping, - row_start, - &mut search_idx, - Some(query), - ); - - if upper_mark.is_none() { - let match_row = *search_idx - .iter() - .find(|idx| wrapped || **idx >= iso.initial_upper_mark)?; - let skip_rows = match_row.saturating_sub(row_start); - *upper_mark = Some(match_row); - visible_lines.extend(formatted_rows.drain(skip_rows..)); - } else { - visible_lines.append(&mut formatted_rows); - } - - if visible_lines.len() >= writable_rows { - visible_lines.truncate(writable_rows); - } - - Some(()) - } - - let writable_rows = rows.saturating_sub(1); - if writable_rows == 0 { - return None; - } - - let start_line_idx = iso.lines_to_row_map.row_to_line(iso.initial_upper_mark)?; - let line_number_digits = crate::minus_core::utils::digits(iso.screen.line_count()); - let mut visible_lines = Vec::with_capacity(writable_rows); - let mut upper_mark = None; - - for (line_idx, line) in iso - .screen - .orig_text - .lines() - .enumerate() - .skip(start_line_idx) - { - preview_line( - iso, - query, - cols, - line_number_digits, - line_idx, - line, - &mut visible_lines, - &mut upper_mark, - writable_rows, - false, - )?; - if visible_lines.len() >= writable_rows { - break; - } - } - - if upper_mark.is_none() { - for (line_idx, line) in iso - .screen - .orig_text - .lines() - .enumerate() - .take(start_line_idx) - { - preview_line( - iso, - query, - cols, - line_number_digits, - line_idx, - line, - &mut visible_lines, - &mut upper_mark, - writable_rows, - true, - )?; - if visible_lines.len() >= writable_rows { - break; - } - } - } - - upper_mark.map(|upper_mark| (visible_lines, upper_mark)) -} - /// Runs the incremental search /// /// It will return if `Ok(SomeIncrementalSearchCache)` if there was a successful run of incremental @@ -549,10 +425,10 @@ where // HACK: GET the bare `Write` trait to be `Send` + `Sync` without leaving the lock struct ThreadSafeWriter<'a>(*mut (dyn Write + 'a)); -unsafe impl<'a> Send for ThreadSafeWriter<'a> {} -unsafe impl<'a> Sync for ThreadSafeWriter<'a> {} +unsafe impl Send for ThreadSafeWriter<'_> {} +unsafe impl Sync for ThreadSafeWriter<'_> {} -impl<'a> Write for ThreadSafeWriter<'a> { +impl Write for ThreadSafeWriter<'_> { fn write(&mut self, buf: &[u8]) -> std::io::Result { unsafe { (*self.0).write(buf) } } @@ -567,23 +443,21 @@ struct SearchHelper<'a> { incremental_search_condition: &'a (dyn Fn(&SearchOpts, &str) -> bool + Send + Sync), } -impl<'a> Helper for SearchHelper<'a> {} +impl Helper for SearchHelper<'_> {} -impl<'a> Highlighter for SearchHelper<'a> { +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(); so.compiled_regex = Regex::new(line).ok(); - if let Ok(Some(cache)) = - run_incremental_search(&mut *out, &*so, line, self.incremental_search_condition) - { - so.incremental_search_cache = Some(cache); - } + 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) } @@ -593,7 +467,7 @@ impl<'a> Highlighter for SearchHelper<'a> { } } -impl<'a> Validator for SearchHelper<'a> { +impl Validator for SearchHelper<'_> { fn validate(&self, _ctx: &mut ValidationContext) -> rustyline::Result { Ok(ValidationResult::Valid(None)) } @@ -602,14 +476,14 @@ impl<'a> Validator for SearchHelper<'a> { } } -impl<'a> Hinter for SearchHelper<'a> { +impl Hinter for SearchHelper<'_> { type Hint = String; fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { None } } -impl<'a> Completer for SearchHelper<'a> { +impl Completer for SearchHelper<'_> { type Candidate = String; } @@ -639,7 +513,7 @@ pub(crate) fn fetch_input( let mut readline = Editor::, _>::new().unwrap(); let search_opts = SearchOpts::from(ps); - let writer_ptr = out as *mut dyn std::io::Write; + 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), @@ -667,31 +541,14 @@ pub(crate) fn fetch_input( let mut so = helper.search_opts.lock().unwrap(); Ok(FetchInputResult { compiled_regex: so.compiled_regex.take(), - incremental_search_result: so.incremental_search_cache.take(), string: str, }) } - Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => { - let mut out_lock = helper.out.lock().unwrap(); - let so = helper.search_opts.lock().unwrap(); - if let Some(iso) = &so.incremental_search_options { - let _ = display::write_text_checked( - &mut *out_lock, - &iso.screen.formatted_lines, - iso.initial_upper_mark, - so.rows.into(), - so.cols.into(), - iso.screen.line_wrapping, - iso.initial_left_mark, - iso.line_numbers, - iso.screen.line_count(), - ); - } + Err(ReadlineError::Io(e)) => Err(MinusError::from(e)), + Err(ReadlineError::Errno(_) | ReadlineError::Signal(_)) => todo!(), + Err(ReadlineError::Interrupted | ReadlineError::Eof | _) => { Ok(FetchInputResult::new_empty()) } - Err(ReadlineError::Io(e)) => Err(MinusError::from(e)), - Err(ReadlineError::Errno(_)) | Err(ReadlineError::Signal(_)) => todo!(), - Err(_) => Ok(FetchInputResult::new_empty()), } } @@ -887,6 +744,14 @@ pub(crate) fn nth_match( #[cfg(test)] mod tests { + use std::collections::BTreeSet; + + use super::SearchMode; + + fn search_idx() -> std::collections::BTreeSet { + BTreeSet::from([2, 10, 15, 17, 50]) + } + #[test] fn nth_match_returns_none_for_empty_search_index() { let search_idx = std::collections::BTreeSet::new(); @@ -984,22 +849,9 @@ mod tests { ); } - #[test] - fn test_next_match_wraps_to_top() { - let search_idx = std::collections::BTreeSet::from([2, 10, 15, 17, 50]); - - assert_eq!(super::next_nth_match(&search_idx, 60, 1), Some(0)); - assert_eq!(super::next_nth_match(&search_idx, 60, 3), Some(2)); - assert_eq!(super::next_nth_match(&search_idx, 50, 1), Some(0)); - assert_eq!(super::next_nth_match(&search_idx, 50, 0), Some(4)); - } - #[allow(clippy::trivial_regex)] mod highlighting { - use std::collections::BTreeSet; - - use crate::PagerState; - use crate::search::{INVERT, NORMAL, highlight_line_matches, 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 fec0df2..93990f2 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,10 @@ #![allow(dead_code)] #[cfg(feature = "search")] -use crate::search::{SearchMode, SearchOpts, nth_match}; +use crate::{ + minus_core::commands::IncrementalSearchCondition, + search::{SearchMode, SearchOpts, nth_match}, +}; use crate::{ LineNumbers, @@ -52,8 +55,7 @@ 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")]