From 7fdcd0d811963f6fa48b6ffd9931ae9fa41d47f2 Mon Sep 17 00:00:00 2001 From: randogoth Date: Fri, 2 Oct 2026 22:32:12 +0300 Subject: [PATCH] refactor: migrate the UI to GTK4 with Relm4 and libadwaita --- Cargo.lock | 3747 ++++------------------------------------ Cargo.toml | 10 +- README.md | 7 +- devbox.json | 11 +- devbox.lock | 36 + scripts/devbox-libs.sh | 37 +- src/app.rs | 869 +++++----- src/demo.rs | 242 +++ src/main.rs | 35 +- src/theme.rs | 126 -- src/view.rs | 2427 ++++++++++++++++++-------- 11 files changed, 2782 insertions(+), 4765 deletions(-) create mode 100644 src/demo.rs delete mode 100644 src/theme.rs diff --git a/Cargo.lock b/Cargo.lock index ed2977b..d4eb85f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,28 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aead" version = "0.5.2" @@ -59,17 +37,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "ahash" version = "0.8.12" @@ -77,236 +44,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android-activity" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" -dependencies = [ - "android-properties", - "bitflags 2.13.2", - "cc", - "jni", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys 0.6.0+11769913", - "num_enum", - "thiserror 2.0.21", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[package]] -name = "ash" -version = "0.37.3+1.3.251" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" -dependencies = [ - "libloading 0.7.4", -] - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.5", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.5", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.5", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.5.1" @@ -319,27 +61,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.2" @@ -355,12 +76,6 @@ dependencies = [ "digest", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -370,28 +85,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block2" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" -dependencies = [ - "objc2 0.5.2", -] - -[[package]] -name = "blocking" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -399,86 +92,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "by_address" -version = "1.2.1" +name = "cairo-rs" +version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0" dependencies = [ - "bytemuck_derive", + "bitflags", + "cairo-sys-rs", + "glib", + "libc", ] [[package]] -name = "bytemuck_derive" -version = "1.12.1" +name = "cairo-sys-rs" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "calloop" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" -dependencies = [ - "bitflags 2.13.2", - "log", - "polling", - "rustix 0.38.44", - "slab", - "thiserror 1.0.69", -] - -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.13.2", - "polling", - "rustix 1.1.5", - "slab", - "tracing", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" -dependencies = [ - "calloop 0.13.0", - "rustix 0.38.44", - "wayland-backend", - "wayland-client", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.4", - "rustix 1.1.5", - "wayland-backend", - "wayland-client", + "glib-sys", + "libc", + "system-deps", ] [[package]] @@ -486,10 +119,12 @@ name = "carrier" version = "0.1.0" dependencies = [ "fumi-core", - "iced", + "gtk4", + "libadwaita", + "qrcodegen", "rand_core", + "relm4", "tempfile", - "tokio", ] [[package]] @@ -499,29 +134,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - [[package]] name = "chacha20" version = "0.9.1" @@ -557,174 +188,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clipboard-win" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" -dependencies = [ - "error-code", -] - -[[package]] -name = "clipboard_macos" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b7f4aaa047ba3c3630b080bb9860894732ff23e2aee290a418909aa6d5df38f" -dependencies = [ - "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "clipboard_wayland" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "003f886bc4e2987729d10c1db3424e7f80809f3fc22dbc16c685738887cb37b8" -dependencies = [ - "smithay-clipboard", -] - -[[package]] -name = "clipboard_x11" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd63e33452ffdafd39924c4f05a5dd1e94db646c779c6bd59148a3d95fff5ad4" -dependencies = [ - "thiserror 2.0.21", - "x11rb", -] - -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - -[[package]] -name = "com" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" -dependencies = [ - "com_macros", -] - -[[package]] -name = "com_macros" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" -dependencies = [ - "com_macros_support", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "com_macros_support" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "combine" -version = "4.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "libc", -] - -[[package]] -name = "cosmic-text" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fd57d82eb4bfe7ffa9b1cec0c05e2fd378155b47f255a67983cb4afe0e80c2" -dependencies = [ - "bitflags 2.13.2", - "fontdb", - "log", - "rangemap", - "rayon", - "rustc-hash 1.1.0", - "rustybuzz", - "self_cell", - "swash", - "sys-locale", - "ttf-parser 0.21.1", - "unicode-bidi", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -734,46 +203,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.7" @@ -785,15 +214,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "ctor" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" -dependencies = [ - "dtor", -] - [[package]] name = "ctr" version = "0.9.2" @@ -803,12 +223,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -836,45 +250,12 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "d3d12" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" -dependencies = [ - "bitflags 2.13.2", - "libloading 0.8.9", - "winapi", -] - -[[package]] -name = "dark-light" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a76fa97167fa740dcdbfe18e8895601e1bc36525f09b044e00916e717c03a3c" -dependencies = [ - "dconf_rs", - "detect-desktop-environment", - "dirs", - "objc", - "rust-ini", - "web-sys", - "winreg", - "zbus", -] - [[package]] name = "data-encoding" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "dconf_rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7046468a81e6a002061c01e6a7c83139daf91b11c30e66795b13217c2d885c8b" - [[package]] name = "der" version = "0.7.10" @@ -885,12 +266,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "detect-desktop-environment" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d8ad60dd5b13a4ee6bd8fa2d5d88965c597c67bce32b5fc49c94f55cb50810" - [[package]] name = "digest" version = "0.10.7" @@ -902,115 +277,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.2", - "objc2 0.6.4", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading 0.8.9", -] - -[[package]] -name = "dlv-list" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "drm" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80bc8c5c6c2941f70a55c15f8d9f00f9710ebda3ffda98075f996a0e6c92756f" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "drm-ffi", - "drm-fourcc", - "libc", - "rustix 0.38.44", -] - -[[package]] -name = "drm-ffi" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a91c9b32ac4e8105dec255e849e0d66e27d7c34d184364fb93e469db08f690" -dependencies = [ - "drm-sys", - "rustix 1.1.5", -] - -[[package]] -name = "drm-fourcc" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aafbcdb8afc29c1a7ee5fbe53b5d62f4565b35a042a662ca9fecd0b54dae6f4" - -[[package]] -name = "drm-sys" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8e1361066d91f5ffccff060a3c3be9c3ecde15be2959c1937595f7a82a9f8" -dependencies = [ - "libc", - "linux-raw-sys 0.9.4", -] - -[[package]] -name = "dtor" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" - [[package]] name = "ed25519" version = "2.2.3" @@ -1036,39 +302,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1085,51 +318,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "error-code" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" - -[[package]] -name = "etagere" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" -dependencies = [ - "euclid", - "svg_fmt", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1148,21 +336,22 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1170,81 +359,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] -name = "flate2" -version = "1.1.10" +name = "flume" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ - "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", + "futures-core", + "futures-sink", + "nanorand", + "spin", ] [[package]] -name = "float_next_after" -version = "1.0.0" +name = "fragile" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" - -[[package]] -name = "font-types" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3971f9a5ca983419cdc386941ba3b9e1feba01a0ab888adf78739feb2798492" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" dependencies = [ - "bytemuck", + "futures-core", ] -[[package]] -name = "fontconfig-parser" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" -dependencies = [ - "roxmltree", -] - -[[package]] -name = "fontdb" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0299020c3ef3f60f526a4f64ab4a3d4ce116b1acbf24cdd22da0068e5d81dc3" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser 0.20.0", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - [[package]] name = "fumi-core" version = "0.1.0" @@ -1311,19 +445,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - [[package]] name = "futures-macro" version = "0.3.34" @@ -1364,6 +485,63 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk-pixbuf" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd242894c084f4beed508a56952750bce3e96e85eb68fdc153637daa163e10c" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b34f3b580c988bd217e9543a2de59823fafae369d1a055555e5f95a8b130b96" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk4" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850c9d9c1aecd1a3eb14fadc1cdb0ac0a2298037e116264c7473e1740a32d60" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6eb95798e2b46f279cf59005daf297d5b69555428f185650d71974a910473a" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1374,16 +552,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.5", - "windows-link", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -1391,20 +559,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", + "wasm-bindgen", ] [[package]] @@ -1415,7 +573,7 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", ] [[package]] @@ -1429,123 +587,194 @@ dependencies = [ ] [[package]] -name = "gl_generator" -version = "0.14.0" +name = "gio" +version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831" dependencies = [ - "khronos_api", - "log", - "xml-rs", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", ] [[package]] -name = "glam" -version = "0.25.0" +name = "gio-sys" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" - -[[package]] -name = "glow" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83" dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "windows-sys 0.59.0", ] [[package]] -name = "glutin_wgl_sys" -version = "0.5.0" +name = "glib" +version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683" dependencies = [ - "gl_generator", + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", ] [[package]] -name = "gpu-alloc" -version = "0.6.2" +name = "glib-macros" +version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9" +checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145" dependencies = [ - "bitflags 2.13.2", - "gpu-alloc-types", + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "gpu-alloc-types" -version = "0.3.1" +name = "glib-sys" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058" +checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215" dependencies = [ - "bitflags 2.13.2", + "libc", + "system-deps", ] [[package]] -name = "gpu-allocator" -version = "0.25.0" +name = "gobject-sys" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda" dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "winapi", - "windows", + "glib-sys", + "libc", + "system-deps", ] [[package]] -name = "gpu-descriptor" -version = "0.2.4" +name = "graphene-rs" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" +checksum = "6b86dfad7d14251c9acaf1de63bc8754b7e3b4e5b16777b6f5a748208fe9519b" dependencies = [ - "bitflags 2.13.2", - "gpu-descriptor-types", - "hashbrown 0.14.5", + "glib", + "graphene-sys", + "libc", ] [[package]] -name = "gpu-descriptor-types" -version = "0.1.2" +name = "graphene-sys" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" +checksum = "df583a85ba2d5e15e1797e40d666057b28bc2f60a67c9c24145e6db2cc3861ea" dependencies = [ - "bitflags 2.13.2", + "glib-sys", + "libc", + "pkg-config", + "system-deps", ] [[package]] -name = "guillotiere" -version = "0.6.2" +name = "gsk4" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +checksum = "61f5e72f931c8c9f65fbfc89fe0ddc7746f147f822f127a53a9854666ac1f855" dependencies = [ - "euclid", - "svg_fmt", + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", ] [[package]] -name = "half" -version = "2.7.1" +name = "gsk4-sys" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +checksum = "755059de55fa6f85a46bde8caf03e2184c96bfda1f6206163c72fb0ea12436dc" dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps", ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "gtk4" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "f274dd0102c21c47bbfa8ebcb92d0464fab794a22fad6c3f3d5f165139a326d6" dependencies = [ - "ahash 0.7.8", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ed1786c4703dd196baf7e103525ce0cf579b3a63a0570fe653b7ee6bac33999" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gtk4-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e03b01e54d77c310e1d98647d73f996d04b2f29b9121fe493ea525a7ec03d6" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps", ] [[package]] @@ -1554,8 +783,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash 0.8.12", - "allocator-api2", + "ahash", ] [[package]] @@ -1574,37 +802,10 @@ dependencies = [ ] [[package]] -name = "hassle-rs" -version = "0.11.0" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" -dependencies = [ - "bitflags 2.13.2", - "com", - "libc", - "libloading 0.8.9", - "thiserror 1.0.69", - "widestring", - "winapi", -] - -[[package]] -name = "hermit-abi" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hkdf" @@ -1624,188 +825,6 @@ dependencies = [ "digest", ] -[[package]] -name = "iced" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88acfabc84ec077eaf9ede3457ffa3a104626d79022a9bf7f296093b1d60c73f" -dependencies = [ - "iced_core", - "iced_futures", - "iced_renderer", - "iced_widget", - "iced_winit", - "thiserror 1.0.69", -] - -[[package]] -name = "iced_core" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0013a238275494641bf8f1732a23a808196540dc67b22ff97099c044ae4c8a1c" -dependencies = [ - "bitflags 2.13.2", - "bytes", - "dark-light", - "glam", - "log", - "num-traits", - "once_cell", - "palette", - "rustc-hash 2.1.3", - "smol_str", - "thiserror 1.0.69", - "web-time", -] - -[[package]] -name = "iced_futures" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c04a6745ba2e80f32cf01e034fd00d853aa4f4cd8b91888099cb7aaee0d5d7c" -dependencies = [ - "futures", - "iced_core", - "log", - "rustc-hash 2.1.3", - "tokio", - "wasm-bindgen-futures", - "wasm-timer", -] - -[[package]] -name = "iced_glyphon" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41c3bb56f1820ca252bc1d0994ece33d233a55657c0c263ea7cb16895adbde82" -dependencies = [ - "cosmic-text", - "etagere", - "lru", - "rustc-hash 2.1.3", - "wgpu", -] - -[[package]] -name = "iced_graphics" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba25a18cfa6d5cc160aca7e1b34f73ccdff21680fa8702168c09739767b6c66f" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "cosmic-text", - "half", - "iced_core", - "iced_futures", - "log", - "lyon_path", - "once_cell", - "raw-window-handle", - "rustc-hash 2.1.3", - "thiserror 1.0.69", - "unicode-segmentation", -] - -[[package]] -name = "iced_renderer" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73558208059f9e622df2bf434e044ee2f838ce75201a023cf0ca3e1244f46c2a" -dependencies = [ - "iced_graphics", - "iced_tiny_skia", - "iced_wgpu", - "log", - "thiserror 1.0.69", -] - -[[package]] -name = "iced_runtime" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348b5b2c61c934d88ca3b0ed1ed913291e923d086a66fa288ce9669da9ef62b5" -dependencies = [ - "bytes", - "iced_core", - "iced_futures", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "iced_tiny_skia" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c625d368284fcc43b0b36b176f76eff1abebe7959dd58bd8ce6897d641962a50" -dependencies = [ - "bytemuck", - "cosmic-text", - "iced_graphics", - "kurbo", - "log", - "rustc-hash 2.1.3", - "softbuffer", - "tiny-skia", -] - -[[package]] -name = "iced_wgpu" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15708887133671d2bcc6c1d01d1f176f43a64d6cdc3b2bf893396c3ee498295f" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "futures", - "glam", - "guillotiere", - "iced_glyphon", - "iced_graphics", - "log", - "lyon", - "once_cell", - "rustc-hash 2.1.3", - "thiserror 1.0.69", - "wgpu", -] - -[[package]] -name = "iced_widget" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81429e1b950b0e4bca65be4c4278fea6678ea782030a411778f26fa9f8983e1d" -dependencies = [ - "iced_renderer", - "iced_runtime", - "num-traits", - "once_cell", - "qrcode", - "rustc-hash 2.1.3", - "thiserror 1.0.69", - "unicode-segmentation", -] - -[[package]] -name = "iced_winit" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f44cd4e1c594b6334f409282937bf972ba14d31fedf03c23aa595d982a2fda28" -dependencies = [ - "iced_futures", - "iced_graphics", - "iced_runtime", - "log", - "rustc-hash 2.1.3", - "thiserror 1.0.69", - "tracing", - "wasm-bindgen-futures", - "web-sys", - "winapi", - "window_clipboard", - "winit", -] - [[package]] name = "indexmap" version = "2.14.2" @@ -1825,89 +844,12 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.21", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - [[package]] name = "js-sys" version = "0.3.106" @@ -1920,30 +862,34 @@ dependencies = [ ] [[package]] -name = "khronos-egl" -version = "6.0.0" +name = "libadwaita" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +checksum = "500135d29c16aabf67baafd3e7741d48e8b8978ca98bac39e589165c8dc78191" dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", "libc", - "libloading 0.8.9", - "pkg-config", + "pango", ] [[package]] -name = "khronos_api" -version = "3.1.0" +name = "libadwaita-sys" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "kurbo" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1618d4ebd923e97d67e7cd363d80aef35fe961005cbbbb3d2dad8bdd1bc63440" +checksum = "6680988058c2558baf3f548a370e4e78da3bf7f08469daa822ac414842c912db" dependencies = [ - "arrayvec", - "smallvec", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", ] [[package]] @@ -1952,44 +898,6 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libloading" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" -dependencies = [ - "bitflags 2.13.2", - "libc", - "plain", - "redox_syscall 0.9.4", -] - [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -2001,18 +909,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2028,94 +924,12 @@ dependencies = [ "scopeguard", ] -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" - -[[package]] -name = "lyon" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" -dependencies = [ - "lyon_algorithms", - "lyon_tessellation", -] - -[[package]] -name = "lyon_algorithms" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" -dependencies = [ - "lyon_path", - "num-traits", -] - -[[package]] -name = "lyon_geom" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" -dependencies = [ - "arrayvec", - "euclid", - "num-traits", -] - -[[package]] -name = "lyon_path" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" -dependencies = [ - "lyon_geom", - "num-traits", -] - -[[package]] -name = "lyon_tessellation" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" -dependencies = [ - "float_next_after", - "lyon_path", - "num-traits", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -2126,431 +940,12 @@ dependencies = [ ] [[package]] -name = "metal" -version = "0.27.0" +name = "nanorand" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" dependencies = [ - "bitflags 2.13.2", - "block", - "core-graphics-types", - "foreign-types", - "log", - "objc", - "paste", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "naga" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" -dependencies = [ - "bit-set", - "bitflags 2.13.2", - "codespan-reporting", - "hexf-parse", - "indexmap", - "log", - "num-traits", - "rustc-hash 1.1.0", - "spirv", - "termcolor", - "thiserror 1.0.69", - "unicode-xid", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.13.2", - "jni-sys 0.3.1", - "log", - "ndk-sys 0.6.0+11769913", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.5.0+25.2.9519653" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases 0.2.2", - "libc", - "memoffset", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", - "objc_exception", -] - -[[package]] -name = "objc-sys" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" - -[[package]] -name = "objc2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" -dependencies = [ - "objc-sys", - "objc2-encode", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" -dependencies = [ - "bitflags 2.13.2", - "block2", - "libc", - "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", - "objc2-foundation 0.2.2", - "objc2-quartz-core 0.2.2", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" -dependencies = [ - "bitflags 2.13.2", - "block2", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-contacts" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-data" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" -dependencies = [ - "bitflags 2.13.2", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.2", - "dispatch2", - "objc2 0.6.4", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.2", - "dispatch2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" -dependencies = [ - "bitflags 2.13.2", - "block2", - "dispatch", - "libc", - "objc2 0.5.2", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.2", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.2", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-link-presentation" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-metal" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" -dependencies = [ - "bitflags 2.13.2", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" -dependencies = [ - "bitflags 2.13.2", - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.13.2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-symbols" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" -dependencies = [ - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" -dependencies = [ - "bitflags 2.13.2", - "block2", - "objc2 0.5.2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-image", - "objc2-core-location", - "objc2-foundation 0.2.2", - "objc2-link-presentation", - "objc2-quartz-core 0.2.2", - "objc2-symbols", - "objc2-uniform-type-identifiers", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" -dependencies = [ - "bitflags 2.13.2", - "block2", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" -dependencies = [ - "cc", + "getrandom 0.2.17", ] [[package]] @@ -2566,176 +961,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "orbclient" -version = "0.3.55" +name = "pango" +version = "0.20.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c" dependencies = [ + "gio", + "glib", "libc", - "libredox", + "pango-sys", ] [[package]] -name = "ordered-multimap" -version = "0.4.3" +name = "pango-sys" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa" dependencies = [ - "dlv-list", - "hashbrown 0.12.3", -] - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser 0.25.1", -] - -[[package]] -name = "palette" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" -dependencies = [ - "approx", - "palette_derive", - "palette_math", - "phf", -] - -[[package]] -name = "palette_derive" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" -dependencies = [ - "by_address", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "palette_math" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core 0.9.12", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", + "glib-sys", + "gobject-sys", "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "system-deps", ] [[package]] @@ -2744,23 +990,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -2777,39 +1006,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.5", - "windows-sys 0.61.2", -] - [[package]] name = "poly1305" version = "0.8.0" @@ -2833,21 +1029,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2867,25 +1048,10 @@ dependencies = [ ] [[package]] -name = "profiling" -version = "1.0.18" +name = "qrcodegen" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" - -[[package]] -name = "qrcode" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" [[package]] name = "quote" @@ -2896,39 +1062,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -2939,119 +1078,47 @@ dependencies = [ ] [[package]] -name = "range-alloc" -version = "0.1.5" +name = "relm4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - -[[package]] -name = "rangemap" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +checksum = "30837553c1a8cfea1a404c83ec387c5c8ff9358e1060b057c274c5daa5035ad1" dependencies = [ - "either", - "rayon-core", + "flume", + "fragile", + "futures", + "gtk4", + "libadwaita", + "once_cell", + "relm4-css", + "relm4-macros", + "tokio", + "tracing", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "relm4-css" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "1d3b924557df1cddc687b60b313c4b76620fdbf0e463afa4b29f67193ccf37f9" + +[[package]] +name = "relm4-macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a895a7455441a857d100ca679bd24a92f91d28b5e3df63296792ac1af2eddde" dependencies = [ - "crossbeam-deque", - "crossbeam-utils", + "proc-macro2", + "quote", + "syn 2.0.119", ] -[[package]] -name = "read-fonts" -version = "0.22.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69aacb76b5c29acfb7f90155d39759a29496aebb49395830e928a9703d2eec2f" -dependencies = [ - "bytemuck", - "font-types", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "redox_syscall" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - [[package]] name = "rusqlite" version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.13.2", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3059,28 +1126,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rust-ini" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - [[package]] name = "rustc_version" version = "0.4.1" @@ -3090,29 +1135,16 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.2", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.2", + "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -3122,63 +1154,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "rustybuzz" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfb9cf8877777222e4a3bc7eb247e398b56baba500c38c1c46842431adc8b55c" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "libm", - "smallvec", - "ttf-parser 0.21.1", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sctk-adwaita" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit 0.19.2", - "tiny-skia", -] - -[[package]] -name = "self_cell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" - [[package]] name = "semver" version = "1.0.28" @@ -3229,25 +1210,12 @@ dependencies = [ ] [[package]] -name = "serde_repr" -version = "0.1.21" +name = "serde_spanned" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "serde_core", ] [[package]] @@ -3267,16 +1235,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "signature" version = "2.2.0" @@ -3286,137 +1244,18 @@ dependencies = [ "rand_core", ] -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "siphasher" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" - -[[package]] -name = "skrifa" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1c44ad1f6c5bdd4eefed8326711b7dbda9ea45dfd36068c427d332aa382cbe" -dependencies = [ - "bytemuck", - "read-fonts", -] - [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - [[package]] name = "smallvec" version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" -[[package]] -name = "smithay-client-toolkit" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.44", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.5", - "thiserror 2.0.21", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - [[package]] name = "snow" version = "0.9.6" @@ -3434,44 +1273,12 @@ dependencies = [ ] [[package]] -name = "softbuffer" -version = "0.4.8" +name = "spin" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ - "as-raw-xcb-connection", - "bytemuck", - "drm", - "fastrand", - "js-sys", - "memmap2", - "ndk", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "raw-window-handle", - "redox_syscall 0.5.18", - "rustix 1.1.5", - "tiny-xlib", - "tracing", - "wasm-bindgen", - "wayland-backend", - "wayland-client", - "wayland-sys", - "web-sys", - "windows-sys 0.61.2", - "x11rb", -] - -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.13.2", + "lock_api", ] [[package]] @@ -3484,52 +1291,12 @@ dependencies = [ "der", ] -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" - [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "svg_fmt" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" - -[[package]] -name = "swash" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd59f3f359ddd2c95af4758c18270eddd9c730dde98598023cdabff472c2ca2" -dependencies = [ - "skrifa", - "yazi", - "zeno", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -3553,14 +1320,24 @@ dependencies = [ ] [[package]] -name = "sys-locale" -version = "0.3.2" +name = "system-deps" +version = "7.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" dependencies = [ - "libc", + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -3570,104 +1347,10 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.5", + "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl 2.0.21", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tiny-xlib" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90a0ca3ee6a69f2ad28fd11621a4c3f03b371f366be500b64df260c4ffbafb4" -dependencies = [ - "as-raw-xcb-connection", - "ctor", - "libloading 0.8.9", - "pkg-config", - "tracing", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - [[package]] name = "tokio" version = "1.53.1" @@ -3677,6 +1360,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3707,13 +1405,18 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3739,101 +1442,18 @@ dependencies = [ "once_cell", ] -[[package]] -name = "ttf-parser" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" - -[[package]] -name = "ttf-parser" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" - [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" - -[[package]] -name = "unicode-ccc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" - [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -3850,37 +1470,24 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -3894,17 +1501,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" -dependencies = [ - "js-sys", - "tokio", - "wasm-bindgen", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -3937,368 +1533,12 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-timer" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be0ecb0db480561e9a7642b5d3e4187c128914e58aa84330b9493e3eb68c5e7f" -dependencies = [ - "futures", - "js-sys", - "parking_lot 0.11.2", - "pin-utils", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wayland-backend" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" -dependencies = [ - "cc", - "downcast-rs", - "rustix 1.1.5", - "scoped-tls", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" -dependencies = [ - "bitflags 2.13.2", - "rustix 1.1.5", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.13.2", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" -dependencies = [ - "rustix 1.1.5", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-plasma" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "dlib", - "log", - "once_cell", - "pkg-config", -] - -[[package]] -name = "web-sys" -version = "0.3.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wgpu" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" -dependencies = [ - "arrayvec", - "cfg-if", - "cfg_aliases 0.1.1", - "js-sys", - "log", - "naga", - "parking_lot 0.12.5", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" -dependencies = [ - "arrayvec", - "bit-vec", - "bitflags 2.13.2", - "cfg_aliases 0.1.1", - "codespan-reporting", - "indexmap", - "log", - "naga", - "once_cell", - "parking_lot 0.12.5", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 1.0.69", - "web-sys", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-hal" -version = "0.19.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.13.2", - "block", - "cfg_aliases 0.1.1", - "core-graphics-types", - "d3d12", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", - "hassle-rs", - "js-sys", - "khronos-egl", - "libc", - "libloading 0.8.9", - "log", - "metal", - "naga", - "ndk-sys 0.5.0+25.2.9519653", - "objc", - "once_cell", - "parking_lot 0.12.5", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", - "wgpu-types", - "winapi", -] - -[[package]] -name = "wgpu-types" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" -dependencies = [ - "bitflags 2.13.2", - "js-sys", - "web-sys", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "window_clipboard" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d692d46038c433f9daee7ad8757e002a4248c20b0a3fbc991d99521d3bcb6d" -dependencies = [ - "clipboard-win", - "clipboard_macos", - "clipboard_wayland", - "clipboard_x11", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core", - "windows-targets", -] - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.59.0" @@ -4381,58 +1621,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winit" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" -dependencies = [ - "ahash 0.8.12", - "android-activity", - "atomic-waker", - "bitflags 2.13.2", - "block2", - "bytemuck", - "calloop 0.13.0", - "cfg_aliases 0.2.2", - "concurrent-queue", - "core-foundation", - "core-graphics", - "cursor-icon", - "dpi", - "js-sys", - "libc", - "memmap2", - "ndk", - "objc2 0.5.2", - "objc2-app-kit", - "objc2-foundation 0.2.2", - "objc2-ui-kit", - "orbclient", - "percent-encoding", - "pin-project", - "raw-window-handle", - "redox_syscall 0.4.1", - "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit 0.19.2", - "smol_str", - "tracing", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-plasma", - "web-sys", - "web-time", - "windows-sys 0.52.0", - "x11-dl", - "x11rb", - "xkbcommon-dl", -] - [[package]] name = "winnow" version = "1.0.4" @@ -4442,53 +1630,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "libloading 0.8.9", - "once_cell", - "rustix 1.1.5", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - [[package]] name = "x25519-dalek" version = "2.0.1" @@ -4501,121 +1642,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "xcursor" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" - -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.13.2", - "dlib", - "log", - "once_cell", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - -[[package]] -name = "xml-rs" -version = "0.8.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" - -[[package]] -name = "yazi" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" - -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-executor", - "async-fs", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix", - "ordered-stream", - "rand", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" -dependencies = [ - "serde", - "static_assertions", - "zvariant", -] - -[[package]] -name = "zeno" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd15f8e0dbb966fd9245e7498c7e9e5055d9e5c8b676b95bd67091cd11a1e697" - [[package]] name = "zerocopy" version = "0.8.59" @@ -4656,51 +1682,8 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "zlib-rs" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" - [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] diff --git a/Cargo.toml b/Cargo.toml index 9e3dbc5..6773e0a 100644 --- a/Cargo.toml +++ b/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" diff --git a/README.md b/README.md index b0587a0..ffe6f40 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/devbox.json b/devbox.json index 5c40862..5fc8e27 100644 --- a/devbox.json +++ b/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": [ diff --git a/devbox.lock b/devbox.lock index 32fb2d0..da92544 100644 --- a/devbox.lock +++ b/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" diff --git a/scripts/devbox-libs.sh b/scripts/devbox-libs.sh index 685a74e..e61ff1f 100755 --- a/scripts/devbox-libs.sh +++ b/scripts/devbox-libs.sh @@ -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}" diff --git a/src/app.rs b/src/app.rs index feab97a..a91c9c5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,7 +1,10 @@ -//! Application state and update. Every fumi-core call that can block runs in -//! `spawn_blocking` and reports back through a `Message`; purely local reads -//! (opening one sealed message, marking it read) happen inline in `update`. +//! Application state: the pure core of the client. `handle` maps UI inputs +//! to state changes and background operations (`PendingOp`), `handle_result` +//! folds an operation's outcome back in — and may chain a refresh. The +//! Relm4 component in `view.rs` is a thin adapter over these two functions, +//! which keeps the state machine testable without a window server. +use std::cell::Cell; use std::collections::HashSet; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -10,10 +13,7 @@ use std::sync::Arc; use fumi_core::address::Address; use fumi_core::crypto::{unb32, KEY_LEN}; use fumi_core::store::Store; -use iced::time::{self, Duration}; -use iced::widget::qr_code; -use iced::widget::text_editor::{self, Action}; -use iced::{clipboard, Subscription, Task}; +use gtk::prelude::*; use crate::ops::{self, Folder, OpenedMsg, Row, Snapshot}; @@ -87,10 +87,11 @@ pub struct Signup { } /// The compose view: `to` takes a short address or a smol:// URI. +#[derive(Debug, Clone)] pub struct Compose { pub to: String, pub subject: String, - pub body: text_editor::Content, + pub body: String, /// The message this replies to, as 64 hex characters. pub reply_to: Option, } @@ -107,33 +108,35 @@ pub enum Dialog { Rotate, } +/// UI inputs. Background results arrive as [`OpResult`] instead, so the +/// busy guard can never swallow the message that clears the busy flag. #[derive(Debug, Clone)] pub enum Message { // Onboarding OnboardCreate, OnboardRestore, OnboardBack, - OnboardSeedEdited(String), + OnboardSeedChanged(String), OnboardRestoreFromSeed, OnboardSeedSaved, // Signup - SignupUserEdited(String), - SignupHostEdited(String), - SignupInviteEdited(String), - SignupKeyEdited(String), + SignupUserChanged(String), + SignupHostChanged(String), + SignupInviteChanged(String), + SignupKeyChanged(String), SignupBack, SignupLearnKey, SignupRegister, SignupRecall, // Pin offer (signup and settings share it) - PinOffered(Result, String>), PinAccept, PinDiscard, - Pinned(Result<(), String>), // Mail + GotoMail(Folder), + Goto(Page), MessageSelected(Folder, String), FetchPressed, FetchCancelled, @@ -141,9 +144,9 @@ pub enum Message { /// as it verifies, so the list can show mail arriving mid-fetch. FetchTick, ComposePressed, - ComposeToEdited(String), - ComposeSubjectEdited(String), - ComposeBody(Action), + ComposeToChanged(String), + ComposeSubjectChanged(String), + ComposeBodyChanged(String), ComposeClosed, SendPressed, ReplyPressed, @@ -154,32 +157,99 @@ pub enum Message { BlockPressed, // Contacts - ContactImportEdited(String), + /// A sidebar contact was clicked: the article pane shows its details. + ContactSelected(String), + ContactImportChanged(String), ContactImportPressed, ContactWrite(String), ContactAccept(String, String), ContactBlock(String), ContactUnblock(String), CopyOwnAddress, + CopyExport, // Settings SettingsLearnKey, - TimeoutEdited(String), + TimeoutChanged(String), RotatePressed, RotateConfirmed, RotateCancelled, UnpinPressed(String), ExportPressed, - Exported(Result<(String, String), String>), - CopyExport, - ImportBody(Action), + ImportBodyChanged(String), ImportPressed, +} - // Navigation and results - GotoMail(Folder), - Goto(Page), +/// What a background operation reports back. +#[derive(Debug, Clone)] +pub enum OpResult { SnapshotLoaded(Result), + /// A one-line note, or the failure prose fumi produced. Generic(Result), + /// The key a server presented with no pin in place (sec 4 tofu). + PinOffered(Result, String>), + Pinned(Result<(), String>), + /// (note, backup text) — the text is for the clipboard and the view. + Exported(Result<(String, String), String>), +} + +/// One background operation to run on the blocking pool, carrying every +/// parameter it needs; the adapter supplies store, master and timeout. +#[derive(Debug, Clone)] +pub enum PendingOp { + Snapshot { + read: HashSet, + }, + Register { + addr: Address, + invite: Option, + /// A pasted operator key, pinned before the registration connects. + key: String, + }, + Restore { + addr: Address, + }, + LearnKey { + addr: Address, + }, + Pin { + addr: Address, + key: [u8; KEY_LEN], + }, + Fetch { + cancel: Arc, + }, + Send { + to: String, + subject: String, + body: String, + reply_to: Option, + }, + Delete { + id: String, + folder: Folder, + everywhere: bool, + }, + Accept { + address: String, + identity: [u8; KEY_LEN], + }, + Block { + address: String, + }, + ImportContact { + uri: String, + }, + Rotate, + Unpin { + label: String, + }, + Export { + dir: PathBuf, + }, + ImportBackup { + text: String, + }, } pub struct Carrier { @@ -199,19 +269,23 @@ pub struct Carrier { pub folder: Folder, pub lists: Vec>, pub open: Option<(Folder, OpenedMsg)>, + /// The contact whose details the article pane shows, selected from + /// the sidebar's bottom panel; a message selection clears it. + pub selected_contact: Option, pub compose: Option, pub snapshot: Option, pub pin_offer: Option<(Address, [u8; KEY_LEN])>, pub backup_text: Option, - pub import_body: text_editor::Content, + pub import_body: String, pub contact_import: String, pub dialog: Option, - /// The own-address QR, rebuilt whenever a snapshot lands. - pub share_qr: Option, + /// The view syncs entry text from the model once after a prefill + /// (reply, write-to-contact), then leaves typing to the widgets. + pub compose_fresh: Cell, } impl Carrier { - pub fn boot(dir: PathBuf) -> (Carrier, Task) { + pub fn boot(dir: PathBuf) -> (Carrier, Option) { let store = match Store::open(&dir.join("carrier.db")) { Ok(store) => Arc::new(store), Err(e) => { @@ -227,7 +301,7 @@ impl Carrier { (true, false) => Page::Signup, (true, true) => Page::Mail, }; - let mut app = Carrier { + let app = Carrier { marks: ReadMarks::load(dir.join("read.txt")), dir, store, @@ -254,21 +328,20 @@ impl Carrier { folder: Folder::Inbox, lists: vec![Vec::new(), Vec::new(), Vec::new()], open: None, + selected_contact: None, compose: None, snapshot: None, pin_offer: None, backup_text: None, - import_body: text_editor::Content::new(), + import_body: String::new(), contact_import: String::new(), dialog: None, - share_qr: None, + compose_fresh: Cell::new(false), }; - let task = if registered { - app.refresh() - } else { - Task::none() - }; - (app, task) + let op = registered.then(|| PendingOp::Snapshot { + read: app.marks.snapshot(), + }); + (app, op) } fn master(&self) -> [u8; KEY_LEN] { @@ -290,7 +363,7 @@ impl Carrier { } /// One operation at a time: a second action while one runs is ignored, - /// except `FetchCancelled`, which raises the token the fetch checks. + /// except the pass-throughs handled before the guard in `handle`. fn start(&mut self, label: &str) -> bool { if self.busy.is_some() { return false; @@ -302,60 +375,23 @@ impl Carrier { fn done(&mut self) { self.busy = None; + // Stop the fetch ticker: its job ended with the operation. + self.cancel.store(true, Ordering::Relaxed); } /// One blocking pass over the store, bringing every page's data home. - fn refresh(&mut self) -> Task { + fn refresh(&mut self) -> Option { if !self.start("refreshing") { - return Task::none(); + return None; } - let store = self.store.clone(); - let master = self.master(); - let read = self.marks.snapshot(); - Task::perform( - async move { - tokio::task::spawn_blocking(move || ops::snapshot(&store, &master, &read)) - .await - .unwrap_or_else(|e| Err(format!("refreshing failed: {e}"))) - }, - Message::SnapshotLoaded, - ) + Some(PendingOp::Snapshot { + read: self.marks.snapshot(), + }) } - fn refresh_after(&mut self, note: String) -> Task { - self.note(note); - self.refresh() - } - - /// Runs one blocking operation on the worker pool, mapping its result to - /// `map` — the label doubles as the busy indicator and the panic message. - fn run_with(&mut self, label: &str, map: M, work: F) -> Task - where - T: Send + 'static, - F: FnOnce(Arc, [u8; KEY_LEN]) -> Result + Send + 'static, - M: Fn(Result) -> Message + Send + 'static, - { - if !self.start(label) { - return Task::none(); - } - let label = label.to_string(); - let store = self.store.clone(); - let master = self.master(); - Task::perform( - async move { - tokio::task::spawn_blocking(move || work(store, master)) - .await - .unwrap_or_else(|e| Err(format!("{label} failed: {e}"))) - }, - map, - ) - } - - fn run(&mut self, label: &str, work: F) -> Task - where - F: FnOnce(Arc, [u8; KEY_LEN]) -> Result + Send + 'static, - { - self.run_with(label, Message::Generic, work) + /// The account address, for the settings page's learn-key action. + fn account_address(&self) -> Option
{ + self.store.account().ok().flatten() } /// `user@host` as typed on the signup page, defaulting the port. @@ -368,332 +404,228 @@ impl Carrier { Address::parse(&format!("{user}@{host}")).ok() } - /// The account address, for the settings page's learn-key action. - fn account_address(&self) -> Option
{ - self.store.account().ok().flatten() - } - - /// While a fetch runs, tick a few times a second so the list shows mail - /// as it lands instead of freezing until the whole fetch returns. - pub fn subscription(&self) -> Subscription { - if self.busy.as_deref() == Some("fetching") { - time::every(Duration::from_millis(250)).map(|_| Message::FetchTick) - } else { - Subscription::none() + /// Opens one message and marks it read inline: opening an envelope and + /// re-listing is local and fast, so it needs no background pass. + fn select_message(&mut self, folder: Folder, id: String) { + self.folder = folder; + self.open = None; + self.marks.mark(id.clone()); + let master = self.master(); + let read = self.marks.snapshot(); + let id = match ops::open(&self.store, &master, &id, folder) { + Ok(msg) => { + self.open = Some((folder, msg)); + self.status = None; + true + } + Err(e) => { + self.fail(e); + false + } + }; + if id { + if let Ok(lists) = ops::lists(&self.store, &master, &read) { + self.lists = lists; + } } } - pub fn update(&mut self, message: Message) -> Task { + pub fn handle(&mut self, message: Message) -> Vec { match message { - // Text editor actions must survive the busy guard: typing in the - // compose body while a fetch runs is ordinary behavior. - Message::ComposeBody(action) => { + // Pass-throughs: typing and the tick survive the busy guard. + Message::ComposeBodyChanged(body) => { if let Some(compose) = &mut self.compose { - compose.body.perform(action); + compose.body = body; } - Task::none() } - Message::ImportBody(action) => { - self.import_body.perform(action); - Task::none() + Message::ImportBodyChanged(text) => { + self.import_body = text; + } + Message::ContactSelected(address) => { + self.selected_contact = Some(address); + self.open = None; + self.page = Page::Mail; } Message::FetchCancelled => { if self.busy.is_some() { self.cancel.store(true, Ordering::Relaxed); } - Task::none() } Message::FetchTick => { - if self.busy.as_deref() != Some("fetching") { - return Task::none(); - } - let master = self.master(); - let read = self.marks.snapshot(); - if let Ok(lists) = ops::lists(&self.store, &master, &read) { - self.lists = lists; - } - Task::none() - } - // Results pass regardless of the busy flag: each one clears the - // operation it belongs to, so swallowing it would deadlock. - Message::PinOffered(result) => { - self.done(); - match result { - Ok(Some((addr, key))) => { - self.pin_offer = Some((addr, key)); - self.note( - "The server presented this key. Pin it only if the fingerprint \ - matches what the operator published." - .into(), - ); - Task::none() - } - Ok(None) => { - self.note("This server's key is already pinned.".into()); - Task::none() - } - Err(e) => { - self.fail(e); - Task::none() + if self.busy.as_deref() == Some("fetching") { + let master = self.master(); + let read = self.marks.snapshot(); + if let Ok(lists) = ops::lists(&self.store, &master, &read) { + self.lists = lists; } } } - Message::Pinned(result) => { - self.done(); - match result { - Ok(()) => self.note("server key pinned".into()), - Err(e) => self.fail(e), - } - Task::none() + Message::OnboardSeedChanged(seed) => { + self.onboard.seed = seed; } - Message::Exported(result) => { - self.done(); - match result { - Ok((note, text)) => { - self.backup_text = Some(text); - self.note(note); - Task::none() - } - Err(e) => { - self.fail(e); - Task::none() - } + Message::SignupUserChanged(user) => { + self.signup.user = user; + } + Message::SignupHostChanged(host) => { + self.signup.host = host; + } + Message::SignupInviteChanged(invite) => { + self.signup.invite = invite; + } + Message::SignupKeyChanged(key) => { + self.signup.key = key; + } + Message::ContactImportChanged(text) => { + self.contact_import = text; + } + Message::TimeoutChanged(text) => { + if let Ok(timeout) = text.trim().parse::() { + self.timeout = timeout; + } + self.timeout_text = text; + } + Message::ComposeToChanged(to) => { + if let Some(compose) = &mut self.compose { + compose.to = to; } } - Message::SnapshotLoaded(result) => { - self.done(); - match result { - Ok(snap) => { - self.share_qr = qr_code::Data::new( - format!("smol://{}/{}", snap.who.address, snap.who.key).as_bytes(), - ) - .ok(); - self.lists = snap.lists.clone(); - self.snapshot = Some(snap); - Task::none() - } - Err(e) => { - self.fail(e); - Task::none() - } + Message::ComposeSubjectChanged(subject) => { + if let Some(compose) = &mut self.compose { + compose.subject = subject; } } - Message::Generic(result) => { - self.done(); - match result { - Ok(note) => self.refresh_after(note), - Err(e) => { - self.fail(e); - Task::none() - } - } - } - _ if self.busy.is_some() => Task::none(), + _ if self.busy.is_some() => return Vec::new(), Message::OnboardCreate => { let master = ops::new_master(); if let Err(e) = ops::save_key(&self.key_path(), &master) { self.fail(e); - return Task::none(); + } else { + self.master = Some(master); + self.onboard.seed_shown = ops::seed_text(&master); + self.onboard.mode = OnboardMode::Created; + self.note( + "Identity created. Write the seed down; it is the only secret.".into(), + ); } - self.master = Some(master); - self.onboard.seed_shown = ops::seed_text(&master); - self.onboard.mode = OnboardMode::Created; - self.note("Identity created. Write the seed down; it is the only secret.".into()); - Task::none() } Message::OnboardRestore => { self.onboard.mode = OnboardMode::Restore; - Task::none() } Message::OnboardBack => { self.onboard.mode = OnboardMode::Start; - Task::none() - } - Message::OnboardSeedEdited(seed) => { - self.onboard.seed = seed; - Task::none() } Message::OnboardRestoreFromSeed => match ops::parse_seed(&self.onboard.seed) { Ok(master) => { if let Err(e) = ops::save_key(&self.key_path(), &master) { self.fail(e); - return Task::none(); + } else { + self.master = Some(master); + self.signup.fresh = true; + self.page = Page::Signup; + self.note("Seed restored. Bind a username or recall an address.".into()); } - self.master = Some(master); - self.signup.fresh = true; - self.page = Page::Signup; - self.note("Seed restored. Bind a username or recall an address.".into()); - Task::none() - } - Err(e) => { - self.fail(e); - Task::none() } + Err(e) => self.fail(e), }, Message::OnboardSeedSaved => { self.signup.fresh = true; self.page = Page::Signup; - Task::none() - } - Message::SignupUserEdited(user) => { - self.signup.user = user; - Task::none() - } - Message::SignupHostEdited(host) => { - self.signup.host = host; - Task::none() - } - Message::SignupInviteEdited(invite) => { - self.signup.invite = invite; - Task::none() - } - Message::SignupKeyEdited(key) => { - self.signup.key = key; - Task::none() } Message::SignupBack => { self.page = Page::Onboard; - Task::none() } Message::SignupLearnKey => { let Some(addr) = self.signup_address() else { self.fail("enter user and host first".into()); - return Task::none(); + return Vec::new(); }; - self.run_with("connecting", Message::PinOffered, move |store, _| { - ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key))) - }) + return vec![PendingOp::LearnKey { addr }]; } Message::SettingsLearnKey => { let Some(addr) = self.account_address() else { - return Task::none(); + return Vec::new(); }; - self.run_with("connecting", Message::PinOffered, move |store, _| { - ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key))) - }) + return vec![PendingOp::LearnKey { addr }]; } Message::PinDiscard => { self.pin_offer = None; - Task::none() } Message::PinAccept => { let Some((addr, key)) = self.pin_offer.take() else { - return Task::none(); + return Vec::new(); }; - self.run_with("pinning", Message::Pinned, move |store, _| { - ops::pin(&store, &addr, &key) - }) + return vec![PendingOp::Pin { addr, key }]; } Message::SignupRegister => { let Some(addr) = self.signup_address() else { self.fail("enter user and host".into()); - return Task::none(); + return Vec::new(); }; let invite = if self.signup.invite.is_empty() { None } else { Some(self.signup.invite.clone()) }; - // A pasted operator key is pinned first: registration - // connects with the pin required. let key = self.signup.key.trim().to_string(); - let timeout = self.timeout; - self.run("registering", move |store, master| { - if !key.is_empty() { - let key = ops::parse_key(&key)?; - ops::pin(&store, &addr, &key)?; - } - ops::register(&store, &master, &addr, invite.as_deref(), timeout) - .map(|_| "registered".to_string()) - }) + return vec![PendingOp::Register { addr, invite, key }]; } Message::SignupRecall => { let Some(addr) = self.signup_address() else { self.fail("enter user and host".into()); - return Task::none(); + return Vec::new(); }; - let timeout = self.timeout; - self.run("restoring", move |store, master| { - ops::restore(&store, &master, &addr, timeout) - .map(|index| format!("restored at rotation {index}")) - }) + return vec![PendingOp::Restore { addr }]; } Message::GotoMail(folder) => { if self.page == Page::Mail && self.folder == folder { - return Task::none(); + return Vec::new(); } self.page = Page::Mail; self.folder = folder; self.open = None; - Task::none() + self.selected_contact = None; + return self.refresh().into_iter().collect(); } Message::Goto(page) => { self.page = page; self.open = None; + self.selected_contact = None; self.compose = None; if page == Page::Mail || page == Page::Contacts || page == Page::Settings { - return self.refresh(); + return self.refresh().into_iter().collect(); } - Task::none() } Message::MessageSelected(folder, id) => { - self.folder = folder; - self.open = None; - self.marks.mark(id.clone()); - // Opening one envelope and re-listing is local and fast, so - // it happens inline; the refresh keeps the unread dots honest. - let master = self.master(); - let read = self.marks.snapshot(); - match ops::open(&self.store, &master, &id, folder) { - Ok(msg) => { - self.open = Some((folder, msg)); - self.status = None; - } - Err(e) => self.fail(e), - } - if let Ok(lists) = ops::lists(&self.store, &master, &read) { - self.lists = lists; - } - Task::none() + self.selected_contact = None; + self.select_message(folder, id); } Message::FetchPressed => { if !self.start("fetching") { - return Task::none(); + return Vec::new(); } - let store = self.store.clone(); - let master = self.master(); - let cancel = self.cancel.clone(); - let timeout = self.timeout; - Task::perform( - async move { - tokio::task::spawn_blocking(move || { - ops::fetch(&store, &master, cancel, timeout) - }) - .await - .unwrap_or_else(|e| Err(format!("fetching failed: {e}"))) - }, - Message::Generic, - ) + return vec![PendingOp::Fetch { + cancel: self.cancel.clone(), + }]; } Message::ComposePressed => { self.compose = Some(Compose { to: String::new(), subject: String::new(), - body: text_editor::Content::new(), + body: String::new(), reply_to: None, }); - Task::none() + self.compose_fresh.set(true); } Message::ReplyPressed => { let Some((_, msg)) = &self.open else { - return Task::none(); + return Vec::new(); }; // A fingerprint-only sender left no address to reply to. let to = if let Some(uri) = &msg.reply_to { uri.clone() } else if msg.from.starts_with('<') { self.fail("this sender left no address to reply to".into()); - return Task::none(); + return Vec::new(); } else { msg.from.clone() }; @@ -705,143 +637,102 @@ impl Carrier { self.compose = Some(Compose { to, subject, - body: text_editor::Content::new(), + body: String::new(), reply_to: Some(msg.id.clone()), }); - Task::none() - } - Message::ComposeToEdited(to) => { - if let Some(compose) = &mut self.compose { - compose.to = to; - } - Task::none() - } - Message::ComposeSubjectEdited(subject) => { - if let Some(compose) = &mut self.compose { - compose.subject = subject; - } - Task::none() + self.compose_fresh.set(true); } Message::ComposeClosed => { self.compose = None; - Task::none() } Message::SendPressed => { let Some(compose) = &self.compose else { - return Task::none(); + return Vec::new(); }; if compose.to.trim().is_empty() { self.fail("enter a recipient".into()); - return Task::none(); + return Vec::new(); } - let to = compose.to.trim().to_string(); - let subject = compose.subject.trim().to_string(); - let body = compose.body.text(); - let reply_to = compose.reply_to.clone(); + let op = PendingOp::Send { + to: compose.to.trim().to_string(), + subject: compose.subject.trim().to_string(), + body: compose.body.clone(), + reply_to: compose.reply_to.clone(), + }; self.compose = None; - let timeout = self.timeout; - self.run("sending", move |store, master| { - ops::send( - &store, - &master, - &to, - if subject.is_empty() { - None - } else { - Some(subject.as_str()) - }, - &body, - reply_to.as_deref(), - timeout, - ) - }) + return vec![op]; } Message::DeletePressed => { let Some((folder, msg)) = &self.open else { - return Task::none(); + return Vec::new(); }; self.dialog = Some(Dialog::Delete { id: msg.id.clone(), folder: *folder, kept: msg.kept, }); - Task::none() } Message::DeleteCancelled => { self.dialog = None; - Task::none() } Message::DeleteConfirmed(everywhere) => { let Some(Dialog::Delete { id, folder, kept }) = self.dialog.take() else { - return Task::none(); + return Vec::new(); }; - let scope = everywhere && kept; - let timeout = self.timeout; - self.run("deleting", move |store, master| { - ops::delete(&store, &master, &id, folder, scope, timeout) - .map(|_| "deleted".to_string()) - }) + return vec![PendingOp::Delete { + id, + folder, + everywhere: everywhere && kept, + }]; } Message::AcceptPressed => { let Some((folder, msg)) = &self.open else { - return Task::none(); + return Vec::new(); }; if *folder != Folder::Requests { - return Task::none(); + return Vec::new(); } let Some(uri) = msg.reply_to.clone() else { self.fail("this sender left no address to accept".into()); - return Task::none(); + return Vec::new(); }; let Ok(addr) = Address::parse(&uri) else { self.fail("the sender's reply address does not parse".into()); - return Task::none(); + return Vec::new(); }; let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&msg.sender).unwrap_or_default()) else { self.fail("the sender's key does not parse".into()); - return Task::none(); + return Vec::new(); }; - let address = addr.short(); - let timeout = self.timeout; - self.run("accepting", move |store, master| { - ops::accept_contact(&store, &master, &address, &identity, timeout) - .map(|_| format!("accepted {address}")) - }) + return vec![PendingOp::Accept { + address: addr.short(), + identity, + }]; } Message::BlockPressed => { let Some((_, msg)) = &self.open else { - return Task::none(); + return Vec::new(); }; let Some(uri) = msg.reply_to.clone() else { self.fail("this sender left no address to block".into()); - return Task::none(); + return Vec::new(); }; let Ok(addr) = Address::parse(&uri) else { self.fail("the sender's reply address does not parse".into()); - return Task::none(); + return Vec::new(); }; - let address = addr.short(); - let timeout = self.timeout; - self.run("blocking", move |store, master| { - ops::block_contact(&store, &master, &address, timeout) - .map(|_| format!("blocked {address}")) - }) - } - Message::ContactImportEdited(text) => { - self.contact_import = text; - Task::none() + return vec![PendingOp::Block { + address: addr.short(), + }]; } Message::ContactImportPressed => { let uri = self.contact_import.trim().to_string(); if uri.is_empty() { - return Task::none(); + return Vec::new(); } - self.run("importing", move |store, _| { - ops::import_contact(&store, &uri) - .map(|(address, _)| format!("imported {address} (verified)")) - }) + return vec![PendingOp::ImportContact { uri }]; } Message::ContactWrite(address) => { let key = self @@ -858,28 +749,20 @@ impl Carrier { // Self-certifying: the sealed copy goes to the key we hold. to: format!("smol://{address}/{key}"), subject: String::new(), - body: text_editor::Content::new(), + body: String::new(), reply_to: None, }); - Task::none() + self.compose_fresh.set(true); } Message::ContactAccept(address, key) => { let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default()) else { - return Task::none(); + return Vec::new(); }; - let timeout = self.timeout; - self.run("accepting", move |store, master| { - ops::accept_contact(&store, &master, &address, &identity, timeout) - .map(|_| format!("accepted {address}")) - }) + return vec![PendingOp::Accept { address, identity }]; } Message::ContactBlock(address) => { - let timeout = self.timeout; - self.run("blocking", move |store, master| { - ops::block_contact(&store, &master, &address, timeout) - .map(|_| format!("blocked {address}")) - }) + return vec![PendingOp::Block { address }]; } Message::ContactUnblock(address) => { // A block keeps the row, so unblocking re-accepts it. @@ -893,68 +776,121 @@ impl Carrier { .map(|c| c.key.clone()) }) .unwrap_or_default(); - let timeout = self.timeout; - self.run("unblocking", move |store, master| { - let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default()) - else { - return Err("contact key does not parse".to_string()); - }; - ops::accept_contact(&store, &master, &address, &identity, timeout) - .map(|_| format!("unblocked {address}")) - }) + let Ok(identity) = <[u8; KEY_LEN]>::try_from(unb32(&key).unwrap_or_default()) + else { + return Vec::new(); + }; + return vec![PendingOp::Accept { address, identity }]; } Message::CopyOwnAddress => { - let Some(snap) = &self.snapshot else { - return Task::none(); - }; - clipboard::write(format!("smol://{}/{}", snap.who.address, snap.who.key)) - } - Message::CopyExport => match &self.backup_text { - Some(text) => clipboard::write(text.clone()), - None => Task::none(), - }, - Message::TimeoutEdited(text) => { - if let Ok(timeout) = text.trim().parse::() { - self.timeout = timeout; + if let Some(snap) = &self.snapshot { + let uri = format!("smol://{}/{}", snap.who.address, snap.who.key); + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(&uri); + } + } + } + Message::CopyExport => { + if let Some(text) = &self.backup_text { + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(text); + } } - self.timeout_text = text; - Task::none() } Message::RotatePressed => { self.dialog = Some(Dialog::Rotate); - Task::none() } Message::RotateCancelled => { self.dialog = None; - Task::none() } Message::RotateConfirmed => { self.dialog = None; - let timeout = self.timeout; - self.run("rotating", move |store, master| { - ops::rotate(&store, &master, timeout).map(|(index, key)| { - format!("rotated to index {index}; new key {}…", &key[..12]) - }) - }) + return vec![PendingOp::Rotate]; + } + Message::UnpinPressed(label) => { + return vec![PendingOp::Unpin { label }]; } - Message::UnpinPressed(label) => self.run("unpinning", move |store, _| { - ops::unpin(&store, &label).map(|_| format!("unpinned {label}")) - }), Message::ExportPressed => { let dir = self.dir.clone(); - self.run_with("exporting", Message::Exported, move |store, master| { - ops::export_backup(&store, &master, &dir) - .map(|(path, text)| (format!("backup written to {}", path.display()), text)) - }) + return vec![PendingOp::Export { dir }]; } Message::ImportPressed => { - let text = self.import_body.text(); - if text.trim().is_empty() { - return Task::none(); + if self.import_body.trim().is_empty() { + return Vec::new(); } - self.run("importing", move |store, master| { - ops::import_backup(&store, &master, &text) - }) + return vec![PendingOp::ImportBackup { + text: self.import_body.clone(), + }]; + } + } + Vec::new() + } + + /// Results always pass — each one clears the operation it belongs to, + /// which is why they never travel through the busy-guarded `handle`. + pub fn handle_result(&mut self, result: OpResult) -> Option { + match result { + OpResult::SnapshotLoaded(Ok(snap)) => { + self.done(); + self.lists = snap.lists.clone(); + self.snapshot = Some(snap); + None + } + OpResult::SnapshotLoaded(Err(e)) => { + self.done(); + self.fail(e); + None + } + OpResult::Generic(Ok(note)) => { + self.done(); + self.note(note); + self.refresh() + } + OpResult::Generic(Err(e)) => { + self.done(); + self.fail(e); + None + } + OpResult::PinOffered(Ok(Some((addr, key)))) => { + self.done(); + self.pin_offer = Some((addr, key)); + self.note( + "The server presented this key. Pin it only if the fingerprint \ + matches what the operator published." + .into(), + ); + None + } + OpResult::PinOffered(Ok(None)) => { + self.done(); + self.note("This server's key is already pinned.".into()); + None + } + OpResult::PinOffered(Err(e)) => { + self.done(); + self.fail(e); + None + } + OpResult::Pinned(Ok(())) => { + self.done(); + self.note("server key pinned".into()); + None + } + OpResult::Pinned(Err(e)) => { + self.done(); + self.fail(e); + None + } + OpResult::Exported(Ok((note, text))) => { + self.done(); + self.backup_text = Some(text); + self.note(note); + None + } + OpResult::Exported(Err(e)) => { + self.done(); + self.fail(e); + None } } } @@ -966,28 +902,30 @@ mod tests { fn app() -> Carrier { let dir = tempfile::tempdir().unwrap().keep(); - let (app, _task) = Carrier::boot(dir); + let (app, _op) = Carrier::boot(dir); app } + /// Results clear the busy flag instead of deadlocking: this is the + /// regression test for the guard bug the Iced version shipped with. #[test] fn results_clear_the_busy_flag_instead_of_deadlocking() { let mut app = app(); app.busy = Some("registering".into()); - let _ = app.update(Message::Generic(Err("boom".into()))); + let chained = app.handle_result(OpResult::Generic(Ok("registered".into()))); + // The note landed and the refresh it triggers is the new busy op. + assert_eq!(app.status.as_deref(), Some("registered")); + assert_eq!(app.busy.as_deref(), Some("refreshing")); + assert!(matches!(chained, Some(PendingOp::Snapshot { .. }))); + + app.busy = Some("registering".into()); + app.handle_result(OpResult::Generic(Err("boom".into()))); assert!(app.busy.is_none()); assert!(app.status_error); app.busy = Some("registering".into()); - let _ = app.update(Message::SnapshotLoaded(Err("boom".into()))); + app.handle_result(OpResult::SnapshotLoaded(Err("boom".into()))); assert!(app.busy.is_none()); - - app.busy = Some("registering".into()); - let _ = app.update(Message::Generic(Ok("registered".into()))); - // The result was handled: the note landed and the refresh it - // triggers is the new busy operation. - assert_eq!(app.status.as_deref(), Some("registered")); - assert_eq!(app.busy.as_deref(), Some("refreshing")); } #[test] @@ -995,27 +933,56 @@ mod tests { let mut app = app(); app.master = Some([0u8; KEY_LEN]); app.busy = Some("fetching".into()); - let _ = app.update(Message::FetchPressed); + let ops = app.handle(Message::FetchPressed); + assert!(ops.is_empty()); assert_eq!(app.busy.as_deref(), Some("fetching")); } -} -#[cfg(test)] -mod tick_tests { - use super::*; - use fumi_core::message::{build_frontmatter, message_id, seal}; + #[test] + fn contact_selection_and_message_selection_supersede_each_other() { + let mut app = app(); + app.handle(Message::ContactSelected("bob@localhost".into())); + assert_eq!(app.selected_contact.as_deref(), Some("bob@localhost")); + assert_eq!(app.page, Page::Mail); + assert!(app.open.is_none()); + + // busy does not block selecting a contact + app.busy = Some("fetching".into()); + app.handle(Message::ContactSelected("bob@localhost".into())); + assert_eq!(app.busy.as_deref(), Some("fetching")); + } + + #[test] + fn text_edits_pass_the_busy_guard() { + let mut app = app(); + app.compose = Some(Compose { + to: String::new(), + subject: String::new(), + body: String::new(), + reply_to: None, + }); + app.busy = Some("fetching".into()); + app.handle(Message::ComposeBodyChanged("typing while busy".into())); + assert_eq!( + app.compose.as_ref().map(|c| c.body.clone()), + Some("typing while busy".to_string()) + ); + assert_eq!(app.busy.as_deref(), Some("fetching")); + } /// The store is committed envelope by envelope, so a tick mid-fetch /// sees a message that the running fetch has already verified. #[test] fn fetch_tick_lists_mail_as_it_lands() { + use fumi_core::message::{build_frontmatter, message_id, seal}; + let dir = tempfile::tempdir().unwrap().keep(); - let (mut app, _task) = Carrier::boot(dir.clone()); + let (mut app, _op) = Carrier::boot(dir); let master = ops::new_master(); app.master = Some(master); let me = ops::account(&app.store, &master).unwrap(); - let addr = fumi_core::address::Address::parse("me@localhost").unwrap(); + let addr = Address::parse("me@localhost").unwrap(); app.store.set_account(&addr).unwrap(); let body = build_frontmatter(&[("Subject", "live")], "tick"); let envelope = seal(me.me(), &me.me().pk(), body.as_bytes(), 1000, true).unwrap(); @@ -1025,13 +992,13 @@ mod tick_tests { .unwrap(); app.busy = Some("fetching".into()); - let _ = app.update(Message::FetchTick); + app.handle(Message::FetchTick); assert_eq!(app.busy.as_deref(), Some("fetching")); assert_eq!(app.lists[0].len(), 1); assert_eq!(app.lists[0][0].subject, "live"); // A tick outside a fetch is a no-op. app.busy = None; - let _ = app.update(Message::FetchTick); + app.handle(Message::FetchTick); } } diff --git a/src/demo.rs b/src/demo.rs new file mode 100644 index 0000000..cca9aac --- /dev/null +++ b/src/demo.rs @@ -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 = 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."; diff --git a/src/main.rs b/src/main.rs index 2d090ef..a2c2c78 100644 --- a/src/main.rs +++ b/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 } diff --git a/src/theme.rs b/src/theme.rs deleted file mode 100644 index 8fc62c9..0000000 --- a/src/theme.rs +++ /dev/null @@ -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() - } - } -} diff --git a/src/view.rs b/src/view.rs index ed3b6df..6eb37bb 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1,791 +1,150 @@ -//! The view: a compact horizontal split — a tab bar, the full-width list -//! above, the article below, and a status line at the bottom edge. Protocol -//! values (addresses, keys, ids, dates) are monospace; prose stays sans. +//! The Relm4 view: one component whose widgets mirror the model. The +//! `Component` impl is a thin adapter — `handle` and `handle_result` in +//! `app.rs` own the state machine; background work runs through +//! `spawn_command` on the blocking pool and reports back as `OpResult`. +//! +//! Every visual decision is delegated to libadwaita: the widgets below are +//! the stylesheet's own rows, groups, dialogs and status pages, and the +//! only styling is its documented class names. Nothing here picks a colour, +//! a font or a size, so carrier follows the user's theme and accent. -use iced::widget::text::Wrapping; -use iced::widget::{ - button, column, container, qr_code, row, rule, scrollable, text, text_editor, text_input, Space, -}; -use iced::{Alignment, Element, Font, Length, Padding}; +use std::sync::atomic::Ordering; +use std::time::Duration; -use fumi_core::crypto::{b32, fingerprint}; +use fumi_core::crypto::{b32, fingerprint, KEY_LEN}; +use gtk::prelude::*; +use gtk::{Align, Orientation, WrapMode}; +use relm4::adw; +use relm4::adw::prelude::*; +use relm4::ComponentSender; +use relm4::{Component, ComponentParts}; -use crate::app::{Carrier, Dialog, Message, OnboardMode, Page}; +use crate::app::{Carrier, Dialog, Message, OnboardMode, OpResult, Page, PendingOp}; use crate::ops::{self, Contact, Folder, Row}; -use crate::theme::{self, ACCENT, MUTED, TEXT}; +use relm4::RelmApp; -/// 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() { - compose(app) - } else { - match app.page { - Page::Onboard => onboard(app), - Page::Signup => signup(app), - Page::Mail => mail(app), - Page::Contacts => contacts(app), - Page::Settings => settings(app), - } - }; - column![content, dialog_banner(app), status(app)] - .spacing(0) - .height(Length::Fill) - .width(Length::Fill) - .into() -} - -// --- shared pieces --------------------------------------------------------- - -/// One line of monospace UI text. -fn mono<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> { - text(content.to_string()) - .font(Font::MONOSPACE) - .size(S) - .color(TEXT) -} - -fn muted<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> { - text(content.to_string()) - .font(Font::MONOSPACE) - .size(S) - .color(MUTED) -} - -fn accent<'a>(content: impl std::fmt::Display) -> iced::widget::Text<'a> { - text(content.to_string()) - .font(Font::MONOSPACE) - .size(S) - .color(ACCENT) -} - -/// A fixed-width cell so every row lines up like a table column. -fn cell<'a>(content: iced::widget::Text<'a>, width: f32) -> Element<'a, Message> { - container(content) - .width(width) - .align_x(Alignment::Start) - .into() -} - -fn action<'a>(label: &str, msg: Message) -> Element<'a, Message> { - button(text(label.to_string()).size(S)) - .on_press(msg) - .style(theme::accent()) - .padding([4, 12]) - .into() -} - -fn action_disabled<'a>(label: &str) -> Element<'a, Message> { - 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(S)) - .on_press(msg) - .style(theme::quiet()) - .padding([4, 12]) - .into() -} - -fn danger_action<'a>(label: &str, msg: Message) -> Element<'a, Message> { - button(text(label.to_string()).size(S)) - .on_press(msg) - .style(theme::danger()) - .padding([4, 12]) - .into() -} - -/// A tab entry: accent text when active, muted otherwise. -fn tab<'a>(label: impl std::fmt::Display, active: bool, msg: Message) -> Element<'a, Message> { - let label = label.to_string(); - button( - text(label) - .font(Font::MONOSPACE) - .size(S) - .color(if active { ACCENT } else { MUTED }), - ) - .on_press(msg) - .padding([4, 8]) - .into() -} - -/// The bar every main page shares: identity, folder tabs, page tabs, actions. -fn top_bar<'a>(app: &'a Carrier) -> Element<'a, Message> { - let address = app - .snapshot - .as_ref() - .map(|snap| snap.who.address.clone()) - .unwrap_or_default(); - let unread = |folder: usize| { - app.lists - .get(folder) - .map(|rows| rows.iter().filter(|r| r.unread).count()) - .unwrap_or(0) - }; - let fetching = app.busy.as_deref() == Some("fetching"); - let mut tabs = row![].spacing(2); - for (folder, label) in [ - (Folder::Inbox, "Inbox"), - (Folder::Requests, "Requests"), - (Folder::Sent, "Sent"), - ] { - let count = unread(folder as usize); - let label = if count > 0 { - format!("{label} {count}") - } else { - label.to_string() - }; - tabs = tabs.push(tab( - label, - app.folder == folder && app.page == Page::Mail, - Message::GotoMail(folder), - )); - } - tabs = tabs - .push(tab( - "Contacts", - app.page == Page::Contacts, - Message::Goto(Page::Contacts), - )) - .push(tab( - "Settings", - app.page == Page::Settings, - Message::Goto(Page::Settings), - )); - let fetch = if fetching { - action_disabled("Fetching…") - } else if app.busy.is_some() { - action_disabled("Fetch") - } else { - action("Fetch", Message::FetchPressed) - }; - let write = if app.busy.is_some() { - action_disabled("Write") - } else { - action("Write", Message::ComposePressed) - }; - container( - row![ - accent("carrier"), - mono(address), - Space::with_width(Length::Fill), - tabs, - Space::with_width(Length::Fill), - fetch, - write, - ] - .spacing(10) - .align_y(Alignment::Center), - ) - .style(theme::panel) - .padding([6, 10]) - .width(Length::Fill) - .into() -} - -/// The one status line: the busy label with its cancel button while an -/// operation runs, otherwise the last result or error. -fn status<'a>(app: &'a Carrier) -> Element<'a, Message> { - let mut line = row![].spacing(10).align_y(Alignment::Center); - match (&app.busy, &app.status) { - (Some(busy), _) => { - if busy == "fetching" { - let inbox = app.lists.first().map(|rows| rows.len()).unwrap_or(0); - line = line.push(muted(format!("fetching… {inbox} in inbox"))); - line = line.push(quiet_action("Cancel", Message::FetchCancelled)); - } else { - line = line.push(muted(format!("{busy}…"))); +/// A label, the unit every line is built from. The class argument is a +/// space-separated set of Adwaita's own typography classes, so weight and +/// size come from the stylesheet rather than from markup or a hardcoded +/// font: `heading` and `title` for hierarchy, `dim` and `caption` for +/// metadata, `mono` wherever a key or address must stay column-aligned. +fn label(text: &str, classes: &str) -> gtk::Label { + let label = gtk::Label::new(Some(text)); + for class in classes.split_whitespace() { + match class { + "dim" => label.add_css_class("dim-label"), + "caption" => { + label.add_css_class("caption"); + label.add_css_class("dim-label"); } - } - (None, Some(status)) => { - let piece = if app.status_error { - text(status.clone()).font(Font::MONOSPACE).size(S) - } else { - muted(status.clone()) - }; - line = line.push(piece); - } - (None, None) => {} - } - container(line) - .style(theme::status(app.status_error && app.busy.is_none())) - .padding([4, 10]) - .width(Length::Fill) - .into() -} - -/// Inline confirmations: a slim banner between the content and the status. -fn dialog_banner<'a>(app: &'a Carrier) -> Element<'a, Message> { - let Some(dialog) = &app.dialog else { - return row![].into(); - }; - let mut line = row![].spacing(8).align_y(Alignment::Center); - match dialog { - Dialog::Delete { kept, .. } => { - line = line.push(muted("delete this message:")); - line = line.push(danger_action("here", Message::DeleteConfirmed(false))); - if *kept { - line = line.push(danger_action("everywhere", Message::DeleteConfirmed(true))); - } - line = line.push(quiet_action("cancel", Message::DeleteCancelled)); - } - Dialog::Rotate => { - line = line.push(muted( - "rotate the identity? the old key stays derivable; the index never moves back", - )); - line = line.push(action("rotate", Message::RotateConfirmed)); - line = line.push(quiet_action("cancel", Message::RotateCancelled)); + "heading" => label.add_css_class("heading"), + "title" => label.add_css_class("title-1"), + "subtitle" => label.add_css_class("title-4"), + "mono" => label.add_css_class("monospace"), + "numeric" => label.add_css_class("numeric"), + other => label.add_css_class(other), } } - container(line) - .style(theme::status(false)) - .padding([4, 10]) - .width(Length::Fill) - .into() + label.set_xalign(0.0); + label } -fn pin_offer_banner<'a>(app: &'a Carrier) -> Element<'a, Message> { - let Some((_, key)) = &app.pin_offer else { - return row![].into(); - }; - row![ - muted("server key offered:"), - mono(fingerprint(key)), - mono(short(&b32(key), 24)), - action("pin", Message::PinAccept), - quiet_action("discard", Message::PinDiscard), - ] - .spacing(8) - .align_y(Alignment::Center) - .into() +fn escape(text: &str) -> String { + gtk::glib::markup_escape_text(text).to_string() } -// --- mail ------------------------------------------------------------------ - -fn mail<'a>(app: &'a Carrier) -> Element<'a, Message> { - column![ - top_bar(app), - container(list_pane(app)).height(Length::FillPortion(38)), - rule::Rule::horizontal(1.0), - container(detail_pane(app)).height(Length::FillPortion(62)), - ] - .spacing(0) - .height(Length::Fill) - .into() -} - -fn list_pane<'a>(app: &'a Carrier) -> Element<'a, Message> { - let rows = app - .lists - .get(app.folder as usize) - .cloned() - .unwrap_or_default(); - 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(14.0))); - } - for row in &rows { - let selected = app - .open - .as_ref() - .is_some_and(|(folder, msg)| *folder == app.folder && msg.id == row.id); - list = list.push(list_row(app.folder, row, selected)); - } - scrollable(list).height(Length::Fill).into() -} - -/// 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 subject = if row.subject.is_empty() { - "(no subject)".to_string() - } else { - truncate(&row.subject, 56) - }; - button( - row![ - flag, - text(subject) - .font(Font::MONOSPACE) - .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::new(4.0).left(12.0).right(12.0)) - .width(Length::Fill) - .into() -} - -/// 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:<12}")), mono(value),] - .spacing(4) - .into() -} - -fn detail_pane<'a>(app: &'a Carrier) -> Element<'a, Message> { - let Some((folder, msg)) = &app.open else { - return container(muted("select a message to read it")) - .center(Length::Fill) - .into(); - }; - // One muted meta line carries what the pane used to stack as headers. - let label = if *folder == Folder::Sent { - "to" - } else { - "from" - }; - 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() - }; - let mut actions = row![].spacing(8); - actions = actions.push(quiet_action("reply", Message::ReplyPressed)); - if *folder == Folder::Requests { - if msg.reply_to.is_some() { - actions = actions.push(action("accept", Message::AcceptPressed)); - actions = actions.push(danger_action("block", Message::BlockPressed)); - } else { - actions = actions.push(muted("no reply address: cannot accept or block")); +/// Buttons map onto the stylesheet's built-in classes: "accent" is the +/// suggested action, "danger" the destructive one, "pill" the wide call to +/// action a welcome screen wants, everything else flat. +fn button(text: &str, class: &str) -> gtk::Button { + let button = gtk::Button::with_label(text); + match class { + "accent" => button.add_css_class("suggested-action"), + "danger" => button.add_css_class("destructive-action"), + "quiet" => button.add_css_class("flat"), + "pill" => { + button.add_css_class("pill"); + button.add_css_class("suggested-action"); } + "pill-quiet" => button.add_css_class("pill"), + _ => {} } - actions = actions.push(danger_action("delete", Message::DeletePressed)); - column![ - 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(BODY) - .color(TEXT) - ) - .padding([10, 12])]) - .height(Length::Fill), - rule::Rule::horizontal(1.0), - container(actions).padding([6, 12]), - ] - .spacing(0) - .height(Length::Fill) - .into() + button } -// --- compose --------------------------------------------------------------- - -/// Compose takes the lower pane, keeping the list visible above — writing is -/// the article pane's other job, as in the layout it comes from. -fn compose<'a>(app: &'a Carrier) -> Element<'a, Message> { - let Some(compose) = &app.compose else { - unreachable!("compose view without a draft") - }; - let label = if compose.reply_to.is_some() { - "reply" - } else { - "write" - }; - column![ - top_bar(app), - container( - column![ - 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(BODY) - .height(Length::Fill), - row![ - action(label, Message::SendPressed), - quiet_action("cancel", Message::ComposeClosed), - ] - .spacing(8), - ] - .spacing(8) - ) - .padding(12) - .height(Length::Fill), - ] - .spacing(0) - .height(Length::Fill) - .into() +/// The same, with a symbolic icon beside the label. `ButtonContent` does +/// the icon-to-label spacing the stylesheet expects. +fn icon_label_button(icon: &str, text: &str, class: &str) -> gtk::Button { + let button = button("", class); + button.set_child(Some( + &adw::ButtonContent::builder() + .icon_name(icon) + .label(text) + .build(), + )); + button } -// --- contacts --------------------------------------------------------------- - -fn contacts<'a>(app: &'a Carrier) -> Element<'a, Message> { - let Some(snap) = &app.snapshot else { - return column![top_bar(app)].into(); - }; - let own = format!("smol://{}/{}", snap.who.address, snap.who.key); - let mut share = row![ - muted("you:"), - mono(own), - quiet_action("copy", Message::CopyOwnAddress), - ] - .spacing(8) - .align_y(Alignment::Center); - if let Some(data) = &app.share_qr { - share = share.push(container(qr_code(data).cell_size(3)).padding(0)); - } - let header = row![ - cell(muted("address"), ADDR_W), - cell(muted("key"), KEY_W), - cell(muted("state"), STATE_W), - muted(""), - ] - .spacing(10) - .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)); - } - if snap.contacts.is_empty() { - table = table.push( - container(muted("no contacts yet — import a smol:// address")) - .padding(Padding::new(10.0)), - ); - } - column![ - top_bar(app), - container(share).style(theme::panel).padding([6, 10]), - container( - row![ - text_input("paste a smol:// address to import", &app.contact_import) - .on_input(Message::ContactImportEdited) - .on_submit(Message::ContactImportPressed) - .size(S) - .width(Length::Fill), - action("import", Message::ContactImportPressed), - ] - .spacing(8) - ) - .padding([6, 10]), - scrollable(table).height(Length::Fill), - ] - .spacing(0) - .height(Length::Fill) - .into() +/// A flat round icon button: the Adwaita form for a secondary affordance +/// that sits inside a row rather than in a toolbar. +fn icon_button(icon: &str, tooltip: &str) -> gtk::Button { + let button = gtk::Button::from_icon_name(icon); + button.add_css_class("flat"); + button.add_css_class("circular"); + button.set_tooltip_text(Some(tooltip)); + button.set_valign(Align::Center); + button } -fn contact_row<'a>(contact: &Contact) -> Element<'a, Message> { - let state = match contact.accepted { - Some(true) => "accepted", - Some(false) => "blocked", - None => { - if contact.verified { - "verified" - } else { - "tofu" - } - } - }; - let mut actions = row![quiet_action( - "write", - Message::ContactWrite(contact.address.clone()), - )] - .spacing(6); - match contact.accepted { - Some(true) => { - actions = actions.push(danger_action( - "block", - Message::ContactBlock(contact.address.clone()), - )); - } - Some(false) => { - actions = actions.push(action( - "unblock", - Message::ContactUnblock(contact.address.clone()), - )); - } - None => { - actions = actions.push(action( - "accept", - Message::ContactAccept(contact.address.clone(), contact.key.clone()), - )); - } - } - container( - row![ - cell(accent(&contact.address), ADDR_W), - cell(mono(short(&contact.key, 32)), KEY_W), - cell(muted(state), STATE_W), - actions, - ] - .spacing(10) - .align_y(Alignment::Center), - ) - .style(theme::card) - .padding(Padding::new(4.0).left(12.0).right(12.0)) - .width(Length::Fill) - .into() +/// One sidebar destination: icon, name, and a count that stays hidden +/// until it is non-zero. The row carries no class of its own — the list's +/// `navigation-sidebar` paints hover and selection. +fn nav_row(icon: &str, text: &str) -> (gtk::ListBoxRow, gtk::Label) { + let row = gtk::ListBoxRow::new(); + let line = gtk::Box::new(Orientation::Horizontal, 12); + line.set_margin_top(6); + line.set_margin_bottom(6); + line.set_margin_start(6); + line.set_margin_end(6); + line.append(>k::Image::from_icon_name(icon)); + let name = gtk::Label::new(Some(text)); + name.set_xalign(0.0); + name.set_hexpand(true); + line.append(&name); + let count = label("", "caption numeric"); + count.set_visible(false); + line.append(&count); + row.set_child(Some(&line)); + (row, count) } -// --- settings --------------------------------------------------------------- - -fn settings<'a>(app: &'a Carrier) -> Element<'a, Message> { - let Some(snap) = &app.snapshot else { - return column![top_bar(app)].into(); - }; - let who = &snap.who; - let identity = column![ - header_line("address", &who.address), - header_line("key", &who.key), - header_line("fingerprint", &who.fingerprint), - header_line("rotations", who.rotations), - row![ - action("rotate", Message::RotatePressed), - quiet_action("learn key", Message::SettingsLearnKey), - ] - .spacing(8), - ] - .spacing(1); - - let mut pins = column![].spacing(1); - for (label, key) in &snap.pins { - pins = pins.push( - row![ - cell(mono(label), ADDR_W), - cell(mono(short(&b32(key), 32)), KEY_W), - danger_action("unpin", Message::UnpinPressed(label.clone())), - ] - .spacing(10) - .align_y(Alignment::Center), - ); - } - if snap.pins.is_empty() { - pins = pins.push(muted("no server keys pinned")); - } - - // Pinned to the app borrow: the column mixes owned rows with the - // `text_editor` that borrows `app.import_body`. - let mut export: iced::widget::Column<'_, Message> = column![].spacing(4); - export = export.push( - row![ - action("export", Message::ExportPressed), - quiet_action("copy", Message::CopyExport), - ] - .spacing(8), - ); - if let Some(text) = &app.backup_text { - export = export.push(scrollable(mono(short(text, 4000))).height(Length::Fixed(80.0))); - } - export = export.push(muted("import a backup (gsmol): paste it below")); - export = export.push( - text_editor(&app.import_body) - .on_action(Message::ImportBody) - .font(Font::MONOSPACE) - .size(S) - .height(Length::Fixed(100.0)), - ); - export = export.push(action("import", Message::ImportPressed)); - - let sections = column![ - section("identity", identity.into()), - section("server keys", pins.into()), - section("backup", export.into()), - section( - "network", - row![ - muted("timeout"), - text_input("30", &app.timeout_text) - .on_input(Message::TimeoutEdited) - .size(S) - .width(80), - muted("seconds"), - ] - .spacing(8) - .align_y(Alignment::Center) - .into(), - ), - ] - .spacing(10); - - column![ - top_bar(app), - pin_offer_banner(app), - scrollable(container(sections).padding(10)).height(Length::Fill), - ] - .spacing(0) - .height(Length::Fill) - .into() +/// A list that paints itself as one rounded group: the Adwaita form for a +/// short, fixed set of rows, as opposed to a long scrolling one. +fn boxed_list() -> gtk::ListBox { + let list = gtk::ListBox::new(); + list.add_css_class("boxed-list"); + list.set_selection_mode(gtk::SelectionMode::None); + list } -fn section<'a>(title: &str, body: Element<'a, Message>) -> Element<'a, Message> { - container( - column![container(accent(title)).padding([2, 0]), body] - .spacing(6) - .max_width(720), - ) - .style(theme::card) - .padding(10) - .width(Length::Fill) - .into() -} - -// --- onboarding and signup --------------------------------------------------- - -fn centered<'a>(body: Element<'a, Message>) -> Element<'a, Message> { - container(body) - .center(Length::Fill) - .max_width(520) - .padding(20) - .into() -} - -fn onboard<'a>(app: &'a Carrier) -> Element<'a, Message> { - let body = match app.onboard.mode { - OnboardMode::Start => column![ - text("carrier").font(Font::MONOSPACE).size(24).color(ACCENT), - muted("smolmail: one seed, sealed mail, five operations"), - row![ - action("create an identity", Message::OnboardCreate), - quiet_action("restore from a seed", Message::OnboardRestore), - ] - .spacing(8) - .padding([12, 0]), - ] - .spacing(10) - .align_x(Alignment::Start), - OnboardMode::Created => column![ - muted("this seed is the only secret — write it down now; it is shown once"), - container( - text_input("", &app.onboard.seed_shown) - .font(Font::MONOSPACE) - .size(S) - .width(Length::Fill) - ) - .style(theme::card) - .padding(6), - row![ - action("i saved it", Message::OnboardSeedSaved), - quiet_action("back", Message::OnboardBack), - ] - .spacing(8), - ] - .spacing(10), - OnboardMode::Restore => column![ - muted("paste the 32-byte seed, base32 or hex"), - text_input("seed", &app.onboard.seed) - .on_input(Message::OnboardSeedEdited) - .on_submit(Message::OnboardRestoreFromSeed) - .size(S) - .width(Length::Fill), - row![ - action("restore", Message::OnboardRestoreFromSeed), - quiet_action("back", Message::OnboardBack), - ] - .spacing(8), - ] - .spacing(10), - }; - centered(body.into()) -} - -fn signup<'a>(app: &'a Carrier) -> Element<'a, Message> { - let mut body = column![ - accent("set up the address"), - 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() { - body = body.push( - row![ - muted("or"), - quiet_action("learn the server key", Message::SignupLearnKey), - muted("and pin what it presents"), - ] - .spacing(8) - .align_y(Alignment::Center), - ); +/// Removes every child of a box, the coarse re-render primitive. +fn clear(boxed: >k::Box) { + while let Some(child) = boxed.first_child() { + boxed.remove(&child); } - body = body.push(pin_offer_banner(app)); - body = body.push( - row![ - action("register", Message::SignupRegister), - quiet_action("i already have this address", Message::SignupRecall), - ] - .spacing(8) - .padding([8, 0]), - ); - if app.signup.fresh { - body = body.push(quiet_action("back", Message::SignupBack)); - } - centered(body.into()) } -// --- small helpers ----------------------------------------------------------- +/// The same for lists. +fn clear_list(list: >k::ListBox) { + while let Some(child) = list.first_child() { + list.remove(&child); + } +} + +pub fn run(dir: std::path::PathBuf) { + let app: RelmApp<_> = RelmApp::new("com.randogoth.carrier"); + app.run::(dir); +} fn truncate(text: &str, max: usize) -> String { if text.chars().count() <= max { @@ -796,10 +155,1546 @@ fn truncate(text: &str, max: usize) -> String { } } -fn short(text: &str, max: usize) -> String { - if text.len() <= max { - text.to_string() - } else { - format!("{}…{}", &text[..max / 2], &text[text.len() - 3..]) +fn buffer_text(buffer: >k::TextBuffer) -> String { + buffer + .text(&buffer.start_iter(), &buffer.end_iter(), false) + .to_string() +} + +/// The contact's trust state as one word, the same vocabulary the sidebar, +/// the detail pane and the contacts page all label it with. +fn contact_state(contact: &Contact) -> &'static str { + match contact.accepted { + Some(true) => "accepted", + Some(false) => "blocked", + None if contact.verified => "verified", + None => "trust on first use", } } + +#[derive(Debug, Clone)] +pub struct CarrierWidgets { + /// `AlertDialog::present` needs a parent, and only the window is one. + window: gtk::Window, + window_title: adw::WindowTitle, + toasts: adw::ToastOverlay, + toolbar: adw::ToolbarView, + + // sidebar top: actions and destinations + folders: gtk::ListBox, + folder_counts: Vec, + settings_nav: gtk::ListBox, + fetch_button: gtk::Button, + write_button: gtk::Button, + + stack: gtk::Stack, + + // mail page + list_stack: gtk::Stack, + mail_list: gtk::ListBox, + list_empty: adw::StatusPage, + list_empty_fetch: gtk::Button, + article_stack: gtk::Stack, + article_subject: gtk::Label, + article_meta: gtk::Label, + article_view: gtk::TextView, + article_body: gtk::TextBuffer, + actions: gtk::Box, + + // compose page + compose_to: adw::EntryRow, + compose_subject: adw::EntryRow, + compose_body: gtk::TextBuffer, + compose_send: adw::ButtonContent, + + // sidebar bottom: own identity and the contact list + sidebar_own: gtk::Label, + sidebar_contacts: gtk::ListBox, + + // contacts page + own_label: gtk::Label, + qr: gtk::Image, + contacts_list: gtk::ListBox, + + // settings page + identity_values: Vec, + offer_group: adw::PreferencesGroup, + offer_row: adw::ActionRow, + pins_group: adw::PreferencesGroup, + pin_rows: Vec, + timeout_row: adw::SpinRow, + backup_label: gtk::Label, + + // onboarding + onboard_seed_label: gtk::Label, + + // signup + signup_back: gtk::Button, + signup_offer_list: gtk::ListBox, + signup_offer_row: adw::ActionRow, + + // status bar and dialogs + spinner: gtk::Spinner, + status_label: gtk::Label, + cancel_button: gtk::Button, + /// The dialog currently on screen, so `render` presents it once on the + /// None -> Some edge instead of stacking a copy on every message. + dialog: Option, + /// The note already spoken as a toast, for the same reason. + last_status: String, +} + +impl CarrierWidgets { + fn new(root: >k::Window, sender: &ComponentSender) -> Self { + // One helper per widget parameter type: a single closure would + // have its signature unified across every call site. + let input = |msg: Message| { + let sender = sender.clone(); + move |_: >k::Button| sender.input(msg.clone()) + }; + + // An Adwaita headerbar carrying the app name over the account + // address, so the window reads like every other GNOME app and the + // identity in use is never more than a glance away. + let window_title = adw::WindowTitle::new("carrier", ""); + let header = adw::HeaderBar::new(); + header.set_title_widget(Some(&window_title)); + root.set_titlebar(Some(&header)); + + // The sidebar column, left of the main box, split on the same + // line as the main box's list/article split. The top panel stacks + // the actions and the destinations; the bottom panel leads with + // our own identity, carded apart from the contact list. + let sidebar = gtk::Paned::new(Orientation::Vertical); + sidebar.set_position(250); + // Refuse to shrink the top panel past its own minimum: otherwise + // dragging the divider up clips it from the top and the actions + // are the first thing to go. The filler below the folders has no + // minimum of its own, so that space collapses first instead. + sidebar.set_shrink_start_child(false); + let sidebar_top = gtk::Box::new(Orientation::Vertical, 12); + sidebar_top.set_margin_top(6); + sidebar_top.set_margin_start(6); + sidebar_top.set_margin_end(6); + let sidebar_bottom = gtk::Box::new(Orientation::Vertical, 6); + sidebar_bottom.set_margin_start(6); + sidebar_bottom.set_margin_end(6); + sidebar_bottom.set_margin_bottom(6); + sidebar.set_start_child(Some(&sidebar_top)); + sidebar.set_end_child(Some(&sidebar_bottom)); + + // Fetch and Write lead the top panel, each an equal half. Only + // Fetch is suggested: two suggested actions side by side would + // leave neither of them reading as the primary one. + let sidebar_actions = gtk::Box::new(Orientation::Horizontal, 6); + sidebar_actions.set_homogeneous(true); + let fetch_button = icon_label_button("mail-send-receive-symbolic", "Fetch", "accent"); + let write_button = icon_label_button("mail-message-new-symbolic", "Write", ""); + sidebar_actions.append(&fetch_button); + sidebar_actions.append(&write_button); + sidebar_top.append(&sidebar_actions); + + // The destinations follow, as the sidebar list every GNOME app + // uses: icon, name, and the unread count on the right. + let folders = gtk::ListBox::new(); + folders.add_css_class("navigation-sidebar"); + folders.set_selection_mode(gtk::SelectionMode::Single); + let folder_rows = [ + nav_row("folder-download-symbolic", "Inbox"), + nav_row("contact-new-symbolic", "Requests"), + nav_row("mail-send-symbolic", "Sent"), + ]; + let mut folder_counts = Vec::new(); + for (row, count) in folder_rows { + folders.append(&row); + folder_counts.push(count); + } + sidebar_top.append(&folders); + + // Settings closes the top panel, pinned to its bottom. It is its + // own list so that it highlights like a destination without + // joining the folders' selection, the way GNOME Settings splits + // its own sidebar. + let filler = gtk::Label::new(None); + filler.set_vexpand(true); + sidebar_top.append(&filler); + let settings_nav = gtk::ListBox::new(); + settings_nav.add_css_class("navigation-sidebar"); + settings_nav.set_selection_mode(gtk::SelectionMode::Single); + let (settings_row, _) = nav_row("preferences-system-symbolic", "Settings"); + settings_nav.append(&settings_row); + sidebar_top.append(&settings_nav); + + // Our own identity heads the bottom panel, carded so it reads + // apart from the contacts below, with the copy the sidebar never + // used to offer. + let own_row = gtk::Box::new(Orientation::Horizontal, 6); + own_row.add_css_class("card"); + own_row.set_margin_top(6); + let own_text = gtk::Box::new(Orientation::Vertical, 0); + own_text.set_hexpand(true); + own_text.set_margin_top(8); + own_text.set_margin_bottom(8); + own_text.set_margin_start(12); + let own_caption = label("you", "caption"); + let sidebar_own = label("", "mono heading"); + sidebar_own.set_selectable(true); + sidebar_own.set_ellipsize(gtk::pango::EllipsizeMode::End); + own_text.append(&own_caption); + own_text.append(&sidebar_own); + let own_copy_small = icon_button("edit-copy-symbolic", "Copy your address"); + own_copy_small.set_margin_end(6); + own_row.append(&own_text); + own_row.append(&own_copy_small); + sidebar_bottom.append(&own_row); + + let contacts_scroll = gtk::ScrolledWindow::new(); + contacts_scroll.set_vexpand(true); + let contacts_list = gtk::ListBox::new(); + contacts_list.add_css_class("navigation-sidebar"); + contacts_list.set_selection_mode(gtk::SelectionMode::Single); + contacts_scroll.set_child(Some(&contacts_list)); + sidebar_bottom.append(&contacts_scroll); + + // --- pages ------------------------------------------------------ + let stack = gtk::Stack::new(); + stack.set_vexpand(true); + + // mail: the split — list above, article below + let paned = gtk::Paned::new(Orientation::Vertical); + paned.set_position(250); + let list_scroll = gtk::ScrolledWindow::new(); + let mail_list = gtk::ListBox::new(); + mail_list.set_selection_mode(gtk::SelectionMode::Single); + list_scroll.set_child(Some(&mail_list)); + // An empty folder gets a status page rather than a stray line of + // dim text, so the pane says what to do about it. + let list_empty = adw::StatusPage::new(); + let list_empty_fetch = icon_label_button("mail-send-receive-symbolic", "Fetch", "pill"); + list_empty_fetch.set_halign(Align::Center); + list_empty.set_child(Some(&list_empty_fetch)); + let list_stack = gtk::Stack::new(); + list_stack.set_hhomogeneous(false); + list_stack.set_vhomogeneous(false); + list_stack.add_named(&list_scroll, Some("list")); + list_stack.add_named(&list_empty, Some("empty")); + paned.set_start_child(Some(&list_stack)); + paned.set_resize_start_child(true); + paned.set_shrink_start_child(false); + + let article = gtk::Box::new(Orientation::Vertical, 0); + let article_head = gtk::Box::new(Orientation::Vertical, 10); + article_head.set_margin_top(12); + article_head.set_margin_bottom(12); + article_head.set_margin_start(12); + article_head.set_margin_end(12); + let article_subject = label("", "subtitle"); + article_subject.set_wrap(true); + let article_meta = label("", "caption"); + article_meta.set_wrap(true); + article_head.append(&article_subject); + article_head.append(&article_meta); + article.append(&article_head); + article.append(>k::Separator::new(Orientation::Horizontal)); + let article_view = gtk::TextView::new(); + article_view.set_wrap_mode(WrapMode::WordChar); + article_view.set_editable(false); + article_view.set_cursor_visible(false); + article_view.set_top_margin(12); + article_view.set_bottom_margin(12); + article_view.set_left_margin(12); + article_view.set_right_margin(12); + let article_body = article_view.buffer(); + let article_scroll = gtk::ScrolledWindow::new(); + article_scroll.set_vexpand(true); + article_scroll.set_child(Some(&article_view)); + article.append(&article_scroll); + // The action bar carries the stylesheet's `toolbar` class, which + // supplies its own padding and ground, so no separator is needed + // to tell it apart from the body above. + let actions = gtk::Box::new(Orientation::Horizontal, 6); + actions.add_css_class("toolbar"); + article.append(&actions); + + let article_empty = adw::StatusPage::builder() + .icon_name("mail-read-symbolic") + .title("No message selected") + .description("Pick a message from the list above to read it.") + .build(); + let article_stack = gtk::Stack::new(); + article_stack.set_hhomogeneous(false); + article_stack.set_vhomogeneous(false); + article_stack.add_named(&article, Some("detail")); + article_stack.add_named(&article_empty, Some("empty")); + paned.set_end_child(Some(&article_stack)); + // The sidebar's divider follows this one and vice versa, so both + // columns always split on the same line. + let sidebar_sync = sidebar.clone(); + paned.connect_position_notify(move |p| { + if sidebar_sync.position() != p.position() { + sidebar_sync.set_position(p.position()); + } + }); + let main_sync = paned.clone(); + sidebar.connect_position_notify(move |p| { + if main_sync.position() != p.position() { + main_sync.set_position(p.position()); + } + }); + stack.add_named(&paned, Some("mail")); + + // compose + let compose = gtk::Box::new(Orientation::Vertical, 12); + compose.set_margin_top(12); + compose.set_margin_bottom(12); + compose.set_margin_start(12); + compose.set_margin_end(12); + let compose_fields = boxed_list(); + let compose_to = adw::EntryRow::builder().title("To").build(); + let compose_subject = adw::EntryRow::builder().title("Subject").build(); + compose_fields.append(&compose_to); + compose_fields.append(&compose_subject); + compose.append(&compose_fields); + let compose_view = gtk::TextView::new(); + compose_view.set_wrap_mode(WrapMode::WordChar); + compose_view.set_top_margin(8); + compose_view.set_bottom_margin(8); + compose_view.set_left_margin(8); + compose_view.set_right_margin(8); + let compose_body = compose_view.buffer(); + let compose_scroll = gtk::ScrolledWindow::new(); + compose_scroll.add_css_class("card"); + compose_scroll.set_vexpand(true); + compose_scroll.set_child(Some(&compose_view)); + compose.append(&compose_scroll); + let compose_send_button = button("", "accent"); + let compose_send = adw::ButtonContent::builder() + .icon_name("mail-send-symbolic") + .label("Send") + .build(); + compose_send_button.set_child(Some(&compose_send)); + let compose_cancel = button("Cancel", "quiet"); + let compose_actions = gtk::Box::new(Orientation::Horizontal, 6); + compose_actions.set_halign(Align::End); + compose_actions.append(&compose_cancel); + compose_actions.append(&compose_send_button); + compose.append(&compose_actions); + stack.add_named(&compose, Some("compose")); + + // contacts + let contacts = gtk::Box::new(Orientation::Vertical, 12); + contacts.set_margin_top(12); + contacts.set_margin_bottom(12); + contacts.set_margin_start(12); + contacts.set_margin_end(12); + let own_card = gtk::Box::new(Orientation::Horizontal, 12); + own_card.add_css_class("card"); + let own_column = gtk::Box::new(Orientation::Vertical, 6); + own_column.set_hexpand(true); + own_column.set_valign(Align::Center); + own_column.set_margin_top(12); + own_column.set_margin_bottom(12); + own_column.set_margin_start(12); + own_column.append(&label("you", "caption")); + let own_label = label("", "mono"); + own_label.set_wrap(true); + own_label.set_wrap_mode(gtk::pango::WrapMode::Char); + own_label.set_selectable(true); + own_column.append(&own_label); + let own_copy = icon_label_button("edit-copy-symbolic", "Copy", ""); + own_copy.set_halign(Align::Start); + own_column.append(&own_copy); + own_card.append(&own_column); + let qr = gtk::Image::new(); + qr.set_pixel_size(144); + qr.set_margin_top(12); + qr.set_margin_bottom(12); + qr.set_margin_end(12); + own_card.append(&qr); + contacts.append(&own_card); + let import_list = boxed_list(); + let contact_import = adw::EntryRow::builder() + .title("Import a smol:// address") + .build(); + let import_button = icon_label_button("document-open-symbolic", "Import", "accent"); + import_button.set_valign(Align::Center); + contact_import.add_suffix(&import_button); + import_list.append(&contact_import); + contacts.append(&import_list); + let contacts_page_scroll = gtk::ScrolledWindow::new(); + contacts_page_scroll.set_vexpand(true); + let contacts_page_list = boxed_list(); + contacts_page_list.set_valign(Align::Start); + contacts_page_scroll.set_child(Some(&contacts_page_list)); + contacts.append(&contacts_page_scroll); + stack.add_named(&contacts, Some("contacts")); + + // settings: the preferences page every GNOME app shows, one group + // per concern, so scrolling, width and margins come for free. + let settings = adw::PreferencesPage::new(); + + let identity_group = adw::PreferencesGroup::builder().title("Identity").build(); + let identity_actions = gtk::Box::new(Orientation::Horizontal, 6); + let rotate_button = icon_label_button("view-refresh-symbolic", "Rotate", ""); + let learn_button = button("Learn key", ""); + identity_actions.append(&learn_button); + identity_actions.append(&rotate_button); + identity_group.set_header_suffix(Some(&identity_actions)); + let mut identity_values = Vec::new(); + for title in ["Address", "Key", "Fingerprint", "Rotations"] { + let row = adw::ActionRow::builder().title(title).build(); + let value = label("", "mono dim"); + value.set_selectable(true); + value.set_ellipsize(gtk::pango::EllipsizeMode::End); + value.set_max_width_chars(48); + value.set_valign(Align::Center); + row.add_suffix(&value); + identity_group.add(&row); + identity_values.push(value); + } + settings.add(&identity_group); + + // The pin offer is a group that appears in place rather than a + // dialog: pinning is a decision about this host, taken next to the + // identity it belongs to. + let offer_group = adw::PreferencesGroup::builder() + .title("Server key offered") + .visible(false) + .build(); + let offer_row = adw::ActionRow::builder().title("").build(); + offer_row.add_css_class("monospace"); + offer_row.set_subtitle_lines(2); + let offer_pin = button("Pin", "accent"); + offer_pin.set_valign(Align::Center); + let offer_discard = button("Discard", ""); + offer_discard.set_valign(Align::Center); + offer_row.add_suffix(&offer_discard); + offer_row.add_suffix(&offer_pin); + offer_group.add(&offer_row); + settings.add(&offer_group); + + let pins_group = adw::PreferencesGroup::builder() + .title("Server keys") + .build(); + settings.add(&pins_group); + + let network_group = adw::PreferencesGroup::builder().title("Network").build(); + let timeout_row = adw::SpinRow::with_range(1.0, 600.0, 1.0); + timeout_row.set_title("Timeout"); + timeout_row.set_subtitle("Seconds before a network call gives up"); + network_group.add(&timeout_row); + settings.add(&network_group); + + let backup_group = adw::PreferencesGroup::builder() + .title("Backup") + .description( + "Sealed to a key derived from the seed, so it restores only where this\n identity already lives. The seed itself is never in the file.", + ) + .build(); + let backup_actions = gtk::Box::new(Orientation::Horizontal, 6); + let export_button = icon_label_button("document-save-symbolic", "Export", ""); + let copy_export = icon_button("edit-copy-symbolic", "Copy the backup"); + backup_actions.append(©_export); + backup_actions.append(&export_button); + backup_group.set_header_suffix(Some(&backup_actions)); + let backup_label = label("", "mono caption"); + backup_label.set_wrap(true); + backup_label.set_wrap_mode(gtk::pango::WrapMode::Char); + backup_label.set_selectable(true); + let backup_scroll = gtk::ScrolledWindow::new(); + backup_scroll.add_css_class("card"); + backup_scroll.set_height_request(72); + backup_scroll.set_child(Some(&backup_label)); + backup_group.add(&backup_scroll); + settings.add(&backup_group); + + let import_group = adw::PreferencesGroup::builder() + .title("Restore") + .description("Paste a gsmol backup to restore the contacts, pins and mail it carries.") + .build(); + let import_view = gtk::TextView::new(); + import_view.set_wrap_mode(WrapMode::WordChar); + import_view.set_top_margin(8); + import_view.set_bottom_margin(8); + import_view.set_left_margin(8); + import_view.set_right_margin(8); + import_view.add_css_class("monospace"); + let import_body = import_view.buffer(); + let import_scroll = gtk::ScrolledWindow::new(); + import_scroll.add_css_class("card"); + import_scroll.set_height_request(96); + import_scroll.set_child(Some(&import_view)); + import_group.add(&import_scroll); + let import_backup = icon_label_button("document-open-symbolic", "Restore", ""); + import_backup.set_halign(Align::End); + import_backup.set_margin_top(6); + import_group.add(&import_backup); + settings.add(&import_group); + + stack.add_named(&settings, Some("settings")); + + // onboarding: three static modes, each a centred column + let center = |child: >k::Box| { + let outer = gtk::Box::new(Orientation::Vertical, 0); + outer.set_hexpand(true); + outer.set_vexpand(true); + child.set_halign(Align::Center); + child.set_valign(Align::Center); + child.set_width_request(520); + outer.append(child); + outer + }; + + let onboard_start = gtk::Box::new(Orientation::Vertical, 12); + let title = label("carrier", "title"); + title.set_halign(Align::Center); + onboard_start.append(&title); + let tagline = label("smolmail: one seed, sealed mail, five operations", "dim"); + tagline.set_halign(Align::Center); + tagline.set_margin_bottom(12); + onboard_start.append(&tagline); + let create_button = button("Create an identity", "pill"); + let restore_button = button("Restore from a seed", "pill-quiet"); + onboard_start.append(&create_button); + onboard_start.append(&restore_button); + stack.add_named(¢er(&onboard_start), Some("onboard")); + + let onboard_created = gtk::Box::new(Orientation::Vertical, 12); + let created_title = label("Write this seed down", "subtitle"); + onboard_created.append(&created_title); + onboard_created.append(&label( + "It is the only secret, and it is shown once.", + "dim", + )); + let onboard_seed_label = label("", "mono subtitle"); + onboard_seed_label.set_selectable(true); + onboard_seed_label.set_wrap(true); + onboard_seed_label.set_wrap_mode(gtk::pango::WrapMode::Char); + onboard_seed_label.set_hexpand(true); + onboard_seed_label.set_margin_top(12); + onboard_seed_label.set_margin_bottom(12); + onboard_seed_label.set_margin_start(12); + onboard_seed_label.set_margin_end(12); + let seed_card = gtk::Box::new(Orientation::Horizontal, 0); + seed_card.add_css_class("card"); + seed_card.append(&onboard_seed_label); + onboard_created.append(&seed_card); + let saved_button = button("I saved it", "pill"); + let created_back = button("Back", "pill-quiet"); + onboard_created.append(&saved_button); + onboard_created.append(&created_back); + stack.add_named(¢er(&onboard_created), Some("onboard-created")); + + let onboard_restore = gtk::Box::new(Orientation::Vertical, 12); + onboard_restore.append(&label("Restore from a seed", "subtitle")); + let restore_list = boxed_list(); + let onboard_seed_entry = adw::EntryRow::builder() + .title("Seed — 32 bytes, base32 or hex") + .build(); + restore_list.append(&onboard_seed_entry); + onboard_restore.append(&restore_list); + let restore_go = button("Restore", "pill"); + let restore_back = button("Back", "pill-quiet"); + onboard_restore.append(&restore_go); + onboard_restore.append(&restore_back); + stack.add_named(¢er(&onboard_restore), Some("onboard-restore")); + + // signup + let signup = gtk::Box::new(Orientation::Vertical, 12); + signup.append(&label("Set up the address", "subtitle")); + let signup_group = adw::PreferencesGroup::builder() + .description( + "The invite token and server key are only needed when the operator published them.", + ) + .build(); + let signup_user = adw::EntryRow::builder().title("User").build(); + let signup_host = adw::EntryRow::builder().title("Host").build(); + let signup_invite = adw::EntryRow::builder().title("Invite token").build(); + let signup_key = adw::EntryRow::builder().title("Server key").build(); + for row in [&signup_user, &signup_host, &signup_invite, &signup_key] { + signup_group.add(row); + } + signup.append(&signup_group); + let signup_learn = button("Learn the server key and pin what it presents", ""); + signup.append(&signup_learn); + let signup_offer_list = boxed_list(); + signup_offer_list.set_visible(false); + let signup_offer_row = adw::ActionRow::builder() + .title("Server key offered") + .build(); + signup_offer_row.set_subtitle_lines(2); + let signup_offer_pin = button("Pin", "accent"); + signup_offer_pin.set_valign(Align::Center); + let signup_offer_discard = button("Discard", ""); + signup_offer_discard.set_valign(Align::Center); + signup_offer_row.add_suffix(&signup_offer_discard); + signup_offer_row.add_suffix(&signup_offer_pin); + signup_offer_list.append(&signup_offer_row); + signup.append(&signup_offer_list); + let register_button = button("Register", "pill"); + let recall_button = button("I already have this address", "pill-quiet"); + signup.append(®ister_button); + signup.append(&recall_button); + let signup_back = button("Back", "quiet"); + signup_back.set_visible(false); + signup.append(&signup_back); + stack.add_named(¢er(&signup), Some("signup")); + + // --- status bar ------------------------------------------------- + // Work in progress gets its own bottom bar, revealed only while + // something runs; finished notes speak through a toast instead, so + // the chrome is present exactly as long as the work is. + let status_bar = gtk::Box::new(Orientation::Horizontal, 10); + status_bar.add_css_class("toolbar"); + let spinner = gtk::Spinner::new(); + let status_label = label("", "caption"); + status_label.set_hexpand(true); + let cancel_button = button("Cancel", ""); + cancel_button.set_visible(false); + status_bar.append(&spinner); + status_bar.append(&status_label); + status_bar.append(&cancel_button); + + let toolbar = adw::ToolbarView::new(); + toolbar.set_content(Some(&stack)); + toolbar.add_bottom_bar(&status_bar); + toolbar.set_reveal_bottom_bars(false); + + // The columns split is a paned too: the divider between sidebar + // and main box drags, extra space goes to the main box. + let columns = gtk::Paned::new(Orientation::Horizontal); + columns.set_start_child(Some(&sidebar)); + columns.set_end_child(Some(&toolbar)); + columns.set_resize_start_child(false); + columns.set_position(225); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&columns)); + root.set_child(Some(&toasts)); + + // --- signal wiring --------------------------------------------- + // Both sidebar lists route through row-activated, which fires only + // on a click or Enter: the programmatic `select_row` in `render` + // would otherwise feed its own selection straight back as input. + let folder_sender = sender.clone(); + folders.connect_row_activated(move |_, row| { + let folder = match row.index() { + 0 => Folder::Inbox, + 1 => Folder::Requests, + 2 => Folder::Sent, + _ => return, + }; + folder_sender.input(Message::GotoMail(folder)); + }); + let settings_sender = sender.clone(); + settings_nav.connect_row_activated(move |_, _| { + settings_sender.input(Message::Goto(Page::Settings)); + }); + fetch_button.connect_clicked(input(Message::FetchPressed)); + list_empty_fetch.connect_clicked(input(Message::FetchPressed)); + write_button.connect_clicked(input(Message::ComposePressed)); + cancel_button.connect_clicked(input(Message::FetchCancelled)); + + // The mail and contact lists carry their target in the row's + // widget name, which row-activated reads back. + let mail_sender = sender.clone(); + mail_list.connect_row_activated(move |_, row| { + if let Some((folder, id)) = row.widget_name().split_once('|') { + let folder = match folder { + "0" => Folder::Inbox, + "1" => Folder::Requests, + "2" => Folder::Sent, + _ => return, + }; + mail_sender.input(Message::MessageSelected(folder, id.to_string())); + } + }); + let contact_sender = sender.clone(); + contacts_list.connect_row_activated(move |_, row| { + contact_sender.input(Message::ContactSelected(row.widget_name().to_string())); + }); + + compose_send_button.connect_clicked(input(Message::SendPressed)); + compose_cancel.connect_clicked(input(Message::ComposeClosed)); + let to_sender = sender.clone(); + compose_to.connect_changed(move |entry| { + to_sender.input(Message::ComposeToChanged(entry.text().to_string())) + }); + let subject_sender = sender.clone(); + compose_subject.connect_changed(move |entry| { + subject_sender.input(Message::ComposeSubjectChanged(entry.text().to_string())) + }); + let body_sender = sender.clone(); + compose_body.connect_changed(move |buffer| { + body_sender.input(Message::ComposeBodyChanged(buffer_text(buffer))) + }); + + own_copy.connect_clicked(input(Message::CopyOwnAddress)); + own_copy_small.connect_clicked(input(Message::CopyOwnAddress)); + copy_export.connect_clicked(input(Message::CopyExport)); + import_button.connect_clicked(input(Message::ContactImportPressed)); + let import_sender = sender.clone(); + contact_import.connect_changed(move |entry| { + import_sender.input(Message::ContactImportChanged(entry.text().to_string())) + }); + let import_entry_sender = sender.clone(); + contact_import.connect_entry_activated(move |_| { + import_entry_sender.input(Message::ContactImportPressed) + }); + + rotate_button.connect_clicked(input(Message::RotatePressed)); + learn_button.connect_clicked(input(Message::SettingsLearnKey)); + offer_pin.connect_clicked(input(Message::PinAccept)); + signup_offer_pin.connect_clicked(input(Message::PinAccept)); + offer_discard.connect_clicked(input(Message::PinDiscard)); + signup_offer_discard.connect_clicked(input(Message::PinDiscard)); + export_button.connect_clicked(input(Message::ExportPressed)); + import_backup.connect_clicked(input(Message::ImportPressed)); + let timeout_sender = sender.clone(); + timeout_row.connect_value_notify(move |row| { + timeout_sender.input(Message::TimeoutChanged((row.value() as u64).to_string())) + }); + let import_body_sender = sender.clone(); + import_body.connect_changed(move |buffer| { + import_body_sender.input(Message::ImportBodyChanged(buffer_text(buffer))) + }); + + create_button.connect_clicked(input(Message::OnboardCreate)); + restore_button.connect_clicked(input(Message::OnboardRestore)); + restore_back.connect_clicked(input(Message::OnboardBack)); + created_back.connect_clicked(input(Message::OnboardBack)); + saved_button.connect_clicked(input(Message::OnboardSeedSaved)); + let seed_sender = sender.clone(); + onboard_seed_entry.connect_changed(move |entry| { + seed_sender.input(Message::OnboardSeedChanged(entry.text().to_string())) + }); + let seed_go_sender = sender.clone(); + onboard_seed_entry.connect_entry_activated(move |_| { + seed_go_sender.input(Message::OnboardRestoreFromSeed) + }); + restore_go.connect_clicked(input(Message::OnboardRestoreFromSeed)); + + let user_sender = sender.clone(); + signup_user.connect_changed(move |entry| { + user_sender.input(Message::SignupUserChanged(entry.text().to_string())) + }); + let host_sender = sender.clone(); + signup_host.connect_changed(move |entry| { + host_sender.input(Message::SignupHostChanged(entry.text().to_string())) + }); + let invite_sender = sender.clone(); + signup_invite.connect_changed(move |entry| { + invite_sender.input(Message::SignupInviteChanged(entry.text().to_string())) + }); + let key_sender = sender.clone(); + signup_key.connect_changed(move |entry| { + key_sender.input(Message::SignupKeyChanged(entry.text().to_string())) + }); + signup_learn.connect_clicked(input(Message::SignupLearnKey)); + register_button.connect_clicked(input(Message::SignupRegister)); + recall_button.connect_clicked(input(Message::SignupRecall)); + signup_back.connect_clicked(input(Message::SignupBack)); + + CarrierWidgets { + window: root.clone(), + window_title, + toasts, + toolbar, + folders, + folder_counts, + settings_nav, + fetch_button, + write_button, + stack, + list_stack, + mail_list, + list_empty, + list_empty_fetch, + article_stack, + article_subject, + article_meta, + article_view, + article_body, + actions, + compose_to, + compose_subject, + compose_body, + compose_send, + sidebar_own, + sidebar_contacts: contacts_list, + own_label, + qr, + contacts_list: contacts_page_list, + identity_values, + offer_group, + offer_row, + pins_group, + pin_rows: Vec::new(), + timeout_row, + backup_label, + onboard_seed_label, + signup_back, + signup_offer_list, + signup_offer_row, + spinner, + status_label, + cancel_button, + dialog: None, + last_status: String::new(), + } + } +} + +impl Component for Carrier { + type CommandOutput = OpResult; + type Input = Message; + type Output = (); + type Init = std::path::PathBuf; + type Root = gtk::Window; + type Widgets = CarrierWidgets; + + fn init_root() -> Self::Root { + let window = gtk::Window::new(); + window.set_title(Some("carrier")); + window.set_default_size(1080, 640); + window + } + + fn init( + init: Self::Init, + root: Self::Root, + sender: ComponentSender, + ) -> ComponentParts { + let (model, op) = Carrier::boot(init); + let widgets = CarrierWidgets::new(&root, &sender); + if let Some(op) = op { + spawn_op(&model, op, &sender); + } + ComponentParts { model, widgets } + } + + fn update(&mut self, message: Self::Input, sender: ComponentSender, _root: &Self::Root) { + for op in self.handle(message) { + spawn_op(self, op, &sender); + } + } + + fn update_cmd( + &mut self, + message: Self::CommandOutput, + sender: ComponentSender, + _root: &Self::Root, + ) { + if let Some(op) = self.handle_result(message) { + spawn_op(self, op, &sender); + } + } + + fn update_view(&self, widgets: &mut Self::Widgets, sender: ComponentSender) { + render(self, widgets, &sender); + } +} + +/// Runs one `PendingOp` on the blocking pool and reports back. The fetch +/// also gets a ticker: a thread that re-lists the store every 250 ms until +/// the model raises the cancel flag, giving the list live progress. +fn spawn_op(model: &Carrier, op: PendingOp, sender: &ComponentSender) { + let store = model.store.clone(); + let master = model.master.unwrap_or([0u8; KEY_LEN]); + let timeout = model.timeout; + match op { + PendingOp::Snapshot { read } => sender.spawn_command(move |out| { + let _ = out.send(OpResult::SnapshotLoaded(ops::snapshot( + &store, &master, &read, + ))); + }), + PendingOp::Register { addr, invite, key } => sender.spawn_command(move |out| { + let res = if key.is_empty() { + ops::register(&store, &master, &addr, invite.as_deref(), timeout) + } else { + ops::parse_key(&key).and_then(|key| { + ops::pin(&store, &addr, &key)?; + ops::register(&store, &master, &addr, invite.as_deref(), timeout) + }) + }; + let _ = out.send(OpResult::Generic(res.map(|_| "registered".to_string()))); + }), + PendingOp::Restore { addr } => sender.spawn_command(move |out| { + let res = ops::restore(&store, &master, &addr, timeout) + .map(|index| format!("restored at rotation {index}")); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::LearnKey { addr } => sender.spawn_command(move |out| { + let offered = + ops::learn_key(&store, &addr, 30).map(|key| key.map(|key| (addr.clone(), key))); + let _ = out.send(OpResult::PinOffered(offered)); + }), + PendingOp::Pin { addr, key } => sender.spawn_command(move |out| { + let _ = out.send(OpResult::Pinned(ops::pin(&store, &addr, &key))); + }), + PendingOp::Fetch { cancel } => { + let ticker_cancel = cancel.clone(); + let input = sender.input_sender().clone(); + std::thread::spawn(move || { + while !ticker_cancel.load(Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(250)); + if ticker_cancel.load(Ordering::Relaxed) { + break; + } + let _ = input.send(Message::FetchTick); + } + }); + sender.spawn_command(move |out| { + let _ = out.send(OpResult::Generic(ops::fetch( + &store, &master, cancel, timeout, + ))); + }); + } + PendingOp::Send { + to, + subject, + body, + reply_to, + } => sender.spawn_command(move |out| { + let res = ops::send( + &store, + &master, + &to, + if subject.is_empty() { + None + } else { + Some(subject.as_str()) + }, + &body, + reply_to.as_deref(), + timeout, + ); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::Delete { + id, + folder, + everywhere, + } => sender.spawn_command(move |out| { + let res = ops::delete(&store, &master, &id, folder, everywhere, timeout) + .map(|_| "deleted".to_string()); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::Accept { address, identity } => sender.spawn_command(move |out| { + let res = ops::accept_contact(&store, &master, &address, &identity, timeout) + .map(|_| format!("accepted {address}")); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::Block { address } => sender.spawn_command(move |out| { + let res = ops::block_contact(&store, &master, &address, timeout).map(|blocked| { + if blocked { + format!("blocked {address}") + } else { + format!("{address} was not blocked") + } + }); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::ImportContact { uri } => sender.spawn_command(move |out| { + let res = ops::import_contact(&store, &uri) + .map(|(address, _)| format!("imported {address} (verified)")); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::Rotate => sender.spawn_command(move |out| { + let res = ops::rotate(&store, &master, timeout) + .map(|(index, key)| format!("rotated to index {index}; new key {}…", &key[..12])); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::Unpin { label } => sender.spawn_command(move |out| { + let res = ops::unpin(&store, &label).map(|_| format!("unpinned {label}")); + let _ = out.send(OpResult::Generic(res)); + }), + PendingOp::Export { dir } => sender.spawn_command(move |out| { + let exported = ops::export_backup(&store, &master, &dir) + .map(|(path, text)| (format!("backup written to {}", path.display()), text)); + let _ = out.send(OpResult::Exported(exported)); + }), + PendingOp::ImportBackup { text } => sender.spawn_command(move |out| { + let _ = out.send(OpResult::Generic(ops::import_backup( + &store, &master, &text, + ))); + }), + } +} +/// The coarse re-render: called after every input and result. Plain state +/// reads map onto widget updates; lists are rebuilt from scratch, which +/// a modest mailbox makes the honest option. +fn render(model: &Carrier, w: &mut CarrierWidgets, sender: &ComponentSender) { + let input = |msg: Message| { + let sender = sender.clone(); + move |_: >k::Button| sender.input(msg.clone()) + }; + + // Prefill entries once after a reply or write-to-contact; afterwards + // typing flows entry -> model only, so the view never fights the user. + if model.compose_fresh.take() { + if let Some(compose) = &model.compose { + w.compose_to.set_text(&compose.to); + w.compose_subject.set_text(&compose.subject); + w.compose_body.set_text(&compose.body); + } + } + + // Header and sidebar identity. + let address = model + .snapshot + .as_ref() + .map(|s| s.who.address.clone()) + .unwrap_or_default(); + w.window_title.set_subtitle(&address); + w.sidebar_own.set_text(&address); + + // Sidebar destinations: the active one carries the list selection, and + // the list that does not own the current page clears its own. + let unread = |folder: usize| { + model + .lists + .get(folder) + .map(|rows| rows.iter().filter(|r| r.unread).count()) + .unwrap_or(0) + }; + for (count, value) in w.folder_counts.iter().zip([unread(0), unread(1), 0]) { + count.set_text(&value.to_string()); + count.set_visible(value > 0); + } + if model.page == Page::Mail { + w.folders + .select_row(w.folders.row_at_index(model.folder as i32).as_ref()); + w.settings_nav.unselect_all(); + } else if model.page == Page::Settings { + w.folders.unselect_all(); + w.settings_nav + .select_row(w.settings_nav.row_at_index(0).as_ref()); + } else { + w.folders.unselect_all(); + w.settings_nav.unselect_all(); + } + let fetching = model.busy.as_deref() == Some("fetching"); + w.fetch_button.set_sensitive(model.busy.is_none()); + w.write_button.set_sensitive(model.busy.is_none()); + + // Page stack. + let page = if model.compose.is_some() { + "compose" + } else { + match model.page { + Page::Onboard => match model.onboard.mode { + OnboardMode::Start => "onboard", + OnboardMode::Created => "onboard-created", + OnboardMode::Restore => "onboard-restore", + }, + Page::Signup => "signup", + Page::Mail => "mail", + Page::Contacts => "contacts", + Page::Settings => "settings", + } + }; + w.stack.set_visible_child_name(page); + + // Mail list. + clear_list(&w.mail_list); + let rows = model + .lists + .get(model.folder as usize) + .cloned() + .unwrap_or_default(); + let (icon, title, description) = match model.folder { + Folder::Inbox => ( + "mail-read-symbolic", + "Inbox is empty", + "Fetch to check the mailbox.", + ), + Folder::Requests => ( + "contact-new-symbolic", + "No requests", + "Mail from senders you have not accepted yet arrives here.", + ), + Folder::Sent => ( + "mail-send-symbolic", + "Nothing sent yet", + "Messages you send keep a sealed copy here.", + ), + }; + w.list_empty.set_icon_name(Some(icon)); + w.list_empty.set_title(title); + w.list_empty.set_description(Some(description)); + w.list_empty_fetch + .set_visible(model.folder == Folder::Inbox); + w.list_stack + .set_visible_child_name(if rows.is_empty() { "empty" } else { "list" }); + let mut selected_row = None; + for row in &rows { + let item = mail_row(model.folder, row); + let is_selected = model + .open + .as_ref() + .is_some_and(|(folder, msg)| *folder == model.folder && msg.id == row.id); + if is_selected { + selected_row = Some(item.clone()); + } + w.mail_list.append(&item); + } + w.mail_list.select_row( + selected_row + .as_ref() + .map(|row| row.upcast_ref::()), + ); + + // Article. + let contact_detail = model.selected_contact.as_ref().and_then(|address| { + model + .snapshot + .as_ref()? + .contacts + .iter() + .find(|c| &c.address == address) + }); + match (&model.open, contact_detail) { + (Some((folder, msg)), _) => { + w.article_stack.set_visible_child_name("detail"); + // A message body is prose; only the contact detail below is a + // column of keys that has to stay aligned. + w.article_view.remove_css_class("monospace"); + let subject = if msg.subject.is_empty() { + "(no subject)".to_string() + } else { + msg.subject.clone() + }; + w.article_subject.set_text(&subject); + let label = if *folder == Folder::Sent { + "to" + } else { + "from" + }; + let mut meta = format!("{label} {} · {} UTC", msg.from, ops::when(msg.time)); + meta.push_str(&format!(" · id {}…", &msg.id[..msg.id.len().min(8)])); + if msg.kept { + meta.push_str(" · a server copy still exists"); + } + w.article_meta.set_text(&meta); + w.article_body.set_text(&msg.body); + + clear(&w.actions); + let reply = icon_label_button("mail-reply-sender-symbolic", "Reply", ""); + reply.connect_clicked(input(Message::ReplyPressed)); + w.actions.append(&reply); + if *folder == Folder::Requests { + if msg.reply_to.is_some() { + let accept = icon_label_button("object-select-symbolic", "Accept", "accent"); + accept.connect_clicked(input(Message::AcceptPressed)); + let block = icon_label_button("action-unavailable-symbolic", "Block", "danger"); + block.connect_clicked(input(Message::BlockPressed)); + w.actions.append(&accept); + w.actions.append(&block); + } else { + w.actions.append(&crate::view::label( + "no reply address: cannot accept or block", + "caption", + )); + } + } + let spacer = gtk::Label::new(None); + spacer.set_hexpand(true); + w.actions.append(&spacer); + let delete = icon_label_button("user-trash-symbolic", "Delete", "danger"); + delete.connect_clicked(input(Message::DeletePressed)); + w.actions.append(&delete); + } + (None, Some(contact)) => { + w.article_stack.set_visible_child_name("detail"); + w.article_view.add_css_class("monospace"); + w.article_subject.set_text(&contact.address); + w.article_meta.set_text(contact_state(contact)); + w.article_body.set_text(&format!( + "key {}\nfingerprint {}\nsmol://{}/{}", + contact.key, contact.key, contact.address, contact.key + )); + + clear(&w.actions); + let write = icon_label_button("mail-message-new-symbolic", "Write", ""); + let write_msg = Message::ContactWrite(contact.address.clone()); + let write_sender = sender.clone(); + write.connect_clicked(move |_| write_sender.input(write_msg.clone())); + w.actions.append(&write); + let spacer = gtk::Label::new(None); + spacer.set_hexpand(true); + w.actions.append(&spacer); + match contact.accepted { + Some(true) => { + let block = icon_label_button("action-unavailable-symbolic", "Block", "danger"); + let msg = Message::ContactBlock(contact.address.clone()); + let block_sender = sender.clone(); + block.connect_clicked(move |_| block_sender.input(msg.clone())); + w.actions.append(&block); + } + Some(false) => { + let unblock = icon_label_button("object-select-symbolic", "Unblock", "accent"); + let msg = Message::ContactUnblock(contact.address.clone()); + let unblock_sender = sender.clone(); + unblock.connect_clicked(move |_| unblock_sender.input(msg.clone())); + w.actions.append(&unblock); + } + None => { + let accept = icon_label_button("object-select-symbolic", "Accept", "accent"); + let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone()); + let accept_sender = sender.clone(); + accept.connect_clicked(move |_| accept_sender.input(msg.clone())); + w.actions.append(&accept); + } + } + } + (None, None) => { + w.article_stack.set_visible_child_name("empty"); + clear(&w.actions); + } + } + + // Compose: a reply says so, on the button and in its icon. + if let Some(compose) = &model.compose { + let replying = compose.reply_to.is_some(); + w.compose_send + .set_label(if replying { "Reply" } else { "Send" }); + w.compose_send.set_icon_name(if replying { + "mail-reply-sender-symbolic" + } else { + "mail-send-symbolic" + }); + } + + // Contacts: the sidebar's bottom panel lists them; the selected one + // carries the list's selection so the detail view has a visible anchor. + clear_list(&w.sidebar_contacts); + if let Some(snap) = &model.snapshot { + if snap.contacts.is_empty() { + let empty = gtk::ListBoxRow::new(); + empty.set_activatable(false); + let hint = label("no contacts", "caption"); + hint.set_margin_top(6); + hint.set_margin_bottom(6); + hint.set_margin_start(12); + empty.set_child(Some(&hint)); + w.sidebar_contacts.append(&empty); + } + let mut selected_contact_row = None; + for contact in &snap.contacts { + let item = sidebar_contact_row(contact); + if model.selected_contact.as_deref() == Some(contact.address.as_str()) { + selected_contact_row = Some(item.clone()); + } + w.sidebar_contacts.append(&item); + } + w.sidebar_contacts.select_row( + selected_contact_row + .as_ref() + .map(|row| row.upcast_ref::()), + ); + } + if let Some(snap) = &model.snapshot { + let uri = format!("smol://{}/{}", snap.who.address, snap.who.key); + w.own_label.set_text(&uri); + if let Some(texture) = qr_texture(&uri) { + w.qr.set_paintable(Some(&texture)); + } + } + clear_list(&w.contacts_list); + if let Some(snap) = &model.snapshot { + if snap.contacts.is_empty() { + let empty = adw::ActionRow::builder() + .title("No contacts yet") + .subtitle("Import a smol:// address above to add one.") + .build(); + w.contacts_list.append(&empty); + } + for contact in &snap.contacts { + w.contacts_list.append(&contact_row(contact, sender)); + } + } + + // Settings: the identity rows are fixed, so only their values change. + if let Some(snap) = &model.snapshot { + let values = [ + snap.who.address.clone(), + snap.who.key.clone(), + snap.who.fingerprint.clone(), + snap.who.rotations.to_string(), + ]; + for (value, text) in w.identity_values.iter().zip(values) { + value.set_text(&text); + } + } + if let Some((_, key)) = &model.pin_offer { + w.offer_row.set_title(&escape(&fingerprint(key))); + w.offer_row.set_subtitle(&escape(&b32(key))); + w.offer_group.set_visible(true); + w.signup_offer_row.set_subtitle(&escape(&fingerprint(key))); + w.signup_offer_list.set_visible(true); + } else { + w.offer_group.set_visible(false); + w.signup_offer_list.set_visible(false); + } + if w.timeout_row.value() as u64 != model.timeout { + w.timeout_row.set_value(model.timeout as f64); + } + for row in std::mem::take(&mut w.pin_rows) { + w.pins_group.remove(&row); + } + if let Some(snap) = &model.snapshot { + if snap.pins.is_empty() { + let empty = adw::ActionRow::builder() + .title("No server keys pinned") + .build(); + w.pins_group.add(&empty); + w.pin_rows.push(empty); + } + for (pin_label, key) in &snap.pins { + let row = adw::ActionRow::builder() + .title(escape(pin_label)) + .subtitle(escape(&b32(key))) + .build(); + row.set_subtitle_lines(1); + let unpin = icon_button("user-trash-symbolic", "Unpin this server key"); + unpin.add_css_class("destructive-action"); + unpin.connect_clicked(input(Message::UnpinPressed(pin_label.clone()))); + row.add_suffix(&unpin); + w.pins_group.add(&row); + w.pin_rows.push(row); + } + } + let backup_text = model + .backup_text + .as_ref() + .map(|text| text.chars().take(2000).collect::()) + .unwrap_or_default(); + w.backup_label.set_text(&backup_text); + + // Onboarding. + if model.onboard.mode == OnboardMode::Created { + w.onboard_seed_label.set_text(&model.onboard.seed_shown); + } + + // Signup. + w.signup_back.set_visible(model.signup.fresh); + + // Confirmations are presented once, on the None -> Some edge: `render` + // runs after every message and an alert dialog is modal, so presenting + // again would stack a second copy over the first. + match (&model.dialog, w.dialog.is_some()) { + (Some(dialog), false) => { + let rotating = matches!(dialog, Dialog::Rotate); + let alert = match dialog { + Dialog::Delete { kept, .. } => { + let alert = adw::AlertDialog::new( + Some("Delete this message?"), + Some(if *kept { + "Deleting it here leaves the server's copy in place. Deleting it everywhere asks the server to drop that copy too." + } else { + "The sealed copy in the local store is removed." + }), + ); + alert.add_response("cancel", "Cancel"); + alert.add_response("here", "Delete"); + alert.set_response_appearance("here", adw::ResponseAppearance::Destructive); + if *kept { + alert.add_response("everywhere", "Delete Everywhere"); + alert.set_response_appearance( + "everywhere", + adw::ResponseAppearance::Destructive, + ); + } + alert + } + Dialog::Rotate => { + let alert = adw::AlertDialog::new( + Some("Rotate the identity?"), + Some("The old key stays derivable from the seed, but the rotation index never moves back."), + ); + alert.add_response("cancel", "Cancel"); + alert.add_response("rotate", "Rotate"); + alert.set_response_appearance("rotate", adw::ResponseAppearance::Destructive); + alert + } + }; + alert.set_close_response("cancel"); + alert.set_default_response(Some("cancel")); + let response_sender = sender.clone(); + alert.connect_response(None, move |_, response| { + response_sender.input(match (rotating, response) { + (true, "rotate") => Message::RotateConfirmed, + (true, _) => Message::RotateCancelled, + (false, "here") => Message::DeleteConfirmed(false), + (false, "everywhere") => Message::DeleteConfirmed(true), + (false, _) => Message::DeleteCancelled, + }); + }); + alert.present(Some(&w.window)); + w.dialog = Some(alert); + } + (None, true) => { + // Drop the handle before closing: `close` emits the close + // response, which would otherwise arrive as a second cancel + // and bring us straight back here. + if let Some(alert) = w.dialog.take() { + alert.close(); + } + } + _ => {} + } + + // The bottom bar carries work in progress and nothing else, so it is + // revealed exactly as long as something runs. + w.toolbar.set_reveal_bottom_bars(model.busy.is_some()); + if model.busy.is_some() { + w.spinner.start(); + } else { + w.spinner.stop(); + } + w.status_label.set_text(&match (&model.busy, fetching) { + (Some(_), true) => { + let inbox = model.lists.first().map(|rows| rows.len()).unwrap_or(0); + format!("fetching… {inbox} in inbox") + } + (Some(busy), false) => format!("{busy}…"), + (None, _) => String::new(), + }); + w.cancel_button.set_visible(fetching); + + // A finished result speaks once, as a toast. Errors hold until they are + // dismissed; everything else fades on its own. + let note = match (&model.busy, &model.status) { + (None, Some(status)) => status.clone(), + _ => String::new(), + }; + if !note.is_empty() && note != w.last_status { + let toast = adw::Toast::new(¬e); + if model.status_error { + toast.set_timeout(0); + } + w.toasts.add_toast(toast); + } + w.last_status = note; +} + +/// One list row, like a note title: the read flag and subject on the left, +/// sender and date muted on the right. An action row supplies the metrics +/// and the selection paint the stylesheet expects of a list. +fn mail_row(folder: Folder, row: &Row) -> adw::ActionRow { + let item = adw::ActionRow::new(); + item.add_prefix(>k::Image::from_icon_name(if row.unread { + "mail-unread-symbolic" + } else { + "mail-read-symbolic" + })); + let subject = if row.subject.is_empty() { + "(no subject)".to_string() + } else { + row.subject.clone() + }; + // Action row titles are markup, so unread weight goes in as a span + // rather than a class, which would take the suffixes with it. + item.set_title(&if row.unread { + format!("{}", escape(&subject)) + } else { + escape(&subject) + }); + item.set_title_lines(1); + if !row.unread { + item.add_css_class("dim-label"); + } + let from = label(&truncate(&row.from, 24), "caption"); + from.set_valign(Align::Center); + let date = label(&ops::when(row.at), "caption numeric"); + date.set_valign(Align::Center); + item.add_suffix(&from); + item.add_suffix(&date); + item.set_activatable(true); + // The list's row-activated signal routes the click; the row carries + // its folder and id in the widget name for the handler to read. + item.set_widget_name(&format!("{}|{}", folder as usize, row.id)); + item +} + +/// One contact in the sidebar's bottom panel: the address over its trust +/// state; activating it opens the detail pane. +fn sidebar_contact_row(contact: &Contact) -> adw::ActionRow { + let item = adw::ActionRow::builder() + .title(escape(&contact.address)) + .subtitle(contact_state(contact)) + .activatable(true) + .build(); + item.set_title_lines(1); + item.set_subtitle_lines(1); + item.set_widget_name(&contact.address); + item +} + +/// One contact on the contacts page: the address over its key, the trust +/// state and the actions that state allows. +fn contact_row(contact: &Contact, sender: &ComponentSender) -> adw::ActionRow { + let row = adw::ActionRow::builder() + .title(escape(&contact.address)) + .subtitle(escape(&contact.key)) + .build(); + row.set_title_lines(1); + row.set_subtitle_lines(1); + let state = label(contact_state(contact), "caption"); + state.set_valign(Align::Center); + row.add_suffix(&state); + + let write = icon_button("mail-message-new-symbolic", "Write to this contact"); + let write_msg = Message::ContactWrite(contact.address.clone()); + let write_sender = sender.clone(); + write.connect_clicked(move |_| write_sender.input(write_msg.clone())); + row.add_suffix(&write); + + match contact.accepted { + Some(true) => { + let block = icon_button("action-unavailable-symbolic", "Block this contact"); + let msg = Message::ContactBlock(contact.address.clone()); + let block_sender = sender.clone(); + block.connect_clicked(move |_| block_sender.input(msg.clone())); + row.add_suffix(&block); + } + Some(false) => { + let unblock = icon_button("object-select-symbolic", "Unblock this contact"); + let msg = Message::ContactUnblock(contact.address.clone()); + let unblock_sender = sender.clone(); + unblock.connect_clicked(move |_| unblock_sender.input(msg.clone())); + row.add_suffix(&unblock); + } + None => { + let accept = icon_button("object-select-symbolic", "Accept this contact"); + let msg = Message::ContactAccept(contact.address.clone(), contact.key.clone()); + let accept_sender = sender.clone(); + accept.connect_clicked(move |_| accept_sender.input(msg.clone())); + row.add_suffix(&accept); + } + } + row +} + +/// The own-address QR as an RGBA texture; black on white, the form every +/// scanner expects, taking no cues from the theme. +fn qr_texture(uri: &str) -> Option { + let code = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::Medium).ok()?; + let size = code.size(); + let stride = size as usize * 4; + let mut pixels = Vec::with_capacity(size as usize * size as usize * 4); + for y in 0..size { + for x in 0..size { + let dark = code.get_module(x, y); + let (r, g, b) = if dark { (0, 0, 0) } else { (0xff, 0xff, 0xff) }; + pixels.extend_from_slice(&[r, g, b, 0xff]); + } + } + Some(gtk::gdk::MemoryTexture::new( + size, + size, + gtk::gdk::MemoryFormat::R8g8b8a8, + >k::glib::Bytes::from_owned(pixels), + stride, + )) +}