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 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 let line_num_cell = Cell::from(Span::styled(
135 format!("{selection_marker}{:>3}", index + 1),
136 line_number_style,
137 ));
138
139 let unread_cell = Cell::from(Span::styled(
141 if is_unread { "N" } else { " " },
142 Style::default().fg(theme.accent).bold(),
143 ));
144
145 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 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 let subject_cell = Cell::from(Span::raw(env.subject.clone()));
187
188 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 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}