use rmut_core::message::MessageView;
pub fn humanize_size(bytes: u64) -> String {
match bytes {
0..=999 => format!("{bytes}"),
1000..=10_239 => format!("{:.1}K", bytes as f64 / 1024.0),
10_240..=1_048_575 => format!("{}K", bytes / 1024),
1_048_576..=10_485_759 => format!("{:.1}M", bytes as f64 / 1_048_576.0),
_ => format!("{}M", bytes / 1_048_576),
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum RowKind {
Header,
Marker,
Quoted(usize),
Text,
}
pub struct Row {
pub text: String,
pub kind: RowKind,
pub links: Vec<RowLink>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RowLink {
pub start: usize,
pub end: usize,
pub url: String,
}
fn url_ranges(line: &str) -> Vec<(usize, usize, String)> {
let mut at = 0;
let mut out = Vec::new();
for (text, url) in link_spans(line) {
let n = text.chars().count();
if let Some(url) = url {
out.push((at, at + n, url));
}
at += n;
}
out
}
fn row_links(
urls: &[(usize, usize, String)],
from: usize,
to: usize,
offset: usize,
) -> Vec<RowLink> {
urls.iter()
.filter(|(start, end, _)| *start < to && *end > from)
.map(|(start, end, url)| RowLink {
start: (*start).max(from) - from + offset,
end: (*end).min(to) - from + offset,
url: url.clone(),
})
.collect()
}
pub fn view_urls(view: &MessageView) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let headers = view
.brief
.iter()
.map(|(name, value)| format!("{name}: {value}"));
for line in headers.chain(view.body.lines().map(String::from)) {
for (_, _, url) in url_ranges(&line) {
if !out.contains(&url) {
out.push(url);
}
}
}
out
}
pub fn quote_depth(line: &str, re: ®ex_lite::Regex) -> usize {
match re.find(line) {
Some(m) if m.start() == 0 => m
.as_str()
.chars()
.filter(|c| !c.is_whitespace())
.count()
.max(1),
_ => 0,
}
}
pub struct PagerStyle<'a> {
pub quote_re: &'a regex_lite::Regex,
pub markers: bool,
pub smart_wrap: bool,
}
impl<'a> PagerStyle<'a> {
pub fn of(config: &'a rmut_core::config::Config, quote_re: &'a regex_lite::Regex) -> Self {
PagerStyle {
quote_re,
markers: config.pager.markers.unwrap_or(true),
smart_wrap: config.pager.smart_wrap.unwrap_or(true),
}
}
}
#[derive(Default)]
pub struct RowCache {
built: std::cell::RefCell<Option<(RowKey, std::rc::Rc<Vec<Row>>)>>,
}
#[derive(PartialEq)]
struct RowKey {
width: usize,
full_headers: bool,
hide_quoted: bool,
markers: bool,
smart_wrap: bool,
quote_re: String,
}
impl RowCache {
pub fn rows(
&self,
view: &MessageView,
width: usize,
full_headers: bool,
style: &PagerStyle,
hide_quoted: bool,
) -> std::rc::Rc<Vec<Row>> {
let key = RowKey {
width,
full_headers,
hide_quoted,
markers: style.markers,
smart_wrap: style.smart_wrap,
quote_re: style.quote_re.as_str().to_string(),
};
let mut built = self.built.borrow_mut();
if let Some((k, rows)) = built.as_ref()
&& *k == key
{
return rows.clone();
}
let rows = std::rc::Rc::new(pager_rows(view, width, full_headers, style, hide_quoted));
*built = Some((key, rows.clone()));
rows
}
}
pub fn pager_rows(
view: &MessageView,
width: usize,
full_headers: bool,
style: &PagerStyle,
hide_quoted: bool,
) -> Vec<Row> {
let quote_re = style.quote_re;
let headers = if full_headers { &view.all } else { &view.brief };
let mut rows: Vec<Row> = headers
.iter()
.map(|(name, value)| {
let text = format!("{name}: {value}");
let links = row_links(&url_ranges(&text), 0, usize::MAX, 0);
Row {
text,
kind: RowKind::Header,
links,
}
})
.collect();
rows.push(Row {
text: String::new(),
kind: RowKind::Text,
links: Vec::new(),
});
for line in view.body.lines() {
let marker = line.starts_with("[-- ") && line.ends_with(" --]");
let depth = if marker {
0
} else {
quote_depth(line, quote_re)
};
if hide_quoted && depth > 0 {
continue;
}
let kind = if marker {
RowKind::Marker
} else if depth > 0 {
RowKind::Quoted(depth)
} else {
RowKind::Text
};
let expanded = line.replace('\t', " ");
let chars: Vec<char> = expanded.chars().collect();
let urls = url_ranges(&expanded);
for (i, (from, to)) in wrap_ranges(&chars, width.saturating_sub(1), style.smart_wrap)
.into_iter()
.enumerate()
{
let wrapped: String = chars[from..to].iter().collect();
let marker = i > 0 && style.markers;
let text = match marker {
true => format!("+{wrapped}"),
false => wrapped,
};
let links = row_links(&urls, from, to, usize::from(marker));
rows.push(Row { text, kind, links });
}
}
rows
}
pub fn pager_text_lines(
view: &MessageView,
width: usize,
full_headers: bool,
style: &PagerStyle,
hide_quoted: bool,
) -> Vec<String> {
pager_rows(view, width, full_headers, style, hide_quoted)
.into_iter()
.map(|row| row.text)
.collect()
}
pub fn pager_line_count(
view: &MessageView,
width: usize,
full_headers: bool,
style: &PagerStyle,
hide_quoted: bool,
) -> usize {
pager_rows(view, width, full_headers, style, hide_quoted).len()
}
pub fn wrap_line_with(line: &str, width: usize, smart: bool) -> Vec<String> {
let expanded = line.replace('\t', " ");
let chars: Vec<char> = expanded.chars().collect();
wrap_ranges(&chars, width, smart)
.into_iter()
.map(|(from, to)| chars[from..to].iter().collect())
.collect()
}
fn wrap_ranges(chars: &[char], width: usize, smart: bool) -> Vec<(usize, usize)> {
use unicode_width::UnicodeWidthChar as _;
let width = width.max(4);
let cols = |c: char| c.width().unwrap_or(0);
if chars.iter().map(|&c| cols(c)).sum::<usize>() <= width {
return vec![(0, chars.len())];
}
let mut out = Vec::new();
let mut start = 0;
while start < chars.len() {
let mut window_end = start;
let mut used = 0;
while window_end < chars.len() && used + cols(chars[window_end]) <= width {
used += cols(chars[window_end]);
window_end += 1;
}
let window_end = window_end.max(start + 1);
if window_end >= chars.len() {
out.push((start, chars.len()));
break;
}
let brk = match smart {
true => (start + 1..window_end)
.rev()
.find(|&i| chars[i] == ' ')
.unwrap_or(window_end),
false => window_end,
};
out.push((start, brk));
start = if chars.get(brk) == Some(&' ') {
brk + 1
} else {
brk
};
}
out
}
#[derive(Clone, Copy)]
pub struct Menu {
pub scroll: bool,
pub context: usize,
pub move_off: bool,
}
pub fn recenter(top: usize, sel: usize, rows: usize, max: usize, menu: Menu) -> usize {
let (mut top, sel, rows, max) = (top as i64, sel as i64, rows as i64, max as i64);
let c = (menu.context as i64).min(rows / 2);
if !menu.move_off && max <= rows {
top = 0;
} else if menu.scroll || rows <= 0 || c < menu.context as i64 {
if sel < top + c {
top = sel - c;
} else if sel >= top + rows - c {
top = sel - rows + c + 1;
}
} else if sel < top + c {
top -= (rows - c) * ((top + rows - 1 - sel) / (rows - c)) - c;
} else if sel >= top + rows - c {
top += (rows - c) * ((sel - top) / (rows - c)) - c;
}
if !menu.move_off {
top = top.min(max - rows);
}
top.max(0) as usize
}
pub use rmut_core::links::link_spans;
pub fn search_lines<S: AsRef<str>>(
lines: &[S],
m: &rmut_core::pattern::Matcher,
from: usize,
forward: bool,
) -> Option<(usize, bool)> {
rmut_session::wrap_order(lines.len(), from, forward)
.into_iter()
.find(|&(idx, _)| m.is_match(lines[idx].as_ref()))
}
#[cfg(test)]
mod tests {
use super::{
Menu, PagerStyle, RowKind, humanize_size, pager_rows, quote_depth, recenter, wrap_line_with,
};
use rmut_core::message::MessageView;
use rmut_session::default_quote_re;
#[test]
fn quote_depth_counts_prefix_marks() {
let re = default_quote_re();
assert_eq!(quote_depth("plain text", &re), 0);
assert_eq!(quote_depth("> quoted", &re), 1);
assert_eq!(quote_depth("> > deeper", &re), 2);
assert_eq!(quote_depth(">>tight", &re), 2);
assert_eq!(quote_depth(" | indented pipe", &re), 1);
assert_eq!(quote_depth("2 > 1", &re), 0);
}
#[test]
fn wide_characters_wrap_by_the_columns_they_take() {
use unicode_width::UnicodeWidthStr as _;
let rows = wrap_line_with("日本語のテキストです", 12, false);
assert_eq!(rows, ["日本語のテキ", "ストです"]);
assert!(rows.iter().all(|r| r.width() <= 12));
assert_eq!(
wrap_line_with("Schůzka zítra ráno", 12, true),
["Schůzka", "zítra ráno"]
);
assert_eq!(
wrap_line_with("e\u{301}e\u{301}e\u{301}e\u{301}", 4, false).len(),
1
);
}
#[test]
fn a_wrapped_url_links_every_row_to_the_whole_url() {
use super::{Row, view_urls};
let url = "https://example.com/a/very/long/path/that/wraps";
let view = MessageView {
brief: vec![("List-Help".into(), "<https://lists.example/help>".into())],
all: vec![],
body: format!("see {url} now\nsee {url} again"),
};
let re = default_quote_re();
let style = PagerStyle {
quote_re: &re,
markers: true,
smart_wrap: false,
};
let rows = pager_rows(&view, 21, false, &style, false);
let header = &rows[0].links;
assert_eq!(header.len(), 1);
assert_eq!(
&rows[0].text[header[0].start..header[0].end],
"https://lists.example/help"
);
let body: Vec<&Row> = rows[2..].iter().filter(|r| !r.links.is_empty()).collect();
assert!(body.len() >= 4, "{}", body.len());
assert!(body.iter().all(|r| r.links.iter().all(|l| l.url == url)));
let end = rows.iter().position(|r| r.text.contains("now")).unwrap();
let covered: String = rows[2..=end]
.iter()
.flat_map(|r| {
r.links.iter().map(|l| {
r.text
.chars()
.skip(l.start)
.take(l.end - l.start)
.collect::<String>()
})
})
.collect();
assert_eq!(covered, url);
assert_eq!(
rows[3].links[0].start, 1,
"after the marker: {:?}",
rows[3].text
);
assert_eq!(
view_urls(&view),
vec!["https://lists.example/help".to_string(), url.to_string()]
);
}
#[test]
fn rows_classify_and_hide_quoted() {
let view = MessageView {
brief: vec![("From".into(), "jane@example.com".into())],
all: vec![("From".into(), "jane@example.com".into())],
body: "top\n> one\n> > two\n[-- marker --]\ntail".into(),
};
let re = default_quote_re();
let style = PagerStyle {
quote_re: &re,
markers: true,
smart_wrap: true,
};
let rows = pager_rows(&view, 80, false, &style, false);
let kinds: Vec<RowKind> = rows.iter().map(|r| r.kind).collect();
assert_eq!(
kinds,
vec![
RowKind::Header,
RowKind::Text, RowKind::Text,
RowKind::Quoted(1),
RowKind::Quoted(2),
RowKind::Marker,
RowKind::Text,
]
);
let hidden = pager_rows(&view, 80, false, &style, true);
assert_eq!(hidden.len(), rows.len() - 2);
assert!(hidden.iter().all(|r| !matches!(r.kind, RowKind::Quoted(_))));
}
#[test]
fn wrap_short_line_untouched() {
assert_eq!(wrap_line_with("hello", 10, true), vec!["hello"]);
assert_eq!(wrap_line_with("", 10, true), vec![""]);
}
#[test]
fn wrap_breaks_at_word_boundary() {
assert_eq!(
wrap_line_with("the quick brown fox", 10, true),
vec!["the quick", "brown fox"]
);
}
#[test]
fn without_smart_wrap_a_line_breaks_at_the_column() {
assert_eq!(
wrap_line_with("alpha beta gamma", 10, false),
vec!["alpha beta", "gamma"]
);
assert_eq!(
wrap_line_with("alpha beta gamma", 10, true),
vec!["alpha", "beta gamma"]
);
}
#[test]
fn wrap_hard_breaks_long_words() {
assert_eq!(
wrap_line_with("abcdefghij", 4, true),
vec!["abcd", "efgh", "ij"]
);
}
#[test]
fn humanize_size_ranges() {
assert_eq!(humanize_size(0), "0");
assert_eq!(humanize_size(999), "999");
assert_eq!(humanize_size(2048), "2.0K");
assert_eq!(humanize_size(204800), "200K");
assert_eq!(humanize_size(2 * 1024 * 1024), "2.0M");
}
#[test]
fn recenter_scrolls_a_line_or_turns_a_page() {
let scroll = Menu {
scroll: true,
context: 0,
move_off: true,
};
let page = Menu {
scroll: false,
context: 0,
move_off: true,
};
assert_eq!(recenter(0, 10, 10, 100, scroll), 1);
assert_eq!(recenter(0, 10, 10, 100, page), 10);
assert_eq!(recenter(20, 19, 10, 100, scroll), 19);
assert_eq!(recenter(20, 19, 10, 100, page), 10);
assert_eq!(recenter(20, 25, 10, 100, scroll), 20);
assert_eq!(recenter(20, 25, 10, 100, page), 20);
}
#[test]
fn recenter_keeps_context_lines() {
let m = Menu {
scroll: true,
context: 3,
move_off: true,
};
assert_eq!(recenter(0, 7, 10, 100, m), 1);
assert_eq!(recenter(20, 22, 10, 100, m), 19);
let big = Menu {
scroll: true,
context: 9,
move_off: true,
};
assert_eq!(recenter(0, 2, 4, 100, big), 1);
}
#[test]
fn recenter_move_off_pins_the_bottom() {
let stuck = Menu {
scroll: true,
context: 0,
move_off: false,
};
assert_eq!(recenter(3, 4, 10, 5, stuck), 0);
assert_eq!(recenter(95, 99, 10, 100, stuck), 90);
let free = Menu {
scroll: true,
context: 0,
move_off: true,
};
assert_eq!(recenter(95, 99, 10, 100, free), 95);
}
#[test]
fn search_lines_steps_and_wraps() {
use super::search_lines;
use rmut_core::pattern::Matcher;
let lines: Vec<String> = ["alpha", "the needle", "beta", "a Needle too"]
.iter()
.map(|s| s.to_string())
.collect();
let m = Matcher::new("needle");
assert_eq!(search_lines(&lines, &m, 0, true), Some((1, false)));
assert_eq!(search_lines(&lines, &m, 1, true), Some((3, false)));
assert_eq!(search_lines(&lines, &m, 3, true), Some((1, true)));
assert_eq!(search_lines(&lines, &m, 3, false), Some((1, false)));
assert_eq!(search_lines(&lines, &m, 1, false), Some((3, true)));
assert_eq!(search_lines(&lines, &Matcher::new("zzz"), 0, true), None);
assert_eq!(search_lines::<&str>(&[], &m, 0, true), None);
let re = Matcher::new("^bet.");
assert_eq!(search_lines(&lines, &re, 0, true), Some((2, false)));
}
}