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