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
137pub fn pager_rows(
138    view: &MessageView,
139    width: usize,
140    full_headers: bool,
141    style: &PagerStyle,
142    hide_quoted: bool,
143) -> Vec<Row> {
144    let quote_re = style.quote_re;
145    let headers = if full_headers { &view.all } else { &view.brief };
146    let mut rows: Vec<Row> = headers
147        .iter()
148        .map(|(name, value)| {
149            let text = format!("{name}: {value}");
150            let links = row_links(&url_ranges(&text), 0, usize::MAX, 0);
151            Row {
152                text,
153                kind: RowKind::Header,
154                links,
155            }
156        })
157        .collect();
158    rows.push(Row {
159        text: String::new(),
160        kind: RowKind::Text,
161        links: Vec::new(),
162    });
163    for line in view.body.lines() {
164        // Marker lines like the PGP verdict get the header treatment.
165        let marker = line.starts_with("[-- ") && line.ends_with(" --]");
166        let depth = if marker {
167            0
168        } else {
169            quote_depth(line, quote_re)
170        };
171        if hide_quoted && depth > 0 {
172            continue;
173        }
174        let kind = if marker {
175            RowKind::Marker
176        } else if depth > 0 {
177            RowKind::Quoted(depth)
178        } else {
179            RowKind::Text
180        };
181        let expanded = line.replace('\t', "    ");
182        let chars: Vec<char> = expanded.chars().collect();
183        let urls = url_ranges(&expanded);
184        for (i, (from, to)) in wrap_ranges(&chars, width.saturating_sub(1), style.smart_wrap)
185            .into_iter()
186            .enumerate()
187        {
188            let wrapped: String = chars[from..to].iter().collect();
189            // mutt's $markers: a wrapped line says it is one.
190            let marker = i > 0 && style.markers;
191            let text = match marker {
192                true => format!("+{wrapped}"),
193                false => wrapped,
194            };
195            let links = row_links(&urls, from, to, usize::from(marker));
196            rows.push(Row { text, kind, links });
197        }
198    }
199    rows
200}
201
202/// The pager's display as plain text: what the body search runs over.
203pub fn pager_text_lines(
204    view: &MessageView,
205    width: usize,
206    full_headers: bool,
207    style: &PagerStyle,
208    hide_quoted: bool,
209) -> Vec<String> {
210    pager_rows(view, width, full_headers, style, hide_quoted)
211        .into_iter()
212        .map(|row| row.text)
213        .collect()
214}
215
216/// Total pager lines at the given width.
217pub fn pager_line_count(
218    view: &MessageView,
219    width: usize,
220    full_headers: bool,
221    style: &PagerStyle,
222    hide_quoted: bool,
223) -> usize {
224    pager_rows(view, width, full_headers, style, hide_quoted).len()
225}
226
227/// Word-wrap one body line to `width` columns (hard break when a single
228/// word is longer than the line). Tabs are expanded first.
229/// The same, with mutt's $smart_wrap: without it a long line breaks
230/// at the column rather than at the last space before it.
231pub fn wrap_line_with(line: &str, width: usize, smart: bool) -> Vec<String> {
232    let expanded = line.replace('\t', "    ");
233    let chars: Vec<char> = expanded.chars().collect();
234    wrap_ranges(&chars, width, smart)
235        .into_iter()
236        .map(|(from, to)| chars[from..to].iter().collect())
237        .collect()
238}
239
240/// The wrap as char ranges of the (tab-expanded) line, one per row.
241fn wrap_ranges(chars: &[char], width: usize, smart: bool) -> Vec<(usize, usize)> {
242    let width = width.max(4);
243    if chars.len() <= width {
244        return vec![(0, chars.len())];
245    }
246    let mut out = Vec::new();
247    let mut start = 0;
248    while start < chars.len() {
249        if chars.len() - start <= width {
250            out.push((start, chars.len()));
251            break;
252        }
253        let window_end = start + width;
254        let brk = match smart {
255            true => (start + 1..window_end)
256                .rev()
257                .find(|&i| chars[i] == ' ')
258                .unwrap_or(window_end),
259            false => window_end,
260        };
261        out.push((start, brk));
262        start = if chars.get(brk) == Some(&' ') {
263            brk + 1
264        } else {
265            brk
266        };
267    }
268    out
269}
270
271/// mutt's $menu_scroll, $menu_context and $menu_move_off, as the
272/// index's recentering reads them.
273#[derive(Clone, Copy)]
274pub struct Menu {
275    pub scroll: bool,
276    pub context: usize,
277    pub move_off: bool,
278}
279
280/// Where the index's first row goes so the cursor stays on screen:
281/// mutt's menu_check_recenter, line for line. `top` is the row now at
282/// the top, `sel` the cursor, `rows` the screen, `max` the entries.
283/// With `scroll` the view moves just far enough (keeping `context`
284/// lines beyond the cursor); without it a whole page turns. Unless
285/// `move_off`, the last entry never scrolls up past the bottom.
286pub fn recenter(top: usize, sel: usize, rows: usize, max: usize, menu: Menu) -> usize {
287    let (mut top, sel, rows, max) = (top as i64, sel as i64, rows as i64, max as i64);
288    let c = (menu.context as i64).min(rows / 2);
289    if !menu.move_off && max <= rows {
290        top = 0;
291    } else if menu.scroll || rows <= 0 || c < menu.context as i64 {
292        if sel < top + c {
293            top = sel - c;
294        } else if sel >= top + rows - c {
295            top = sel - rows + c + 1;
296        }
297    } else if sel < top + c {
298        top -= (rows - c) * ((top + rows - 1 - sel) / (rows - c)) - c;
299    } else if sel >= top + rows - c {
300        top += (rows - c) * ((sel - top) / (rows - c)) - c;
301    }
302    if !menu.move_off {
303        top = top.min(max - rows);
304    }
305    top.max(0) as usize
306}
307
308/// One pager line cut around its URLs: `(text, None)` runs and
309/// `(url, Some(url))` spans, in order. Trailing sentence punctuation
310/// stays outside the link, the way a URL at the end of a sentence
311/// reads.
312pub fn link_spans(line: &str) -> Vec<(String, Option<String>)> {
313    let mut out = Vec::new();
314    let mut rest = line;
315    while let Some(at) = rest.find("http") {
316        let candidate = &rest[at..];
317        let scheme_ok = candidate.starts_with("http://") || candidate.starts_with("https://");
318        if !scheme_ok {
319            let cut = at + 4;
320            let (head, tail) = rest.split_at(cut);
321            out.push((head.to_string(), None));
322            rest = tail;
323            continue;
324        }
325        if at > 0 {
326            out.push((rest[..at].to_string(), None));
327        }
328        let end = candidate
329            .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"' | '\'' | ')' | ']'))
330            .unwrap_or(candidate.len());
331        let mut url = &candidate[..end];
332        while let Some(stripped) = url.strip_suffix(['.', ',', ';', ':', '!', '?']) {
333            url = stripped;
334        }
335        out.push((url.to_string(), Some(url.to_string())));
336        rest = &candidate[url.len()..];
337    }
338    if !rest.is_empty() || out.is_empty() {
339        out.push((rest.to_string(), None));
340    }
341    out
342}
343
344/// The pager's text search: the next line matching `m` from `from`,
345/// wrapping around, and whether it wrapped. Both front ends step
346/// their pagers with this.
347pub fn search_lines(
348    lines: &[String],
349    m: &rmut_core::pattern::Matcher,
350    from: usize,
351    forward: bool,
352) -> Option<(usize, bool)> {
353    rmut_session::wrap_order(lines.len(), from, forward)
354        .into_iter()
355        .find(|&(idx, _)| m.is_match(&lines[idx]))
356}
357
358#[cfg(test)]
359mod tests {
360    use super::{
361        Menu, PagerStyle, RowKind, humanize_size, pager_rows, quote_depth, recenter, wrap_line_with,
362    };
363    use rmut_core::message::MessageView;
364    use rmut_session::default_quote_re;
365
366    #[test]
367    fn quote_depth_counts_prefix_marks() {
368        let re = default_quote_re();
369        assert_eq!(quote_depth("plain text", &re), 0);
370        assert_eq!(quote_depth("> quoted", &re), 1);
371        assert_eq!(quote_depth("> > deeper", &re), 2);
372        assert_eq!(quote_depth(">>tight", &re), 2);
373        assert_eq!(quote_depth("  | indented pipe", &re), 1);
374        // A > later in the line is not a quote.
375        assert_eq!(quote_depth("2 > 1", &re), 0);
376    }
377
378    #[test]
379    fn a_wrapped_url_links_every_row_to_the_whole_url() {
380        use super::{Row, view_urls};
381        let url = "https://example.com/a/very/long/path/that/wraps";
382        let view = MessageView {
383            brief: vec![("List-Help".into(), "<https://lists.example/help>".into())],
384            all: vec![],
385            body: format!("see {url} now\nsee {url} again"),
386        };
387        let re = default_quote_re();
388        let style = PagerStyle {
389            quote_re: &re,
390            markers: true,
391            smart_wrap: false,
392        };
393        let rows = pager_rows(&view, 21, false, &style, false);
394        let header = &rows[0].links;
395        assert_eq!(header.len(), 1);
396        assert_eq!(
397            &rows[0].text[header[0].start..header[0].end],
398            "https://lists.example/help"
399        );
400        // Every row the URL touches links to all of it; the text the
401        // link covers is what is on the row, after the "+" marker.
402        let body: Vec<&Row> = rows[2..].iter().filter(|r| !r.links.is_empty()).collect();
403        assert!(body.len() >= 4, "{}", body.len());
404        assert!(body.iter().all(|r| r.links.iter().all(|l| l.url == url)));
405        // The first line's rows run up to the one holding " now".
406        let end = rows.iter().position(|r| r.text.contains("now")).unwrap();
407        let covered: String = rows[2..=end]
408            .iter()
409            .flat_map(|r| {
410                r.links.iter().map(|l| {
411                    r.text
412                        .chars()
413                        .skip(l.start)
414                        .take(l.end - l.start)
415                        .collect::<String>()
416                })
417            })
418            .collect();
419        assert_eq!(covered, url);
420        assert_eq!(
421            rows[3].links[0].start, 1,
422            "after the marker: {:?}",
423            rows[3].text
424        );
425        assert_eq!(
426            view_urls(&view),
427            vec!["https://lists.example/help".to_string(), url.to_string()]
428        );
429    }
430
431    #[test]
432    fn rows_classify_and_hide_quoted() {
433        let view = MessageView {
434            brief: vec![("From".into(), "jane@example.com".into())],
435            all: vec![("From".into(), "jane@example.com".into())],
436            body: "top\n> one\n> > two\n[-- marker --]\ntail".into(),
437        };
438        let re = default_quote_re();
439        let style = PagerStyle {
440            quote_re: &re,
441            markers: true,
442            smart_wrap: true,
443        };
444        let rows = pager_rows(&view, 80, false, &style, false);
445        let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
446        assert_eq!(
447            kinds,
448            vec![
449                RowKind::Header,
450                RowKind::Text, // separator
451                RowKind::Text,
452                RowKind::Quoted(1),
453                RowKind::Quoted(2),
454                RowKind::Marker,
455                RowKind::Text,
456            ]
457        );
458        // T drops the quoted rows for every consumer at once.
459        let hidden = pager_rows(&view, 80, false, &style, true);
460        assert_eq!(hidden.len(), rows.len() - 2);
461        assert!(hidden.iter().all(|r| !matches!(r.kind, RowKind::Quoted(_))));
462    }
463
464    #[test]
465    fn wrap_short_line_untouched() {
466        assert_eq!(wrap_line_with("hello", 10, true), vec!["hello"]);
467        assert_eq!(wrap_line_with("", 10, true), vec![""]);
468    }
469
470    #[test]
471    fn wrap_breaks_at_word_boundary() {
472        assert_eq!(
473            wrap_line_with("the quick brown fox", 10, true),
474            vec!["the quick", "brown fox"]
475        );
476    }
477
478    #[test]
479    fn without_smart_wrap_a_line_breaks_at_the_column() {
480        // mutt's $smart_wrap off: the break lands on the width, not
481        // on the last space before it.
482        assert_eq!(
483            wrap_line_with("alpha beta gamma", 10, false),
484            vec!["alpha beta", "gamma"]
485        );
486        assert_eq!(
487            wrap_line_with("alpha beta gamma", 10, true),
488            vec!["alpha", "beta gamma"]
489        );
490    }
491
492    #[test]
493    fn wrap_hard_breaks_long_words() {
494        assert_eq!(
495            wrap_line_with("abcdefghij", 4, true),
496            vec!["abcd", "efgh", "ij"]
497        );
498    }
499
500    #[test]
501    fn humanize_size_ranges() {
502        assert_eq!(humanize_size(0), "0");
503        assert_eq!(humanize_size(999), "999");
504        assert_eq!(humanize_size(2048), "2.0K");
505        assert_eq!(humanize_size(204800), "200K");
506        assert_eq!(humanize_size(2 * 1024 * 1024), "2.0M");
507    }
508
509    #[test]
510    fn recenter_scrolls_a_line_or_turns_a_page() {
511        let scroll = Menu {
512            scroll: true,
513            context: 0,
514            move_off: true,
515        };
516        let page = Menu {
517            scroll: false,
518            context: 0,
519            move_off: true,
520        };
521        // Moving down off a 10-row screen: scrolling shows one more
522        // line, paging turns the whole page (mutt's default).
523        assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
524        assert_eq!(recenter(0, 10, 10, 100, page), 10);
525        // Moving up off the top is symmetrical.
526        assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
527        assert_eq!(recenter(20, 19, 10, 100, page), 10);
528        // On screen already: nothing moves.
529        assert_eq!(recenter(20, 25, 10, 100, scroll), 20);
530        assert_eq!(recenter(20, 25, 10, 100, page), 20);
531    }
532
533    #[test]
534    fn recenter_keeps_context_lines() {
535        let m = Menu {
536            scroll: true,
537            context: 3,
538            move_off: true,
539        };
540        // The cursor stays three rows clear of the bottom edge.
541        assert_eq!(recenter(0, 7, 10, 100, m), 1);
542        // And of the top edge.
543        assert_eq!(recenter(20, 22, 10, 100, m), 19);
544        // Context is capped at half the screen (a 4-row screen: 2).
545        let big = Menu {
546            scroll: true,
547            context: 9,
548            move_off: true,
549        };
550        assert_eq!(recenter(0, 2, 4, 100, big), 1);
551    }
552
553    #[test]
554    fn recenter_move_off_pins_the_bottom() {
555        let stuck = Menu {
556            scroll: true,
557            context: 0,
558            move_off: false,
559        };
560        // Fewer entries than rows: the top is always the top.
561        assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
562        // The last page stays full: top never passes max - rows.
563        assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
564        // With move_off (the default) it may.
565        let free = Menu {
566            scroll: true,
567            context: 0,
568            move_off: true,
569        };
570        assert_eq!(recenter(95, 99, 10, 100, free), 95);
571    }
572
573    #[test]
574    fn search_lines_steps_and_wraps() {
575        use super::search_lines;
576        use rmut_core::pattern::Matcher;
577        let lines: Vec<String> = ["alpha", "the needle", "beta", "a Needle too"]
578            .iter()
579            .map(|s| s.to_string())
580            .collect();
581        let m = Matcher::new("needle");
582        // Forward from the top: the next hit, no wrap; case-insensitive.
583        assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
584        assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
585        // Past the last hit it wraps to the first.
586        assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
587        // Backwards, with and without the wrap.
588        assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
589        assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
590        // No match, and the empty pager.
591        assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
592        assert_eq!(search_lines(&[], &m, 0, true), None);
593        // A regex argument works like the patterns do.
594        let re = Matcher::new("^bet.");
595        assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
596    }
597
598    #[test]
599    fn urls_cut_out_of_a_line() {
600        use super::link_spans;
601        let spans = link_spans("see https://example.com/x, then more");
602        assert_eq!(
603            spans,
604            vec![
605                ("see ".into(), None),
606                (
607                    "https://example.com/x".into(),
608                    Some("https://example.com/x".into())
609                ),
610                (", then more".into(), None),
611            ]
612        );
613        assert_eq!(
614            link_spans("no links here"),
615            vec![("no links here".into(), None)]
616        );
617        let wrapped = link_spans("(https://a.example) and <https://b.example>.");
618        assert_eq!(wrapped[1].1.as_deref(), Some("https://a.example"));
619        assert_eq!(wrapped[3].1.as_deref(), Some("https://b.example"));
620        assert_eq!(
621            link_spans("httpx is not a link"),
622            vec![("http".into(), None), ("x is not a link".into(), None),]
623        );
624    }
625}