use std::collections::HashMap;
use std::fmt::Write as _;
use std::sync::mpsc::{self, Receiver};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Terminal,
backend::{Backend as _, TestBackend},
buffer::{Buffer, Cell},
layout::Position,
style::{Color, Modifier},
};
use time::{OffsetDateTime, macros::datetime};
use crate::app::{AccountDescriptor, App};
use crate::backend::{ActionStatus, BackendEvent, MailBackend, MailboxSnapshot, OutgoingMessage};
use crate::clock;
use crate::model::{
Action, MailboxKind, Message, MessageAttachment, MessageContent, MessageContentPart, MessageId,
MessageStatus,
};
use crate::ui;
pub(crate) const NOW: OffsetDateTime = datetime!(2026-03-14 09:41:00 UTC);
pub(crate) const OPERATION_AGE: Duration = Duration::from_millis(400);
const SETTLE_TIMEOUT: Duration = Duration::from_secs(10);
const NO_CURSOR: Position = Position {
x: u16::MAX,
y: u16::MAX,
};
const FONT_SIZE: usize = 16;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum TerminalTheme {
Dark,
Light,
}
impl TerminalTheme {
pub(crate) const ALL: [Self; 2] = [Self::Dark, Self::Light];
pub(crate) fn name(self) -> &'static str {
match self {
Self::Dark => "dark",
Self::Light => "light",
}
}
pub(crate) fn ui_theme(self) -> ui::Theme {
match self {
Self::Dark => ui::Theme::Dark,
Self::Light => ui::Theme::Light,
}
}
fn fg(self) -> &'static str {
match self {
Self::Dark => "#d3d7cf",
Self::Light => "#2e3436",
}
}
fn bg(self) -> &'static str {
match self {
Self::Dark => "#2e3436",
Self::Light => "#eeeeec",
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct CellMetrics {
pub(crate) width: usize,
pub(crate) height: usize,
pub(crate) baseline: usize,
}
impl Default for CellMetrics {
fn default() -> Self {
Self {
width: 10,
height: 20,
baseline: 15,
}
}
}
pub(crate) struct TestApp {
app: App,
terminal: Terminal<TestBackend>,
}
impl TestApp {
pub(crate) fn new(width: u16, height: u16, accounts: Vec<AccountDescriptor>) -> Self {
let mut app = Self::starting(width, height, accounts);
app.settle();
app.draw();
app
}
pub(crate) fn inbox(width: u16, height: u16) -> Self {
Self::new(
width,
height,
vec![account("Personal", FixtureBackend::new())],
)
}
pub(crate) fn starting(width: u16, height: u16, accounts: Vec<AccountDescriptor>) -> Self {
clock::freeze(NOW, OPERATION_AGE);
let app = App::new(accounts).expect("build the application");
let terminal = Terminal::new(TestBackend::new(width, height)).expect("test terminal");
let mut test_app = Self { app, terminal };
test_app.draw();
test_app
}
pub(crate) fn app_mut(&mut self) -> &mut App {
&mut self.app
}
pub(crate) fn draw(&mut self) {
self.draw_in(ui::Theme::Dark);
}
pub(crate) fn draw_in(&mut self, theme: ui::Theme) {
self.terminal
.backend_mut()
.set_cursor_position(NO_CURSOR)
.expect("reset the test cursor");
let app = &mut self.app;
self.terminal
.draw(|frame| ui::render(frame, app, theme))
.expect("draw frame");
}
fn cursor(&mut self) -> Option<Position> {
match self.terminal.get_cursor_position() {
Ok(position) if position != NO_CURSOR => Some(position),
_ => None,
}
}
pub(crate) fn settle(&mut self) {
let deadline = Instant::now() + SETTLE_TIMEOUT;
loop {
self.app.poll_backend_events();
if !self.app.has_work_in_flight() {
return;
}
assert!(
Instant::now() < deadline,
"backend work did not finish within {SETTLE_TIMEOUT:?}"
);
std::thread::sleep(Duration::from_millis(1));
}
}
pub(crate) fn key(&mut self, code: KeyCode) {
self.key_with(code, KeyModifiers::NONE);
}
pub(crate) fn key_with(&mut self, code: KeyCode, modifiers: KeyModifiers) {
self.app
.handle_key(KeyEvent::new(code, modifiers))
.expect("handle key");
self.settle();
self.draw();
}
pub(crate) fn key_without_settling(&mut self, code: KeyCode) {
self.app
.handle_key(KeyEvent::new(code, KeyModifiers::NONE))
.expect("handle key");
self.draw();
}
pub(crate) fn char(&mut self, ch: char) {
self.key(KeyCode::Char(ch));
}
pub(crate) fn type_text(&mut self, text: &str) {
for ch in text.chars() {
self.char(ch);
}
}
pub(crate) fn clear_field(&mut self) {
for _ in 0..256 {
self.key(KeyCode::Backspace);
}
}
pub(crate) fn buffer(&self) -> &Buffer {
self.terminal.backend().buffer()
}
pub(crate) fn buffer_lines(&self) -> Vec<String> {
let buffer = self.terminal.backend().buffer();
let area = buffer.area();
(0..area.height)
.map(|y| {
(0..area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect()
}
pub(crate) fn svg(&mut self, theme: TerminalTheme) -> String {
self.svg_with(CellMetrics::default(), theme)
}
pub(crate) fn svg_in_palette(&mut self, palette: ui::Theme, theme: TerminalTheme) -> String {
self.render_svg(palette, CellMetrics::default(), theme)
}
pub(crate) fn svg_with(&mut self, metrics: CellMetrics, theme: TerminalTheme) -> String {
self.render_svg(theme.ui_theme(), metrics, theme)
}
fn render_svg(
&mut self,
palette: ui::Theme,
metrics: CellMetrics,
theme: TerminalTheme,
) -> String {
self.draw_in(palette);
let cursor = self.cursor();
buffer_to_svg(self.terminal.backend().buffer(), cursor, metrics, theme)
}
}
pub(crate) fn account(name: &str, backend: FixtureBackend) -> AccountDescriptor {
AccountDescriptor::new(name, Arc::new(backend))
}
#[derive(Clone, PartialEq)]
struct CellStyle {
fg: String,
bg: String,
bold: bool,
dim: bool,
italic: bool,
underline: bool,
strike: bool,
}
fn cell_style(cell: &Cell, theme: TerminalTheme) -> CellStyle {
let mut fg = css_color(cell.fg, theme.fg());
let mut bg = css_color(cell.bg, theme.bg());
let modifier = cell.modifier;
if modifier.contains(Modifier::REVERSED) {
std::mem::swap(&mut fg, &mut bg);
}
CellStyle {
fg,
bg,
bold: modifier.contains(Modifier::BOLD),
dim: modifier.contains(Modifier::DIM),
italic: modifier.contains(Modifier::ITALIC),
underline: modifier.contains(Modifier::UNDERLINED),
strike: modifier.contains(Modifier::CROSSED_OUT),
}
}
const ANSI16: [&str; 16] = [
"#2e3436", "#cc0000", "#4e9a06", "#c4a000", "#3465a4", "#75507b", "#06989a", "#d3d7cf",
"#555753", "#ef2929", "#8ae234", "#fce94f", "#729fcf", "#ad7fa8", "#34e2e2", "#eeeeec",
];
fn css_color(color: Color, default: &str) -> String {
match color {
Color::Reset => default.to_string(),
Color::Black => ANSI16[0].to_string(),
Color::Red => ANSI16[1].to_string(),
Color::Green => ANSI16[2].to_string(),
Color::Yellow => ANSI16[3].to_string(),
Color::Blue => ANSI16[4].to_string(),
Color::Magenta => ANSI16[5].to_string(),
Color::Cyan => ANSI16[6].to_string(),
Color::Gray => ANSI16[7].to_string(),
Color::DarkGray => ANSI16[8].to_string(),
Color::LightRed => ANSI16[9].to_string(),
Color::LightGreen => ANSI16[10].to_string(),
Color::LightYellow => ANSI16[11].to_string(),
Color::LightBlue => ANSI16[12].to_string(),
Color::LightMagenta => ANSI16[13].to_string(),
Color::LightCyan => ANSI16[14].to_string(),
Color::White => ANSI16[15].to_string(),
Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
Color::Indexed(i) => indexed_color(i),
}
}
fn indexed_color(index: u8) -> String {
match index {
0..=15 => ANSI16[index as usize].to_string(),
16..=231 => {
let i = index - 16;
let steps = [0u8, 95, 135, 175, 215, 255];
let r = steps[(i / 36) as usize];
let g = steps[((i % 36) / 6) as usize];
let b = steps[(i % 6) as usize];
format!("#{r:02x}{g:02x}{b:02x}")
}
232..=255 => {
let v = 8 + 10 * (index - 232);
format!("#{v:02x}{v:02x}{v:02x}")
}
}
}
fn xml_escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
pub(crate) fn buffer_to_svg(
buffer: &Buffer,
cursor: Option<Position>,
metrics: CellMetrics,
theme: TerminalTheme,
) -> String {
let CellMetrics {
width: cell_w,
height: cell_h,
baseline,
} = metrics;
let area = buffer.area;
let width_px = area.width as usize * cell_w;
let height_px = area.height as usize * cell_h;
let mut svg = String::new();
let _ = writeln!(
svg,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width_px}" height="{height_px}" viewBox="0 0 {width_px} {height_px}" font-family="'DejaVu Sans Mono', Menlo, Consolas, monospace" font-size="{FONT_SIZE}px">"#
);
let _ = writeln!(
svg,
r#"<rect width="100%" height="100%" fill="{}"/>"#,
theme.bg()
);
for y in 0..area.height {
let mut runs: Vec<(usize, usize, String, CellStyle)> = Vec::new();
for x in 0..area.width {
let cell = buffer.cell(Position::new(x, y)).expect("cell in area");
let style = cell_style(cell, theme);
match runs.last_mut() {
Some((_, cells, text, last_style)) if *last_style == style => {
*cells += 1;
text.push_str(cell.symbol());
}
_ => runs.push((x as usize, 1, cell.symbol().to_string(), style)),
}
}
let row_px = y as usize * cell_h;
for (start, cells, text, style) in &runs {
if style.bg != theme.bg() {
let _ = writeln!(
svg,
r#"<rect x="{}" y="{row_px}" width="{}" height="{cell_h}" fill="{}"/>"#,
start * cell_w,
cells * cell_w,
style.bg,
);
}
if text.trim().is_empty() && !style.underline && !style.strike {
continue;
}
let mut attrs = String::new();
if style.bold {
attrs.push_str(r#" font-weight="bold""#);
}
if style.italic {
attrs.push_str(r#" font-style="italic""#);
}
match (style.underline, style.strike) {
(true, true) => attrs.push_str(r#" text-decoration="underline line-through""#),
(true, false) => attrs.push_str(r#" text-decoration="underline""#),
(false, true) => attrs.push_str(r#" text-decoration="line-through""#),
(false, false) => {}
}
if style.dim {
attrs.push_str(r#" opacity="0.6""#);
}
let _ = writeln!(
svg,
r#"<text x="{}" y="{}" fill="{}" textLength="{}" lengthAdjust="spacingAndGlyphs" xml:space="preserve"{attrs}>{}</text>"#,
start * cell_w,
row_px + baseline,
style.fg,
cells * cell_w,
xml_escape(text),
);
}
}
if let Some(position) = cursor {
let _ = writeln!(
svg,
r#"<rect x="{}" y="{}" width="{cell_w}" height="{cell_h}" fill="{}" fill-opacity="0.4"/>"#,
position.x as usize * cell_w,
position.y as usize * cell_h,
theme.fg(),
);
}
svg.push_str("</svg>\n");
svg
}
pub(crate) struct FixtureBackend {
mailboxes: HashMap<MailboxKind, Vec<Message>>,
contents: HashMap<MessageId, MessageContent>,
totals: HashMap<MailboxKind, usize>,
gate: Option<Arc<Gate>>,
failure: Option<String>,
}
#[derive(Default)]
pub(crate) struct Gate {
open: Mutex<bool>,
changed: Condvar,
}
impl Gate {
fn wait(&self) {
let mut open = self.open.lock().expect("gate mutex");
while !*open {
open = self.changed.wait(open).expect("gate mutex");
}
}
pub(crate) fn open(&self) {
*self.open.lock().expect("gate mutex") = true;
self.changed.notify_all();
}
}
impl FixtureBackend {
pub(crate) fn new() -> Self {
Self {
mailboxes: fixture_mailboxes(),
contents: fixture_contents(),
totals: HashMap::new(),
gate: None,
failure: None,
}
}
pub(crate) fn blocking() -> (Self, Arc<Gate>) {
let gate = Arc::new(Gate::default());
let backend = Self {
gate: Some(Arc::clone(&gate)),
..Self::new()
};
(backend, gate)
}
pub(crate) fn failing(reason: &str) -> Self {
Self {
failure: Some(reason.to_string()),
..Self::new()
}
}
pub(crate) fn backfilling(mailbox: MailboxKind, total: usize) -> Self {
let mut backend = Self::new();
backend.totals.insert(mailbox, total);
backend
}
pub(crate) fn empty() -> Self {
Self {
mailboxes: HashMap::new(),
contents: HashMap::new(),
totals: HashMap::new(),
gate: None,
failure: None,
}
}
}
impl MailBackend for FixtureBackend {
fn load_mailbox(
&self,
mailbox: MailboxKind,
) -> Result<(MailboxSnapshot, Receiver<BackendEvent>)> {
if let Some(gate) = &self.gate {
gate.wait();
}
if let Some(reason) = &self.failure {
return Err(anyhow!("{reason}"));
}
let messages = self.mailboxes.get(&mailbox).cloned().unwrap_or_default();
let total = self
.totals
.get(&mailbox)
.copied()
.unwrap_or(messages.len())
.max(messages.len());
let (_sender, events) = mpsc::channel();
Ok((MailboxSnapshot { total, messages }, events))
}
fn load_message(&self, message_id: MessageId) -> Result<MessageContent> {
self.contents
.get(&message_id)
.cloned()
.ok_or_else(|| anyhow!("no such message: {message_id}"))
}
fn apply_actions(&self, actions: Vec<Action>) -> Result<Receiver<ActionStatus>> {
let (sender, receiver) = mpsc::channel();
for action in actions {
let _ = sender.send(ActionStatus {
action,
result: Ok(()),
});
}
Ok(receiver)
}
fn send_message(&self, _message: OutgoingMessage) -> Result<()> {
Ok(())
}
fn save_draft(&self, _message: OutgoingMessage) -> Result<()> {
Ok(())
}
fn fetch_attachment_blob(&self, _blob_id: &str) -> Result<Vec<u8>> {
Ok(b"fixture attachment payload".to_vec())
}
}
struct Fixture {
id: MessageId,
sent: OffsetDateTime,
sender: &'static str,
recipients: &'static [&'static str],
subject: &'static str,
size: usize,
status: MessageStatus,
starred: bool,
important: bool,
answered: bool,
forwarded: bool,
has_attachments: bool,
labels: &'static [&'static str],
}
impl Fixture {
const fn new(
id: MessageId,
sent: OffsetDateTime,
sender: &'static str,
subject: &'static str,
size: usize,
status: MessageStatus,
) -> Self {
Self {
id,
sent,
sender,
recipients: &["rob@example.com"],
subject,
size,
status,
starred: false,
important: false,
answered: false,
forwarded: false,
has_attachments: false,
labels: &[],
}
}
fn build(&self, seq: u32) -> Message {
Message {
id: self.id,
sent: self.sent,
sender: self.sender.to_string(),
recipients: self.recipients.iter().map(|to| to.to_string()).collect(),
subject: self.subject.to_string(),
size: self.size,
starred: self.starred,
important: self.important,
answered: self.answered,
forwarded: self.forwarded,
status: self.status,
labels: self.labels.iter().map(|l| l.to_string()).collect(),
uid: 1000 + self.id as u32,
seq,
has_attachments: self.has_attachments,
}
}
}
fn inbox_fixtures() -> Vec<Fixture> {
vec![
Fixture {
starred: true,
important: true,
labels: &["Work", "Invoices"],
has_attachments: true,
recipients: &["rob@example.com", "accounts@example.com"],
..Fixture::new(
1,
datetime!(2026-03-14 08:15:00 UTC),
"billing@vendor.example",
"Invoice 2026-0342 for February",
18_400,
MessageStatus::New,
)
},
Fixture {
labels: &["Work"],
..Fixture::new(
2,
datetime!(2026-03-14 07:52:00 UTC),
"mira.tomassi@example.org",
"Re: Release notes for 0.3",
7_320,
MessageStatus::New,
)
},
Fixture {
answered: true,
..Fixture::new(
3,
datetime!(2026-03-13 19:04:00 UTC),
"dev-list@ratatui.example",
"[dev] Widget layout changes landing next week",
42_100,
MessageStatus::Read,
)
},
Fixture {
starred: true,
..Fixture::new(
4,
datetime!(2026-03-13 15:30:00 UTC),
"anna@example.com",
"Lunch on Thursday?",
2_100,
MessageStatus::Read,
)
},
Fixture {
forwarded: true,
has_attachments: true,
labels: &["Travel"],
..Fixture::new(
5,
datetime!(2026-03-12 11:20:00 UTC),
"reservations@hotel.example",
"Your booking confirmation",
96_500,
MessageStatus::Read,
)
},
Fixture {
important: true,
..Fixture::new(
6,
datetime!(2026-03-11 09:00:00 UTC),
"security@bank.example",
"Unusual sign-in attempt blocked",
5_600,
MessageStatus::Read,
)
},
Fixture::new(
7,
datetime!(2026-03-10 16:45:00 UTC),
"newsletter@rustweekly.example",
"This Week in Rust #612",
134_000,
MessageStatus::Read,
),
Fixture::new(
8,
datetime!(2026-03-09 08:05:00 UTC),
"no-reply@calendar.example",
"Invitation: Sprint review @ Fri Mar 20",
8_900,
MessageStatus::Archived,
),
Fixture::new(
9,
datetime!(2026-03-08 22:18:00 UTC),
"deals@shop.example",
"LAST CHANCE: 80% off everything!!!",
61_000,
MessageStatus::Spam,
),
Fixture::new(
10,
datetime!(2026-03-07 13:37:00 UTC),
"tickets@support.example",
"Ticket #4711 has been closed",
3_400,
MessageStatus::Deleted,
),
Fixture {
labels: &["Work", "Archive"],
..Fixture::new(
11,
datetime!(2025-12-24 10:02:00 UTC),
"hr@example.com",
"Holiday schedule for next year",
12_800,
MessageStatus::Read,
)
},
]
}
fn mailbox(fixtures: &[Fixture]) -> Vec<Message> {
let mut sorted: Vec<&Fixture> = fixtures.iter().collect();
sorted.sort_by_key(|fixture| fixture.sent);
sorted
.into_iter()
.enumerate()
.map(|(idx, fixture)| fixture.build(idx as u32 + 1))
.collect()
}
fn fixture_mailboxes() -> HashMap<MailboxKind, Vec<Message>> {
let mut mailboxes = HashMap::new();
mailboxes.insert(MailboxKind::Inbox, mailbox(&inbox_fixtures()));
let sent = mailbox(&[
Fixture {
recipients: &["mira.tomassi@example.org", "anna@example.com"],
..Fixture::new(
21,
datetime!(2026-03-13 18:40:00 UTC),
"rob@example.com",
"Re: Release notes for 0.3",
4_200,
MessageStatus::Read,
)
},
Fixture {
recipients: &["billing@vendor.example"],
..Fixture::new(
22,
datetime!(2026-03-11 12:12:00 UTC),
"rob@example.com",
"Payment scheduled",
1_900,
MessageStatus::Read,
)
},
]);
mailboxes.insert(MailboxKind::Sent, sent);
let drafts = mailbox(&[Fixture {
recipients: &["anna@example.com"],
labels: &["Draft"],
..Fixture::new(
31,
datetime!(2026-03-14 09:12:00 UTC),
"rob@example.com",
"Thursday works for me",
1_400,
MessageStatus::New,
)
}]);
mailboxes.insert(MailboxKind::Drafts, drafts);
let starred: Vec<Fixture> = inbox_fixtures()
.into_iter()
.filter(|fixture| fixture.starred)
.collect();
mailboxes.insert(MailboxKind::Starred, mailbox(&starred));
mailboxes
}
fn fixture_contents() -> HashMap<MessageId, MessageContent> {
let mut contents = HashMap::new();
contents.insert(
1,
MessageContent {
mailer: "VendorBilling/2.1".to_string(),
parts: vec![
MessageContentPart {
content_type: "text/plain".to_string(),
content: b"Invoice 2026-0342 is attached. Payment is due within 14 days."
.to_vec(),
},
MessageContentPart {
content_type: "text/html".to_string(),
content: br#"<html><body>
<h1>Invoice 2026-0342</h1>
<p>Dear customer,</p>
<p>your invoice for <b>February 2026</b> is attached as a PDF. The total is
<b>1,248.00 EUR</b>, payable within 14 days.</p>
<ul>
<li>Hosting, February 2026 — 899.00 EUR</li>
<li>Support plan — 349.00 EUR</li>
</ul>
<p>Kind regards,<br>Vendor Billing</p>
</body></html>"#
.to_vec(),
},
],
attachments: vec![
MessageAttachment {
filename: Some("invoice-2026-0342.pdf".to_string()),
mime_type: "application/pdf".to_string(),
size: 14_320,
data: Some(b"%PDF-1.4 fixture".to_vec()),
blob_id: None,
inline: false,
},
MessageAttachment {
filename: Some("terms.txt".to_string()),
mime_type: "text/plain".to_string(),
size: 1_100,
data: Some(b"Payment terms: 14 days net.".to_vec()),
blob_id: None,
inline: false,
},
MessageAttachment {
filename: Some("logo.png".to_string()),
mime_type: "image/png".to_string(),
size: 2_400,
data: Some(b"\x89PNG fixture".to_vec()),
blob_id: None,
inline: true,
},
],
},
);
contents.insert(
2,
MessageContent {
mailer: "Thunderbird/128.0".to_string(),
parts: vec![
MessageContentPart {
content_type: "text/plain".to_string(),
content: b"Looks good to me. One question about the changelog.".to_vec(),
},
MessageContentPart {
content_type: "text/html".to_string(),
content: br#"<html><body>
<p>Hi Rob,</p>
<p>the release notes look good to me. One question though: should the
<i>attachment indicator</i> be listed under features or under fixes?</p>
<blockquote><p>> The list now marks messages that carry an attachment.</p></blockquote>
<p>Either way, ship it.</p>
<p>— Mira</p>
</body></html>"#
.to_vec(),
},
],
attachments: Vec::new(),
},
);
contents.insert(
31,
MessageContent {
mailer: "Elma".to_string(),
parts: vec![
MessageContentPart {
content_type: "text/plain".to_string(),
content: b"Thursday works for me. Shall we say half past twelve?".to_vec(),
},
MessageContentPart {
content_type: "text/html".to_string(),
content: br#"<html><body><p>Thursday works for me. Shall we say half past
twelve?</p></body></html>"#
.to_vec(),
},
],
attachments: Vec::new(),
},
);
contents.insert(
4,
MessageContent {
mailer: "Apple Mail (16.0)".to_string(),
parts: vec![MessageContentPart {
content_type: "text/plain".to_string(),
content: b"Are you free for lunch on Thursday? There is a new place by the river."
.to_vec(),
}],
attachments: Vec::new(),
},
);
contents
}
#[cfg(test)]
#[path = "snapshot_tests.rs"]
mod snapshot_tests;