feat: adopt a notational-velocity layout with larger text

This commit is contained in:
randogoth 2026-10-02 00:08:48 +03:00
parent 7ed2889966
commit 517d938c46

View file

@ -14,13 +14,18 @@ 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;
/// Text sizes: rows and controls read at 14, the article body at 15, the
/// selected subject as a 16 heading — the sizes of the notational-velocity
/// family this layout borrows from.
const S: f32 = 14.0;
const BODY: f32 = 15.0;
const BIG: f32 = 16.0;
/// Column widths of the contacts table, in pixels; monospace keeps them
/// aligned.
const ADDR_W: f32 = 300.0;
const KEY_W: f32 = 300.0;
const STATE_W: f32 = 110.0;
pub fn view(app: &Carrier) -> Element<'_, Message> {
let content: Element<'_, Message> = if app.compose.is_some() {
@ -47,21 +52,21 @@ pub fn view(app: &Carrier) -> Element<'_, Message> {
fn mono<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
text(content.to_string())
.font(Font::MONOSPACE)
.size(12)
.size(S)
.color(TEXT)
}
fn muted<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
text(content.to_string())
.font(Font::MONOSPACE)
.size(12)
.size(S)
.color(MUTED)
}
fn accent<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> {
text(content.to_string())
.font(Font::MONOSPACE)
.size(12)
.size(S)
.color(ACCENT)
}
@ -74,33 +79,33 @@ fn cell<'a>(content: iced::widget::Text<'a>, width: f32) -> Element<'a, Message>
}
fn action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
button(text(label.to_string()).size(S))
.on_press(msg)
.style(theme::accent())
.padding([3, 10])
.padding([4, 12])
.into()
}
fn action_disabled<'a>(label: &str) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
.padding([3, 10])
button(text(label.to_string()).size(S))
.padding([4, 12])
.style(theme::quiet())
.into()
}
fn quiet_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
button(text(label.to_string()).size(S))
.on_press(msg)
.style(theme::quiet())
.padding([3, 10])
.padding([4, 12])
.into()
}
fn danger_action<'a>(label: &str, msg: Message) -> Element<'a, Message> {
button(text(label.to_string()).size(12))
button(text(label.to_string()).size(S))
.on_press(msg)
.style(theme::danger())
.padding([3, 10])
.padding([4, 12])
.into()
}
@ -110,11 +115,11 @@ fn tab<'a>(label: impl std::fmt::Display, active: bool, msg: Message) -> Element
button(
text(label)
.font(Font::MONOSPACE)
.size(12)
.size(S)
.color(if active { ACCENT } else { MUTED }),
)
.on_press(msg)
.padding([3, 6])
.padding([4, 8])
.into()
}
@ -208,7 +213,7 @@ fn status<'a>(app: &'a Carrier) -> Element<'a, Message> {
}
(None, Some(status)) => {
let piece = if app.status_error {
text(status.clone()).font(Font::MONOSPACE).size(12)
text(status.clone()).font(Font::MONOSPACE).size(S)
} else {
muted(status.clone())
};
@ -289,22 +294,14 @@ fn list_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
.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);
let mut list = column![].spacing(2);
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)));
list = list.push(container(muted(hint)).padding(Padding::new(14.0)));
}
for row in &rows {
let selected = app
@ -316,43 +313,44 @@ fn list_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
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.
/// One row, like a note title: unread flag and subject on the left, sender
/// and date muted on the right — no column chrome, the whole line clicks.
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);
let subject = if row.subject.is_empty() {
"(no subject)".to_string()
} else {
truncate(&row.subject, 56)
};
button(
row![
cell(mono(date), DATE_W),
cell(flag, FLAG_W),
cell(mono(truncate(&row.from, 32)), FROM_W),
text(truncate(&row.subject, 64))
flag,
text(subject)
.font(Font::MONOSPACE)
.size(12)
.size(S)
.color(if row.unread { TEXT } else { MUTED })
.wrapping(Wrapping::None),
Space::with_width(Length::Fill),
muted(truncate(&row.from, 24)),
muted(ops::when(row.at)),
]
.spacing(10)
.align_y(Alignment::Center),
)
.on_press(Message::MessageSelected(folder, row.id.clone()))
.style(theme::row_style(selected))
.padding(padding())
.padding(Padding::new(4.0).left(12.0).right(12.0))
.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.
/// A `label: value` line for the settings page.
fn header_line<'a>(label: &str, value: impl std::fmt::Display) -> Element<'a, Message> {
row![muted(format!("{label:<9}")), mono(value),]
row![muted(format!("{label:<12}")), mono(value),]
.spacing(4)
.into()
}
@ -363,26 +361,22 @@ fn detail_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
.center(Length::Fill)
.into();
};
let mut headers = column![].spacing(1);
let to_label = if *folder == Folder::Sent {
// One muted meta line carries what the pane used to stack as headers.
let 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() {
let mut meta = format!("{label} {} · {} UTC", msg.from, ops::when(msg.time));
meta.push_str(&format!(" · id {}", short(&msg.id, 12)));
if msg.kept {
meta.push_str(" · a server copy still exists");
}
let 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 {
@ -395,18 +389,29 @@ fn detail_pane<'a>(app: &'a Carrier) -> Element<'a, Message> {
}
actions = actions.push(danger_action("delete", Message::DeletePressed));
column![
container(headers).padding([8, 10]),
container(
column![
text(subject)
.font(Font::MONOSPACE)
.size(BIG)
.color(TEXT)
.wrapping(Wrapping::None),
muted(meta),
]
.spacing(4),
)
.padding([10, 12]),
rule::Rule::horizontal(1.0),
scrollable(column![container(
text(msg.body.clone())
.font(Font::MONOSPACE)
.size(12)
.size(BODY)
.color(TEXT)
)
.padding([8, 10])])
.padding([10, 12])])
.height(Length::Fill),
rule::Rule::horizontal(1.0),
container(actions).padding([6, 10]),
container(actions).padding([6, 12]),
]
.spacing(0)
.height(Length::Fill)
@ -430,26 +435,18 @@ fn compose<'a>(app: &'a Carrier) -> Element<'a, Message> {
top_bar(app),
container(
column![
row![
muted("to"),
text_input("alice@example.org or smol://…", &compose.to)
text_input("to — alice@example.org or smol://…", &compose.to)
.on_input(Message::ComposeToEdited)
.size(12)
.size(S)
.width(Length::Fill),
]
.spacing(8),
row![
muted("subject"),
text_input("subject", &compose.subject)
.on_input(Message::ComposeSubjectEdited)
.size(12)
.size(S)
.width(Length::Fill),
]
.spacing(8),
text_editor(&compose.body)
.on_action(Message::ComposeBody)
.font(Font::MONOSPACE)
.size(12)
.size(BODY)
.height(Length::Fill),
row![
action(label, Message::SendPressed),
@ -457,9 +454,9 @@ fn compose<'a>(app: &'a Carrier) -> Element<'a, Message> {
]
.spacing(8),
]
.spacing(6)
.spacing(8)
)
.padding(10)
.padding(12)
.height(Length::Fill),
]
.spacing(0)
@ -491,7 +488,7 @@ fn contacts<'a>(app: &'a Carrier) -> Element<'a, Message> {
muted(""),
]
.spacing(10)
.padding(padding());
.padding(Padding::new(4.0).left(12.0).right(12.0));
let mut table = column![header].spacing(1);
for contact in &snap.contacts {
table = table.push(contact_row(contact));
@ -510,7 +507,7 @@ fn contacts<'a>(app: &'a Carrier) -> Element<'a, Message> {
text_input("paste a smol:// address to import", &app.contact_import)
.on_input(Message::ContactImportEdited)
.on_submit(Message::ContactImportPressed)
.size(12)
.size(S)
.width(Length::Fill),
action("import", Message::ContactImportPressed),
]
@ -572,7 +569,7 @@ fn contact_row<'a>(contact: &Contact) -> Element<'a, Message> {
.align_y(Alignment::Center),
)
.style(theme::card)
.padding(padding())
.padding(Padding::new(4.0).left(12.0).right(12.0))
.width(Length::Fill)
.into()
}
@ -631,7 +628,7 @@ fn settings<'a>(app: &'a Carrier) -> Element<'a, Message> {
text_editor(&app.import_body)
.on_action(Message::ImportBody)
.font(Font::MONOSPACE)
.size(12)
.size(S)
.height(Length::Fixed(100.0)),
);
export = export.push(action("import", Message::ImportPressed));
@ -646,7 +643,7 @@ fn settings<'a>(app: &'a Carrier) -> Element<'a, Message> {
muted("timeout"),
text_input("30", &app.timeout_text)
.on_input(Message::TimeoutEdited)
.size(12)
.size(S)
.width(80),
muted("seconds"),
]
@ -708,7 +705,7 @@ fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> {
container(
text_input("", &app.onboard.seed_shown)
.font(Font::MONOSPACE)
.size(12)
.size(S)
.width(Length::Fill)
)
.style(theme::card)
@ -725,7 +722,7 @@ fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> {
text_input("seed", &app.onboard.seed)
.on_input(Message::OnboardSeedEdited)
.on_submit(Message::OnboardRestoreFromSeed)
.size(12)
.size(S)
.width(Length::Fill),
row![
action("restore", Message::OnboardRestoreFromSeed),
@ -741,39 +738,26 @@ fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> {
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)
text_input("user — alice", &app.signup.user)
.on_input(Message::SignupUserEdited)
.size(12)
.size(S)
.width(Length::Fill),
]
.spacing(8),
row![
muted("host"),
text_input("example.org", &app.signup.host)
text_input("host — example.org", &app.signup.host)
.on_input(Message::SignupHostEdited)
.size(12)
.size(S)
.width(Length::Fill),
]
.spacing(8),
row![
muted("invite"),
text_input("optional token", &app.signup.invite)
text_input(
"invite token, if the server requires one",
&app.signup.invite
)
.on_input(Message::SignupInviteEdited)
.size(12)
.size(S)
.width(Length::Fill),
]
.spacing(8),
row![
muted("key"),
text_input("server key, if the operator published one", &app.signup.key)
text_input("server key, if the operator published one", &app.signup.key,)
.on_input(Message::SignupKeyEdited)
.size(12)
.size(S)
.width(Length::Fill),
]
.spacing(8),
]
.spacing(8);
if app.signup.key.is_empty() {
body = body.push(