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
308pub use rmut_core::links::link_spans;
309
310/// The pager's text search: the next line matching `m` from `from`,
311/// wrapping around, and whether it wrapped. Both front ends step
312/// their pagers with this.
313pub fn search_lines(
314    lines: &[String],
315    m: &rmut_core::pattern::Matcher,
316    from: usize,
317    forward: bool,
318) -> Option<(usize, bool)> {
319    rmut_session::wrap_order(lines.len(), from, forward)
320        .into_iter()
321        .find(|&(idx, _)| m.is_match(&lines[idx]))
322}
323
324#[cfg(test)]
325mod tests {
326    use super::{
327        Menu, PagerStyle, RowKind, humanize_size, pager_rows, quote_depth, recenter, wrap_line_with,
328    };
329    use rmut_core::message::MessageView;
330    use rmut_session::default_quote_re;
331
332    #[test]
333    fn quote_depth_counts_prefix_marks() {
334        let re = default_quote_re();
335        assert_eq!(quote_depth("plain text", &re), 0);
336        assert_eq!(quote_depth("> quoted", &re), 1);
337        assert_eq!(quote_depth("> > deeper", &re), 2);
338        assert_eq!(quote_depth(">>tight", &re), 2);
339        assert_eq!(quote_depth("  | indented pipe", &re), 1);
340        // A > later in the line is not a quote.
341        assert_eq!(quote_depth("2 > 1", &re), 0);
342    }
343
344    #[test]
345    fn a_wrapped_url_links_every_row_to_the_whole_url() {
346        use super::{Row, view_urls};
347        let url = "https://example.com/a/very/long/path/that/wraps";
348        let view = MessageView {
349            brief: vec![("List-Help".into(), "<https://lists.example/help>".into())],
350            all: vec![],
351            body: format!("see {url} now\nsee {url} again"),
352        };
353        let re = default_quote_re();
354        let style = PagerStyle {
355            quote_re: &re,
356            markers: true,
357            smart_wrap: false,
358        };
359        let rows = pager_rows(&view, 21, false, &style, false);
360        let header = &rows[0].links;
361        assert_eq!(header.len(), 1);
362        assert_eq!(
363            &rows[0].text[header[0].start..header[0].end],
364            "https://lists.example/help"
365        );
366        // Every row the URL touches links to all of it; the text the
367        // link covers is what is on the row, after the "+" marker.
368        let body: Vec<&Row> = rows[2..].iter().filter(|r| !r.links.is_empty()).collect();
369        assert!(body.len() >= 4, "{}", body.len());
370        assert!(body.iter().all(|r| r.links.iter().all(|l| l.url == url)));
371        // The first line's rows run up to the one holding " now".
372        let end = rows.iter().position(|r| r.text.contains("now")).unwrap();
373        let covered: String = rows[2..=end]
374            .iter()
375            .flat_map(|r| {
376                r.links.iter().map(|l| {
377                    r.text
378                        .chars()
379                        .skip(l.start)
380                        .take(l.end - l.start)
381                        .collect::<String>()
382                })
383            })
384            .collect();
385        assert_eq!(covered, url);
386        assert_eq!(
387            rows[3].links[0].start, 1,
388            "after the marker: {:?}",
389            rows[3].text
390        );
391        assert_eq!(
392            view_urls(&view),
393            vec!["https://lists.example/help".to_string(), url.to_string()]
394        );
395    }
396
397    #[test]
398    fn rows_classify_and_hide_quoted() {
399        let view = MessageView {
400            brief: vec![("From".into(), "jane@example.com".into())],
401            all: vec![("From".into(), "jane@example.com".into())],
402            body: "top\n> one\n> > two\n[-- marker --]\ntail".into(),
403        };
404        let re = default_quote_re();
405        let style = PagerStyle {
406            quote_re: &re,
407            markers: true,
408            smart_wrap: true,
409        };
410        let rows = pager_rows(&view, 80, false, &style, false);
411        let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
412        assert_eq!(
413            kinds,
414            vec![
415                RowKind::Header,
416                RowKind::Text, // separator
417                RowKind::Text,
418                RowKind::Quoted(1),
419                RowKind::Quoted(2),
420                RowKind::Marker,
421                RowKind::Text,
422            ]
423        );
424        // T drops the quoted rows for every consumer at once.
425        let hidden = pager_rows(&view, 80, false, &style, true);
426        assert_eq!(hidden.len(), rows.len() - 2);
427        assert!(hidden.iter().all(|r| !matches!(r.kind, RowKind::Quoted(_))));
428    }
429
430    #[test]
431    fn wrap_short_line_untouched() {
432        assert_eq!(wrap_line_with("hello", 10, true), vec!["hello"]);
433        assert_eq!(wrap_line_with("", 10, true), vec![""]);
434    }
435
436    #[test]
437    fn wrap_breaks_at_word_boundary() {
438        assert_eq!(
439            wrap_line_with("the quick brown fox", 10, true),
440            vec!["the quick", "brown fox"]
441        );
442    }
443
444    #[test]
445    fn without_smart_wrap_a_line_breaks_at_the_column() {
446        // mutt's $smart_wrap off: the break lands on the width, not
447        // on the last space before it.
448        assert_eq!(
449            wrap_line_with("alpha beta gamma", 10, false),
450            vec!["alpha beta", "gamma"]
451        );
452        assert_eq!(
453            wrap_line_with("alpha beta gamma", 10, true),
454            vec!["alpha", "beta gamma"]
455        );
456    }
457
458    #[test]
459    fn wrap_hard_breaks_long_words() {
460        assert_eq!(
461            wrap_line_with("abcdefghij", 4, true),
462            vec!["abcd", "efgh", "ij"]
463        );
464    }
465
466    #[test]
467    fn humanize_size_ranges() {
468        assert_eq!(humanize_size(0), "0");
469        assert_eq!(humanize_size(999), "999");
470        assert_eq!(humanize_size(2048), "2.0K");
471        assert_eq!(humanize_size(204800), "200K");
472        assert_eq!(humanize_size(2 * 1024 * 1024), "2.0M");
473    }
474
475    #[test]
476    fn recenter_scrolls_a_line_or_turns_a_page() {
477        let scroll = Menu {
478            scroll: true,
479            context: 0,
480            move_off: true,
481        };
482        let page = Menu {
483            scroll: false,
484            context: 0,
485            move_off: true,
486        };
487        // Moving down off a 10-row screen: scrolling shows one more
488        // line, paging turns the whole page (mutt's default).
489        assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
490        assert_eq!(recenter(0, 10, 10, 100, page), 10);
491        // Moving up off the top is symmetrical.
492        assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
493        assert_eq!(recenter(20, 19, 10, 100, page), 10);
494        // On screen already: nothing moves.
495        assert_eq!(recenter(20, 25, 10, 100, scroll), 20);
496        assert_eq!(recenter(20, 25, 10, 100, page), 20);
497    }
498
499    #[test]
500    fn recenter_keeps_context_lines() {
501        let m = Menu {
502            scroll: true,
503            context: 3,
504            move_off: true,
505        };
506        // The cursor stays three rows clear of the bottom edge.
507        assert_eq!(recenter(0, 7, 10, 100, m), 1);
508        // And of the top edge.
509        assert_eq!(recenter(20, 22, 10, 100, m), 19);
510        // Context is capped at half the screen (a 4-row screen: 2).
511        let big = Menu {
512            scroll: true,
513            context: 9,
514            move_off: true,
515        };
516        assert_eq!(recenter(0, 2, 4, 100, big), 1);
517    }
518
519    #[test]
520    fn recenter_move_off_pins_the_bottom() {
521        let stuck = Menu {
522            scroll: true,
523            context: 0,
524            move_off: false,
525        };
526        // Fewer entries than rows: the top is always the top.
527        assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
528        // The last page stays full: top never passes max - rows.
529        assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
530        // With move_off (the default) it may.
531        let free = Menu {
532            scroll: true,
533            context: 0,
534            move_off: true,
535        };
536        assert_eq!(recenter(95, 99, 10, 100, free), 95);
537    }
538
539    #[test]
540    fn search_lines_steps_and_wraps() {
541        use super::search_lines;
542        use rmut_core::pattern::Matcher;
543        let lines: Vec<String> = ["alpha", "the needle", "beta", "a Needle too"]
544            .iter()
545            .map(|s| s.to_string())
546            .collect();
547        let m = Matcher::new("needle");
548        // Forward from the top: the next hit, no wrap; case-insensitive.
549        assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
550        assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
551        // Past the last hit it wraps to the first.
552        assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
553        // Backwards, with and without the wrap.
554        assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
555        assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
556        // No match, and the empty pager.
557        assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
558        assert_eq!(search_lines(&[], &m, 0, true), None);
559        // A regex argument works like the patterns do.
560        let re = Matcher::new("^bet.");
561        assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
562    }
563}