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/// The pager's text search: the next line matching `m` from `from`,
227/// wrapping around, and whether it wrapped. Both front ends step
228/// their pagers with this.
229pub fn search_lines(
230    lines: &[String],
231    m: &rmut_core::pattern::Matcher,
232    from: usize,
233    forward: bool,
234) -> Option<(usize, bool)> {
235    rmut_session::wrap_order(lines.len(), from, forward)
236        .into_iter()
237        .find(|&(idx, _)| m.is_match(&lines[idx]))
238}
239
240#[cfg(test)]
241mod tests {
242    use super::{
243        Menu, PagerStyle, RowKind, humanize_size, pager_rows, quote_depth, recenter, wrap_line_with,
244    };
245    use rmut_core::message::MessageView;
246    use rmut_session::default_quote_re;
247
248    #[test]
249    fn quote_depth_counts_prefix_marks() {
250        let re = default_quote_re();
251        assert_eq!(quote_depth("plain text", &re), 0);
252        assert_eq!(quote_depth("> quoted", &re), 1);
253        assert_eq!(quote_depth("> > deeper", &re), 2);
254        assert_eq!(quote_depth(">>tight", &re), 2);
255        assert_eq!(quote_depth("  | indented pipe", &re), 1);
256        // A > later in the line is not a quote.
257        assert_eq!(quote_depth("2 > 1", &re), 0);
258    }
259
260    #[test]
261    fn rows_classify_and_hide_quoted() {
262        let view = MessageView {
263            brief: vec![("From".into(), "jane@example.com".into())],
264            all: vec![("From".into(), "jane@example.com".into())],
265            body: "top\n> one\n> > two\n[-- marker --]\ntail".into(),
266        };
267        let re = default_quote_re();
268        let style = PagerStyle {
269            quote_re: &re,
270            markers: true,
271            smart_wrap: true,
272        };
273        let rows = pager_rows(&view, 80, false, &style, false);
274        let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
275        assert_eq!(
276            kinds,
277            vec![
278                RowKind::Header,
279                RowKind::Text, // separator
280                RowKind::Text,
281                RowKind::Quoted(1),
282                RowKind::Quoted(2),
283                RowKind::Marker,
284                RowKind::Text,
285            ]
286        );
287        // T drops the quoted rows for every consumer at once.
288        let hidden = pager_rows(&view, 80, false, &style, true);
289        assert_eq!(hidden.len(), rows.len() - 2);
290        assert!(hidden.iter().all(|r| !matches!(r.kind, RowKind::Quoted(_))));
291    }
292
293    #[test]
294    fn wrap_short_line_untouched() {
295        assert_eq!(wrap_line_with("hello", 10, true), vec!["hello"]);
296        assert_eq!(wrap_line_with("", 10, true), vec![""]);
297    }
298
299    #[test]
300    fn wrap_breaks_at_word_boundary() {
301        assert_eq!(
302            wrap_line_with("the quick brown fox", 10, true),
303            vec!["the quick", "brown fox"]
304        );
305    }
306
307    #[test]
308    fn without_smart_wrap_a_line_breaks_at_the_column() {
309        // mutt's $smart_wrap off: the break lands on the width, not
310        // on the last space before it.
311        assert_eq!(
312            wrap_line_with("alpha beta gamma", 10, false),
313            vec!["alpha beta", "gamma"]
314        );
315        assert_eq!(
316            wrap_line_with("alpha beta gamma", 10, true),
317            vec!["alpha", "beta gamma"]
318        );
319    }
320
321    #[test]
322    fn wrap_hard_breaks_long_words() {
323        assert_eq!(
324            wrap_line_with("abcdefghij", 4, true),
325            vec!["abcd", "efgh", "ij"]
326        );
327    }
328
329    #[test]
330    fn humanize_size_ranges() {
331        assert_eq!(humanize_size(0), "0");
332        assert_eq!(humanize_size(999), "999");
333        assert_eq!(humanize_size(2048), "2.0K");
334        assert_eq!(humanize_size(204800), "200K");
335        assert_eq!(humanize_size(2 * 1024 * 1024), "2.0M");
336    }
337
338    #[test]
339    fn recenter_scrolls_a_line_or_turns_a_page() {
340        let scroll = Menu {
341            scroll: true,
342            context: 0,
343            move_off: true,
344        };
345        let page = Menu {
346            scroll: false,
347            context: 0,
348            move_off: true,
349        };
350        // Moving down off a 10-row screen: scrolling shows one more
351        // line, paging turns the whole page (mutt's default).
352        assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
353        assert_eq!(recenter(0, 10, 10, 100, page), 10);
354        // Moving up off the top is symmetrical.
355        assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
356        assert_eq!(recenter(20, 19, 10, 100, page), 10);
357        // On screen already: nothing moves.
358        assert_eq!(recenter(20, 25, 10, 100, scroll), 20);
359        assert_eq!(recenter(20, 25, 10, 100, page), 20);
360    }
361
362    #[test]
363    fn recenter_keeps_context_lines() {
364        let m = Menu {
365            scroll: true,
366            context: 3,
367            move_off: true,
368        };
369        // The cursor stays three rows clear of the bottom edge.
370        assert_eq!(recenter(0, 7, 10, 100, m), 1);
371        // And of the top edge.
372        assert_eq!(recenter(20, 22, 10, 100, m), 19);
373        // Context is capped at half the screen (a 4-row screen: 2).
374        let big = Menu {
375            scroll: true,
376            context: 9,
377            move_off: true,
378        };
379        assert_eq!(recenter(0, 2, 4, 100, big), 1);
380    }
381
382    #[test]
383    fn recenter_move_off_pins_the_bottom() {
384        let stuck = Menu {
385            scroll: true,
386            context: 0,
387            move_off: false,
388        };
389        // Fewer entries than rows: the top is always the top.
390        assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
391        // The last page stays full: top never passes max - rows.
392        assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
393        // With move_off (the default) it may.
394        let free = Menu {
395            scroll: true,
396            context: 0,
397            move_off: true,
398        };
399        assert_eq!(recenter(95, 99, 10, 100, free), 95);
400    }
401
402    #[test]
403    fn search_lines_steps_and_wraps() {
404        use super::search_lines;
405        use rmut_core::pattern::Matcher;
406        let lines: Vec<String> = ["alpha", "the needle", "beta", "a Needle too"]
407            .iter()
408            .map(|s| s.to_string())
409            .collect();
410        let m = Matcher::new("needle");
411        // Forward from the top: the next hit, no wrap; case-insensitive.
412        assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
413        assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
414        // Past the last hit it wraps to the first.
415        assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
416        // Backwards, with and without the wrap.
417        assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
418        assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
419        // No match, and the empty pager.
420        assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
421        assert_eq!(search_lines(&[], &m, 0, true), None);
422        // A regex argument works like the patterns do.
423        let re = Matcher::new("^bet.");
424        assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
425    }
426}