Skip to main content

rich_interact/components/
pager.rs

1//! A pager over rendered output, with search (#291).
2//!
3//! The content is any renderable, rendered at the terminal's width (and
4//! again after a resize), or lines already rendered. It scrolls like the
5//! [`Viewport`] it is built on. `/` starts a search: matches are marked in
6//! place, `n` and `N` jump between them, and the status line counts them.
7//! `q` or Escape closes it.
8//!
9//! Since 0.0.7 a table's header can stay on top while its rows scroll
10//! (#716): the first lines given with [`Pager::sticky_header`], or a
11//! table's header found in the content with
12//! [`Pager::sticky_table_header`].
13
14use std::sync::Arc;
15
16use rich::cells::cell_len;
17use rich::{Renderable, Segment, Style};
18
19use crate::component::{Component, Context, Flow, View};
20use crate::components::{Theme, fit, plain, text};
21use crate::event::{Event, Key, KeyCode};
22use crate::keymap::{Keymap, keys};
23use crate::policy::{LineIo, NotInteractive};
24use crate::viewport::Viewport;
25
26/// One match: a line, and its cells.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28struct Hit {
29    line: usize,
30    start: usize,
31    end: usize,
32}
33
34/// The keys of a [`Pager`], in context `pager`: `quit`, `search`,
35/// `next-match`, `previous-match` and [`ScrollState::keymap`]'s.
36///
37/// [`ScrollState::keymap`]: crate::kit::ScrollState::keymap
38pub fn pager_keymap() -> Keymap {
39    let mut keymap = Keymap::new("pager")
40        .bind("quit", keys("q escape"), "quit")
41        .bind("search", keys("/"), "search")
42        .bind("next-match", keys("n"), "next match")
43        .bind("previous-match", keys("N"), "previous match");
44    keymap.extend(crate::kit::ScrollState::keymap("pager"));
45    keymap
46}
47
48/// The keys of a [`Pager`] while a search is typed, in context
49/// `pager-search`.
50pub fn pager_search_keymap() -> Keymap {
51    Keymap::new("pager-search")
52        .bind("find", keys("enter"), "find")
53        .bind("cancel", keys("escape"), "stop searching")
54        .bind("delete", keys("backspace"), "delete a character")
55}
56
57/// Pages content; returns when closed.
58#[must_use = "a component does nothing until it is run or mounted"]
59pub struct Pager {
60    keymap: Keymap,
61    search_keymap: Keymap,
62    source: Option<Arc<dyn Renderable + Send + Sync>>,
63    rendered_at: Option<usize>,
64    viewport: Viewport,
65    /// Each line's plain text, for searching.
66    plain: Vec<String>,
67    /// The search being typed, while `/` is active.
68    typing: Option<String>,
69    query: Option<String>,
70    hits: Vec<Hit>,
71    current: usize,
72    theme: Theme,
73    mouse: bool,
74    /// Links clicked (#476), in order.
75    clicked: Vec<String>,
76    /// Which first lines stay on top (#716).
77    sticky: Sticky,
78}
79
80/// Which of a pager's first lines stay on top.
81#[derive(Clone, Copy, Debug, PartialEq, Eq)]
82enum Sticky {
83    Lines(usize),
84    /// A table's header, found in the content at each width.
85    Table,
86}
87
88impl Pager {
89    /// Page `renderable`, rendered at the terminal's width.
90    pub fn new(renderable: impl Renderable + Send + Sync + 'static) -> Pager {
91        let mut pager = Pager::lines(Vec::new());
92        pager.source = Some(Arc::new(renderable));
93        pager
94    }
95
96    /// Page lines already rendered.
97    pub fn lines(lines: Vec<Vec<Segment>>) -> Pager {
98        let mut pager = Pager {
99            keymap: pager_keymap(),
100            search_keymap: pager_search_keymap(),
101            source: None,
102            rendered_at: None,
103            viewport: Viewport::default(),
104            plain: Vec::new(),
105            typing: None,
106            query: None,
107            hits: Vec::new(),
108            current: 0,
109            theme: Theme::default(),
110            mouse: false,
111            clicked: Vec::new(),
112            sticky: Sticky::Lines(0),
113        };
114        pager.set(lines);
115        pager
116    }
117
118    /// Make `keys` do `action` (see [`pager_keymap`] and
119    /// [`pager_search_keymap`]) on this pager only; no keys unbinds it.
120    pub fn rebind(mut self, action: &str, keys: impl IntoIterator<Item = Key>) -> Self {
121        let keys: Vec<Key> = keys.into_iter().collect();
122        self.keymap.rebind(action, keys.clone());
123        self.search_keymap.rebind(action, keys);
124        self
125    }
126
127    pub fn theme(mut self, theme: Theme) -> Self {
128        self.theme = theme;
129        self
130    }
131
132    /// Report the mouse (#476): the wheel scrolls, and a click on a link
133    /// (an OSC 8 hyperlink in the content) records it: see
134    /// [`Pager::links`]. The pager never opens a link itself.
135    pub fn with_mouse(mut self, on: bool) -> Self {
136        self.mouse = on;
137        self
138    }
139
140    /// The links clicked while paging, in order: for the caller to open,
141    /// print or ignore.
142    pub fn links(&self) -> &[String] {
143        &self.clicked
144    }
145
146    /// Keep the first `lines` lines on top while the rest scroll (#716):
147    /// a header rendered with the content.
148    pub fn sticky_header(mut self, lines: usize) -> Self {
149        self.sticky = Sticky::Lines(lines);
150        self.stick();
151        self
152    }
153
154    /// Keep a table's header on top while its rows scroll (#716), when the
155    /// content starts with a table: its title, top edge and header rows,
156    /// down to the line under the header, as [`table_header_rows`] finds
157    /// them at each width. Content that does not start with a table
158    /// scrolls as it is.
159    pub fn sticky_table_header(mut self, on: bool) -> Self {
160        self.sticky = if on { Sticky::Table } else { Sticky::Lines(0) };
161        self.stick();
162        self
163    }
164
165    /// The lines that stay on top now.
166    pub fn sticky_rows(&self) -> usize {
167        self.viewport.sticky_rows()
168    }
169
170    /// Work out the sticky lines for the content.
171    fn stick(&mut self) {
172        let rows = match self.sticky {
173            Sticky::Lines(lines) => lines,
174            Sticky::Table => table_header_rows(self.viewport.lines()).unwrap_or(0),
175        };
176        self.viewport.set_sticky(rows);
177    }
178
179    /// Start with this search.
180    pub fn search(mut self, query: impl Into<String>) -> Self {
181        self.query = Some(query.into());
182        self.find();
183        self
184    }
185
186    fn set(&mut self, mut lines: Vec<Vec<Segment>>) {
187        // Controls in the content are shown as text; search what is shown.
188        for line in &mut lines {
189            crate::paint::sanitize_line(line);
190        }
191        self.plain = lines
192            .iter()
193            .map(|line| line.iter().map(|s| s.text.as_str()).collect())
194            .collect();
195        self.viewport.set_lines(lines);
196        self.stick();
197        self.find();
198    }
199
200    /// Render the source at `width` if it has not been yet at that width.
201    fn render_at(&mut self, context: &Context<'_>) {
202        let width = context.width;
203        if let Some(source) = &self.source
204            && self.rendered_at != Some(width)
205        {
206            let lines = context.lines_at(&**source, width);
207            self.rendered_at = Some(width);
208            self.set(lines);
209        }
210    }
211
212    /// Find every case-insensitive occurrence of the query.
213    fn find(&mut self) {
214        self.hits.clear();
215        self.current = 0;
216        let Some(query) = self.query.as_ref().filter(|q| !q.is_empty()) else {
217            return;
218        };
219        let needle: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
220        for (line, text) in self.plain.iter().enumerate() {
221            for (start, end) in find_folded(text, &needle) {
222                self.hits.push(Hit {
223                    line,
224                    start: cell_len(&text[..start]),
225                    end: cell_len(&text[..end]),
226                });
227            }
228        }
229    }
230
231    fn page(context: &Context<'_>) -> usize {
232        context.height.saturating_sub(1).max(1)
233    }
234
235    fn jump(&mut self, delta: isize, page: usize) {
236        if self.hits.is_empty() {
237            return;
238        }
239        let count = self.hits.len() as isize;
240        self.current = (self.current as isize + delta).rem_euclid(count) as usize;
241        self.viewport.show(self.hits[self.current].line, page);
242    }
243
244    /// `line` with the cells of `hits` restyled.
245    fn mark(&self, line: &[Segment], index: usize) -> Vec<Segment> {
246        let hits: Vec<(usize, &Hit)> = self
247            .hits
248            .iter()
249            .enumerate()
250            .filter(|(_, hit)| hit.line == index)
251            .collect();
252        if hits.is_empty() {
253            return line.to_vec();
254        }
255        let other = Style::parse("reverse").expect("style");
256        let current = Style::parse("black on yellow").expect("style");
257        let mut out = Vec::new();
258        let mut column = 0;
259        for segment in line {
260            for c in segment.text.chars() {
261                let width = cell_len(c.encode_utf8(&mut [0; 4]));
262                let marked = hits
263                    .iter()
264                    .find(|(_, hit)| column >= hit.start && column < hit.end)
265                    .map(|(n, _)| if *n == self.current { &current } else { &other });
266                let style = match (marked, &segment.style) {
267                    (Some(mark), Some(style)) => Some(style.combine(mark)),
268                    (Some(mark), None) => Some(mark.clone()),
269                    (None, style) => style.clone(),
270                };
271                match out.last_mut() {
272                    Some(Segment {
273                        text, style: last, ..
274                    }) if *last == style => text.push(c),
275                    _ => out.push(Segment::new(c.to_string(), style)),
276                }
277                column += width;
278            }
279        }
280        out
281    }
282}
283
284/// The lines of a table's top, when `lines` start with one: its title,
285/// its top edge and its header rows, down to and including the line under
286/// the header (#716). `None` when the content does not start with a table
287/// whose header a line of its box marks off, such as rich's default
288/// `HEAVY_HEAD` table, or when nothing would be left under it, or when the
289/// box draws the lines between rows as it draws that one and the line comes
290/// again (a table without a header, or with `show_lines`, in `SQUARE`):
291/// pinning a row would be worse than pinning nothing. The text of each line
292/// is what is looked at, so it works on output rendered elsewhere
293/// (`rich data.csv --force-terminal | rich pager`).
294pub fn table_header_rows(lines: &[Vec<Segment>]) -> Option<usize> {
295    // A header taller than this is not looked for.
296    const LIMIT: usize = 16;
297    let plain: Vec<String> = lines
298        .iter()
299        .take(LIMIT)
300        .map(|line| {
301            let text: String = line
302                .iter()
303                .filter(|s| !s.control)
304                .map(|s| s.text.as_str())
305                .collect();
306            text.trim_matches(' ').to_string()
307        })
308        .collect();
309    for under in 1..plain.len() {
310        for set in BOXES.iter() {
311            if let Some(rows) = header_above(&plain, under, set) {
312                return (rows < lines.len()).then_some(rows);
313            }
314        }
315    }
316    None
317}
318
319/// The boxes whose header rows end with a line of their own: rich's, but
320/// those that mark the header off with a plain rule, which a rule in the
321/// content would look like.
322const BOXES: [rich::r#box::Box; 15] = {
323    use rich::r#box::*;
324    [
325        HEAVY_HEAD,
326        SQUARE,
327        ROUNDED,
328        HEAVY,
329        DOUBLE,
330        MINIMAL,
331        ASCII,
332        ASCII2,
333        ASCII_DOUBLE_HEAD,
334        SQUARE_DOUBLE_HEAD,
335        MINIMAL_HEAVY_HEAD,
336        MINIMAL_DOUBLE_HEAD,
337        HEAVY_EDGE,
338        DOUBLE_EDGE,
339        MARKDOWN,
340    ]
341};
342
343/// The lines down to `under` when `plain[under]` is the line under a
344/// header drawn with `set`, the header rows are above it, and above those
345/// at most a top edge and a title of three lines.
346fn header_above(plain: &[String], under: usize, set: &rich::r#box::Box) -> Option<usize> {
347    let line = &plain[under];
348    let (left, across, cross, right) = (
349        set.head_row_left,
350        set.head_row_horizontal,
351        set.head_row_cross,
352        set.head_row_right,
353    );
354    let ends = |text: &str, first: char, last: char| {
355        text.starts_with(first) && text.ends_with(last) && text.chars().count() > 1
356    };
357    let drawn = !line.is_empty()
358        && line.contains(across)
359        && line
360            .chars()
361            .all(|c| [left, across, cross, right].contains(&c) && c != ' ');
362    // Its edges, or a crossing, tell it from a rule.
363    let edged = left != ' ' && right != ' ' && ends(line, left, right);
364    if !drawn || !(edged || (cross != across && line.contains(cross))) {
365        return None;
366    }
367    // A box that draws the line between rows (`show_lines`) as it draws
368    // the one under the header (SQUARE, and so a headerless HEAVY_HEAD
369    // table): the line comes again in a table without a header, so it
370    // marks one only where it is the only such line. Better not to pin a
371    // header than to pin a row.
372    let between = (
373        set.row_left,
374        set.row_horizontal,
375        set.row_cross,
376        set.row_right,
377    );
378    if between == (left, across, cross, right)
379        && plain
380            .iter()
381            .enumerate()
382            .any(|(at, text)| at != under && text == line)
383    {
384        return None;
385    }
386    // With its edges, or (`show_edge=False`) its dividers.
387    let header = |text: &str| {
388        (set.head_left != ' ' && set.head_right != ' ' && ends(text, set.head_left, set.head_right))
389            || (set.head_vertical != ' ' && text.contains(set.head_vertical))
390    };
391    let mut top = under;
392    while top > 0 && header(&plain[top - 1]) {
393        top -= 1;
394    }
395    if top == under {
396        return None;
397    }
398    let edge = |text: &str| {
399        !text.is_empty()
400            && text
401                .chars()
402                .all(|c| [set.top_left, set.top, set.top_divider, set.top_right, ' '].contains(&c))
403    };
404    if top > 0 && edge(&plain[top - 1]) {
405        top -= 1;
406    }
407    (top <= 3).then_some(under + 1)
408}
409
410/// The byte ranges of `text` whose lower-cased characters are `needle`
411/// (already lower-cased), without overlaps. Lower-casing can change a
412/// character's length (`ẞ` to `ß`, `İ` to `i̇`), so the text is folded one
413/// character at a time and each match mapped back to whole characters of
414/// the original: nothing is sliced with an offset into the folded text.
415fn find_folded(text: &str, needle: &[char]) -> Vec<(usize, usize)> {
416    let mut hits = Vec::new();
417    if needle.is_empty() {
418        return hits;
419    }
420    // Each folded character, and the byte range of the character it came
421    // from.
422    let mut folded: Vec<(char, usize, usize)> = Vec::with_capacity(text.len());
423    for (at, c) in text.char_indices() {
424        let end = at + c.len_utf8();
425        folded.extend(c.to_lowercase().map(|lower| (lower, at, end)));
426    }
427    let mut from = 0;
428    while from + needle.len() <= folded.len() {
429        let window = &folded[from..from + needle.len()];
430        if window.iter().map(|(c, _, _)| c).eq(needle.iter()) {
431            let (start, end) = (window[0].1, window[needle.len() - 1].2);
432            hits.push((start, end));
433            // Past the last original character the match touched.
434            from += needle.len();
435            while from < folded.len() && folded[from].1 < end {
436                from += 1;
437            }
438        } else {
439            from += 1;
440        }
441    }
442    hits
443}
444
445impl Component for Pager {
446    type Output = ();
447
448    /// Render the content at the terminal's width and, for a search given
449    /// up front, show its first match.
450    fn start(&mut self, context: &Context<'_>) -> Flow<()> {
451        self.render_at(context);
452        self.jump(0, Self::page(context));
453        Flow::Continue
454    }
455
456    fn handle(&mut self, event: &Event, context: &Context<'_>) -> Flow<()> {
457        self.render_at(context);
458        let page = Self::page(context);
459        if let Event::Link(url) = event {
460            self.clicked.push(url.clone());
461            return Flow::Continue;
462        }
463        if self.typing.is_some() {
464            let Some(key) = event.key() else {
465                return Flow::Continue;
466            };
467            match self
468                .search_keymap
469                .action(key)
470                .map(str::to_string)
471                .as_deref()
472            {
473                Some("find") => {
474                    self.query = self.typing.take();
475                    self.find();
476                    // The first match at or after the top of the page.
477                    let top = match self.viewport.offset() {
478                        0 => 0,
479                        _ => self.viewport.top(page),
480                    };
481                    let first = self
482                        .hits
483                        .iter()
484                        .position(|hit| hit.line >= top)
485                        .unwrap_or(0);
486                    self.current = first;
487                    self.jump(0, page);
488                }
489                Some("cancel") => self.typing = None,
490                Some("delete") => {
491                    if self.typing.as_mut().and_then(String::pop).is_none() {
492                        self.typing = None;
493                    }
494                }
495                _ => {
496                    if let (KeyCode::Char(c), Some(typing)) = (key.code, &mut self.typing) {
497                        typing.push(c);
498                    }
499                }
500            }
501            return Flow::Continue;
502        }
503        let Some(key) = event.key() else {
504            // The wheel.
505            self.viewport.handle_scroll(event, page);
506            return Flow::Continue;
507        };
508        match self.keymap.action(key).map(str::to_string).as_deref() {
509            Some("quit") => Flow::Done(()),
510            Some("search") => {
511                self.typing = Some(String::new());
512                Flow::Continue
513            }
514            Some("next-match") => {
515                self.jump(1, page);
516                Flow::Continue
517            }
518            Some("previous-match") => {
519                self.jump(-1, page);
520                Flow::Continue
521            }
522            Some(action) if self.viewport.act(action, page) => Flow::Continue,
523            _ => Flow::Ignored,
524        }
525    }
526
527    fn keymap(&self) -> Keymap {
528        if self.typing.is_some() {
529            return self.search_keymap.clone();
530        }
531        self.keymap.clone()
532    }
533
534    fn render(&self, context: &Context<'_>) -> View {
535        let width = context.width;
536        let page = Self::page(context);
537        let theme = &self.theme;
538        // Before the first event a renderable source is not rendered yet.
539        let fresh;
540        let (rows, plain_lines): (Vec<(usize, &[Segment])>, usize) =
541            match (&self.source, self.rendered_at) {
542                (Some(source), None) => {
543                    fresh = context.lines_at(&**source, width);
544                    let rows = fresh.iter().map(Vec::as_slice).enumerate();
545                    (rows.take(page).collect(), fresh.len())
546                }
547                // The sticky lines, then those scrolled to.
548                _ => (self.viewport.rows(page), self.viewport.len()),
549            };
550        let mut out: Vec<Vec<Segment>> = rows
551            .into_iter()
552            .map(|(index, line)| fit(self.mark(line, index), width))
553            .collect();
554        out.resize_with(page.min(plain_lines.max(1)), Vec::new);
555        let status = if let Some(typing) = &self.typing {
556            let line = vec![text("/", &theme.prompt), plain(typing.clone())];
557            let column = 1 + cell_len(typing);
558            out.push(fit(line, width));
559            return View::new(out).with_cursor(page.min(plain_lines.max(1)), column);
560        } else {
561            let viewport_status = if self.viewport.is_empty() && self.source.is_some() {
562                format!("all {plain_lines} lines")
563            } else {
564                self.viewport.status(page)
565            };
566            let search = match (&self.query, self.hits.len()) {
567                (Some(query), 0) => format!(" · no match for {query:?}"),
568                (Some(_), count) => format!(" · match {}/{count} · n/N", self.current + 1),
569                (None, _) => String::new(),
570            };
571            let link = match self.clicked.last() {
572                Some(url) => format!(" · link {url}"),
573                None => String::new(),
574            };
575            format!("{viewport_status}{search}{link} · / search · q quit")
576        };
577        out.push(fit(vec![text(status, &theme.hint)], width));
578        View::new(out)
579    }
580
581    fn mouse(&self) -> bool {
582        self.mouse
583    }
584
585    fn default_value(&self) -> Option<()> {
586        Some(())
587    }
588
589    /// Without a terminal, there is nothing to page: write it all.
590    fn prompt(&mut self, io: &mut dyn LineIo) -> Result<Option<()>, NotInteractive> {
591        if let (Some(source), None) = (&self.source, self.rendered_at) {
592            let console = rich::Console::builder().force_terminal(false).build();
593            let options = console.options();
594            let lines = console.render_lines(&**source, &options, false);
595            self.set(lines);
596        }
597        for line in &self.plain {
598            io.write(line);
599            io.write("\n");
600        }
601        Ok(Some(()))
602    }
603}
604
605#[cfg(test)]
606mod tests {
607    use super::*;
608
609    #[test]
610    fn search_folds_case_one_character_at_a_time() {
611        // `ẞ` lower-cases to `ß`, two bytes shorter: offsets into the
612        // folded text are not offsets into the line.
613        assert_eq!(find_folded("Ⱥẞ", &['ß']), [(2, 5)]);
614        assert_eq!(find_folded("ȺẞxSS", &['ß']), [(2, 5)]);
615        // `İ` folds to two characters; lines holding it are searched too.
616        let needle: Vec<char> = "x".chars().collect();
617        assert_eq!(find_folded("İx", &needle), [(2, 3)]);
618        let needle: Vec<char> = "i̇".chars().collect();
619        assert_eq!(find_folded("aİb", &needle), [(1, 3)]);
620        // Non-overlapping, case-insensitive.
621        assert_eq!(find_folded("AaAa", &['a', 'a']), [(0, 2), (2, 4)]);
622        assert!(find_folded("abc", &[]).is_empty());
623    }
624
625    #[test]
626    fn a_search_that_changes_byte_lengths_marks_the_right_cells() {
627        let pager = Pager::lines(vec![vec![Segment::new("Ⱥẞ end", None)]]).search("ß");
628        assert_eq!(
629            pager.hits,
630            [Hit {
631                line: 0,
632                start: 1,
633                end: 2
634            }]
635        );
636    }
637}