Skip to main content

rmut_front/
status.rs

1//! The status bar and the terminal title: mutt's $status_format,
2//! $ts_status_format and $pager_format, expanded from the session.
3//! Both front ends show the same line.
4
5use rmut_core::config::Config;
6use rmut_core::format;
7use rmut_core::message::MessageView;
8use rmut_session::Session;
9
10use crate::pager::{PagerStyle, RowCache};
11
12/// mutt's $wrap: the pager's text width at a screen width. Positive
13/// caps it, negative leaves that margin (never under 20 columns).
14pub fn pager_wrap(config: &Config, width: usize) -> usize {
15    match config.pager.wrap {
16        Some(n) if n > 0 => (n as usize).min(width),
17        Some(n) if n < 0 => width.saturating_sub(n.unsigned_abs() as usize).max(20),
18        _ => width,
19    }
20}
21
22/// What the pager's status line needs to know about the open message.
23pub struct PagerView<'a> {
24    pub view: &'a MessageView,
25    /// The front end's rows for this view: the status line counts them.
26    pub rows: &'a RowCache,
27    pub scroll: usize,
28    pub full_headers: bool,
29    pub hide_quoted: bool,
30}
31
32pub fn index_status(session: &Session, index_offset: usize, width: usize, rows: usize) -> String {
33    let fmt = session
34        .config
35        .ui
36        .status_format
37        .as_deref()
38        .unwrap_or(format::DEFAULT_STATUS_FORMAT);
39    format::render_status(fmt, width, &|spec| {
40        index_status_field(session, index_offset, spec, rows)
41    })
42}
43
44/// mutt's $ts_status_format: the terminal title, from the same fields
45/// as the status line (a wide width, so %>… padding does not clip).
46pub fn index_title(session: &Session, index_offset: usize, rows: usize) -> String {
47    let fmt = session
48        .config
49        .ui
50        .title_format
51        .as_deref()
52        .unwrap_or("rmut: %f");
53    format::render_status(fmt, 200, &|spec| {
54        index_status_field(session, index_offset, spec, rows)
55    })
56    .trim_end()
57    .to_string()
58}
59
60/// One status specifier's value, shared by the bottom bar and the
61/// terminal title.
62fn index_status_field(session: &Session, index_offset: usize, spec: char, rows: usize) -> String {
63    match spec {
64        'f' => session.title.clone(),
65        'm' => session.msgs.len().to_string(),
66        // Shown message count, only when a limit narrows the view.
67        'M' => {
68            if session.visible.len() != session.msgs.len() {
69                session.visible.len().to_string()
70            } else {
71                String::new()
72            }
73        }
74        'n' => session.new_count().to_string(),
75        'u' => session
76            .msgs
77            .iter()
78            .filter(|m| !m.env.file.flags.seen)
79            .count()
80            .to_string(),
81        'd' => session.deleted_count().to_string(),
82        'F' => session
83            .msgs
84            .iter()
85            .filter(|m| m.env.file.flags.flagged)
86            .count()
87            .to_string(),
88        't' => session
89            .msgs
90            .iter()
91            .filter(|m| m.env.tagged)
92            .count()
93            .to_string(),
94        's' => format!(
95            "{}{}",
96            session.sort.name(),
97            if session.sort_rev { "-rev" } else { "" }
98        ),
99        'V' => session
100            .limit
101            .as_ref()
102            .map(|(s, _)| s.clone())
103            .unwrap_or_default(),
104        'r' => {
105            // mutt's $status_chars: [0] unchanged, [1] changed, [2]
106            // read-only. Unset keeps rmut's own marks.
107            let chars: Option<Vec<char>> = session
108                .config
109                .ui
110                .status_chars
111                .as_deref()
112                .map(|s| s.chars().collect());
113            let pick = |i: usize, default: &str| -> String {
114                chars
115                    .as_ref()
116                    .and_then(|c| c.get(i))
117                    .map(|c| c.to_string())
118                    .unwrap_or_else(|| default.to_string())
119            };
120            if session.read_only {
121                pick(2, "%")
122            } else if session.pending_count() > 0 {
123                pick(1, "*")
124            } else {
125                pick(0, "")
126            }
127        }
128        'v' => env!("CARGO_PKG_VERSION").to_string(),
129        // Index scroll position, like mutt's %P.
130        'P' => {
131            let len = session.visible.len();
132            if len <= rows {
133                "all".into()
134            } else if index_offset == 0 {
135                "top".into()
136            } else if index_offset + rows >= len {
137                "bot".into()
138            } else {
139                format!("{}%", (index_offset + rows) * 100 / len)
140            }
141        }
142        '%' => "%".to_string(),
143        other => format!("%{other}"),
144    }
145}
146
147/// The classic pager bottom line; override with `[pager] format`.
148pub const DEFAULT_PAGER_FORMAT: &str = "---Message %C/%m: %s -- %P";
149
150/// mutt's $pager_format: %C message number, %m count, %n sender,
151/// %s subject, %Z status chars, %P percent through the message,
152/// %f mailbox, plus the conditional and %> machinery.
153pub fn pager_status(
154    session: &Session,
155    pager: &PagerView,
156    content_height: usize,
157    width: usize,
158) -> String {
159    let total = pager
160        .rows
161        .rows(
162            pager.view,
163            pager_wrap(&session.config, width),
164            pager.full_headers,
165            &PagerStyle::of(&session.config, &session.quote_re),
166            pager.hide_quoted,
167        )
168        .len()
169        .max(1);
170    let shown = (pager.scroll + content_height).min(total);
171    let subject = pager
172        .view
173        .brief
174        .iter()
175        .find(|(n, _)| n == "Subject" || n == "Content-Type")
176        .map(|(_, v)| v.as_str())
177        .unwrap_or("");
178    let msg = session.visible.get(session.sel).map(|&i| &session.msgs[i]);
179    let fmt = session
180        .config
181        .pager
182        .format
183        .as_deref()
184        .unwrap_or(DEFAULT_PAGER_FORMAT);
185    format::render_status(fmt, width, &|spec| match spec {
186        'C' => (session.sel + 1).to_string(),
187        'm' => session.visible.len().to_string(),
188        's' => subject.to_string(),
189        'n' => msg.map(|m| m.env.from.clone()).unwrap_or_default(),
190        'Z' => msg
191            .map(|m| {
192                let f = &m.env.file;
193                format!(
194                    "{}{} ",
195                    f.flags.status_char(f.is_new),
196                    if f.flags.flagged { '!' } else { ' ' }
197                )
198            })
199            .unwrap_or_default(),
200        // mutt's pager: "all" when the message fits, "end" once its
201        // last line is on screen, a percentage until then.
202        'P' => match (pager.scroll, shown >= total) {
203            (0, true) => "all".into(),
204            (_, true) => "end".into(),
205            _ => format!("{}%", shown * 100 / total),
206        },
207        'f' => session.title.clone(),
208        '%' => "%".to_string(),
209        other => format!("%{other}"),
210    })
211}