minus/screen/mod.rs
1//! Provides functions for getting analysis of the text data inside minus.
2//!
3//! This module is still a work is progress and is subject to change.
4use crate::{
5 LineNumbers,
6 minus_core::{self, utils::LinesRowMap},
7};
8#[cfg(feature = "search")]
9use regex::Regex;
10
11use std::{borrow::Cow, fmt};
12
13#[cfg(feature = "search")]
14use {crate::search, std::collections::BTreeSet};
15
16// |||||||||||||||||||||||||||||||||||||||||||||||||||||||
17// TYPES TO BETTER DESCRIBE THE PURPOSE OF STRINGS
18// |||||||||||||||||||||||||||||||||||||||||||||||||||||||
19pub type Row = String;
20pub type Rows = Vec<String>;
21pub type Line<'a> = &'a str;
22pub type TextBlock<'a> = &'a str;
23pub type OwnedTextBlock = String;
24
25pub(crate) struct FormattedRow<'a> {
26 pub(crate) row: Cow<'a, str>,
27 show_line_numbers: bool,
28 line_number: Option<usize>,
29 padding: usize,
30}
31
32impl FormattedRow<'_> {
33 fn raw_row(&self) -> &str {
34 &self.row
35 }
36
37 fn fmt_prefix(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38 if !self.show_line_numbers {
39 return Ok(());
40 }
41
42 match self.line_number {
43 Some(line_number) => {
44 let line_number = line_number + 1;
45 let number_width = minus_core::utils::digits(line_number) + 1;
46 let left_padding = self.padding.saturating_sub(number_width);
47
48 write!(f, "{:left_padding$}", "")?;
49 if cfg!(not(test)) {
50 write!(f, "{}", crossterm::style::Attribute::Bold)?;
51 }
52 write!(f, "{line_number}.")?;
53 if cfg!(not(test)) {
54 write!(f, "{}", crossterm::style::Attribute::Reset)?;
55 }
56 f.write_str(" ")
57 }
58 None => write!(f, "{:>width$} ", "", width = self.padding),
59 }
60 }
61}
62
63impl fmt::Display for FormattedRow<'_> {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 self.fmt_prefix(f)?;
66 f.write_str(self.raw_row())
67 }
68}
69
70#[cfg(feature = "search")]
71pub(crate) struct SearchFormattedRow<'a, 'b> {
72 row: FormattedRow<'a>,
73 search_term: Option<&'b Regex>,
74 is_match: bool,
75}
76
77#[cfg(feature = "search")]
78impl fmt::Display for SearchFormattedRow<'_, '_> {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 self.row.fmt_prefix(f)?;
81
82 if self.is_match {
83 write!(
84 f,
85 "{}",
86 search::highlight_matches_args(
87 self.row.raw_row(),
88 self.search_term.unwrap(),
89 false
90 )
91 )
92 } else {
93 f.write_str(self.row.raw_row())
94 }
95 }
96}
97
98// ||||||||||||||||||||||||||||||||||||||||||||||
99// SCREEN TYPE AND ITS REKATED FUNCTIONS
100// ||||||||||||||||||||||||||||||||||||||||||||||
101
102/// Stores all the data for the terminal
103///
104/// This can be used by applications to get a basic analysis of the data that minus has captured
105/// while formattng it for terminal display.
106///
107/// Most of the functions of this type are cheap as minus does a lot of caching of the analysis
108/// behind the scenes
109#[derive(Clone, Debug)]
110pub struct Screen {
111 pub(crate) orig_text: OwnedTextBlock,
112 pub(crate) formatted_lines: Rows,
113 pub(crate) line_count: usize,
114 pub(crate) max_line_length: usize,
115 /// Unterminated lines
116 /// Keeps track of the number of lines at the last of [`Self::formatted_lines`] which are not
117 /// terminated by a newline
118 pub(crate) unterminated: usize,
119 /// Whether to Line wrap lines
120 ///
121 /// Its negation gives the state of whether horizontal scrolling is allowed.
122 pub(crate) line_wrapping: bool,
123}
124
125impl Screen {
126 /// Get the actual number of physical rows that the text that will actually occupy on the
127 /// terminal
128 #[must_use]
129 pub const fn formatted_lines_count(&self) -> usize {
130 self.formatted_lines.len()
131 }
132
133 /// Get the number of [`Lines`](std::str::Lines) in the text.
134 #[must_use]
135 pub const fn line_count(&self) -> usize {
136 self.line_count
137 }
138
139 /// Get the length of the longest [Line] in the text.
140 #[must_use]
141 pub const fn get_max_line_length(&self) -> usize {
142 self.max_line_length
143 }
144
145 /// Insert the text into the []
146 pub(crate) fn push_screen_buf(
147 &mut self,
148 text: TextBlock,
149 line_numbers: LineNumbers,
150 cols: u16,
151 #[cfg(feature = "search")] search_term: Option<&Regex>,
152 ) -> FormatResult {
153 // If the last line of self.screen.orig_text is not terminated by than the first line of
154 // the incoming text is part of that line so we also need to take care of that.
155 //
156 // Appropriately in that case we set the last lne of self.screen.orig_text as attachment
157 // text for the FormatOpts.
158 let clean_append = self.orig_text.ends_with('\n') || self.orig_text.is_empty();
159 // We check if number of digits in current line count change during this text push.
160 let old_lc = self.line_count();
161
162 let formatted_lines_count = self.formatted_lines.len();
163
164 // Conditionally appends to [`self.formatted_lines`] or changes the last unterminated rows of
165 // [`self.formatted_lines`]
166 //
167 // `num_unterminated` is the current number of lines returned by [`self.make_append_str`]
168 // that should be truncated from [`self.formatted_lines`] to update the last line
169 self.formatted_lines
170 .truncate(self.formatted_lines.len() - self.unterminated);
171
172 let append_props = {
173 let attachment = if clean_append {
174 None
175 } else {
176 self.orig_text.lines().last()
177 };
178
179 let append_opts = FormatOpts {
180 buffer: &mut self.formatted_lines,
181 text,
182 attachment,
183 line_numbers,
184 formatted_lines_count,
185 lines_count: old_lc,
186 prev_unterminated: self.unterminated,
187 cols: cols.into(),
188 line_wrapping: self.line_wrapping,
189 #[cfg(feature = "search")]
190 search_term,
191 };
192 format_text_block(append_opts)
193 };
194 self.orig_text.push_str(text);
195
196 let (num_unterminated, lines_formatted, max_line_length) = (
197 append_props.num_unterminated,
198 append_props.lines_formatted,
199 append_props.max_line_length,
200 );
201
202 self.line_count = old_lc + lines_formatted.saturating_sub(usize::from(!clean_append));
203 if max_line_length > self.max_line_length {
204 self.max_line_length = max_line_length;
205 }
206
207 self.unterminated = num_unterminated;
208 append_props
209 }
210}
211
212impl Default for Screen {
213 fn default() -> Self {
214 Self {
215 line_wrapping: true,
216 orig_text: String::with_capacity(100 * 1024),
217 formatted_lines: Vec::with_capacity(500 * 1024),
218 line_count: 0,
219 max_line_length: 0,
220 unterminated: 0,
221 }
222 }
223}
224
225// |||||||||||||||||||||||||||||||
226// TEXT FORMATTING FUNCTIONS
227// |||||||||||||||||||||||||||||||
228
229// minus has a very interesting but simple text model that you must go through to understand how minus works.
230//
231// # Text Block
232// A text block in minus is just a bunch of text that may contain newlines (`\n`) between them.
233// [`PagerState::lines`] is nothing but just a giant text block.
234//
235// # Line
236// A line is text that must not contain any newlines inside it but may or may not end with a newline.
237// Don't confuse this with Rust's [Lines](std::str::Lines) which is similar to minus's Lines terminolagy but only
238// differs for the fact that they don't end with a newline. Although the Rust's Lines is heavily used inside minus
239// as an important building block.
240//
241// # Row
242// A row is part of a line that fits perfectly inside one row of terminal. Out of the three text types, only row
243// is dependent on the terminal conditions. If the terminal gets resized, each row will grow or shrink to hold
244// more or less text inside it.
245//
246// # Termination
247// # Termination of Line
248// A line is called terminated when it ends with a newline character, otherwise it is called unterminated.
249// You may ask why is this important? Because minus supports completing a line in multiple steps, if we don't care
250// whether a line is terminated or not, we won't know that the data coming right now is part of the current line or
251// it is for a new line.
252//
253// # Termination of block
254// A block is terminated if the last line of the block is terminated i.e it ends with a newline character.
255//
256// # Unterminated rows
257// It is 0 in most of the cases. The only case when it has a non-zero value is a line or block of text is unterminated
258// In this case, it is equal to the number of rows that the last line of the block or a the line occupied.
259//
260// Whenever new data comes while a line or block is unterminated minus cleans up the number of unterminated rows
261// on the terminal i.e the entire last line. Then it merges the incoming data to the last line and then reprints
262// them on the terminal.
263//
264// Why this complex approach?
265// Simple! printing an entire page on the terminal is slow and this approach allows minus to reprint only the
266// parts that are required without having to redraw everything
267//
268// [`PagerState::lines`]: crate::state::PagerState::lines
269
270pub(crate) trait AppendableBuffer {
271 fn push_fmt<D>(&mut self, row: D)
272 where
273 D: fmt::Display;
274}
275
276impl AppendableBuffer for Rows {
277 fn push_fmt<D>(&mut self, row: D)
278 where
279 D: fmt::Display,
280 {
281 self.push(row.to_string());
282 }
283}
284
285impl AppendableBuffer for &mut Rows {
286 fn push_fmt<D>(&mut self, row: D)
287 where
288 D: fmt::Display,
289 {
290 self.push(row.to_string());
291 }
292}
293
294pub(crate) struct ReusableRows<'a> {
295 rows: &'a mut Rows,
296 used: usize,
297}
298
299impl<'a> ReusableRows<'a> {
300 pub(crate) const fn new(rows: &'a mut Rows) -> Self {
301 Self { rows, used: 0 }
302 }
303
304 pub(crate) fn finish(self) {
305 self.rows.truncate(self.used);
306 }
307}
308
309impl AppendableBuffer for &mut ReusableRows<'_> {
310 fn push_fmt<D>(&mut self, row: D)
311 where
312 D: fmt::Display,
313 {
314 if self.used == self.rows.len() {
315 self.rows.push(String::new());
316 }
317
318 let slot = &mut self.rows[self.used];
319 slot.clear();
320 fmt::write(slot, format_args!("{row}")).unwrap();
321 self.used += 1;
322 }
323}
324
325pub(crate) struct FormatOpts<'a, B>
326where
327 B: AppendableBuffer,
328{
329 /// Buffer to insert the text into
330 pub buffer: B,
331 /// Contains the incoming text data
332 pub text: TextBlock<'a>,
333 /// This is Some when the last line inside minus's present data is unterminated. It contains the
334 /// last line to be attached to the the incoming text
335 pub attachment: Option<TextBlock<'a>>,
336 /// Status of line numbers
337 pub line_numbers: LineNumbers,
338 /// This is equal to the number of lines in [`Screen::orig_text`]. This basically tells what
339 /// line number the upcoming line will hold.
340 pub lines_count: usize,
341 /// This is equal to the number of lines in [`Screen::formatted_lines`]. This is used to
342 /// calculate the search index of the rows of the line.
343 pub formatted_lines_count: usize,
344 /// Actual number of columns available for displaying
345 pub cols: usize,
346 /// Number of lines that are previously unterminated. It is only relevant when there is
347 /// `attachment` text otherwise it should be 0.
348 pub prev_unterminated: usize,
349 /// Search term if a search is active
350 #[cfg(feature = "search")]
351 pub search_term: Option<&'a regex::Regex>,
352
353 /// Value of [`Screen::line_wrapping`]
354 pub line_wrapping: bool,
355}
356
357/// Contains the formatted rows along with some basic information about the text formatted
358///
359/// The basic information includes things like the number of lines formatted or the length of
360/// longest line encountered. These are tracked as each line is being formatted hence we refer to
361/// them as **tracking variables**.
362#[derive(Debug)]
363pub(crate) struct FormatResult {
364 // **Tracking variables**
365 //
366 /// Number of lines that have been formatted from `text`.
367 pub lines_formatted: usize,
368 /// Number of rows that have been formatted from `text`.
369 pub rows_formatted: usize,
370 /// Number of rows that are unterminated
371 pub num_unterminated: usize,
372 /// If search is active, this contains the indices where search matches in the incoming text have been found
373 #[cfg(feature = "search")]
374 pub append_search_idx: BTreeSet<usize>,
375 /// Map of where first row of each line is placed inside in [`Screen::formatted_lines`]
376 pub lines_to_row_map: LinesRowMap,
377 /// The length of longest line encountered in the formatted text block
378 pub max_line_length: usize,
379 pub clean_append: bool,
380}
381
382/// Makes the text that will be displayed.
383#[allow(clippy::too_many_lines)]
384pub(crate) fn format_text_block<B>(mut opts: FormatOpts<'_, B>) -> FormatResult
385where
386 B: AppendableBuffer,
387{
388 // Formatting a text block not only requires us to format each line according to the terminal
389 // configuration and the main applications's preference but also gather some basic information
390 // about the text that we formatted. The basic information that we gather is supplied along
391 // with the formatted lines in the FormatResult's tracking variables.
392 //
393 // This is a high level overview of how the text formatting works.
394 //
395 // For a text block, we hae a couple of things to care about:-
396 // * Each line is formatted using the using the `formatted_line()` function.
397 // After a line has been formatted using the `formatted_line()` function, calling `.len()` on
398 // the returned vector will give the number of rows that it would span on the terminal.
399 // For less confusion, we call this *row span of that line*.
400 // * The first line can have an attachment, in the sense that it can be part of the last line of the
401 // already present text. In that case the FrmatResult::attachment will hold a `Some(...)`
402 // value. `clean_append` keeps track of this: it will be false if an attachment is available.
403 // * Formatting of the lines between the first line and last line ie. *middle lines*, is actually
404 // rather simple: we simply format them
405 // * The last is also similar to the middle lines except for one exception:-
406 //
407 // If it isn't terminated by a \n then we need to find how many rows it
408 // will span in the terminal and set it to the `unterminated` count.
409 //
410 // More on this is described in the unterminated section.
411 //
412 // * We also have more things to take care like `append_search_idx` but most of these
413 // either documented in their respective section or self-understanable so not discussed here.
414 //
415 // Now the good stuff...
416 // * First, if there's an attachment, we merge it with the actual text to be formatted
417 // and tweak certain parameters (see below)
418 // * Then we split the entire text block into two parts: rest_lines and last_line.
419 // * Next we format the rest_lines, and all update the tracking variables.
420 // * Next we format the last line and keep it separate to calculate unterminated.
421 // * If there's exactly one line to format, it will automatically behave as last_line and there
422 // will be no rest_lines.
423 // * After all the formatting is done, we return the format results.
424
425 // Compute the text to be format and set clean_append
426 let to_format = if let Some(attached_text) = opts.attachment {
427 // Tweak certain parameters if we are joining the last line of already present text with the first line of
428 // incoming text.
429 //
430 // First reduce line count by 1 if, because the first line of the incoming text should have the same line
431 // number as the last line. Hence all subsequent lines must get a line number less than expected.
432 //
433 // Next subtract the number of rows that the last line occupied from formatted_lines_count since it is
434 // also getting reformatted. This can be easily accomplished by taking help of [`PagerState::unterminated`]
435 // which we get in opts.prev_unterminated.
436 opts.lines_count = opts.lines_count.saturating_sub(1);
437 opts.formatted_lines_count = opts
438 .formatted_lines_count
439 .saturating_sub(opts.prev_unterminated);
440 let mut s = String::with_capacity(opts.text.len() + attached_text.len());
441 s.push_str(attached_text);
442 s.push_str(opts.text);
443
444 s
445 } else {
446 opts.text.to_string()
447 };
448
449 let lines = to_format
450 .lines()
451 .enumerate()
452 .collect::<Vec<(usize, &str)>>();
453
454 let to_format_size = lines.len();
455
456 let mut fr = FormatResult {
457 lines_formatted: to_format_size,
458 rows_formatted: 0,
459 num_unterminated: opts.prev_unterminated,
460 #[cfg(feature = "search")]
461 append_search_idx: BTreeSet::new(),
462 lines_to_row_map: LinesRowMap::new(),
463 max_line_length: 0,
464 clean_append: opts.attachment.is_none(),
465 };
466
467 let line_number_digits = minus_core::utils::digits(opts.lines_count + to_format_size);
468
469 // Return if we have nothing to format
470 if lines.is_empty() {
471 return fr;
472 }
473
474 // Number of rows that have been formatted so far
475 // Whenever a line is formatted, this will be incremented to te number of rows that the formatted line has occupied
476 let mut formatted_row_count = opts.formatted_lines_count;
477
478 let (last_idx, last_line_text) = lines.last().copied().unwrap();
479 for (idx, line) in lines.iter().take(lines.len().saturating_sub(1)) {
480 fr.lines_to_row_map.insert(formatted_row_count, true);
481 fr.max_line_length = fr.max_line_length.max(line.len());
482
483 let rows = format_line(
484 line,
485 line_number_digits,
486 opts.lines_count + idx,
487 opts.line_numbers,
488 opts.cols,
489 opts.line_wrapping,
490 );
491
492 #[cfg(feature = "search")]
493 let rows = format_search_rows(rows, opts.search_term);
494
495 #[cfg(feature = "search")]
496 {
497 formatted_row_count += collect_rows(
498 &mut opts.buffer,
499 rows,
500 formatted_row_count,
501 &mut fr.append_search_idx,
502 );
503 }
504
505 #[cfg(not(feature = "search"))]
506 {
507 formatted_row_count += collect_rows(&mut opts.buffer, rows);
508 }
509 }
510
511 let last_line = format_line(
512 last_line_text,
513 line_number_digits,
514 opts.lines_count + last_idx,
515 opts.line_numbers,
516 opts.cols,
517 opts.line_wrapping,
518 );
519 #[cfg(feature = "search")]
520 let last_line = format_search_rows(last_line, opts.search_term);
521
522 let last_line_rows = last_line.size_hint().1.unwrap();
523
524 fr.lines_to_row_map.insert(formatted_row_count, true);
525 fr.max_line_length = fr.max_line_length.max(last_line_text.len());
526
527 #[cfg(feature = "search")]
528 {
529 formatted_row_count += collect_rows(
530 &mut opts.buffer,
531 last_line,
532 formatted_row_count,
533 &mut fr.append_search_idx,
534 );
535 }
536
537 #[cfg(not(feature = "search"))]
538 {
539 formatted_row_count += collect_rows(&mut opts.buffer, last_line);
540 }
541
542 // Calculate number of rows which are part of last line and are left unterminated due to absence of \n
543 fr.num_unterminated = if opts.text.ends_with('\n') {
544 // If the last line ends with \n, then the line is complete so nothing is left as unterminated
545 0
546 } else {
547 last_line_rows
548 };
549 fr.rows_formatted = formatted_row_count - opts.formatted_lines_count;
550
551 fr
552}
553
554pub(crate) fn format_line(
555 line: Line<'_>,
556 len_line_number: usize,
557 line_number: usize,
558 show_line_numbers: LineNumbers,
559 cols: usize,
560 line_wrapping: bool,
561) -> impl Iterator<Item = FormattedRow<'_>> {
562 assert!(
563 !line.contains('\n'),
564 "Newlines found in appending line {line:?}",
565 );
566 let line_numbers = matches!(
567 show_line_numbers,
568 LineNumbers::Enabled | LineNumbers::AlwaysOn
569 );
570
571 // NOTE: Only relevant when line numbers are active
572 // Padding is the space that the actual line text will be shifted to accommodate for
573 // line numbers. This is equal to:-
574 // LineNumbers::EXTRA_PADDING + len_line_number + 1 (for '.') + 1 (for 1 space)
575 //
576 // We reduce this from the number of available columns as this space cannot be used for
577 // actual line display when wrapping the lines
578 let padding = len_line_number + LineNumbers::EXTRA_PADDING + 1;
579
580 // Wrap the line and return an iterator over all the rows
581 let enumerated_rows = if line_wrapping {
582 let cols_avail = if line_numbers {
583 cols.saturating_sub(padding + 2)
584 } else {
585 cols
586 };
587 textwrap::wrap(line, cols_avail)
588 } else {
589 vec![Cow::from(line)]
590 }
591 .into_iter()
592 .enumerate();
593
594 enumerated_rows.map(move |(i, row)| FormattedRow {
595 row,
596 show_line_numbers: line_numbers,
597 line_number: if line_numbers && i == 0 {
598 Some(line_number)
599 } else {
600 None
601 },
602 padding,
603 })
604}
605
606#[cfg(feature = "search")]
607pub(crate) fn format_search_rows<'a>(
608 rows: impl Iterator<Item = FormattedRow<'a>> + 'a,
609 search_term: Option<&'a Regex>,
610) -> impl Iterator<Item = (SearchFormattedRow<'a, 'a>, bool)> + 'a {
611 rows.map(move |row| {
612 let is_match = search_term.is_some_and(|st| st.is_match(row.raw_row()));
613 (
614 SearchFormattedRow {
615 row,
616 search_term,
617 is_match,
618 },
619 is_match,
620 )
621 })
622}
623
624#[cfg(feature = "search")]
625fn collect_rows<B, I, D>(
626 buffer: &mut B,
627 rows: I,
628 formatted_idx: usize,
629 search_idx: &mut BTreeSet<usize>,
630) -> usize
631where
632 B: AppendableBuffer,
633 I: IntoIterator<Item = (D, bool)>,
634 D: fmt::Display,
635{
636 let mut row_count = 0;
637 for (wrap_idx, (row, is_match)) in rows.into_iter().enumerate() {
638 if is_match {
639 search_idx.insert(formatted_idx + wrap_idx);
640 }
641 buffer.push_fmt(row);
642 row_count = wrap_idx + 1;
643 }
644 row_count
645}
646
647#[cfg(not(feature = "search"))]
648fn collect_rows<B, I, D>(buffer: &mut B, rows: I) -> usize
649where
650 B: AppendableBuffer,
651 I: IntoIterator<Item = D>,
652 D: fmt::Display,
653{
654 let mut row_count = 0;
655 for row in rows {
656 buffer.push_fmt(row);
657 row_count += 1;
658 }
659 row_count
660}
661
662pub(crate) fn format_lines_into(
663 buffer: &mut Rows,
664 text: &String,
665 line_numbers: LineNumbers,
666 cols: usize,
667 line_wrapping: bool,
668 #[cfg(feature = "search")] search_term: Option<®ex::Regex>,
669) -> FormatResult {
670 let mut reusable_rows = ReusableRows::new(buffer);
671 let format_opts = FormatOpts {
672 buffer: &mut reusable_rows,
673 text,
674 attachment: None,
675 line_numbers,
676 formatted_lines_count: 0,
677 lines_count: 0,
678 prev_unterminated: 0,
679 cols,
680 #[cfg(feature = "search")]
681 search_term,
682 line_wrapping,
683 };
684 let fr = format_text_block(format_opts);
685 reusable_rows.finish();
686 fr
687}
688
689#[cfg(test)]
690mod tests;