Skip to main content

rmut_core/
format.rs

1//! Mutt-like index format strings: `%[-][min][.max]X` where X is
2//! `C` number, `Z` status/flag/mark chars, `d` date, `F` from, `L`
3//! its list-aware variant ("To <list>" for List-Id mail), `c` size,
4//! `l` body lines, `y` X-Label, `s` subject, `%` a literal percent. `-`
5//! left-aligns, `min` pads, `.max` truncates (all in characters).
6//! Mutt conditionals work too: `%?X?then&else?` renders `then` when
7//! field X is set and non-zero.
8
9/// Mutt's default index_format, with its inline `%{%b %e}` date as
10/// `%-6d` (the date_format default is "%b %e").
11pub const DEFAULT_FORMAT: &str = "%4C %Z %-6d %-15.15L (%?l?%4l&%4c?) %s";
12
13/// Renders exactly rmut's classic status line; override with
14/// `[ui] status_format`. Status specifiers: %f mailbox, %m messages,
15/// %M shown-when-limited, %n new, %u unread, %d deleted, %F flagged,
16/// %t tagged, %s sort, %V limit pattern, %r mailbox mark (* pending
17/// changes, % read-only), %P index position, %v version; `%>X` fills
18/// the rest of the width with X, right-aligning what follows.
19pub const DEFAULT_STATUS_FORMAT: &str =
20    "---rmut%r: %f [Msgs:%?M?%M/?%m New:%n%?d? Del:%d?] (sort:%s)%?V? (limit:%V)?";
21
22pub struct IndexFields<'a> {
23    pub number: usize,
24    pub status: char,
25    pub flag: char,
26    /// Third %Z slot: '*' tagged, or how the mail addresses me
27    /// ('+' only me in To, 'T' me among others, 'C' me in Cc).
28    pub mark: char,
29    pub date: &'a str,
30    pub from: &'a str,
31    pub size: &'a str,
32    /// Body line count; None (header-only cache file) makes `%l`
33    /// empty, so `%?l?…&…?` shows its else branch.
34    pub lines: Option<usize>,
35    /// Mailing-list name (List-Id); `%L` shows "To <name>" instead of
36    /// the author when set.
37    pub list: Option<&'a str>,
38    /// Messages hidden under this collapsed thread root (`%M`).
39    pub hidden: Option<usize>,
40    /// mutt's X-Label, for `%y`.
41    pub label: Option<&'a str>,
42    pub subject: &'a str,
43}
44
45/// The raw text of one specifier, for both rendering and the
46/// conditional set/unset test.
47fn value_of(spec: char, f: &IndexFields) -> String {
48    match spec {
49        'C' => f.number.to_string(),
50        'Z' => format!("{}{}{}", f.status, f.flag, f.mark),
51        'd' => f.date.to_string(),
52        'F' => f.from.to_string(),
53        'L' => match f.list {
54            Some(list) => format!("To {list}"),
55            None => f.from.to_string(),
56        },
57        'c' => f.size.to_string(),
58        'l' => f.lines.map(|n| n.to_string()).unwrap_or_default(),
59        'M' => f.hidden.map(|n| n.to_string()).unwrap_or_default(),
60        's' => f.subject.to_string(),
61        'y' => f.label.unwrap_or_default().to_string(),
62        '%' => "%".to_string(),
63        other => format!("%{other}"),
64    }
65}
66
67pub fn render(fmt: &str, f: &IndexFields) -> String {
68    render_with(fmt, &|spec| value_of(spec, f))
69}
70
71/// `render_with` plus mutt's `%>X`: everything after it is pushed to
72/// the right edge of `width`, the gap filled with X (the status line
73/// uses this; only the first `%>` counts).
74pub fn render_status(fmt: &str, width: usize, value_of: &dyn Fn(char) -> String) -> String {
75    let Some((left_fmt, rest)) = fmt.split_once("%>") else {
76        return render_with(fmt, value_of);
77    };
78    let mut rest = rest.chars();
79    let fill = rest.next().unwrap_or(' ');
80    let left = render_with(left_fmt, value_of);
81    let right = render_with(rest.as_str(), value_of);
82    let used = left.chars().count() + right.chars().count();
83    let gap = fill.to_string().repeat(width.saturating_sub(used));
84    format!("{left}{gap}{right}")
85}
86
87/// The `%[-][min][.max]X` + `%?X?then&else?` machinery over any
88/// specifier set; index lines and the status line share it. Unknown
89/// specifiers should come back as `"%x"` to stay visible.
90pub fn render_with(fmt: &str, value_of: &dyn Fn(char) -> String) -> String {
91    let mut out = String::new();
92    let mut chars = fmt.chars().peekable();
93    while let Some(c) = chars.next() {
94        if c != '%' {
95            out.push(c);
96            continue;
97        }
98        // %?X?then&else?, mutt's conditional on field X.
99        if chars.peek() == Some(&'?') {
100            chars.next();
101            let Some(spec) = chars.next() else { break };
102            if chars.peek() == Some(&'?') {
103                chars.next();
104            }
105            let mut then_part = String::new();
106            let mut else_part = String::new();
107            let mut cur = &mut then_part;
108            for c in chars.by_ref() {
109                match c {
110                    '&' => cur = &mut else_part,
111                    '?' => break,
112                    c => cur.push(c),
113                }
114            }
115            let value = value_of(spec);
116            let chosen = if value.trim().is_empty() || value.trim() == "0" {
117                &else_part
118            } else {
119                &then_part
120            };
121            out.push_str(&render_with(chosen, value_of));
122            continue;
123        }
124        let mut left = false;
125        if chars.peek() == Some(&'-') {
126            left = true;
127            chars.next();
128        }
129        let mut min = 0usize;
130        while let Some(d) = chars.peek().and_then(|c| c.to_digit(10)) {
131            min = min * 10 + d as usize;
132            chars.next();
133        }
134        let mut max = usize::MAX;
135        if chars.peek() == Some(&'.') {
136            chars.next();
137            max = 0;
138            while let Some(d) = chars.peek().and_then(|c| c.to_digit(10)) {
139                max = max * 10 + d as usize;
140                chars.next();
141            }
142        }
143        let Some(spec) = chars.next() else { break };
144        let value = value_of(spec);
145        let mut v: Vec<char> = value.chars().collect();
146        if v.len() > max {
147            v.truncate(max);
148        }
149        let pad = min.saturating_sub(v.len());
150        if left {
151            out.extend(v);
152            out.extend(std::iter::repeat_n(' ', pad));
153        } else {
154            out.extend(std::iter::repeat_n(' ', pad));
155            out.extend(v);
156        }
157    }
158    out
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn status_right_align_fills_the_width() {
167        let v = |c: char| match c {
168            'a' => "A".to_string(),
169            'b' => "BB".to_string(),
170            _ => String::new(),
171        };
172        assert_eq!(render_status("%a%>-%b", 8, &v), "A-----BB");
173        // Space fill, mutt's usual "%> ".
174        assert_eq!(render_status("%a%> %b", 6, &v), "A   BB");
175        // Without %> it renders plainly, no padding.
176        assert_eq!(render_status("%a %b", 8, &v), "A BB");
177        // Too narrow: the gap just collapses.
178        assert_eq!(render_status("%a%>-%b", 2, &v), "ABB");
179    }
180
181    fn fields() -> IndexFields<'static> {
182        IndexFields {
183            number: 7,
184            status: 'N',
185            flag: '!',
186            mark: 'T',
187            date: "Jul 06",
188            from: "Jane Doe",
189            size: "1.2K",
190            lines: None,
191            list: None,
192            label: None,
193            hidden: None,
194            subject: "Lunch",
195        }
196    }
197
198    #[test]
199    fn default_format_matches_layout() {
200        assert_eq!(
201            render(DEFAULT_FORMAT, &fields()),
202            "   7 N!T Jul 06 Jane Doe        (1.2K) Lunch"
203        );
204    }
205
206    #[test]
207    fn conditionals_and_list_alias() {
208        let f = fields();
209        // Unknown line count (header-only cache): the else branch wins.
210        assert_eq!(render("(%?l?%4l&%5c?)", &f), "( 1.2K)");
211        assert_eq!(render("%?s?have&none?", &f), "have");
212        assert_eq!(render("%?l?lines?", &f), "");
213        assert_eq!(render("%-10.10L|", &f), "Jane Doe  |");
214        // A known count renders and satisfies the conditional.
215        let counted = IndexFields {
216            lines: Some(42),
217            ..fields()
218        };
219        assert_eq!(render("(%?l?%4l&%5c?)", &counted), "(  42)");
220        assert_eq!(render("%l", &counted), "42");
221        // A zero-line body is "unset" for the conditional, like mutt.
222        let empty = IndexFields {
223            lines: Some(0),
224            ..fields()
225        };
226        assert_eq!(render("%?l?%l&-?", &empty), "-");
227        // %L prefers the mailing list over the author.
228        let listed = IndexFields {
229            list: Some("dev"),
230            ..fields()
231        };
232        assert_eq!(render("%-10.10L|", &listed), "To dev    |");
233        assert_eq!(render("%F", &listed), "Jane Doe");
234    }
235
236    #[test]
237    fn width_precision_and_alignment() {
238        let f = fields();
239        assert_eq!(render("%-4.4F", &f), "Jane");
240        assert_eq!(render("%10s", &f), "     Lunch");
241        assert_eq!(render("%.3s", &f), "Lun");
242        assert_eq!(render("100%% %C", &f), "100% 7");
243    }
244
245    #[test]
246    fn unknown_specifier_is_kept_visibly() {
247        assert_eq!(render("%q", &fields()), "%q");
248    }
249}