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
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 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 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
202pub 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
216pub 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
227pub 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
240fn 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#[derive(Clone, Copy)]
274pub struct Menu {
275 pub scroll: bool,
276 pub context: usize,
277 pub move_off: bool,
278}
279
280pub 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 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
344pub 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 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 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 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, RowKind::Text,
452 RowKind::Quoted(1),
453 RowKind::Quoted(2),
454 RowKind::Marker,
455 RowKind::Text,
456 ]
457 );
458 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 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 assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
524 assert_eq!(recenter(0, 10, 10, 100, page), 10);
525 assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
527 assert_eq!(recenter(20, 19, 10, 100, page), 10);
528 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 assert_eq!(recenter(0, 7, 10, 100, m), 1);
542 assert_eq!(recenter(20, 22, 10, 100, m), 19);
544 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 assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
562 assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
564 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 assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
584 assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
585 assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
587 assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
589 assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
590 assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
592 assert_eq!(search_lines(&[], &m, 0, true), None);
593 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}