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 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 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, RowKind::Text,
281 RowKind::Quoted(1),
282 RowKind::Quoted(2),
283 RowKind::Marker,
284 RowKind::Text,
285 ]
286 );
287 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 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 assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
353 assert_eq!(recenter(0, 10, 10, 100, page), 10);
354 assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
356 assert_eq!(recenter(20, 19, 10, 100, page), 10);
357 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 assert_eq!(recenter(0, 7, 10, 100, m), 1);
371 assert_eq!(recenter(20, 22, 10, 100, m), 19);
373 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 assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
391 assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
393 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 assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
413 assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
414 assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
416 assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
418 assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
419 assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
421 assert_eq!(search_lines(&[], &m, 0, true), None);
422 let re = Matcher::new("^bet.");
424 assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
425 }
426}