1use 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#[derive(Clone, Copy, PartialEq, Debug)]
21pub enum RowKind {
22 Header,
23 Marker,
25 Quoted(usize),
27 Text,
28}
29
30pub struct Row {
31 pub text: String,
32 pub kind: RowKind,
33 pub links: Vec<RowLink>,
36}
37
38#[derive(Clone, Debug, PartialEq)]
41pub struct RowLink {
42 pub start: usize,
43 pub end: usize,
44 pub url: String,
45}
46
47fn 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
61fn 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
79pub 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
97pub fn quote_depth(line: &str, re: ®ex_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
111pub struct PagerStyle<'a> {
120 pub quote_re: &'a regex_lite::Regex,
121 pub markers: bool,
123 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#[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 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 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 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
254pub 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
268pub 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
279pub 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
292fn 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 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#[derive(Clone, Copy)]
337pub struct Menu {
338 pub scroll: bool,
339 pub context: usize,
340 pub move_off: bool,
341}
342
343pub 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
373pub fn search_lines(
377 lines: &[String],
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]))
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 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 let rows = wrap_line_with("日本語のテキストです", 12, false);
412 assert_eq!(rows, ["日本語のテキ", "ストです"]);
413 assert!(rows.iter().all(|r| r.width() <= 12));
414 assert_eq!(
416 wrap_line_with("Schůzka zítra ráno", 12, true),
417 ["Schůzka", "zítra ráno"]
418 );
419 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 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 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, RowKind::Text,
500 RowKind::Quoted(1),
501 RowKind::Quoted(2),
502 RowKind::Marker,
503 RowKind::Text,
504 ]
505 );
506 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 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 assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
572 assert_eq!(recenter(0, 10, 10, 100, page), 10);
573 assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
575 assert_eq!(recenter(20, 19, 10, 100, page), 10);
576 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 assert_eq!(recenter(0, 7, 10, 100, m), 1);
590 assert_eq!(recenter(20, 22, 10, 100, m), 19);
592 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 assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
610 assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
612 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 assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
632 assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
633 assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
635 assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
637 assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
638 assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
640 assert_eq!(search_lines(&[], &m, 0, true), None);
641 let re = Matcher::new("^bet.");
643 assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
644 }
645}