Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/core/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub enum Command {
AddExitCallback(Box<dyn FnMut() + Send + Sync + 'static>),
#[cfg(feature = "static_output")]
SetRunNoOverflow(bool),
#[cfg(feature = "dynamic_output")]
SetQuitIfOneScreen(bool),
#[cfg(feature = "dynamic_output")]
CheckQuitIfOneScreen,
#[cfg(feature = "search")]
IncrementalSearchCondition(Box<dyn Fn(&SearchOpts) -> bool + Send + Sync + 'static>),

Expand All @@ -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")]
Expand Down Expand Up @@ -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:?})"),
}
Expand Down
98 changes: 98 additions & 0 deletions src/core/ev_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
}
19 changes: 18 additions & 1 deletion src/dynamic_pager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
167 changes: 165 additions & 2 deletions src/pager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Command>,
}

#[cfg(feature = "dynamic_output")]
impl AliveGuard {
pub(crate) const fn new(tx: Sender<Command>) -> 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
Expand Down Expand Up @@ -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<Command>,
pub(crate) rx: Receiver<Command>,
/// Shared guard that fires [`Command::CheckQuitIfOneScreen`] when all
/// application-side [`Pager`] clones are dropped.
#[cfg(feature = "dynamic_output")]
pub(crate) alive: Arc<AliveGuard>,
}

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 {
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")]
Expand Down
Loading