From 860f70555caec66e1527328cfe6b68bfa8a8fc5a Mon Sep 17 00:00:00 2001 From: squirreljetpacks Date: Mon, 17 Aug 2026 12:31:27 -0400 Subject: [PATCH 1/2] feat: configurable output sink --- src/core/commands.rs | 7 ++-- src/core/ev_handler.rs | 11 ++++++ src/core/init.rs | 76 +++++++++++++++++++----------------------- src/core/utils/term.rs | 8 ++--- src/lib.rs | 2 ++ src/pager.rs | 24 ++++++++++++- src/sink.rs | 72 +++++++++++++++++++++++++++++++++++++++ src/state.rs | 18 +++++++--- src/static_pager.rs | 4 +-- src/tests.rs | 59 ++++++++++++++++++++++++++++++++ 10 files changed, 226 insertions(+), 55 deletions(-) create mode 100644 src/sink.rs diff --git a/src/core/commands.rs b/src/core/commands.rs index 7dc3b32..ec6c2c6 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -6,7 +6,7 @@ use std::fmt::Debug; use crate::{ - ExitStrategy, LineNumbers, + ExitStrategy, LineNumbers, OutputSink, hooks::{Hook, HookCallback}, input::{InputClassifier, InputEvent}, minus_core::utils::display::AppendStyle, @@ -49,6 +49,7 @@ pub enum Command { LineWrapping(bool), SetLineNumbers(LineNumbers), FollowOutput(bool), + SetOutputSink(Box), // Configuration options SetExitStrategy(ExitStrategy), @@ -79,7 +80,8 @@ impl PartialEq for Command { (Self::SetRunNoOverflow(d1), Self::SetRunNoOverflow(d2)) => d1 == d2, (Self::SetInputClassifier(_), Self::SetInputClassifier(_)) | (Self::AddExitCallback(_), Self::AddExitCallback(_)) - | (Self::AddHook(..), Self::AddHook(..)) => true, + | (Self::AddHook(..), Self::AddHook(..)) + | (Self::SetOutputSink(_), Self::SetOutputSink(_)) => true, (Self::RemoveHook(h1, id1), Self::RemoveHook(h2, id2)) => h1 == h2 && id1 == id2, #[cfg(feature = "search")] (Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true, @@ -110,6 +112,7 @@ 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::SetOutputSink(_) => write!(f, "SetOutputSink"), Self::Io(c) => write!(f, "Io({c:?})"), } } diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index da149e3..5ca618c 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -349,6 +349,17 @@ pub fn handle_event( p.hooks.remove_callback(hook, id); } Command::ShowPrompt(show) => p.show_prompt = show, + Command::SetOutputSink(sink) => { + #[cfg(not(test))] + if sink.is_tty() + && let Ok(size) = crossterm::terminal::size() + { + p.cols = size.0 as usize; + p.rows = size.1 as usize; + p.reformat_display(); + } + *p.output_sink.lock() = sink; + } Command::FollowOutput(follow_output) | Command::UserInput(InputEvent::FollowOutput(follow_output)) => { p.follow_output = follow_output; diff --git a/src/core/init.rs b/src/core/init.rs index 9b1a2ea..aca38ca 100644 --- a/src/core/init.rs +++ b/src/core/init.rs @@ -9,7 +9,7 @@ //! the [`Receiver`] held inside the [`Pager`] for events. Whenever a event is //! detected, it reacts to it accordingly. use crate::{ - Pager, PagerState, + OutputSink, Pager, PagerState, error::MinusError, hooks::Hook, input::InputEvent, @@ -24,7 +24,6 @@ use crate::{ use crossbeam_channel::{Receiver, Sender, TrySendError}; use crossterm::event; use std::{ - io::Write, panic, sync::{ Arc, @@ -32,9 +31,6 @@ use std::{ }, }; -#[cfg(not(test))] -use std::io::stdout; - #[cfg(feature = "search")] use parking_lot::Condvar; use parking_lot::Mutex; @@ -50,11 +46,11 @@ use super::{CommandQueue, RUNMODE, utils::display::draw_for_change}; /// and creates the initial state that to be stored inside the [`PagerState`] /// /// Then it checks if the minus is running in static mode and does some checks:- -/// * If standard output is not a terminal screen, that is if it is a file or block -/// device, minus will write all the data at once to the stdout and quit +/// * If output sink is not a terminal screen, that is if it is a file or block +/// device, minus will write all the data at once to the output sink and quit /// /// * If the size of the data is less than the available number of rows in the terminal -/// then it displays everything on the main stdout screen at once and quits. This +/// then it displays everything on the main screen at once and quits. This /// behaviour can be turned off if [`Pager::set_run_no_overflow`] is called /// by the main application // Sorry... this behaviour would have been cool to have in async mode, just think about it!!! Many @@ -76,11 +72,6 @@ use super::{CommandQueue, RUNMODE, utils::display::draw_for_change}; #[allow(clippy::module_name_repetitions)] #[allow(clippy::too_many_lines)] pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusError> { - #[cfg(not(test))] - let mut out = stdout(); - #[cfg(test)] - let mut out = Vec::new(); - // Is the event reader running #[cfg(feature = "search")] let input_thread_running = Arc::new((Mutex::new(true), Condvar::new())); @@ -96,20 +87,25 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr *super::RUNMODE.lock() = rm; ps.run_hooks(Hook::PrePagerStart); + let output_sink = ps.output_sink.clone(); + // Static mode checks #[cfg(all(feature = "static_output", not(test)))] if *RUNMODE.lock() == RunMode::Static { - use {super::utils::display::write_raw_lines, crossterm::tty::IsTty}; - // If stdout is not a tty, write everything and quit + use super::utils::display::write_raw_lines; + let mut out = output_sink.lock(); + // If output sink is not a tty, write everything and quit if !out.is_tty() { - write_raw_lines(&mut out, &[ps.screen.orig_text], None)?; + write_raw_lines(&mut *out, &[ps.screen.orig_text], None)?; + drop(out); *RUNMODE.lock() = RunMode::Uninitialized; return Ok(()); } // If number of lines of text is less than available rows, write everything and quit // unless run_no_overflow is set to true if ps.screen.formatted_lines_count() <= ps.rows && !ps.run_no_overflow { - write_raw_lines(&mut out, &ps.screen.formatted_lines, Some("\r"))?; + write_raw_lines(&mut *out, &ps.screen.formatted_lines, Some("\r"))?; + drop(out); ps.exit(); *RUNMODE.lock() = RunMode::Uninitialized; return Ok(()); @@ -118,7 +114,7 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr // Setup terminal, adjust line wraps and get rows #[cfg(not(test))] - term::setup(&mut out)?; + term::setup(&mut *output_sink.lock())?; // Has the user quit let is_exited = Arc::new(AtomicBool::new(false)); @@ -126,18 +122,14 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr { let panic_hook = panic::take_hook(); + let panic_sink = output_sink.clone(); panic::set_hook(Box::new(move |pinfo| { is_exited2.store(true, std::sync::atomic::Ordering::SeqCst); - // HACK: In test we don't care about the cleanup code so just use a separate buffer - // for panic handler. - #[cfg(test)] - let mut out2 = Vec::new(); - #[cfg(not(test))] - let mut out2 = stdout(); - // 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, true)); + let mut out2 = panic_sink.lock(); + drop(term::cleanup(&mut *out2, true)); + drop(out2); panic_hook(pinfo); })); } @@ -155,11 +147,9 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr std::thread::scope(|s| -> crate::Result { let is_exited3 = is_exited.clone(); let is_exited4 = is_exited.clone(); - - #[cfg(test)] - let mut out2 = Vec::new(); - #[cfg(not(test))] - let mut out2 = stdout(); + let output_sink2 = output_sink.clone(); + let ps_mutex2 = ps_mutex.clone(); + let rx2 = rx.clone(); let t1 = s.spawn(move || { let res = event_reader( @@ -177,9 +167,9 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr }); let t2 = s.spawn(move || { let res = start_reactor( - &rx, - &ps_mutex, - &mut out2, + &rx2, + &ps_mutex2, + &output_sink2, #[cfg(feature = "search")] &input_thread_running, &is_exited4, @@ -196,7 +186,8 @@ 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, true)?; + #[cfg(not(test))] + term::cleanup(&mut *output_sink.lock(), true)?; } r1?; @@ -221,7 +212,7 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr fn start_reactor( rx: &Receiver, ps: &Arc>, - mut out_lock: impl Write, + output_sink: &Arc>>, #[cfg(feature = "search")] input_thread_running: &Arc<(Mutex, Condvar)>, is_exited: &Arc, ) -> Result<(), MinusError> { @@ -229,12 +220,13 @@ fn start_reactor( { let mut p = ps.lock(); + let mut out_lock = output_sink.lock(); - draw_full(&mut out_lock, &mut p)?; + 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))?; + draw_for_change(&mut *out_lock, &mut p, &mut (usize::MAX - 1))?; } } @@ -243,7 +235,7 @@ fn start_reactor( #[cfg(feature = "dynamic_output")] RunMode::Dynamic => loop { if is_exited.load(Ordering::SeqCst) { - term::cleanup(&mut out_lock, true)?; + term::cleanup(&mut *output_sink.lock(), true)?; ps.lock().run_hooks(Hook::PostPagerExit); let mut rm = RUNMODE.lock(); *rm = RunMode::Uninitialized; @@ -263,7 +255,7 @@ fn start_reactor( handle_io_command( ic, - &mut out_lock, + &mut *output_sink.lock(), &mut p, &mut command_queue, #[cfg(feature = "search")] @@ -280,7 +272,7 @@ 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, true)?; + term::cleanup(&mut *output_sink.lock(), true)?; ps.lock().run_hooks(Hook::PostPagerExit); let mut rm = RUNMODE.lock(); @@ -302,7 +294,7 @@ fn start_reactor( handle_io_command( ic, - &mut out_lock, + &mut *output_sink.lock(), &mut p, &mut command_queue, #[cfg(feature = "search")] diff --git a/src/core/utils/term.rs b/src/core/utils/term.rs index 45aeb09..cb3c60a 100644 --- a/src/core/utils/term.rs +++ b/src/core/utils/term.rs @@ -2,11 +2,11 @@ #![allow(dead_code)] +use crate::OutputSink; use crate::error::{CleanupError, MinusError, SetupError}; use crossterm::{ cursor, event, execute, queue, terminal::{self, Clear}, - tty::IsTty, }; use std::io; @@ -18,14 +18,14 @@ use std::io; /// - Clear the entire screen and hide the cursor. /// /// # Errors -/// The function will return with an error if `stdout` is not a terminal. It will qlso fail -/// if it cannot executo commands on the terminal See [`SetupError`]. +/// The function will return with an error if `out` is not a terminal. It will also fail +/// if it cannot execute commands on the terminal See [`SetupError`]. /// /// [alternate screen]: ../../../crossterm/terminal/index.html#alternate-screen /// [raw mode]: ../../../crossterm/terminal/index.html#raw-mode // This function should be kept close to `cleanup` to help ensure both are // doing the opposite of the other. -pub fn setup(out: &mut io::Stdout) -> std::result::Result<(), SetupError> { +pub fn setup(out: &mut impl OutputSink) -> std::result::Result<(), SetupError> { if out.is_tty() { Ok(()) } else { diff --git a/src/lib.rs b/src/lib.rs index 1eff884..f121bfc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -198,6 +198,7 @@ pub mod screen; #[cfg(feature = "search")] #[cfg_attr(docsrs, doc(cfg(feature = "search")))] pub mod search; +pub mod sink; pub mod state; #[cfg(feature = "static_output")] mod static_pager; @@ -213,6 +214,7 @@ pub use search::SearchMode; pub use error::MinusError; pub use pager::Pager; +pub use sink::OutputSink; pub use state::PagerState; /// A convenient type for `Vec>` diff --git a/src/pager.rs b/src/pager.rs index c90613e..3ec7c83 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -1,7 +1,7 @@ //! Proivdes the [Pager] type use crate::{ - ExitStrategy, LineNumbers, + ExitStrategy, LineNumbers, OutputSink, error::MinusError, hooks::{Hook, HookCallback}, input, @@ -414,6 +414,28 @@ impl Pager { self.tx.send(Command::FollowOutput(follow_output))?; Ok(()) } + + /// Set the output sink for the pager. + /// + /// By default, minus writes all output to [`std::io::stdout`]. This function allows you + /// to redirect the pager to another output destination, such as [`std::io::stderr`] or `/dev/tty` + /// (via [`std::fs::File`]). + /// + /// # 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.set_output_sink(std::io::stderr()).unwrap(); + /// ``` + pub fn set_output_sink(&self, sink: S) -> crate::Result { + self.tx.send(Command::SetOutputSink(Box::new(sink)))?; + Ok(()) + } } impl Default for Pager { diff --git a/src/sink.rs b/src/sink.rs new file mode 100644 index 0000000..2b751d4 --- /dev/null +++ b/src/sink.rs @@ -0,0 +1,72 @@ +//! Defines the [`OutputSink`] trait and its implementations. + +use crossterm::tty::IsTty; +use std::io::Write; + +/// A trait for configuring the output sink for minus. +/// +/// By default, minus writes all formatted text and terminal control sequences to +/// [`std::io::stdout`]. By implementing this trait or using the provided implementations, +/// you can redirect minus's output to other sinks such as [`std::io::stderr`], `/dev/tty` +/// (via [`std::fs::File`]), or custom buffers. +/// +/// # Implementations +/// minus provides implementations of [`OutputSink`] for: +/// - [`std::io::Stdout`] +/// - [`std::io::Stderr`] +/// - [`std::fs::File`] +/// - [`Vec`] +/// - [`std::io::Cursor`] +/// - [`std::io::Sink`] +/// - [`Box`] where `T: OutputSink + ?Sized` +pub trait OutputSink: Write + Send + Sync + 'static { + /// Returns `true` if the sink is connected to a terminal / TTY. + fn is_tty(&self) -> bool { + false + } +} + +impl OutputSink for std::io::Stdout { + fn is_tty(&self) -> bool { + IsTty::is_tty(self) + } +} + +impl OutputSink for std::io::Stderr { + fn is_tty(&self) -> bool { + IsTty::is_tty(self) + } +} + +impl OutputSink for std::fs::File { + fn is_tty(&self) -> bool { + IsTty::is_tty(self) + } +} + +impl OutputSink for Vec { + fn is_tty(&self) -> bool { + false + } +} + +impl + Send + Sync + 'static> OutputSink for std::io::Cursor +where + Self: Write, +{ + fn is_tty(&self) -> bool { + false + } +} + +impl OutputSink for std::io::Sink { + fn is_tty(&self) -> bool { + false + } +} + +impl OutputSink for Box { + fn is_tty(&self) -> bool { + (**self).is_tty() + } +} diff --git a/src/state.rs b/src/state.rs index 1abec0a..77f4277 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,7 +5,7 @@ use crate::search::{SearchMode, SearchOpts, next_nth_match}; use crate::{ - LineNumbers, + LineNumbers, OutputSink, error::{MinusError, TermError}, hooks::{Hook, Hooks}, input::{self, HashedEventRegister}, @@ -18,7 +18,7 @@ use crate::{ }, screen::{self, Screen}, }; -use crossterm::{terminal, tty::IsTty}; +use crossterm::terminal; use parking_lot::Mutex; #[cfg(feature = "search")] use std::collections::BTreeSet; @@ -26,7 +26,6 @@ use std::{ borrow::Cow, collections::hash_map::RandomState, convert::TryInto, - io::stdout, sync::{Arc, atomic::AtomicBool}, }; @@ -163,14 +162,24 @@ pub struct PagerState { /// See [`follow_output`](crate::pager::Pager::follow_output) for more info on follow mode. pub(crate) follow_output: bool, pub(crate) selection_anchor: Option, + /// The output sink configured for the pager. + pub output_sink: Arc>>, } impl PagerState { pub(crate) fn new() -> Result { + #[cfg(not(test))] + let default_sink: Box = Box::new(std::io::stdout()); + #[cfg(test)] + let default_sink: Box = Box::new(Vec::new()); + + let output_sink = Arc::new(Mutex::new(default_sink)); + let is_tty = output_sink.lock().is_tty(); + let (cols, rows) = if cfg!(test) { // In tests, set number of columns to 80 and rows to 10 (80, 10) - } else if stdout().is_tty() { + } else if is_tty { // If a proper terminal is present, get size and set it let size = terminal::size()?; (size.0 as usize, size.1 as usize) @@ -216,6 +225,7 @@ impl PagerState { lines_to_row_map: LinesRowMap::new(), follow_output: false, selection_anchor: None, + output_sink, }; state.hooks.add_callback( diff --git a/src/static_pager.rs b/src/static_pager.rs index 308ae70..139ba46 100644 --- a/src/static_pager.rs +++ b/src/static_pager.rs @@ -7,9 +7,9 @@ use crate::{Pager, error::MinusError}; /// Display static information to the screen /// /// Since it is sure that fed data will never change, minus can do some checks like:- -/// * If stdout is not a tty, minus not start a pager. It will simply print all the data and quit +/// * If the output sink is not a tty, minus will not start a pager. It will simply print all the data and quit /// * If there are more rows in the terminal than the number of lines of data to display -/// minus will not start a pager and simply display all data on the main stdout screen. +/// minus will not start a pager and simply display all data on the main screen. /// This behaviour can be turned off if /// [`Pager::set_run_no_overflow(true)`](Pager::set_run_no_overflow) has been /// called before starting diff --git a/src/tests.rs b/src/tests.rs index e30a70a..67467c2 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -345,4 +345,63 @@ mod emit_events { pager.rx.try_recv().unwrap() ); } + + #[test] + fn set_output_sink() { + let pager = Pager::new(); + pager.set_output_sink(std::io::stderr()).unwrap(); + assert_eq!( + Command::SetOutputSink(Box::new(std::io::stderr())), + pager.rx.try_recv().unwrap() + ); + } +} + +mod output_sink { + use crate::{OutputSink, Pager, PagerState}; + use std::sync::{Arc, Mutex}; + + #[derive(Clone, Default)] + struct MockSink { + buffer: Arc>>, + is_tty: bool, + } + + impl std::io::Write for MockSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.buffer.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl OutputSink for MockSink { + fn is_tty(&self) -> bool { + self.is_tty + } + } + + #[test] + fn test_custom_output_sink_in_pagerstate() { + let sink = MockSink { + buffer: Arc::new(Mutex::new(Vec::new())), + is_tty: false, + }; + + let pager = Pager::new(); + pager.set_output_sink(sink).unwrap(); + + let ps = PagerState::generate_initial_state(&pager.rx).unwrap(); + assert!(!ps.output_sink.lock().is_tty()); + } + + #[test] + fn test_sink_implementations() { + assert!(!OutputSink::is_tty(&Vec::::new())); + assert!(!OutputSink::is_tty(&std::io::Cursor::new(Vec::::new()))); + assert!(!OutputSink::is_tty(&std::io::sink())); + } } From 709c65db88404d485c4d14b1f3cbd940b6973bfb Mon Sep 17 00:00:00 2001 From: squirreljetpacks Date: Tue, 18 Aug 2026 10:51:33 -0400 Subject: [PATCH 2/2] feat: help key + broader keybinding modifier support --- src/core/ev_handler.rs | 80 +++++++++++++++ src/help.rs | 152 +++++++++++++++++++++++++++++ src/input/definitions/keydefs.rs | 11 ++- src/input/definitions/mousedefs.rs | 2 +- src/input/hashed_event_register.rs | 137 +++++++++++++++++++++++--- src/input/mod.rs | 47 +++++---- src/input/tests.rs | 47 +++++++++ src/lib.rs | 1 + src/screen/mod.rs | 1 + src/search.rs | 30 +++++- src/state.rs | 87 +++++++++++++++-- 11 files changed, 552 insertions(+), 43 deletions(-) create mode 100644 src/help.rs diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 5ca618c..b070271 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -33,12 +33,22 @@ pub fn handle_event( ) { match ev { Command::SetData(text) => { + if let Some(ref mut hs) = p.help_state { + hs.screen.orig_text = text; + hs.screen.line_count = hs.screen.orig_text.lines().count(); + return; + } p.screen.orig_text = text; p.screen.line_count = p.screen.orig_text.lines().count(); p.reformat_display(); command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); } Command::UserInput(InputEvent::Exit) => { + if p.help_state.is_some() { + p.exit_help(); + command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); + return; + } p.run_hooks(Hook::PrePagerExit); p.exit(); is_exited.store(true, std::sync::atomic::Ordering::SeqCst); @@ -141,11 +151,24 @@ pub fn handle_event( } } Command::UserInput(InputEvent::RestorePrompt) => { + if p.help_state.is_some() { + p.exit_help(); + command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); + return; + } // Set the message to None and new messages to false as all messages have been shown p.message = None; p.format_prompt(); command_queue.push_back(Command::Io(IoCommand::RedrawPrompt)); } + Command::UserInput(InputEvent::ShowHelp) => { + if p.help_state.is_some() { + p.exit_help(); + } else { + p.show_help(); + } + command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); + } Command::UserInput(InputEvent::UpdateTermArea(c, r)) => { p.rows = r; p.cols = c; @@ -283,6 +306,11 @@ pub fn handle_event( } Command::AppendData(text) => { + if let Some(ref mut hs) = p.help_state { + hs.screen.orig_text.push_str(&text); + hs.screen.line_count = hs.screen.orig_text.lines().count(); + return; + } let prev_unterminated = p.screen.unterminated; let prev_fmt_lines_count = p.screen.formatted_lines_count(); let append_style = p.append_str(text.as_str()); @@ -548,6 +576,58 @@ mod tests { assert_eq!(ps.message.unwrap(), TEST_STR.to_string()); } + #[test] + fn show_help() { + let mut ps = PagerState::new().unwrap(); + ps.screen.orig_text = "original text\n".to_string(); + ps.reformat_display(); + ps.upper_mark = 0; + + let ev = Command::UserInput(InputEvent::ShowHelp); + let mut command_queue = CommandQueue::new_zero(); + + // Showing help sets the screen to the formatted help table + handle_event( + ev, + &mut ps, + &mut command_queue, + &Arc::new(AtomicBool::new(false)), + ); + assert!(ps.help_state.is_some()); + assert!(ps.screen.orig_text.contains("COMMAND SUMMARY")); + assert!(ps.prompt.contains("HELP")); + + // Pressing help again toggles it off and restores original text + let ev2 = Command::UserInput(InputEvent::ShowHelp); + handle_event( + ev2, + &mut ps, + &mut command_queue, + &Arc::new(AtomicBool::new(false)), + ); + assert!(ps.help_state.is_none()); + assert_eq!(ps.screen.orig_text, "original text\n"); + + // Showing help then exiting with Exit returns to pager + handle_event( + Command::UserInput(InputEvent::ShowHelp), + &mut ps, + &mut command_queue, + &Arc::new(AtomicBool::new(false)), + ); + assert!(ps.help_state.is_some()); + let is_exited = Arc::new(AtomicBool::new(false)); + handle_event( + Command::UserInput(InputEvent::Exit), + &mut ps, + &mut command_queue, + &is_exited, + ); + assert!(ps.help_state.is_none()); + assert_eq!(is_exited.load(std::sync::atomic::Ordering::SeqCst), false); + assert_eq!(ps.screen.orig_text, "original text\n"); + } + #[test] #[cfg(feature = "static_output")] fn set_run_no_overflow() { diff --git a/src/help.rs b/src/help.rs new file mode 100644 index 0000000..17cc8e5 --- /dev/null +++ b/src/help.rs @@ -0,0 +1,152 @@ +//! Help text and related definitions for the pager. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Format a [`KeyEvent`] into a human-readable representation (e.g. `"Ctrl-c"`, `"Alt-h"`). +pub fn format_key(ke: &KeyEvent) -> String { + let mut s = String::new(); + if ke.modifiers.contains(KeyModifiers::CONTROL) { + s.push_str("Ctrl-"); + } + if ke.modifiers.contains(KeyModifiers::ALT) { + s.push_str("Alt-"); + } + if ke.modifiers.contains(KeyModifiers::SHIFT) { + if let KeyCode::Char(c) = ke.code { + if !c.is_ascii_uppercase() { + s.push_str("Shift-"); + } + } else { + s.push_str("Shift-"); + } + } + + match ke.code { + KeyCode::Char(c) => s.push(c), + KeyCode::Enter => s.push_str("Enter"), + KeyCode::Tab => s.push_str("Tab"), + KeyCode::BackTab => s.push_str("BackTab"), + KeyCode::Backspace => s.push_str("Backspace"), + KeyCode::Esc => s.push_str("Esc"), + KeyCode::Up => s.push_str("Up"), + KeyCode::Down => s.push_str("Down"), + KeyCode::Left => s.push_str("Left"), + KeyCode::Right => s.push_str("Right"), + KeyCode::PageUp => s.push_str("PageUp"), + KeyCode::PageDown => s.push_str("PageDown"), + KeyCode::Home => s.push_str("Home"), + KeyCode::End => s.push_str("End"), + KeyCode::Delete => s.push_str("Delete"), + KeyCode::Insert => s.push_str("Insert"), + KeyCode::F(n) => s.push_str(&format!("F{n}")), + KeyCode::Null => s.push_str("Null"), + _ => s.push_str("Unknown"), + } + s +} + +/// Format dynamic help table from key event entries and their descriptions. +/// +/// Empty descriptions are omitted. +pub fn format_help_table_from_entries<'a, I>(entries: I) -> String +where + I: IntoIterator, +{ + let mut groups: Vec<(&'a str, Vec)> = Vec::new(); + for (key, desc) in entries { + let trimmed_desc = desc.trim(); + if trimmed_desc.is_empty() { + continue; + } + let key_str = format_key(key); + if let Some((_, keys)) = groups.iter_mut().find(|(d, _)| *d == trimmed_desc) { + if !keys.contains(&key_str) { + keys.push(key_str); + } + } else { + groups.push((trimmed_desc, vec![key_str])); + } + } + + if groups.is_empty() { + return String::new(); + } + + let mut out = String::new(); + out.push_str(" COMMAND SUMMARY\n\n"); + out.push_str(" Key(s) Action\n"); + out.push_str(" ------ ------\n"); + + for (desc, keys) in groups { + let keys_str = keys.join(", "); + out.push_str(&format!(" {:<30} {}\n", keys_str, desc)); + } + + out.push_str("\n -- Press q, Enter, or Alt-h to return to pager --\n"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::KeyEventState; + + #[test] + fn test_format_key() { + let k1 = KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + assert_eq!(format_key(&k1), "q"); + + let k2 = KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + assert_eq!(format_key(&k2), "Ctrl-c"); + + let k3 = KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::ALT, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + assert_eq!(format_key(&k3), "Alt-Up"); + } + + #[test] + fn test_format_help_table_from_entries() { + let k1 = KeyEvent { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + let k2 = KeyEvent { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + let k3 = KeyEvent { + code: KeyCode::Char('x'), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }; + + let entries = vec![(&k1, "quit"), (&k2, "quit"), (&k3, "")]; + let table = format_help_table_from_entries(entries); + assert!(table.contains("COMMAND SUMMARY")); + assert!(table.contains("q, Ctrl-c")); + assert!(table.contains("quit")); + assert!(!table.contains(" x ")); + + let empty_table = format_help_table_from_entries(Vec::<(&KeyEvent, &str)>::new()); + assert!(empty_table.is_empty()); + } +} diff --git a/src/input/definitions/keydefs.rs b/src/input/definitions/keydefs.rs index 3593543..9c2cce9 100644 --- a/src/input/definitions/keydefs.rs +++ b/src/input/definitions/keydefs.rs @@ -99,7 +99,7 @@ impl KeySeq { } } Token::MultipleChar(c) => { - let c = c.to_ascii_lowercase().clone(); + let c = c.to_ascii_lowercase(); SPECIAL_KEYS.get(c.as_str()).map_or_else( || panic!("'{}': Invalid key input sequence given", text), |key| { @@ -316,4 +316,13 @@ fn test_parse_key_event() { state: KeyEventState::NONE, } ); + assert_eq!( + parse_key_event("m-h"), + KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::ALT, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + } + ); } diff --git a/src/input/definitions/mousedefs.rs b/src/input/definitions/mousedefs.rs index 90ec8cf..b13d66f 100644 --- a/src/input/definitions/mousedefs.rs +++ b/src/input/definitions/mousedefs.rs @@ -67,7 +67,7 @@ fn gen_mouse_event_from_tokenlist(token_list: &[Token], text: &str) -> MouseEven ); } Token::MultipleChar(c) => { - let c = c.to_ascii_lowercase().clone(); + let c = c.to_ascii_lowercase(); MOUSE_ACTIONS.get(c.as_str()).map_or_else( || panic!("'{}': Invalid key input sequence given", text), |k| { diff --git a/src/input/hashed_event_register.rs b/src/input/hashed_event_register.rs index a4a9b0a..bfd7712 100644 --- a/src/input/hashed_event_register.rs +++ b/src/input/hashed_event_register.rs @@ -12,9 +12,17 @@ use std::{ sync::Arc, }; +use std::borrow::Cow; + /// A convenient type for the return type of [`HashedEventRegister::get`] type EventReturnType = Arc InputEvent + Send + Sync>; +#[derive(Clone)] +struct EventCallback { + cb: EventReturnType, + desc: Cow<'static, str>, +} + // ////////////////////////////// // EVENTWRAPPER TYPE // ////////////////////////////// @@ -89,7 +97,7 @@ 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 @@ -115,6 +123,15 @@ where fn classify_input(&self, ev: Event, ps: &crate::PagerState) -> Option { self.get(&ev).map(|c| c(ev, ps)) } + + fn format_help(&self) -> Option { + let h = self.format_help(); + if h.is_empty() { + None + } else { + Some(h) + } + } } // #################### @@ -129,6 +146,16 @@ where Self(HashMap::with_hasher(s)) } + /// Format dynamic help table from all registered key bindings that have non-empty descriptions. + #[must_use] + pub fn format_help(&self) -> String { + let entries = self.0.iter().filter_map(|(k, v)| match k { + EventWrapper::ExactMatchEvent(Event::Key(ke)) => Some((ke, v.desc.as_ref())), + _ => None, + }); + crate::help::format_help_table_from_entries(entries) + } + /// Adds a callback to handle all events that failed to match /// /// Sometimes there are bunch of keys having equal importance that should have the same @@ -142,13 +169,20 @@ where &mut self, cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, ) { - self.0.insert(EventWrapper::WildEvent, Arc::new(cb)); + self.0.insert( + EventWrapper::WildEvent, + EventCallback { + cb: Arc::new(cb), + desc: Cow::Borrowed(""), + }, + ); } fn get(&self, k: &Event) -> Option<&EventReturnType> { self.0 .get(&k.into()) - .map_or_else(|| self.0.get(&EventWrapper::WildEvent), |k| Some(k)) + .map_or_else(|| self.0.get(&EventWrapper::WildEvent), Some) + .map(|entry| &entry.cb) } /// Adds a callback for handling resize events @@ -176,8 +210,13 @@ where let v = Arc::new(cb); // The 0, 0 are present just to ensure everything compiles and they can be anything. // These values are never hashed or stored into the HashedEventRegister - self.0 - .insert(EventWrapper::ExactMatchEvent(Event::Resize(0, 0)), v); + self.0.insert( + EventWrapper::ExactMatchEvent(Event::Resize(0, 0)), + EventCallback { + cb: v, + desc: Cow::Borrowed(""), + }, + ); } /// Removes the currently active resize event callback @@ -194,7 +233,7 @@ impl HashedEventRegister where S: BuildHasher, { - /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb` + /// Add all elements 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. @@ -213,17 +252,31 @@ where &mut self, desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + ) { + self.add_described_key_events(desc, "", cb); + } + + /// Add all elements of `keys` as key bindings with a description that minus should respond to with the callback `cb`. + pub fn add_described_key_events( + &mut self, + keys: &[&str], + desc: impl Into>, + cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, ) { let v = Arc::new(cb); - for k in desc { + let d = desc.into(); + for k in keys { self.0.insert( Event::Key(super::definitions::keydefs::parse_key_event(k)).into(), - v.clone(), + EventCallback { + cb: v.clone(), + desc: d.clone(), + }, ); } } - /// Add all elemnts of `desc` as key bindings that minus should respond to with the callback `cb`. + /// Add all elements 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). /// @@ -247,13 +300,31 @@ where desc: &[&str], cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, remap: bool, + ) { + self.add_described_key_events_checked(desc, "", cb, remap); + } + + /// Add all elements of `keys` as key bindings with a description that minus should respond to with the callback `cb`, with conflict checking. + pub fn add_described_key_events_checked( + &mut self, + keys: &[&str], + desc: impl Into>, + cb: impl Fn(Event, &PagerState) -> InputEvent + Send + Sync + 'static, + remap: bool, ) { let v = Arc::new(cb); - for k in desc { + let d = desc.into(); + for k in keys { 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( + def, + EventCallback { + cb: v.clone(), + desc: d.clone(), + }, + ); } } @@ -272,6 +343,37 @@ where .remove(&Event::Key(super::definitions::keydefs::parse_key_event(k)).into()); } } + + /// Add key binding(s) to show help in the pager prompt. + /// + /// If `desc` is empty, defaults to `&["m-h"]`. + /// + /// # Example + /// ``` + /// use minus::input::HashedEventRegister; + /// + /// let mut input_register = HashedEventRegister::default(); + /// // Bind default Meta/Alt-h key to show help + /// input_register.add_help_key(&[]); + /// // Or specify custom keys + /// input_register.add_help_key(&["f1"]); + /// ``` + pub fn add_help_key(&mut self, desc: &[&str]) { + let keys = if desc.is_empty() { &["m-h"][..] } else { desc }; + self.add_described_key_events(keys, "help", |_, _| InputEvent::ShowHelp); + } + + /// Add key binding(s) to show help in the pager prompt with conflict checking. + /// + /// If `desc` is empty, defaults to `&["m-h"]`. + /// + /// # Panics + /// This will panic if any of the keybindings has been previously defined, unless `remap` + /// is set to true. + pub fn add_help_key_checked(&mut self, desc: &[&str], remap: bool) { + let keys = if desc.is_empty() { &["m-h"][..] } else { desc }; + self.add_described_key_events_checked(keys, "help", |_, _| InputEvent::ShowHelp, remap); + } } // ############################### @@ -305,7 +407,10 @@ where for k in desc { self.0.insert( Event::Mouse(super::definitions::mousedefs::parse_mouse_event(k)).into(), - v.clone(), + EventCallback { + cb: v.clone(), + desc: Cow::Borrowed(""), + }, ); } } @@ -339,7 +444,13 @@ where 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( + def, + EventCallback { + cb: v.clone(), + desc: Cow::Borrowed(""), + }, + ); } } diff --git a/src/input/mod.rs b/src/input/mod.rs index f52fff7..7192194 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -265,6 +265,8 @@ pub enum InputEvent { /// This is similar to [`Pager::follow_output`](crate::pager::Pager::follow_output) except that /// this is used to control it from the user's side. FollowOutput(bool), + /// Show help message in the prompt area. + ShowHelp, } /// Classifies the input and returns the appropriate [`InputEvent`] @@ -280,6 +282,11 @@ pub enum InputEvent { )] pub trait InputClassifier { fn classify_input(&self, ev: Event, ps: &PagerState) -> Option; + + /// Format dynamic help text from registered bindings, if supported. + fn format_help(&self) -> Option { + None + } } /// Insert the default set of actions into the [`HashedEventRegister`] @@ -293,20 +300,20 @@ pub fn generate_default_bindings(map: &mut HashedEventRegister) where S: std::hash::BuildHasher, { - map.add_key_events(&["q", "c-c"], |_, _| InputEvent::Exit); + map.add_described_key_events(&["q", "c-c"], "quit", |_, _| InputEvent::Exit); - map.add_key_events(&["up", "k"], |_, ps| { + map.add_described_key_events(&["up", "k"], "scroll up", |_, 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.add_described_key_events(&["down", "j"], "scroll down", |_, 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.add_described_key_events(&["c-f"], "toggle follow", |_, ps| { InputEvent::FollowOutput(!ps.follow_output) }); - map.add_key_events(&["enter"], |_, ps| { + map.add_described_key_events(&["enter"], "scroll lines", |_, ps| { if ps.message.is_some() { InputEvent::RestorePrompt } else { @@ -314,17 +321,17 @@ where InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(position)) } }); - map.add_key_events(&["u", "c-u"], |_, ps| { + map.add_described_key_events(&["u", "c-u"], "half-page up", |_, 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.add_described_key_events(&["d", "c-d"], "half-page down", |_, 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.add_described_key_events(&["g", "home"], "top", |_, _| InputEvent::UpdateUpperMark(0)); - map.add_key_events(&["s-g", "G"], |_, ps| { + map.add_described_key_events(&["s-g", "G"], "bottom", |_, ps| { let mut position = ps .prefix_num .parse::() @@ -344,21 +351,21 @@ where .unwrap_or(&(usize::MAX - 1)); InputEvent::UpdateUpperMark(row_to_go) }); - map.add_key_events(&["pageup"], |_, ps| { + map.add_described_key_events(&["pageup"], "page up", |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_sub(ps.rows - 1)) }); - map.add_key_events(&["pagedown", "space"], |_, ps| { + map.add_described_key_events(&["pagedown", "space"], "page down", |_, ps| { InputEvent::UpdateUpperMark(ps.upper_mark.saturating_add(ps.rows - 1)) }); - map.add_key_events(&["c-l"], |_, ps| { + map.add_described_key_events(&["c-l"], "toggle line numbers", |_, ps| { InputEvent::UpdateLineNumber(!ps.line_numbers) }); - map.add_key_events(&["end"], |_, _| InputEvent::UpdateUpperMark(usize::MAX - 1)); + map.add_described_key_events(&["end"], "bottom", |_, _| 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.add_described_key_events(&["/"], "search forward", |_, _| InputEvent::Search(SearchMode::Forward)); + map.add_described_key_events(&["?"], "search backward", |_, _| InputEvent::Search(SearchMode::Reverse)); + map.add_described_key_events(&["n"], "next match", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); if ps.search_state.search_mode == SearchMode::Forward { @@ -369,7 +376,7 @@ where InputEvent::Ignore } }); - map.add_key_events(&["p", "s-n"], |_, ps| { + map.add_described_key_events(&["p", "s-n"], "previous match", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); if ps.search_state.search_mode == SearchMode::Forward { @@ -407,14 +414,14 @@ where map.add_key_events(&["y"], |_, _| InputEvent::CopySelection); } - map.add_key_events(&["c-s-h", "c-h"], |_, ps| { + map.add_described_key_events(&["c-s-h", "c-h"], "toggle line wrap", |_, ps| { InputEvent::HorizontalScroll(!ps.screen.line_wrapping) }); - map.add_key_events(&["h", "left"], |_, ps| { + map.add_described_key_events(&["h", "left"], "scroll 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.add_described_key_events(&["l", "right"], "scroll right", |_, ps| { let position = ps.prefix_num.parse::().unwrap_or(1); InputEvent::UpdateLeftMark(ps.left_mark.saturating_add(position)) }); diff --git a/src/input/tests.rs b/src/input/tests.rs index c8d00a3..44b1eea 100644 --- a/src/input/tests.rs +++ b/src/input/tests.rs @@ -488,3 +488,50 @@ fn test_search_bindings() { ); } } + +#[test] +fn test_help_key() { + use crate::input::{HashedEventRegister, InputClassifier}; + + let pager = PagerState::new().unwrap(); + + // Default register does not have help bound (falls back to wild matcher -> Ignore) + let alt_h = Event::Key(KeyEvent { + code: KeyCode::Char('h'), + modifiers: KeyModifiers::ALT, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }); + assert_eq!(pager.input_classifier.classify_input(alt_h.clone(), &pager), Some(InputEvent::Ignore)); + + // Attach default help key (alt-h / m-h) + let mut reg = HashedEventRegister::default(); + reg.add_help_key(&[]); + assert_eq!(reg.classify_input(alt_h.clone(), &pager), Some(InputEvent::ShowHelp)); + + // Attach custom help key + let mut reg_custom = HashedEventRegister::default(); + reg_custom.add_help_key(&["f1"]); + let f1 = Event::Key(KeyEvent { + code: KeyCode::F(1), + modifiers: KeyModifiers::NONE, + kind: crossterm::event::KeyEventKind::Press, + state: KeyEventState::NONE, + }); + assert_eq!(reg_custom.classify_input(f1, &pager), Some(InputEvent::ShowHelp)); + + // Dynamic help generation with described keys and omitted empty descriptions + let mut reg_dynamic = HashedEventRegister::with_default_hasher(); + reg_dynamic.add_described_key_events(&["q", "c-c"], "quit", |_, _| InputEvent::Exit); + reg_dynamic.add_described_key_events(&["j", "down"], "scroll down", |_, _| InputEvent::UpdateUpperMark(1)); + // Undescribed key (empty description) should not appear in help text + reg_dynamic.add_key_events(&["x"], |_, _| InputEvent::Exit); + + let help = reg_dynamic.format_help(); + assert!(help.contains("q, Ctrl-c") || help.contains("Ctrl-c, q")); + assert!(help.contains("quit")); + assert!(help.contains("j, Down") || help.contains("Down, j")); + assert!(help.contains("scroll down")); + assert!(!help.contains(" x ")); + assert!(help.contains("COMMAND SUMMARY")); +} diff --git a/src/lib.rs b/src/lib.rs index f121bfc..8c003d2 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 help; pub mod hooks; pub mod input; #[path = "core/mod.rs"] diff --git a/src/screen/mod.rs b/src/screen/mod.rs index 9d53732..9358e53 100644 --- a/src/screen/mod.rs +++ b/src/screen/mod.rs @@ -106,6 +106,7 @@ impl fmt::Display for SearchFormattedRow<'_, '_> { /// /// Most of the functions of this type are cheap as minus does a lot of caching of the analysis /// behind the scenes +#[derive(Clone, Debug)] pub struct Screen { pub(crate) orig_text: OwnedTextBlock, pub(crate) formatted_lines: Rows, diff --git a/src/search.rs b/src/search.rs index beb439a..adc8045 100644 --- a/src/search.rs +++ b/src/search.rs @@ -652,10 +652,10 @@ where } Event::Key(KeyEvent { code: KeyCode::Char(c), - modifiers: KeyModifiers::NONE, + modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT, .. }) => { - // For any character key, without a modifier, insert it into so.string before + // For any character key, without a modifier (or with Shift), insert it into so.string before // current cursor position and update the line so.string .insert(so.cursor_position.saturating_sub(1).into(), *c); @@ -1023,6 +1023,32 @@ mod tests { assert_eq!(search_opts.input_status, InputStatus::Confirmed); } + #[test] + fn input_uppercase_and_shifted_text() { + let mut search_opts = new_search_opts(SearchMode::Forward); + let mut out = Vec::with_capacity(1500); + for (i, c) in "Hello World".chars().enumerate() { + let modifiers = if c.is_uppercase() { + KeyModifiers::SHIFT + } else { + KeyModifiers::NONE + }; + search_opts.ev = Some(Event::Key(KeyEvent { + code: KeyCode::Char(c), + kind: KeyEventKind::Press, + modifiers, + state: KeyEventState::NONE, + })); + 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.string, "Hello World"); + assert_eq!(search_opts.input_status, InputStatus::Confirmed); + } + #[test] fn home_end_keys() { // Setup diff --git a/src/state.rs b/src/state.rs index 77f4277..2260579 100644 --- a/src/state.rs +++ b/src/state.rs @@ -94,6 +94,15 @@ pub struct Selection { /// /// Various fields are made public so that their values can be accessed while implementing the /// trait. +#[derive(Clone, Debug)] +pub(crate) struct HelpState { + pub(crate) screen: Screen, + pub(crate) upper_mark: usize, + pub(crate) left_mark: usize, + pub(crate) prompt: String, + pub(crate) follow_output: bool, + pub(crate) line_numbers: LineNumbers, +} #[allow(clippy::module_name_repetitions)] pub struct PagerState { /// Configuration for line numbers. See [`LineNumbers`] @@ -162,6 +171,8 @@ pub struct PagerState { /// See [`follow_output`](crate::pager::Pager::follow_output) for more info on follow mode. pub(crate) follow_output: bool, pub(crate) selection_anchor: Option, + /// Saved state while help screen is active. + pub(crate) help_state: Option, /// The output sink configured for the pager. pub output_sink: Arc>>, } @@ -225,6 +236,7 @@ impl PagerState { lines_to_row_map: LinesRowMap::new(), follow_output: false, selection_anchor: None, + help_state: None, output_sink, }; @@ -337,13 +349,11 @@ impl PagerState { // the prompt/message and the indicators on the right // NOTE: Count chars of prompt_str as they can be non-ASCII let prefix_len = prefix_str.len(); - let extra_space = self.cols.saturating_sub( - search_len + prefix_len + follow_mode_str.len() + prompt_str.chars().count(), - ); + let indicators_len = search_len + prefix_len + follow_mode_str.len(); + let available_space = self.cols.saturating_sub(indicators_len); + let extra_space = available_space.saturating_sub(prompt_str.chars().count()); - let byte_idx = prompt_str - .char_indices() - .nth(search_len + prefix_len + follow_mode_str.len()); + let byte_idx = prompt_str.char_indices().nth(available_space); // The if-case is especially frequent under non-tty conditions let dsp_prompt: &str = if extra_space == 0 @@ -387,6 +397,53 @@ impl PagerState { self.displayed_prompt = format_string; } + /// Enter help mode, displaying the help table screen. + pub(crate) fn show_help(&mut self) { + if self.help_state.is_some() { + return; + } + let help_text = self + .input_classifier + .format_help() + .unwrap_or_default(); + + let saved = HelpState { + screen: std::mem::take(&mut self.screen), + upper_mark: self.upper_mark, + left_mark: self.left_mark, + prompt: std::mem::take(&mut self.prompt), + follow_output: self.follow_output, + line_numbers: self.line_numbers, + }; + + self.screen = Screen::default(); + self.screen.orig_text = help_text; + self.screen.line_count = self.screen.orig_text.lines().count(); + self.screen.line_wrapping = false; + self.upper_mark = 0; + self.left_mark = 0; + self.follow_output = false; + self.line_numbers = LineNumbers::Disabled; + self.prompt = "HELP -- Press q, Enter, or Alt-h to return to pager".to_string(); + self.message = None; + self.help_state = Some(saved); + self.reformat_display(); + } + + /// Exit help mode, restoring the original document and scroll position. + pub(crate) fn exit_help(&mut self) { + if let Some(saved) = self.help_state.take() { + self.screen = saved.screen; + self.upper_mark = saved.upper_mark; + self.left_mark = saved.left_mark; + self.prompt = saved.prompt; + self.follow_output = saved.follow_output; + self.line_numbers = saved.line_numbers; + self.message = None; + self.reformat_display(); + } + } + 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); @@ -722,4 +779,22 @@ mod tests { assert_eq!(ps.extract_selection().as_deref(), Some("cdefghi\njklm")); } + + #[test] + fn format_prompt_truncates_long_message_to_available_width() { + let mut ps = PagerState::new().unwrap(); + ps.cols = 20; + let long_msg = "Help: q:quit | j/k:scroll | Space:page"; + ps.message = Some(long_msg.to_string()); + ps.format_prompt(); + + // Should truncate message to fit 20 cols + assert!(ps.displayed_prompt.contains(&long_msg[..20])); + + // With follow mode [F] (3 chars), prompt should truncate to 17 chars + ps.follow_output = true; + ps.format_prompt(); + assert!(ps.displayed_prompt.contains(&long_msg[..17])); + assert!(ps.displayed_prompt.contains("[F]")); + } }