carrier/src/view.rs

822 lines
25 KiB
Rust
Raw Normal View History

//! 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..])
}
}