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 use rmut_core::links::link_spans;
309
310pub 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 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 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 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, RowKind::Text,
418 RowKind::Quoted(1),
419 RowKind::Quoted(2),
420 RowKind::Marker,
421 RowKind::Text,
422 ]
423 );
424 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 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 assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
490 assert_eq!(recenter(0, 10, 10, 100, page), 10);
491 assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
493 assert_eq!(recenter(20, 19, 10, 100, page), 10);
494 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 assert_eq!(recenter(0, 7, 10, 100, m), 1);
508 assert_eq!(recenter(20, 22, 10, 100, m), 19);
510 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 assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
528 assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
530 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 assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
550 assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
551 assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
553 assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
555 assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
556 assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
558 assert_eq!(search_lines(&[], &m, 0, true), None);
559 let re = Matcher::new("^bet.");
561 assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
562 }
563}