//! The view: a compact horizontal split — a tab bar, the full-width list //! above, the article below, and a status line at the bottom edge. Protocol //! values (addresses, keys, ids, dates) are monospace; prose stays sans. use iced::widget::text::Wrapping; use iced::widget::{ button, column, container, qr_code, row, rule, scrollable, text, text_editor, text_input, Space, }; use iced::{Alignment, Element, Font, Length, Padding}; use fumi_core::crypto::{b32, fingerprint}; use crate::app::{Carrier, Dialog, Message, OnboardMode, Page}; use crate::ops::{self, Contact, Folder, Row}; use crate::theme::{self, ACCENT, MUTED, TEXT}; /// Column widths of the list, in pixels; monospace keeps them aligned. const DATE_W: f32 = 104.0; const FLAG_W: f32 = 24.0; const FROM_W: f32 = 248.0; const ADDR_W: f32 = 260.0; const KEY_W: f32 = 248.0; const STATE_W: f32 = 88.0; pub fn view(app: &Carrier) -> Element<'_, Message> { let content: Element<'_, Message> = if app.compose.is_some() { compose(app) } else { match app.page { Page::Onboard => onboard(app), Page::Signup => signup(app), Page::Mail => mail(app), Page::Contacts => contacts(app), Page::Settings => settings(app), } }; column![content, dialog_banner(app), status(app)] .spacing(0) .height(Length::Fill) .width(Length::Fill) .into() } // --- shared pieces --------------------------------------------------------- /// One line of monospace UI text. fn mono<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> { text(content.to_string()) .font(Font::MONOSPACE) .size(12) .color(TEXT) } fn muted<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> { text(content.to_string()) .font(Font::MONOSPACE) .size(12) .color(MUTED) } fn accent<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> { text(content.to_string()) .font(Font::MONOSPACE) .size(12) .color(ACCENT) } /// A fixed-width cell so every row lines up like a table column. fn cell<'a>(content: iced::widget::Text<'a>, width: f32) -> Element<'a, Message> { container(content) .width(width) .align_x(Alignment::Start) .into() } fn action<'a>(label: &str, msg: Message) -> Element<'a, Message> { button(text(label.to_string()).size(12)) .on_press(msg) .style(theme::accent()) .padding([3, 10]) .into() } fn action_disabled<'a>(label: &str) -> Element<'a, Message> { button(text(label.to_string()).size(12)) .padding([3, 10]) .style(theme::quiet()) .into() } fn quiet_action<'a>(label: &str, msg: Message) -> Element<'a, Message> { button(text(label.to_string()).size(12)) .on_press(msg) .style(theme::quiet()) .padding([3, 10]) .into() } fn danger_action<'a>(label: &str, msg: Message) -> Element<'a, Message> { button(text(label.to_string()).size(12)) .on_press(msg) .style(theme::danger()) .padding([3, 10]) .into() } /// A tab entry: accent text when active, muted otherwise. fn tab<'a>(label: impl std::fmt::Display, active: bool, msg: Message) -> Element<'a, Message> { let label = label.to_string(); button( text(label) .font(Font::MONOSPACE) .size(12) .color(if active { ACCENT } else { MUTED }), ) .on_press(msg) .padding([3, 6]) .into() } /// The bar every main page shares: identity, folder tabs, page tabs, actions. fn top_bar<'a>(app: &'a Carrier) -> Element<'a, Message> { let address = app .snapshot .as_ref() .map(|snap| snap.who.address.clone()) .unwrap_or_default(); let unread = |folder: usize| { app.lists .get(folder) .map(|rows| rows.iter().filter(|r| r.unread).count()) .unwrap_or(0) }; let fetching = app.busy.as_deref() == Some("fetching"); let mut tabs = row![].spacing(2); for (folder, label) in [ (Folder::Inbox, "Inbox"), (Folder::Requests, "Requests"), (Folder::Sent, "Sent"), ] { let count = unread(folder as usize); let label = if count > 0 { format!("{label} {count}") } else { label.to_string() }; tabs = tabs.push(tab( label, app.folder == folder && app.page == Page::Mail, Message::GotoMail(folder), )); } tabs = tabs .push(tab( "Contacts", app.page == Page::Contacts, Message::Goto(Page::Contacts), )) .push(tab( "Settings", app.page == Page::Settings, Message::Goto(Page::Settings), )); let fetch = if fetching { action_disabled("Fetching…") } else if app.busy.is_some() { action_disabled("Fetch") } else { action("Fetch", Message::FetchPressed) }; let write = if app.busy.is_some() { action_disabled("Write") } else { action("Write", Message::ComposePressed) }; container( row![ accent("carrier"), mono(address), Space::with_width(Length::Fill), tabs, Space::with_width(Length::Fill), fetch, write, ] .spacing(10) .align_y(Alignment::Center), ) .style(theme::panel) .padding([6, 10]) .width(Length::Fill) .into() } /// The one status line: the busy label with its cancel button while an /// operation runs, otherwise the last result or error. fn status<'a>(app: &'a Carrier) -> Element<'a, Message> { let mut line = row![].spacing(10).align_y(Alignment::Center); match (&app.busy, &app.status) { (Some(busy), _) => { if busy == "fetching" { let inbox = app.lists.first().map(|rows| rows.len()).unwrap_or(0); line = line.push(muted(format!("fetching… {inbox} in inbox"))); line = line.push(quiet_action("Cancel", Message::FetchCancelled)); } else { line = line.push(muted(format!("{busy}…"))); } } (None, Some(status)) => { let piece = if app.status_error { text(status.clone()).font(Font::MONOSPACE).size(12) } else { muted(status.clone()) }; line = line.push(piece); } (None, None) => {} } container(line) .style(theme::status(app.status_error && app.busy.is_none())) .padding([4, 10]) .width(Length::Fill) .into() } /// Inline confirmations: a slim banner between the content and the status. fn dialog_banner<'a>(app: &'a Carrier) -> Element<'a, Message> { let Some(dialog) = &app.dialog else { return row![].into(); }; let mut line = row![].spacing(8).align_y(Alignment::Center); match dialog { Dialog::Delete { kept, .. } => { line = line.push(muted("delete this message:")); line = line.push(danger_action("here", Message::DeleteConfirmed(false))); if *kept { line = line.push(danger_action("everywhere", Message::DeleteConfirmed(true))); } line = line.push(quiet_action("cancel", Message::DeleteCancelled)); } Dialog::Rotate => { line = line.push(muted( "rotate the identity? the old key stays derivable; the index never moves back", )); line = line.push(action("rotate", Message::RotateConfirmed)); line = line.push(quiet_action("cancel", Message::RotateCancelled)); } } container(line) .style(theme::status(false)) .padding([4, 10]) .width(Length::Fill) .into() } fn pin_offer_banner<'a>(app: &'a Carrier) -> Element<'a, Message> { let Some((_, key)) = &app.pin_offer else { return row![].into(); }; row![ muted("server key offered:"), mono(fingerprint(key)), mono(short(&b32(key), 24)), action("pin", Message::PinAccept), quiet_action("discard", Message::PinDiscard), ] .spacing(8) .align_y(Alignment::Center) .into() } // --- mail ------------------------------------------------------------------ fn mail<'a>(app: &'a Carrier) -> Element<'a, Message> { column![ top_bar(app), container(list_pane(app)).height(Length::FillPortion(38)), rule::Rule::horizontal(1.0), container(detail_pane(app)).height(Length::FillPortion(62)), ] .spacing(0) .height(Length::Fill) .into() } fn list_pane<'a>(app: &'a Carrier) -> Element<'a, Message> { let rows = app .lists .get(app.folder as usize) .cloned() .unwrap_or_default(); let header = row![ cell(muted("date"), DATE_W), cell(muted("f"), FLAG_W), cell(muted("from/to"), FROM_W), muted("subject"), ] .spacing(10) .padding(padding()); let mut list = column![header].spacing(1); if rows.is_empty() { let hint = match app.folder { Folder::Inbox => "inbox is empty — fetch to check the mailbox", Folder::Requests => "no requests", Folder::Sent => "nothing sent yet", }; list = list.push(container(muted(hint)).padding(Padding::new(10.0))); } for row in &rows { let selected = app .open .as_ref() .is_some_and(|(folder, msg)| *folder == app.folder && msg.id == row.id); list = list.push(list_row(app.folder, row, selected)); } scrollable(list).height(Length::Fill).into() } /// One compact row: date, flags, from/to, subject — a flat button so the /// whole line is clickable like a mu4e header. fn list_row<'a>(folder: Folder, row: &Row, selected: bool) -> Element<'a, Message> { let flag = if row.unread { accent("●") } else { muted("·") }; let date = ops::when(row.at); button( row![ cell(mono(date), DATE_W), cell(flag, FLAG_W), cell(mono(truncate(&row.from, 32)), FROM_W), text(truncate(&row.subject, 64)) .font(Font::MONOSPACE) .size(12) .color(if row.unread { TEXT } else { MUTED }) .wrapping(Wrapping::None), ] .spacing(10) .align_y(Alignment::Center), ) .on_press(Message::MessageSelected(folder, row.id.clone())) .style(theme::row_style(selected)) .padding(padding()) .width(Length::Fill) .into() } fn padding() -> Padding { Padding::new(3.0).right(10.0).left(10.0) } /// A `label: value` header line for the article view. fn header_line<'a>(label: &str, value: impl std::fmt::Display) -> Element<'a, Message> { row![muted(format!("{label:<9}")), mono(value),] .spacing(4) .into() } fn detail_pane<'a>(app: &'a Carrier) -> Element<'a, Message> { let Some((folder, msg)) = &app.open else { return container(muted("select a message to read it")) .center(Length::Fill) .into(); }; let mut headers = column![].spacing(1); let to_label = if *folder == Folder::Sent { "to" } else { "from" }; headers = headers.push(header_line(to_label, &msg.from)); headers = headers.push(header_line( "subject", if msg.subject.is_empty() { "(no subject)".to_string() } else { msg.subject.clone() }, )); headers = headers.push(header_line("date", format!("{} UTC", ops::when(msg.time)))); headers = headers.push(header_line("id", short(&msg.id, 16))); if msg.kept { headers = headers.push(header_line("server", "a copy still exists there")); } let mut actions = row![].spacing(8); actions = actions.push(quiet_action("reply", Message::ReplyPressed)); if *folder == Folder::Requests { if msg.reply_to.is_some() { actions = actions.push(action("accept", Message::AcceptPressed)); actions = actions.push(danger_action("block", Message::BlockPressed)); } else { actions = actions.push(muted("no reply address: cannot accept or block")); } } actions = actions.push(danger_action("delete", Message::DeletePressed)); column![ container(headers).padding([8, 10]), rule::Rule::horizontal(1.0), scrollable(column![container( text(msg.body.clone()) .font(Font::MONOSPACE) .size(12) .color(TEXT) ) .padding([8, 10])]) .height(Length::Fill), rule::Rule::horizontal(1.0), container(actions).padding([6, 10]), ] .spacing(0) .height(Length::Fill) .into() } // --- compose --------------------------------------------------------------- /// Compose takes the lower pane, keeping the list visible above — writing is /// the article pane's other job, as in the layout it comes from. fn compose<'a>(app: &'a Carrier) -> Element<'a, Message> { let Some(compose) = &app.compose else { unreachable!("compose view without a draft") }; let label = if compose.reply_to.is_some() { "reply" } else { "write" }; column![ top_bar(app), container( column![ row![ muted("to"), text_input("alice@example.org or smol://…", &compose.to) .on_input(Message::ComposeToEdited) .size(12) .width(Length::Fill), ] .spacing(8), row![ muted("subject"), text_input("subject", &compose.subject) .on_input(Message::ComposeSubjectEdited) .size(12) .width(Length::Fill), ] .spacing(8), text_editor(&compose.body) .on_action(Message::ComposeBody) .font(Font::MONOSPACE) .size(12) .height(Length::Fill), row![ action(label, Message::SendPressed), quiet_action("cancel", Message::ComposeClosed), ] .spacing(8), ] .spacing(6) ) .padding(10) .height(Length::Fill), ] .spacing(0) .height(Length::Fill) .into() } // --- contacts --------------------------------------------------------------- fn contacts<'a>(app: &'a Carrier) -> Element<'a, Message> { let Some(snap) = &app.snapshot else { return column![top_bar(app)].into(); }; let own = format!("smol://{}/{}", snap.who.address, snap.who.key); let mut share = row![ muted("you:"), mono(own), quiet_action("copy", Message::CopyOwnAddress), ] .spacing(8) .align_y(Alignment::Center); if let Some(data) = &app.share_qr { share = share.push(container(qr_code(data).cell_size(3)).padding(0)); } let header = row![ cell(muted("address"), ADDR_W), cell(muted("key"), KEY_W), cell(muted("state"), STATE_W), muted(""), ] .spacing(10) .padding(padding()); let mut table = column![header].spacing(1); for contact in &snap.contacts { table = table.push(contact_row(contact)); } if snap.contacts.is_empty() { table = table.push( container(muted("no contacts yet — import a smol:// address")) .padding(Padding::new(10.0)), ); } column![ top_bar(app), container(share).style(theme::panel).padding([6, 10]), container( row![ text_input("paste a smol:// address to import", &app.contact_import) .on_input(Message::ContactImportEdited) .on_submit(Message::ContactImportPressed) .size(12) .width(Length::Fill), action("import", Message::ContactImportPressed), ] .spacing(8) ) .padding([6, 10]), scrollable(table).height(Length::Fill), ] .spacing(0) .height(Length::Fill) .into() } fn contact_row<'a>(contact: &Contact) -> Element<'a, Message> { let state = match contact.accepted { Some(true) => "accepted", Some(false) => "blocked", None => { if contact.verified { "verified" } else { "tofu" } } }; let mut actions = row![quiet_action( "write", Message::ContactWrite(contact.address.clone()), )] .spacing(6); match contact.accepted { Some(true) => { actions = actions.push(danger_action( "block", Message::ContactBlock(contact.address.clone()), )); } Some(false) => { actions = actions.push(action( "unblock", Message::ContactUnblock(contact.address.clone()), )); } None => { actions = actions.push(action( "accept", Message::ContactAccept(contact.address.clone(), contact.key.clone()), )); } } container( row![ cell(accent(&contact.address), ADDR_W), cell(mono(short(&contact.key, 32)), KEY_W), cell(muted(state), STATE_W), actions, ] .spacing(10) .align_y(Alignment::Center), ) .style(theme::card) .padding(padding()) .width(Length::Fill) .into() } // --- settings --------------------------------------------------------------- fn settings<'a>(app: &'a Carrier) -> Element<'a, Message> { let Some(snap) = &app.snapshot else { return column![top_bar(app)].into(); }; let who = &snap.who; let identity = column![ header_line("address", &who.address), header_line("key", &who.key), header_line("fingerprint", &who.fingerprint), header_line("rotations", who.rotations), row![ action("rotate", Message::RotatePressed), quiet_action("learn key", Message::SettingsLearnKey), ] .spacing(8), ] .spacing(1); let mut pins = column![].spacing(1); for (label, key) in &snap.pins { pins = pins.push( row![ cell(mono(label), ADDR_W), cell(mono(short(&b32(key), 32)), KEY_W), danger_action("unpin", Message::UnpinPressed(label.clone())), ] .spacing(10) .align_y(Alignment::Center), ); } if snap.pins.is_empty() { pins = pins.push(muted("no server keys pinned")); } // Pinned to the app borrow: the column mixes owned rows with the // `text_editor` that borrows `app.import_body`. let mut export: iced::widget::Column<'_, Message> = column![].spacing(4); export = export.push( row![ action("export", Message::ExportPressed), quiet_action("copy", Message::CopyExport), ] .spacing(8), ); if let Some(text) = &app.backup_text { export = export.push(scrollable(mono(short(text, 4000))).height(Length::Fixed(80.0))); } export = export.push(muted("import a backup (gsmol): paste it below")); export = export.push( text_editor(&app.import_body) .on_action(Message::ImportBody) .font(Font::MONOSPACE) .size(12) .height(Length::Fixed(100.0)), ); export = export.push(action("import", Message::ImportPressed)); let sections = column![ section("identity", identity.into()), section("server keys", pins.into()), section("backup", export.into()), section( "network", row![ muted("timeout"), text_input("30", &app.timeout_text) .on_input(Message::TimeoutEdited) .size(12) .width(80), muted("seconds"), ] .spacing(8) .align_y(Alignment::Center) .into(), ), ] .spacing(10); column![ top_bar(app), pin_offer_banner(app), scrollable(container(sections).padding(10)).height(Length::Fill), ] .spacing(0) .height(Length::Fill) .into() } fn section<'a>(title: &str, body: Element<'a, Message>) -> Element<'a, Message> { container( column![container(accent(title)).padding([2, 0]), body] .spacing(6) .max_width(720), ) .style(theme::card) .padding(10) .width(Length::Fill) .into() } // --- onboarding and signup --------------------------------------------------- fn centered<'a>(body: Element<'a, Message>) -> Element<'a, Message> { container(body) .center(Length::Fill) .max_width(520) .padding(20) .into() } fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> { let body = match app.onboard.mode { OnboardMode::Start => column![ text("carrier").font(Font::MONOSPACE).size(24).color(ACCENT), muted("smolmail: one seed, sealed mail, five operations"), row![ action("create an identity", Message::OnboardCreate), quiet_action("restore from a seed", Message::OnboardRestore), ] .spacing(8) .padding([12, 0]), ] .spacing(10) .align_x(Alignment::Start), OnboardMode::Created => column![ muted("this seed is the only secret — write it down now; it is shown once"), container( text_input("", &app.onboard.seed_shown) .font(Font::MONOSPACE) .size(12) .width(Length::Fill) ) .style(theme::card) .padding(6), row![ action("i saved it", Message::OnboardSeedSaved), quiet_action("back", Message::OnboardBack), ] .spacing(8), ] .spacing(10), OnboardMode::Restore => column![ muted("paste the 32-byte seed, base32 or hex"), text_input("seed", &app.onboard.seed) .on_input(Message::OnboardSeedEdited) .on_submit(Message::OnboardRestoreFromSeed) .size(12) .width(Length::Fill), row![ action("restore", Message::OnboardRestoreFromSeed), quiet_action("back", Message::OnboardBack), ] .spacing(8), ] .spacing(10), }; centered(body.into()) } fn signup<'a>(app: &'a Carrier) -> Element<'a, Message> { let mut body = column![ accent("set up the address"), row![ muted("user"), text_input("alice", &app.signup.user) .on_input(Message::SignupUserEdited) .size(12) .width(Length::Fill), ] .spacing(8), row![ muted("host"), text_input("example.org", &app.signup.host) .on_input(Message::SignupHostEdited) .size(12) .width(Length::Fill), ] .spacing(8), row![ muted("invite"), text_input("optional token", &app.signup.invite) .on_input(Message::SignupInviteEdited) .size(12) .width(Length::Fill), ] .spacing(8), row![ muted("key"), text_input("server key, if the operator published one", &app.signup.key) .on_input(Message::SignupKeyEdited) .size(12) .width(Length::Fill), ] .spacing(8), ] .spacing(8); if app.signup.key.is_empty() { body = body.push( row![ muted("or"), quiet_action("learn the server key", Message::SignupLearnKey), muted("and pin what it presents"), ] .spacing(8) .align_y(Alignment::Center), ); } body = body.push(pin_offer_banner(app)); body = body.push( row![ action("register", Message::SignupRegister), quiet_action("i already have this address", Message::SignupRecall), ] .spacing(8) .padding([8, 0]), ); if app.signup.fresh { body = body.push(quiet_action("back", Message::SignupBack)); } centered(body.into()) } // --- small helpers ----------------------------------------------------------- fn truncate(text: &str, max: usize) -> String { if text.chars().count() <= max { text.to_string() } else { let cut: String = text.chars().take(max - 1).collect(); format!("{cut}…") } } fn short(text: &str, max: usize) -> String { if text.len() <= max { text.to_string() } else { format!("{}…{}", &text[..max / 2], &text[text.len() - 3..]) } }