From 517d938c4603c5d735d2d3ae5b0bccfe94a4f427 Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 2 Oct 2026 00:08:48 +0300 Subject: [PATCH] feat: adopt a notational-velocity layout with larger text --- src/view.rs | 228 ++++++++++++++++++++++++---------------------------- 1 file changed, 106 insertions(+), 122 deletions(-) diff --git a/src/view.rs b/src/view.rs index a35b1da..ed3b6df 100644 --- a/src/view.rs +++ b/src/view.rs @@ -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() { - "(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))); + let mut meta = format!("{label} {} · {} UTC", msg.from, ops::when(msg.time)); + meta.push_str(&format!(" · id {}", short(&msg.id, 12))); if msg.kept { - headers = headers.push(header_line("server", "a copy still exists there")); + meta.push_str(" · a server copy still exists"); } + let subject = if msg.subject.is_empty() { + "(no subject)".to_string() + } else { + msg.subject.clone() + }; 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) - .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_input("to — alice@example.org or smol://…", &compose.to) + .on_input(Message::ComposeToEdited) + .size(S) + .width(Length::Fill), + text_input("subject", &compose.subject) + .on_input(Message::ComposeSubjectEdited) + .size(S) + .width(Length::Fill), 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,38 +738,25 @@ 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) - .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), + text_input("user — alice", &app.signup.user) + .on_input(Message::SignupUserEdited) + .size(S) + .width(Length::Fill), + text_input("host — example.org", &app.signup.host) + .on_input(Message::SignupHostEdited) + .size(S) + .width(Length::Fill), + text_input( + "invite token, if the server requires one", + &app.signup.invite + ) + .on_input(Message::SignupInviteEdited) + .size(S) + .width(Length::Fill), + text_input("server key, if the operator published one", &app.signup.key,) + .on_input(Message::SignupKeyEdited) + .size(S) + .width(Length::Fill), ] .spacing(8); if app.signup.key.is_empty() {