Skip to main content

rmut_front/
pager.rs

1//! The pager's rows and the index's scroll math: what a message looks
2//! like as lines of text at a width, and where the cursor lands. No
3//! toolkit; both front ends draw from this.
4
5use rmut_core::message::MessageView;
6
7pub fn humanize_size(bytes: u64) -> String {
8    match bytes {
9        0..=999 => format!("{bytes}"),
10        1000..=10_239 => format!("{:.1}K", bytes as f64 / 1024.0),
11        10_240..=1_048_575 => format!("{}K", bytes / 1024),
12        1_048_576..=10_485_759 => format!("{:.1}M", bytes as f64 / 1_048_576.0),
13        _ => format!("{}M", bytes / 1_048_576),
14    }
15}
16
17// ---- pager ----
18
19/// How one pager display row gets colored.
20#[derive(Clone, Copy, PartialEq, Debug)]
21pub enum RowKind {
22    Header,
23    /// `[-- ... --]` notices (PGP verdicts, missing parts).
24    Marker,
25    /// Quoted body text, 1-based nesting depth.
26    Quoted(usize),
27    Text,
28}
29
30pub struct Row {
31    pub text: String,
32    pub kind: RowKind,
33    /// The URLs on this row, in chars of `text`. A URL the wrap broke
34    /// across rows is a link on each of them, all to the whole URL.
35    pub links: Vec<RowLink>,
36}
37
38/// Where a URL sits on a pager row: chars `start..end` of its text,
39/// and the whole URL they belong to.
40#[derive(Clone, Debug, PartialEq)]
41pub struct RowLink {
42    pub start: usize,
43    pub end: usize,
44    pub url: String,
45}
46
47/// The URLs of a line as char ranges of it.
48fn url_ranges(line: &str) -> Vec<(usize, usize, String)> {
49    let mut at = 0;
50    let mut out = Vec::new();
51    for (text, url) in link_spans(line) {
52        let n = text.chars().count();
53        if let Some(url) = url {
54            out.push((at, at + n, url));
55        }
56        at += n;
57    }
58    out
59}
60
61/// The links of the row holding chars `from..to` of a line whose URLs
62/// are `urls`, shifted right by `offset` (a wrap marker).
63fn row_links(
64    urls: &[(usize, usize, String)],
65    from: usize,
66    to: usize,
67    offset: usize,
68) -> Vec<RowLink> {
69    urls.iter()
70        .filter(|(start, end, _)| *start < to && *end > from)
71        .map(|(start, end, url)| RowLink {
72            start: (*start).max(from) - from + offset,
73            end: (*end).min(to) - from + offset,
74            url: url.clone(),
75        })
76        .collect()
77}
78
79/// Every URL of a message, headers first, each once, in the order
80/// they appear: what the URL list offers.
81pub fn view_urls(view: &MessageView) -> Vec<String> {
82    let mut out: Vec<String> = Vec::new();
83    let headers = view
84        .brief
85        .iter()
86        .map(|(name, value)| format!("{name}: {value}"));
87    for line in headers.chain(view.body.lines().map(String::from)) {
88        for (_, _, url) in url_ranges(&line) {
89            if !out.contains(&url) {
90                out.push(url);
91            }
92        }
93    }
94    out
95}
96
97/// Quote depth of a body line under $quote_regexp: the number of
98/// quote characters in the prefix match, 0 for unquoted text.
99pub fn quote_depth(line: &str, re: &regex_lite::Regex) -> usize {
100    match re.find(line) {
101        Some(m) if m.start() == 0 => m
102            .as_str()
103            .chars()
104            .filter(|c| !c.is_whitespace())
105            .count()
106            .max(1),
107        _ => 0,
108    }
109}
110
111/// The pager display: header block, separator, wrapped body (with
112/// mutt's `+` continuation markers), each row classified for
113/// coloring. The scroll math, the body search, and the drawing all
114/// share this; T (hide_quoted) drops quoted rows here, so every
115/// consumer agrees on what a line number means.
116/// What the config says about drawing a message: which lines count as
117/// quoted, whether a wrapped line is marked, and whether it breaks at
118/// a word.
119pub struct PagerStyle<'a> {
120    pub quote_re: &'a regex_lite::Regex,
121    /// mutt's $markers.
122    pub markers: bool,
123    /// mutt's $smart_wrap.
124    pub smart_wrap: bool,
125}
126
127impl<'a> PagerStyle<'a> {
128    pub fn of(config: &'a rmut_core::config::Config, quote_re: &'a regex_lite::Regex) -> Self {
129        PagerStyle {
130            quote_re,
131            markers: config.pager.markers.unwrap_or(true),
132            smart_wrap: config.pager.smart_wrap.unwrap_or(true),
133        }
134    }
135}
136
137/// The rows of the message on show, built once per layout. The pager
138/// draws every frame, its status line counts the rows, and each
139/// motion key needs the count too: rebuilt from the text each time, a
140/// big patch or log (tens of thousands of lines) cost tens of
141/// milliseconds a keystroke. A front end keeps one beside the view it
142/// shows and drops it with that view; anything that changes the rows
143/// (the width, a toggle, a `:set`) is in the key.
144#[derive(Default)]
145pub struct RowCache {
146    built: std::cell::RefCell<Option<(RowKey, std::rc::Rc<Vec<Row>>)>>,
147}
148
149#[derive(PartialEq)]
150struct RowKey {
151    width: usize,
152    full_headers: bool,
153    hide_quoted: bool,
154    markers: bool,
155    smart_wrap: bool,
156    quote_re: String,
157}
158
159impl RowCache {
160    /// [`pager_rows`], from the cache when nothing it depends on moved.
161    pub fn rows(
162        &self,
163        view: &MessageView,
164        width: usize,
165        full_headers: bool,
166        style: &PagerStyle,
167        hide_quoted: bool,
168    ) -> std::rc::Rc<Vec<Row>> {
169        let key = RowKey {
170            width,
171            full_headers,
172            hide_quoted,
173            markers: style.markers,
174            smart_wrap: style.smart_wrap,
175            quote_re: style.quote_re.as_str().to_string(),
176        };
177        let mut built = self.built.borrow_mut();
178        if let Some((k, rows)) = built.as_ref()
179            && *k == key
180        {
181            return rows.clone();
182        }
183        let rows = std::rc::Rc::new(pager_rows(view, width, full_headers, style, hide_quoted));
184        *built = Some((key, rows.clone()));
185        rows
186    }
187}
188
189pub fn pager_rows(
190    view: &MessageView,
191    width: usize,
192    full_headers: bool,
193    style: &PagerStyle,
194    hide_quoted: bool,
195) -> Vec<Row> {
196    let quote_re = style.quote_re;
197    let headers = if full_headers { &view.all } else { &view.brief };
198    let mut rows: Vec<Row> = headers
199        .iter()
200        .map(|(name, value)| {
201            let text = format!("{name}: {value}");
202            let links = row_links(&url_ranges(&text), 0, usize::MAX, 0);
203            Row {
204                text,
205                kind: RowKind::Header,
206                links,
207            }
208        })
209        .collect();
210    rows.push(Row {
211        text: String::new(),
212        kind: RowKind::Text,
213        links: Vec::new(),
214    });
215    for line in view.body.lines() {
216        // Marker lines like the PGP verdict get the header treatment.
217        let marker = line.starts_with("[-- ") && line.ends_with(" --]");
218        let depth = if marker {
219            0
220        } else {
221            quote_depth(line, quote_re)
222        };
223        if hide_quoted && depth > 0 {
224            continue;
225        }
226        let kind = if marker {
227            RowKind::Marker
228        } else if depth > 0 {
229            RowKind::Quoted(depth)
230        } else {
231            RowKind::Text
232        };
233        let expanded = line.replace('\t', "    ");
234        let chars: Vec<char> = expanded.chars().collect();
235        let urls = url_ranges(&expanded);
236        for (i, (from, to)) in wrap_ranges(&chars, width.saturating_sub(1), style.smart_wrap)
237            .into_iter()
238            .enumerate()
239        {
240            let wrapped: String = chars[from..to].iter().collect();
241            // mutt's $markers: a wrapped line says it is one.
242            let marker = i > 0 && style.markers;
243            let text = match marker {
244                true => format!("+{wrapped}"),
245                false => wrapped,
246            };
247            let links = row_links(&urls, from, to, usize::from(marker));
248            rows.push(Row { text, kind, links });
249        }
250    }
251    rows
252}
253
254/// The pager's display as plain text: what the body search runs over.
255pub fn pager_text_lines(
256    view: &MessageView,
257    width: usize,
258    full_headers: bool,
259    style: &PagerStyle,
260    hide_quoted: bool,
261) -> Vec<String> {
262    pager_rows(view, width, full_headers, style, hide_quoted)
263        .into_iter()
264        .map(|row| row.text)
265        .collect()
266}
267
268/// Total pager lines at the given width.
269pub fn pager_line_count(
270    view: &MessageView,
271    width: usize,
272    full_headers: bool,
273    style: &PagerStyle,
274    hide_quoted: bool,
275) -> usize {
276    pager_rows(view, width, full_headers, style, hide_quoted).len()
277}
278
279/// Word-wrap one body line to `width` columns (hard break when a single
280/// word is longer than the line). Tabs are expanded first.
281/// The same, with mutt's $smart_wrap: without it a long line breaks
282/// at the column rather than at the last space before it.
283pub fn wrap_line_with(line: &str, width: usize, smart: bool) -> Vec<String> {
284    let expanded = line.replace('\t', "    ");
285    let chars: Vec<char> = expanded.chars().collect();
286    wrap_ranges(&chars, width, smart)
287        .into_iter()
288        .map(|(from, to)| chars[from..to].iter().collect())
289        .collect()
290}
291
292/// The wrap as char ranges of the (tab-expanded) line, one per row,
293/// each at most `width` display columns: a CJK ideograph or an emoji
294/// takes two, a combining mark none.
295fn wrap_ranges(chars: &[char], width: usize, smart: bool) -> Vec<(usize, usize)> {
296    use unicode_width::UnicodeWidthChar as _;
297    let width = width.max(4);
298    let cols = |c: char| c.width().unwrap_or(0);
299    if chars.iter().map(|&c| cols(c)).sum::<usize>() <= width {
300        return vec![(0, chars.len())];
301    }
302    let mut out = Vec::new();
303    let mut start = 0;
304    while start < chars.len() {
305        // As many chars as fit, one at least.
306        let mut window_end = start;
307        let mut used = 0;
308        while window_end < chars.len() && used + cols(chars[window_end]) <= width {
309            used += cols(chars[window_end]);
310            window_end += 1;
311        }
312        let window_end = window_end.max(start + 1);
313        if window_end >= chars.len() {
314            out.push((start, chars.len()));
315            break;
316        }
317        let brk = match smart {
318            true => (start + 1..window_end)
319                .rev()
320                .find(|&i| chars[i] == ' ')
321                .unwrap_or(window_end),
322            false => window_end,
323        };
324        out.push((start, brk));
325        start = if chars.get(brk) == Some(&' ') {
326            brk + 1
327        } else {
328            brk
329        };
330    }
331    out
332}
333
334/// mutt's $menu_scroll, $menu_context and $menu_move_off, as the
335/// index's recentering reads them.
336#[derive(Clone, Copy)]
337pub struct Menu {
338    pub scroll: bool,
339    pub context: usize,
340    pub move_off: bool,
341}
342
343/// Where the index's first row goes so the cursor stays on screen:
344/// mutt's menu_check_recenter, line for line. `top` is the row now at
345/// the top, `sel` the cursor, `rows` the screen, `max` the entries.
346/// With `scroll` the view moves just far enough (keeping `context`
347/// lines beyond the cursor); without it a whole page turns. Unless
348/// `move_off`, the last entry never scrolls up past the bottom.
349pub fn recenter(top: usize, sel: usize, rows: usize, max: usize, menu: Menu) -> usize {
350    let (mut top, sel, rows, max) = (top as i64, sel as i64, rows as i64, max as i64);
351    let c = (menu.context as i64).min(rows / 2);
352    if !menu.move_off && max <= rows {
353        top = 0;
354    } else if menu.scroll || rows <= 0 || c < menu.context as i64 {
355        if sel < top + c {
356            top = sel - c;
357        } else if sel >= top + rows - c {
358            top = sel - rows + c + 1;
359        }
360    } else if sel < top + c {
361        top -= (rows - c) * ((top + rows - 1 - sel) / (rows - c)) - c;
362    } else if sel >= top + rows - c {
363        top += (rows - c) * ((sel - top) / (rows - c)) - c;
364    }
365    if !menu.move_off {
366        top = top.min(max - rows);
367    }
368    top.max(0) as usize
369}
370
371pub use rmut_core::links::link_spans;
372
373/// The pager's text search: the next line matching `m` from `from`,
374/// wrapping around, and whether it wrapped. Both front ends step
375/// their pagers with this.
376pub fn search_lines<S: AsRef<str>>(
377    lines: &[S],
378    m: &rmut_core::pattern::Matcher,
379    from: usize,
380    forward: bool,
381) -> Option<(usize, bool)> {
382    rmut_session::wrap_order(lines.len(), from, forward)
383        .into_iter()
384        .find(|&(idx, _)| m.is_match(lines[idx].as_ref()))
385}
386
387#[cfg(test)]
388mod tests {
389    use super::{
390        Menu, PagerStyle, RowKind, humanize_size, pager_rows, quote_depth, recenter, wrap_line_with,
391    };
392    use rmut_core::message::MessageView;
393    use rmut_session::default_quote_re;
394
395    #[test]
396    fn quote_depth_counts_prefix_marks() {
397        let re = default_quote_re();
398        assert_eq!(quote_depth("plain text", &re), 0);
399        assert_eq!(quote_depth("> quoted", &re), 1);
400        assert_eq!(quote_depth("> > deeper", &re), 2);
401        assert_eq!(quote_depth(">>tight", &re), 2);
402        assert_eq!(quote_depth("  | indented pipe", &re), 1);
403        // A > later in the line is not a quote.
404        assert_eq!(quote_depth("2 > 1", &re), 0);
405    }
406
407    #[test]
408    fn wide_characters_wrap_by_the_columns_they_take() {
409        use unicode_width::UnicodeWidthStr as _;
410        // Ten ideographs are twenty columns: two rows at twelve.
411        let rows = wrap_line_with("日本語のテキストです", 12, false);
412        assert_eq!(rows, ["日本語のテキ", "ストです"]);
413        assert!(rows.iter().all(|r| r.width() <= 12));
414        // Latin text with accents is one column a char, as before.
415        assert_eq!(
416            wrap_line_with("Schůzka zítra ráno", 12, true),
417            ["Schůzka", "zítra ráno"]
418        );
419        // A combining mark adds no width.
420        assert_eq!(
421            wrap_line_with("e\u{301}e\u{301}e\u{301}e\u{301}", 4, false).len(),
422            1
423        );
424    }
425
426    #[test]
427    fn a_wrapped_url_links_every_row_to_the_whole_url() {
428        use super::{Row, view_urls};
429        let url = "https://example.com/a/very/long/path/that/wraps";
430        let view = MessageView {
431            brief: vec![("List-Help".into(), "<https://lists.example/help>".into())],
432            all: vec![],
433            body: format!("see {url} now\nsee {url} again"),
434        };
435        let re = default_quote_re();
436        let style = PagerStyle {
437            quote_re: &re,
438            markers: true,
439            smart_wrap: false,
440        };
441        let rows = pager_rows(&view, 21, false, &style, false);
442        let header = &rows[0].links;
443        assert_eq!(header.len(), 1);
444        assert_eq!(
445            &rows[0].text[header[0].start..header[0].end],
446            "https://lists.example/help"
447        );
448        // Every row the URL touches links to all of it; the text the
449        // link covers is what is on the row, after the "+" marker.
450        let body: Vec<&Row> = rows[2..].iter().filter(|r| !r.links.is_empty()).collect();
451        assert!(body.len() >= 4, "{}", body.len());
452        assert!(body.iter().all(|r| r.links.iter().all(|l| l.url == url)));
453        // The first line's rows run up to the one holding " now".
454        let end = rows.iter().position(|r| r.text.contains("now")).unwrap();
455        let covered: String = rows[2..=end]
456            .iter()
457            .flat_map(|r| {
458                r.links.iter().map(|l| {
459                    r.text
460                        .chars()
461                        .skip(l.start)
462                        .take(l.end - l.start)
463                        .collect::<String>()
464                })
465            })
466            .collect();
467        assert_eq!(covered, url);
468        assert_eq!(
469            rows[3].links[0].start, 1,
470            "after the marker: {:?}",
471            rows[3].text
472        );
473        assert_eq!(
474            view_urls(&view),
475            vec!["https://lists.example/help".to_string(), url.to_string()]
476        );
477    }
478
479    #[test]
480    fn rows_classify_and_hide_quoted() {
481        let view = MessageView {
482            brief: vec![("From".into(), "jane@example.com".into())],
483            all: vec![("From".into(), "jane@example.com".into())],
484            body: "top\n> one\n> > two\n[-- marker --]\ntail".into(),
485        };
486        let re = default_quote_re();
487        let style = PagerStyle {
488            quote_re: &re,
489            markers: true,
490            smart_wrap: true,
491        };
492        let rows = pager_rows(&view, 80, false, &style, false);
493        let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
494        assert_eq!(
495            kinds,
496            vec![
497                RowKind::Header,
498                RowKind::Text, // separator
499                RowKind::Text,
500                RowKind::Quoted(1),
501                RowKind::Quoted(2),
502                RowKind::Marker,
503                RowKind::Text,
504            ]
505        );
506        // T drops the quoted rows for every consumer at once.
507        let hidden = pager_rows(&view, 80, false, &style, true);
508        assert_eq!(hidden.len(), rows.len() - 2);
509        assert!(hidden.iter().all(|r| !matches!(r.kind, RowKind::Quoted(_))));
510    }
511
512    #[test]
513    fn wrap_short_line_untouched() {
514        assert_eq!(wrap_line_with("hello", 10, true), vec!["hello"]);
515        assert_eq!(wrap_line_with("", 10, true), vec![""]);
516    }
517
518    #[test]
519    fn wrap_breaks_at_word_boundary() {
520        assert_eq!(
521            wrap_line_with("the quick brown fox", 10, true),
522            vec!["the quick", "brown fox"]
523        );
524    }
525
526    #[test]
527    fn without_smart_wrap_a_line_breaks_at_the_column() {
528        // mutt's $smart_wrap off: the break lands on the width, not
529        // on the last space before it.
530        assert_eq!(
531            wrap_line_with("alpha beta gamma", 10, false),
532            vec!["alpha beta", "gamma"]
533        );
534        assert_eq!(
535            wrap_line_with("alpha beta gamma", 10, true),
536            vec!["alpha", "beta gamma"]
537        );
538    }
539
540    #[test]
541    fn wrap_hard_breaks_long_words() {
542        assert_eq!(
543            wrap_line_with("abcdefghij", 4, true),
544            vec!["abcd", "efgh", "ij"]
545        );
546    }
547
548    #[test]
549    fn humanize_size_ranges() {
550        assert_eq!(humanize_size(0), "0");
551        assert_eq!(humanize_size(999), "999");
552        assert_eq!(humanize_size(2048), "2.0K");
553        assert_eq!(humanize_size(204800), "200K");
554        assert_eq!(humanize_size(2 * 1024 * 1024), "2.0M");
555    }
556
557    #[test]
558    fn recenter_scrolls_a_line_or_turns_a_page() {
559        let scroll = Menu {
560            scroll: true,
561            context: 0,
562            move_off: true,
563        };
564        let page = Menu {
565            scroll: false,
566            context: 0,
567            move_off: true,
568        };
569        // Moving down off a 10-row screen: scrolling shows one more
570        // line, paging turns the whole page (mutt's default).
571        assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
572        assert_eq!(recenter(0, 10, 10, 100, page), 10);
573        // Moving up off the top is symmetrical.
574        assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
575        assert_eq!(recenter(20, 19, 10, 100, page), 10);
576        // On screen already: nothing moves.
577        assert_eq!(recenter(20, 25, 10, 100, scroll), 20);
578        assert_eq!(recenter(20, 25, 10, 100, page), 20);
579    }
580
581    #[test]
582    fn recenter_keeps_context_lines() {
583        let m = Menu {
584            scroll: true,
585            context: 3,
586            move_off: true,
587        };
588        // The cursor stays three rows clear of the bottom edge.
589        assert_eq!(recenter(0, 7, 10, 100, m), 1);
590        // And of the top edge.
591        assert_eq!(recenter(20, 22, 10, 100, m), 19);
592        // Context is capped at half the screen (a 4-row screen: 2).
593        let big = Menu {
594            scroll: true,
595            context: 9,
596            move_off: true,
597        };
598        assert_eq!(recenter(0, 2, 4, 100, big), 1);
599    }
600
601    #[test]
602    fn recenter_move_off_pins_the_bottom() {
603        let stuck = Menu {
604            scroll: true,
605            context: 0,
606            move_off: false,
607        };
608        // Fewer entries than rows: the top is always the top.
609        assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
610        // The last page stays full: top never passes max - rows.
611        assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
612        // With move_off (the default) it may.
613        let free = Menu {
614            scroll: true,
615            context: 0,
616            move_off: true,
617        };
618        assert_eq!(recenter(95, 99, 10, 100, free), 95);
619    }
620
621    #[test]
622    fn search_lines_steps_and_wraps() {
623        use super::search_lines;
624        use rmut_core::pattern::Matcher;
625        let lines: Vec<String> = ["alpha", "the needle", "beta", "a Needle too"]
626            .iter()
627            .map(|s| s.to_string())
628            .collect();
629        let m = Matcher::new("needle");
630        // Forward from the top: the next hit, no wrap; case-insensitive.
631        assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
632        assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
633        // Past the last hit it wraps to the first.
634        assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
635        // Backwards, with and without the wrap.
636        assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
637        assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
638        // No match, and the empty pager.
639        assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
640        assert_eq!(search_lines::<&str>(&[], &m, 0, true), None);
641        // A regex argument works like the patterns do.
642        let re = Matcher::new("^bet.");
643        assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
644    }
645}