From 0e46db7d9d0b1518578bff95cb633b0480a69e12 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Mon, 21 Sep 2026 08:25:44 +0530 Subject: [PATCH 1/3] feat: data source trait rework --- src/lib.rs | 1 + src/source/mod.rs | 304 ++++++++++++++++++++++++++++++++++++++++++++ src/source/tests.rs | 230 +++++++++++++++++++++++++++++++++ 3 files changed, 535 insertions(+) create mode 100644 src/source/mod.rs create mode 100644 src/source/tests.rs diff --git a/src/lib.rs b/src/lib.rs index f73ddc5..45a886d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -201,6 +201,7 @@ pub mod screen; #[cfg_attr(docsrs, doc(cfg(feature = "search")))] pub mod search; pub mod sink; +pub mod source; pub mod state; #[cfg(feature = "static_output")] mod static_pager; diff --git a/src/source/mod.rs b/src/source/mod.rs new file mode 100644 index 0000000..1d76bb0 --- /dev/null +++ b/src/source/mod.rs @@ -0,0 +1,304 @@ +//! Provides the [`DataSource`] trait and built-in sources for on-demand text loading. +//! +//! ## Motivation +//! Storing the entire text inside minus twice (once as the original text and once as the +//! formatted rows destined for the terminal) does not scale for very large inputs: the whole +//! text may simply not fit into memory. The [`DataSource`] trait solves this by letting +//! end-applications define where the text comes from and how minus loads it on demand. minus +//! keeps only a small cache of the rows currently needed for display and asks the source for +//! lines whenever it needs more. +//! +//! # Implementing a custom source +//! Any type that can answer "how many lines do you have?" and "give me line N" can be a data +//! source — a file on disk, a memory-mapped region, a database, a stream, etc. The trait is +//! object-safe, so sources are handed to the pager as `Box`. +//! +//! minus is a pure *reader* of a data source: it never writes text into it. Sources that grow +//! over time (dynamic feeds) are fed by the application itself through its own means; minus +//! notices new data by re-checking [`DataSource::line_count`] and the tail state while the +//! pager runs. +//! +//! # Example +//! ``` +//! use minus::source::{DataSource, InMemorySource}; +//! +//! let source = InMemorySource::from("Hello\nWorld\n"); +//! assert_eq!(source.line_count(), 2); +//! assert_eq!(source.line(0).as_deref(), Some("Hello")); +//! ``` +//! +//! Sources that are fed while the pager runs can be shared across threads by wrapping them +//! in a mutex: +//! ``` +//! use minus::source::{DataSource, InMemorySource}; +//! use parking_lot::Mutex; +//! use std::sync::Arc; +//! +//! let source = Arc::new(Mutex::new(InMemorySource::new())); +//! // `Arc>` implements `DataSource` itself +//! let dyn_source: Box = Box::new(source.clone()); +//! +//! source.lock().append("Hello\n"); +//! assert_eq!(dyn_source.line_count(), 1); +//! assert_eq!(dyn_source.line(0).as_deref(), Some("Hello")); +//! ``` + +use std::{borrow::Cow, fmt, sync::Arc}; + +use parking_lot::Mutex; + +/// Defines how minus loads text data on demand +/// +/// Implementing this trait allows end-applications to plug their own text origins into minus +/// instead of handing over the entire text at once. This removes the need for minus to hold +/// the whole text in memory twice and makes it possible to page inputs that would not fit +/// into memory at all. +/// +/// # Text model +/// Lines are the unit of everything inside minus: line numbers, wrapping, search and +/// selection all operate on lines. A line is text that must not contain any newline (`\n`) +/// inside it but may or may not end with one. This trait follows the same model: +/// [`DataSource::line`] returns a line without its trailing newline, exactly like +/// [`str::lines`] does (a trailing `\n` and a `\r` immediately preceding it are stripped). +/// +/// # Requirements +/// The trait is object-safe and requires [`Send`], [`Sync`] and [`'static`](std::marker) so +/// that a `Box` can be shared across the threads the pager runs on. All +/// methods can be called from any thread at any time while the pager is running, so +/// implementations backed by IO or locks should keep their critical sections small. +/// +/// # Defaults +/// * [`DataSource::last_line_terminated`] defaults to `true`: the source's last line is +/// considered final unless the implementation says otherwise. minus uses this to know +/// whether incoming data merges into the last line or starts a new one. +/// * [`DataSource::is_complete`] defaults to `false`: minus keeps re-checking the source for +/// new data while the pager runs. Implementations that know no more data will ever arrive +/// (for example a fully-read file) can return `true` to skip these checks. +#[allow(clippy::module_name_repetitions)] +pub trait DataSource: Send + Sync + 'static { + /// Number of logical lines currently available + #[must_use] + fn line_count(&self) -> usize; + + /// Line at 0-based `idx`, without trailing newline (mimics [`str::lines`]: strips a + /// trailing `\n` and a `\r` immediately preceding it) + /// + /// Returns [`None`] if `idx` is out of bounds. + #[must_use] + fn line(&self, idx: usize) -> Option>; + + /// Whether the last line may still receive more text (mid-line append) + #[must_use] + fn last_line_terminated(&self) -> bool { + true + } + + /// Whether no more data will ever arrive (lets minus skip diff polling) + #[must_use] + fn is_complete(&self) -> bool { + false + } +} + +/// An in-memory [`DataSource`] thatborrow::Cow, stores the text exactly once +/// +/// This is the default source minus uses: text fed through appends is stored here as a +/// single [`String`] with a byte-offset index over the line starts, making random access to +/// any line cheap while keeping the memory usage to the text itself. +/// +/// # Example +/// ``` +/// use minus::source::{DataSource, InMemorySource}; +/// use std::fmt::Write; +/// +/// let mut source = InMemorySource::new(); +/// write!(source, "Hello {}", "World").unwrap(); +/// // Appends merge into an unterminated last line +/// source.append(" and everyone"); +/// assert_eq!(source.line_count(), 1); +/// assert_eq!(source.line(0).as_deref(), Some("Hello World and everyone")); +/// ``` +#[allow(clippy::module_name_repetitions)] +#[derive(Clone, Debug, Default)] +pub struct InMemorySource { + /// The entire text stored exactly once + text: String, + /// Byte offset of the start of each line + line_starts: Vec, + /// Whether no more data will ever arrive + complete: bool, +} + +impl InMemorySource { + /// Create an empty source + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Append raw text to the end of the source + /// + /// If the last line of the source is unterminated (i.e, the source is not empty and does + /// not end with a newline), the incoming text is part of that line so it merges into it. + /// Otherwise the text starts a new line. An empty text changes nothing. + pub fn append(&mut self, s: &str) { + if s.is_empty() { + return; + } + + // If the source is empty or its last line is terminated, the incoming text starts a + // new line at the current end of the text. Otherwise it merges into the unterminated + // last line and no line start is recorded for it. + if self.text.is_empty() || self.text.ends_with('\n') { + self.line_starts.push(self.text.len()); + } + + let append_start = self.text.len(); + self.text.push_str(s); + + // Every newline inside the appended text that is not the very last byte of the + // resulting text starts a new line right after it. A newline at the very end only + // becomes the start of a line once more text is appended after it. + for (idx, byte) in s.bytes().enumerate() { + if byte == b'\n' { + let pos = append_start + idx; + if pos + 1 < self.text.len() { + self.line_starts.push(pos + 1); + } + } + } + } + + /// Replace the entire text with `s` + /// + /// This resets everything including the completion state — replacing the content voids a + /// previous [`Self::finish`] promise, so the source becomes incomplete again until + /// [`Self::finish`] is called. + pub fn replace(&mut self, s: &str) { + self.text.clear(); + self.line_starts.clear(); + self.complete = false; + self.append(s); + } + + /// Mark the source as complete, i.e, no more data will ever arrive + /// + /// The last line stays as it is. minus uses this to skip re-checking the source for new + /// data while the pager runs. + pub fn finish(&mut self) { + self.complete = true; + } +} + +impl fmt::Write for InMemorySource { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.append(s); + Ok(()) + } +} + +impl From for InMemorySource +where + T: AsRef, +{ + fn from(s: T) -> Self { + let mut source = Self::new(); + source.append(s.as_ref()); + source + } +} + +/// Each element is appended as raw text in order, consistent with [`InMemorySource::append`]. +/// Collecting elements that do not end with a newline merges them into one line. +impl FromIterator for InMemorySource { + fn from_iter>(iter: I) -> Self { + let mut source = Self::new(); + for s in iter { + source.append(&s); + } + source + } +} + +impl DataSource for InMemorySource { + fn line_count(&self) -> usize { + self.line_starts.len() + } + + fn line(&self, idx: usize) -> Option> { + let start = *self.line_starts.get(idx)?; + // The line ends where the next line starts, or at the end of the text for the last + // line. + let end = self + .line_starts + .get(idx + 1) + .copied() + .unwrap_or(self.text.len()); + // Mimic `str::lines()`: strip the trailing '\n' of a terminated line and a '\r' + // immediately preceding it. The `end > start` guard keeps an empty terminated line + // from stripping into the preceding line's terminator. + let bytes = self.text.as_bytes(); + let mut end = end; + if end > start && bytes[end - 1] == b'\n' { + end -= 1; + if end > start && bytes[end - 1] == b'\r' { + end -= 1; + } + } + Some(Cow::Borrowed(&self.text[start..end])) + } + + fn last_line_terminated(&self) -> bool { + self.text.is_empty() || self.text.ends_with('\n') + } + + fn is_complete(&self) -> bool { + self.complete + } +} + +impl DataSource for Mutex { + fn line_count(&self) -> usize { + self.lock().line_count() + } + + fn line(&self, idx: usize) -> Option> { + // The MutexGuard temporary cannot outlive this call, so the borrowed variant of the + // inner source cannot escape the lock. Materialize the line into an owned Cow + // instead. + self.lock() + .line(idx) + .map(|cow| Cow::Owned(cow.into_owned())) + } + + fn last_line_terminated(&self) -> bool { + self.lock().last_line_terminated() + } + + fn is_complete(&self) -> bool { + self.lock().is_complete() + } +} + +impl DataSource for Arc { + fn line_count(&self) -> usize { + self.as_ref().line_count() + } + + fn line(&self, idx: usize) -> Option> { + // The data lives as long as the Arc, so the borrowed variant can be returned + // directly without copying. + self.as_ref().line(idx) + } + + fn last_line_terminated(&self) -> bool { + self.as_ref().last_line_terminated() + } + + fn is_complete(&self) -> bool { + self.as_ref().is_complete() + } +} + +#[cfg(test)] +mod tests; diff --git a/src/source/tests.rs b/src/source/tests.rs new file mode 100644 index 0000000..e236113 --- /dev/null +++ b/src/source/tests.rs @@ -0,0 +1,230 @@ +use std::{fmt::Write, sync::Arc}; + +use parking_lot::Mutex; + +use super::{DataSource, InMemorySource}; + +#[test] +fn empty_source() { + let src = InMemorySource::new(); + assert_eq!(src.line_count(), 0); + assert_eq!(src.line(0), None); + assert!(src.last_line_terminated()); + assert!(!src.is_complete()); +} + +#[test] +fn append_starts_new_line_after_terminated() { + let mut src = InMemorySource::new(); + src.append("abc\n"); + assert_eq!(src.line_count(), 1); + assert_eq!(src.line(0).as_deref(), Some("abc")); + assert!(src.last_line_terminated()); + + src.append("def\n"); + assert_eq!(src.line_count(), 2); + assert_eq!(src.line(1).as_deref(), Some("def")); + assert!(src.last_line_terminated()); +} + +#[test] +fn append_merges_into_unterminated_line() { + let mut src = InMemorySource::new(); + src.append("abc"); + assert_eq!(src.line_count(), 1); + assert_eq!(src.line(0).as_deref(), Some("abc")); + assert!(!src.last_line_terminated()); + + // The incoming text is part of the unterminated last line + src.append("def\n"); + assert_eq!(src.line_count(), 1); + assert_eq!(src.line(0).as_deref(), Some("abcdef")); + assert!(src.last_line_terminated()); + + src.append("ghi\n"); + assert_eq!(src.line_count(), 2); + assert_eq!(src.line(1).as_deref(), Some("ghi")); +} + +#[test] +fn append_multiline_merges_first_line() { + let mut src = InMemorySource::new(); + src.append("abc"); + src.append("def\nghi\n"); + assert_eq!(src.line_count(), 2); + assert_eq!(src.line(0).as_deref(), Some("abcdefghi")); + assert_eq!(src.line(1).as_deref(), Some("ghi")); + assert!(src.last_line_terminated()); +} + +#[test] +fn append_empty_text_is_noop() { + let mut src = InMemorySource::new(); + src.append("abc\n"); + src.append(""); + assert_eq!(src.line_count(), 1); + assert_eq!(src.line(0).as_deref(), Some("abc")); + assert!(src.last_line_terminated()); +} + +#[test] +fn line_strips_line_endings_like_str_lines() { + let src = InMemorySource::from("a\r\nb\nc"); + assert_eq!(src.line(0).as_deref(), Some("a")); + assert_eq!(src.line(1).as_deref(), Some("b")); + assert_eq!(src.line(2).as_deref(), Some("c")); + assert_eq!(src.line(3), None); + assert_eq!(src.line_count(), 3); + assert!(!src.last_line_terminated()); +} + +#[test] +fn line_handles_empty_lines() { + let src = InMemorySource::from("\n\n"); + assert_eq!(src.line_count(), 2); + assert_eq!(src.line(0).as_deref(), Some("")); + assert_eq!(src.line(1).as_deref(), Some("")); + + let src = InMemorySource::from("a\n\nb\n"); + assert_eq!(src.line_count(), 3); + assert_eq!(src.line(0).as_deref(), Some("a")); + assert_eq!(src.line(1).as_deref(), Some("")); + assert_eq!(src.line(2).as_deref(), Some("b")); +} + +#[test] +fn line_handles_lone_carriage_return() { + // A '\r' not followed by a '\n' is not a line terminator + let src = InMemorySource::from("a\rb\n"); + assert_eq!(src.line_count(), 1); + assert_eq!(src.line(0).as_deref(), Some("a\rb")); +} + +#[test] +fn line_handles_multibyte_utf8() { + let src = InMemorySource::from("▲▼\n日本語\n"); + assert_eq!(src.line(0).as_deref(), Some("▲▼")); + assert_eq!(src.line(1).as_deref(), Some("日本語")); + assert_eq!(src.line(2), None); +} + +#[test] +fn line_matches_str_lines_randomized() { + // Deterministic pseudo-random text covering varied line content, line + // endings and a final unterminated line + let mut text = String::new(); + let mut state = 0x5eed_1234_u64; + let mut next = move || { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + state + }; + for _ in 0..500 { + let len = (next() % 20) as usize; + for _ in 0..len { + let ch = match next() % 5 { + 0 => 'a', + 1 => 'Z', + 2 => '▲', + 3 => ' ', + _ => '\n', + }; + text.push(ch); + } + // Randomly terminate the block so that the last line is sometimes + // unterminated + if next() % 4 == 0 { + text.push('\n'); + } + } + + let src = InMemorySource::from(&text); + let expected = text.lines().collect::>(); + assert_eq!(src.line_count(), expected.len()); + for (idx, line) in expected.iter().enumerate() { + assert_eq!(src.line(idx).as_deref(), Some(*line)); + } +} + +#[test] +fn replace_resets_everything() { + let mut src = InMemorySource::from("old\ntext\n"); + src.finish(); + assert!(src.is_complete()); + + src.replace("new\n"); + assert_eq!(src.line_count(), 1); + assert_eq!(src.line(0).as_deref(), Some("new")); + assert!(src.last_line_terminated()); + // Replacing voids the previous completion promise + assert!(!src.is_complete()); +} + +#[test] +fn finish_marks_complete() { + let mut src = InMemorySource::from("abc\n"); + assert!(!src.is_complete()); + src.finish(); + assert!(src.is_complete()); +} + +#[test] +fn from_impls() { + let src = InMemorySource::from("a\nb\n"); + assert_eq!(src.line_count(), 2); + + let src = InMemorySource::from(String::from("a\nb\n")); + assert_eq!(src.line_count(), 2); + + // Each element is appended as raw text in order + let src = ["a\n", "b\n"] + .into_iter() + .map(String::from) + .collect::(); + assert_eq!(src.line_count(), 2); + assert_eq!(src.line(0).as_deref(), Some("a")); + assert_eq!(src.line(1).as_deref(), Some("b")); +} + +#[test] +fn fmt_write_appends() { + let mut src = InMemorySource::new(); + writeln!(src, "Hello {}", "World").unwrap(); + writeln!(src, "second line").unwrap(); + assert_eq!(src.line_count(), 2); + assert_eq!(src.line(0).as_deref(), Some("Hello World")); + assert_eq!(src.line(1).as_deref(), Some("second line")); +} + +#[test] +fn shared_source_through_mutex_lock() { + let source = Arc::new(Mutex::new(InMemorySource::new())); + let dyn_source: Box = Box::new(source.clone()); + + assert_eq!(dyn_source.line_count(), 0); + assert_eq!(dyn_source.line(0), None); + assert!(dyn_source.last_line_terminated()); + + source.lock().append("abc\n"); + assert_eq!(dyn_source.line_count(), 1); + assert_eq!(dyn_source.line(0).as_deref(), Some("abc")); + + // Merging through the lock is visible through the trait object + source.lock().append("def"); + assert_eq!(dyn_source.line_count(), 1); + assert_eq!(dyn_source.line(0).as_deref(), Some("abcdef")); + assert!(!dyn_source.last_line_terminated()); +} + +#[test] +fn shared_source_through_arc_zero_copy() { + let source = Arc::new(InMemorySource::from("abc\ndef\n")); + let dyn_source: Box = Box::new(source.clone()); + + assert_eq!(dyn_source.line_count(), 2); + assert_eq!(dyn_source.line(0).as_deref(), Some("abc")); + assert_eq!(dyn_source.line(1).as_deref(), Some("def")); + assert!(dyn_source.last_line_terminated()); + assert!(!dyn_source.is_complete()); +} From 344027bf0e64fcb79837a2de8278adf09e15adf6 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Wed, 30 Sep 2026 06:42:58 +0530 Subject: [PATCH 2/3] fix: failing tests --- src/source/mod.rs | 2 +- src/source/tests.rs | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/source/mod.rs b/src/source/mod.rs index 1d76bb0..5650097 100644 --- a/src/source/mod.rs +++ b/src/source/mod.rs @@ -100,7 +100,7 @@ pub trait DataSource: Send + Sync + 'static { } } -/// An in-memory [`DataSource`] thatborrow::Cow, stores the text exactly once +/// An in-memory [`DataSource`] that stores the text exactly once /// /// This is the default source minus uses: text fed through appends is stored here as a /// single [`String`] with a byte-offset index over the line starts, making random access to diff --git a/src/source/tests.rs b/src/source/tests.rs index e236113..ad9c54f 100644 --- a/src/source/tests.rs +++ b/src/source/tests.rs @@ -52,7 +52,8 @@ fn append_multiline_merges_first_line() { src.append("abc"); src.append("def\nghi\n"); assert_eq!(src.line_count(), 2); - assert_eq!(src.line(0).as_deref(), Some("abcdefghi")); + // The first line of the appended text merges into the unterminated last line + assert_eq!(src.line(0).as_deref(), Some("abcdef")); assert_eq!(src.line(1).as_deref(), Some("ghi")); assert!(src.last_line_terminated()); } @@ -206,15 +207,21 @@ fn shared_source_through_mutex_lock() { assert_eq!(dyn_source.line(0), None); assert!(dyn_source.last_line_terminated()); - source.lock().append("abc\n"); + source.lock().append("abc"); assert_eq!(dyn_source.line_count(), 1); assert_eq!(dyn_source.line(0).as_deref(), Some("abc")); + assert!(!dyn_source.last_line_terminated()); // Merging through the lock is visible through the trait object - source.lock().append("def"); + source.lock().append("def\n"); assert_eq!(dyn_source.line_count(), 1); assert_eq!(dyn_source.line(0).as_deref(), Some("abcdef")); - assert!(!dyn_source.last_line_terminated()); + assert!(dyn_source.last_line_terminated()); + + // Appending a new line after a terminated one is visible too + source.lock().append("ghi\n"); + assert_eq!(dyn_source.line_count(), 2); + assert_eq!(dyn_source.line(1).as_deref(), Some("ghi")); } #[test] From e6adea709d9dd03dcb591c3a462f7cccc784d7f0 Mon Sep 17 00:00:00 2001 From: Arijit Dey Date: Wed, 30 Sep 2026 12:24:45 +0530 Subject: [PATCH 3/3] feat: pull data from the active source to display --- src/core/commands.rs | 22 ++---- src/core/ev_handler.rs | 119 ++++++++++---------------------- src/core/init.rs | 61 +++++++++++----- src/core/utils/display/mod.rs | 56 --------------- src/core/utils/display/tests.rs | 33 ++++----- src/core/utils/mod.rs | 12 +--- 6 files changed, 104 insertions(+), 199 deletions(-) diff --git a/src/core/commands.rs b/src/core/commands.rs index ec6c2c6..b7a222c 100644 --- a/src/core/commands.rs +++ b/src/core/commands.rs @@ -9,21 +9,16 @@ use crate::{ ExitStrategy, LineNumbers, OutputSink, hooks::{Hook, HookCallback}, input::{InputClassifier, InputEvent}, - minus_core::utils::display::AppendStyle, + source::DataSource, }; #[cfg(feature = "search")] use crate::search::SearchOpts; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Copy, Clone)] pub enum IoCommand { RedrawPrompt, RedrawDisplay, - /// Append text to the screen - /// - /// First item corresponds to the value of unterminated lines before the text is formatted while - /// the second value corresponds to the total number of rows before formatting. - DrawAppendedText(usize, usize, AppendStyle), SetUpperMark(usize), #[cfg(feature = "search")] FetchSearchQuery, @@ -37,8 +32,7 @@ pub enum Command { UserInput(InputEvent), // Data related - AppendData(String), - SetData(String), + SetDataSource(Box), // Prompt related SendMessage(String), @@ -68,9 +62,7 @@ pub enum Command { impl PartialEq for Command { fn eq(&self, other: &Self) -> bool { match (self, other) { - (Self::SetData(d1), Self::SetData(d2)) - | (Self::AppendData(d1), Self::AppendData(d2)) - | (Self::SetPrompt(d1), Self::SetPrompt(d2)) + (Self::SetPrompt(d1), Self::SetPrompt(d2)) | (Self::SendMessage(d1), Self::SendMessage(d2)) => d1 == d2, (Self::LineWrapping(d1), Self::LineWrapping(d2)) => d1 == d2, (Self::SetLineNumbers(d1), Self::SetLineNumbers(d2)) => d1 == d2, @@ -81,7 +73,8 @@ impl PartialEq for Command { (Self::SetInputClassifier(_), Self::SetInputClassifier(_)) | (Self::AddExitCallback(_), Self::AddExitCallback(_)) | (Self::AddHook(..), Self::AddHook(..)) - | (Self::SetOutputSink(_), Self::SetOutputSink(_)) => true, + | (Self::SetOutputSink(_), Self::SetOutputSink(_)) + | (Self::SetDataSource(_), Self::SetDataSource(_)) => true, (Self::RemoveHook(h1, id1), Self::RemoveHook(h2, id2)) => h1 == h2 && id1 == id2, #[cfg(feature = "search")] (Self::IncrementalSearchCondition(_), Self::IncrementalSearchCondition(_)) => true, @@ -94,8 +87,7 @@ impl PartialEq for Command { impl Debug for Command { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::SetData(text) => write!(f, "SetData({text:?})"), - Self::AppendData(text) => write!(f, "AppendData({text:?})"), + Self::SetDataSource(_) => write!(f, "SetDataSource"), Self::SetPrompt(text) => write!(f, "SetPrompt({text:?})"), Self::SendMessage(text) => write!(f, "SendMessage({text:?})"), Self::SetLineNumbers(ln) => write!(f, "SetLineNumbers({ln:?})"), diff --git a/src/core/ev_handler.rs b/src/core/ev_handler.rs index 4ab2354..b13d594 100644 --- a/src/core/ev_handler.rs +++ b/src/core/ev_handler.rs @@ -9,7 +9,7 @@ use parking_lot::{Condvar, Mutex}; use super::CommandQueue; use super::commands::{Command, IoCommand}; -use super::utils::display::{self, AppendStyle}; +use super::utils::display::{self}; use crate::ExitStrategy; #[cfg(feature = "search")] use crate::search; @@ -32,14 +32,14 @@ pub fn handle_event( is_exited: &Arc, ) { match ev { - Command::SetData(text) => { - if let Some(ref mut hs) = p.help_state { - hs.screen.orig_text = text; - hs.screen.line_count = hs.screen.orig_text.lines().count(); + Command::SetDataSource(source) => { + if let Some(hs) = &mut p.help_state { + // The help screen owns the data source while it is active; remember the new + // source so it takes effect when help exits + hs.data_source = source; return; } - p.screen.orig_text = text; - p.screen.line_count = p.screen.orig_text.lines().count(); + p.data_source = source; p.reformat_display(); command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); } @@ -305,34 +305,6 @@ pub fn handle_event( command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); } - Command::AppendData(text) => { - if let Some(ref mut hs) = p.help_state { - hs.screen.orig_text.push_str(&text); - hs.screen.line_count = hs.screen.orig_text.lines().count(); - return; - } - let prev_unterminated = p.screen.unterminated; - let prev_fmt_lines_count = p.screen.formatted_lines_count(); - let append_style = p.append_str(text.as_str()); - - if append_style == AppendStyle::FullRedraw { - command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); - return; - } - - command_queue.push_back(Command::Io(IoCommand::DrawAppendedText( - prev_unterminated, - prev_fmt_lines_count, - append_style, - ))); - - if p.follow_output { - command_queue.push_back(Command::Io(IoCommand::SetUpperMark( - p.screen.formatted_lines_count(), - ))); - } - } - Command::SetPrompt(ref text) | Command::SendMessage(ref text) => { if let Command::SetPrompt(_) = ev { p.prompt = text.clone(); @@ -432,19 +404,6 @@ pub fn handle_io_command( } p.upper_mark = um; } - IoCommand::DrawAppendedText(prev_unterminated, prev_fmt_lines_count, append_style) => { - let AppendStyle::PartialUpdate(bounds) = append_style else { - unreachable!(); - }; - let fmt_lines = p.render_rows_for_display(bounds.0, bounds.1); - display::draw_append_text( - out, - p.rows, - prev_unterminated, - prev_fmt_lines_count, - &fmt_lines, - )?; - } #[cfg(feature = "search")] IoCommand::FetchSearchQuery => { // Pause the main user input thread, read search query and then restart the main input thread @@ -495,7 +454,7 @@ pub fn handle_io_command( mod tests { use super::super::commands::{Command, IoCommand}; use super::handle_event; - use crate::{PagerState, input::InputEvent, minus_core::CommandQueue, state::Selection}; + use crate::{PagerState, input::InputEvent, minus_core::CommandQueue, source::InMemorySource, state::Selection}; use std::fmt::Write; use std::sync::{Arc, atomic::AtomicBool}; @@ -504,9 +463,9 @@ mod tests { // Tests for event emitting functions of Pager #[test] #[cfg(any(feature = "dynamic_output", feature = "static_output"))] - fn set_data() { + fn set_data_source() { let mut ps = PagerState::new().unwrap(); - let ev = Command::SetData(TEST_STR.to_string()); + let ev = Command::SetDataSource(Box::new(InMemorySource::from(TEST_STR))); let mut command_queue = CommandQueue::new_zero(); handle_event( @@ -520,24 +479,14 @@ mod tests { } #[test] - fn append_str() { + fn appended_lines_are_served_from_source() { let mut ps = PagerState::new().unwrap(); - let ev1 = Command::AppendData(format!("{TEST_STR}\n")); - let ev2 = Command::AppendData(TEST_STR.to_string()); - let mut command_queue = CommandQueue::new_zero(); + let mut source = InMemorySource::new(); + source.append(&format!("{TEST_STR}\n")); + source.append(TEST_STR); + ps.data_source = Box::new(source); + ps.reformat_display(); - handle_event( - ev1, - &mut ps, - &mut command_queue, - &Arc::new(AtomicBool::new(false)), - ); - handle_event( - ev2, - &mut ps, - &mut command_queue, - &Arc::new(AtomicBool::new(false)), - ); assert_eq!( ps.screen.formatted_lines, vec![TEST_STR.to_string(), TEST_STR.to_string()] @@ -579,7 +528,7 @@ mod tests { #[test] fn show_help() { let mut ps = PagerState::new().unwrap(); - ps.screen.orig_text = "original text\n".to_string(); + ps.data_source = Box::new(InMemorySource::from("original text\n")); ps.reformat_display(); ps.upper_mark = 0; @@ -594,7 +543,11 @@ mod tests { &Arc::new(AtomicBool::new(false)), ); assert!(ps.help_state.is_some()); - assert!(ps.screen.orig_text.contains("COMMAND SUMMARY")); + assert!(ps + .screen + .formatted_lines + .iter() + .any(|row| row.contains("COMMAND SUMMARY"))); assert!(ps.prompt.contains("HELP")); // Pressing help again toggles it off and restores original text @@ -606,7 +559,7 @@ mod tests { &Arc::new(AtomicBool::new(false)), ); assert!(ps.help_state.is_none()); - assert_eq!(ps.screen.orig_text, "original text\n"); + assert_eq!(ps.screen.formatted_lines, vec!["original text".to_string()]); // Showing help then exiting with Exit returns to pager handle_event( @@ -625,7 +578,7 @@ mod tests { ); assert!(ps.help_state.is_none()); assert!(!is_exited.load(std::sync::atomic::Ordering::SeqCst)); - assert_eq!(ps.screen.orig_text, "original text\n"); + assert_eq!(ps.screen.formatted_lines, vec!["original text".to_string()]); } #[test] @@ -663,10 +616,10 @@ mod tests { fn update_selection_scrolls_up_at_top_edge() { let mut ps = PagerState::new().unwrap(); ps.rows = 5; - ps.screen.orig_text = (0..10).fold(String::new(), |mut t, idx| { - let _ = writeln!(t, "line {idx}"); - t - }); + ps.data_source = Box::new((0..10).fold(InMemorySource::new(), |mut s, idx| { + let _ = writeln!(s, "line {idx}"); + s + })); ps.reformat_display(); ps.upper_mark = 3; ps.selection_anchor = Some(Selection { @@ -701,10 +654,10 @@ mod tests { fn update_selection_scrolls_down_at_bottom_edge() { let mut ps = PagerState::new().unwrap(); ps.rows = 5; - ps.screen.orig_text = (0..10).fold(String::new(), |mut t, idx| { - let _ = writeln!(t, "line {idx}"); - t - }); + ps.data_source = Box::new((0..10).fold(InMemorySource::new(), |mut s, idx| { + let _ = writeln!(s, "line {idx}"); + s + })); ps.reformat_display(); ps.upper_mark = 3; ps.selection_anchor = Some(Selection { @@ -739,10 +692,10 @@ mod tests { fn update_selection_clamps_scroll_at_bottom_bound() { let mut ps = PagerState::new().unwrap(); ps.rows = 5; - ps.screen.orig_text = (0..6).fold(String::new(), |mut t, idx| { - let _ = writeln!(t, "line {idx}"); - t - }); + ps.data_source = Box::new((0..6).fold(InMemorySource::new(), |mut s, idx| { + let _ = writeln!(s, "line {idx}"); + s + })); ps.reformat_display(); ps.upper_mark = 2; ps.selection_anchor = Some(Selection { diff --git a/src/core/init.rs b/src/core/init.rs index aca38ca..8cb2fa9 100644 --- a/src/core/init.rs +++ b/src/core/init.rs @@ -15,7 +15,7 @@ use crate::{ input::InputEvent, minus_core::{ RunMode, - commands::Command, + commands::{Command, IoCommand}, ev_handler::handle_event, utils::{display::draw_full, term}, }, @@ -96,7 +96,11 @@ pub fn init_core(pager: &Pager, rm: RunMode) -> std::result::Result<(), MinusErr 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)?; + let lines: Vec = (0..ps.data_source.line_count()) + .filter_map(|idx| ps.data_source.line(idx)) + .map(std::borrow::Cow::into_owned) + .collect(); + write_raw_lines(&mut *out, &lines, None)?; drop(out); *RUNMODE.lock() = RunMode::Uninitialized; return Ok(()); @@ -244,25 +248,46 @@ fn start_reactor( } let next_command = if command_queue.is_empty() { - rx.recv() + // A timeout instead of a blocking receive so that changes in the data source + // are polled while waiting for commands + rx.recv_timeout(std::time::Duration::from_millis(100)).ok() } else { - Ok(command_queue.pop_front().unwrap()) + Some(command_queue.pop_front().unwrap()) }; - let mut p = ps.lock(); - if let Ok(Command::Io(ic)) = next_command { - use crate::minus_core::ev_handler::handle_io_command; - - handle_io_command( - ic, - &mut *output_sink.lock(), - &mut p, - &mut command_queue, - #[cfg(feature = "search")] - input_thread_running, - )?; - } else if let Ok(command) = next_command { - handle_event(command, &mut p, &mut command_queue, is_exited); + let should_reformat = { + let mut p = ps.lock(); + if let Some(Command::Io(ic)) = next_command { + use crate::minus_core::ev_handler::handle_io_command; + + handle_io_command( + ic, + &mut *output_sink.lock(), + &mut p, + &mut command_queue, + #[cfg(feature = "search")] + input_thread_running, + )?; + } else if let Some(command) = next_command { + handle_event(command, &mut p, &mut command_queue, is_exited); + } + + // Poll the data source for changes and reformat when it differs from what + // minus has already formatted + command_queue.is_empty() && p.source_changed(p.data_source.as_ref()) + }; + + if should_reformat { + let (follow, fmt_count) = { + let mut p = ps.lock(); + p.reformat_display(); + (p.follow_output, p.screen.formatted_lines_count()) + }; + if follow { + command_queue.push_back(Command::Io(IoCommand::SetUpperMark(fmt_count))); + } else { + command_queue.push_back(Command::Io(IoCommand::RedrawDisplay)); + } } }, #[cfg(feature = "static_output")] diff --git a/src/core/utils/display/mod.rs b/src/core/utils/display/mod.rs index 095d85d..955a96a 100644 --- a/src/core/utils/display/mod.rs +++ b/src/core/utils/display/mod.rs @@ -11,16 +11,6 @@ use std::{cmp::Ordering, convert::TryInto, fmt::Display, io::Write}; use super::term; use crate::{LineNumbers, PagerState, error::MinusError, minus_core}; -/// How should the incoming text be drawn on the screen -#[derive(Debug, PartialEq, Eq)] -pub enum AppendStyle { - /// Draw only the region that needs to change - PartialUpdate((usize, usize)), - - /// Redraw the entire screen - FullRedraw, -} - /// Handles drawing of screen based on movement /// /// Refreshing the entire terminal can be costly, especially on high resolution displays and this cost can turns out to be @@ -156,52 +146,6 @@ pub fn draw_full(out: &mut impl Write, ps: &mut PagerState) -> Result<(), MinusE out.flush().map_err(MinusError::Draw) } -pub fn draw_append_text>( - out: &mut impl Write, - rows: usize, - prev_unterminated: usize, - prev_fmt_lines_count: usize, - fmt_text: &[L], -) -> Result<(), MinusError> { - if prev_fmt_lines_count < rows { - // Move the cursor to the very next line after the last displayed line - term::move_cursor( - out, - 0, - prev_fmt_lines_count - .saturating_sub(prev_unterminated) - .try_into() - .unwrap(), - false, - )?; - // available_rows -> Rows that are still unfilled - // rows - number of lines displayed -1 (for prompt) - // For example if 20 rows are in total in a terminal - // and 10 rows are already occupied, then this will be equal to 9 - let available_rows = rows.saturating_sub( - prev_fmt_lines_count - .saturating_sub(prev_unterminated) - .saturating_add(1), - ); - // Minimum amount of text that an be appended - // If available_rows is less, than this will be available rows else it will be - // the length of the formatted text - // - // If number of rows in terminal is 23 with 20 rows filled and another 5 lines are given - // This woll be equal to 3 as available rows will be 3 - // If in the above example only 2 lines need to be added, this will be equal to 2 - let num_appendable = fmt_text.len().min(available_rows); - if num_appendable >= 1 { - crossterm::execute!(out, crossterm::terminal::Clear(ClearType::CurrentLine))?; - } - for line in &fmt_text[0..num_appendable] { - write!(out, "{line}\n\r")?; - } - out.flush()?; - } - Ok(()) -} - /// Write the lines to the terminal /// /// Note: Although this function can take any type that implements [`Write`] however it assumes that diff --git a/src/core/utils/display/tests.rs b/src/core/utils/display/tests.rs index c96c7db..e322b2b 100644 --- a/src/core/utils/display/tests.rs +++ b/src/core/utils/display/tests.rs @@ -1,7 +1,7 @@ #![allow(clippy::shadow_unrelated)] #![allow(clippy::cast_possible_truncation)] use super::{draw_for_change, draw_full, write_from_pagerstate, write_prompt}; -use crate::{LineNumbers, PagerState}; +use crate::{LineNumbers, PagerState, source::InMemorySource}; use std::fmt::Write; // * In some places, where test lines are close to the row, 1 should be added @@ -16,7 +16,7 @@ fn short_no_line_numbers() { let lines = "A line\nAnother line"; let mut pager = PagerState::new().unwrap(); - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.reformat_display(); let mut out = Vec::with_capacity(lines.len()); @@ -52,7 +52,7 @@ fn long_no_line_numbers() { let mut pager = PagerState::new().unwrap(); // One extra line for prompt pager.rows = 4; - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.reformat_display(); assert!(write_from_pagerstate(&mut out, &mut pager).is_ok()); @@ -65,7 +65,7 @@ fn long_no_line_numbers() { // This ensures that asking for a position other than 0 works. let mut out = Vec::with_capacity(lines.len()); - pager.screen.orig_text = "Another line\nThird line\nFourth line\nFifth line\n".to_string(); + pager.data_source = Box::new(InMemorySource::from("Another line\nThird line\nFourth line\nFifth line\n")); pager.upper_mark = 1; pager.reformat_display(); @@ -97,7 +97,7 @@ fn short_with_line_numbers() { let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.line_numbers = LineNumbers::Enabled; pager.reformat_display(); @@ -132,7 +132,7 @@ fn long_with_line_numbers() { let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); pager.rows = 4; - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.line_numbers = LineNumbers::Enabled; pager.reformat_display(); @@ -184,7 +184,7 @@ fn big_line_numbers_are_padded() { let mut pager = PagerState::new().unwrap(); pager.upper_mark = 95; pager.rows = 11; - pager.screen.orig_text = lines; + pager.data_source = Box::new(InMemorySource::from(lines)); pager.line_numbers = LineNumbers::AlwaysOn; pager.reformat_display(); @@ -227,7 +227,7 @@ fn draw_short_no_line_numbers() { let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.line_numbers = LineNumbers::AlwaysOff; pager.reformat_display(); @@ -263,7 +263,7 @@ fn draw_long_no_line_numbers() { let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); pager.rows = 3; - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.reformat_display(); assert!(draw_full(&mut out, &mut pager).is_ok()); @@ -308,7 +308,7 @@ fn draw_short_with_line_numbers() { let lines = "A line\nAnother line"; let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.line_numbers = LineNumbers::Enabled; pager.reformat_display(); @@ -343,7 +343,7 @@ fn draw_long_with_line_numbers() { let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); pager.rows = 3; - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.line_numbers = LineNumbers::Enabled; pager.reformat_display(); @@ -397,7 +397,7 @@ fn draw_big_line_numbers_are_padded() { let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); pager.upper_mark = 95; - pager.screen.orig_text = lines; + pager.data_source = Box::new(InMemorySource::from(lines)); pager.line_numbers = LineNumbers::Enabled; pager.reformat_display(); @@ -422,7 +422,7 @@ fn draw_wrapping_line_numbers() { let mut out = Vec::new(); let mut pager = PagerState::new().unwrap(); - pager.screen.orig_text = lines; + pager.data_source = Box::new(InMemorySource::from(lines)); pager.cols = 30; pager.upper_mark = 2; pager.line_numbers = LineNumbers::Enabled; @@ -441,7 +441,7 @@ fn draw_help_message() { let mut out = Vec::with_capacity(lines.len()); let mut pager = PagerState::new().unwrap(); - pager.screen.orig_text = lines.to_string(); + pager.data_source = Box::new(InMemorySource::from(&lines)); pager.line_numbers = LineNumbers::AlwaysOff; pager.format_prompt(); @@ -456,7 +456,7 @@ fn test_draw_no_overflow() { const TEXT: &str = "This is a line of text to the pager"; let mut out = Vec::with_capacity(TEXT.len()); let mut pager = PagerState::new().unwrap(); - pager.screen.orig_text = TEXT.to_string(); + pager.data_source = Box::new(InMemorySource::from(TEXT)); pager.reformat_display(); draw_full(&mut out, &mut pager).unwrap(); assert!( @@ -469,6 +469,7 @@ fn test_draw_no_overflow() { #[cfg(test)] mod draw_for_change_tests { use super::{draw_for_change, write_prompt}; + use crate::source::InMemorySource; use crate::state::PagerState; use crossterm::{ cursor::MoveTo, @@ -487,7 +488,7 @@ mod draw_for_change_tests { }; let mut ps = PagerState::new().unwrap(); ps.upper_mark = 0; - ps.screen.orig_text = lines; + ps.data_source = Box::new(InMemorySource::from(lines)); ps.reformat_display(); ps.format_prompt(); ps diff --git a/src/core/utils/mod.rs b/src/core/utils/mod.rs index f7ba3c9..f816b59 100644 --- a/src/core/utils/mod.rs +++ b/src/core/utils/mod.rs @@ -19,17 +19,7 @@ impl LinesRowMap { Self(Vec::new()) } - pub fn append(&mut self, idx: &mut Self, clean_append: bool) { - if !clean_append { - self.0.pop(); - } - self.0.append(&mut idx.0); - } - - pub fn insert(&mut self, ln: usize, clean_append: bool) { - if !clean_append { - self.0.pop(); - } + pub fn insert(&mut self, ln: usize) { self.0.push(ln); }