diff --git a/src/core/commands.rs b/src/core/commands.rs index 3d3ca11..1517f18 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -39,6 +39,10 @@ pub enum Command { AddExitCallback(Box), #[cfg(feature = "static_output")] SetRunNoOverflow(bool), + #[cfg(feature = "dynamic_output")] + SetQuitIfOneScreen(bool), + #[cfg(feature = "dynamic_output")] + CheckQuitIfOneScreen, #[cfg(feature = "search")] IncrementalSearchCondition(Box bool + Send + Sync + 'static>), @@ -60,6 +64,10 @@ impl PartialEq for Command { (Self::SetExitStrategy(d1), Self::SetExitStrategy(d2)) => d1 == d2, #[cfg(feature = "static_output")] (Self::SetRunNoOverflow(d1), Self::SetRunNoOverflow(d2)) => d1 == d2, + #[cfg(feature = "dynamic_output")] + (Self::SetQuitIfOneScreen(d1), Self::SetQuitIfOneScreen(d2)) => d1 == d2, + #[cfg(feature = "dynamic_output")] + (Self::CheckQuitIfOneScreen, Self::CheckQuitIfOneScreen) => true, (Self::SetInputClassifier(_), Self::SetInputClassifier(_)) | (Self::AddExitCallback(_), Self::AddExitCallback(_)) => true, #[cfg(feature = "search")] @@ -88,6 +96,10 @@ impl Debug for Command { Self::AddExitCallback(_) => write!(f, "AddExitCallback"), #[cfg(feature = "static_output")] Self::SetRunNoOverflow(val) => write!(f, "SetRunNoOverflow({val:?})"), + #[cfg(feature = "dynamic_output")] + Self::SetQuitIfOneScreen(val) => write!(f, "SetQuitIfOneScreen({val:?})"), + #[cfg(feature = "dynamic_output")] + Self::CheckQuitIfOneScreen => write!(f, "CheckQuitIfOneScreen"), Self::UserInput(input) => write!(f, "UserInput({input:?})"), Self::FollowOutput(follow_output) => write!(f, "FollowOutput({follow_output:?})"), } diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 92391d4..fd76359 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -314,6 +314,27 @@ pub fn handle_event( } #[cfg(feature = "static_output")] Command::SetRunNoOverflow(val) => p.run_no_overflow = val, + #[cfg(feature = "dynamic_output")] + Command::SetQuitIfOneScreen(val) => p.quit_if_one_screen = val, + #[cfg(feature = "dynamic_output")] + Command::CheckQuitIfOneScreen => { + if p.quit_if_one_screen { + let writable_rows = p.rows.saturating_sub(1); + if p.screen.formatted_lines_count() <= writable_rows { + p.exit(); + is_exited.store(true, std::sync::atomic::Ordering::SeqCst); + // Restore terminal without calling process::exit yet + term::cleanup(&mut out, &crate::ExitStrategy::PagerQuit, true)?; + // Write content to the main screen so it is preserved + display::write_raw_lines(&mut out, &p.screen.formatted_lines, None)?; + out.flush().map_err(MinusError::Draw)?; + // Respect the configured exit strategy + if p.exit_strategy == crate::ExitStrategy::ProcessQuit { + std::process::exit(0); + } + } + } + } #[cfg(feature = "search")] Command::IncrementalSearchCondition(cb) => p.search_state.incremental_search_condition = cb, Command::SetInputClassifier(clf) => p.input_classifier = clf, @@ -531,4 +552,81 @@ mod tests { .unwrap(); assert_eq!(ps.exit_callbacks.len(), 1); } + + #[test] + #[cfg(feature = "dynamic_output")] + fn set_quit_if_one_screen() { + let mut ps = PagerState::new().unwrap(); + let ev = Command::SetQuitIfOneScreen(true); + let mut out = Vec::new(); + let mut command_queue = CommandQueue::new_zero(); + + handle_event( + ev, + &mut out, + &mut ps, + &mut command_queue, + &Arc::new(AtomicBool::new(false)), + #[cfg(feature = "search")] + &UIA, + ) + .unwrap(); + assert!(ps.quit_if_one_screen); + } + + /// When `quit_if_one_screen` is false (the default), `CheckQuitIfOneScreen` + /// should be a no-op. + #[test] + #[cfg(feature = "dynamic_output")] + fn check_quit_if_one_screen_noop_when_disabled() { + let mut ps = PagerState::new().unwrap(); + // quit_if_one_screen defaults to false + let ev = Command::CheckQuitIfOneScreen; + let mut out = Vec::new(); + let mut command_queue = CommandQueue::new_zero(); + let is_exited = Arc::new(AtomicBool::new(false)); + + handle_event( + ev, + &mut out, + &mut ps, + &mut command_queue, + &is_exited, + #[cfg(feature = "search")] + &UIA, + ) + .unwrap(); + // is_exited must not have been set + assert!(!is_exited.load(std::sync::atomic::Ordering::SeqCst)); + } + + /// When `quit_if_one_screen` is true but the content overflows the screen, + /// `CheckQuitIfOneScreen` should also be a no-op. + #[test] + #[cfg(feature = "dynamic_output")] + fn check_quit_if_one_screen_noop_when_overflow() { + let mut ps = PagerState::new().unwrap(); + ps.quit_if_one_screen = true; + // In tests, rows = 10. Fill more than 10 lines so the content overflows. + let big_text: String = (0..20).map(|i| format!("line {i}\n")).collect(); + ps.screen.orig_text = big_text; + ps.format_lines(); + + let ev = Command::CheckQuitIfOneScreen; + let mut out = Vec::new(); + let mut command_queue = CommandQueue::new_zero(); + let is_exited = Arc::new(AtomicBool::new(false)); + + handle_event( + ev, + &mut out, + &mut ps, + &mut command_queue, + &is_exited, + #[cfg(feature = "search")] + &UIA, + ) + .unwrap(); + assert!(!is_exited.load(std::sync::atomic::Ordering::SeqCst)); + } } diff --git a/src/dynamic_pager.rs b/src/dynamic_pager.rs index 1b4d3f7..a83fe7c 100644 --- a/src/dynamic_pager.rs +++ b/src/dynamic_pager.rs @@ -16,5 +16,22 @@ use crate::minus_core::init; #[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) + use crate::pager::AliveGuard; + use std::sync::Arc; + // Build a new Pager whose `alive` Arc is independent of the one held by + // application-side clones. When this local Pager drops (after `init_core` + // returns) only the independent Arc is decremented, which is harmless. + // + // Dropping `pager` here decrements the application-side Arc so that the + // correct reference count is maintained: only the application-side handles + // should keep that Arc alive. + let pager_for_init = Pager { + tx: pager.tx.clone(), + rx: pager.rx.clone(), + // New, independent Arc that fires a CheckQuitIfOneScreen into an already- + // closed channel once init_core returns – the send error is silently ignored. + alive: Arc::new(AliveGuard::new(pager.tx.clone())), + }; + drop(pager); // decrement the application-side Arc (count N+1 → N) + init::init_core(&pager_for_init, crate::RunMode::Dynamic) } diff --git a/src/pager.rs b/src/pager.rs index 6545c63..081eeb2 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -3,10 +3,39 @@ use crate::{ExitStrategy, LineNumbers, error::MinusError, input, minus_core::commands::Command}; use crossbeam_channel::{Receiver, Sender}; use std::fmt; +#[cfg(feature = "dynamic_output")] +use std::sync::Arc; #[cfg(feature = "search")] use crate::search::SearchOpts; +/// Guard that detects when all application-side [`Pager`] instances have been dropped. +/// +/// When the last [`Pager`] clone held by the application is dropped, the `Arc` wrapping +/// this guard reaches a reference count of zero and its `Drop` implementation sends a +/// [`Command::CheckQuitIfOneScreen`] to the reactor. The reactor then checks whether the +/// [`quit_if_one_screen`](Pager::set_quit_if_one_screen) option is enabled and, if so, +/// whether all content fits on one screen. +#[cfg(feature = "dynamic_output")] +pub struct AliveGuard { + tx: Sender, +} + +#[cfg(feature = "dynamic_output")] +impl AliveGuard { + pub(crate) const fn new(tx: Sender) -> Self { + Self { tx } + } +} + +#[cfg(feature = "dynamic_output")] +impl Drop for AliveGuard { + fn drop(&mut self) { + // Ignore errors: the reactor may have already exited. + let _ = self.tx.send(Command::CheckQuitIfOneScreen); + } +} + /// A communication bridge between the main application and the pager. /// /// The [Pager] type which is a bridge between your application and running @@ -34,10 +63,24 @@ use crate::search::SearchOpts; /// writeln!(pager, "Hello {WHO}").unwrap(); /// // which is also equivalent to writing this /// pager.push_str(format!("Hello {WHO}\n")).unwrap(); -#[derive(Clone)] pub struct Pager { pub(crate) tx: Sender, pub(crate) rx: Receiver, + /// Shared guard that fires [`Command::CheckQuitIfOneScreen`] when all + /// application-side [`Pager`] clones are dropped. + #[cfg(feature = "dynamic_output")] + pub(crate) alive: Arc, +} + +impl Clone for Pager { + fn clone(&self) -> Self { + Self { + tx: self.tx.clone(), + rx: self.rx.clone(), + #[cfg(feature = "dynamic_output")] + alive: Arc::clone(&self.alive), + } + } } impl Pager { @@ -50,7 +93,12 @@ impl Pager { #[must_use] pub fn new() -> Self { let (tx, rx) = crossbeam_channel::unbounded(); - Self { tx, rx } + Self { + #[cfg(feature = "dynamic_output")] + alive: Arc::new(AliveGuard::new(tx.clone())), + tx, + rx, + } } /// Set the output text to this `t` @@ -217,6 +265,121 @@ impl Pager { Ok(self.tx.send(Command::SetRunNoOverflow(val))?) } + /// Automatically quit when all content fits on one screen in dynamic paging mode. + /// + /// When this is set to `true`, minus will automatically exit the pager and preserve + /// the output on the terminal screen (similar to `less -F`) once the end of output + /// is signalled **and** the content fits within the available rows. If the content + /// does not fit the pager remains open until the user quits manually. + /// + /// The end of output can be signalled in two ways: + /// + /// - **Explicitly** — call [`Pager::end_of_output`] when you have finished sending + /// data. This is the preferred approach when the caller needs to keep the [`Pager`] + /// handle alive after signalling (e.g. to receive later notifications). + /// - **Implicitly** — simply drop all application-side [`Pager`] clones. When the + /// last clone is dropped the signal is sent automatically. + /// + /// The content is always preserved on the terminal after an automatic quit, + /// regardless of the configured [`ExitStrategy`](crate::ExitStrategy). + /// + /// By default this is set to `false`. + /// + /// # Errors + /// Returns [`Err(MinusError::Communication)`](MinusError::Communication) if the + /// configuration message could not be delivered to the pager. + /// + /// # Example — explicit signal + /// ```no_run + /// # #[cfg(feature = "dynamic_output")] + /// # { + /// use minus::{Pager, dynamic_paging}; + /// + /// let pager = Pager::new(); + /// pager.set_quit_if_one_screen(true).unwrap(); + /// + /// let pager2 = pager.clone(); + /// let t = std::thread::spawn(move || dynamic_paging(pager2)); + /// + /// pager.push_str("Hello\nWorld\n").unwrap(); + /// + /// // Explicitly signal that no more data will be sent. + /// // The pager handle is still alive after this call. + /// pager.end_of_output().unwrap(); + /// + /// // If the two lines fit on one screen the pager has already exited. + /// t.join().unwrap().unwrap(); + /// # } + /// ``` + /// + /// # Example — implicit signal (drop-based) + /// ```no_run + /// # #[cfg(feature = "dynamic_output")] + /// # { + /// use minus::{Pager, dynamic_paging}; + /// + /// let pager = Pager::new(); + /// pager.set_quit_if_one_screen(true).unwrap(); + /// + /// let pager2 = pager.clone(); + /// let t = std::thread::spawn(move || dynamic_paging(pager2)); + /// + /// pager.push_str("Hello\nWorld\n").unwrap(); + /// + /// // Dropping the last application-side clone signals end-of-output. + /// drop(pager); + /// + /// t.join().unwrap().unwrap(); + /// # } + /// ``` + #[cfg(feature = "dynamic_output")] + #[cfg_attr(docsrs, doc(cfg(feature = "dynamic_output")))] + pub fn set_quit_if_one_screen(&self, val: bool) -> Result<(), MinusError> { + Ok(self.tx.send(Command::SetQuitIfOneScreen(val))?) + } + + /// Signal that the application has finished sending output. + /// + /// When [`set_quit_if_one_screen`](Pager::set_quit_if_one_screen) is enabled, calling + /// this method checks whether all buffered content fits on one screen and, if so, + /// exits the pager automatically while preserving the content on the terminal. + /// If the content does not fit the pager remains open and the user can quit manually. + /// + /// This is the explicit counterpart to the implicit drop-based signal: it lets you + /// mark the end of output while still holding the [`Pager`] handle (e.g. when the + /// caller needs to keep the handle for other purposes). + /// + /// Calling this method when `set_quit_if_one_screen` is `false` is a no-op. + /// + /// # Errors + /// Returns [`Err(MinusError::Communication)`](MinusError::Communication) if the + /// signal could not be delivered to the pager. + /// + /// # Example + /// ```no_run + /// # #[cfg(feature = "dynamic_output")] + /// # { + /// use minus::{Pager, dynamic_paging}; + /// + /// let pager = Pager::new(); + /// pager.set_quit_if_one_screen(true).unwrap(); + /// + /// let pager2 = pager.clone(); + /// let t = std::thread::spawn(move || dynamic_paging(pager2)); + /// + /// pager.push_str("Hello\nWorld\n").unwrap(); + /// pager.end_of_output().unwrap(); + /// + /// // The pager handle is still usable here if needed. + /// t.join().unwrap().unwrap(); + /// # } + /// ``` + #[cfg(feature = "dynamic_output")] + #[cfg_attr(docsrs, doc(cfg(feature = "dynamic_output")))] + pub fn end_of_output(&self) -> Result<(), MinusError> { + Ok(self.tx.send(Command::CheckQuitIfOneScreen)?) + } + /// Whether to allow scrolling horizontally /// /// Setting this to `true` implicitly disables line wrapping diff --git a/src/state.rs b/src/state.rs index 3be49fa..468d28a 100644 --- a/src/state.rs +++ b/src/state.rs @@ -87,6 +87,7 @@ impl Default for SearchState { /// Various fields are made public so that their values can be accessed while implementing the /// trait. #[allow(clippy::module_name_repetitions)] +#[allow(clippy::struct_excessive_bools)] pub struct PagerState { /// Configuration for line numbers. See [`LineNumbers`] pub line_numbers: LineNumbers, @@ -149,6 +150,9 @@ pub struct PagerState { /// Do we want to page if there is no overflow #[cfg(feature = "static_output")] pub(crate) run_no_overflow: bool, + /// Whether to automatically quit when content fits on one screen in dynamic paging mode + #[cfg(feature = "dynamic_output")] + pub(crate) quit_if_one_screen: bool, pub(crate) lines_to_row_map: LinesRowMap, /// Value for follow mode. /// See [follow_output](crate::pager::Pager::follow_output) for more info on follow mode. @@ -194,6 +198,8 @@ impl PagerState { show_prompt: true, #[cfg(feature = "static_output")] run_no_overflow: false, + #[cfg(feature = "dynamic_output")] + quit_if_one_screen: false, #[cfg(feature = "search")] search_mode: SearchMode::default(), #[cfg(feature = "search")] diff --git a/src/tests.rs b/src/tests.rs index c2becad..9f2b57a 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -326,4 +326,23 @@ mod emit_events { assert_eq!(Command::AddExitCallback(func), pager.rx.try_recv().unwrap()); } + + #[test] + #[cfg(feature = "dynamic_output")] + fn set_quit_if_one_screen() { + let pager = Pager::new(); + pager.set_quit_if_one_screen(true).unwrap(); + assert_eq!( + Command::SetQuitIfOneScreen(true), + pager.rx.try_recv().unwrap() + ); + } + + #[test] + #[cfg(feature = "dynamic_output")] + fn end_of_output() { + let pager = Pager::new(); + pager.end_of_output().unwrap(); + assert_eq!(Command::CheckQuitIfOneScreen, pager.rx.try_recv().unwrap()); + } }