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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Changelog
This file documents all changes made to the project and is updated before each release.

## Unreleased
### Added
* Add `Pager::set_frozen_lines` to keep the first `n` lines (e.g. a header) fixed while the rest of the output scrolls beneath them.

## v5.8.0 [2026-09-19]
### Added
* [#164](https://github.com/arijit79/minus/pull/167): Introduce the `OutputSink` to allow custom output sinks like stderr, files, etc.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ downloaded from the internet.
colors, emphasis etc. This is not a minus-specific problem but rather its how terminals behave and is inherently limited because of their design**
- Follow output mode
This feature ensures that you always see the last line as the data is being pushed onto the pager's buffer.
- Frozen (sticky) lines
Keep the first `n` lines, such as a table header, fixed at the top while the rest of the output scrolls beneath them via [`Pager::set_frozen_lines`].
- Full [regex](https://docs.rs/regex) based searching.
Which also fully takes care of escape sequences. Also supports incremental searching of text as you type.
- Tries to be very minimal on dependencies.
Expand Down
3 changes: 3 additions & 0 deletions src/core/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub enum Command {
// Screen output configurations
LineWrapping(bool),
SetLineNumbers(LineNumbers),
SetFrozenLines(usize),
FollowOutput(bool),
SetOutputSink(Box<dyn OutputSink>),

Expand All @@ -74,6 +75,7 @@ impl PartialEq for Command {
| (Self::SendMessage(d1), Self::SendMessage(d2)) => d1 == d2,
(Self::LineWrapping(d1), Self::LineWrapping(d2)) => d1 == d2,
(Self::SetLineNumbers(d1), Self::SetLineNumbers(d2)) => d1 == d2,
(Self::SetFrozenLines(d1), Self::SetFrozenLines(d2)) => d1 == d2,
(Self::ShowPrompt(d1), Self::ShowPrompt(d2)) => d1 == d2,
(Self::SetExitStrategy(d1), Self::SetExitStrategy(d2)) => d1 == d2,
#[cfg(feature = "static_output")]
Expand All @@ -99,6 +101,7 @@ impl Debug for Command {
Self::SetPrompt(text) => write!(f, "SetPrompt({text:?})"),
Self::SendMessage(text) => write!(f, "SendMessage({text:?})"),
Self::SetLineNumbers(ln) => write!(f, "SetLineNumbers({ln:?})"),
Self::SetFrozenLines(n) => write!(f, "SetFrozenLines({n})"),
Self::LineWrapping(lw) => write!(f, "LineWrapping({lw:?})"),
Self::SetExitStrategy(es) => write!(f, "SetExitStrategy({es:?})"),
Self::SetInputClassifier(_) => write!(f, "SetInputClassifier"),
Expand Down
4 changes: 4 additions & 0 deletions src/core/ev_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,10 @@ pub fn handle_event(
p.reformat_display();
command_queue.push_back(Command::Io(IoCommand::RedrawDisplay));
}
Command::SetFrozenLines(n) => {
p.frozen_lines = n;
command_queue.push_back(Command::Io(IoCommand::RedrawDisplay));
}
Command::SetExitStrategy(es) => {
p.hooks.remove_callback(Hook::PostPagerExit, 1);
if es == ExitStrategy::ProcessQuit {
Expand Down
42 changes: 33 additions & 9 deletions src/core/utils/display/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ pub fn draw_for_change(
) -> Result<(), MinusError> {
let line_count = ps.screen.formatted_lines_count();

// With a sticky header the scroll region excludes the frozen lines, which
// makes the partial scroll optimization below incorrect. Fall back to a full
// redraw, which renders the frozen header followed by the scrolled content.
if ps.frozen_lines > 0 {
let frozen = ps.frozen_lines.min(line_count);
let writable_rows = ps.rows.saturating_sub(1).saturating_sub(frozen);
let scrollable = line_count.saturating_sub(frozen);
let max_upper = frozen.saturating_add(scrollable.saturating_sub(writable_rows));
*new_upper_mark = (*new_upper_mark).clamp(frozen, max_upper);
ps.upper_mark = *new_upper_mark;
return draw_full(out, ps);
}

// Reduce one row for prompt/messages
//
// NOTE This should be the value of rows that should be used throughout this function.
Expand Down Expand Up @@ -265,18 +278,29 @@ pub fn write_text_checked<L: Display + AsRef<str>>(
pub fn write_from_pagerstate(out: &mut impl Write, ps: &mut PagerState) -> Result<(), MinusError> {
let line_count = ps.screen.formatted_lines_count();

// Reduce one row for prompt/messages
let writable_rows = ps.rows.saturating_sub(1);
// Number of rows frozen at the top of the screen (sticky header)
let frozen = ps.frozen_lines.min(line_count);

// Calculate the lower_mark by adding either the rows or line_count depending
// on the minimality
let lower_mark = ps.upper_mark.saturating_add(writable_rows.min(line_count));
if lower_mark > line_count {
ps.upper_mark = line_count.saturating_sub(writable_rows);
// Reduce one row for prompt/messages and the frozen rows for the header
let writable_rows = ps.rows.saturating_sub(1).saturating_sub(frozen);

// Clamp the upper mark into the scrollable content region
let scrollable = line_count.saturating_sub(frozen);
let max_upper = frozen.saturating_add(scrollable.saturating_sub(writable_rows));
if ps.upper_mark < frozen {
ps.upper_mark = frozen;
}
if ps.upper_mark > max_upper {
ps.upper_mark = max_upper;
}

let display_lines = ps.render_rows_for_display(ps.upper_mark, lower_mark);
write_raw_lines(out, &display_lines, Some("\r"))
let lower_mark = ps.upper_mark.saturating_add(writable_rows).min(line_count);

let header_lines = ps.render_rows_for_display(0, frozen);
write_raw_lines(out, &header_lines, Some("\r"))?;

let content_lines = ps.render_rows_for_display(ps.upper_mark, lower_mark);
write_raw_lines(out, &content_lines, Some("\r"))
}

pub fn write_lines<L: Display + AsRef<str>>(
Expand Down
31 changes: 31 additions & 0 deletions src/core/utils/display/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,37 @@ fn test_draw_no_overflow() {
);
}

#[test]
fn frozen_lines_stay_at_top_while_scrolling() {
// The pager assumes a 80x10 terminal in tests
let mut pager = PagerState::new().unwrap();
pager.screen.orig_text = (0..20).fold(String::new(), |mut t, i| {
let _ = writeln!(t, "line {i:02}");
t
});
pager.reformat_display();
pager.frozen_lines = 2;

// Before any scrolling the header rows are shown followed by the content.
let mut out = Vec::new();
write_from_pagerstate(&mut out, &mut pager).unwrap();
let rendered = String::from_utf8(out).expect("Should have written valid UTF-8");
assert!(rendered.starts_with("\rline 00\n\rline 01\n"));
// upper_mark is clamped to the frozen region so the header is not scrolled away
assert_eq!(pager.upper_mark, 2);

// After scrolling down the two frozen header rows must still be rendered first.
pager.upper_mark = 6;
let mut out = Vec::new();
write_from_pagerstate(&mut out, &mut pager).unwrap();
let rendered = String::from_utf8(out).expect("Should have written valid UTF-8");
assert!(rendered.starts_with("\rline 00\n\rline 01\n"));
assert!(rendered.contains("line 06\n"));
// The header rows occupy 2 of the 9 writable rows, so 7 content rows are shown.
let content_rows = rendered.lines().skip(2).count();
assert_eq!(content_rows, 7);
}

#[cfg(test)]
mod draw_for_change_tests {
use super::{draw_for_change, write_prompt};
Expand Down
27 changes: 27 additions & 0 deletions src/pager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ impl Pager {
Ok(self.tx.send(Command::SetLineNumbers(l))?)
}

/// Set the number of lines at the top of the output that stay fixed while
/// the remaining content scrolls beneath them.
///
/// This is useful for keeping a header row (or rows) visible while scrolling
/// through long output, similar to a sticky table header.
///
/// Note that when `n > 0` scrolling falls back to a full redraw instead of
/// the incremental partial-scroll optimization, because the frozen region
/// makes the incremental path incorrect.
///
/// Pass `0` to disable the sticky header (the default).
///
/// # 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_frozen_lines(1).expect("Failed to communicate with the pager");
/// ```
pub fn set_frozen_lines(&self, n: usize) -> Result<(), MinusError> {
Ok(self.tx.send(Command::SetFrozenLines(n))?)
}

/// Set the text displayed at the bottom prompt
///
/// # Panics
Expand Down
6 changes: 6 additions & 0 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ pub struct PagerState {
///
/// When this is `> 0`, this amount of text will be truncated from the left side
pub left_mark: usize,
/// Number of lines at the top of the buffer that stay fixed (sticky) while
/// the remaining content scrolls beneath them.
///
/// A value of `0` disables the sticky header and keeps the default behaviour.
pub frozen_lines: usize,
/// Direction of search
///
/// See [`SearchMode`] for available options
Expand Down Expand Up @@ -215,6 +220,7 @@ impl PagerState {
prompt,
running: &minus_core::RUNMODE,
left_mark: 0,
frozen_lines: 0,
input_classifier: Box::<HashedEventRegister<RandomState>>::default(),
exit_callbacks: Vec::with_capacity(5),
hooks: Hooks::new(),
Expand Down