Skip to main content

mxr_tui/ui/
mail_list.rs

1use crate::app::{ActivePane, MailListMode, MailListRow};
2use crate::theme::Theme;
3use chrono::{Datelike, Local, Utc};
4use mxr_core::id::MessageId;
5use mxr_core::types::MessageFlags;
6use ratatui::prelude::*;
7use ratatui::widgets::*;
8use std::collections::HashSet;
9#[cfg(test)]
10use unicode_width::UnicodeWidthStr;
11
12pub struct MailListView<'a> {
13    pub rows: &'a [MailListRow],
14    pub selected_index: usize,
15    pub scroll_offset: usize,
16    pub active_pane: &'a ActivePane,
17    pub title: &'a str,
18    pub selected_set: &'a HashSet<MessageId>,
19    pub mode: MailListMode,
20}
21
22#[expect(
23    clippy::too_many_arguments,
24    reason = "TUI draw entrypoint keeps call sites explicit"
25)]
26pub fn draw(
27    frame: &mut Frame,
28    area: Rect,
29    rows: &[MailListRow],
30    selected_index: usize,
31    scroll_offset: usize,
32    active_pane: &ActivePane,
33    title: &str,
34    theme: &Theme,
35) {
36    draw_view(
37        frame,
38        area,
39        &MailListView {
40            rows,
41            selected_index,
42            scroll_offset,
43            active_pane,
44            title,
45            selected_set: &HashSet::new(),
46            mode: MailListMode::Threads,
47        },
48        theme,
49    );
50}
51
52pub fn draw_view(frame: &mut Frame, area: Rect, view: &MailListView<'_>, theme: &Theme) {
53    let is_focused = *view.active_pane == ActivePane::MailList;
54    let border_style = theme.border_style(is_focused);
55
56    let visible_height = area.height.saturating_sub(2) as usize;
57
58    let table_rows: Vec<Row> = view
59        .rows
60        .iter()
61        .enumerate()
62        .skip(view.scroll_offset)
63        .take(visible_height)
64        .map(|(i, row)| build_row(view, row, i, theme))
65        .collect();
66
67    let widths = [
68        Constraint::Length(4),  // line number
69        Constraint::Length(1),  // unread indicator
70        Constraint::Length(2),  // star
71        Constraint::Length(2),  // unsubscribe
72        Constraint::Length(22), // sender
73        Constraint::Fill(1),    // subject (+ thread count badge)
74        Constraint::Length(8),  // date
75        Constraint::Length(2),  // attachment icon
76    ];
77
78    let table = Table::new(table_rows, widths)
79        .block(
80            Block::default()
81                .title(format!(" {} ", view.title))
82                .borders(Borders::ALL)
83                .border_style(border_style),
84        )
85        .row_highlight_style(
86            Style::default()
87                .bg(theme.selection_bg)
88                .fg(theme.selection_fg),
89        )
90        .column_spacing(1);
91
92    // We use manual highlight via row styles since we handle scroll_offset ourselves
93    frame.render_widget(table, area);
94
95    // Scrollbar
96    if view.rows.len() > visible_height {
97        let mut scrollbar_state =
98            ScrollbarState::new(view.rows.len().saturating_sub(visible_height))
99                .position(view.scroll_offset);
100        frame.render_stateful_widget(
101            Scrollbar::default()
102                .orientation(ScrollbarOrientation::VerticalRight)
103                .thumb_style(Style::default().fg(theme.accent)),
104            area,
105            &mut scrollbar_state,
106        );
107    }
108}
109
110fn build_row<'a>(
111    view: &MailListView<'_>,
112    row: &MailListRow,
113    index: usize,
114    theme: &Theme,
115) -> Row<'a> {
116    let env = &row.representative;
117    let is_selected = index == view.selected_index;
118    let is_unread = !env.flags.contains(MessageFlags::READ);
119    let is_starred = env.flags.contains(MessageFlags::STARRED);
120    let is_in_set = view.selected_set.contains(&env.id);
121
122    // Line number
123    let line_num_cell = Cell::from(Span::styled(
124        format!("{:>3}", index + 1),
125        Style::default().fg(theme.line_number_fg),
126    ));
127
128    // Unread indicator
129    let unread_cell = Cell::from(Span::styled(
130        if is_unread { "N" } else { " " },
131        Style::default().fg(theme.accent).bold(),
132    ));
133
134    // Star
135    let star_cell = Cell::from(Span::styled(
136        if is_starred { "★" } else { " " },
137        Style::default().fg(theme.warning),
138    ));
139
140    let unsubscribe_cell = Cell::from(Span::styled(
141        unsubscribe_marker(&env.unsubscribe),
142        Style::default().fg(theme.text_muted),
143    ));
144
145    // Sender (with thread count badge)
146    let (sender_text, thread_count) = sender_parts(row, view.mode);
147    let sender_spans: Vec<Span> = if let Some(count) = thread_count {
148        vec![
149            Span::styled(
150                sender_text,
151                Style::default().fg(if is_unread {
152                    theme.text_primary
153                } else {
154                    theme.text_secondary
155                }),
156            ),
157            Span::styled(
158                format!(" {}", count),
159                Style::default().fg(theme.accent_dim).bold(),
160            ),
161        ]
162    } else {
163        vec![Span::styled(
164            sender_text,
165            Style::default().fg(if is_unread {
166                theme.text_primary
167            } else {
168                theme.text_secondary
169            }),
170        )]
171    };
172    let sender_cell = Cell::from(Line::from(sender_spans));
173
174    // Subject
175    let subject_cell = Cell::from(Span::raw(env.subject.clone()));
176
177    // Date
178    let date_str = format_date(&env.date);
179    let date_cell = Cell::from(Span::styled(
180        date_str,
181        Style::default().fg(theme.text_muted),
182    ));
183
184    // Attachment
185    let attach_cell = Cell::from(Span::styled(
186        attachment_marker(env.has_attachments),
187        Style::default().fg(theme.success),
188    ));
189
190    let base_style = if is_selected {
191        Style::default()
192            .bg(theme.selection_bg)
193            .fg(theme.selection_fg)
194    } else if is_in_set {
195        Style::default().bg(theme.label_bg).fg(theme.text_primary)
196    } else if is_unread {
197        theme.unread_style()
198    } else {
199        Style::default().fg(theme.text_secondary)
200    };
201
202    Row::new(vec![
203        line_num_cell,
204        unread_cell,
205        star_cell,
206        unsubscribe_cell,
207        sender_cell,
208        subject_cell,
209        date_cell,
210        attach_cell,
211    ])
212    .style(base_style)
213}
214
215fn sender_parts(row: &MailListRow, mode: MailListMode) -> (String, Option<usize>) {
216    let from_raw = row
217        .representative
218        .from
219        .name
220        .as_deref()
221        .unwrap_or(&row.representative.from.email);
222    match mode {
223        MailListMode::Threads if row.message_count > 1 => {
224            (from_raw.to_string(), Some(row.message_count))
225        }
226        _ => (from_raw.to_string(), None),
227    }
228}
229
230fn format_date(date: &chrono::DateTime<Utc>) -> String {
231    let local = date.with_timezone(&Local);
232    let now = Local::now();
233
234    if local.date_naive() == now.date_naive() {
235        local.format("%I:%M%p").to_string()
236    } else if local.year() == now.year() {
237        local.format("%b %d").to_string()
238    } else {
239        local.format("%m/%d/%y").to_string()
240    }
241}
242
243fn attachment_marker(has_attachments: bool) -> &'static str {
244    if has_attachments {
245        "📎"
246    } else {
247        "  "
248    }
249}
250
251fn unsubscribe_marker(unsubscribe: &mxr_core::types::UnsubscribeMethod) -> &'static str {
252    if matches!(unsubscribe, mxr_core::types::UnsubscribeMethod::None) {
253        " "
254    } else {
255        "U"
256    }
257}
258
259#[cfg(test)]
260fn display_width(text: &str) -> usize {
261    UnicodeWidthStr::width(text)
262}
263
264#[cfg(test)]
265fn truncate_display(text: &str, max_width: usize) -> String {
266    if max_width == 0 {
267        return String::new();
268    }
269    if display_width(text) <= max_width {
270        return text.to_string();
271    }
272    if max_width <= 3 {
273        return ".".repeat(max_width);
274    }
275
276    let mut width = 0;
277    let mut truncated = String::new();
278    for ch in text.chars() {
279        let ch_width = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]));
280        if width + ch_width > max_width.saturating_sub(3) {
281            break;
282        }
283        truncated.push(ch);
284        width += ch_width;
285    }
286    truncated.push_str("...");
287    truncated
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use chrono::Utc;
294    use mxr_core::id::{AccountId, ThreadId};
295    use mxr_core::types::{Address, Envelope, UnsubscribeMethod};
296
297    fn row(message_count: usize, has_attachments: bool) -> MailListRow {
298        MailListRow {
299            thread_id: ThreadId::new(),
300            representative: Envelope {
301                id: MessageId::new(),
302                account_id: AccountId::new(),
303                provider_id: "fake".into(),
304                thread_id: ThreadId::new(),
305                message_id_header: None,
306                in_reply_to: None,
307                references: vec![],
308                from: Address {
309                    name: Some("Matt".into()),
310                    email: "matt@example.com".into(),
311                },
312                to: vec![],
313                cc: vec![],
314                bcc: vec![],
315                subject: "A very long subject line that should not eat the date column".into(),
316                date: Utc::now(),
317                flags: MessageFlags::empty(),
318                snippet: String::new(),
319                has_attachments,
320                size_bytes: 1024,
321                unsubscribe: UnsubscribeMethod::None,
322                label_provider_ids: vec![],
323            },
324            message_count,
325            unread_count: message_count,
326        }
327    }
328
329    #[test]
330    fn sender_text_inlines_thread_count_without_brackets() {
331        assert_eq!(
332            sender_parts(&row(1, false), MailListMode::Threads),
333            ("Matt".into(), None)
334        );
335        assert_eq!(
336            sender_parts(&row(4, false), MailListMode::Threads),
337            ("Matt".into(), Some(4))
338        );
339    }
340
341    #[test]
342    fn truncate_display_adds_ellipsis() {
343        assert_eq!(truncate_display("abcdefghij", 6), "abc...");
344    }
345
346    #[test]
347    fn attachment_marker_uses_clip_icon() {
348        assert_eq!(attachment_marker(true), "📎");
349        assert_eq!(attachment_marker(false), "  ");
350    }
351}