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}
34
35pub fn quote_depth(line: &str, re: ®ex_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
49pub struct PagerStyle<'a> {
58 pub quote_re: &'a regex_lite::Regex,
59 pub markers: bool,
61 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 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 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
128pub 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
142pub 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
153pub 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#[derive(Clone, Copy)]
192pub struct Menu {
193 pub scroll: bool,
194 pub context: usize,
195 pub move_off: bool,
196}
197
198pub 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
226pub 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
262pub 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 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, RowKind::Text,
317 RowKind::Quoted(1),
318 RowKind::Quoted(2),
319 RowKind::Marker,
320 RowKind::Text,
321 ]
322 );
323 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 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 assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
389 assert_eq!(recenter(0, 10, 10, 100, page), 10);
390 assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
392 assert_eq!(recenter(20, 19, 10, 100, page), 10);
393 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 assert_eq!(recenter(0, 7, 10, 100, m), 1);
407 assert_eq!(recenter(20, 22, 10, 100, m), 19);
409 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 assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
427 assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
429 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 assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
449 assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
450 assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
452 assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
454 assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
455 assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
457 assert_eq!(search_lines(&[], &m, 0, true), None);
458 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}