diff --git a/src/core/commands.rs b/src/core/commands.rs index 16920fb..3410ed0 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -7,6 +7,7 @@ use std::fmt::Debug; use crate::{ ExitStrategy, LineNumbers, + hooks::{Hook, HookCallback}, input::{InputClassifier, InputEvent}, minus_core::utils::display::AppendStyle, }; @@ -53,6 +54,8 @@ pub enum Command { SetExitStrategy(ExitStrategy), SetInputClassifier(Box), AddExitCallback(Box), + AddHook(Hook, u64, HookCallback), + RemoveHook(Hook, u64), #[cfg(feature = "static_output")] SetRunNoOverflow(bool), #[cfg(feature = "search")] @@ -75,7 +78,9 @@ impl PartialEq for Command { #[cfg(feature = "static_output")] (Self::SetRunNoOverflow(d1), Self::SetRunNoOverflow(d2)) => d1 == d2, (Self::SetInputClassifier(_), Self::SetInputClassifier(_)) - | (Self::AddExitCallback(_), Self::AddExitCallback(_)) => true, + | (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::Io(a), Self::Io(b)) => a == b, @@ -99,6 +104,8 @@ impl Debug for Command { #[cfg(feature = "search")] Self::IncrementalSearchCondition(_) => write!(f, "IncrementalSearchCondition"), Self::AddExitCallback(_) => write!(f, "AddExitCallback"), + Self::AddHook(h, id, _) => write!(f, "AddHook({h:?}, {id})"), + Self::RemoveHook(h, id) => write!(f, "RemoveHook({h:?}, {id})"), #[cfg(feature = "static_output")] Self::SetRunNoOverflow(val) => write!(f, "SetRunNoOverflow({val:?})"), Self::UserInput(input) => write!(f, "UserInput({input:?})"), diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index fa930ac..3791ee4 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -10,9 +10,10 @@ 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::{PagerState, error::MinusError, input::InputEvent}; +use crate::{PagerState, error::MinusError, hooks::Hook, input::InputEvent}; /// Respond based on the type of command /// @@ -36,6 +37,7 @@ pub fn handle_event( command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); } Command::UserInput(InputEvent::Exit) => { + p.run_hooks(Hook::PrePagerExit); p.exit(); is_exited.store(true, std::sync::atomic::Ordering::SeqCst); } @@ -228,7 +230,21 @@ pub fn handle_event( p.format_lines(); command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); } - Command::SetExitStrategy(es) => p.exit_strategy = es, + Command::SetExitStrategy(es) => { + p.hooks.remove_callback(Hook::PostPagerExit, 1); + if es == ExitStrategy::ProcessQuit { + p.hooks.add_callback( + Hook::PostPagerExit, + 1, + Box::new(|_| { + std::process::exit(1); + }), + ); + } else { + p.hooks + .add_callback(Hook::PostPagerExit, 1, Box::new(|_| {})); + } + } Command::LineWrapping(lw) => { p.screen.line_wrapping = lw; p.format_lines(); @@ -239,6 +255,10 @@ pub fn handle_event( 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) => { + p.hooks.remove_callback(hook, id); + } Command::ShowPrompt(show) => p.show_prompt = show, Command::FollowOutput(follow_output) | Command::UserInput(InputEvent::FollowOutput(follow_output)) => { @@ -278,6 +298,10 @@ pub fn handle_io_command( } IoCommand::SetUpperMark(mut um) => { display::draw_for_change(out, p, &mut um)?; + let line_count = p.screen.formatted_lines_count(); + if um >= line_count.saturating_sub(p.rows.saturating_sub(1)) && line_count > p.rows { + p.run_hooks(Hook::EofReached); + } p.upper_mark = um; } IoCommand::DrawAppendedText(prev_unterminated, prev_fmt_lines_count, append_style) => { @@ -346,7 +370,7 @@ pub fn handle_io_command( mod tests { use super::super::commands::Command; use super::handle_event; - use crate::{ExitStrategy, PagerState, minus_core::CommandQueue}; + use crate::{PagerState, minus_core::CommandQueue}; use std::sync::{Arc, atomic::AtomicBool}; const TEST_STR: &str = "This is some sample text"; @@ -442,21 +466,6 @@ mod tests { assert!(!ps.run_no_overflow); } - #[test] - fn set_exit_strategy() { - let mut ps = PagerState::new().unwrap(); - let ev = Command::SetExitStrategy(ExitStrategy::PagerQuit); - let mut command_queue = CommandQueue::new_zero(); - - handle_event( - ev, - &mut ps, - &mut command_queue, - &Arc::new(AtomicBool::new(false)), - ); - assert_eq!(ps.exit_strategy, ExitStrategy::PagerQuit); - } - #[test] fn add_exit_callback() { let mut ps = PagerState::new().unwrap(); diff --git a/src/core/init.rs b/src/core/init.rs index bf01116..8eec9b7 100644 --- a/src/core/init.rs +++ b/src/core/init.rs @@ -11,6 +11,7 @@ use crate::{ Pager, PagerState, error::MinusError, + hooks::Hook, input::InputEvent, minus_core::{ RunMode, @@ -93,6 +94,7 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr #[allow(unused_mut)] let mut ps = crate::state::PagerState::generate_initial_state(&pager.rx)?; *super::RUNMODE.lock() = rm; + ps.run_hooks(Hook::PrePagerStart); // Static mode checks #[cfg(all(feature = "static_output", not(test)))] @@ -135,11 +137,7 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr // While silently ignoring error is considered a bad practice, we are forced to do it here // as we cannot use the ? and panicking here will (probably?) cause an immediate abort - drop(term::cleanup( - &mut out2, - &crate::ExitStrategy::PagerQuit, - true, - )); + drop(term::cleanup(&mut out2, true)); panic_hook(pinfo); })); } @@ -198,7 +196,7 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr if r1.is_err() || r2.is_err() { *RUNMODE.lock() = RunMode::Uninitialized; - term::cleanup(&mut out, &crate::ExitStrategy::PagerQuit, true)?; + term::cleanup(&mut out, true)?; } r1?; @@ -233,6 +231,7 @@ fn start_reactor( let mut p = ps.lock(); draw_full(&mut out_lock, &mut p)?; + p.run_hooks(Hook::PostPagerStart); if p.follow_output { draw_for_change(&mut out_lock, &mut p, &mut (usize::MAX - 1))?; @@ -244,7 +243,8 @@ fn start_reactor( #[cfg(feature = "dynamic_output")] RunMode::Dynamic => loop { if is_exited.load(Ordering::SeqCst) { - term::cleanup(&mut out_lock, &ps.lock().exit_strategy, true)?; + term::cleanup(&mut out_lock, true)?; + ps.lock().run_hooks(Hook::PostPagerExit); let mut rm = RUNMODE.lock(); *rm = RunMode::Uninitialized; drop(rm); @@ -280,7 +280,8 @@ fn start_reactor( // Cleanup the screen // // This is not needed in dynamic paging because this is already handled by handle_event - term::cleanup(&mut out_lock, &ps.lock().exit_strategy, true)?; + term::cleanup(&mut out_lock, true)?; + ps.lock().run_hooks(Hook::PostPagerExit); let mut rm = RUNMODE.lock(); *rm = RunMode::Uninitialized; diff --git a/src/core/utils/term.rs b/src/core/utils/term.rs index 55d915e..45aeb09 100644 --- a/src/core/utils/term.rs +++ b/src/core/utils/term.rs @@ -56,7 +56,6 @@ pub fn setup(out: &mut io::Stdout) -> std::result::Result<(), SetupError> { /// [raw mode]: ../../../crossterm/terminal/index.html#raw-mode pub fn cleanup( out: &mut impl io::Write, - es: &crate::ExitStrategy, cleanup_screen: bool, ) -> std::result::Result<(), CleanupError> { if cleanup_screen { @@ -68,12 +67,7 @@ pub fn cleanup( execute!(out, terminal::LeaveAlternateScreen) .map_err(|e| CleanupError::LeaveAlternateScreen(e.into()))?; } - - if *es == crate::ExitStrategy::ProcessQuit { - std::process::exit(0); - } else { - Ok(()) - } + Ok(()) } /// Moves the terminal cursor to given x, y coordinates diff --git a/src/hooks.rs b/src/hooks.rs new file mode 100644 index 0000000..2949d4a --- /dev/null +++ b/src/hooks.rs @@ -0,0 +1,80 @@ +//! Manages and runs callbacks for events happening in minus. +//! +//! ## Note on Thread Blacking +//! +//! Callbacks registered for hooks are run on the same thread as the pager. +//! This means that if you add a long-running task in a callback, it will block the pager +//! from rendering, scrolling and responding to events. Hence you should avoid adding +//! long-running tasks in callbacks. If you have a long running task, you should run it on a +//! separate thread. + +use std::collections::HashMap; + +use crate::PagerState; + +/// Events that can have callbacks registered +#[derive(Debug, Eq, PartialEq, Hash, Clone, Copy)] +pub enum Hook { + /// Fired just before the terminal UI is drawn (before switching to the alternate screen). + PrePagerStart, + /// Fired after the terminal UI is drawn with text. + PostPagerStart, + /// Fired when the user hits the end of the page + EofReached, + /// Fired just before the pager exits due to [`InputEvent::Exit`](crate::input::InputEvent::Exit). + PrePagerExit, + /// Fired after the terminal UI is cleared up and main screen is restored. + /// + /// For this hook, start your IDs from 2 because 1 is occupied for the + /// [ExitStrategy](crate::ExitStrategy). + PostPagerExit, +} + +/// A callback that can be executed on a hook +pub type HookCallback = Box; + +/// Stores callbacks for all hooks +#[derive(Default)] +pub(crate) struct Hooks { + hooks: HashMap>, + next_id: u64, +} + +impl Hooks { + #[must_use] + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn add_callback(&mut self, hook: Hook, mut id: u64, cb: HookCallback) { + if id == 0 { + id = self.next_id; + self.next_id += 1; + } + + let callbacks = self.hooks.entry(hook).or_default(); + assert!( + !callbacks.iter().any(|(cb_id, _)| *cb_id == id), + "Callback ID {id} already exists for hook {hook:?}" + ); + callbacks.push((id, cb)); + } + + pub(crate) fn remove_callback(&mut self, hook: Hook, id: u64) -> bool { + if let Some(cbs) = self.hooks.get_mut(&hook) + && let Some(pos) = cbs.iter().position(|(cb_id, _)| *cb_id == id) + { + _ = cbs.remove(pos); + return true; + } + false + } + + pub(crate) fn run_hooks(&mut self, hook: Hook, pager_state: &PagerState) { + if let Some(cbs) = self.hooks.get_mut(&hook) { + for (_, cb) in cbs { + cb(pager_state); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index d94d234..558bcd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -189,6 +189,7 @@ #[cfg(feature = "dynamic_output")] mod dynamic_pager; pub mod error; +pub mod hooks; pub mod input; #[path = "core/mod.rs"] mod minus_core; diff --git a/src/pager.rs b/src/pager.rs index 3ea694f..624048b 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -1,6 +1,12 @@ //! Proivdes the [Pager] type -use crate::{ExitStrategy, LineNumbers, error::MinusError, input, minus_core::commands::Command}; +use crate::{ + ExitStrategy, LineNumbers, + error::MinusError, + hooks::{Hook, HookCallback}, + input, + minus_core::commands::Command, +}; use crossbeam_channel::{Receiver, Sender}; use std::fmt; @@ -306,6 +312,33 @@ impl Pager { Ok(self.tx.send(Command::AddExitCallback(cb))?) } + /// Add a function to be called when a specific [`Hook`] is triggered + /// + /// The `id` parameter is a unique identifier for the callback. If you don't care about the + /// `id`, pass `0` and minus will automatically assign a unique ID. + /// + /// # Panics + /// This function will panic if a callback with the same `id` is already registered for the + /// given `hook`. + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + pub fn add_hook(&self, hook: Hook, id: u64, cb: HookCallback) -> Result<(), MinusError> { + Ok(self.tx.send(Command::AddHook(hook, id, cb))?) + } + + /// Remove a callback + /// + /// This function will return `false` if the callback is not found. + /// + /// # Errors + /// This function will return a [`Err(MinusError::Communication)`](MinusError::Communication) if the data + /// could not be sent to the receiver + pub fn remove_hook(&self, hook: Hook, id: u64) -> Result<(), MinusError> { + Ok(self.tx.send(Command::RemoveHook(hook, id))?) + } + /// Override the condition for running incremental search /// /// See [Incremental Search](../search/index.html#incremental-search) to know more on how this diff --git a/src/state.rs b/src/state.rs index d26113e..3166354 100644 --- a/src/state.rs +++ b/src/state.rs @@ -4,8 +4,9 @@ use crate::search::{SearchMode, SearchOpts}; use crate::{ - ExitStrategy, LineNumbers, + LineNumbers, error::{MinusError, TermError}, + hooks::Hooks, input::{self, HashedEventRegister}, minus_core::{ self, CommandQueue, @@ -134,9 +135,8 @@ pub struct PagerState { pub(crate) input_classifier: Box, /// Functions to run when the pager quits pub(crate) exit_callbacks: Vec>, - /// The behaviour to do when user quits the program using `q` or `Ctrl+C` - /// See [`ExitStrategy`] for available options - pub(crate) exit_strategy: ExitStrategy, + /// Callbacks for hooks + pub(crate) hooks: Hooks, /// The prompt that should be displayed to the user, formatted with the /// current search index and number of matches (if the search feature is enabled), /// and the current numbers inputted to scroll @@ -182,9 +182,9 @@ impl PagerState { prompt, running: &minus_core::RUNMODE, left_mark: 0, - exit_strategy: ExitStrategy::ProcessQuit, input_classifier: Box::>::default(), exit_callbacks: Vec::with_capacity(5), + hooks: Hooks::new(), message: None, screen: Screen::default(), displayed_prompt: String::new(), @@ -342,6 +342,12 @@ impl PagerState { self.displayed_prompt = format_string; } + pub(crate) fn run_hooks(&mut self, hook: crate::hooks::Hook) { + let mut hooks = std::mem::take(&mut self.hooks); + hooks.run_hooks(hook, self); + self.hooks = hooks; + } + /// Runs the exit callbacks pub(crate) fn exit(&mut self) { for func in &mut self.exit_callbacks { @@ -349,7 +355,7 @@ impl PagerState { } } - pub(crate) fn append_str(&'_ mut self, text: &str) -> AppendStyle { + pub(crate) fn append_str(&mut self, text: &str) -> AppendStyle { let old_lc = self.screen.line_count(); let old_lc_dgts = minus_core::utils::digits(old_lc); let mut append_result = self.screen.push_screen_buf(