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), Constraint::Length(1), Constraint::Length(2), Constraint::Length(2), Constraint::Length(22), Constraint::Fill(1), Constraint::Length(8), Constraint::Length(2), ];
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 frame.render_widget(table, area);
94
95 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 let line_num_cell = Cell::from(Span::styled(
124 format!("{:>3}", index + 1),
125 Style::default().fg(theme.line_number_fg),
126 ));
127
128 let unread_cell = Cell::from(Span::styled(
130 if is_unread { "N" } else { " " },
131 Style::default().fg(theme.accent).bold(),
132 ));
133
134 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 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 let subject_cell = Cell::from(Span::raw(env.subject.clone()));
176
177 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 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}