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;