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