diff --git a/CHANGELOG.md b/CHANGELOG.md index 149e2bb4..7a2efcf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 4c0544dd..92ea0b0d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/core/commands.rs b/src/core/commands.rs index ec6c2c63..36b94ad0 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -48,6 +48,7 @@ pub enum Command { // Screen output configurations LineWrapping(bool), SetLineNumbers(LineNumbers), + SetFrozenLines(usize), FollowOutput(bool), SetOutputSink(Box), @@ -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")] @@ -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"), diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 4ab23541..0f428690 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -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 { diff --git a/src/core/utils/display/mod.rs b/src/core/utils/display/mod.rs index 095d85d7..ed44787d 100644 --- a/src/core/utils/display/mod.rs +++ b/src/core/utils/display/mod.rs @@ -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. @@ -265,18 +278,29 @@ pub fn write_text_checked>( 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>( diff --git a/src/core/utils/display/tests.rs b/src/core/utils/display/tests.rs index c96c7dbf..4de4e931 100644 --- a/src/core/utils/display/tests.rs +++ b/src/core/utils/display/tests.rs @@ -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}; diff --git a/src/pager.rs b/src/pager.rs index 3ec7c83e..50b98aed 100644 --- a/src/pager.rs +++ b/src/pager.rs @@ -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 diff --git a/src/state.rs b/src/state.rs index 6c275067..9de92b2f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -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 @@ -215,6 +220,7 @@ impl PagerState { prompt, running: &minus_core::RUNMODE, left_mark: 0, + frozen_lines: 0, input_classifier: Box::>::default(), exit_callbacks: Vec::with_capacity(5), hooks: Hooks::new(),