Skip to main content

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<&regex::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;