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}
34
35/// Quote depth of a body line under $quote_regexp: the number of
36/// quote characters in the prefix match, 0 for unquoted text.
37pub fn quote_depth(line: &str, re: &regex_lite::Regex) -> usize {
38    match re.find(line) {
39        Some(m) if m.start() == 0 => m
40            .as_str()
41            .chars()
42            .filter(|c| !c.is_whitespace())
43            .count()
44            .max(1),
45        _ => 0,
46    }
47}
48
49/// The pager display: header block, separator, wrapped body (with
50/// mutt's `+` continuation markers), each row classified for
51/// coloring. The scroll math, the body search, and the drawing all
52/// share this; T (hide_quoted) drops quoted rows here, so every
53/// consumer agrees on what a line number means.
54/// What the config says about drawing a message: which lines count as
55/// quoted, whether a wrapped line is marked, and whether it breaks at
56/// a word.
57pub struct PagerStyle<'a> {
58    pub quote_re: &'a regex_lite::Regex,
59    /// mutt's $markers.
60    pub markers: bool,
61    /// mutt's $smart_wrap.
62    pub smart_wrap: bool,
63}
64
65impl<'a> PagerStyle<'a> {
66    pub fn of(config: &'a rmut_core::config::Config, quote_re: &'a regex_lite::Regex) -> Self {
67        PagerStyle {
68            quote_re,
69            markers: config.pager.markers.unwrap_or(true),
70            smart_wrap: config.pager.smart_wrap.unwrap_or(true),
71        }
72    }
73}
74
75pub fn pager_rows(
76    view: &MessageView,
77    width: usize,
78    full_headers: bool,
79    style: &PagerStyle,
80    hide_quoted: bool,
81) -> Vec<Row> {
82    let quote_re = style.quote_re;
83    let headers = if full_headers { &view.all } else { &view.brief };
84    let mut rows: Vec<Row> = headers
85        .iter()
86        .map(|(name, value)| Row {
87            text: format!("{name}: {value}"),
88            kind: RowKind::Header,
89        })
90        .collect();
91    rows.push(Row {
92        text: String::new(),
93        kind: RowKind::Text,
94    });
95    for line in view.body.lines() {
96        // Marker lines like the PGP verdict get the header treatment.
97        let marker = line.starts_with("[-- ") && line.ends_with(" --]");
98        let depth = if marker {
99            0
100        } else {
101            quote_depth(line, quote_re)
102        };
103        if hide_quoted && depth > 0 {
104            continue;
105        }
106        let kind = if marker {
107            RowKind::Marker
108        } else if depth > 0 {
109            RowKind::Quoted(depth)
110        } else {
111            RowKind::Text
112        };
113        for (i, wrapped) in wrap_line_with(line, width.saturating_sub(1), style.smart_wrap)
114            .into_iter()
115            .enumerate()
116        {
117            // mutt's $markers: a wrapped line says it is one.
118            let text = match i > 0 && style.markers {
119                true => format!("+{wrapped}"),
120                false => wrapped,
121            };
122            rows.push(Row { text, kind });
123        }
124    }
125    rows
126}
127
128/// The pager's display as plain text: what the body search runs over.
129pub fn pager_text_lines(
130    view: &MessageView,
131    width: usize,
132    full_headers: bool,
133    style: &PagerStyle,
134    hide_quoted: bool,
135) -> Vec<String> {
136    pager_rows(view, width, full_headers, style, hide_quoted)
137        .into_iter()
138        .map(|row| row.text)
139        .collect()
140}
141
142/// Total pager lines at the given width.
143pub fn pager_line_count(
144    view: &MessageView,
145    width: usize,
146    full_headers: bool,
147    style: &PagerStyle,
148    hide_quoted: bool,
149) -> usize {
150    pager_rows(view, width, full_headers, style, hide_quoted).len()
151}
152
153/// Word-wrap one body line to `width` columns (hard break when a single
154/// word is longer than the line). Tabs are expanded first.
155/// The same, with mutt's $smart_wrap: without it a long line breaks
156/// at the column rather than at the last space before it.
157pub fn wrap_line_with(line: &str, width: usize, smart: bool) -> Vec<String> {
158    let width = width.max(4);
159    let expanded = line.replace('\t', "    ");
160    let chars: Vec<char> = expanded.chars().collect();
161    if chars.len() <= width {
162        return vec![expanded];
163    }
164    let mut out = Vec::new();
165    let mut start = 0;
166    while start < chars.len() {
167        if chars.len() - start <= width {
168            out.push(chars[start..].iter().collect());
169            break;
170        }
171        let window_end = start + width;
172        let brk = match smart {
173            true => (start + 1..window_end)
174                .rev()
175                .find(|&i| chars[i] == ' ')
176                .unwrap_or(window_end),
177            false => window_end,
178        };
179        out.push(chars[start..brk].iter().collect());
180        start = if chars.get(brk) == Some(&' ') {
181            brk + 1
182        } else {
183            brk
184        };
185    }
186    out
187}
188
189/// mutt's $menu_scroll, $menu_context and $menu_move_off, as the
190/// index's recentering reads them.
191#[derive(Clone, Copy)]
192pub struct Menu {
193    pub scroll: bool,
194    pub context: usize,
195    pub move_off: bool,
196}
197
198/// Where the index's first row goes so the cursor stays on screen:
199/// mutt's menu_check_recenter, line for line. `top` is the row now at
200/// the top, `sel` the cursor, `rows` the screen, `max` the entries.
201/// With `scroll` the view moves just far enough (keeping `context`
202/// lines beyond the cursor); without it a whole page turns. Unless
203/// `move_off`, the last entry never scrolls up past the bottom.
204pub fn recenter(top: usize, sel: usize, rows: usize, max: usize, menu: Menu) -> usize {
205    let (mut top, sel, rows, max) = (top as i64, sel as i64, rows as i64, max as i64);
206    let c = (menu.context as i64).min(rows / 2);
207    if !menu.move_off && max <= rows {
208        top = 0;
209    } else if menu.scroll || rows <= 0 || c < menu.context as i64 {
210        if sel < top + c {
211            top = sel - c;
212        } else if sel >= top + rows - c {
213            top = sel - rows + c + 1;
214        }
215    } else if sel < top + c {
216        top -= (rows - c) * ((top + rows - 1 - sel) / (rows - c)) - c;
217    } else if sel >= top + rows - c {
218        top += (rows - c) * ((sel - top) / (rows - c)) - c;
219    }
220    if !menu.move_off {
221        top = top.min(max - rows);
222    }
223    top.max(0) as usize
224}
225
226/// One pager line cut around its URLs: `(text, None)` runs and
227/// `(url, Some(url))` spans, in order. Trailing sentence punctuation
228/// stays outside the link, the way a URL at the end of a sentence
229/// reads.
230pub fn link_spans(line: &str) -> Vec<(String, Option<String>)> {
231    let mut out = Vec::new();
232    let mut rest = line;
233    while let Some(at) = rest.find("http") {
234        let candidate = &rest[at..];
235        let scheme_ok = candidate.starts_with("http://") || candidate.starts_with("https://");
236        if !scheme_ok {
237            let cut = at + 4;
238            let (head, tail) = rest.split_at(cut);
239            out.push((head.to_string(), None));
240            rest = tail;
241            continue;
242        }
243        if at > 0 {
244            out.push((rest[..at].to_string(), None));
245        }
246        let end = candidate
247            .find(|c: char| c.is_whitespace() || matches!(c, '<' | '>' | '"' | '\'' | ')' | ']'))
248            .unwrap_or(candidate.len());
249        let mut url = &candidate[..end];
250        while let Some(stripped) = url.strip_suffix(['.', ',', ';', ':', '!', '?']) {
251            url = stripped;
252        }
253        out.push((url.to_string(), Some(url.to_string())));
254        rest = &candidate[url.len()..];
255    }
256    if !rest.is_empty() || out.is_empty() {
257        out.push((rest.to_string(), None));
258    }
259    out
260}
261
262/// The pager's text search: the next line matching `m` from `from`,
263/// wrapping around, and whether it wrapped. Both front ends step
264/// their pagers with this.
265pub fn search_lines(
266    lines: &[String],
267    m: &rmut_core::pattern::Matcher,
268    from: usize,
269    forward: bool,
270) -> Option<(usize, bool)> {
271    rmut_session::wrap_order(lines.len(), from, forward)
272        .into_iter()
273        .find(|&(idx, _)| m.is_match(&lines[idx]))
274}
275
276#[cfg(test)]
277mod tests {
278    use super::{
279        Menu, PagerStyle, RowKind, humanize_size, pager_rows, quote_depth, recenter, wrap_line_with,
280    };
281    use rmut_core::message::MessageView;
282    use rmut_session::default_quote_re;
283
284    #[test]
285    fn quote_depth_counts_prefix_marks() {
286        let re = default_quote_re();
287        assert_eq!(quote_depth("plain text", &re), 0);
288        assert_eq!(quote_depth("> quoted", &re), 1);
289        assert_eq!(quote_depth("> > deeper", &re), 2);
290        assert_eq!(quote_depth(">>tight", &re), 2);
291        assert_eq!(quote_depth("  | indented pipe", &re), 1);
292        // A > later in the line is not a quote.
293        assert_eq!(quote_depth("2 > 1", &re), 0);
294    }
295
296    #[test]
297    fn rows_classify_and_hide_quoted() {
298        let view = MessageView {
299            brief: vec![("From".into(), "jane@example.com".into())],
300            all: vec![("From".into(), "jane@example.com".into())],
301            body: "top\n> one\n> > two\n[-- marker --]\ntail".into(),
302        };
303        let re = default_quote_re();
304        let style = PagerStyle {
305            quote_re: &re,
306            markers: true,
307            smart_wrap: true,
308        };
309        let rows = pager_rows(&view, 80, false, &style, false);
310        let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
311        assert_eq!(
312            kinds,
313            vec![
314                RowKind::Header,
315                RowKind::Text, // separator
316                RowKind::Text,
317                RowKind::Quoted(1),
318                RowKind::Quoted(2),
319                RowKind::Marker,
320                RowKind::Text,
321            ]
322        );
323        // T drops the quoted rows for every consumer at once.
324        let hidden = pager_rows(&view, 80, false, &style, true);
325        assert_eq!(hidden.len(), rows.len() - 2);
326        assert!(hidden.iter().all(|r| !matches!(r.kind, RowKind::Quoted(_))));
327    }
328
329    #[test]
330    fn wrap_short_line_untouched() {
331        assert_eq!(wrap_line_with("hello", 10, true), vec!["hello"]);
332        assert_eq!(wrap_line_with("", 10, true), vec![""]);
333    }
334
335    #[test]
336    fn wrap_breaks_at_word_boundary() {
337        assert_eq!(
338            wrap_line_with("the quick brown fox", 10, true),
339            vec!["the quick", "brown fox"]
340        );
341    }
342
343    #[test]
344    fn without_smart_wrap_a_line_breaks_at_the_column() {
345        // mutt's $smart_wrap off: the break lands on the width, not
346        // on the last space before it.
347        assert_eq!(
348            wrap_line_with("alpha beta gamma", 10, false),
349            vec!["alpha beta", "gamma"]
350        );
351        assert_eq!(
352            wrap_line_with("alpha beta gamma", 10, true),
353            vec!["alpha", "beta gamma"]
354        );
355    }
356
357    #[test]
358    fn wrap_hard_breaks_long_words() {
359        assert_eq!(
360            wrap_line_with("abcdefghij", 4, true),
361            vec!["abcd", "efgh", "ij"]
362        );
363    }
364
365    #[test]
366    fn humanize_size_ranges() {
367        assert_eq!(humanize_size(0), "0");
368        assert_eq!(humanize_size(999), "999");
369        assert_eq!(humanize_size(2048), "2.0K");
370        assert_eq!(humanize_size(204800), "200K");
371        assert_eq!(humanize_size(2 * 1024 * 1024), "2.0M");
372    }
373
374    #[test]
375    fn recenter_scrolls_a_line_or_turns_a_page() {
376        let scroll = Menu {
377            scroll: true,
378            context: 0,
379            move_off: true,
380        };
381        let page = Menu {
382            scroll: false,
383            context: 0,
384            move_off: true,
385        };
386        // Moving down off a 10-row screen: scrolling shows one more
387        // line, paging turns the whole page (mutt's default).
388        assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
389        assert_eq!(recenter(0, 10, 10, 100, page), 10);
390        // Moving up off the top is symmetrical.
391        assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
392        assert_eq!(recenter(20, 19, 10, 100, page), 10);
393        // On screen already: nothing moves.
394        assert_eq!(recenter(20, 25, 10, 100, scroll), 20);
395        assert_eq!(recenter(20, 25, 10, 100, page), 20);
396    }
397
398    #[test]
399    fn recenter_keeps_context_lines() {
400        let m = Menu {
401            scroll: true,
402            context: 3,
403            move_off: true,
404        };
405        // The cursor stays three rows clear of the bottom edge.
406        assert_eq!(recenter(0, 7, 10, 100, m), 1);
407        // And of the top edge.
408        assert_eq!(recenter(20, 22, 10, 100, m), 19);
409        // Context is capped at half the screen (a 4-row screen: 2).
410        let big = Menu {
411            scroll: true,
412            context: 9,
413            move_off: true,
414        };
415        assert_eq!(recenter(0, 2, 4, 100, big), 1);
416    }
417
418    #[test]
419    fn recenter_move_off_pins_the_bottom() {
420        let stuck = Menu {
421            scroll: true,
422            context: 0,
423            move_off: false,
424        };
425        // Fewer entries than rows: the top is always the top.
426        assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
427        // The last page stays full: top never passes max - rows.
428        assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
429        // With move_off (the default) it may.
430        let free = Menu {
431            scroll: true,
432            context: 0,
433            move_off: true,
434        };
435        assert_eq!(recenter(95, 99, 10, 100, free), 95);
436    }
437
438    #[test]
439    fn search_lines_steps_and_wraps() {
440        use super::search_lines;
441        use rmut_core::pattern::Matcher;
442        let lines: Vec<String> = ["alpha", "the needle", "beta", "a Needle too"]
443            .iter()
444            .map(|s| s.to_string())
445            .collect();
446        let m = Matcher::new("needle");
447        // Forward from the top: the next hit, no wrap; case-insensitive.
448        assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
449        assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
450        // Past the last hit it wraps to the first.
451        assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
452        // Backwards, with and without the wrap.
453        assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
454        assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
455        // No match, and the empty pager.
456        assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
457        assert_eq!(search_lines(&[], &m, 0, true), None);
458        // A regex argument works like the patterns do.
459        let re = Matcher::new("^bet.");
460        assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
461    }
462
463    #[test]
464    fn urls_cut_out_of_a_line() {
465        use super::link_spans;
466        let spans = link_spans("see https://example.com/x, then more");
467        assert_eq!(
468            spans,
469            vec![
470                ("see ".into(), None),
471                (
472                    "https://example.com/x".into(),
473                    Some("https://example.com/x".into())
474                ),
475                (", then more".into(), None),
476            ]
477        );
478        assert_eq!(
479            link_spans("no links here"),
480            vec![("no links here".into(), None)]
481        );
482        let wrapped = link_spans("(https://a.example) and <https://b.example>.");
483        assert_eq!(wrapped[1].1.as_deref(), Some("https://a.example"));
484        assert_eq!(wrapped[3].1.as_deref(), Some("https://b.example"));
485        assert_eq!(
486            link_spans("httpx is not a link"),
487            vec![("http".into(), None), ("x is not a link".into(), None),]
488        );
489    }
490}