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 base_style = row_base_style(theme, is_selected, is_in_set, is_unread);
122    let row_fg = base_style.fg.unwrap_or(theme.text_primary);
123    let row_secondary_fg = if is_selected || is_in_set {
124        row_fg
125    } else if is_unread {
126        theme.text_primary
127    } else {
128        theme.text_secondary
129    };
130    let row_muted_fg = if is_selected || is_in_set {
131        row_fg
132    } else {
133        theme.text_muted
134    };
135    let row_marker_fg = if is_selected || is_in_set {
136        row_fg
137    } else {
138        theme.line_number_fg
139    };
140    let selection_marker = match (is_selected, is_in_set) {
141        (true, true) => "*",
142        (true, false) => ">",
143        (false, true) => "+",
144        (false, false) => " ",
145    };
146    let line_number_style = match (is_selected, is_in_set) {
147        (true, true) | (true, false) => Style::default().fg(row_fg).bold(),
148        (false, true) => Style::default().fg(row_fg).bold(),
149        (false, false) => Style::default().fg(row_marker_fg),
150    };
151
152    // Line number
153    let line_num_cell = Cell::from(Span::styled(
154        format!("{selection_marker}{:>3}", index + 1),
155        line_number_style,
156    ));
157
158    // Unread indicator
159    let unread_cell = Cell::from(Span::styled(
160        if is_unread { "N" } else { " " },
161        Style::default().fg(row_fg).bold(),
162    ));
163
164    // Star
165    let star_cell = Cell::from(Span::styled(
166        if is_starred { "★" } else { " " },
167        Style::default().fg(row_fg),
168    ));
169
170    let unsubscribe_cell = Cell::from(Span::styled(
171        unsubscribe_marker(&env.unsubscribe),
172        Style::default().fg(row_muted_fg),
173    ));
174
175    // Sender (with thread count badge)
176    let (sender_text, thread_count) = sender_parts(row, view.mode);
177    let sender_spans: Vec<Span> = if let Some(count) = thread_count {
178        vec![
179            Span::styled(sender_text, Style::default().fg(row_secondary_fg)),
180            Span::styled(format!(" {}", count), Style::default().fg(row_fg).bold()),
181        ]
182    } else {
183        vec![Span::styled(
184            sender_text,
185            Style::default().fg(row_secondary_fg),
186        )]
187    };
188    let sender_cell = Cell::from(Line::from(sender_spans));
189
190    // Subject
191    let subject_cell = Cell::from(Span::raw(env.subject.clone()));
192
193    // Date
194    let date_str = format_date(&env.date);
195    let date_cell = Cell::from(Span::styled(date_str, Style::default().fg(row_muted_fg)));
196
197    // Attachment
198    let attach_cell = Cell::from(Span::styled(
199        attachment_marker(env.has_attachments),
200        Style::default().fg(row_fg),
201    ));
202
203    Row::new(vec![
204        line_num_cell,
205        unread_cell,
206        star_cell,
207        unsubscribe_cell,
208        sender_cell,
209        subject_cell,
210        date_cell,
211        attach_cell,
212    ])
213    .style(base_style)
214}
215
216fn row_base_style(theme: &Theme, is_selected: bool, is_in_set: bool, is_unread: bool) -> Style {
217    match (is_selected, is_in_set) {
218        (true, true) => {
219            let bg = blend_bg(theme.selection_bg, theme.accent, 96);
220            let fg = contrast_foreground(bg, theme.selection_fg);
221            Style::default()
222                .bg(bg)
223                .fg(fg)
224                .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
225        }
226        (true, false) => {
227            let fg = contrast_foreground(theme.selection_bg, theme.selection_fg);
228            Style::default()
229                .bg(theme.selection_bg)
230                .fg(fg)
231                .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
232        }
233        (false, true) => {
234            let bg = blend_bg(theme.selection_bg, theme.accent_dim, 72);
235            let fg = contrast_foreground(bg, theme.selection_fg);
236            Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD)
237        }
238        (false, false) if is_unread => theme.unread_style(),
239        (false, false) => Style::default().fg(theme.text_secondary),
240    }
241}
242
243fn contrast_foreground(bg: Color, fallback: Color) -> Color {
244    let Some((r, g, b)) = color_rgb(bg) else {
245        return fallback;
246    };
247    let luminance = (u32::from(r) * 299 + u32::from(g) * 587 + u32::from(b) * 114) / 1000;
248    if luminance >= 140 {
249        Color::Black
250    } else {
251        Color::White
252    }
253}
254
255fn blend_bg(base: Color, tint: Color, tint_weight: u8) -> Color {
256    let Some((base_r, base_g, base_b)) = color_rgb(base) else {
257        return base;
258    };
259    let Some((tint_r, tint_g, tint_b)) = color_rgb(tint) else {
260        return base;
261    };
262    let tint_weight = u16::from(tint_weight);
263    let base_weight = 255u16.saturating_sub(tint_weight);
264    let mix = |base: u8, tint: u8| -> u8 {
265        (((u16::from(base) * base_weight) + (u16::from(tint) * tint_weight)) / 255) as u8
266    };
267    Color::Rgb(
268        mix(base_r, tint_r),
269        mix(base_g, tint_g),
270        mix(base_b, tint_b),
271    )
272}
273
274fn color_rgb(color: Color) -> Option<(u8, u8, u8)> {
275    match color {
276        Color::Reset => None,
277        Color::Black => Some((0, 0, 0)),
278        Color::Red => Some((205, 49, 49)),
279        Color::Green => Some((13, 188, 121)),
280        Color::Yellow => Some((229, 229, 16)),
281        Color::Blue => Some((36, 114, 200)),
282        Color::Magenta => Some((188, 63, 188)),
283        Color::Cyan => Some((17, 168, 205)),
284        Color::Gray => Some((229, 229, 229)),
285        Color::DarkGray => Some((102, 102, 102)),
286        Color::LightRed => Some((241, 76, 76)),
287        Color::LightGreen => Some((35, 209, 139)),
288        Color::LightYellow => Some((245, 245, 67)),
289        Color::LightBlue => Some((59, 142, 234)),
290        Color::LightMagenta => Some((214, 112, 214)),
291        Color::LightCyan => Some((41, 184, 219)),
292        Color::White => Some((255, 255, 255)),
293        Color::Rgb(r, g, b) => Some((r, g, b)),
294        Color::Indexed(idx) => Some(indexed_color_rgb(idx)),
295    }
296}
297
298fn indexed_color_rgb(idx: u8) -> (u8, u8, u8) {
299    match idx {
300        0 => (0, 0, 0),
301        1 => (128, 0, 0),
302        2 => (0, 128, 0),
303        3 => (128, 128, 0),
304        4 => (0, 0, 128),
305        5 => (128, 0, 128),
306        6 => (0, 128, 128),
307        7 => (192, 192, 192),
308        8 => (128, 128, 128),
309        9 => (255, 0, 0),
310        10 => (0, 255, 0),
311        11 => (255, 255, 0),
312        12 => (0, 0, 255),
313        13 => (255, 0, 255),
314        14 => (0, 255, 255),
315        15 => (255, 255, 255),
316        16..=231 => {
317            let idx = idx - 16;
318            let r = idx / 36;
319            let g = (idx % 36) / 6;
320            let b = idx % 6;
321            let to_channel = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
322            (to_channel(r), to_channel(g), to_channel(b))
323        }
324        232..=255 => {
325            let shade = 8 + (idx - 232) * 10;
326            (shade, shade, shade)
327        }
328    }
329}
330fn sender_parts(row: &MailListRow, mode: MailListMode) -> (String, Option<usize>) {
331    let from_raw = row
332        .representative
333        .from
334        .name
335        .as_deref()
336        .unwrap_or(&row.representative.from.email);
337    match mode {
338        MailListMode::Threads if row.message_count > 1 => {
339            (from_raw.to_string(), Some(row.message_count))
340        }
341        _ => (from_raw.to_string(), None),
342    }
343}
344
345fn format_date(date: &chrono::DateTime<Utc>) -> String {
346    let local = date.with_timezone(&Local);
347    let now = Local::now();
348
349    if local.date_naive() == now.date_naive() {
350        local.format("%I:%M%p").to_string()
351    } else if local.year() == now.year() {
352        local.format("%b %d").to_string()
353    } else {
354        local.format("%m/%d/%y").to_string()
355    }
356}
357
358fn attachment_marker(has_attachments: bool) -> &'static str {
359    if has_attachments {
360        "📎"
361    } else {
362        "  "
363    }
364}
365
366fn unsubscribe_marker(unsubscribe: &mxr_core::types::UnsubscribeMethod) -> &'static str {
367    if matches!(unsubscribe, mxr_core::types::UnsubscribeMethod::None) {
368        " "
369    } else {
370        "U"
371    }
372}
373
374#[cfg(test)]
375fn display_width(text: &str) -> usize {
376    UnicodeWidthStr::width(text)
377}
378
379#[cfg(test)]
380fn truncate_display(text: &str, max_width: usize) -> String {
381    if max_width == 0 {
382        return String::new();
383    }
384    if display_width(text) <= max_width {
385        return text.to_string();
386    }
387    if max_width <= 3 {
388        return ".".repeat(max_width);
389    }
390
391    let mut width = 0;
392    let mut truncated = String::new();
393    for ch in text.chars() {
394        let ch_width = UnicodeWidthStr::width(ch.encode_utf8(&mut [0; 4]));
395        if width + ch_width > max_width.saturating_sub(3) {
396            break;
397        }
398        truncated.push(ch);
399        width += ch_width;
400    }
401    truncated.push_str("...");
402    truncated
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use chrono::Utc;
409    use mxr_core::id::{AccountId, ThreadId};
410    use mxr_core::types::{Address, Envelope, UnsubscribeMethod};
411
412    fn row(message_count: usize, has_attachments: bool) -> MailListRow {
413        MailListRow {
414            thread_id: ThreadId::new(),
415            representative: Envelope {
416                id: MessageId::new(),
417                account_id: AccountId::new(),
418                provider_id: "fake".into(),
419                thread_id: ThreadId::new(),
420                message_id_header: None,
421                in_reply_to: None,
422                references: vec![],
423                from: Address {
424                    name: Some("Matt".into()),
425                    email: "matt@example.com".into(),
426                },
427                to: vec![],
428                cc: vec![],
429                bcc: vec![],
430                subject: "A very long subject line that should not eat the date column".into(),
431                date: Utc::now(),
432                flags: MessageFlags::empty(),
433                snippet: String::new(),
434                has_attachments,
435                size_bytes: 1024,
436                unsubscribe: UnsubscribeMethod::None,
437                label_provider_ids: vec![],
438            },
439            message_count,
440            unread_count: message_count,
441        }
442    }
443
444    #[test]
445    fn sender_text_inlines_thread_count_without_brackets() {
446        assert_eq!(
447            sender_parts(&row(1, false), MailListMode::Threads),
448            ("Matt".into(), None)
449        );
450        assert_eq!(
451            sender_parts(&row(4, false), MailListMode::Threads),
452            ("Matt".into(), Some(4))
453        );
454    }
455
456    #[test]
457    fn truncate_display_adds_ellipsis() {
458        assert_eq!(truncate_display("abcdefghij", 6), "abc...");
459    }
460
461    #[test]
462    fn attachment_marker_uses_clip_icon() {
463        assert_eq!(attachment_marker(true), "📎");
464        assert_eq!(attachment_marker(false), "  ");
465    }
466
467    #[test]
468    fn selection_markers_distinguish_cursor_and_bulk_selection() {
469        use mxr_test_support::render_to_string;
470        use std::collections::HashSet;
471
472        let first = row(1, false);
473        let second = row(1, false);
474        let rows = vec![first.clone(), second.clone()];
475        let mut selected_set = HashSet::new();
476        selected_set.insert(second.representative.id.clone());
477
478        let snapshot = render_to_string(80, 8, |frame| {
479            draw_view(
480                frame,
481                Rect::new(0, 0, 80, 8),
482                &MailListView {
483                    rows: &rows,
484                    selected_index: 0,
485                    scroll_offset: 0,
486                    active_pane: &ActivePane::MailList,
487                    title: "Inbox",
488                    selected_set: &selected_set,
489                    mode: MailListMode::Threads,
490                },
491                &Theme::default(),
492            );
493        });
494
495        assert!(snapshot.contains(">  1"));
496        assert!(snapshot.contains("+  2"));
497    }
498    #[test]
499    fn bulk_selection_uses_tinted_background_for_contrast() {
500        let theme = Theme::default();
501        let style = row_base_style(
502            &Theme {
503                selection_bg: Color::Rgb(40, 44, 52),
504                accent_dim: Color::Rgb(160, 200, 255),
505                ..theme
506            },
507            false,
508            true,
509            false,
510        );
511
512        assert_eq!(style.bg, Some(Color::Rgb(73, 88, 109)));
513        assert_eq!(style.fg, Some(Color::White));
514    }
515}