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    let selection_marker = match (is_selected, is_in_set) {
122        (true, true) => "*",
123        (true, false) => ">",
124        (false, true) => "+",
125        (false, false) => " ",
126    };
127    let line_number_style = match (is_selected, is_in_set) {
128        (true, true) | (true, false) => Style::default().fg(theme.warning).bold(),
129        (false, true) => Style::default().fg(theme.accent).bold(),
130        (false, false) => Style::default().fg(theme.line_number_fg),
131    };
132
133    // Line number
134    let line_num_cell = Cell::from(Span::styled(
135        format!("{selection_marker}{:>3}", index + 1),
136        line_number_style,
137    ));
138
139    // Unread indicator
140    let unread_cell = Cell::from(Span::styled(
141        if is_unread { "N" } else { " " },
142        Style::default().fg(theme.accent).bold(),
143    ));
144
145    // Star
146    let star_cell = Cell::from(Span::styled(
147        if is_starred { "★" } else { " " },
148        Style::default().fg(theme.warning),
149    ));
150
151    let unsubscribe_cell = Cell::from(Span::styled(
152        unsubscribe_marker(&env.unsubscribe),
153        Style::default().fg(theme.text_muted),
154    ));
155
156    // Sender (with thread count badge)
157    let (sender_text, thread_count) = sender_parts(row, view.mode);
158    let sender_spans: Vec<Span> = if let Some(count) = thread_count {
159        vec![
160            Span::styled(
161                sender_text,
162                Style::default().fg(if is_unread {
163                    theme.text_primary
164                } else {
165                    theme.text_secondary
166                }),
167            ),
168            Span::styled(
169                format!(" {}", count),
170                Style::default().fg(theme.accent_dim).bold(),
171            ),
172        ]
173    } else {
174        vec![Span::styled(
175            sender_text,
176            Style::default().fg(if is_unread {
177                theme.text_primary
178            } else {
179                theme.text_secondary
180            }),
181        )]
182    };
183    let sender_cell = Cell::from(Line::from(sender_spans));
184
185    // Subject
186    let subject_cell = Cell::from(Span::raw(env.subject.clone()));
187
188    // Date
189    let date_str = format_date(&env.date);
190    let date_cell = Cell::from(Span::styled(
191        date_str,
192        Style::default().fg(theme.text_muted),
193    ));
194
195    // Attachment
196    let attach_cell = Cell::from(Span::styled(
197        attachment_marker(env.has_attachments),
198        Style::default().fg(theme.success),
199    ));
200
201    let base_style = match (is_selected, is_in_set) {
202        (true, true) => Style::default()
203            .bg(theme.accent)
204            .fg(theme.selection_fg)
205            .add_modifier(Modifier::BOLD),
206        (true, false) => Style::default()
207            .bg(theme.selection_bg)
208            .fg(theme.selection_fg)
209            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
210        (false, true) => Style::default()
211            .bg(theme.accent_dim)
212            .fg(theme.selection_fg)
213            .add_modifier(Modifier::BOLD),
214        (false, false) if is_unread => theme.unread_style(),
215        (false, false) => Style::default().fg(theme.text_secondary),
216    };
217
218    Row::new(vec![
219        line_num_cell,
220        unread_cell,
221        star_cell,
222        unsubscribe_cell,
223        sender_cell,
224        subject_cell,
225        date_cell,
226        attach_cell,
227    ])
228    .style(base_style)
229}
230
231fn sender_parts(row: &MailListRow, mode: MailListMode) -> (String, Option<usize>) {
232    let from_raw = row
233        .representative
234        .from
235        .name
236        .as_deref()
237        .unwrap_or(&row.representative.from.email);
238    match mode {
239        MailListMode::Threads if row.message_count > 1 => {
240            (from_raw.to_string(), Some(row.message_count))
241        }
242        _ => (from_raw.to_string(), None),
243    }
244}
245
246fn format_date(date: &chrono::DateTime<Utc>) -> String {
247    let local = date.with_timezone(&Local);
248    let now = Local::now();
249
250    if local.date_naive() == now.date_naive() {
251        local.format("%I:%M%p").to_string()
252    } else if local.year() == now.year() {
253        local.format("%b %d").to_string()
254    } else {
255        local.format("%m/%d/%y").to_string()
256    }
257}
258
259fn attachment_marker(has_attachments: bool) -> &'static str {
260    if has_attachments {
261        "📎"
262    } else {
263        "  "
264    }
265}
266
267fn unsubscribe_marker(unsubscribe: &mxr_core::types::UnsubscribeMethod) -> &'static str {
268    if matches!(unsubscribe, mxr_core::types::UnsubscribeMethod::None) {
269        " "
270    } else {
271        "U"
272    }
273}
274
275#[cfg(test)]
276fn display_width(text: &str) -> usize {
277    UnicodeWidthStr::width(text)
278}
279
280#[cfg(test)]
281fn truncate_display(text: &str, max_width: usize) -> String {
282    if max_width == 0 {
283        return String::new();
284    }
285    if display_width(text) <= max_width {
286        return text.to_string();
287    }
288    if max_width <= 3 {
289        return ".".repeat(max_width);
290    }
291
292    let mut width = 0;
293    let mut truncated = String::new();
294    for ch in text.chars() {
295        let ch_width = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]));
296        if width + ch_width > max_width.saturating_sub(3) {
297            break;
298        }
299        truncated.push(ch);
300        width += ch_width;
301    }
302    truncated.push_str("...");
303    truncated
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use chrono::Utc;
310    use mxr_core::id::{AccountId, ThreadId};
311    use mxr_core::types::{Address, Envelope, UnsubscribeMethod};
312
313    fn row(message_count: usize, has_attachments: bool) -> MailListRow {
314        MailListRow {
315            thread_id: ThreadId::new(),
316            representative: Envelope {
317                id: MessageId::new(),
318                account_id: AccountId::new(),
319                provider_id: "fake".into(),
320                thread_id: ThreadId::new(),
321                message_id_header: None,
322                in_reply_to: None,
323                references: vec![],
324                from: Address {
325                    name: Some("Matt".into()),
326                    email: "matt@example.com".into(),
327                },
328                to: vec![],
329                cc: vec![],
330                bcc: vec![],
331                subject: "A very long subject line that should not eat the date column".into(),
332                date: Utc::now(),
333                flags: MessageFlags::empty(),
334                snippet: String::new(),
335                has_attachments,
336                size_bytes: 1024,
337                unsubscribe: UnsubscribeMethod::None,
338                label_provider_ids: vec![],
339            },
340            message_count,
341            unread_count: message_count,
342        }
343    }
344
345    #[test]
346    fn sender_text_inlines_thread_count_without_brackets() {
347        assert_eq!(
348            sender_parts(&row(1, false), MailListMode::Threads),
349            ("Matt".into(), None)
350        );
351        assert_eq!(
352            sender_parts(&row(4, false), MailListMode::Threads),
353            ("Matt".into(), Some(4))
354        );
355    }
356
357    #[test]
358    fn truncate_display_adds_ellipsis() {
359        assert_eq!(truncate_display("abcdefghij", 6), "abc...");
360    }
361
362    #[test]
363    fn attachment_marker_uses_clip_icon() {
364        assert_eq!(attachment_marker(true), "📎");
365        assert_eq!(attachment_marker(false), "  ");
366    }
367
368    #[test]
369    fn selection_markers_distinguish_cursor_and_bulk_selection() {
370        use mxr_test_support::render_to_string;
371        use std::collections::HashSet;
372
373        let first = row(1, false);
374        let second = row(1, false);
375        let rows = vec![first.clone(), second.clone()];
376        let mut selected_set = HashSet::new();
377        selected_set.insert(second.representative.id.clone());
378
379        let snapshot = render_to_string(80, 8, |frame| {
380            draw_view(
381                frame,
382                Rect::new(0, 0, 80, 8),
383                &MailListView {
384                    rows: &rows,
385                    selected_index: 0,
386                    scroll_offset: 0,
387                    active_pane: &ActivePane::MailList,
388                    title: "Inbox",
389                    selected_set: &selected_set,
390                    mode: MailListMode::Threads,
391                },
392                &Theme::default(),
393            );
394        });
395
396        assert!(snapshot.contains(">  1"));
397        assert!(snapshot.contains("+  2"));
398    }
399}