From 860f70555caec66e1527328cfe6b68bfa8a8fc5a Mon Sep 17 00:00:00 2001 From: squirreljetpacks Date: Mon, 17 Aug 2026 12:31:27 -0400 Subject: [PATCH] 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())); + } }