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
3747
Cargo.lock
generated
3747
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
10
Cargo.toml
10
Cargo.toml
|
|
@ -2,14 +2,18 @@
|
|||
name = "carrier"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "A minimalist smolmail client built on fumi-core, with an Iced UI"
|
||||
description = "A minimalist smolmail client built on fumi-core, with a Relm4 UI"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
fumi-core = { path = "../fumi/core" }
|
||||
iced = { version = "0.13", features = ["tokio", "qr_code"] }
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
gtk = { version = "0.9", package = "gtk4" }
|
||||
# Relm4 pulls libadwaita in; the direct entry only raises the API level,
|
||||
# since Cargo unions features and the rows and dialogs used here are 1.5.
|
||||
libadwaita = { version = "0.7", features = ["v1_5"] }
|
||||
relm4 = { version = "0.9", features = ["libadwaita"] }
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
qrcodegen = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# carrier
|
||||
|
||||
A minimalist desktop client for [Smol Mail](../smolmail): one compact window — the list above, the article below — built on [fumi-core](../fumi), the reference CLI's own library, with an [Iced](https://iced.rs) UI. One 32-byte seed is the only secret; mail stays sealed at rest and opens on demand.
|
||||
A minimalist desktop client for [Smol Mail](../smolmail): one compact window — the list above, the article below — built on [fumi-core](../fumi), the reference CLI's own library, with a [Relm4](https://relm4.org) (GTK4) UI built from libadwaita's own widgets, so it follows the user's theme and accent colour. One 32-byte seed is the only secret; mail stays sealed at rest and opens on demand.
|
||||
|
||||
## What it does
|
||||
|
||||
|
|
@ -14,11 +14,10 @@ A minimalist desktop client for [Smol Mail](../smolmail): one compact window —
|
|||
## Layout
|
||||
|
||||
```
|
||||
src/main.rs config directory, iced launch
|
||||
src/main.rs config directory, demo seeding, launch
|
||||
src/app.rs state and update; blocking calls run on the worker pool
|
||||
src/view.rs the compact split view: tab bar, list, article, status line
|
||||
src/view.rs the Relm4 component: widgets, the op adapter, the re-render
|
||||
src/ops.rs fumi-core calls, key/read-mark files, and the offline tests
|
||||
src/theme.rs the palette: navy surfaces, mint accent, monospace protocol values
|
||||
```
|
||||
|
||||
## State
|
||||
|
|
|
|||
11
devbox.json
11
devbox.json
|
|
@ -10,7 +10,16 @@
|
|||
"nixpkgs#mesa",
|
||||
"nixpkgs#libGL",
|
||||
"nixpkgs#vulkan-loader",
|
||||
"nixpkgs#gcc"
|
||||
"nixpkgs#gcc",
|
||||
"nixpkgs#gtk4",
|
||||
"nixpkgs#glib",
|
||||
"nixpkgs#pango",
|
||||
"nixpkgs#cairo",
|
||||
"nixpkgs#graphene",
|
||||
"nixpkgs#gdk-pixbuf",
|
||||
"nixpkgs#harfbuzz",
|
||||
"nixpkgs#libadwaita",
|
||||
"nixpkgs#adwaita-icon-theme"
|
||||
],
|
||||
"shell": {
|
||||
"init_hook": [
|
||||
|
|
|
|||
36
devbox.lock
36
devbox.lock
|
|
@ -43,14 +43,46 @@
|
|||
"last_modified": "2026-09-29T03:29:29Z",
|
||||
"resolved": "github:NixOS/nixpkgs/b6c8664de9b6cc07fe5666a29f91884ba81197c4?lastModified=1790652569&narHash=sha256-641r7xrlSOHYoktL9%2FaArPIYNgZJ9eM5URzSEOtPJDY%3D"
|
||||
},
|
||||
"nixpkgs#adwaita-icon-theme": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#adwaita-icon-theme"
|
||||
},
|
||||
"nixpkgs#cairo": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#cairo"
|
||||
},
|
||||
"nixpkgs#gcc": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gcc"
|
||||
},
|
||||
"nixpkgs#gdk-pixbuf": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gdk-pixbuf"
|
||||
},
|
||||
"nixpkgs#glib": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#glib"
|
||||
},
|
||||
"nixpkgs#graphene": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#graphene"
|
||||
},
|
||||
"nixpkgs#gtk4": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#gtk4"
|
||||
},
|
||||
"nixpkgs#harfbuzz": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#harfbuzz"
|
||||
},
|
||||
"nixpkgs#libGL": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libGL"
|
||||
},
|
||||
"nixpkgs#libadwaita": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libadwaita"
|
||||
},
|
||||
"nixpkgs#libxkbcommon": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#libxkbcommon"
|
||||
|
|
@ -59,6 +91,10 @@
|
|||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#mesa"
|
||||
},
|
||||
"nixpkgs#pango": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#pango"
|
||||
},
|
||||
"nixpkgs#vulkan-loader": {
|
||||
"last_modified": "1970-01-01T00:00:00Z",
|
||||
"resolved": "path:/nix/store/v8ynlbrldn7k8byb1kx0j0wka8dsbrw9-source?narHash=sha256-BZWCi9ZRJiARTuKTbbtvFTj7t1TK4G3UEckT3HyNfRg%3D#vulkan-loader"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
#!/usr/bin/env sh
|
||||
# Devbox installs library packages as their -dev outputs, which carry no
|
||||
# shared objects; the outs they reference do. Put the GUI stack's real lib
|
||||
# dirs on LD_LIBRARY_PATH — wayland, xkbcommon, EGL and vulkan are dlopened
|
||||
# at runtime, so neither rpath nor a DT_NEEDED scan can cover them. Only
|
||||
# matching package names are picked up: a blanket list would drag glibc and
|
||||
# friends onto the path for every process in the shell.
|
||||
# dirs on LD_LIBRARY_PATH — wayland, xkbcommon, EGL, vulkan, gtk — all of
|
||||
# which are dlopened or linked at runtime, so neither rpath nor a
|
||||
# DT_NEEDED scan can cover them. Package names match exactly up to the
|
||||
# version suffix: a substring match would drag glibc onto the path for
|
||||
# every process in the shell ("glib" matches "glibc") and crash them.
|
||||
set -e
|
||||
|
||||
if [ -z "$DEVBOX_PACKAGES_DIR" ]; then
|
||||
|
|
@ -15,17 +16,24 @@ MANIFEST="$DEVBOX_PACKAGES_DIR/manifest.json"
|
|||
[ -f "$MANIFEST" ] || return 0 2>/dev/null || exit 0
|
||||
|
||||
libs=$(python3 - "$MANIFEST" <<'EOF'
|
||||
import json, os, subprocess, sys
|
||||
import json, os, re, subprocess, sys
|
||||
|
||||
WANTED = {
|
||||
"wayland", "libxkbcommon", "libglvnd", "vulkan-loader", "mesa",
|
||||
"gtk4", "libadwaita", "glib", "pango", "cairo", "gdk-pixbuf", "graphene",
|
||||
"harfbuzz", "libepoxy", "freetype", "fribidi", "fontconfig",
|
||||
"libpng", "pixman", "libxml2", "libtiff", "libjpeg_turbo",
|
||||
}
|
||||
|
||||
def pkg_name(path):
|
||||
base = os.path.basename(path.rstrip("/"))
|
||||
# "pango-1.56.0-dev" -> "pango"
|
||||
m = re.match(r"([A-Za-z0-9_.-]+?)(-[0-9].*)?$", base)
|
||||
return m.group(1).rstrip("-") if m else base
|
||||
|
||||
wanted = ("wayland", "libxkbcommon", "libglvnd", "vulkan-loader", "mesa")
|
||||
manifest = json.load(open(sys.argv[1]))
|
||||
seen, dirs = set(), []
|
||||
paths = [
|
||||
path
|
||||
for name, el in manifest["elements"].items()
|
||||
if any(w in name for w in wanted)
|
||||
for path in el.get("storePaths", [])
|
||||
]
|
||||
paths = [p for el in manifest["elements"].values() for p in el.get("storePaths", [])]
|
||||
for path in paths:
|
||||
candidates = [path]
|
||||
try:
|
||||
|
|
@ -37,10 +45,9 @@ for path in paths:
|
|||
pass
|
||||
for cand in candidates:
|
||||
lib = os.path.join(cand, "lib")
|
||||
base = os.path.basename(cand)
|
||||
if lib in seen or not os.path.isdir(lib):
|
||||
continue
|
||||
if not any(w in base for w in wanted):
|
||||
if pkg_name(cand) not in WANTED:
|
||||
continue
|
||||
if any(name.endswith(".so") or ".so." in name for name in os.listdir(lib)):
|
||||
seen.add(lib)
|
||||
|
|
@ -50,3 +57,5 @@ EOF
|
|||
)
|
||||
|
||||
export LD_LIBRARY_PATH="${libs}:${DEVBOX_PACKAGES_DIR}/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||
# GTK finds schemas, icons and themes through XDG_DATA_DIRS.
|
||||
export XDG_DATA_DIRS="${DEVBOX_PACKAGES_DIR}/share:${XDG_DATA_DIRS:-/usr/share:/usr/local/share}"
|
||||
|
|
|
|||
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