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 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 let line_num_cell = Cell::from(Span::styled(
154 format!("{selection_marker}{:>3}", index + 1),
155 line_number_style,
156 ));
157
158 let unread_cell = Cell::from(Span::styled(
160 if is_unread { "N" } else { " " },
161 Style::default().fg(row_fg).bold(),
162 ));
163
164 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 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 let subject_cell = Cell::from(Span::raw(env.subject.clone()));
192
193 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 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}