use std::io::{Error, Stdout}; use std::sync::atomic::{AtomicU32, Ordering}; use std::sync::{mpsc, Arc}; use std::thread; use std::time::{Duration, Instant}; use crossterm::event; use crossterm::event::{Event, KeyCode}; use ratatui::backend::CrosstermBackend; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::prelude::{Color, Line, Modifier, Span, Style}; use ratatui::Terminal; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use crate::config::Config; pub mod config; mod connect; mod inbox; const POLL_INTERVAL: Duration = Duration::from_secs(30); const NAV_DEBOUNCE: Duration = Duration::from_millis(150); #[derive(PartialEq)] enum Focus { Inbox, Message, } pub(crate) struct Email { pub seq: u32, pub subject: String, pub from: String, pub date: String, } enum WorkerCmd { Refresh, FetchMore { oldest_seq: u32 }, FetchBody { seq: u32 }, Delete { seq: u32 }, Quit, } enum WorkerResult { Refreshed(Result), FetchedMore(Result<(Vec, u32), String>), Body { seq: u32, result: Result }, Deleted(Result<(), String>), } fn worker_loop( config: Config, cmd_rx: mpsc::Receiver, result_tx: mpsc::Sender, wanted_body_seq: Arc, ) { let mut session: Option = None; for cmd in &cmd_rx { match cmd { WorkerCmd::Refresh => { let result = inbox::refresh(&mut session, &config); let _ = result_tx.send(WorkerResult::Refreshed(result)); } WorkerCmd::FetchMore { oldest_seq } => { let result = inbox::fetch_older_batch(&mut session, oldest_seq, &config); let _ = result_tx.send(WorkerResult::FetchedMore(result)); } WorkerCmd::FetchBody { seq } => { // Skip if a newer body request has been made if wanted_body_seq.load(Ordering::Relaxed) != seq { continue; } let result = inbox::fetch_body(&mut session, seq, &config); let _ = result_tx.send(WorkerResult::Body { seq, result }); } WorkerCmd::Delete { seq } => { let result = inbox::delete_email(&mut session, seq, &config); let _ = result_tx.send(WorkerResult::Deleted(result)); } WorkerCmd::Quit => break, } } if let Some(mut s) = session.take() { let _ = s.logout(); } } pub fn main(config: &Config, terminal: &mut Terminal>) -> Result<(), Error> { let (cmd_tx, cmd_rx) = mpsc::channel(); let (result_tx, result_rx) = mpsc::channel(); let wanted_body_seq = Arc::new(AtomicU32::new(0)); let worker_config = config.clone(); let worker_wanted = Arc::clone(&wanted_body_seq); let worker = thread::spawn(move || { worker_loop(worker_config, cmd_rx, result_tx, worker_wanted); }); // Send initial refresh let _ = cmd_tx.send(WorkerCmd::Refresh); let mut emails: Vec = Vec::new(); let mut oldest_seq: Option = None; let mut has_older = false; let mut error: Option = None; let mut loading = true; let mut list_state = ListState::default(); let mut last_fetch = Instant::now(); let mut message_body: String = String::new(); let mut message_rendered: Option> = None; let mut message_seq: Option = None; let mut body_loading = false; let mut focus = Focus::Inbox; let mut message_scroll: u16 = 0; let mut pending_fetch_seq: Option = None; let mut last_nav = Instant::now(); // --- Main loop --- loop { // Check for worker results (non-blocking) while let Ok(result) = result_rx.try_recv() { match result { WorkerResult::Refreshed(Ok(inbox)) => { let prev_selected_seq = list_state.selected() .and_then(|i| emails.get(i)) .map(|e| e.seq); // Preserve emails older than the refresh batch (previously loaded via // FetchMore). Without this, scrolling into older emails and then // triggering a refresh would drop those emails and reset selection to 0. let refresh_oldest = inbox.emails.last().map(|e| e.seq).unwrap_or(0); let mut merged = inbox.emails; for e in emails.drain(..) { if e.seq < refresh_oldest { merged.push(e); } } emails = merged; // Recalculate from the merged list oldest_seq = emails.last().map(|e| e.seq); has_older = oldest_seq.map_or(false, |s| s > 1); error = None; loading = false; if !emails.is_empty() { let new_idx = prev_selected_seq .and_then(|seq| emails.iter().position(|e| e.seq == seq)) .unwrap_or(0); list_state.select(Some(new_idx)); let seq = emails[new_idx].seq; // Fetch if the selected email changed OR a debounce fetch is pending // (pending means the body shown may be stale from a previous email) if message_seq != Some(seq) || pending_fetch_seq.is_some() { message_seq = Some(seq); message_body.clear(); message_rendered = None; message_scroll = 0; body_loading = true; pending_fetch_seq = None; wanted_body_seq.store(seq, Ordering::Relaxed); let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); } } else { list_state.select(None); message_seq = None; message_body.clear(); message_rendered = None; pending_fetch_seq = None; } } WorkerResult::Refreshed(Err(e)) => { error = Some(e); loading = false; } WorkerResult::FetchedMore(Ok((mut new_emails, new_oldest))) => { has_older = new_oldest > 1; oldest_seq = Some(new_oldest); emails.append(&mut new_emails); loading = false; } WorkerResult::FetchedMore(Err(e)) => { error = Some(e); loading = false; } WorkerResult::Body { seq, result } => { body_loading = false; if message_seq == Some(seq) { message_scroll = 0; message_rendered = None; match result { Ok(body) => message_body = body, Err(e) => message_body = format!("Error loading body: {}", e), } } } WorkerResult::Deleted(result) => { if let Err(e) = result { error = Some(format!("Delete failed: {}", e)); } } } } // Fire pending body fetch after nav debounce elapses if let Some(seq) = pending_fetch_seq { if last_nav.elapsed() >= NAV_DEBOUNCE { message_body.clear(); message_rendered = None; body_loading = true; wanted_body_seq.store(seq, Ordering::Relaxed); let _ = cmd_tx.send(WorkerCmd::FetchBody { seq }); pending_fetch_seq = None; } } // Periodic refresh if !loading && last_fetch.elapsed() >= POLL_INTERVAL { let _ = cmd_tx.send(WorkerCmd::Refresh); loading = true; last_fetch = Instant::now(); } terminal.draw(|frame| { let area = frame.area(); let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Percentage(50), Constraint::Percentage(50), Constraint::Length(1), ]) .split(area); // --- Top: Inbox list --- let (inbox_title_style, inbox_prefix) = if focus == Focus::Inbox { (Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ") } else { (Style::default().fg(Color::DarkGray), " ") }; let (message_title_style, message_prefix) = if focus == Focus::Message { (Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "▶ ") } else { (Style::default().fg(Color::DarkGray), " ") }; let inbox_border = inbox_title_style; let message_border = message_title_style; let inbox_title = |label: &str| { Line::from(Span::styled(format!("{}{}", inbox_prefix, label), inbox_title_style)) }; if let Some(e) = &error { let p = Paragraph::new(format!("IMAP error: {}", e)) .block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border)) .style(Style::default().fg(Color::Red)); frame.render_widget(p, layout[0]); } else if emails.is_empty() && loading { let p = Paragraph::new("Loading...") .block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border)) .style(Style::default().fg(Color::Yellow)); frame.render_widget(p, layout[0]); } else if emails.is_empty() { let p = Paragraph::new("No messages in inbox.") .block(Block::default().title(inbox_title("Inbox")).borders(Borders::BOTTOM).border_style(inbox_border)) .style(Style::default().fg(Color::Yellow)); frame.render_widget(p, layout[0]); } else { let max_from = emails.iter() .map(|e| e.from.chars().count()) .max() .unwrap_or(20) .min(40); let items: Vec = emails .iter() .map(|e| { let from_len = e.from.chars().count(); let from = if from_len > max_from { format!("{}…", e.from.chars().take(max_from.saturating_sub(1)).collect::()) } else { format!("{:> "); frame.render_stateful_widget(list, layout[0], &mut list_state); } // --- Bottom: Message --- let message_text = if body_loading { ratatui::text::Text::raw("Loading...") } else { message_rendered .get_or_insert_with(|| { // Convert to owned Text<'static> so it can be cached across frames let t = tui_markdown::from_str(&message_body); ratatui::text::Text { alignment: t.alignment, style: t.style, lines: t.lines.into_iter().map(|line| ratatui::text::Line { style: line.style, alignment: line.alignment, spans: line.spans.into_iter().map(|span| ratatui::text::Span { style: span.style, content: std::borrow::Cow::Owned(span.content.into_owned()), }).collect(), }).collect(), } }) .clone() }; let message_title = Line::from(Span::styled( format!("{}Message", message_prefix), message_title_style, )); let preview = Paragraph::new(message_text) .block(Block::default().title(message_title).borders(Borders::BOTTOM).border_style(message_border)) .style(Style::default().fg(Color::White)) .wrap(ratatui::widgets::Wrap { trim: false }) .scroll((message_scroll, 0)); frame.render_widget(preview, layout[1]); let status = Paragraph::new(" 'q' quit | 'r' refresh | 'd' delete | ↑/↓ navigate | Tab switch pane") .style(Style::default().fg(Color::DarkGray)); frame.render_widget(status, layout[2]); })?; // --- Input handling --- if event::poll(Duration::from_millis(200))? { if let Event::Key(key) = event::read()? { match key.code { KeyCode::Char('q') | KeyCode::Esc => break, KeyCode::Tab => { focus = match focus { Focus::Inbox => Focus::Message, Focus::Message => Focus::Inbox, }; } KeyCode::Char('r') => { if !loading { let _ = cmd_tx.send(WorkerCmd::Refresh); loading = true; last_fetch = Instant::now(); } } KeyCode::Down | KeyCode::Char('j') => match focus { Focus::Inbox => { let len = emails.len(); if len > 0 { let i = list_state.selected().map_or(0, |i| i + 1); if i >= len && has_older && !loading { if let Some(seq) = oldest_seq { let _ = cmd_tx.send(WorkerCmd::FetchMore { oldest_seq: seq }); loading = true; } } let new_idx = i.min(len.saturating_sub(1)); list_state.select(Some(new_idx)); let seq = emails[new_idx].seq; if message_seq != Some(seq) { message_seq = Some(seq); message_scroll = 0; pending_fetch_seq = Some(seq); last_nav = Instant::now(); } } } Focus::Message => { message_scroll = message_scroll.saturating_add(1); } } KeyCode::Up | KeyCode::Char('k') => match focus { Focus::Inbox => { let i = list_state.selected().map_or(0, |i| i.saturating_sub(1)); list_state.select(Some(i)); if !emails.is_empty() { let seq = emails[i].seq; if message_seq != Some(seq) { message_seq = Some(seq); message_scroll = 0; pending_fetch_seq = Some(seq); last_nav = Instant::now(); } } } Focus::Message => { message_scroll = message_scroll.saturating_sub(1); } } KeyCode::Char('d') if focus == Focus::Inbox => { if let Some(idx) = list_state.selected() { if idx < emails.len() { let seq = emails[idx].seq; // Remove from UI immediately emails.remove(idx); // IMAP expunge shifts sequence numbers: every message // that had seq > deleted seq is renumbered down by 1. for e in emails.iter_mut() { if e.seq > seq { e.seq -= 1; } } if emails.is_empty() { list_state.select(None); message_seq = None; message_body.clear(); message_rendered = None; pending_fetch_seq = None; } else { let new_idx = idx.min(emails.len().saturating_sub(1)); list_state.select(Some(new_idx)); let new_seq = emails[new_idx].seq; if message_seq != Some(new_seq) { message_seq = Some(new_seq); message_body.clear(); message_rendered = None; message_scroll = 0; body_loading = true; pending_fetch_seq = None; wanted_body_seq.store(new_seq, Ordering::Relaxed); let _ = cmd_tx.send(WorkerCmd::FetchBody { seq: new_seq }); } } // Delete on server in background let _ = cmd_tx.send(WorkerCmd::Delete { seq }); } } } _ => {} } } } } // Clean up worker let _ = cmd_tx.send(WorkerCmd::Quit); let _ = worker.join(); Ok(()) }