refactor: migrate the UI to GTK4 with Relm4 and libadwaita
This commit is contained in:
parent
517d938c46
commit
7fdcd0d811
11 changed files with 2782 additions and 4765 deletions
869
src/app.rs
869
src/app.rs
File diff suppressed because it is too large
Load diff
242
src/demo.rs
Normal file
242
src/demo.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
//! Sample data for layout work, compiled into dev builds only. The dev
|
||||
//! build uses its own `carrier-demo` profile directory; seeding runs once
|
||||
//! when it has no key yet, so read marks survive across refinement
|
||||
//! sessions and the release profile's real account is never touched.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use fumi_core::account::{Account, Identity};
|
||||
use fumi_core::address::Address;
|
||||
use fumi_core::crypto::{b32, KEY_LEN};
|
||||
use fumi_core::message::{build_frontmatter, message_id, seal};
|
||||
use fumi_core::store::{now, Store};
|
||||
|
||||
/// The demo master: fixed bytes, so every dev build derives the same
|
||||
/// identity and can open the same sealed sample mail.
|
||||
fn demo_master() -> [u8; KEY_LEN] {
|
||||
core::array::from_fn(|i| i as u8 + 1)
|
||||
}
|
||||
|
||||
/// A stable sender identity per seed byte.
|
||||
fn identity(tag: u8) -> Identity {
|
||||
Identity::from_seed([tag; KEY_LEN])
|
||||
}
|
||||
|
||||
pub fn seed(dir: &Path) {
|
||||
if dir.join("carrier.key").exists() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = plant(dir) {
|
||||
eprintln!("demo seed failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn plant(dir: &Path) -> Result<(), String> {
|
||||
let master = demo_master();
|
||||
crate::ops::save_key(&dir.join("carrier.key"), &master)?;
|
||||
|
||||
let store = Store::open(&dir.join("carrier.db")).map_err(|e| e.to_string())?;
|
||||
let me = Account::new(master, 0).map_err(|e| e.to_string())?;
|
||||
let addr = Address::parse("demo@carrier.local").map_err(|e| e.to_string())?;
|
||||
store.set_account(&addr).map_err(|e| e.to_string())?;
|
||||
|
||||
let pk = me.me().pk();
|
||||
let mut read_ids: Vec<String> = Vec::new();
|
||||
|
||||
// contacts: (address, tag, verified) — acceptance set below
|
||||
let contacts = [
|
||||
("alice@example.org", 11u8, true),
|
||||
("bob@example.org", 12, false),
|
||||
("carol@example.org", 13, true),
|
||||
("dave@example.org", 14, false),
|
||||
("erin@example.org", 15, true),
|
||||
("frank@example.org", 16, false),
|
||||
("grace@example.org", 17, true),
|
||||
("heidi@example.org", 18, false),
|
||||
];
|
||||
for (address, tag, verified) in contacts {
|
||||
let sender = identity(tag);
|
||||
store
|
||||
.save_contact(address, &sender.pk(), verified)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// a spread of states: accepted, pending, blocked
|
||||
for (address, tag) in [
|
||||
("alice@example.org", 11),
|
||||
("bob@example.org", 12),
|
||||
("grace@example.org", 17),
|
||||
] {
|
||||
store
|
||||
.accept(address, &identity(tag).pk())
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
store
|
||||
.accept("erin@example.org", &identity(15).pk())
|
||||
.map_err(|e| e.to_string())?;
|
||||
store.block("erin@example.org").map_err(|e| e.to_string())?;
|
||||
|
||||
let mut deliver = |sender: &Identity,
|
||||
from: &str,
|
||||
subject: Option<&str>,
|
||||
body: &str,
|
||||
hours_ago: i64,
|
||||
requests: bool,
|
||||
read: bool|
|
||||
-> Result<(), String> {
|
||||
let mut fields: Vec<(&str, &str)> = Vec::new();
|
||||
if let Some(subject) = subject {
|
||||
fields.push(("Subject", subject));
|
||||
}
|
||||
let uri = Address::parse(from).unwrap().uri(&sender.pk());
|
||||
fields.push(("Reply-To", &uri));
|
||||
let text = build_frontmatter(&fields, body);
|
||||
let envelope = seal(sender, &pk, text.as_bytes(), now() - hours_ago * 3600, true)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let id = message_id(&envelope);
|
||||
store
|
||||
.store_inbox(&id, &envelope, now() - hours_ago * 3600, requests, false)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if read {
|
||||
read_ids.push(id.iter().map(|b| format!("{b:02x}")).collect());
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// inbox: mixed senders, subjects, read state and times
|
||||
deliver(
|
||||
&identity(11),
|
||||
"alice@example.org",
|
||||
Some("Re: deploying the new relay"),
|
||||
RELAY_BODY,
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
)?;
|
||||
deliver(
|
||||
&identity(12),
|
||||
"bob@example.org",
|
||||
Some("lunch on friday?"),
|
||||
"There's a new place near the market hall.\n\nSay 12:30?",
|
||||
2,
|
||||
false,
|
||||
false,
|
||||
)?;
|
||||
deliver(
|
||||
&identity(11),
|
||||
"alice@example.org",
|
||||
None,
|
||||
"no subject, just a short note — the layout shows the fallback.",
|
||||
26,
|
||||
false,
|
||||
true,
|
||||
)?;
|
||||
deliver(
|
||||
&identity(17),
|
||||
"grace@example.org",
|
||||
Some("quarterly infrastructure review — action items, owners and the follow-ups from the migration plan"),
|
||||
"Long subjects exercise the row's ellipsis.\n\nMore detail in the pad.",
|
||||
49,
|
||||
false,
|
||||
false,
|
||||
)?;
|
||||
deliver(
|
||||
&identity(13),
|
||||
"carol@example.org",
|
||||
Some("reading list"),
|
||||
LIST_BODY,
|
||||
73,
|
||||
false,
|
||||
true,
|
||||
)?;
|
||||
deliver(
|
||||
&identity(12),
|
||||
"bob@example.org",
|
||||
Some("Re: lunch on friday?"),
|
||||
"12:30 works. See you there.",
|
||||
74,
|
||||
false,
|
||||
true,
|
||||
)?;
|
||||
|
||||
// requests: senders not in the contact list
|
||||
deliver(
|
||||
&identity(31),
|
||||
"ivy@example.org",
|
||||
Some("intro — heard you run a mailbox"),
|
||||
"A friend pointed me here. Wanted to say hello and ask how the server holds up.",
|
||||
5,
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
deliver(
|
||||
&identity(32),
|
||||
"judy@example.org",
|
||||
Some("hello"),
|
||||
"first message, no subject given on the next one.",
|
||||
9,
|
||||
true,
|
||||
true,
|
||||
)?;
|
||||
deliver(
|
||||
&identity(33),
|
||||
"mallory@example.org",
|
||||
Some("URGENT: verify your account"),
|
||||
"Click nothing. This row exists to test how spam sits in the requests tier.",
|
||||
30,
|
||||
true,
|
||||
false,
|
||||
)?;
|
||||
|
||||
// sent: self-copies opened with our own keys
|
||||
for (to, subject, hours_ago, read) in [
|
||||
("alice@example.org", "Re: deploying the new relay", 3, true),
|
||||
("bob@example.org", "lunch on friday?", 27, true),
|
||||
(
|
||||
"grace@example.org",
|
||||
"re: quarterly infrastructure review",
|
||||
52,
|
||||
true,
|
||||
),
|
||||
] {
|
||||
let text = build_frontmatter(&[("Subject", subject)], "the sent copy of the message");
|
||||
let envelope = seal(
|
||||
me.me(),
|
||||
&pk,
|
||||
text.as_bytes(),
|
||||
now() - hours_ago * 3600,
|
||||
true,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let id = message_id(&envelope);
|
||||
store
|
||||
.store_sent(&id, to, &envelope, now() - hours_ago * 3600)
|
||||
.map_err(|e| e.to_string())?;
|
||||
if read {
|
||||
read_ids.push(id.iter().map(|b| format!("{b:02x}")).collect());
|
||||
}
|
||||
}
|
||||
|
||||
// pre-marked read rows, so unread dots have both states to show
|
||||
std::fs::write(dir.join("read.txt"), read_ids.join("\n") + "\n").map_err(|e| e.to_string())?;
|
||||
|
||||
let _ = b32(&pk);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const RELAY_BODY: &str = "The relay is up and taking traffic again.
|
||||
|
||||
I rotated the upstream certificate last night; the old chain still verifies for the transition week, so nothing should fall over mid-handshake. If your client complains, re-resolve once and it will pick up the new key.
|
||||
|
||||
Two things to watch:
|
||||
- the queue depth alarm is set to 5000, we hovered near 3000 during the migration
|
||||
- retention on the requests tier stays at seven days, main tier at thirty
|
||||
|
||||
If nothing explodes by friday, I will collapse the fallback route.";
|
||||
|
||||
const LIST_BODY: &str = "A few things worth your evening:
|
||||
|
||||
- 'The Rise and Fall of the BBS Scene' — long, nostalgic, worth it for the FidoNet chapter alone
|
||||
- the paper on Noise protocol patterns we discussed
|
||||
- that talk about small mail servers, the one with the graphs about latency budgets
|
||||
|
||||
Nothing urgent in there, just good company for a cup of tea.";
|
||||
35
src/main.rs
35
src/main.rs
|
|
@ -1,38 +1,37 @@
|
|||
//! carrier — a minimalist smolmail client built on fumi-core, with an Iced
|
||||
//! UI. State lives in the config directory: `carrier.key` (the master,
|
||||
//! 0600), `carrier.db` (fumi-core's sealed store) and `read.txt` (read
|
||||
//! marks, carrier's own sidecar).
|
||||
//! carrier — a minimalist smolmail client built on fumi-core, with a
|
||||
//! Relm4 UI. State lives in the config directory: `carrier.key` (the
|
||||
//! master, 0600), `carrier.db` (fumi-core's sealed store) and `read.txt`
|
||||
//! (read marks, carrier's own sidecar).
|
||||
|
||||
mod app;
|
||||
#[cfg(debug_assertions)]
|
||||
mod demo;
|
||||
mod ops;
|
||||
mod theme;
|
||||
mod view;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use app::Carrier;
|
||||
|
||||
fn main() -> iced::Result {
|
||||
fn main() {
|
||||
let dir = config_dir();
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
eprintln!("cannot create {}: {e}", dir.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
iced::application("carrier", Carrier::update, view::view)
|
||||
.theme(|_| theme::theme())
|
||||
.subscription(Carrier::subscription)
|
||||
.window_size(iced::Size::new(1080.0, 640.0))
|
||||
.run_with(move || {
|
||||
let (state, task) = Carrier::boot(dir);
|
||||
(state, task)
|
||||
})
|
||||
#[cfg(debug_assertions)]
|
||||
demo::seed(&dir);
|
||||
view::run(dir);
|
||||
}
|
||||
|
||||
fn config_dir() -> PathBuf {
|
||||
match std::env::var("XDG_CONFIG_HOME") {
|
||||
let base = match std::env::var("XDG_CONFIG_HOME") {
|
||||
Ok(xdg) if !xdg.is_empty() => PathBuf::from(xdg).join("carrier"),
|
||||
_ => PathBuf::from(std::env::var("HOME").unwrap_or_default())
|
||||
.join(".config")
|
||||
.join("carrier"),
|
||||
}
|
||||
};
|
||||
// Dev builds get their own profile, seeded with sample data in
|
||||
// `demo.rs`, so layout work never touches the real account.
|
||||
#[cfg(debug_assertions)]
|
||||
let base = base.with_file_name("carrier-demo");
|
||||
base
|
||||
}
|
||||
|
|
|
|||
126
src/theme.rs
126
src/theme.rs
|
|
@ -1,126 +0,0 @@
|
|||
//! The one palette the whole UI is built from: dark navy surfaces, light
|
||||
//! text, mint accents — the same scheme kirakira ships as its dark theme.
|
||||
|
||||
use iced::border;
|
||||
use iced::color;
|
||||
use iced::theme::Palette;
|
||||
use iced::widget::{button, container};
|
||||
use iced::{Border, Color, Theme};
|
||||
|
||||
pub const BACKGROUND: Color = color!(0x01111d);
|
||||
pub const PANEL: Color = color!(0x0a1c2e);
|
||||
pub const CARD: Color = color!(0x10263b);
|
||||
pub const ACCENT: Color = color!(0x3ddc97);
|
||||
pub const TEXT: Color = color!(0xdce7f2);
|
||||
pub const MUTED: Color = color!(0x7a90a5);
|
||||
pub const DANGER: Color = color!(0xf0564a);
|
||||
|
||||
pub fn theme() -> Theme {
|
||||
Theme::custom(
|
||||
"carrier".to_string(),
|
||||
Palette {
|
||||
background: BACKGROUND,
|
||||
text: TEXT,
|
||||
primary: ACCENT,
|
||||
success: ACCENT,
|
||||
danger: DANGER,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The surface of the navigation sidebar.
|
||||
pub fn panel(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(PANEL.into()),
|
||||
text_color: Some(TEXT),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A card slightly lighter than its panel.
|
||||
pub fn card(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(CARD.into()),
|
||||
text_color: Some(TEXT),
|
||||
border: border::rounded(6),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The status line at the bottom of the window.
|
||||
pub fn status(error: bool) -> impl Fn(&Theme) -> container::Style {
|
||||
move |_theme| container::Style {
|
||||
background: Some(PANEL.into()),
|
||||
text_color: Some(if error { DANGER } else { MUTED }),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The primary action button: mint on navy.
|
||||
pub fn accent() -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(if hovered { color!(0x54e8a8) } else { ACCENT }.into()),
|
||||
text_color: BACKGROUND,
|
||||
border: border::rounded(4),
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A quiet secondary button: filled with the panel color, no border.
|
||||
pub fn quiet() -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(if hovered { CARD } else { PANEL }.into()),
|
||||
text_color: TEXT,
|
||||
border: border::rounded(4),
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A destructive action, red text on a quiet surface.
|
||||
pub fn danger() -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(if hovered { color!(0x2a1420) } else { PANEL }.into()),
|
||||
text_color: DANGER,
|
||||
border: border::rounded(4),
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single list row; the selected row carries the accent border.
|
||||
pub fn row_style(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
|
||||
move |_theme, status| {
|
||||
let hovered = matches!(status, button::Status::Hovered);
|
||||
button::Style {
|
||||
background: Some(
|
||||
if selected {
|
||||
CARD
|
||||
} else if hovered {
|
||||
color!(0x0d2136)
|
||||
} else {
|
||||
PANEL
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
text_color: TEXT,
|
||||
border: if selected {
|
||||
Border {
|
||||
color: ACCENT,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
}
|
||||
} else {
|
||||
border::rounded(4)
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
2427
src/view.rs
2427
src/view.rs
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue