From ecb35dd5b5fc770be825d780c06d4a89f2883cbf Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sun, 4 Oct 2026 00:19:50 +0200 Subject: [PATCH] Remove the Slint front-end It has been untouched since 2026-09-21 while the web front-end got every feature and the Pi performance work since (thumbnails, ?pi=1, number keys, clock, volume steps). Keeping it meant a second, already stale copy of the search, keymap, Home Assistant client and API types. Nothing outside slint-frontend/ referenced it. Co-Authored-By: Claude Opus 5.5 --- slint-frontend/.gitignore | 1 - slint-frontend/Cargo.lock | 5908 ------------------------ slint-frontend/Cargo.toml | 65 - slint-frontend/README.md | 192 - slint-frontend/build.rs | 3 - slint-frontend/fonts/Nunito.ttf | Bin 276932 -> 0 bytes slint-frontend/fonts/README.md | 9 - slint-frontend/shell.nix | 62 - slint-frontend/src/api.rs | 334 -- slint-frontend/src/covers.rs | 406 -- slint-frontend/src/format.rs | 61 - slint-frontend/src/ha.rs | 319 -- slint-frontend/src/keymap.rs | 915 ---- slint-frontend/src/library.rs | 504 -- slint-frontend/src/main.rs | 1363 ------ slint-frontend/src/oklch.rs | 95 - slint-frontend/src/ws.rs | 97 - slint-frontend/tools/headless-shots.sh | 56 - slint-frontend/ui/app.slint | 347 -- slint-frontend/ui/browse.slint | 567 --- slint-frontend/ui/models.slint | 151 - slint-frontend/ui/player.slint | 413 -- slint-frontend/ui/room.slint | 460 -- slint-frontend/ui/theme.slint | 107 - slint-frontend/ui/widgets.slint | 299 -- 25 files changed, 12734 deletions(-) delete mode 100644 slint-frontend/.gitignore delete mode 100644 slint-frontend/Cargo.lock delete mode 100644 slint-frontend/Cargo.toml delete mode 100644 slint-frontend/README.md delete mode 100644 slint-frontend/build.rs delete mode 100644 slint-frontend/fonts/Nunito.ttf delete mode 100644 slint-frontend/fonts/README.md delete mode 100644 slint-frontend/shell.nix delete mode 100644 slint-frontend/src/api.rs delete mode 100644 slint-frontend/src/covers.rs delete mode 100644 slint-frontend/src/format.rs delete mode 100644 slint-frontend/src/ha.rs delete mode 100644 slint-frontend/src/keymap.rs delete mode 100644 slint-frontend/src/library.rs delete mode 100644 slint-frontend/src/main.rs delete mode 100644 slint-frontend/src/oklch.rs delete mode 100644 slint-frontend/src/ws.rs delete mode 100755 slint-frontend/tools/headless-shots.sh delete mode 100644 slint-frontend/ui/app.slint delete mode 100644 slint-frontend/ui/browse.slint delete mode 100644 slint-frontend/ui/models.slint delete mode 100644 slint-frontend/ui/player.slint delete mode 100644 slint-frontend/ui/room.slint delete mode 100644 slint-frontend/ui/theme.slint delete mode 100644 slint-frontend/ui/widgets.slint diff --git a/slint-frontend/.gitignore b/slint-frontend/.gitignore deleted file mode 100644 index eb5a316..0000000 --- a/slint-frontend/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target diff --git a/slint-frontend/Cargo.lock b/slint-frontend/Cargo.lock deleted file mode 100644 index bdb05be..0000000 --- a/slint-frontend/Cargo.lock +++ /dev/null @@ -1,5908 +0,0 @@ -# This file is automatically @generated by Cargo. -# 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 = "ahash" -version = "0.8.12" -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 = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[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", - "num_enum", - "thiserror 2.0.20", -] - -[[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 = "annotate-snippets" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" -dependencies = [ - "anstyle", - "memchr", - "unicode-width", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "arboard" -version = "3.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" -dependencies = [ - "clipboard-win", - "log", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "wl-clipboard-rs", - "x11rb", -] - -[[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.38.0+1.3.281" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" -dependencies = [ - "libloading", -] - -[[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-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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "serde", - "unty", -] - -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.13.2", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.3", - "shlex 1.3.0", - "syn 2.0.119", -] - -[[package]] -name = "bit-set" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -dependencies = [ - "serde_core", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -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 = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2 0.6.4", -] - -[[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 = "borsh" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" -dependencies = [ - "bytes", - "cfg_aliases", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "by_address" -version = "1.2.1" -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" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[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 = "cc" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex 2.0.1", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", -] - -[[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.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "cgl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" -dependencies = [ - "libc", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "clang-sys" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[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 = "clru" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "197fd99cb113a8d5d9b6376f3aa817f32c1078f2343b714fff7d2ca44fdf67d5" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "codespan-reporting" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" -dependencies = [ - "unicode-width", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[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-field-offset" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d535c69e75b4e3c69e17205b5b37a91936c54086cecd339b70071500786acb9c" -dependencies = [ - "const-field-offset-macro", - "field-offset", -] - -[[package]] -name = "const-field-offset-macro" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d5848843a221ac268cfdd6bfd14e68d5d3dfa79d52c880aafab93f9517fb74" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[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 = "countme" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -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 = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" -dependencies = [ - "crossbeam-utils", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "ctor" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" -dependencies = [ - "dtor", -] - -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", - "unicode-xid", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[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 = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[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 = "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "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 = "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 = "fastrand" -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 = "femtovg" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31eebf3ab1b76359ccd5f850c07a7c6b2722c64d9127c40e02358e83f3ff9b1a" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "fnv", - "glow", - "imgref", - "itertools 0.15.0", - "log", - "rgb", - "slotmap", - "swash", - "wasm-bindgen", - "web-sys", -] - -[[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 = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" - -[[package]] -name = "fixed_decimal" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" -dependencies = [ - "displaydoc", - "smallvec", - "writeable", -] - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "flate2" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" -dependencies = [ - "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", -] - -[[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "font-types" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "fontdb" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" -dependencies = [ - "log", - "slotmap", - "tinyvec", -] - -[[package]] -name = "fontique" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a" -dependencies = [ - "hashbrown 0.17.1", - "linebender_resource_handle", - "memmap2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-text", - "objc2-foundation 0.3.2", - "parlance", - "read-fonts", - "roxmltree", - "smallvec", - "windows", - "windows-core", - "yeslogic-fontconfig-sys", -] - -[[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 = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-executor" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "gbm" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce852e998d3ca5e4a97014fb31c940dc5ef344ec7d364984525fd11e8a547e6a" -dependencies = [ - "bitflags 2.13.2", - "drm", - "drm-fourcc", - "gbm-sys", - "libc", -] - -[[package]] -name = "gbm-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13a5f2acc785d8fb6bf6b7ab6bfb0ef5dad4f4d97e8e70bb8e470722312f76f" -dependencies = [ - "libc", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "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", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", -] - -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "glow" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" -dependencies = [ - "bitflags 2.13.2", - "cfg_aliases", - "cgl", - "dispatch2", - "glutin_egl_sys", - "glutin_glx_sys", - "glutin_wgl_sys", - "libloading", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "raw-window-handle", - "wayland-sys", - "windows-sys 0.52.0", - "x11-dl", -] - -[[package]] -name = "glutin-winit" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" -dependencies = [ - "cfg_aliases", - "glutin", - "raw-window-handle", - "winit", -] - -[[package]] -name = "glutin_egl_sys" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" -dependencies = [ - "gl_generator", - "windows-sys 0.52.0", -] - -[[package]] -name = "glutin_glx_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" -dependencies = [ - "gl_generator", - "x11-dl", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" -dependencies = [ - "gl_generator", -] - -[[package]] -name = "gpu-allocator" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" -dependencies = [ - "ash", - "hashbrown 0.16.1", - "log", - "presser", - "thiserror 2.0.20", - "windows", -] - -[[package]] -name = "grid" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "num-traits", - "zerocopy", -] - -[[package]] -name = "harfrust" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "read-fonts", - "smallvec", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "foldhash 0.2.0", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" - -[[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 = "htmlparser" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48ce8546b993eaf241d69ded33b1be6d205dd9857ec879d9d18bd05d3676e144" - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "i-slint-backend-linuxkms" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4f4679cd1788d9a7004c205c16c2fd7380ae27aeeb5ccfac2fb86849be92f10" -dependencies = [ - "bytemuck", - "calloop 0.14.4", - "cfg_aliases", - "drm", - "gbm", - "glutin", - "i-slint-common", - "i-slint-core", - "i-slint-renderer-femtovg", - "i-slint-renderer-skia", - "i-slint-renderer-software", - "input", - "libseat", - "memmap2", - "nix", - "raw-window-handle", - "xkbcommon", -] - -[[package]] -name = "i-slint-backend-selector" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aae6ba886a5085f34692a7021eb6225d2786ab2800ffbc8a1d20371456e736a" -dependencies = [ - "cfg-if", - "cfg_aliases", - "i-slint-backend-linuxkms", - "i-slint-backend-testing", - "i-slint-backend-winit", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "i-slint-renderer-femtovg", - "i-slint-renderer-skia", -] - -[[package]] -name = "i-slint-backend-testing" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2890ef1b9bead9f327d358aa925b6136ebe6b119fe5e302f6f06ef23453fa25" -dependencies = [ - "cfg_aliases", - "i-slint-common", - "i-slint-core", - "i-slint-renderer-skia", - "i-slint-renderer-software", - "vtable", -] - -[[package]] -name = "i-slint-backend-winit" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9639d746caa964a88ad764c32b6c5163e1cefd4b7443b3269162aa65c33c81d8" -dependencies = [ - "arboard", - "block2 0.6.2", - "bytemuck", - "cfg-if", - "cfg_aliases", - "futures", - "glutin", - "glutin-winit", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "i-slint-renderer-femtovg", - "i-slint-renderer-skia", - "i-slint-renderer-software", - "imgref", - "lyon_path", - "muda", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "objc2-ui-kit 0.3.2", - "pin-weak", - "raw-window-handle", - "rgb", - "scoped-tls-hkt", - "scopeguard", - "softbuffer", - "strum", - "vtable", - "wasm-bindgen", - "web-sys", - "webbrowser", - "windows", - "winit", - "zbus", -] - -[[package]] -name = "i-slint-common" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520fa2961d5c09f3630fd1bdd7e8df0250a40004e85ca3ee6202264dc412f2c5" -dependencies = [ - "derive_more", - "fontique", - "htmlparser", - "icu_decimal", - "icu_locale_core", - "icu_provider", - "pulldown-cmark", - "resvg", - "skrifa", -] - -[[package]] -name = "i-slint-compiler" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2412bc535ec506ca7917fdda20a2fb0a32cb03c5f9b215566d098b949d31b720" -dependencies = [ - "annotate-snippets", - "by_address", - "data-url", - "derive_more", - "i-slint-common", - "icu_normalizer", - "icu_properties", - "image", - "indexmap", - "itertools 0.15.0", - "lyon_extra", - "lyon_path", - "num_enum", - "proc-macro2", - "quote", - "rayon", - "resvg", - "rowan", - "rspolib", - "skrifa", - "smol_str 0.3.6", - "strum", - "swash", - "typed-index-collections", - "unicode-segmentation", - "url", -] - -[[package]] -name = "i-slint-core" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b8b730a020446b367e0033e6a1e17719df6b35fd4a65f723e86a7147fbc983" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "chrono", - "clru", - "const-field-offset", - "derive_more", - "euclid", - "i-slint-common", - "i-slint-core-macros", - "icu_normalizer", - "image", - "js-sys", - "lyon_algorithms", - "lyon_extra", - "lyon_geom", - "lyon_path", - "num-traits", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "once_cell", - "parley", - "pin-project", - "pin-weak", - "portable-atomic", - "raw-window-handle", - "resvg", - "rgb", - "scopeguard", - "skrifa", - "slab", - "strum", - "swash", - "sys-locale", - "taffy", - "unicode-linebreak", - "unicode-script", - "unicode-segmentation", - "vtable", - "wasm-bindgen", - "web-sys", - "web-time", - "wgpu", - "windows", -] - -[[package]] -name = "i-slint-core-macros" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cd88de59a6d5178643dc3a18e8b92f09c118d2c642cad7b8b16342b0d133a85" -dependencies = [ - "quote", - "serde_json", - "syn 3.0.6", -] - -[[package]] -name = "i-slint-renderer-femtovg" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d639b7c55640274d077f16136ad405ccf607bda1754cce3f02c47ec08d31f1d7" -dependencies = [ - "cfg-if", - "const-field-offset", - "femtovg", - "glow", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "imgref", - "lyon_path", - "pin-weak", - "rgb", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "i-slint-renderer-skia" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815501a47406f473af92b2666d79b4b15af28a3e9f22020e5a6da16331482ab8" -dependencies = [ - "bytemuck", - "cfg-if", - "cfg_aliases", - "clru", - "const-field-offset", - "glow", - "glutin", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "lyon_path", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", - "objc2-quartz-core 0.3.2", - "pin-weak", - "raw-window-handle", - "read-fonts", - "scoped-tls-hkt", - "skia-safe", - "softbuffer", - "spin_on", - "unicode-segmentation", - "vtable", - "wgpu", - "wgpu-hal", - "windows", - "windows-core", - "write-fonts", -] - -[[package]] -name = "i-slint-renderer-software" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeaa80487398676502661f4bfb4f665d53be0433b0ad83445ca3cca43ddc51b9" -dependencies = [ - "bytemuck", - "clru", - "derive_more", - "euclid", - "i-slint-common", - "i-slint-core", - "lyon_path", - "num-traits", - "skrifa", - "swash", - "zeno", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_decimal" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_decimal_data", - "icu_locale_core", - "icu_locale_fallback", - "icu_plurals", - "icu_provider", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_decimal_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4c887fc7d4c297cf3f9436864af9e3dba7f8be9415a6c2a7f392f3b1636298c" - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_plurals" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" -dependencies = [ - "fixed_decimal", - "icu_locale_fallback", - "icu_plurals_data", - "icu_provider", - "zerovec", -] - -[[package]] -name = "icu_plurals_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1251aa39a95e1333888e499b1263e1c196447a28948acf5bdabd82c046f153bf" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "serde", - "stable_deref_trait", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_segmenter" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" -dependencies = [ - "icu_collections", - "icu_locale_fallback", - "icu_provider", - "icu_segmenter_data", - "potential_utf", - "smallvec", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_segmenter_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imagesize" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" - -[[package]] -name = "imgref" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "input" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9793345a65d71317763a33066b5d8351f8760dde8d4930fe9e39b5f14a7959d" -dependencies = [ - "bitflags 2.13.2", - "input-sys", - "libc", - "log", - "udev", -] - -[[package]] -name = "input-sys" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eee07d8e02bd95bf52b2e642cf13d33701b94c6e4b04fbf1d1fb07e9cb19e7" - -[[package]] -name = "io-lifetimes" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2" -dependencies = [ - "hermit-abi 0.3.9", - "libc", - "windows-sys 0.48.0", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" -dependencies = [ - "either", -] - -[[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.20", - "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.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.13.2", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - -[[package]] -name = "kurbo" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" -dependencies = [ - "arrayvec", - "euclid", - "polycool", - "smallvec", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[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.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" -dependencies = [ - "bitflags 2.13.2", - "libc", - "plain", - "redox_syscall 0.9.4", -] - -[[package]] -name = "libseat" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6656c69b6e0366ffb83faa232f3e4fda5fc91161722d140f2c95ce2e90b1ef31" -dependencies = [ - "errno", - "libseat-sys", - "log", -] - -[[package]] -name = "libseat-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a484fa48be5f62a8d3ffed767779d0ab808cfead91de98ef4362d469097dfb" -dependencies = [ - "pkg-config", -] - -[[package]] -name = "libudev-sys" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "linebender_resource_handle" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[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_extra" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7755f08423275157ad1680aaecc9ccb7e0cc633da3240fea2d1522935cc15c72" -dependencies = [ - "lyon_path", - "thiserror 2.0.20", -] - -[[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 = "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[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 = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "muda" -version = "0.19.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" -dependencies = [ - "crossbeam-channel", - "dpi", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "once_cell", - "png", - "thiserror 2.0.20", - "windows-sys 0.61.2", -] - -[[package]] -name = "musicmouse-slint" -version = "0.1.0" -dependencies = [ - "dirs", - "image", - "serde", - "serde_json", - "slint", - "slint-build", - "tungstenite", - "unicode-normalization", - "ureq", -] - -[[package]] -name = "naga" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" -dependencies = [ - "arrayvec", - "bit-set", - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "codespan-reporting", - "half", - "hashbrown 0.17.1", - "indexmap", - "libm", - "log", - "naga-types", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "spirv", - "thiserror 2.0.20", - "unicode-ident", -] - -[[package]] -name = "naga-types" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" -dependencies = [ - "hashbrown 0.17.1", - "indexmap", - "rustc-hash 1.1.0", - "thiserror 2.0.20", -] - -[[package]] -name = "natord" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" - -[[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", - "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.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.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[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-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 0.5.1", - "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-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.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 0.5.1", - "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 0.5.1", - "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 0.5.1", - "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 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-core-location" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" -dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", - "objc2-contacts", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags 2.13.2", - "objc2-core-foundation", -] - -[[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 0.5.1", - "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", - "block2 0.6.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 0.5.1", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "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 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.13.2", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-foundation 0.3.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 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[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-core-graphics", - "objc2-foundation 0.3.2", - "objc2-metal 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 0.5.1", - "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-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.13.2", - "block2 0.6.2", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "objc2-uniform-type-identifiers" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" -dependencies = [ - "block2 0.5.1", - "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 0.5.1", - "objc2 0.5.2", - "objc2-core-location", - "objc2-foundation 0.2.2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "orbclient" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" -dependencies = [ - "libc", - "libredox", -] - -[[package]] -name = "ordered-float" -version = "5.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" -dependencies = [ - "num-traits", -] - -[[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 = "os_pipe" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] - -[[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.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[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 = "parlance" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" - -[[package]] -name = "parley" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5" -dependencies = [ - "fontique", - "harfrust", - "hashbrown 0.17.1", - "icu_normalizer", - "icu_properties", - "icu_segmenter", - "linebender_resource_handle", - "parlance", - "parley_data", - "skrifa", -] - -[[package]] -name = "parley_data" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889" -dependencies = [ - "icu_properties", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - -[[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - -[[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", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pin-weak" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b330c9d1b92dfe68442ca20b009c717d5f0b1e3cf4965e62f704c3c6e95a1305" - -[[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 = "pkg-config" -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.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.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 0.5.3", - "pin-project-lite", - "rustix 1.1.5", - "windows-sys 0.61.2", -] - -[[package]] -name = "polycool" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" -dependencies = [ - "critical-section", -] - -[[package]] -name = "portable-atomic-util" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "serde_core", - "writeable", - "zerovec", -] - -[[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 = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "profiling" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags 2.13.2", - "memchr", - "unicase", -] - -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "range-alloc" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "raw-window-metal" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" -dependencies = [ - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "read-fonts" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" -dependencies = [ - "bytemuck", - "font-types", - "once_cell", -] - -[[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 = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "renderdoc-sys" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - -[[package]] -name = "resvg" -version = "0.48.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" -dependencies = [ - "bytemuck", - "gif", - "image-webp", - "log", - "pico-args", - "rgb", - "svgtypes", - "tiny-skia 0.12.0", - "usvg", - "zune-jpeg", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "rowan" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14b574c58582fa59fa43a2feb6608b8744184659f08a2e0117e4b8224d95ed61" -dependencies = [ - "countme", - "hashbrown 0.14.5", - "memoffset", - "rustc-hash 1.1.0", - "text-size", -] - -[[package]] -name = "roxmltree" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] - -[[package]] -name = "rspolib" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fda9a7796aff63a7b1b39ccc93fffaaf65e20042984b4843041a49ca4677535" -dependencies = [ - "lazy_static", - "natord", - "snafu", - "unicode-linebreak", - "unicode-width", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -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", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[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 = "scoped-tls-hkt" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9603871ffe5df3ac39cb624790c296dbd47a400d202f56bf3e414045099524d" - -[[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", - "tiny-skia 0.11.4", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shlex" -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 = "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 = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "skia-bindings" -version = "0.153.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb325787c91b3f6c34a7c6f2b8a2297b20292d733ca53a08c233e5028aec49af" -dependencies = [ - "bindgen", - "cc", - "flate2", - "heck", - "pkg-config", - "regex", - "serde_json", - "tar", - "toml", -] - -[[package]] -name = "skia-safe" -version = "0.153.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61cb643dac43067f3eb76c866635a74377ac0abca0ff2590674e1e295514c3d" -dependencies = [ - "bitflags 2.13.2", - "skia-bindings", - "windows", -] - -[[package]] -name = "skrifa" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" -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 = "slint" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460524eed7afee6000683eccb97df58198265384099c76dade430cdae08b9b94" -dependencies = [ - "const-field-offset", - "i-slint-backend-linuxkms", - "i-slint-backend-selector", - "i-slint-common", - "i-slint-core", - "i-slint-core-macros", - "i-slint-renderer-femtovg", - "i-slint-renderer-skia", - "i-slint-renderer-software", - "num-traits", - "once_cell", - "pin-weak", - "slint-macros", - "unicode-segmentation", - "vtable", -] - -[[package]] -name = "slint-build" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6cfc9bb0dd48d01508f9e21511d0215e78e8e3be032bacd6be61744abe03c83" -dependencies = [ - "derive_more", - "fontique", - "i-slint-compiler", - "spin_on", - "toml_edit", -] - -[[package]] -name = "slint-macros" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a4d3e807d538d047a4b6b6c54586569d350dd795ce52b33c4992507de467104" -dependencies = [ - "i-slint-compiler", - "proc-macro2", - "quote", - "spin_on", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[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", - "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 = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - -[[package]] -name = "smol_str" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -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 = "spin_on" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076e103ed41b9864aa838287efe5f4e3a7a0362dd00671ae62a212e5e4612da2" -dependencies = [ - "pin-utils", -] - -[[package]] -name = "spirv" -version = "0.4.0+sdk-1.4.341.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" -dependencies = [ - "bitflags 2.13.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[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" -dependencies = [ - "float-cmp", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "svgtypes" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" -dependencies = [ - "kurbo", - "siphasher", -] - -[[package]] -name = "swash" -version = "0.2.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" -dependencies = [ - "skrifa", - "yazi", - "zeno", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "js-sys", - "libc", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "taffy" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" -dependencies = [ - "arrayvec", - "grid", - "serde", - "slotmap", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix 1.1.5", - "windows-sys 0.61.2", -] - -[[package]] -name = "text-size" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" - -[[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.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl 2.0.20", -] - -[[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.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -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", - "tiny-skia-path 0.11.4", -] - -[[package]] -name = "tiny-skia" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png", - "tiny-skia-path 0.12.0", -] - -[[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-skia-path" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" -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", - "pkg-config", - "tracing", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.15+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -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", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tree_magic_mini" -version = "3.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" -dependencies = [ - "memchr", - "nom 8.0.0", - "petgraph", -] - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" - -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "sha1", - "thiserror 1.0.69", - "utf-8", -] - -[[package]] -name = "typed-index-collections" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" -dependencies = [ - "bincode", - "serde", -] - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "udev" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af4e37e9ea4401fc841ff54b9ddfc9be1079b1e89434c1a6a865dd68980f7e9f" -dependencies = [ - "io-lifetimes", - "libc", - "libudev-sys", - "pkg-config", -] - -[[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 = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[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-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[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-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "serde", - "serde_json", - "url", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "usvg" -version = "0.48.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" -dependencies = [ - "base64 0.23.1", - "data-url", - "flate2", - "fontdb", - "harfrust", - "imagesize", - "kurbo", - "log", - "pico-args", - "roxmltree", - "simplecss", - "siphasher", - "skrifa", - "strict-num", - "svgtypes", - "tiny-skia-path 0.12.0", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -dependencies = [ - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vtable" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "515a4b5b1399cc46fb0dc4081436cb40a3852a2d3e94fd1fa50bb68a334896eb" -dependencies = [ - "const-field-offset", - "portable-atomic", - "stable_deref_trait", - "vtable-macro", -] - -[[package]] -name = "vtable-macro" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8191d73ececf73fc94e65809bf0e667525a0bcff9aeeb8d042a4173a508332a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[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.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[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-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.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" -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 = "webbrowser" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" -dependencies = [ - "jni", - "log", - "ndk-context", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "url", - "web-sys", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "wgpu" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" -dependencies = [ - "arrayvec", - "bitflags 2.13.2", - "bytemuck", - "cfg-if", - "cfg_aliases", - "document-features", - "hashbrown 0.17.1", - "log", - "portable-atomic", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" -dependencies = [ - "arrayvec", - "bit-set", - "bit-vec", - "bitflags 2.13.2", - "bytemuck", - "cfg_aliases", - "document-features", - "hashbrown 0.17.1", - "indexmap", - "log", - "naga", - "naga-types", - "once_cell", - "parking_lot", - "portable-atomic", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 2.0.20", - "wgpu-core-deps-apple", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-naga-bridge", - "wgpu-types", -] - -[[package]] -name = "wgpu-core-deps-apple" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-windows-linux-android" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-hal" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.13.2", - "block2 0.6.2", - "bytemuck", - "cfg-if", - "cfg_aliases", - "gpu-allocator", - "hashbrown 0.17.1", - "libc", - "libloading", - "log", - "naga", - "naga-types", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", - "objc2-quartz-core 0.3.2", - "once_cell", - "ordered-float", - "parking_lot", - "portable-atomic", - "portable-atomic-util", - "profiling", - "range-alloc", - "raw-window-handle", - "raw-window-metal", - "renderdoc-sys", - "smallvec", - "static_assertions", - "thiserror 2.0.20", - "wgpu-naga-bridge", - "wgpu-types", - "windows", - "windows-core", -] - -[[package]] -name = "wgpu-naga-bridge" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" -dependencies = [ - "naga", - "wgpu-types", -] - -[[package]] -name = "wgpu-types" -version = "30.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "log", - "naga-types", - "raw-window-handle", - "static_assertions", -] - -[[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 = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winit" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" -dependencies = [ - "ahash", - "android-activity", - "atomic-waker", - "bitflags 2.13.2", - "block2 0.5.1", - "bytemuck", - "calloop 0.13.0", - "cfg_aliases", - "concurrent-queue", - "core-foundation", - "core-graphics", - "cursor-icon", - "dpi", - "js-sys", - "libc", - "memmap2", - "ndk", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit 0.2.2", - "orbclient", - "percent-encoding", - "pin-project", - "raw-window-handle", - "redox_syscall 0.4.1", - "rustix 0.38.44", - "sctk-adwaita", - "smithay-client-toolkit", - "smol_str 0.2.2", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "wl-clipboard-rs" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" -dependencies = [ - "libc", - "log", - "os_pipe", - "rustix 1.1.5", - "thiserror 2.0.20", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - -[[package]] -name = "write-fonts" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cae90d1d9f6d1d36ef116b0b1e651a6d7c12ba925439d4d06ff9adc94662a3d8" -dependencies = [ - "font-types", - "indexmap", - "kurbo", - "log", - "read-fonts", -] - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[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", - "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 = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.5", -] - -[[package]] -name = "xcursor" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" - -[[package]] -name = "xkbcommon" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a974f48060a14e95705c01f24ad9c3345022f4d97441b8a36beb7ed5c4a02d" -dependencies = [ - "libc", - "memmap2", - "xkeysym", -] - -[[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 = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - -[[package]] -name = "yazi" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.5", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.6", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow", - "zvariant", -] - -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - -[[package]] -name = "zeno" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" - -[[package]] -name = "zerocopy" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[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 = "zune-core" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] - -[[package]] -name = "zvariant" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow", - "zcheapstr", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" -dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.6", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 3.0.6", - "winnow", -] diff --git a/slint-frontend/Cargo.toml b/slint-frontend/Cargo.toml deleted file mode 100644 index 0438d76..0000000 --- a/slint-frontend/Cargo.toml +++ /dev/null @@ -1,65 +0,0 @@ -[package] -name = "musicmouse-slint" -version = "0.1.0" -edition = "2021" -description = "Native Slint front-end for the MusicMouse player" - -[dependencies] -# default-features off so the renderer is an explicit choice below, never an -# accident: on a Pi 5 (Mesa V3D) the default FemtoVG renderer draws every runtime -# loaded Image as solid black - see slint-ui/slint#11785 - which for this app means -# every album cover. The `skia` feature is the device's way out. -slint = { version = "1.15", default-features = false, features = [ - "compat-1-2", - "std", -] } - -serde = { version = "1", features = ["derive"] } -serde_json = "1" -# Blocking HTTP and blocking websockets, each on its own thread, rather than an async -# runtime. The whole client makes one library request, one websocket connection and a -# trickle of covers; tokio would be more machinery than the job has work. -ureq = { version = "2.12", default-features = false, features = ["json", "gzip"] } -tungstenite = { version = "0.24", default-features = false, features = ["handshake"] } -image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } -dirs = "5" -# Only for `normalize()`: folding "Grüffelo" to "gruffelo" needs a real NFD pass, which -# is what the web client gets from String.prototype.normalize for free. -unicode-normalization = "0.1" - -[build-dependencies] -slint-build = "1.15" - -[features] -default = ["desktop"] - -# Development on a normal desktop session. FemtoVG is fine on x86/Mesa and builds in -# seconds, which is what makes it the right default *here* and the wrong one on the Pi. -# `renderer-software` rides along so `SLINT_BACKEND=winit-software` works without a -# rebuild: it is how the UI can be rendered and screenshotted on a machine with no GPU -# (or no session), and it is the fallback the Pi 5 FemtoVG bug leaves standing. -desktop = ["slint/backend-winit", "slint/renderer-femtovg", "slint/renderer-software"] - -# Same window, Skia instead - the combination the device runs, so visual checks here -# match what ships. Costs a long first build while rust-skia bootstraps. -skia = ["slint/backend-winit", "slint/renderer-skia"] - -# The device: straight onto KMS/DRM with no X, no Wayland and no compositor, input -# via libinput. (`backend-linuxkms-noseat` is deprecated upstream in favour of this.) -# -# Build it with `--no-default-features --features kiosk`: cargo features are additive, -# so leaving `desktop` enabled links winit and linuxkms into the same binary. -kiosk = ["slint/backend-linuxkms-libinput", "slint/renderer-skia"] - -[profile.release] -opt-level = 3 -lto = "thin" -codegen-units = 1 -panic = "abort" - -# The UI is laid out and hit-tested in debug builds too; an unoptimised image resize -# on a 343-album first run is otherwise the slowest thing in the app by far. -[profile.dev.package.image] -opt-level = 3 -[profile.dev.package.slint] -opt-level = 2 diff --git a/slint-frontend/README.md b/slint-frontend/README.md deleted file mode 100644 index 2b4d82d..0000000 --- a/slint-frontend/README.md +++ /dev/null @@ -1,192 +0,0 @@ -# Musik Delfin — the Slint front-end - -A native front-end for the music player, next to `../web/`. Same backend, same layout, -same German copy; no browser. It exists because the React UI is sluggish on the Pi 5 -kiosk, and the things making it sluggish are browser-shaped: a compositor deciding what -gets its own layer, a `requestAnimationFrame` loop that repaints the viewport, and a -JPEG decode per album card per cold start. - -The music player and the room panel ("Mein Zimmer"). The typing game (`Tippen`), parent -mode and the IR-remote assignment are still web-only. - -## Running it - -Start the backend, then: - -```sh -nix-shell # cargo, the GL/X11/Wayland libs, and Nunito -cargo run # windowed, FemtoVG -cargo run -- --server http://musicdolphin:8080 -cargo run -- --page room # open on the room panel rather than the player -``` - -Checks: - -```sh -cargo test && cargo clippy --all-targets && cargo fmt --check -``` - -`tools/headless-shots.sh` renders the UI and screenshots it inside a nested headless -compositor, for checking a layout change on a machine with no display (or a locked one). -A Slint window needs a real compositor - under bare Xvfb nothing maps at all. - -## Build profiles - -| | Backend | Renderer | For | -|---|---|---|---| -| `cargo run` | winit | FemtoVG | development here | -| `cargo run --features skia` | winit | Skia | checking what the device will show | -| `cargo build --release --no-default-features --features kiosk` | linuxkms + libinput | Skia | the Pi | - -**The device must not use FemtoVG.** On a Pi 5 (Mesa V3D), FemtoVG draws every -runtime-loaded `Image` as solid black - [slint#11785][bug], open as of this writing - -which for this app means every album cover. Skia and the software renderer are both -unaffected. FemtoVG stays the default *here* because it builds in seconds and is fine on -desktop Mesa; `kiosk` is the profile that ships. - -`--no-default-features` is not optional on that last one: the features are additive, so -leaving `desktop` on would link winit *and* linuxkms into the same binary. - -`kiosk` takes the framebuffer directly through KMS/DRM: no X, no Wayland, no compositor. -(`backend-linuxkms-noseat` is deprecated upstream in favour of the libinput variant used -here.) `SLINT_BACKEND=winit-software` forces the software renderer without a rebuild. - -[bug]: https://github.com/slint-ui/slint/issues/11785 - -## Where the speed comes from - -Four things, in rough order of how much they matter on the device: - -**Covers are downscaled once, ever.** The backend serves one size - 640 px on the long -edge - and has no `?size=`. A grid card is 180 px. `src/covers.rs` fetches each cover -once on a worker thread, resizes it to the tier that was asked for, and writes a JPEG -thumbnail to `~/.cache/musicmouse-slint/covers/@.jpg`. Every later start -reads that instead. There are two tiers and only two, which is what makes the cache -worth having: a per-widget pixel size would give each album a dozen near-identical files -and a fresh decode for each. - -The cache is dropped wholesale when the websocket announces a rescan -(`{"type": "library"}`), because that is the one moment the backend rewrites the art -behind an unchanged cover URL. That is the bug the web client had to learn about the -hard way - see the comment at `python-backend/musicmouse/services/web/api.py:106`. - -**The grid is virtualized.** Rust hands the UI a `[GridRow]` - the album list -pre-chunked into rows - and the view puts those in a `ListView`, which only instantiates -the rows on screen. A flat list of 660 cards gives it no rows to skip, hence the -chunking. This is the counterpart of `content-visibility: auto` on `.grid > .card` in -the web UI, which exists for exactly the same measurement: 340 live cards saturated the -Pi's renderer so completely that the main thread stopped scheduling anything. - -**The progress bar animates instead of ticking.** Position arrives twice a second. The -web client interpolates it onto animation frames with a JS clock and a render budget; -here the fill is a Slint `animate width` between the pushed values, so interpolation -costs a property evaluation per frame on the render side and there is no clock at all. -It is suppressed for the one frame a track changes on, so a new track jumps rather than -sliding across two unrelated positions. - -**Nothing on screen animates by itself.** The web UI's per-track ambient canvas is not -here. Halving its resolution and its frame rate took it from 53.5% of a core to 25.0% -and 25% was still too much: a loop repainting the viewport is a floor you cannot get -under while it runs at all. The stage keeps its gradient, which is what `?pi=1` already -does on the device this replaces. - -## What is deliberately different from the web UI - -**No glass blur.** Slint has no `backdrop-filter`, so the panels use a flat translucent -fill - exactly the web app's own `data-blur="off"` fallback. Some depth is genuinely -lost. Faking it by compositing a pre-blurred copy would reintroduce the per-frame cost -that dropping the blur was meant to avoid. - -Worth knowing if you ever port the blur back: on the Pi, `backdrop-filter` measured five -times *better* than no blur (25% of a core against 118%), because it is what promotes -each panel to its own compositing layer. That is a browser-compositor artifact and does -not transfer here - but it is why the web UI keeps the panel blur under `?pi=1`. - -**No dolphin.** The mascot and its swim/bob loops are not ported. - -**Layout** is Slint's, not a transcription of the CSS: fixed row heights instead of -`clamp()` and `vh`, a 1080p-shaped fixed measure for the track list rather than -`max-width`. The colours are the same - `ui/theme.slint` carries the web UI's oklch -tokens converted to sRGB, with the oklch source kept beside each one, because oklch is -the form the design is actually maintained in. - -## How it is put together - -The split that matters is the web UI's, unchanged: **what is playing** comes from the -backend, **what you are looking at** lives here. The buttons on the mouse, a figurine on -the reader and Home Assistant all move playback too, so this follows it rather than -assuming it is in charge. - -Three worker threads and no async runtime - the whole client makes one library request, -one websocket connection and a trickle of covers. - -| Path | What it is | -|---|---| -| `src/api.rs` | The wire types and the HTTP calls. Mirrors `services/web/schemas.py`. | -| `src/ws.rs` | The state websocket, with reconnect-and-reseed. | -| `src/library.rs` | The search index and the browse hierarchy. Port of `web/src/lib/search.ts`. | -| `src/keymap.rs` | The keyboard model, as a pure function. Port of `web/src/lib/keyboard.ts`. | -| `src/covers.rs` | The thumbnail cache, and the generated stand-in art. | -| `src/ha.rs` | Home Assistant through the backend's proxy, and the shutter/colour conversions. Port of `web/src/hooks/useHomeAssistant.ts` and `lib/shutter.ts`. | -| `src/oklch.rs`, `src/format.rs` | Colour conversion; durations. | -| `src/main.rs` | Wiring: worker channels in, Slint models out. | -| `ui/*.slint` | Layout only. Nothing here formats a number or picks a word. | - -`src/keymap.rs` and `src/library.rs` are ports of modules that were already pure -functions with their own tests, and they keep them: 43 tests, no window required. - -## Keyboard - -The same map as the web UI. - -| Key | | -|---|---| -| `SPACE` | play / pause (types a space mid-query) | -| `SHIFT+H` / `SHIFT+L` | previous / next | -| `SHIFT+J` / `SHIFT+K` | quieter / louder | -| `SHIFT+M` | mute | -| `SHIFT+←` / `SHIFT+→` | seek 15 s | -| `A-Z`, `0-9` | search albums as you type | -| `?` | search individual tracks | -| `TAB` | cycle Musik / Hörbücher / Podcasts | -| `← ↑ ↓ →` | move the selection while browsing; transport and volume while playing | -| `CTRL+H/J/K/L` | the same, without leaving the home row | -| `CTRL+D` / `CTRL+U` | half a page | -| `ENTER` | play the selection | -| `SHIFT+ENTER` | open the track list | -| `ESC` | back out one layer at a time | -| `/` | back to browsing | -| `CTRL+TAB` | cycle Musik / Mein Zimmer / Tippen | - -Everything is clickable too. - -`CTRL+TAB` is the one addition to the web front-end's map. Its tab rail is reachable -only with a pointer, which sits badly with a device whose stated design is that every -screen can be reached from the keyboard. Plain `TAB` was already the group cycle. - -## Mein Zimmer - -The Home Assistant room panel, in its own violet rather than the player's teal so the -two read as siblings. Lights get a toggle, five brightness steps and six colours; -shutters get four presets, up/stop/down and a window that reflects the reported -position. Nothing is animated locally - a real cover reports its own movement, and -guessing where it will end up is what would fight with that. - -The whole page is a poll, not a subscription, and only while it is on screen: Home -Assistant's own auth-and-subscribe websocket is more machinery than this needs, and a -lamp is not worth a request every 2.5 s when nobody is looking. A tap patches the -entity locally and remembers when, so the poll already in flight cannot undo it - the -same reconciliation `useHomeAssistant.ts` documents, and for the same reason. - -The tab appears only when the backend answers `GET /api/ha` with a config; a 404 there -means Home Assistant is not set up, which is a normal answer rather than an error. - -The special keys are named in `ui/app.slint`, where the `Key.*` constants live, and -reach Rust as `"escape"`, `"left"`, `"enter"` and so on - so `src/keymap.rs` is testable -without a window and without hardcoding keysym values. - -## Fonts - -Nunito, via `SLINT_DEFAULT_FONT`, which `shell.nix` sets. Slint cannot read the woff2 -the web app self-hosts, so this wants the TTF; a deployment can drop one at -`fonts/Nunito.ttf` instead. With neither, everything still renders in the system sans. diff --git a/slint-frontend/build.rs b/slint-frontend/build.rs deleted file mode 100644 index 29ee3db..0000000 --- a/slint-frontend/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - slint_build::compile("ui/app.slint").expect("compiling ui/app.slint"); -} diff --git a/slint-frontend/fonts/Nunito.ttf b/slint-frontend/fonts/Nunito.ttf deleted file mode 100644 index 2ec1f4b0676c83ef33049db87b311171699e9192..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 276932 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%0Do|XJG1A?#Jml~?ZHg8;t1C9I zCM#Tle2g;FG_w~s=z3^yq&m>rT-elAQLf1O0CSsE-+1d6X|p_6t}q{SNa`Pb*qJsEojF7jq@qeL4fUN*YT&w zZ`wy*Zn-AVb1B3Jt!d%ymg~>P5nTAvlLQ<_Opt7qf`ZT%=|wnCJIRO_lNJeTfqp_{G{jidmvV2+nW~Z(5TJ!l<5ToH&?cwc zqvNQctu26?w&u3Lel%K;T6>L5E!SNI7@69dA6YWO(jTkd;T*6^4Y{;^t1bGsAP^m+13W9NL8x>4^Ui zZDZi2`>FJ{yWP&ez6)A6`H#tGSqA?j@nq0CA*CTk6-YE9TPN<0md0;6CA^3)*`L(Ud~$62dZH%7SAi(z31unlWia zdXueWMbtMFBMp=ctb}<%dzC)kbZVND{)j`G?}KCR0imjXC(%*Egcri{74}!6Uvq69 zk76qcXXk{AZP;G0H8=R9_aLPbRd(6P=%rK{at7^q@$A`G9YdWB@Ym>r4I5Me<09|I zP}mqB7%#axn$r&2Oz6 z5o6V1=7QGG!qv!;9EF+6nVTDbx416fmS0;Ee-UHA^TU3BuIR&XVKB?;W>wV#P%hCtQ%Y*ld|ffKQ}ddR zjzaj0peL?Kb$qfq3JC2*e+m;;{xq0a>g&bS;t~4TwMz^b=|ILcNYt_r(anA!@tN6TB$ht$k zkQIPWgyaiE`2~sc0+c_A@(V@zg;Wms;C<E=gF542jeW~nTD|_|JuW)#gzD)fL zYP(9I45+gap~(pj7?O{QJ{~pUjB=5q)g!1k`bcaKd@ozsq9FY!%Rm5XI=#rg>d^k| z^dcqApB)-{6@}$63aSBV%%of--UwDI7MF^VT8o(^w3K7IGWK%%94?n5uc_CwRsRUx z9?^Xn^JnGKZEkaqC(Cgm-G0iuU9TQ$JWlJXc>vz8WW8~%9GeT=bu|nv#ad9E_IM7Y zAmZ{>QYPQdTR}uxf5bjJVufj7lY_*YoV=rdSNObZH4xvF{>ojH!m%AT`lPkd(aru)zR@crwXsvXRksW-GS+l+1feLd|P zdIv{ow~_Y6cVpy~`)skW+hV;!8g5e)`*%Vnh6rZK$lzaUn79`p0;y0~uW(+X+2CtF z2kKNonM}HNQJkHx~;t@+|ic(c5a}vGuS=Mk95`NRy9X#m2FK0HDaGFMt&op!|*E- z`-8+≦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

MZ6ig%eU?U=Z}EJ{q=5OE-FqF6 zXD((_Jj@w`l|A|h=6^Idi)BgoYhtd{6w<;yBQuvwCJ#CnYR;wdc!>S&;GR#R>61y~ zk^U2ZF#o|mPP_9-c5aB`aeJrIjfCQ(ZpJ3)0JpDMn_#cR5y;Q%r&E}#Uu0uo2Xj$g zo>PN{JQFfa;pDW8jSFp7)_^woxcmxH=5`#GGyZDj1-Wnx zcmLj&mFE()*G7BxIEzO2ZAd13t{@G273SUm#pQcJX1&3>nW%e7WQp_|d0s{HXXHa4 z)>)Dj<%78l=`{J%83eup_@1Ik&+53HV-gSFyQF>iM4lw~29STSh7t|XPg`z^>*Pu4 z1|Qn{v*ijrL8LvfBj$`>Z^X5eVY1^IImzC|;#w+xzt{REy;}WhxGb+-c>e8iUalTR zKmA3*&Tbs5cFZ=ipZvfi_nsOD&W<0GqkG#9@(esldG_NKy&@5^zHpQ$Q|nt{p_+`Yudbtht`eE!*g%0cU`dlz#b#pVY{D2`>Gf_TzT2FQe9m; z?#AQ(MQPzoHhoZrc72co+~;;_ZP3}Vi`K;3I0fu=$LpTWQ-{Wo`W?z~P}@D0I_h|T z>igT@w(WJx@sFUb{e(ONTb7tF>m57;o>c2XyLUn1g$b$H^;v>&ZaZeI z>s&q?PI*%q62eHw?a#6b+P|`8qkKxVT?xVuB7yNMPR79=M8cRAJg02S<&=SKh&eaN zdkzWq%Jy9+?YKNCDH_XcyE{n-BQH{P@8`?|&~K94OeF2vUDiX|!ya0 z^(*_%25Lt~@9-YGnJJ3E-a#KrrX$h&@E+kI;?Cc@DYd7g&wl-9H|QBOO0nIV1ZPv&|FyQ~QRc?>xu*89x)I1Rk=3z9v7oIo zM#m`RJqcwD)-hI&F^(7&7dDaF-#I^Q?>oiV_e0(P>V6o${orI}4m xM$GzC~$G z{PyN6sfvno*v`{ZF|N2a2aoMr#NpDh811R(>uonC`jS2+`W=Y7qj&6}T){l&K++6& z6Xpiw->$CUarNBp>A?PJ+hgs_4cdEMxyono($%aQO50_D&7!Qag{DD!>@T32!z zY`Hp+5zEio2DiNX%fqHk?}DR`W4GJcHF{qX-Jb;y)rX$Hb(6RJeBX`k)zQ6Sx<~tV zjH^RAB+8HG$F$Ey@-urpc3g_3n401_I_?Dq>ssd-qQqE`UtC>mIM=g-vur`R?ECYa z_Yh+J!1so2T6_)K281;3|A`0BswrdH?lJZLL%%b?IKBWK9=>z@P zG0B|=w@U+SHoIRLy&<_Q+r=S|4)*3c)D>69-RCkAGoiVQ%bOV9fje3Luz?vArNjBL zO*3xZ1kc3#Mv37zUPjy9mZY-VJOjT@5J}5!&Q>Ib1v;E?4iNeB92oi+%nvDR+n+it z2A{^LSz7(S3fR+pWv(KA?FP4{~H16MZ{24~gCkJ4rH1=$Evn zvHK5{FX!#dKj?V@ZC`CE;;)<@*_tHpL*Vl{R+cuJV{^D4n;Ozy3GIVMpVejNvf{lW zR5p~Uy?wLkCA&IfOR`|M4LRKK)A`)QPq2?cJYAF9yu>wfVjBN6cPHLWL^4k-pY==B zTUtjEuU*>(@yuAm=TLBO7|(XFB;EdtzL6n9ZIAFc8$<*F+r<5b_E5O}k%zl>qkA8; zkDg*oMP5&Ea;}ZDeG=tr*VYanx2@T0WZOacZ_|aNEAq^UK8|M*lgP+c!S+?_rmOXX zJlL+;u%Q1woS;Yao)C&>`mg2h)RpWuy&HwjawYA*SR2yZB%$BOrA=+%_ERfAdcTV6 zPi1tT;pTOJLjR3R*Ep;^$QjuBU3~w8r5(lj02(W(4Qzb7XA{LUmwY~j=6Q+xR$pwz zdlhsHK4WXn401ls7+}-1*Pu_6hpnp(WIyEPj72DOaj-{d^~YYFc;Jh(*Zh1+g8bb4 zwEPlu!AZ|0t!$8o*)NQ(1L;f`%g>Qd^b5!K(|I#I!)M1|PM6x8p80e3LE`J|<{nSS z?Q5I9?{aWi>~rSyu30)SXUAXrY-KX}(fCB|Y-M(U01=AYj zH9h{g`+w9A?6^sBNRF0|-OgI1U7C&u^;^fuB+I~g)+sSOl{>IqW{)cwU)-GFBaHnW z^pm9VjgD!VZI#3mv@Ox*z7q8r4x@fe`bX;z=iCs&S?1u^e6as+-=Mf1eW1@Bu0Py0 z1lw{`Ppez5-Y4c``x^VFZ5`!?+xYB9x^MRHU7lpLy_W$wbZRbxG6eO5tP=J{bq)#g z6vg+(cNXoOvd%bEzNO^RQ&| zsoh)~MB}J=HxQ>~=7es#akSI!NEh}up$z6+I@?0pU$O0J?WLJ_aGF#e%0D3u3gh^0 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 z%0A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 { } }: - -let - # Everything the winit backend dlopen()s at runtime. Missing one of these shows up - # as a panic inside winit, not as a link error, so they are easy to misdiagnose. - runtimeLibs = with pkgs; [ - libGL - libxkbcommon - wayland - xorg.libX11 - xorg.libXcursor - xorg.libXi - xorg.libXrandr - fontconfig - freetype - ]; -in -pkgs.mkShell { - nativeBuildInputs = with pkgs; [ - cargo - rustc - rustfmt - clippy - pkg-config - # Only needed when building with --features skia: rust-skia compiles its own - # bindings shim and wants a C++ toolchain plus python for the GN build. - clang - python3 - ]; - - # Nunito is the face the design is drawn in. `SLINT_DEFAULT_FONT` takes one file and - # makes it the primary font. This points at the copy vendored in `fonts/` rather than - # at nixpkgs, so development and the device render from the identical file. - SLINT_DEFAULT_FONT = toString ./fonts/Nunito.ttf; - - buildInputs = runtimeLibs ++ (with pkgs; [ - fontconfig - # libinput/libdrm/libseat are the linuxkms (kiosk) backend's dependencies. They - # cost nothing to have present on a desktop build. - libinput - libdrm - seatd - udev - ]); - - LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; - - # rust-skia downloads a prebuilt libskia rather than building it, when it can. - SKIA_BINARIES_URL_TEMPLATE = null; - - shellHook = '' - echo "musicmouse slint-frontend — cargo $(cargo --version | cut -d' ' -f2)" - echo " cargo run # windowed, femtovg" - echo " cargo run --features skia # windowed, skia (what the Pi uses)" - echo " cargo build --features kiosk # linuxkms + skia, for the device" - ''; -} diff --git a/slint-frontend/src/api.rs b/slint-frontend/src/api.rs deleted file mode 100644 index 8a1c141..0000000 --- a/slint-frontend/src/api.rs +++ /dev/null @@ -1,334 +0,0 @@ -//! The shapes the backend serves, and the calls that fetch them. -//! -//! Mirrors `python-backend/musicmouse/services/web/schemas.py`, by way of -//! `web/src/api/types.ts` - the three are hand-kept in step. Only the music player's -//! share of the API is here; settings, the IR remote, Home Assistant and the typing -//! game are all reachable and all deliberately absent. -//! -//! Every field the backend can omit is modelled with `#[serde(default)]` rather than a -//! required `Option`, so a backend that grows a field does not break this client and -//! one that loses a field fails at the one call site that cared. - -use serde::Deserialize; - -/// Default for a development run; `--server` overrides it. -pub const DEFAULT_BASE_URL: &str = "http://127.0.0.1:8080"; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AlbumKind { - #[default] - Music, - Book, -} - -impl AlbumKind { - /// Shape is how you tell the two apart without reading anything: albums square, - /// audiobooks taller than wide, everywhere they appear. Nothing else in this app - /// may set a cover's aspect ratio. Ported from `web/src/lib/covers.ts`. - pub fn aspect(self) -> f32 { - match self { - Self::Music => 1.0, - Self::Book => 0.82, - } - } -} - -#[derive(Debug, Clone, Deserialize)] -pub struct Track { - pub title: String, - /// Seconds, read from the file's tags at scan time. `0.0` when it carries none. - #[serde(default)] - pub duration: f64, - /// A typing-reward track not yet earned. This front-end does not host the typing - /// game, but it still has to not show what the game is withholding. - #[serde(default)] - pub locked: bool, -} - -// `series`, and the parts of `PlayerState` the UI does not read, are kept because these -// structs are the record of what the backend serves - dropping a field would make this -// file a list of what this front-end happens to use today, which is a much less useful -// thing to read next to `schemas.py`. -#[allow(dead_code)] -#[derive(Debug, Clone, Deserialize)] -pub struct Album { - pub id: String, - /// One of the four fixed shelf folder names: `Figuren`, `Musik`, `Hörbücher`, - /// `Kinderpodcasts`. What `Group::of` reads to spot a podcast. - pub section: String, - #[serde(default)] - pub kind: AlbumKind, - pub title: String, - #[serde(default)] - pub artist: String, - #[serde(default)] - pub series: Option, - /// The figurine whose folder this is, when it has one. - #[serde(default)] - pub figure: Option, - /// Series for audiobooks and podcasts, artist for music. What browsing groups by. - #[serde(default)] - pub category: String, - /// Exactly three `#rrggbb`: primary, secondary, accent, pulled out of the cover art - /// or synthesised from the id. The generated stand-in cover is painted from these. - #[serde(default)] - pub colors: Vec, - #[serde(default)] - pub has_cover: bool, - #[serde(default)] - pub duration: f64, - #[serde(default)] - pub tracks: Vec, - #[serde(default)] - pub locked: bool, -} - -impl Album { - pub fn is_book(&self) -> bool { - self.kind == AlbumKind::Book - } - - /// `"Album · Artist"`, unless a podcast makes those the same string. - pub fn line(&self) -> String { - let prefix = if self.is_book() { "📖 " } else { "" }; - if !self.artist.is_empty() && self.artist != self.title { - format!("{prefix}{} · {}", self.title, self.artist) - } else { - format!("{prefix}{}", self.title) - } - } - - /// `colors` padded out to the three the painter wants, with the same fallbacks the - /// web client uses, so a library entry missing them looks the same in both. - pub fn palette(&self) -> [&str; 3] { - [ - self.colors.first().map_or("#4a6fa5", String::as_str), - self.colors.get(1).map_or("#6a8fc5", String::as_str), - self.colors.get(2).map_or("#a5804a", String::as_str), - ] - } -} - -#[derive(Debug, Clone, Deserialize)] -struct LibraryOut { - albums: Vec, -} - -#[allow(dead_code)] -#[derive(Debug, Clone, Default, Deserialize)] -pub struct Connection { - #[serde(default)] - pub firmware: bool, - #[serde(default)] - pub mqtt: bool, - #[serde(default)] - pub lirc: bool, -} - -/// What is playing. Owned entirely by the backend: the buttons on the mouse, a figurine -/// on the reader and Home Assistant all move it too, so this client follows it rather -/// than assuming it is in charge. -#[allow(dead_code)] // see the note above `Album` -#[derive(Debug, Clone, Default, Deserialize)] -pub struct PlayerState { - #[serde(default)] - pub playing: bool, - #[serde(default)] - pub album_id: Option, - #[serde(default)] - pub album_title: Option, - #[serde(default)] - pub artist: Option, - #[serde(default)] - pub kind: Option, - #[serde(default)] - pub track_index: i32, - #[serde(default)] - pub track_title: Option, - #[serde(default)] - pub track_count: i32, - #[serde(default)] - pub position: f64, - #[serde(default)] - pub duration: f64, - /// Percent, 0..100. The device's configured range never leaves the backend. - #[serde(default)] - pub volume: i32, - #[serde(default)] - pub active_figure: Option, - #[serde(default)] - pub connected: Connection, -} - -/// The push-only websocket's frames. `library` carries no payload - it is a bare "your -/// index is stale, fetch it again" after a rescan. -#[derive(Debug, Clone, Deserialize)] -#[serde(tag = "type", rename_all = "lowercase")] -pub enum ServerMessage { - State { - state: PlayerState, - }, - Position { - position: f64, - #[serde(default)] - duration: f64, - }, - Library, - /// A frame this build does not know. Ignored rather than fatal, so the backend can - /// grow a message type without this client falling over on it. - #[serde(other)] - Unknown, -} - -// ------------------------------------------------------------------------- client -- - -#[derive(Debug, Clone)] -pub struct Client { - base: String, - agent: ureq::Agent, -} - -/// Enough of an error type to put a cause on screen. Nothing here is recoverable in a -/// way the caller could branch on - the UI either has a library or says it cannot reach -/// the mouse - so the variants are not worth splitting. -pub type Error = String; - -impl Client { - pub fn new(base_url: &str) -> Self { - Self { - base: base_url.trim_end_matches('/').to_string(), - agent: ureq::AgentBuilder::new() - .timeout_connect(std::time::Duration::from_secs(4)) - .timeout_read(std::time::Duration::from_secs(30)) - .build(), - } - } - - /// `http://host:port` -> `ws://host:port/api/ws`. The device serves plain HTTP, but - /// deriving the scheme rather than hardcoding it keeps a TLS proxy in front working. - pub fn websocket_url(&self) -> String { - let ws = if let Some(rest) = self.base.strip_prefix("https://") { - format!("wss://{rest}") - } else if let Some(rest) = self.base.strip_prefix("http://") { - format!("ws://{rest}") - } else { - format!("ws://{}", self.base) - }; - format!("{ws}/api/ws") - } - - pub fn cover_url(&self, album_id: &str) -> String { - format!("{}/api/albums/{album_id}/cover", self.base) - } - - /// A GET that decodes JSON, with the status code kept in the error text so a - /// caller that cares about 404 - several do, because "not configured" is a normal - /// answer here - can tell it from a connection failure. - pub(crate) fn get_json(&self, path: &str) -> Result { - self.agent - .get(&format!("{}/api{path}", self.base)) - .call() - .map_err(|e| format!("GET {path}: {e}"))? - .into_json() - .map_err(|e| format!("GET {path}: bad JSON: {e}")) - } - - /// A POST whose response body, if any, is not worth reading. Home Assistant's - /// relayed replies are the only ones with a body, and nothing here reads it. - pub(crate) fn post_json(&self, path: &str, body: serde_json::Value) -> Result<(), Error> { - self.agent - .post(&format!("{}/api{path}", self.base)) - .send_json(body) - .map(|_| ()) - .map_err(|e| format!("POST {path}: {e}")) - } - - pub fn library(&self) -> Result, Error> { - let out: LibraryOut = self - .agent - .get(&format!("{}/api/library", self.base)) - .call() - .map_err(|e| format!("GET /api/library: {e}"))? - .into_json() - .map_err(|e| format!("GET /api/library: bad JSON: {e}"))?; - Ok(out.albums) - } - - pub fn state(&self) -> Result { - self.agent - .get(&format!("{}/api/state", self.base)) - .call() - .map_err(|e| format!("GET /api/state: {e}"))? - .into_json() - .map_err(|e| format!("GET /api/state: bad JSON: {e}")) - } - - /// Raw cover bytes, or `Ok(None)` for a 404 - which is a normal answer, not a - /// failure: `has_cover` says so in advance and the client paints the album's own - /// colours instead. - pub fn cover(&self, album_id: &str) -> Result>, Error> { - let response = match self.agent.get(&self.cover_url(album_id)).call() { - Ok(response) => response, - Err(ureq::Error::Status(404, _)) => return Ok(None), - Err(e) => return Err(format!("GET cover {album_id}: {e}")), - }; - let mut bytes = Vec::new(); - std::io::Read::read_to_end(&mut response.into_reader(), &mut bytes) - .map_err(|e| format!("GET cover {album_id}: {e}"))?; - Ok(Some(bytes)) - } - - // -------------------------------------------------------------------- commands -- - // - // Every one of these emits the same *intent* the physical buttons emit, so this UI - // has no privileged path and no way to get out of step with a figure someone puts - // on the reader. They all answer 204 with no body - nothing to parse. - - fn command(&self, path: &str, body: serde_json::Value) -> Result<(), Error> { - let request = self.agent.post(&format!("{}/api{path}", self.base)); - let result = if body.is_null() { - request.call() - } else { - request.send_json(body) - }; - result.map(|_| ()).map_err(|e| format!("POST {path}: {e}")) - } - - pub fn play(&self, album_id: &str, track_index: i32) -> Result<(), Error> { - self.command( - "/play", - serde_json::json!({ "album_id": album_id, "track_index": track_index.max(0) }), - ) - } - - pub fn resume(&self) -> Result<(), Error> { - self.command("/resume", serde_json::Value::Null) - } - - pub fn pause(&self) -> Result<(), Error> { - self.command("/pause", serde_json::Value::Null) - } - - pub fn next(&self) -> Result<(), Error> { - self.command("/next", serde_json::Value::Null) - } - - pub fn previous(&self) -> Result<(), Error> { - self.command("/previous", serde_json::Value::Null) - } - - pub fn seek(&self, position: f64) -> Result<(), Error> { - self.command( - "/seek", - serde_json::json!({ "position": position.max(0.0) }), - ) - } - - pub fn volume(&self, percent: i32) -> Result<(), Error> { - self.command( - "/volume", - serde_json::json!({ "percent": percent.clamp(0, 100) }), - ) - } -} diff --git a/slint-frontend/src/covers.rs b/slint-frontend/src/covers.rs deleted file mode 100644 index 2210f8d..0000000 --- a/slint-frontend/src/covers.rs +++ /dev/null @@ -1,406 +0,0 @@ -//! Album art: fetched once, downscaled once, then reused forever. -//! -//! The backend serves one size and only one - `MAX_COVER_PX = 640` on the longest edge, -//! JPEG, at `/api/albums/{id}/cover`, with no `?size=` to ask for less. A browse grid -//! draws that at 192 px and the play view at 384, so painting the served file directly -//! means decoding a 640x640 JPEG for every visible card on every cold start. At 343 -//! albums on a Pi that is the whole of "the UI feels slow". -//! -//! So this keeps a thumbnail cache on disk, keyed by album id *and* the size that was -//! asked for, and the decode-and-resize cost is paid once ever rather than once per -//! launch. A miss goes to the network on a worker thread; the UI never blocks on one. -//! -//! **Invalidation.** The bytes behind a cover URL do change while the URL does not - -//! the id addresses which album the art belongs to, not which art. Rather than pay a -//! conditional request per cover per start, this cache is cleared wholesale when the -//! websocket announces a rescan (`{"type": "library"}`), which is the one moment the -//! backend reprocesses cover art. See the long comment at -//! `python-backend/musicmouse/services/web/api.py:106` for how the web client learned -//! this the hard way. - -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; -use std::sync::mpsc::{Receiver, Sender}; - -use image::imageops::FilterType; -use image::{Rgba, RgbaImage}; - -use crate::api::{AlbumKind, Client}; -use crate::library::Group; -use crate::oklch::{mix, oklch_to_rgb, parse_hex}; - -/// The two sizes anything on screen actually asks for. Keeping it to two is what makes -/// the disk cache worth having: a per-widget pixel size would give every album a dozen -/// near-identical files and a fresh decode for each. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Tier { - /// Grid cards, shelf tiles, track rows and the player bar's 56 px thumbnail, which - /// scales down from this rather than earning a tier of its own. - Thumb, - /// The play view's cover, and the album sheet's. - Hero, -} - -impl Tier { - pub fn px(self) -> u32 { - match self { - Self::Thumb => 192, - Self::Hero => 384, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct Key { - pub album_id: String, - pub tier: Tier, -} - -/// Everything the worker needs about an album, flattened so it can cross a thread -/// boundary without the `Rc` the UI side holds. -#[derive(Debug, Clone)] -pub struct Request { - pub album_id: String, - pub tier: Tier, - pub has_cover: bool, - pub is_book: bool, - pub locked: bool, - pub colors: [String; 3], -} - -/// A finished bitmap on its way back to the UI thread. Raw RGBA rather than a -/// `slint::Image`, because the image types are built on the UI thread by whoever -/// receives this. -pub struct Ready { - pub key: Key, - pub width: u32, - pub height: u32, - pub rgba: Vec, -} - -// ------------------------------------------------------------------ generated art -- - -/// Diagonal two-tone stripes: the stand-in for a music cover, and the thing that shows -/// through underneath one that has not arrived yet. -fn stripes(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage { - // `max(6, size / 13)` in `web/src/lib/covers.ts` - the stripe scales with the art so - // a 56 px thumbnail does not turn into a grey smear of forty bands. - let band = (width / 13).max(6) as f32; - RgbaImage::from_fn(width, height, |x, y| { - // 135deg in CSS points the gradient line at the bottom-right corner, so the - // position along it is the projection of (x, y) onto (1, 1)/sqrt(2). - let along = (x + y) as f32 * std::f32::consts::FRAC_1_SQRT_2; - let c = if ((along / band) as u32).is_multiple_of(2) { - primary - } else { - secondary - }; - Rgba([c[0], c[1], c[2], 255]) - }) -} - -/// A book: page edges down the right, a darker board down the left, over a diagonal -/// wash. Four layers in CSS, four passes here, in the same order. -fn spine(width: u32, height: u32, primary: [u8; 3], secondary: [u8; 3]) -> RgbaImage { - // The audiobook shelf's own hue - warm amber, not a duller yellow-brown - so a - // generated spine and the shelf it sits on are the same family by construction. - let hue = Group::Audiobooks.hue(); - let page_light = oklch_to_rgb(0.97, 0.02, hue); - let page_dark = oklch_to_rgb(0.90, 0.03, hue); - - RgbaImage::from_fn(width, height, |x, y| { - let fx = x as f32 / width.max(1) as f32; - let fy = y as f32 / height.max(1) as f32; - - // Layer 4 (bottom): linear-gradient(155deg, primary -> secondary). - let t = ((fx * 0.42 + fy * 0.91) / 1.33).clamp(0.0, 1.0); - let mut c = mix(primary, secondary, t); - - // Layer 3: the board down the left edge. - if fx < 0.11 { - c = secondary; - } else if fx < 0.13 { - c = primary; - } - - // Layer 2: two thin highlight rules over the board. - if (0.035..0.043).contains(&fx) || (0.070..0.078).contains(&fx) { - c = mix(c, [255, 255, 255], 0.35); - } - - // Layer 1 (top): the page block on the right. - if fx >= 0.975 { - c = page_dark; - } else if fx >= 0.95 { - c = page_light; - } - - Rgba([c[0], c[1], c[2], 255]) - }) -} - -/// What an album looks like before - or instead of - its real art. -pub fn generated(request: &Request) -> RgbaImage { - let primary = parse_hex(&request.colors[0], [0x4a, 0x6f, 0xa5]); - let secondary = parse_hex(&request.colors[1], [0x6a, 0x8f, 0xc5]); - let px = request.tier.px(); - let (width, height) = dimensions(px, request.is_book); - if request.is_book { - spine(width, height, primary, secondary) - } else { - stripes(width, height, primary, secondary) - } -} - -/// Shape comes from the media type and from nowhere else: albums square, audiobooks -/// taller than wide. `px` fixes the longest edge either way. -fn dimensions(px: u32, is_book: bool) -> (u32, u32) { - let kind = if is_book { - AlbumKind::Book - } else { - AlbumKind::Music - }; - (((px as f32) * kind.aspect()).round() as u32, px) -} - -// ------------------------------------------------------------------------- worker -- - -pub struct Worker { - client: Client, - dir: Option, -} - -impl Worker { - pub fn new(client: Client) -> Self { - let dir = dirs::cache_dir().map(|d| d.join("musicmouse-slint").join("covers")); - if let Some(dir) = &dir { - if let Err(e) = std::fs::create_dir_all(dir) { - // Not fatal: without a disk cache every cover is simply fetched and - // resized again next launch, which is the old behaviour, not a failure. - eprintln!("cover cache unavailable at {}: {e}", dir.display()); - } - } - Self { client, dir } - } - - fn path(&self, key: &Key) -> Option { - self.dir - .as_ref() - .map(|d| d.join(format!("{}@{}.jpg", key.album_id, key.tier.px()))) - } - - /// Drop every stored thumbnail. Called when the backend says it has rescanned, and - /// therefore possibly rewritten the art behind these ids. - pub fn clear_disk(&self) { - let Some(dir) = &self.dir else { return }; - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.flatten() { - let _ = std::fs::remove_file(entry.path()); - } - } - } - - fn render(&self, request: &Request) -> RgbaImage { - let px = request.tier.px(); - let (want_w, want_h) = dimensions(px, request.is_book); - - // A locked album is one the typing game is withholding. It has real art, and - // showing it would give away the reward, so it never gets fetched at all. - if request.locked || !request.has_cover { - return generated(request); - } - - if let Some(path) = self.path(&Key { - album_id: request.album_id.clone(), - tier: request.tier, - }) { - if let Ok(image) = image::open(&path) { - return image.to_rgba8(); - } - } - - let bytes = match self.client.cover(&request.album_id) { - Ok(Some(bytes)) => bytes, - // 404 is a normal answer - `has_cover` can be stale by a rescan - and a - // network error should show the album, not a hole. - Ok(None) | Err(_) => return generated(request), - }; - let Ok(decoded) = image::load_from_memory(&bytes) else { - return generated(request); - }; - - // `resize_to_fill` rather than `resize`: a real cover is square but a book's - // slot is not, and letterboxing one inside the other would break the shelf - // metaphor that the whole browse screen is built on. - let thumb = decoded.resize_to_fill(want_w, want_h, FilterType::CatmullRom); - - if let Some(path) = self.path(&Key { - album_id: request.album_id.clone(), - tier: request.tier, - }) { - // Storing JPEG, not PNG: these are photographs, so JPEG is both smaller on - // disk and faster to decode on the next start, which is the whole point. - let _ = thumb - .to_rgb8() - .save_with_format(&path, image::ImageFormat::Jpeg); - } - thumb.to_rgba8() - } -} - -/// Runs the fetch/decode/resize loop off the UI thread, calling `deliver` for each -/// finished bitmap. `deliver` is expected to hop back onto the Slint event loop. -pub fn spawn( - client: Client, - jobs: Receiver, - deliver: impl Fn(Ready) + Send + 'static, -) -> std::thread::JoinHandle<()> { - std::thread::Builder::new() - .name("covers".into()) - .spawn(move || { - let worker = Worker::new(client); - // A cover asked for while it is already in flight - a card scrolled past - // twice, a grid rebuilt on a keystroke - must not become a second fetch. - let mut done: HashSet = HashSet::new(); - for job in jobs { - match job { - Job::Clear => { - worker.clear_disk(); - done.clear(); - } - Job::Fetch(request) => { - let key = Key { - album_id: request.album_id.clone(), - tier: request.tier, - }; - if !done.insert(key.clone()) { - continue; - } - let image = worker.render(&request); - deliver(Ready { - key, - width: image.width(), - height: image.height(), - rgba: image.into_raw(), - }); - } - } - } - }) - .expect("spawning the cover worker") -} - -pub enum Job { - Fetch(Request), - Clear, -} - -/// The UI thread's half: what is already decoded, and what has been asked for. -pub struct Store { - jobs: Sender, - ready: HashMap, - requested: HashSet, -} - -impl Store { - pub fn new(jobs: Sender) -> Self { - Self { - jobs, - ready: HashMap::new(), - requested: HashSet::new(), - } - } - - pub fn insert(&mut self, ready: Ready) { - let mut buffer = - slint::SharedPixelBuffer::::new(ready.width, ready.height); - buffer.make_mut_bytes().copy_from_slice(&ready.rgba); - self.ready - .insert(ready.key, slint::Image::from_rgba8(buffer)); - } - - /// The cover for an album if it is decoded, and a fetch queued if it is not. Callers - /// paint the album's own colours in the meantime, so this returning `None` is a - /// normal frame rather than a missing one. - pub fn get(&mut self, request: Request) -> Option { - let key = Key { - album_id: request.album_id.clone(), - tier: request.tier, - }; - if let Some(image) = self.ready.get(&key) { - return Some(image.clone()); - } - if self.requested.insert(key) { - let _ = self.jobs.send(Job::Fetch(request)); - } - None - } - - pub fn clear(&mut self) { - self.ready.clear(); - self.requested.clear(); - let _ = self.jobs.send(Job::Clear); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn request(is_book: bool, tier: Tier) -> Request { - Request { - album_id: "abc".into(), - tier, - has_cover: false, - is_book, - locked: false, - colors: ["#4a6fa5".into(), "#6a8fc5".into(), "#a5804a".into()], - } - } - - #[test] - fn shape_follows_the_media_type() { - assert_eq!(dimensions(192, false), (192, 192)); - assert_eq!(dimensions(192, true), (157, 192)); - assert_eq!(dimensions(384, true), (315, 384)); - } - - #[test] - fn generated_art_fills_the_tier_it_was_asked_for() { - let music = generated(&request(false, Tier::Thumb)); - assert_eq!((music.width(), music.height()), (192, 192)); - let book = generated(&request(true, Tier::Hero)); - assert_eq!((book.width(), book.height()), (315, 384)); - } - - #[test] - fn stripes_actually_alternate() { - let art = generated(&request(false, Tier::Thumb)); - let colours: HashSet<[u8; 3]> = (0..192) - .map(|x| { - let p = art.get_pixel(x, 0).0; - [p[0], p[1], p[2]] - }) - .collect(); - assert_eq!(colours.len(), 2, "expected exactly the two stripe colours"); - } - - #[test] - fn a_book_has_bright_page_edges_on_its_right() { - let art = generated(&request(true, Tier::Hero)); - let (w, h) = (art.width(), art.height()); - let edge = art.get_pixel(w - 2, h / 2).0; - let middle = art.get_pixel(w / 2, h / 2).0; - let brightness = |p: [u8; 4]| p[0] as u32 + p[1] as u32 + p[2] as u32; - assert!( - brightness(edge) > brightness(middle), - "page edges should be lighter than the cover" - ); - } - - #[test] - fn every_generated_pixel_is_opaque() { - let art = generated(&request(true, Tier::Thumb)); - assert!(art.pixels().all(|p| p.0[3] == 255)); - } -} diff --git a/slint-frontend/src/format.rs b/slint-frontend/src/format.rs deleted file mode 100644 index 981df6b..0000000 --- a/slint-frontend/src/format.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Durations, as the screen says them. Ported from `web/src/lib/format.ts`. - -/// `m:ss`, or `h:mm:ss` once there is an hour to show. Negative and non-finite inputs -/// collapse to `0:00` rather than rendering `-1:-3`, because both reach here honestly: -/// a position can briefly exceed a tag-derived duration, and a track with no duration -/// tag at all arrives as `0.0`. -pub fn clock(seconds: f64) -> String { - if !seconds.is_finite() || seconds <= 0.0 { - return "0:00".into(); - } - let total = seconds.round() as u64; - let (hours, minutes, secs) = (total / 3600, (total % 3600) / 60, total % 60); - if hours > 0 { - format!("{hours}:{minutes:02}:{secs:02}") - } else { - format!("{minutes}:{secs:02}") - } -} - -/// How much of the album is left: the rest of this track plus every track after it. -pub fn remaining_in_album(durations: &[f64], track_index: usize, position: f64) -> f64 { - let current = durations.get(track_index).copied().unwrap_or(0.0); - let rest: f64 = durations.iter().skip(track_index + 1).sum(); - (current - position).max(0.0) + rest -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn clock_formats_minutes_and_hours() { - assert_eq!(clock(0.0), "0:00"); - assert_eq!(clock(9.0), "0:09"); - assert_eq!(clock(61.0), "1:01"); - assert_eq!(clock(599.0), "9:59"); - assert_eq!(clock(3600.0), "1:00:00"); - assert_eq!(clock(12130.0), "3:22:10"); - } - - #[test] - fn clock_survives_the_inputs_a_real_player_produces() { - assert_eq!(clock(-4.0), "0:00"); - assert_eq!(clock(f64::NAN), "0:00"); - assert_eq!(clock(f64::INFINITY), "0:00"); - } - - #[test] - fn remaining_counts_this_track_and_the_ones_after_it() { - let durations = [100.0, 200.0, 300.0]; - assert_eq!(remaining_in_album(&durations, 0, 0.0), 600.0); - assert_eq!(remaining_in_album(&durations, 1, 50.0), 450.0); - assert_eq!(remaining_in_album(&durations, 2, 300.0), 0.0); - } - - #[test] - fn a_position_past_the_tagged_duration_does_not_go_negative() { - assert_eq!(remaining_in_album(&[100.0, 50.0], 0, 130.0), 50.0); - assert_eq!(remaining_in_album(&[], 7, 10.0), 0.0); - } -} diff --git a/slint-frontend/src/ha.rs b/slint-frontend/src/ha.rs deleted file mode 100644 index 654b9b7..0000000 --- a/slint-frontend/src/ha.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Home Assistant, through the backend's proxy. -//! -//! Ported from `web/src/hooks/useHomeAssistant.ts`. The browser never learns Home -//! Assistant's URL or token and neither does this: the backend keeps both and relays -//! `GET /api/ha/states/{id}` and `POST /api/ha/services/{domain}/{service}`, so all -//! this client knows is which entities exist. -//! -//! A short poll rather than a subscription. HA's own auth/subscribe websocket is more -//! machinery than a room panel needs, and the panel is only on screen while someone is -//! looking at it. - -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use serde::Deserialize; - -use crate::api::{Client, Error}; - -pub const POLL_INTERVAL: Duration = Duration::from_millis(2500); - -#[derive(Debug, Clone, Deserialize)] -pub struct Device { - pub entity_id: String, - #[serde(default)] - pub name: Option, -} - -impl Device { - pub fn label(&self) -> &str { - self.name.as_deref().unwrap_or(&self.entity_id) - } - - pub fn domain(&self) -> &str { - self.entity_id.split('.').next().unwrap_or("") - } -} - -/// Presence-only, like the LIRC config - there is nothing secret in a list of entity -/// ids. `None` from `GET /api/ha` means Home Assistant is not configured at all, which -/// is a normal answer and hides the tab rather than being an error. -#[derive(Debug, Clone, Default, Deserialize)] -pub struct Config { - #[serde(default)] - pub devices: Vec, - #[serde(default)] - pub scenes: Vec, -} - -/// One entity's `state`/`attributes`, relayed byte-for-byte from Home Assistant. -#[derive(Debug, Clone, Deserialize)] -pub struct EntityState { - #[serde(default)] - pub state: String, - #[serde(default)] - pub attributes: serde_json::Map, -} - -impl EntityState { - pub fn is_on(&self) -> bool { - self.state == "on" - } - - fn number(&self, key: &str) -> Option { - self.attributes.get(key)?.as_f64() - } - - /// 0..255 as Home Assistant reports it. - pub fn brightness(&self) -> f64 { - self.number("brightness").unwrap_or(0.0) - } - - /// Percent *open*, which is HA's convention. The room UI thinks in percent closed - - /// see `closed_percent`. - pub fn cover_position(&self) -> Option { - self.number("current_position") - } - - pub fn supported_features(&self) -> u64 { - self.number("supported_features").unwrap_or(0.0) as u64 - } - - pub fn rgb(&self) -> [u8; 3] { - let Some(values) = self.attributes.get("rgb_color").and_then(|v| v.as_array()) else { - // The warm default the web card falls back to, so an on-but-uncoloured lamp - // is not drawn as if it were off. - return [255, 214, 140]; - }; - let mut out = [255u8, 214, 140]; - for (slot, value) in out.iter_mut().zip(values) { - if let Some(number) = value.as_f64() { - *slot = number.clamp(0.0, 255.0) as u8; - } - } - out - } - - /// Whether this light can be given a colour, as opposed to only a brightness. A - /// `brightness_pct` sent to a plain on/off bulb is harmlessly ignored by HA, so - /// only the colour row is worth branching on. - pub fn is_color(&self) -> bool { - const COLOR_MODES: [&str; 5] = ["hs", "rgb", "rgbw", "rgbww", "xy"]; - self.attributes - .get("supported_color_modes") - .and_then(|v| v.as_array()) - .is_some_and(|modes| { - modes - .iter() - .filter_map(|m| m.as_str()) - .any(|m| COLOR_MODES.contains(&m)) - }) - } -} - -// --------------------------------------------------------------------- shutters -- - -/// Home Assistant's `CoverEntityFeature.SET_POSITION`. -pub const SUPPORT_SET_POSITION: u64 = 4; - -/// The four buttons on a shutter card, in percent *closed*. -pub const SHUTTER_PRESETS: [(&str, f64); 4] = [ - ("Offen", 0.0), - ("Halb zu", 50.0), - ("Fast zu", 85.0), - ("Ganz zu", 100.0), -]; - -/// HA reports percent *open*; the UI, following the design, thinks in percent *closed*. -/// The inversion lives in one place because it is easy to get backwards. -pub fn closed_percent(position: f64) -> f64 { - 100.0 - position -} - -pub fn position_from_closed(closed: f64) -> f64 { - 100.0 - closed -} - -pub fn closed_label(closed: f64) -> String { - if closed <= 1.0 { - "Offen".into() - } else if closed >= 99.0 { - "Ganz zu".into() - } else if closed >= 80.0 { - "Fast zu".into() - } else if closed >= 40.0 { - "Halb zu".into() - } else { - format!("{}% zu", closed.round()) - } -} - -// ---------------------------------------------------------------------- colours -- - -/// The six colours the room page offers, with the oklch each was authored as. Kept as -/// literals rather than converted at runtime for the same reason `ui/theme.slint` does: -/// the oklch is the form the design is maintained in, and the sRGB is what gets drawn. -pub const SWATCHES: [(&str, [u8; 3]); 6] = [ - ("Warmweiß", [0xf0, 0xdc, 0xb1]), // oklch(90% 0.06 85) - ("Sonnengelb", [0xef, 0xcc, 0x36]), // oklch(85% 0.16 95) - ("Korallenrot", [0xf1, 0x4d, 0x4c]), // oklch(65% 0.20 25) - ("Delfinblau", [0x00, 0xab, 0xed]), // oklch(70% 0.15 235) - ("Riffgrün", [0x42, 0xcb, 0x80]), // oklch(75% 0.16 155) - ("Quallenlila", [0xb1, 0x6a, 0xe0]), // oklch(65% 0.18 310) -]; - -// ----------------------------------------------------------------------- client -- - -impl Client { - /// `Ok(None)` when Home Assistant is not configured - a 404 here is a normal - /// answer, and the room tab simply does not appear. - pub fn ha_config(&self) -> Result, Error> { - match self.get_json::("/ha") { - Ok(config) => Ok(Some(config)), - Err(error) if error.contains("404") => Ok(None), - Err(error) => Err(error), - } - } - - pub fn ha_state(&self, entity_id: &str) -> Result { - self.get_json(&format!("/ha/states/{entity_id}")) - } - - pub fn ha_call( - &self, - domain: &str, - service: &str, - body: serde_json::Value, - ) -> Result<(), Error> { - self.post_json(&format!("/ha/services/{domain}/{service}"), body) - } -} - -/// What the poll thread sends back. -pub struct Snapshot { - pub states: HashMap, - /// When the request that produced this went out. The UI drops any entity whose - /// optimistic patch is newer - see the note in `Room::apply`. - pub started_at: Instant, -} - -pub enum Job { - /// Poll these entities once, now. - Poll(Vec), - Call { - domain: String, - service: String, - body: serde_json::Value, - }, -} - -/// One worker for both polling and commands, so a command can never overtake the poll -/// that was meant to confirm it. -pub fn spawn( - client: Client, - jobs: std::sync::mpsc::Receiver, - deliver: impl Fn(Snapshot) + Send + 'static, -) -> std::thread::JoinHandle<()> { - std::thread::Builder::new() - .name("ha".into()) - .spawn(move || { - for job in jobs { - match job { - Job::Call { - domain, - service, - body, - } => { - if let Err(error) = client.ha_call(&domain, &service, body) { - // The next poll corrects whatever the optimistic update - // guessed, so a failed call is worth a line and nothing more. - eprintln!("{domain}.{service}: {error}"); - } - } - Job::Poll(entity_ids) => { - let started_at = Instant::now(); - let mut states = HashMap::new(); - for entity_id in entity_ids { - if let Ok(state) = client.ha_state(&entity_id) { - states.insert(entity_id, state); - } - } - deliver(Snapshot { states, started_at }); - } - } - } - }) - .expect("spawning the home assistant worker") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn state(json: serde_json::Value) -> EntityState { - serde_json::from_value(json).unwrap() - } - - #[test] - fn closed_percent_inverts_home_assistants_convention() { - assert_eq!(closed_percent(100.0), 0.0); // fully open - assert_eq!(closed_percent(0.0), 100.0); // fully closed - assert_eq!(position_from_closed(85.0), 15.0); - } - - #[test] - fn closed_label_names_the_presets_and_falls_back_to_a_number() { - assert_eq!(closed_label(0.0), "Offen"); - assert_eq!(closed_label(100.0), "Ganz zu"); - assert_eq!(closed_label(85.0), "Fast zu"); - assert_eq!(closed_label(50.0), "Halb zu"); - assert_eq!(closed_label(20.0), "20% zu"); - } - - #[test] - fn a_colour_light_is_told_apart_by_its_modes() { - assert!(state(serde_json::json!({ - "state": "on", - "attributes": { "supported_color_modes": ["hs", "color_temp"] } - })) - .is_color()); - assert!(!state(serde_json::json!({ - "state": "on", - "attributes": { "supported_color_modes": ["brightness"] } - })) - .is_color()); - assert!(!state(serde_json::json!({ "state": "on", "attributes": {} })).is_color()); - } - - #[test] - fn rgb_falls_back_to_a_warm_white_rather_than_black() { - assert_eq!( - state(serde_json::json!({"state":"on","attributes":{}})).rgb(), - [255, 214, 140] - ); - assert_eq!( - state(serde_json::json!({ - "state": "on", "attributes": { "rgb_color": [10, 20, 30] } - })) - .rgb(), - [10, 20, 30] - ); - } - - #[test] - fn a_missing_attribute_reads_as_zero_rather_than_failing() { - let s = state(serde_json::json!({ "state": "off", "attributes": {} })); - assert_eq!(s.brightness(), 0.0); - assert_eq!(s.cover_position(), None); - assert_eq!(s.supported_features(), 0); - assert!(!s.is_on()); - } - - #[test] - fn a_device_knows_its_domain_and_its_label() { - let device: Device = - serde_json::from_value(serde_json::json!({ "entity_id": "light.kitchen" })).unwrap(); - assert_eq!(device.domain(), "light"); - assert_eq!(device.label(), "light.kitchen"); - } -} diff --git a/slint-frontend/src/keymap.rs b/slint-frontend/src/keymap.rs deleted file mode 100644 index 2e2d5cc..0000000 --- a/slint-frontend/src/keymap.rs +++ /dev/null @@ -1,915 +0,0 @@ -//! What you are looking at, and what a keypress does to it. -//! -//! Ported from `web/src/lib/keyboard.ts`, which is already a pure function from a -//! keypress to a list of actions - the single most portable thing in that codebase, and -//! tested there without a DOM for the same reason it is tested here without a window. -//! -//! The split this rests on: **what is playing** comes from the backend, **what you are -//! looking at** lives here. A real player has other front-ends - the buttons on the -//! mouse, a figurine on the reader, Home Assistant - so this UI follows playback rather -//! than owning it, and owns only the browse state. -//! -//! Not ported: the typing game, the room-lights page, the in-app help overlay and the -//! IR-remote assign mode, none of which this front-end hosts. - -use crate::library::{Group, Results, GROUPS}; - -pub const VOLUME_STEP: i32 = 10; -pub const SEEK_STEP: f64 = 15.0; -/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards. -pub const PAGE_ROWS: i32 = 3; - -/// Which top-level page is showing. Orthogonal to everything below it, so switching -/// pages and coming back leaves the music side exactly as it was. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Page { - Music, - Room, - Typing, -} - -impl Page { - pub fn icon(self) -> &'static str { - match self { - // A plain Unicode symbol rather than the emoji: that glyph carries its own - // muted colour on some platforms and ignores `color`, which makes it - // illegible on the rail's own background. - Self::Music => "♪", - Self::Room => "💡", - Self::Typing => "⌨️", - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Music => "Musik", - Self::Room => "Mein Zimmer", - Self::Typing => "Tippen", - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct UiState { - pub page: Page, - pub search: String, - pub tracks_mode: bool, - /// `None` is the bare root screen (three shelves); otherwise which one is open. - pub group: Option, - pub category: Option, - pub sel_index: usize, - /// Which of the three root shelves is focused. Only meaningful at the bare root, - /// where `sel_index` doubles as "which tile in that row". - pub shelf_row: usize, - pub play_view: bool, - pub open_album: Option, - /// Which track is highlighted in the open album's list. - pub sheet_index: usize, - /// Grid columns, recomputed from the window width - `move_selection` needs it to - /// know what one row down means. - pub cols: usize, -} - -impl Default for UiState { - fn default() -> Self { - Self { - page: Page::Music, - search: String::new(), - tracks_mode: false, - group: None, - category: None, - sel_index: 0, - shelf_row: 0, - play_view: false, - open_album: None, - sheet_index: 0, - cols: 4, - } - } -} - -impl UiState { - /// The true root: nothing chosen yet, drawn as three shelves rather than a list. - pub fn is_root_shelf(&self) -> bool { - self.group.is_none() - && self.search.is_empty() - && !self.tracks_mode - && self.category.is_none() - } - - pub fn browsing(&self) -> bool { - !self.play_view && self.open_album.is_none() - } - - /// One step of "back": search, then track-search mode, then group and category - /// together - the order ESC and the corner button both use. - /// - /// `group` and `category` clear as one step because a root shelf tile sets both at - /// once, jumping straight to one category's albums; undoing that jump should be one - /// step too, however the category was reached. - pub fn browse_back(&mut self) { - self.sel_index = 0; - if !self.search.is_empty() { - self.search.clear(); - } else if self.tracks_mode { - self.tracks_mode = false; - } else { - self.group = None; - self.category = None; - } - } - - /// Whether anything is left to back out of - what the corner button's presence is. - pub fn can_go_back(&self) -> bool { - self.page != Page::Music - || self.play_view - || self.open_album.is_some() - || !self.search.is_empty() - || self.tracks_mode - || self.group.is_some() - || self.category.is_some() - } -} - -/// What a keypress asks the *player* to do. Everything else a key can do is a change to -/// [`UiState`], applied in place. -#[derive(Debug, Clone, PartialEq)] -pub enum Action { - Play { - album_id: String, - track_index: usize, - }, - Toggle, - Next, - Previous, - /// Percentage points, positive or negative. - Volume(i32), - /// Seconds, positive or negative. - Seek(f64), - Mute, -} - -/// Where the flat selection index is pointing. Songs, then categories, then albums. -enum Selection { - Song { - album_id: String, - track_index: usize, - }, - Category(String), - Album(String), - None, -} - -fn selection_at(results: &Results, sel_index: usize) -> Selection { - let total = results.total(); - if total == 0 { - return Selection::None; - } - let index = sel_index.min(total - 1); - if let Some(hit) = results.songs.get(index) { - return Selection::Song { - album_id: hit.album.id.clone(), - track_index: hit.index, - }; - } - let after_songs = index - results.songs.len(); - if let Some(category) = results.categories.get(after_songs) { - return Selection::Category(category.key.clone()); - } - match results.albums.get(after_songs - results.categories.len()) { - Some(album) => Selection::Album(album.id.clone()), - None => Selection::None, - } -} - -/// The album behind the selection, when "show me the tracks" makes sense for it: an -/// album tile directly, or - so you can reach the rest of it - the album behind a song -/// hit. `None` for a category tile, and for a podcast episode, which is a single track -/// with nothing else to show. -pub fn selection_album(results: &Results, sel_index: usize) -> Option { - let total = results.total(); - if total == 0 { - return None; - } - let index = sel_index.min(total - 1); - let album = if let Some(hit) = results.songs.get(index) { - Some(&hit.album) - } else { - let after_songs = index - results.songs.len(); - if after_songs < results.categories.len() { - return None; - } - results.albums.get(after_songs - results.categories.len()) - }?; - (Group::of(album) != Group::Podcasts).then(|| album.id.clone()) -} - -/// The bare root is a genuine two-axis layout: three rows, each a differently-long row -/// of tiles. `shelf_lengths` is how long each row actually is, so a vertical move can -/// clamp the column to the row it lands on rather than guessing. -fn move_root_shelf(state: &mut UiState, dx: i32, dy: i32, shelf_lengths: &[usize; 3]) { - if dy != 0 { - state.shelf_row = (state.shelf_row as i32 + dy).clamp(0, GROUPS.len() as i32 - 1) as usize; - state.sel_index = state - .sel_index - .min(shelf_lengths[state.shelf_row].saturating_sub(1)); - } - if dx != 0 { - let last = shelf_lengths[state.shelf_row].saturating_sub(1) as i32; - state.sel_index = (state.sel_index as i32 + dx).clamp(0, last.max(0)) as usize; - } -} - -fn move_selection(state: &mut UiState, results: &Results, dx: i32, dy: i32) { - let total = results.total(); - if total == 0 { - return; - } - let cols = state.cols.max(1) as i32; - let mut index = state.sel_index.min(total - 1) as i32; - index += dx; - if dy != 0 { - // Song hits are a single-column list; everything below them is a grid. - index += if (index as usize) < results.songs.len() { - dy - } else { - dy * cols - }; - } - state.sel_index = index.clamp(0, total as i32 - 1) as usize; -} - -/// The keys that mean the same thing from anywhere, matching the muscle memory of every -/// other vim-ish media app. Keyed on the exact shifted letter, so this intercepts only -/// these five - every other Shift+letter still reaches the search box. Search is -/// case-insensitive, so nothing searchable is lost. -fn shift_media(key: &str) -> Option { - match key.to_ascii_uppercase().as_str() { - "H" => Some(Action::Previous), - "L" => Some(Action::Next), - "K" => Some(Action::Volume(VOLUME_STEP)), - "J" => Some(Action::Volume(-VOLUME_STEP)), - "M" => Some(Action::Mute), - _ => None, - } -} - -/// A plain character key's default: type it into the search box. Letters and digits -/// only - `char::is_alphanumeric` rather than `[a-z0-9]`, so the umlauts a German title -/// needs are searchable too. -fn type_into_search(state: &mut UiState, key: &str) -> bool { - let mut chars = key.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) if c.is_alphanumeric() => { - state.search.push(c); - state.play_view = false; - state.sel_index = 0; - true - } - _ => false, - } -} - -/// Translate one keypress: mutate `state`, and return whatever the player should be -/// asked to do. -/// -/// `key` is already named by the UI layer - `"escape"`, `"left"`, `"enter"`, … - or is -/// the literal character typed. `shelf_lengths` and `sheet_tracks` are the two counts -/// that `results` does not carry but the clamping needs. -pub fn handle_key( - key: &str, - ctrl: bool, - shift: bool, - state: &mut UiState, - results: &Results, - shelf_lengths: &[usize; 3], - sheet_tracks: usize, -) -> Vec { - // Reachable from anywhere, including from the play view and with an album sheet open. - if shift && !ctrl { - if let Some(action) = shift_media(key) { - return vec![action]; - } - } - - if key == "space" { - // Except mid-search, where a space is punctuation the query needs, as in - // "geolino azte". - if state.browsing() && !state.search.is_empty() { - state.search.push(' '); - state.sel_index = 0; - return vec![]; - } - return vec![Action::Toggle]; - } - - // Ctrl+Tab cycles the pages. This is an addition to the web front-end, where the - // tab rail is reachable only with a pointer - which sits badly with a device whose - // stated design is that every screen can be reached from the keyboard. Plain Tab - // is already taken by the group cycle, and Ctrl+Tab is the idiom for the outer - // one everywhere else. - if ctrl && key == "tab" { - state.page = match state.page { - Page::Music => Page::Room, - Page::Room => Page::Typing, - Page::Typing => Page::Music, - }; - return vec![]; - } - - // Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the - // arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump. - if ctrl { - if state.open_album.is_some() { - match key.to_ascii_lowercase().as_str() { - "j" => { - state.sheet_index = (state.sheet_index + 1).min(sheet_tracks.saturating_sub(1)) - } - "k" => state.sheet_index = state.sheet_index.saturating_sub(1), - _ => {} - } - return vec![]; - } - if state.play_view { - return vec![]; - } - let lower = key.to_ascii_lowercase(); - if state.is_root_shelf() { - match lower.as_str() { - "h" => move_root_shelf(state, -1, 0, shelf_lengths), - "l" => move_root_shelf(state, 1, 0, shelf_lengths), - "j" | "d" => move_root_shelf(state, 0, 1, shelf_lengths), - "k" | "u" => move_root_shelf(state, 0, -1, shelf_lengths), - _ => {} - } - return vec![]; - } - match lower.as_str() { - "h" => move_selection(state, results, -1, 0), - "l" => move_selection(state, results, 1, 0), - "j" => move_selection(state, results, 0, 1), - "k" => move_selection(state, results, 0, -1), - "d" => move_selection(state, results, 0, PAGE_ROWS), - "u" => move_selection(state, results, 0, -PAGE_ROWS), - _ => {} - } - return vec![]; - } - - let browsing = state.browsing(); - - match key { - "tab" => { - let current = state.group.map_or(-1, |g| g.index() as i32); - state.group = Some(Group::from_index( - ((current + 1) % GROUPS.len() as i32) as usize, - )); - state.category = None; - state.sel_index = 0; - state.play_view = false; - vec![] - } - "/" => { - state.play_view = false; - vec![] - } - - // Arrows browse while browsing and drive the player while playing - the one - // pair of keys that changes meaning with the screen, because on the play view - // there is no selection for them to move. - "right" => { - if shift { - return vec![Action::Seek(SEEK_STEP)]; - } - if !browsing { - return vec![Action::Next]; - } - if state.is_root_shelf() { - move_root_shelf(state, 1, 0, shelf_lengths); - } else { - move_selection(state, results, 1, 0); - } - vec![] - } - "left" => { - if shift { - return vec![Action::Seek(-SEEK_STEP)]; - } - if !browsing { - return vec![Action::Previous]; - } - if state.is_root_shelf() { - move_root_shelf(state, -1, 0, shelf_lengths); - } else { - move_selection(state, results, -1, 0); - } - vec![] - } - "down" => { - if !browsing { - return vec![Action::Volume(-VOLUME_STEP)]; - } - if state.is_root_shelf() { - move_root_shelf(state, 0, 1, shelf_lengths); - } else { - move_selection(state, results, 0, 1); - } - vec![] - } - "up" => { - if !browsing { - return vec![Action::Volume(VOLUME_STEP)]; - } - if state.is_root_shelf() { - move_root_shelf(state, 0, -1, shelf_lengths); - } else { - move_selection(state, results, 0, -1); - } - vec![] - } - - "?" => { - state.tracks_mode = true; - state.search.clear(); - state.sel_index = 0; - state.play_view = false; - vec![] - } - "escape" => { - // Peel one layer at a time rather than dumping you back at the top. The - // page is the outermost layer: ESC on the room or typing page comes back - // to the player, and only then starts unwinding the browse hierarchy. - if state.page != Page::Music { - state.page = Page::Music; - } else if state.open_album.is_some() { - state.open_album = None; - } else if state.play_view { - state.play_view = false; - } else { - state.browse_back(); - } - vec![] - } - "backspace" => { - if !state.search.is_empty() { - state.search.pop(); - state.sel_index = 0; - } - vec![] - } - "enter" => { - if let Some(album_id) = state.open_album.clone() { - return vec![Action::Play { - album_id, - track_index: state.sheet_index, - }]; - } - if browsing && state.is_root_shelf() { - let row = state.shelf_row.min(GROUPS.len() - 1); - state.group = Some(Group::from_index(row)); - // A shelf row with no tiles still opens its group; there is simply no - // category to pre-select. - state.category = None; - state.sel_index = 0; - return vec![]; - } - if shift { - if let Some(album_id) = selection_album(results, state.sel_index) { - state.open_album = Some(album_id); - state.sheet_index = 0; - return vec![]; - } - } - match selection_at(results, state.sel_index) { - Selection::Song { - album_id, - track_index, - } => { - vec![Action::Play { - album_id, - track_index, - }] - } - Selection::Album(album_id) => vec![Action::Play { - album_id, - track_index: 0, - }], - Selection::Category(key) => { - state.category = Some(key); - state.sel_index = 0; - vec![] - } - Selection::None => vec![], - } - } - "f1" => vec![], - _ => { - type_into_search(state, key); - vec![] - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::library::{Category, Library, SongHit}; - - fn empty() -> Results { - Results::default() - } - - fn press(key: &str, state: &mut UiState) -> Vec { - handle_key(key, false, false, state, &empty(), &[0, 0, 0], 0) - } - - #[test] - fn typing_a_letter_searches_and_leaves_the_play_view() { - let mut state = UiState { - play_view: true, - ..Default::default() - }; - press("k", &mut state); - press("i", &mut state); - assert_eq!(state.search, "ki"); - assert!(!state.play_view); - } - - #[test] - fn umlauts_are_searchable_like_any_other_letter() { - let mut state = UiState::default(); - press("ö", &mut state); - assert_eq!(state.search, "ö"); - } - - #[test] - fn space_toggles_but_types_mid_search() { - let mut state = UiState::default(); - assert_eq!(press("space", &mut state), vec![Action::Toggle]); - - state.search = "geolino".into(); - assert!(press("space", &mut state).is_empty()); - assert_eq!(state.search, "geolino "); - } - - #[test] - fn space_toggles_from_the_play_view_even_with_a_query_behind_it() { - let mut state = UiState { - search: "conni".into(), - play_view: true, - ..Default::default() - }; - assert_eq!(press("space", &mut state), vec![Action::Toggle]); - } - - #[test] - fn shift_media_works_from_every_screen() { - for play_view in [false, true] { - let mut state = UiState { - play_view, - ..Default::default() - }; - let got = handle_key("H", false, true, &mut state, &empty(), &[0, 0, 0], 0); - assert_eq!(got, vec![Action::Previous]); - let got = handle_key("K", false, true, &mut state, &empty(), &[0, 0, 0], 0); - assert_eq!(got, vec![Action::Volume(VOLUME_STEP)]); - } - } - - #[test] - fn other_shifted_letters_still_reach_the_search_box() { - let mut state = UiState::default(); - handle_key("C", false, true, &mut state, &empty(), &[0, 0, 0], 0); - assert_eq!(state.search, "C"); - } - - #[test] - fn arrows_browse_while_browsing_and_drive_the_player_while_playing() { - let mut state = UiState { - group: Some(Group::Music), - ..Default::default() - }; - assert!(press("right", &mut state).is_empty()); - - state.play_view = true; - assert_eq!(press("right", &mut state), vec![Action::Next]); - assert_eq!(press("up", &mut state), vec![Action::Volume(VOLUME_STEP)]); - assert_eq!( - press("down", &mut state), - vec![Action::Volume(-VOLUME_STEP)] - ); - } - - #[test] - fn shift_arrows_seek_from_either_screen() { - let mut state = UiState::default(); - let got = handle_key("right", false, true, &mut state, &empty(), &[0, 0, 0], 0); - assert_eq!(got, vec![Action::Seek(SEEK_STEP)]); - let got = handle_key("left", false, true, &mut state, &empty(), &[0, 0, 0], 0); - assert_eq!(got, vec![Action::Seek(-SEEK_STEP)]); - } - - #[test] - fn the_root_shelf_is_two_axis_and_clamps_to_the_row_it_lands_on() { - let mut state = UiState::default(); - let lengths = [5, 2, 0]; - - handle_key("right", false, false, &mut state, &empty(), &lengths, 0); - handle_key("right", false, false, &mut state, &empty(), &lengths, 0); - handle_key("right", false, false, &mut state, &empty(), &lengths, 0); - assert_eq!((state.shelf_row, state.sel_index), (0, 3)); - - // Row 1 only has two tiles, so the column has to come back with it. - handle_key("down", false, false, &mut state, &empty(), &lengths, 0); - assert_eq!((state.shelf_row, state.sel_index), (1, 1)); - - // Row 2 has none at all. - handle_key("down", false, false, &mut state, &empty(), &lengths, 0); - assert_eq!((state.shelf_row, state.sel_index), (2, 0)); - - // And it does not walk off the bottom. - handle_key("down", false, false, &mut state, &empty(), &lengths, 0); - assert_eq!(state.shelf_row, 2); - } - - #[test] - fn enter_at_the_root_opens_the_focused_shelfs_group() { - let mut state = UiState { - shelf_row: 1, - ..Default::default() - }; - assert!(press("enter", &mut state).is_empty()); - assert_eq!(state.group, Some(Group::Audiobooks)); - assert_eq!(state.sel_index, 0); - } - - #[test] - fn escape_peels_one_layer_at_a_time() { - let mut state = UiState { - group: Some(Group::Music), - category: Some("Rolf".into()), - search: "abc".into(), - tracks_mode: true, - play_view: true, - open_album: Some("x".into()), - ..Default::default() - }; - - press("escape", &mut state); - assert!(state.open_album.is_none(), "the sheet closes first"); - press("escape", &mut state); - assert!(!state.play_view, "then the play view"); - press("escape", &mut state); - assert_eq!(state.search, "", "then the query"); - press("escape", &mut state); - assert!(!state.tracks_mode, "then track-search mode"); - press("escape", &mut state); - assert_eq!( - (state.group, state.category.clone()), - (None, None), - "then both at once" - ); - assert!(!state.can_go_back()); - } - - #[test] - fn ctrl_tab_cycles_the_pages_and_plain_tab_still_cycles_groups() { - let mut state = UiState::default(); - for expected in [Page::Room, Page::Typing, Page::Music] { - handle_key("tab", true, false, &mut state, &empty(), &[0, 0, 0], 0); - assert_eq!(state.page, expected); - } - // And the outer cycle has not eaten the inner one. - press("tab", &mut state); - assert_eq!(state.group, Some(Group::Music)); - assert_eq!(state.page, Page::Music); - } - - #[test] - fn escape_comes_back_from_another_page_before_it_touches_the_browse_state() { - let mut state = UiState { - page: Page::Room, - group: Some(Group::Music), - ..Default::default() - }; - press("escape", &mut state); - assert_eq!(state.page, Page::Music); - assert_eq!( - state.group, - Some(Group::Music), - "the player was left as it was" - ); - press("escape", &mut state); - assert_eq!(state.group, None); - } - - #[test] - fn tab_cycles_the_three_groups_and_wraps() { - let mut state = UiState::default(); - for expected in [ - Group::Music, - Group::Audiobooks, - Group::Podcasts, - Group::Music, - ] { - press("tab", &mut state); - assert_eq!(state.group, Some(expected)); - } - } - - #[test] - fn backspace_only_bites_when_there_is_a_query() { - let mut state = UiState { - search: "ab".into(), - ..Default::default() - }; - press("backspace", &mut state); - assert_eq!(state.search, "a"); - press("backspace", &mut state); - press("backspace", &mut state); - assert_eq!(state.search, ""); - } - - #[test] - fn question_mark_switches_to_track_search_and_clears_the_query() { - let mut state = UiState { - search: "conni".into(), - ..Default::default() - }; - press("?", &mut state); - assert!(state.tracks_mode); - assert_eq!(state.search, ""); - } - - // ---- selection over a real results set ---- - - fn results_with(songs: usize, categories: usize, albums: usize) -> Results { - let library = Library::build( - (0..albums.max(1)) - .map(|i| { - serde_json::from_value(serde_json::json!({ - "id": format!("al{i}"), - "section": "Musik", - "kind": "music", - "title": format!("Album {i}"), - "artist": "A", - "category": "A", - "tracks": [{ "title": "T", "duration": 10.0 }], - })) - .unwrap() - }) - .collect(), - ); - Results { - songs: (0..songs) - .map(|i| SongHit { - album: std::rc::Rc::clone(&library.albums[0]), - index: i, - title: format!("S{i}"), - duration: 10.0, - }) - .collect(), - categories: (0..categories) - .map(|i| Category { - key: format!("C{i}"), - albums: vec![], - }) - .collect(), - albums: library - .albums - .iter() - .take(albums) - .map(std::rc::Rc::clone) - .collect(), - } - } - - #[test] - fn the_selection_runs_flat_across_songs_then_categories_then_albums() { - let results = results_with(2, 2, 3); - assert_eq!(results.total(), 7); - - let mut state = UiState { - group: Some(Group::Music), - cols: 3, - ..Default::default() - }; - - // Inside the song list a vertical move is one row, not one grid row. - handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0); - assert_eq!(state.sel_index, 1); - - // Past the songs it jumps a whole grid row. - state.sel_index = 2; - handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0); - assert_eq!(state.sel_index, 5); - - // And it never runs off the end. - handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0); - assert_eq!(state.sel_index, 6); - } - - #[test] - fn enter_plays_a_song_hit_at_its_own_track_index() { - let results = results_with(2, 0, 0); - let mut state = UiState { - group: Some(Group::Music), - sel_index: 1, - ..Default::default() - }; - let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0); - assert_eq!( - got, - vec![Action::Play { - album_id: "al0".into(), - track_index: 1 - }] - ); - } - - #[test] - fn enter_on_a_category_drills_in_rather_than_playing() { - let results = results_with(0, 2, 0); - let mut state = UiState { - group: Some(Group::Music), - sel_index: 1, - ..Default::default() - }; - assert!(handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0).is_empty()); - assert_eq!(state.category.as_deref(), Some("C1")); - } - - #[test] - fn enter_on_an_album_plays_it_from_the_top() { - let results = results_with(0, 0, 3); - let mut state = UiState { - group: Some(Group::Music), - sel_index: 2, - ..Default::default() - }; - let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0); - assert_eq!( - got, - vec![Action::Play { - album_id: "al2".into(), - track_index: 0 - }] - ); - } - - #[test] - fn shift_enter_opens_the_track_list_instead() { - let results = results_with(0, 0, 2); - let mut state = UiState { - group: Some(Group::Music), - sel_index: 1, - ..Default::default() - }; - assert!(handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0).is_empty()); - assert_eq!(state.open_album.as_deref(), Some("al1")); - } - - #[test] - fn shift_enter_on_a_category_falls_through_to_drilling_in() { - let results = results_with(0, 1, 0); - let mut state = UiState { - group: Some(Group::Music), - sel_index: 0, - ..Default::default() - }; - handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0); - assert!(state.open_album.is_none()); - assert_eq!(state.category.as_deref(), Some("C0")); - } - - #[test] - fn ctrl_jk_walks_the_open_track_list_and_clamps() { - let mut state = UiState { - open_album: Some("al0".into()), - ..Default::default() - }; - handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3); - handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3); - handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3); - assert_eq!(state.sheet_index, 2); - handle_key("k", true, false, &mut state, &empty(), &[0, 0, 0], 3); - assert_eq!(state.sheet_index, 1); - } - - #[test] - fn enter_with_the_sheet_open_plays_the_highlighted_track() { - let mut state = UiState { - open_album: Some("al0".into()), - sheet_index: 2, - ..Default::default() - }; - let got = handle_key("enter", false, false, &mut state, &empty(), &[0, 0, 0], 4); - assert_eq!( - got, - vec![Action::Play { - album_id: "al0".into(), - track_index: 2 - }] - ); - } -} diff --git a/slint-frontend/src/library.rs b/slint-frontend/src/library.rs deleted file mode 100644 index 15a3ca6..0000000 --- a/slint-frontend/src/library.rs +++ /dev/null @@ -1,504 +0,0 @@ -//! Browsing and searching the library, entirely on the client. -//! -//! The whole index arrives in one response, so type-to-search has no round trip - which -//! is the point of a keyboard-first UI. Ported from `web/src/lib/search.ts`, including -//! the reason that module is shaped the way it is: -//! -//! The cost that matters is per keystroke, not per library load. A real collection is -//! 343 albums and 4969 tracks, and the naive version re-derives its search keys from -//! scratch on every one of them for every letter typed - `normalize` runs a lowercase, -//! a Unicode NFD decomposition and two filtering passes, so track search would mean -//! five thousand NFD normalizations before a single comparison. That is work whose -//! answer cannot change until the library does, so it happens once, in -//! [`Library::build`], and a keystroke becomes `str::contains` over strings that -//! already exist. - -use std::collections::HashMap; -use std::rc::Rc; - -use unicode_normalization::UnicodeNormalization; - -use crate::api::Album; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Group { - Music, - Audiobooks, - Podcasts, -} - -pub const GROUPS: [Group; 3] = [Group::Music, Group::Audiobooks, Group::Podcasts]; - -impl Group { - /// Which of the three shelves an album belongs on. A podcast is exactly a - /// `Kinderpodcasts`-section album; everything else buckets by `kind`, so a Figuren - /// album joins whichever of music/audiobooks matches what it actually holds. - pub fn of(album: &Album) -> Self { - if album.section == "Kinderpodcasts" { - Self::Podcasts - } else if album.is_book() { - Self::Audiobooks - } else { - Self::Music - } - } - - pub fn index(self) -> usize { - match self { - Self::Music => 0, - Self::Audiobooks => 1, - Self::Podcasts => 2, - } - } - - pub fn from_index(index: usize) -> Self { - GROUPS[index.min(2)] - } - - pub fn label(self) -> &'static str { - match self { - Self::Music => "Musik", - Self::Audiobooks => "Hörbücher", - Self::Podcasts => "Podcasts", - } - } - - pub fn icon(self) -> &'static str { - match self { - Self::Music => "🎵", - Self::Audiobooks => "📖", - Self::Podcasts => "🎙️", - } - } - - /// Heading over the category tiles inside this group. - pub fn category_label(self) -> &'static str { - match self { - Self::Music => "Künstler", - Self::Audiobooks => "Figuren", - Self::Podcasts => "Sendungen", - } - } - - /// Heading over the album grid inside this group. - pub fn section_label(self) -> &'static str { - match self { - Self::Music => "Alben", - Self::Audiobooks => "Hörbücher", - Self::Podcasts => "Episoden", - } - } - - /// Per-group hue in oklch degrees, shared with the generated cover art so a shelf - /// already hints at the mood its albums play into. - pub fn hue(self) -> f32 { - match self { - Self::Music => 210.0, // today's sea - the baseline hue - Self::Audiobooks => 55.0, // warm amber - Self::Podcasts => 300.0, // the one otherwise-unused family - } - } -} - -/// Diacritics and punctuation are noise when a child is hunting for letters. -pub fn normalize(value: &str) -> String { - value - .to_lowercase() - .nfd() - // Combining marks, i.e. what NFD just split off the base letters. - .filter(|c| !matches!(*c as u32, 0x0300..=0x036F)) - .filter(|c| c.is_ascii_alphanumeric()) - .collect() -} - -/// Words of a query, normalized individually. A space-separated query like "conni rad" -/// should find "Conni lernt Rad fahren" even though "rad" is not right after "conni", -/// so requiring the whole phrase to be one contiguous substring is too strict. -fn search_words(value: &str) -> Vec { - value - .split_whitespace() - .map(normalize) - .filter(|w| !w.is_empty()) - .collect() -} - -/// Every word has to show up somewhere in the haystack, in any order. -fn matches_words(haystack: &str, words: &[String]) -> bool { - words.iter().all(|word| haystack.contains(word.as_str())) -} - -pub struct AlbumEntry { - pub album: Rc, - /// `normalize(title + artist)`, as a naive `album_matches` would recompute per key. - haystack: String, -} - -pub struct TrackEntry { - pub album: Rc, - /// Index within `album.tracks`, which is what playback wants. - pub index: usize, - pub title: String, - pub duration: f64, - haystack: String, -} - -#[derive(Clone)] -pub struct Category { - pub key: String, - pub albums: Vec>, -} - -#[derive(Clone)] -pub struct SongHit { - pub album: Rc, - pub index: usize, - pub title: String, - pub duration: f64, -} - -/// Everything a search needs, derived once per library payload. -#[derive(Default)] -pub struct Library { - pub albums: Vec>, - entries: Vec, - by_group: [Vec; 3], - /// Each group's categories, already grouped and sorted. - categories_by_group: [Vec; 3], - /// Unlocked tracks only, album order then track order. A locked track shows as a - /// question mark wherever it appears, so it has no title to match on and is left - /// out of the index entirely rather than skipped at match time. - tracks: Vec, - tracks_by_group: [Vec; 3], - by_id: HashMap>, -} - -impl Library { - pub fn build(albums: Vec) -> Self { - let mut library = Self::default(); - // One map per group rather than one overall: two groups may legitimately hold - // the same category name, and merging them would put an artist's albums on - // the audiobook shelf. - let mut category_maps: [HashMap>>; 3] = Default::default(); - - for album in albums { - let album = Rc::new(album); - let group = Group::of(&album); - - library.by_id.insert(album.id.clone(), Rc::clone(&album)); - library.albums.push(Rc::clone(&album)); - - library.by_group[group.index()].push(library.entries.len()); - library.entries.push(AlbumEntry { - haystack: normalize(&format!("{}{}", album.title, album.artist)), - album: Rc::clone(&album), - }); - - category_maps[group.index()] - .entry(album.category.clone()) - .or_default() - .push(Rc::clone(&album)); - - for (index, track) in album.tracks.iter().enumerate() { - if track.locked { - continue; - } - library.tracks_by_group[group.index()].push(library.tracks.len()); - library.tracks.push(TrackEntry { - haystack: normalize(&track.title), - album: Rc::clone(&album), - index, - title: track.title.clone(), - duration: track.duration, - }); - } - } - - for (slot, map) in library.categories_by_group.iter_mut().zip(category_maps) { - let mut categories: Vec = map - .into_iter() - .map(|(key, albums)| Category { key, albums }) - .collect(); - // `localeCompare(_, "de")` in the original. A full collator is more than - // this earns; normalizing first folds the umlauts, which is the whole of - // what German ordering asks for here. - categories.sort_by_key(|c| normalize(&c.key)); - *slot = categories; - } - - library - } - - pub fn get(&self, album_id: &str) -> Option<&Rc> { - self.by_id.get(album_id) - } - - pub fn categories(&self, group: Group) -> &[Category] { - &self.categories_by_group[group.index()] - } -} - -// --------------------------------------------------------------------- queries -- - -/// `group: None` means unrestricted - typing at the root searches every group at once. -pub struct Query<'a> { - pub search: &'a str, - pub tracks_mode: bool, - pub group: Option, - pub category: Option<&'a str>, -} - -/// Which of the three lists the browse view is showing. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListMode { - Tracks, - Albums, - Categories, -} - -/// Capped, because an empty query over 900 podcast episodes is not a useful screen. -pub const MAX_SONG_HITS: usize = 40; - -/// Selection indices run flat across songs, then categories, then albums, so one pair -/// of arrow keys walks the whole page. -#[derive(Default)] -pub struct Results { - pub songs: Vec, - pub categories: Vec, - pub albums: Vec>, -} - -impl Results { - pub fn total(&self) -> usize { - self.songs.len() + self.categories.len() + self.albums.len() - } -} - -impl Library { - pub fn list_mode(&self, query: &Query) -> ListMode { - if query.tracks_mode { - ListMode::Tracks - } else if !query.search.is_empty() || query.category.is_some() { - ListMode::Albums - } else { - ListMode::Categories - } - } - - fn album_pool(&self, query: &Query) -> Vec { - match query.group { - None => (0..self.entries.len()).collect(), - Some(group) => self.by_group[group.index()].clone(), - } - } - - pub fn category_matches(&self, query: &Query) -> Vec { - // `group: None` is the bare root screen, rendered as three shelves instead of a - // flat category list - each shelf asks again with its own group filled in. - match (self.list_mode(query), query.group) { - (ListMode::Categories, Some(group)) => self.categories(group).to_vec(), - _ => Vec::new(), - } - } - - pub fn album_matches(&self, query: &Query) -> Vec> { - if query.tracks_mode { - return Vec::new(); - } - let words = search_words(query.search); - if words.is_empty() && query.category.is_none() { - return Vec::new(); - } - self.album_pool(query) - .into_iter() - .map(|i| &self.entries[i]) - .filter(|entry| match query.category { - Some(category) => entry.album.category == category, - None => true, - }) - .filter(|entry| words.is_empty() || matches_words(&entry.haystack, &words)) - .map(|entry| Rc::clone(&entry.album)) - .collect() - } - - pub fn song_matches(&self, query: &Query) -> Vec { - if !query.tracks_mode { - return Vec::new(); - } - let words = search_words(query.search); - let pool: &[usize] = match query.group { - None => return self.take_song_hits(self.tracks.iter(), &words), - Some(group) => &self.tracks_by_group[group.index()], - }; - self.take_song_hits(pool.iter().map(|&i| &self.tracks[i]), &words) - } - - /// Stopping at the cap rather than filling every hit and slicing is what keeps an - /// empty query in tracks mode from walking all 4969 tracks to show 40 rows. - fn take_song_hits<'a>( - &self, - pool: impl Iterator, - words: &[String], - ) -> Vec { - pool.filter(|track| words.is_empty() || matches_words(&track.haystack, words)) - .take(MAX_SONG_HITS) - .map(|track| SongHit { - album: Rc::clone(&track.album), - index: track.index, - title: track.title.clone(), - duration: track.duration, - }) - .collect() - } - - pub fn results(&self, query: &Query) -> Results { - Results { - songs: self.song_matches(query), - categories: self.category_matches(query), - albums: self.album_matches(query), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn album(id: &str, title: &str, artist: &str, section: &str, tracks: &[&str]) -> Album { - serde_json::from_value(serde_json::json!({ - "id": id, - "section": section, - "kind": if section == "Musik" { "music" } else { "book" }, - "title": title, - "artist": artist, - "category": artist, - "tracks": tracks.iter().map(|t| serde_json::json!({ "title": t, "duration": 60.0 })).collect::>(), - })) - .unwrap() - } - - fn fixture() -> Library { - Library::build(vec![ - album( - "a", - "Grüffelo", - "Julia Donaldson", - "Hörbücher", - &["Kapitel 1", "Kapitel 2"], - ), - album("b", "Bibi Blocksberg", "Bibi", "Hörbücher", &["Hexerei"]), - album( - "c", - "Rote Lieder", - "Rolf Zuckowski", - "Musik", - &["Wie schön"], - ), - album( - "d", - "Maus Folge 3", - "Sendung mit der Maus", - "Kinderpodcasts", - &["Folge 3"], - ), - ]) - } - - #[test] - fn normalize_folds_umlauts_and_punctuation() { - assert_eq!(normalize("Hörbücher!"), "horbucher"); - assert_eq!(normalize("Grüffelo"), "gruffelo"); - assert_eq!(normalize("A-B C"), "abc"); - } - - #[test] - fn groups_split_podcasts_out_of_books() { - let library = fixture(); - assert_eq!(Group::of(library.get("d").unwrap()), Group::Podcasts); - assert_eq!(Group::of(library.get("a").unwrap()), Group::Audiobooks); - assert_eq!(Group::of(library.get("c").unwrap()), Group::Music); - } - - #[test] - fn album_search_is_word_order_independent() { - let library = fixture(); - let hits = library.album_matches(&Query { - search: "donaldson gruffelo", - tracks_mode: false, - group: None, - category: None, - }); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "a"); - } - - #[test] - fn an_empty_query_lists_no_albums_but_does_list_categories() { - let library = fixture(); - let query = Query { - search: "", - tracks_mode: false, - group: Some(Group::Audiobooks), - category: None, - }; - assert!(library.album_matches(&query).is_empty()); - assert_eq!(library.category_matches(&query).len(), 2); - } - - #[test] - fn the_root_screen_has_no_flat_category_list_of_its_own() { - let library = fixture(); - let query = Query { - search: "", - tracks_mode: false, - group: None, - category: None, - }; - assert!(library.category_matches(&query).is_empty()); - } - - #[test] - fn a_category_lists_its_albums_without_a_search() { - let library = fixture(); - let hits = library.album_matches(&Query { - search: "", - tracks_mode: false, - group: Some(Group::Audiobooks), - category: Some("Bibi"), - }); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].id, "b"); - } - - #[test] - fn track_search_is_capped_and_group_scoped() { - let library = fixture(); - let hits = library.song_matches(&Query { - search: "kapitel", - tracks_mode: true, - group: None, - category: None, - }); - assert_eq!(hits.len(), 2); - assert!(hits.len() <= MAX_SONG_HITS); - - let scoped = library.song_matches(&Query { - search: "", - tracks_mode: true, - group: Some(Group::Music), - category: None, - }); - assert_eq!(scoped.len(), 1); - assert_eq!(scoped[0].title, "Wie schön"); - } - - #[test] - fn categories_sort_by_folded_key() { - let library = fixture(); - let keys: Vec<&str> = library - .categories(Group::Audiobooks) - .iter() - .map(|c| c.key.as_str()) - .collect(); - assert_eq!(keys, ["Bibi", "Julia Donaldson"]); - } -} diff --git a/slint-frontend/src/main.rs b/slint-frontend/src/main.rs deleted file mode 100644 index 9e0c882..0000000 --- a/slint-frontend/src/main.rs +++ /dev/null @@ -1,1363 +0,0 @@ -//! A native front-end for the MusicMouse player. -//! -//! The same split the web front-end is built on: **what is playing** comes from the -//! backend over a push-only websocket, **what you are looking at** lives here. The -//! device has other front-ends - the buttons on the mouse, a figurine on the reader, -//! Home Assistant - so this follows playback rather than owning it. -//! -//! Three threads and no async runtime. The UI thread does layout and nothing else; a -//! websocket thread reads state; a cover thread fetches and downscales album art; a -//! command thread makes the HTTP calls, because a blocking POST on the UI thread is -//! exactly the stall this front-end exists to remove. - -mod api; -mod covers; -mod format; -mod ha; -mod keymap; -mod library; -mod oklch; -mod ws; - -use std::cell::RefCell; -use std::collections::{HashMap, VecDeque}; -use std::rc::Rc; -use std::sync::mpsc::{channel, Sender}; -use std::sync::{Arc, Mutex}; -use std::time::Instant; - -use slint::{ModelRc, SharedString, VecModel}; - -use api::{Album, Client, PlayerState}; - -use covers::Tier; -use keymap::{Action, Page, UiState}; -use library::{Group, Library, Query, Results, GROUPS}; - -slint::include_modules!(); - -// The application state lives on the UI thread and is reached from the event-loop -// closures the worker relay posts. See `pump_workers`. -thread_local! { - static APP: RefCell>>> = const { RefCell::new(None) }; -} - -// --------------------------------------------------------------------- commands -- - -/// Anything that talks to the backend, run off the UI thread. They all answer 204 with -/// no body, so nothing comes back: the authoritative answer arrives as a pushed state -/// frame, which is also what makes the optimistic updates below safe. -enum Command { - Play { - album_id: String, - track_index: usize, - }, - Resume, - Pause, - Next, - Previous, - Seek(f64), - Volume(i32), - ReloadLibrary, -} - -enum FromWorker { - Library(Result, String>), - Cover(covers::Ready), - Ws(WsEvent), - /// `None` means Home Assistant is not configured, which hides the room tab. - HaConfig(Option), - HaStates(ha::Snapshot), -} - -enum WsEvent { - Connected, - Disconnected, - State(Box), - Position { position: f64, duration: f64 }, - LibraryChanged, -} - -// ------------------------------------------------------------------------ state -- - -struct App { - commands: Sender, - ui: UiState, - player: PlayerState, - library: Library, - covers: covers::Store, - online: bool, - loaded: bool, - error: Option, - /// Changes whenever the track itself changes, so the progress bar can stop - /// animating across two unrelated positions instead of sliding between them. - track_key: String, - /// False for exactly one render: the frame a track changes on, and the frame a seek - /// lands on. - smooth: bool, - - // ------------------------------------------------------------ Mein Zimmer -- - ha: Sender, - ha_config: Option, - ha_states: HashMap, - /// When each entity last got an optimistic patch. A poll that was already in - /// flight when the patch landed resolves with pre-click data; without this, that - /// stale answer puts a lamp back to "off" until the next poll catches up, which - /// is what made the web page feel laggy despite having optimistic updates. - ha_patched_at: HashMap, - ha_loaded: bool, - /// Home Assistant scenes have no "currently active" state of their own, so this is - /// purely local: set by activating one, cleared by any manual device change. - active_scene: Option, -} - -impl App { - fn send(&self, command: Command) { - let _ = self.commands.send(command); - } - - fn query(&self) -> Query<'_> { - Query { - search: &self.ui.search, - tracks_mode: self.ui.tracks_mode, - group: self.ui.group, - category: self.ui.category.as_deref(), - } - } - - fn current_album(&self) -> Option<&Rc> { - self.player - .album_id - .as_deref() - .and_then(|id| self.library.get(id)) - } - - /// How long each of the three root shelves is, for the keyboard's column clamping. - fn shelf_lengths(&self) -> [usize; 3] { - [ - self.library.categories(Group::Music).len(), - self.library.categories(Group::Audiobooks).len(), - self.library.categories(Group::Podcasts).len(), - ] - } - - fn ha_send(&self, job: ha::Job) { - let _ = self.ha.send(job); - } - - /// Patch one entity locally so a tap feels instant, and remember when, so the poll - /// already in flight cannot undo it. - fn ha_optimistic(&mut self, entity_id: &str, patch: impl FnOnce(&mut ha::EntityState)) { - self.ha_patched_at - .insert(entity_id.to_string(), Instant::now()); - if let Some(state) = self.ha_states.get_mut(entity_id) { - patch(state); - } - self.active_scene = None; - } - - fn ha_call(&self, domain: &str, service: &str, body: serde_json::Value) { - self.ha_send(ha::Job::Call { - domain: domain.into(), - service: service.into(), - body, - }); - } - - fn ha_entity_ids(&self) -> Vec { - let Some(config) = &self.ha_config else { - return Vec::new(); - }; - config - .devices - .iter() - .chain(config.scenes.iter()) - .map(|d| d.entity_id.clone()) - .collect() - } - - fn apply(&mut self, actions: Vec) { - for action in actions { - match action { - Action::Play { - album_id, - track_index, - } => { - // Starting something switches to the full-screen view and closes - // the track list behind it, as the web front-end does: what you - // just chose becomes the thing on screen. ESC and `/` come back. - self.ui.play_view = true; - self.ui.open_album = None; - self.send(Command::Play { - album_id, - track_index, - }); - } - Action::Toggle => { - // Optimistic: flip it here and let the pushed frame confirm. Safe - // precisely because state only ever arrives pushed, never polled, - // so there is no poll in flight to race with. - self.player.playing = !self.player.playing; - self.send(if self.player.playing { - Command::Resume - } else { - Command::Pause - }); - } - Action::Next => self.send(Command::Next), - Action::Previous => self.send(Command::Previous), - Action::Volume(delta) => { - self.player.volume = (self.player.volume + delta).clamp(0, 100); - self.send(Command::Volume(self.player.volume)); - } - Action::Seek(delta) => { - let target = (self.player.position + delta).clamp(0.0, self.player.duration); - self.player.position = target; - self.smooth = false; - self.send(Command::Seek(target)); - } - Action::Mute => { - // Unmuting restores a sensible level rather than whatever it was: - // the level before a mute is not recorded anywhere the device - // agrees on, and coming back at 8% reads as still broken. - let target = if self.player.volume == 0 { 60 } else { 0 }; - self.player.volume = target; - self.send(Command::Volume(target)); - } - } - } - } -} - -// ------------------------------------------------------------------ presentation -- - -fn cover_of(store: &mut covers::Store, album: &Album, tier: Tier) -> slint::Image { - let palette = album.palette(); - store - .get(covers::Request { - album_id: album.id.clone(), - tier, - has_cover: album.has_cover, - is_book: album.is_book(), - locked: album.locked, - colors: [palette[0].into(), palette[1].into(), palette[2].into()], - }) - .unwrap_or_default() -} - -/// "12 Songs · 45:03 · 🧸" - the line under an album's title. -fn album_meta(album: &Album) -> String { - let unit = if album.is_book() { - "Kapitel" - } else if album.tracks.len() == 1 { - "Song" - } else { - "Songs" - }; - let mut meta = format!( - "{} {unit} · {}", - album.tracks.len(), - format::clock(album.duration) - ); - if album.figure.is_some() { - meta.push_str(" · 🧸"); - } - meta -} - -/// "7 Hörbücher" - the line under a category tile. -fn category_meta(group: Group, count: usize) -> String { - let unit = match (group, count) { - (Group::Music, 1) => "Album", - (Group::Music, _) => "Alben", - (Group::Audiobooks, _) => "Hörbücher", - (Group::Podcasts, 1) => "Episode", - (Group::Podcasts, _) => "Episoden", - }; - format!("{count} {unit}") -} - -fn album_tile(store: &mut covers::Store, album: &Album, current: bool, nav: usize) -> AlbumTile { - AlbumTile { - id: album.id.as_str().into(), - title: if album.locked { - "❓ Geheimnis".into() - } else { - album.title.as_str().into() - }, - // A podcast episode's artist is its show, which the title already says. - artist: if album.artist == album.title { - SharedString::new() - } else { - album.artist.as_str().into() - }, - meta: album_meta(album).into(), - cover: cover_of(store, album, Tier::Thumb), - is_book: album.is_book(), - locked: album.locked, - current, - nav: nav as i32, - } -} - -fn category_tile( - store: &mut covers::Store, - group: Group, - category: &library::Category, - nav: usize, -) -> CategoryTile { - // Up to four covers in a 2x2 contact sheet, which is how a category reads as "a - // stack of these" without a label saying so. - let mut covers_out = [ - slint::Image::default(), - slint::Image::default(), - slint::Image::default(), - slint::Image::default(), - ]; - for (slot, album) in covers_out.iter_mut().zip(category.albums.iter()) { - *slot = cover_of(store, album, Tier::Thumb); - } - let [c1, c2, c3, c4] = covers_out; - CategoryTile { - key: category.key.as_str().into(), - count_label: category_meta(group, category.albums.len()).into(), - cover1: c1, - cover2: c2, - cover3: c3, - cover4: c4, - cover_count: category.albums.len().min(4) as i32, - is_book: category.albums.first().is_some_and(|a| a.is_book()), - nav: nav as i32, - } -} - -fn track_row( - store: &mut covers::Store, - album: &Album, - index: usize, - title: &str, - duration: f64, - current: bool, - nav: usize, -) -> TrackRow { - TrackRow { - title: title.into(), - subtitle: album.line().into(), - duration: format::clock(duration).into(), - cover: cover_of(store, album, Tier::Thumb), - is_book: album.is_book(), - current, - locked: false, - album_id: album.id.as_str().into(), - index: index as i32, - nav: nav as i32, - } -} - -fn now_playing(app: &mut App) -> NowPlaying { - let album = app.current_album().map(Rc::clone); - let state = &app.player; - let is_book = album.as_ref().is_some_and(|a| a.is_book()); - let is_podcast = album - .as_ref() - .is_some_and(|a| Group::of(a) == Group::Podcasts); - - let (cover, hero) = match &album { - Some(album) => ( - cover_of(&mut app.covers, album, Tier::Thumb), - cover_of(&mut app.covers, album, Tier::Hero), - ), - None => (slint::Image::default(), slint::Image::default()), - }; - - let remaining_label = match &album { - // "Noch ... im Hörbuch" means nothing for a podcast, where an episode is the - // whole of what was chosen. - Some(album) if !is_podcast => { - let durations: Vec = album.tracks.iter().map(|t| t.duration).collect(); - let left = format::remaining_in_album( - &durations, - state.track_index.max(0) as usize, - state.position, - ); - let what = if is_book { "im Hörbuch" } else { "im Album" }; - format!("Noch {} {what}", format::clock(left)) - } - _ => String::new(), - }; - - NowPlaying { - title: state - .track_title - .clone() - .unwrap_or_else(|| "Wähl ein Album!".into()) - .into(), - subtitle: album - .as_ref() - .map_or_else(|| "Tippen oder klicken".to_string(), |a| a.line()) - .into(), - counter: format!( - "{} {} von {}", - if is_book { "Kapitel" } else { "Song" }, - state.track_index + 1, - state.track_count.max(1) - ) - .into(), - cover, - hero_cover: hero, - is_book, - has_album: album.is_some(), - clock_label: if state.duration > 0.0 { - format!("−{}", format::clock(state.duration - state.position)).into() - } else { - SharedString::new() - }, - remaining_label: remaining_label.into(), - } -} - -// ---------------------------------------------------------------------- rendering -- - -fn render(app: &mut App, window: &AppWindow) { - window.set_loading(!app.loaded); - window.set_splash_text( - match &app.error { - Some(error) => format!("Der Delfin ist nicht erreichbar …\n{error}"), - None => "Einen Moment …".to_string(), - } - .into(), - ); - window.set_online(app.online); - window.set_status_label( - if !app.online { - "Keine Verbindung".into() - } else { - match &app.player.active_figure { - Some(figure) => format!("🧸 {figure}"), - None => String::new(), - } - } - .into(), - ); - - // How many columns fit, from the width the window is actually giving the grid. The - // keyboard needs the same number to know what "one row down" means. - let gap = 24.0_f32; - let card = 180.0_f32; - let cols = (((window.get_grid_width() + gap) / (card + gap)).floor() as usize).max(1); - app.ui.cols = cols; - - window.set_play_view(app.ui.play_view); - window.set_can_go_back(app.ui.can_go_back()); - window.set_playing(app.player.playing); - window.set_volume(app.player.volume); - window.set_smooth(app.smooth); - window.set_progress(if app.player.duration > 0.0 { - (app.player.position / app.player.duration).clamp(0.0, 1.0) as f32 - } else { - 0.0 - }); - - let now = now_playing(app); - let has_album = now.has_album; - window.set_now(now); - window.set_has_bar(has_album && !app.ui.play_view); - - // The room tab only exists when Home Assistant is configured - the backend - // answering 404 for /api/ha is how that is known. - let tabs: Vec = [Page::Music, Page::Room, Page::Typing] - .into_iter() - .filter(|page| *page != Page::Room || app.ha_config.is_some()) - .map(|page| Tab { - id: match page { - Page::Music => "music", - Page::Room => "room", - Page::Typing => "typing", - } - .into(), - icon: page.icon().into(), - label: page.label().into(), - }) - .collect(); - window.set_tabs(ModelRc::new(VecModel::from(tabs))); - - window.set_page( - match app.ui.page { - Page::Music => "music", - Page::Room => "room", - Page::Typing => "typing", - } - .into(), - ); - - render_room(app, window); - - let root_screen = app.ui.is_root_shelf(); - window.set_root_screen(root_screen); - window.set_shelf_row(app.ui.shelf_row as i32); - window.set_sel_index(app.ui.sel_index as i32); - - if root_screen { - render_shelves(app, window); - } else { - render_results(app, window, cols); - } - - render_sheet(app, window); -} - -fn render_shelves(app: &mut App, window: &AppWindow) { - let mut shelves = Vec::with_capacity(GROUPS.len()); - for group in GROUPS { - let categories: Vec = app.library.categories(group).to_vec(); - let tiles: Vec = categories - .iter() - .enumerate() - .map(|(i, category)| category_tile(&mut app.covers, group, category, i)) - .collect(); - shelves.push(Shelf { - label: group.label().into(), - icon: group.icon().into(), - tiles: ModelRc::new(VecModel::from(tiles)), - empty_label: "Noch nichts hier".into(), - }); - } - window.set_shelves(ModelRc::new(VecModel::from(shelves))); - // The shelves replace the flat result lists rather than sitting beside them. - window.set_songs(ModelRc::new(VecModel::from(Vec::::new()))); - window.set_grid(ModelRc::new(VecModel::from(Vec::::new()))); - window.set_category_grid(ModelRc::new(VecModel::from(Vec::::new()))); -} - -fn render_results(app: &mut App, window: &AppWindow, cols: usize) { - let results: Results = app.library.results(&app.query()); - let total = results.total(); - let current_album = app.player.album_id.clone(); - let current_track = app.player.track_index.max(0) as usize; - - // Selection indices run flat across songs, then categories, then albums. - let mut nav = 0usize; - - let songs: Vec = results - .songs - .iter() - .map(|hit| { - let row = track_row( - &mut app.covers, - &hit.album, - hit.index, - &hit.title, - hit.duration, - current_album.as_deref() == Some(hit.album.id.as_str()) - && current_track == hit.index, - nav, - ); - nav += 1; - row - }) - .collect(); - - let group = app.ui.group.unwrap_or(Group::Music); - let categories: Vec = results - .categories - .iter() - .map(|category| { - let tile = category_tile(&mut app.covers, group, category, nav); - nav += 1; - tile - }) - .collect(); - // Chunked like the album grid, and for the same reason: the view puts each row in - // a virtualizing list rather than one ever-widening horizontal layout. - let category_grid: Vec = categories - .chunks(cols) - .map(|chunk| CategoryRow { - tiles: ModelRc::new(VecModel::from(chunk.to_vec())), - }) - .collect(); - - let tiles: Vec = results - .albums - .iter() - .map(|album| { - let tile = album_tile( - &mut app.covers, - album, - current_album.as_deref() == Some(album.id.as_str()), - nav, - ); - nav += 1; - tile - }) - .collect(); - - // Pre-chunked into rows: a ListView virtualizes its model, and a flat list of 343 - // cards has no rows for it to skip. The web grid reaches the same place with - // `content-visibility: auto`. - let first_album_nav = songs.len() + categories.len(); - let grid: Vec = tiles - .chunks(cols) - .map(|chunk| GridRow { - tiles: ModelRc::new(VecModel::from(chunk.to_vec())), - }) - .collect(); - - // Which grid row the selection is in, so the scroller can follow it without - // measuring anything - every row is the same height by construction. - let sel_row = app - .ui - .sel_index - .checked_sub(first_album_nav) - .map_or(0, |within| within / cols.max(1)); - - let count_label = if app.ui.tracks_mode { - format!("{} Titel gefunden", results.songs.len()) - } else if results.albums.is_empty() { - "nichts gefunden".to_string() - } else if results.albums.len() == 1 { - "1 Album gefunden".to_string() - } else { - format!("{} Alben gefunden", results.albums.len()) - }; - - window.set_group_label( - app.ui - .group - .map_or(SharedString::new(), |g| g.label().into()), - ); - window.set_category_heading(group.category_label().into()); - window.set_album_heading( - match app.ui.category.as_deref() { - Some(category) => category.to_string(), - None => group.section_label().to_string(), - } - .into(), - ); - window.set_show_search(!app.ui.search.is_empty() || app.ui.tracks_mode); - window.set_search_text( - if app.ui.search.is_empty() { - "tippe …".to_string() - } else { - format!("\"{}\"", app.ui.search) - } - .into(), - ); - window.set_mode_label(if app.ui.tracks_mode { - "♪ Titel".into() - } else { - "🔎 Alben".into() - }); - window.set_count_label(count_label.into()); - window.set_no_results(total == 0); - window.set_sel_row(sel_row as i32); - - window.set_songs(ModelRc::new(VecModel::from(songs))); - window.set_category_grid(ModelRc::new(VecModel::from(category_grid))); - window.set_grid(ModelRc::new(VecModel::from(grid))); -} - -/// Build the room page's models. One `RoomCard` list in Home Assistant's own config -/// order, because that order is the only layout information the config carries. -fn render_room(app: &mut App, window: &AppWindow) { - let Some(config) = app.ha_config.clone() else { - return; - }; - window.set_room_loading(!app.ha_loaded); - - let mut cards = Vec::with_capacity(config.devices.len()); - for device in &config.devices { - let state = app.ha_states.get(&device.entity_id); - let mut card = RoomCard { - entity_id: device.entity_id.as_str().into(), - name: device.label().into(), - ..Default::default() - }; - match device.domain() { - "light" => { - let on = state.is_some_and(|s| s.is_on()); - let rgb = state.map_or([255, 214, 140], |s| s.rgb()); - card.kind = "light".into(); - card.on = on; - card.is_color = state.is_some_and(|s| s.is_color()); - // 0..5, matching the five buttons - HA reports 0..255. - card.level = state - .map_or(0.0, |s| (s.brightness() / 255.0 * 5.0).round()) - .clamp(0.0, 5.0) as i32; - card.tint = slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]); - card.swatch = ha::SWATCHES - .iter() - .position(|(_, swatch)| *swatch == rgb) - .map_or(-1, |i| i as i32); - } - "cover" => { - let position = state.and_then(|s| s.cover_position()); - let features = state.map_or(0, |s| s.supported_features()); - let closed = ha::closed_percent(position.unwrap_or(0.0)); - let moving = match state.map(|s| s.state.as_str()) { - Some("opening") => "up", - Some("closing") => "down", - _ => "", - }; - card.kind = "shutter".into(); - card.closed_percent = closed as f32; - card.moving = moving.into(); - card.status = match moving { - "down" => "Fährt runter …".to_string(), - "up" => "Fährt hoch …".to_string(), - _ => ha::closed_label(closed), - } - .into(); - card.supports_position = - position.is_some() || features & ha::SUPPORT_SET_POSITION != 0; - let label = ha::closed_label(closed); - card.preset = ha::SHUTTER_PRESETS - .iter() - .position(|(name, _)| *name == label) - .map_or(-1, |i| i as i32); - } - // Not a domain this page knows how to draw - skip rather than guess. - _ => continue, - } - cards.push(card); - } - - let scenes: Vec = config - .scenes - .iter() - .map(|scene| SceneTile { - entity_id: scene.entity_id.as_str().into(), - name: scene.label().into(), - active: app.active_scene.as_deref() == Some(scene.entity_id.as_str()), - }) - .collect(); - - // Three across at 1080p, which is what the web grid's `minmax(300px, 1fr)` inside - // a 1180px column comes to on the same screen. - let columns = ((window.get_grid_width() / 400.0).floor() as usize).clamp(1, 3); - let rows: Vec = cards - .chunks(columns) - .map(|chunk| RoomRow { - cards: ModelRc::new(VecModel::from(chunk.to_vec())), - }) - .collect(); - window.set_room_rows(ModelRc::new(VecModel::from(rows))); - window.set_room_scenes(ModelRc::new(VecModel::from(scenes))); -} - -fn render_sheet(app: &mut App, window: &AppWindow) { - let Some(album) = app - .ui - .open_album - .clone() - .and_then(|id| app.library.get(&id).map(Rc::clone)) - else { - window.set_show_sheet(false); - return; - }; - - let playing_here = app.player.album_id.as_deref() == Some(album.id.as_str()); - let current = app.player.track_index.max(0) as usize; - let rows: Vec = album - .tracks - .iter() - .enumerate() - .map(|(i, track)| { - let title = if track.locked { - "❓".to_string() - } else { - track.title.clone() - }; - track_row( - &mut app.covers, - &album, - i, - &title, - track.duration, - playing_here && current == i, - i, - ) - }) - .collect(); - - let kind = if album.is_book() { - "📖 Hörbuch" - } else { - "🎵 Album" - }; - let mut meta = format!("{kind} · {}", album_meta(&album)); - if let Some(figure) = &album.figure { - meta.push_str(&format!(" · {figure}")); - } - - window.set_show_sheet(true); - window.set_sheet_title(album.title.as_str().into()); - window.set_sheet_subtitle(album.artist.as_str().into()); - window.set_sheet_meta(meta.into()); - window.set_sheet_cover(cover_of(&mut app.covers, &album, Tier::Hero)); - window.set_sheet_is_book(album.is_book()); - window.set_sheet_index(app.ui.sheet_index.min(album.tracks.len().saturating_sub(1)) as i32); - window.set_sheet_tracks(ModelRc::new(VecModel::from(rows))); -} - -// ----------------------------------------------------------------------- wiring -- - -fn main() -> Result<(), Box> { - let arg = |name: &str| -> Option { std::env::args().skip_while(|a| a != name).nth(1) }; - let base = arg("--server").unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string()); - // Which page to open on. A kiosk may well want to come up on the room panel rather - // than the player, and it is the only way to reach a page without a keyboard or a - // pointer. - let page = match arg("--page").as_deref() { - Some("room") => Page::Room, - Some("typing") => Page::Typing, - _ => Page::Music, - }; - println!("musicmouse-slint → {base}"); - - // Nunito is the face the design is drawn in, vendored as a TTF in `fonts/` because - // Slint cannot read the woff2 the web front-end self-hosts. Slint takes one file as - // the primary font through `SLINT_DEFAULT_FONT`; the dev shell and the device's - // launcher both set it, and this is the fallback for running the binary directly. - // None of them finding it just means the system sans - a missing font should change - // how this looks, not whether it runs. - if std::env::var_os("SLINT_DEFAULT_FONT").is_none() { - let beside_exe = std::env::current_exe() - .ok() - // target/release/ -> the crate root - .and_then(|exe| Some(exe.parent()?.parent()?.parent()?.join("fonts/Nunito.ttf"))); - for candidate in [ - Some(std::path::PathBuf::from("fonts/Nunito.ttf")), - beside_exe, - ] - .into_iter() - .flatten() - { - if candidate.exists() { - std::env::set_var("SLINT_DEFAULT_FONT", candidate); - break; - } - } - } - - let window = AppWindow::new()?; - let client = Client::new(&base); - - let (to_ui, from_workers) = channel::(); - let (cover_jobs, cover_rx) = channel::(); - let (commands, command_rx) = channel::(); - let (ha_jobs, ha_rx) = channel::(); - - // Covers: fetch, downscale, cache to disk. Never on the UI thread. - { - let to_ui = to_ui.clone(); - covers::spawn(client.clone(), cover_rx, move |ready| { - let _ = to_ui.send(FromWorker::Cover(ready)); - }); - } - - // Commands, and the library fetch that seeds everything. - { - let client = client.clone(); - let to_ui = to_ui.clone(); - std::thread::Builder::new() - .name("commands".into()) - .spawn(move || { - let _ = to_ui.send(FromWorker::Library(client.library())); - for command in command_rx { - let result = match command { - Command::Play { - album_id, - track_index, - } => client.play(&album_id, track_index as i32), - Command::Resume => client.resume(), - Command::Pause => client.pause(), - Command::Next => client.next(), - Command::Previous => client.previous(), - Command::Seek(position) => client.seek(position), - Command::Volume(percent) => client.volume(percent), - Command::ReloadLibrary => { - let _ = to_ui.send(FromWorker::Library(client.library())); - Ok(()) - } - }; - if let Err(error) = result { - // A failed command is not fatal: the next pushed frame says - // what actually happened, which is the only truth anyway. - eprintln!("{error}"); - } - } - })?; - } - - // Home Assistant, behind the backend's proxy. One worker for both the poll and - // the commands, so a command can never overtake the poll meant to confirm it. - { - let states_tx = to_ui.clone(); - ha::spawn(client.clone(), ha_rx, move |snapshot| { - let _ = states_tx.send(FromWorker::HaStates(snapshot)); - }); - let client = client.clone(); - let to_ui = to_ui.clone(); - std::thread::Builder::new() - .name("ha-config".into()) - .spawn(move || { - // A 404 here means Home Assistant is not configured, which is a normal - // answer: the tab simply does not appear. - let _ = to_ui.send(FromWorker::HaConfig(client.ha_config().ok().flatten())); - })?; - } - - // State. - { - let to_ui = to_ui.clone(); - let (ws_tx, ws_rx) = channel(); - ws::spawn(client.clone(), ws_tx); - std::thread::Builder::new() - .name("ws-relay".into()) - .spawn(move || { - for update in ws_rx { - let event = match update { - ws::Update::Connected => WsEvent::Connected, - ws::Update::Disconnected => WsEvent::Disconnected, - ws::Update::State(state) => WsEvent::State(state), - ws::Update::Position { position, duration } => { - WsEvent::Position { position, duration } - } - ws::Update::LibraryChanged => WsEvent::LibraryChanged, - }; - if to_ui.send(FromWorker::Ws(event)).is_err() { - return; - } - } - })?; - } - - let app = Rc::new(RefCell::new(App { - commands, - ui: UiState { - page, - ..UiState::default() - }, - player: PlayerState::default(), - library: Library::default(), - covers: covers::Store::new(cover_jobs), - ha: ha_jobs, - ha_config: None, - ha_states: HashMap::new(), - ha_patched_at: HashMap::new(), - ha_loaded: false, - active_scene: None, - online: false, - loaded: false, - error: None, - track_key: String::new(), - smooth: true, - })); - - // The room page's poll. Only runs while that page is showing: a lamp's state is - // not worth a request every 2.5 s when nobody is looking at it, and this device - // has a music player to get out of the way of. - let poll = slint::Timer::default(); - { - let handle = Rc::clone(&app); - let weak = window.as_weak(); - poll.start(slint::TimerMode::Repeated, ha::POLL_INTERVAL, move || { - let Some(window) = weak.upgrade() else { return }; - let entity_ids = { - let app = handle.borrow(); - if app.ui.page != Page::Room || app.ha_config.is_none() { - return; - } - app.ha_entity_ids() - }; - handle.borrow().ha_send(ha::Job::Poll(entity_ids)); - let _ = &window; - }); - } - - // The static parts of the room page - the six colours and the four shutter - // presets - never change, so they are set once rather than rebuilt per render. - window.set_room_swatches(ModelRc::new(VecModel::from( - ha::SWATCHES - .iter() - .map(|(name, rgb)| Swatch { - name: (*name).into(), - tint: slint::Color::from_rgb_u8(rgb[0], rgb[1], rgb[2]), - }) - .collect::>(), - ))); - window.set_shutter_presets(ModelRc::new(VecModel::from( - ha::SHUTTER_PRESETS - .iter() - .map(|(label, _)| SharedString::from(*label)) - .collect::>(), - ))); - - install_callbacks(&app, &window); - pump_workers(&app, &window, from_workers); - - render(&mut app.borrow_mut(), &window); - window.run()?; - Ok(()) -} - -/// Bridge the worker channels onto the Slint event loop. -/// -/// `invoke_from_event_loop` takes a `Send` closure, and the application state is -/// `Rc>` that never leaves the UI thread - so the state stays in a -/// thread-local here and only the *messages* cross, which are `Send` by construction. -/// -/// The queue is drained rather than handled one at a time, and that is not incidental: -/// a cold start delivers a few hundred covers in a burst, and one re-render per cover -/// would rebuild every model a few hundred times. Coalescing collapses each burst into -/// a single render. -fn pump_workers( - app: &Rc>, - window: &AppWindow, - from_workers: std::sync::mpsc::Receiver, -) { - APP.with(|slot| *slot.borrow_mut() = Some(Rc::clone(app))); - - let queue: Arc>> = Arc::default(); - let weak = window.as_weak(); - let producer = Arc::clone(&queue); - - std::thread::Builder::new() - .name("relay".into()) - .spawn(move || { - for message in from_workers { - match producer.lock() { - Ok(mut queue) => queue.push_back(message), - // Only reachable if a UI-thread panic poisoned the lock, in which - // case there is no UI left to deliver to. - Err(_) => return, - } - let drain = Arc::clone(&producer); - let posted = weak.upgrade_in_event_loop(move |window| { - let messages: Vec = match drain.lock() { - Ok(mut queue) => queue.drain(..).collect(), - Err(_) => return, - }; - if messages.is_empty() { - return; - } - APP.with(|slot| { - let Some(app) = slot.borrow().clone() else { - return; - }; - { - let mut app = app.borrow_mut(); - for message in messages { - handle_worker_message(&mut app, message); - } - } - render(&mut app.borrow_mut(), &window); - }); - }); - if posted.is_err() { - return; - } - } - }) - .expect("spawning the worker relay"); -} - -fn handle_worker_message(app: &mut App, message: FromWorker) { - match message { - FromWorker::Library(Ok(albums)) => { - app.library = Library::build(albums); - app.loaded = true; - app.error = None; - } - FromWorker::Library(Err(error)) => { - app.error = Some(error); - } - FromWorker::Cover(ready) => app.covers.insert(ready), - FromWorker::Ws(WsEvent::Connected) => app.online = true, - FromWorker::Ws(WsEvent::Disconnected) => app.online = false, - FromWorker::Ws(WsEvent::State(state)) => { - let key = format!("{:?}:{}", state.album_id, state.track_index); - // A new track means the bar must jump, not slide: animating across two - // unrelated positions is what makes a seek look like it drifts backwards. - app.smooth = key == app.track_key; - app.track_key = key; - app.player = *state; - } - FromWorker::Ws(WsEvent::Position { position, duration }) => { - app.player.position = position; - if duration > 0.0 { - app.player.duration = duration; - } - app.smooth = true; - } - FromWorker::HaConfig(config) => { - app.ha_config = config; - if app.ha_config.is_some() { - app.ha_send(ha::Job::Poll(app.ha_entity_ids())); - } - } - FromWorker::HaStates(snapshot) => { - for (entity_id, state) in snapshot.states { - // Drop any entity whose optimistic patch is newer than the request - // that produced this - see `ha_patched_at`. - let patched = app.ha_patched_at.get(&entity_id).copied(); - if patched.is_none_or(|at| snapshot.started_at >= at) { - app.ha_states.insert(entity_id, state); - } - } - app.ha_loaded = true; - } - FromWorker::Ws(WsEvent::LibraryChanged) => { - // A rescan is the one moment the backend rewrites the art behind an - // unchanged cover URL, so the thumbnails go with it. - app.covers.clear(); - app.send(Command::ReloadLibrary); - } - } -} - -fn install_callbacks(app: &Rc>, window: &AppWindow) { - // Every callback does the same three things: mutate state, maybe queue a command, - // re-render. `bind!` is that shape written once, so no individual handler has to - // remember the third. - macro_rules! bind { - ($setter:ident, |$a:ident $(, $arg:ident : $ty:ty)*| $body:block) => { - let weak = window.as_weak(); - let handle = Rc::clone(app); - window.$setter(move |$($arg: $ty),*| { - let Some(window) = weak.upgrade() else { return }; - { - let mut $a = handle.borrow_mut(); - $body - } - render(&mut handle.borrow_mut(), &window); - }); - }; - } - - bind!(on_key, |a, key: SharedString, ctrl: bool, shift: bool| { - let results = a.library.results(&a.query()); - let shelves = a.shelf_lengths(); - let sheet_tracks = - a.ui.open_album - .as_deref() - .and_then(|id| a.library.get(id)) - .map_or(0, |album| album.tracks.len()); - let mut ui = a.ui.clone(); - let actions = keymap::handle_key( - key.as_str(), - ctrl, - shift, - &mut ui, - &results, - &shelves, - sheet_tracks, - ); - a.ui = ui; - a.apply(actions); - }); - - bind!(on_toggle, |a| { - a.apply(vec![Action::Toggle]); - }); - bind!(on_next, |a| { - a.apply(vec![Action::Next]); - }); - bind!(on_previous, |a| { - a.apply(vec![Action::Previous]); - }); - bind!(on_mute, |a| { - a.apply(vec![Action::Mute]); - }); - - bind!(on_seek, |a, fraction: f32| { - let target = (fraction as f64 * a.player.duration).clamp(0.0, a.player.duration); - a.player.position = target; - a.smooth = false; - a.send(Command::Seek(target)); - }); - - bind!(on_set_volume, |a, percent: i32| { - a.player.volume = percent.clamp(0, 100); - a.send(Command::Volume(a.player.volume)); - }); - - bind!(on_go_back, |a| { - if a.ui.open_album.is_some() { - a.ui.open_album = None; - } else if a.ui.play_view { - a.ui.play_view = false; - } else { - a.ui.browse_back(); - } - }); - - bind!(on_enter_group, |a, shelf: i32| { - a.ui.group = Some(Group::from_index(shelf.max(0) as usize)); - a.ui.category = None; - a.ui.sel_index = 0; - }); - - bind!(on_enter_category, |a, shelf: i32, key: SharedString| { - a.ui.group = Some(Group::from_index(shelf.max(0) as usize)); - a.ui.category = Some(key.to_string()); - a.ui.sel_index = 0; - }); - - bind!(on_open_category, |a, key: SharedString| { - a.ui.category = Some(key.to_string()); - a.ui.sel_index = 0; - }); - - bind!(on_open_album, |a, id: SharedString| { - // A podcast episode is a single track with nothing to choose between, so the - // cover plays it rather than opening a list of one. - match a.library.get(id.as_str()).map(Rc::clone) { - Some(album) if Group::of(&album) == Group::Podcasts => { - a.apply(vec![Action::Play { - album_id: album.id.clone(), - track_index: 0, - }]); - } - Some(album) => { - a.ui.open_album = Some(album.id.clone()); - a.ui.sheet_index = 0; - } - None => {} - } - }); - - bind!(on_play_album, |a, id: SharedString| { - a.apply(vec![Action::Play { - album_id: id.to_string(), - track_index: 0, - }]); - }); - - bind!(on_play_song, |a, id: SharedString, index: i32| { - a.apply(vec![Action::Play { - album_id: id.to_string(), - track_index: index.max(0) as usize, - }]); - }); - - bind!(on_open_play_view, |a| { - a.ui.play_view = true; - }); - bind!(on_close_sheet, |a| { - a.ui.open_album = None; - }); - - bind!(on_select_page, |a, page: SharedString| { - a.ui.page = match page.as_str() { - "room" => Page::Room, - "typing" => Page::Typing, - _ => Page::Music, - }; - // Ask for fresh state the moment the page comes up, rather than making the - // first thing you see be up to one poll interval stale. - if a.ui.page == Page::Room && a.ha_config.is_some() { - a.ha_send(ha::Job::Poll(a.ha_entity_ids())); - } - }); - - bind!(on_toggle_light, |a, id: SharedString| { - let on = a.ha_states.get(id.as_str()).is_some_and(|s| s.is_on()); - a.ha_optimistic(id.as_str(), |state| { - state.state = if on { "off".into() } else { "on".into() }; - }); - a.ha_call( - "light", - if on { "turn_off" } else { "turn_on" }, - serde_json::json!({ "entity_id": id.as_str() }), - ); - }); - - bind!(on_set_light_level, |a, id: SharedString, level: i32| { - let percent = level.clamp(1, 5) * 20; - a.ha_optimistic(id.as_str(), |state| { - state.state = "on".into(); - state.attributes.insert( - "brightness".into(), - serde_json::json!((percent as f64 / 100.0 * 255.0).round()), - ); - }); - a.ha_call( - "light", - "turn_on", - serde_json::json!({ "entity_id": id.as_str(), "brightness_pct": percent }), - ); - }); - - bind!(on_set_light_swatch, |a, id: SharedString, index: i32| { - let Some((_, rgb)) = ha::SWATCHES.get(index.max(0) as usize) else { - return; - }; - let rgb = *rgb; - a.ha_optimistic(id.as_str(), |state| { - state.state = "on".into(); - state.attributes.insert( - "rgb_color".into(), - serde_json::json!([rgb[0], rgb[1], rgb[2]]), - ); - }); - a.ha_call( - "light", - "turn_on", - serde_json::json!({ "entity_id": id.as_str(), "rgb_color": [rgb[0], rgb[1], rgb[2]] }), - ); - }); - - bind!(on_set_shutter_preset, |a, id: SharedString, index: i32| { - let Some((_, closed)) = ha::SHUTTER_PRESETS.get(index.max(0) as usize) else { - return; - }; - let position = ha::position_from_closed(*closed); - a.ha_optimistic(id.as_str(), |state| { - state - .attributes - .insert("current_position".into(), serde_json::json!(position)); - }); - a.ha_call( - "cover", - "set_cover_position", - serde_json::json!({ "entity_id": id.as_str(), "position": position }), - ); - }); - - bind!(on_shutter, |a, id: SharedString, what: SharedString| { - let service = match what.as_str() { - "open" => "open_cover", - "close" => "close_cover", - _ => "stop_cover", - }; - // No optimistic position here: a real cover reports its own movement, and - // guessing where it will end up is exactly what would fight with that. - a.active_scene = None; - a.ha_patched_at.remove(id.as_str()); - a.ha_call( - "cover", - service, - serde_json::json!({ "entity_id": id.as_str() }), - ); - }); - - bind!(on_activate_scene, |a, id: SharedString| { - a.active_scene = Some(id.to_string()); - a.ha_call( - "scene", - "turn_on", - serde_json::json!({ "entity_id": id.as_str() }), - ); - }); - - bind!(on_play_sheet_track, |a, index: i32| { - if let Some(album_id) = a.ui.open_album.clone() { - a.apply(vec![Action::Play { - album_id, - track_index: index.max(0) as usize, - }]); - a.ui.open_album = None; - } - }); -} diff --git a/slint-frontend/src/oklch.rs b/slint-frontend/src/oklch.rs deleted file mode 100644 index 0c1845a..0000000 --- a/slint-frontend/src/oklch.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! oklch -> sRGB. -//! -//! The web front-end's palette is oklch throughout (`web/src/styles/app.css`) and -//! Slint has no such colour space, so every value crossing over is converted here. -//! Ported from `web/src/lib/oklch.ts`. -//! -//! The static palette is converted once at build time into the literals in -//! `ui/theme.slint`; this module exists for the values that are only known at runtime - -//! the per-group hues behind the generated book-spine art. - -/// `l` is 0..1 (not the 0..100% that CSS writes), `c` is chroma, `h` is degrees. -pub fn oklch_to_rgb(l: f32, c: f32, h: f32) -> [u8; 3] { - let (sin, cos) = (h.to_radians().sin(), h.to_radians().cos()); - let (a, b) = (c * cos, c * sin); - - let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b; - let m_ = l - 0.105_561_34 * a - 0.063_854_17 * b; - let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b; - let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); - - let lin = [ - 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3, - -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3, - -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3, - ]; - lin.map(|v| { - // Gamut clipping is per channel and deliberately crude: every colour this app - // actually asks for is inside sRGB, so a channel landing outside it means a - // typo, and a hard clamp shows that as a flat primary rather than hiding it. - let v = v.clamp(0.0, 1.0); - let encoded = if v <= 0.003_130_8 { - 12.92 * v - } else { - 1.055 * v.powf(1.0 / 2.4) - 0.055 - }; - (encoded * 255.0).round().clamp(0.0, 255.0) as u8 - }) -} - -/// `#rrggbb` as the backend writes it. Anything unparseable falls back rather than -/// failing: the colours are decoration, and an album with a malformed one should still -/// appear on the shelf. -pub fn parse_hex(hex: &str, fallback: [u8; 3]) -> [u8; 3] { - let digits = hex.trim().trim_start_matches('#'); - if digits.len() != 6 { - return fallback; - } - let byte = |i: usize| u8::from_str_radix(&digits[i..i + 2], 16).ok(); - match (byte(0), byte(2), byte(4)) { - (Some(r), Some(g), Some(b)) => [r, g, b], - _ => fallback, - } -} - -/// Linear blend, for the spine's translucent highlight lines. -pub fn mix(base: [u8; 3], over: [u8; 3], alpha: f32) -> [u8; 3] { - let alpha = alpha.clamp(0.0, 1.0); - [0, 1, 2].map(|i| (base[i] as f32 * (1.0 - alpha) + over[i] as f32 * alpha).round() as u8) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn known_anchors_round_trip_into_srgb() { - // Pure white and pure black are the two values with no room to be subtly wrong. - assert_eq!(oklch_to_rgb(1.0, 0.0, 0.0), [255, 255, 255]); - assert_eq!(oklch_to_rgb(0.0, 0.0, 0.0), [0, 0, 0]); - } - - #[test] - fn the_accent_pink_lands_where_the_web_ui_puts_it() { - // oklch(70% 0.16 340) is `--accent`; the browser resolves it to ~#e A pink in - // the red-magenta corner. Assert the shape of it rather than an exact byte. - let [r, g, b] = oklch_to_rgb(0.70, 0.16, 340.0); - assert!(r > g && b > g, "expected a pink, got {r},{g},{b}"); - assert!(r > 200, "expected a light pink, got {r},{g},{b}"); - } - - #[test] - fn hex_parsing_falls_back_rather_than_failing() { - assert_eq!(parse_hex("#4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]); - assert_eq!(parse_hex("4a6fa5", [0, 0, 0]), [0x4a, 0x6f, 0xa5]); - assert_eq!(parse_hex("nope", [1, 2, 3]), [1, 2, 3]); - assert_eq!(parse_hex("", [1, 2, 3]), [1, 2, 3]); - } - - #[test] - fn mix_interpolates_between_the_two_ends() { - assert_eq!(mix([0, 0, 0], [255, 255, 255], 0.0), [0, 0, 0]); - assert_eq!(mix([0, 0, 0], [255, 255, 255], 1.0), [255, 255, 255]); - assert_eq!(mix([0, 0, 0], [200, 100, 50], 0.5), [100, 50, 25]); - } -} diff --git a/slint-frontend/src/ws.rs b/slint-frontend/src/ws.rs deleted file mode 100644 index f849754..0000000 --- a/slint-frontend/src/ws.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! The state websocket, with reconnect-and-reseed. -//! -//! Ported from `web/src/hooks/usePlayerState.ts`. The backend pushes and never listens, -//! so this is a read loop and nothing else. Two details are load-bearing: -//! -//! * **Reseed on open, not just on connect.** A reconnect can have missed every change -//! between the drop and the new socket, and the server only sends a snapshot to a -//! client it already considers connected. So each successful open is followed by a -//! `GET /api/state`. -//! * **Position is not on the event bus.** The server polls its player on a 0.5 s timer -//! and sends `position` frames only while something is playing - so the absence of -//! them is not a stall, and the UI interpolates between them rather than waiting. - -use std::sync::mpsc::Sender; -use std::time::Duration; - -use crate::api::{Client, PlayerState, ServerMessage}; - -/// What the UI thread hears from this connection. Wider than `ServerMessage` because -/// connectivity is itself a thing the screen shows. -pub enum Update { - Connected, - Disconnected, - State(Box), - Position { - position: f64, - duration: f64, - }, - /// The library was rescanned: re-fetch it, and drop the cover thumbnails, since a - /// rescan is exactly when the art behind an unchanged URL gets rewritten. - LibraryChanged, -} - -/// Matches the web client's retry interval. Short enough that the screen comes back -/// before a child gives up on it, long enough not to spin while the backend restarts. -const RETRY: Duration = Duration::from_millis(1500); - -pub fn spawn(client: Client, updates: Sender) -> std::thread::JoinHandle<()> { - std::thread::Builder::new() - .name("ws".into()) - .spawn(move || loop { - match connect_once(&client, &updates) { - Ok(()) | Err(()) => {} - } - // `send` failing means the UI is gone, which is the one reason to stop. - if updates.send(Update::Disconnected).is_err() { - return; - } - std::thread::sleep(RETRY); - }) - .expect("spawning the websocket thread") -} - -fn connect_once(client: &Client, updates: &Sender) -> Result<(), ()> { - let (mut socket, _) = tungstenite::connect(client.websocket_url()).map_err(|_| ())?; - - if updates.send(Update::Connected).is_err() { - return Err(()); - } - // Reseed: see the note at the top of this module. - if let Ok(state) = client.state() { - if updates.send(Update::State(Box::new(state))).is_err() { - return Err(()); - } - } - - loop { - let message = match socket.read() { - Ok(message) => message, - Err(_) => return Err(()), - }; - let text = match message { - tungstenite::Message::Text(text) => text, - tungstenite::Message::Close(_) => return Ok(()), - // Ping/Pong are answered by tungstenite itself on the next write; binary - // frames are not part of this protocol. - _ => continue, - }; - let parsed: ServerMessage = match serde_json::from_str(&text) { - Ok(parsed) => parsed, - // A frame this build cannot read is not a reason to drop the connection - - // the next one is probably fine. - Err(_) => continue, - }; - let update = match parsed { - ServerMessage::State { state } => Update::State(Box::new(state)), - ServerMessage::Position { position, duration } => { - Update::Position { position, duration } - } - ServerMessage::Library => Update::LibraryChanged, - ServerMessage::Unknown => continue, - }; - if updates.send(update).is_err() { - return Err(()); - } - } -} diff --git a/slint-frontend/tools/headless-shots.sh b/slint-frontend/tools/headless-shots.sh deleted file mode 100755 index a05a8e3..0000000 --- a/slint-frontend/tools/headless-shots.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# -# Render the UI and screenshot it without touching your session. -# -# A Slint window needs a compositor: under bare Xvfb nothing maps, and on a locked or -# remote machine there is no session to draw into. So this starts a nested headless -# sway, runs the app inside it, drives it with synthetic keys and grabs a frame per -# screen. It is how the shots in README.md were made, and how to check a layout change -# on a machine with no display. -# -# nix-shell -E '(import ./shell.nix {}).overrideAttrs (o: { -# nativeBuildInputs = o.nativeBuildInputs ++ (with (import {}); [ sway grim wtype ]); -# })' --run 'tools/headless-shots.sh out/ http://127.0.0.1:8080' -# -set -euo pipefail -OUTDIR="${1:?usage: headless-shots.sh [server-url]}" -SERVER="${2:-http://127.0.0.1:8080}" -BIN="${BIN:-./target/release/musicmouse-slint}" -mkdir -p "$OUTDIR" - -export XDG_RUNTIME_DIR="$(mktemp -d)" -unset WAYLAND_DISPLAY DISPLAY -export WLR_BACKENDS=headless WLR_LIBINPUT_NO_DEVICES=1 WLR_RENDERER=pixman -trap 'kill %1 2>/dev/null || true; rm -rf "$XDG_RUNTIME_DIR"' EXIT - -launcher="$XDG_RUNTIME_DIR/launch.sh" -printf '#!/usr/bin/env bash\nexec %q --server %q >%q/app.log 2>&1\n' \ - "$(realpath "$BIN")" "$SERVER" "$XDG_RUNTIME_DIR" > "$launcher" -chmod +x "$launcher" -printf 'output HEADLESS-1 mode 1920x1080\ndefault_border none\nexec %s\n' "$launcher" \ - > "$XDG_RUNTIME_DIR/sway.cfg" - -sway -c "$XDG_RUNTIME_DIR/sway.cfg" >"$XDG_RUNTIME_DIR/sway.log" 2>&1 & -for _ in $(seq 30); do [ -S "$XDG_RUNTIME_DIR/wayland-1" ] && break; sleep 1; done -export WAYLAND_DISPLAY=wayland-1 -sleep 16 # library fetch plus the first covers - -# Each step is ONE wtype invocation, and starts with a throwaway keypress. -# -# wtype creates a virtual keyboard, sends, and exits. The client needs a moment to -# notice the seat gained a keyboard, so the first key or two of any invocation is -# dropped - which makes a per-key loop lose every key it sends. Batching a step into -# one invocation and opening it with "x" + BackSpace spends that warm-up on a keypress -# that cancels itself out. -WARM=(-s 260 "x" -k BackSpace) -shot() { sleep 2.5; grim "$OUTDIR/$1.png"; echo " $1"; } - -shot 01-root -wtype "${WARM[@]}" -k Down -k Return; shot 02-categories -wtype "${WARM[@]}" -k Return; shot 03-grid -wtype "${WARM[@]}" -k Escape -k Escape "maja"; shot 04-search -wtype "${WARM[@]}" -k Escape -k question "brumm"; shot 05-tracksearch -wtype "${WARM[@]}" -k Return; sleep 4; shot 06-play -wtype "${WARM[@]}" -k Escape; shot 07-bar - -echo "wrote 7 frames to $OUTDIR" diff --git a/slint-frontend/ui/app.slint b/slint-frontend/ui/app.slint deleted file mode 100644 index 4949ac2..0000000 --- a/slint-frontend/ui/app.slint +++ /dev/null @@ -1,347 +0,0 @@ -import { Theme } from "theme.slint"; -import { RoundButton } from "widgets.slint"; -import { ShelfView, ResultsView } from "browse.slint"; -import { PlayerBar, PlayView, AlbumSheet } from "player.slint"; -import { RoomView } from "room.slint"; -import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab } from "models.slint"; - -export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, RoomCard, RoomRow, SceneTile, Swatch, Tab, Theme } - -export component AppWindow inherits Window { - title: "Musik Delfin"; - default-font-family: Theme.font; - default-font-size: Theme.text-body; - // The stage. In the web app a per-track animated canvas sits on top of this; here - // it is the whole background, on purpose. A full-viewport repaint loop is a floor - // you cannot get under while it runs at all, and it is the first thing `?pi=1` - // already removes on the device this is meant to replace. - background: @linear-gradient(180deg, - Theme.stage-top 0%, - Theme.stage-mid 45%, - Theme.sea-deep 100%); - - // ------------------------------------------------------------------- inputs -- - // "music" | "room" | "typing" - named rather than an enum so Rust owns the - // ordering and the labels, as it does for every other string on screen. - in property page: "music"; - in property <[Tab]> tabs; - - in property <[RoomRow]> room-rows; - in property <[SceneTile]> room-scenes; - in property <[Swatch]> room-swatches; - in property <[string]> shutter-presets; - in property room-loading: true; - - in property loading: true; - in property splash-text; - in property status-label; - in property online: false; - - in property play-view: false; - in property show-sheet: false; - in property can-go-back: false; - - in property <[Shelf]> shelves; - in property root-screen: true; - in property shelf-row: 0; - - in property group-label; - in property category-heading; - in property album-heading; - in property show-search: false; - in property search-text; - in property mode-label; - in property count-label; - in property <[TrackRow]> songs; - in property <[CategoryRow]> category-grid; - in property <[GridRow]> grid; - in property no-results: false; - in property sel-index: 0; - in property sel-row: 0; - - in property now; - in property playing: false; - in property progress: 0.0; - in property smooth: true; - in property volume: 60; - in property has-bar: false; - - in property sheet-title; - in property sheet-subtitle; - in property sheet-meta; - in property sheet-cover; - in property sheet-is-book: false; - in property <[TrackRow]> sheet-tracks; - in property sheet-index: 0; - - // How wide the grid may be. Rust reads this back to decide the column count, so the - // chunking into `GridRow`s and the layout agree about how many fit. - out property grid-width: self.width - 32px - Theme.rail-clearance; - - // ---------------------------------------------------------------- callbacks -- - // The special keys are named here rather than in Rust because this is where the - // `Key.*` constants exist; Rust receives "escape"/"left"/"enter"/… or the literal - // character that was typed. - callback key(string, bool, bool); - callback toggle(); - callback next(); - callback previous(); - callback seek(float); - callback set-volume(int); - callback mute(); - callback go-back(); - callback enter-group(int); - callback enter-category(int, string); - callback open-category(string); - callback open-album(string); - callback play-album(string); - callback play-song(string, int); - callback open-play-view(); - callback close-sheet(); - callback play-sheet-track(int); - callback select-page(string); - callback toggle-light(string); - callback set-light-level(string, int); - callback set-light-swatch(string, int); - callback set-shutter-preset(string, int); - callback shutter(string, string); - callback activate-scene(string); - - forward-focus: keys; - - keys := FocusScope { - key-pressed(event) => { - root.key( - event.text == Key.Escape ? "escape" - : event.text == Key.Return ? "enter" - : event.text == Key.Backspace ? "backspace" - : event.text == Key.Tab ? "tab" - : event.text == Key.LeftArrow ? "left" - : event.text == Key.RightArrow ? "right" - : event.text == Key.UpArrow ? "up" - : event.text == Key.DownArrow ? "down" - : event.text == Key.F1 ? "f1" - : event.text == Key.Space ? "space" - : event.text, - event.modifiers.control, - event.modifiers.shift); - accept - } - - VerticalLayout { - // ------------------------------------------------------------ header -- - if root.page == "music" && !root.play-view: HorizontalLayout { - alignment: center; - spacing: 16px; - padding-top: 18px; - padding-bottom: 6px; - Text { - text: "Musik Delfin"; - font-size: 34px; - font-weight: 900; - color: Theme.paper; - } - if root.status-label != "": VerticalLayout { - alignment: center; - Rectangle { - background: Theme.paper.with-alpha(0.16); - border-radius: Theme.pill; - width: status.preferred-width; - height: status.preferred-height; - status := HorizontalLayout { - padding-left: 14px; - padding-right: 14px; - padding-top: 6px; - padding-bottom: 6px; - Text { - text: root.status-label; - font-size: Theme.text-small + 1px; - font-weight: 800; - color: root.online ? Theme.paper : #ffb4a2; - } - } - } - } - } - - // -------------------------------------------------------------- body -- - if root.page == "music" && !root.play-view && root.root-screen: ShelfView { - shelves: root.shelves; - shelf-row: root.shelf-row; - sel-index: root.sel-index; - enter-group(s) => { root.enter-group(s); } - enter-category(s, key) => { root.enter-category(s, key); } - } - - if root.page == "music" && !root.play-view && !root.root-screen: ResultsView { - group-label: root.group-label; - category-heading: root.category-heading; - album-heading: root.album-heading; - show-search: root.show-search; - search-text: root.search-text; - mode-label: root.mode-label; - count-label: root.count-label; - songs: root.songs; - category-grid: root.category-grid; - grid: root.grid; - sel-index: root.sel-index; - sel-row: root.sel-row; - empty: root.no-results; - play-song(id, i) => { root.play-song(id, i); } - open-category(key) => { root.open-category(key); } - open-album(id) => { root.open-album(id); } - play-album(id) => { root.play-album(id); } - } - - if root.page == "room": RoomView { - rows: root.room-rows; - scenes: root.room-scenes; - swatches: root.room-swatches; - presets: root.shutter-presets; - loading: root.room-loading; - toggle-light(id) => { root.toggle-light(id); } - set-level(id, n) => { root.set-light-level(id, n); } - set-swatch(id, i) => { root.set-light-swatch(id, i); } - set-preset(id, i) => { root.set-shutter-preset(id, i); } - shutter(id, what) => { root.shutter(id, what); } - activate-scene(id) => { root.activate-scene(id); } - } - - if root.page == "typing": VerticalLayout { - alignment: center; - Text { - text: "Tippen"; - font-size: 34px; - font-weight: 900; - color: Theme.paper; - horizontal-alignment: center; - } - Text { - text: "Noch nicht portiert."; - font-size: Theme.text-heading; - font-weight: 700; - color: Theme.text-dim; - horizontal-alignment: center; - } - } - - if root.page == "music" && root.play-view: PlayView { - now: root.now; - playing: root.playing; - progress: root.progress; - smooth: root.smooth; - volume: root.volume; - toggle => { root.toggle(); } - next => { root.next(); } - previous => { root.previous(); } - seek(f) => { root.seek(f); } - set-volume(v) => { root.set-volume(v); } - mute => { root.mute(); } - open-album => { root.open-album(root.now.title); } - } - } - } - - // The player bar floats over the browse screen rather than displacing it, which is - // why the scrollers above reserve bottom padding for it. Geometry is set here - // rather than inside the component: a plain element takes its *preferred* width - // from its content, not its parent's width, so a bar that only declared a height - // left gaps for the grid behind it to show through. - if root.page == "music" && !root.play-view && root.has-bar: PlayerBar { - x: 0; - y: parent.height - self.height; - width: parent.width; - now: root.now; - playing: root.playing; - progress: root.progress; - smooth: root.smooth; - volume: root.volume; - toggle => { root.toggle(); } - next => { root.next(); } - previous => { root.previous(); } - seek(f) => { root.seek(f); } - set-volume(v) => { root.set-volume(v); } - mute => { root.mute(); } - open-play-view => { root.open-play-view(); } - } - - // One fixed round button in the top-left: back, everywhere it applies, so "top - // left" always means the same thing. - if root.can-go-back: RoundButton { - x: 24px; - y: 18px; - label: "‹"; - clicked => { root.go-back(); } - } - - // The vertical tab rail. Right edge, vertically centred - the one fixed control - // that is in the same place on every page. - tab-rail := Rectangle { - x: parent.width - self.width - 16px; - y: (parent.height - self.height) / 2; - width: 60px; - height: rail.preferred-height; - border-radius: 30px; - background: Theme.paper.with-alpha(0.22); - border-width: 1px; - border-color: Theme.paper.with-alpha(0.3); - - rail := VerticalLayout { - padding: 8px; - spacing: 6px; - for tab[i] in root.tabs: Rectangle { - width: 44px; - height: 44px; - border-radius: 22px; - background: root.page == tab.id ? Theme.accent : transparent; - animate background { duration: 150ms; } - Text { - text: tab.icon; - font-size: 20px; - color: root.page == tab.id ? white : Theme.paper; - vertical-alignment: center; - horizontal-alignment: center; - } - TouchArea { - clicked => { root.select-page(tab.id); } - } - } - } - } - - if root.show-sheet: AlbumSheet { - width: 100%; - height: 100%; - title: root.sheet-title; - subtitle: root.sheet-subtitle; - meta: root.sheet-meta; - cover: root.sheet-cover; - is-book: root.sheet-is-book; - tracks: root.sheet-tracks; - sel-index: root.sheet-index; - close => { root.close-sheet(); } - play(i) => { root.play-sheet-track(i); } - play-all => { root.play-sheet-track(0); } - } - - if root.loading: Rectangle { - background: Theme.sea-deep.with-alpha(0.92); - TouchArea { } - VerticalLayout { - alignment: center; - spacing: 18px; - Text { - text: "🐬"; - font-size: 96px; - horizontal-alignment: center; - } - Text { - text: root.splash-text; - font-size: 22px; - font-weight: 800; - color: Theme.paper; - horizontal-alignment: center; - } - } - } -} diff --git a/slint-frontend/ui/browse.slint b/slint-frontend/ui/browse.slint deleted file mode 100644 index 8fbe962..0000000 --- a/slint-frontend/ui/browse.slint +++ /dev/null @@ -1,567 +0,0 @@ -import { ListView } from "std-widgets.slint"; -import { Theme } from "theme.slint"; -import { GlassPanel, Cover } from "widgets.slint"; -import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow } from "models.slint"; - -// Heights are fixed rather than derived from content, and that is the whole reason the -// grid can be virtualized: a `ListView` that has to measure a row before it can decide -// whether to skip it has not skipped anything. The web grid does the same thing with -// `contain-intrinsic-size` for the same reason. -// -// A book is taller than an album, so the cover *slot* is sized for the taller of the -// two and each cover is centred in it. That keeps every row identical without -// flattening the shape difference that tells the two media apart. -export global Metrics { - out property cover-slot: 200px; - out property card-text: 78px; - out property card-height: 278px; // cover-slot + card-text - out property row-height: 302px; // card-height + grid-gap - out property shelf-height: 236px; - out property category-row-height: 260px; -} - -component SelectionRing inherits Rectangle { - in property selected; - in property current; - - // Selection outranks "currently playing": when the keyboard is on the album that is - // also playing, the ring it needs to see is the one that moves. - border-width: root.selected ? 5px : (root.current ? 4px : 0px); - border-color: root.selected ? Theme.paper : Theme.accent; - border-radius: Theme.radius-card + 6px; -} - -component AlbumCard inherits Rectangle { - in property tile; - in property selected; - callback open(); - callback play(); - - height: Metrics.card-height; - - ring := SelectionRing { - selected: root.selected; - current: root.tile.current; - x: -7px; - y: -7px; - width: parent.width + 14px; - height: parent.height + 14px; - } - - card := Rectangle { - background: root.tile.is-book ? Theme.card-book : Theme.card-music; - border-radius: Theme.radius-card; - drop-shadow-color: #00101759; - drop-shadow-blur: 18px; - drop-shadow-offset-y: 6px; - // The lift on selection: 120ms, the same as the web card's transition. - y: root.selected ? -3px : 0px; - animate y { duration: 120ms; easing: ease-out; } - - VerticalLayout { - padding: 10px; - spacing: 6px; - - // The cover, centred in a slot tall enough for the taller media type. - Rectangle { - height: Metrics.cover-slot - 20px; - HorizontalLayout { - alignment: center; - Cover { - source: root.tile.cover; - is-book: root.tile.is-book; - height: parent.height; - width: root.tile.is-book ? parent.height * 0.82 : parent.height; - } - } - TouchArea { - // Cover opens the track list; the title below starts it playing. - // Two targets, because "show me what is in this" and "play this" - // are different intentions and a child has both. - clicked => { root.open(); } - } - } - - Rectangle { - height: Metrics.card-text - 26px; - VerticalLayout { - spacing: 1px; - Text { - text: root.tile.title; - font-size: Theme.text-body - 1px; - font-weight: 800; - color: Theme.ink; - wrap: word-wrap; - overflow: elide; - // Two lines always, whether or not the title needs them, so - // every card in the row ends at the same place. - height: (Theme.text-body - 1px) * 2.4; - } - Text { - text: root.tile.artist; - font-size: Theme.text-small - 1px; - font-weight: 700; - color: Theme.ink.with-alpha(0.65); - overflow: elide; - } - } - TouchArea { - clicked => { root.play(); } - } - } - - Text { - text: root.tile.meta; - font-size: Theme.text-tiny; - font-weight: 800; - color: Theme.count-pink; - overflow: elide; - } - } - } -} - -component CategoryCard inherits Rectangle { - in property tile; - in property selected; - in property tile-width: Theme.shelf-tile; - callback open(); - - width: root.tile-width; - height: Metrics.shelf-height; - - SelectionRing { - selected: root.selected; - current: false; - x: -7px; - y: -7px; - width: parent.width + 14px; - height: parent.height + 14px; - } - - Rectangle { - background: root.tile.is-book ? Theme.card-book : Theme.card-music; - border-radius: Theme.radius-card; - drop-shadow-color: #00101759; - drop-shadow-blur: 18px; - drop-shadow-offset-y: 6px; - y: root.selected ? -3px : 0px; - animate y { duration: 120ms; easing: ease-out; } - - VerticalLayout { - padding: 8px; - spacing: 5px; - - // One album fills the tile; several show as a 2x2 contact sheet, which is - // how a category reads as "a stack of these" without a label saying so. - Rectangle { - height: root.tile-width - 16px; - clip: true; - if root.tile.cover-count <= 1: Cover { - source: root.tile.cover1; - is-book: root.tile.is-book; - width: 100%; - height: 100%; - } - if root.tile.cover-count > 1: VerticalLayout { - spacing: 4px; - HorizontalLayout { - spacing: 4px; - Cover { source: root.tile.cover1; radius: 6px; } - Cover { source: root.tile.cover2; radius: 6px; } - } - HorizontalLayout { - spacing: 4px; - Cover { source: root.tile.cover3; radius: 6px; } - Cover { source: root.tile.cover4; radius: 6px; } - } - } - } - - Text { - text: root.tile.key; - font-size: Theme.text-body; - font-weight: 800; - color: Theme.ink; - overflow: elide; - } - Text { - text: root.tile.count-label; - font-size: Theme.text-tiny; - font-weight: 800; - color: Theme.count-pink; - overflow: elide; - } - } - - TouchArea { - clicked => { root.open(); } - } - } -} - -component TrackListRow inherits Rectangle { - in property track; - in property selected; - callback play(); - - height: 58px; - background: root.track.current ? Theme.row-active : Theme.row-idle; - border-radius: Theme.radius-row; - border-width: root.selected ? 3px : 1px; - border-color: root.selected ? Theme.paper : (root.track.current ? Theme.accent : Theme.glass-border); - - HorizontalLayout { - padding-left: 12px; - padding-right: 16px; - spacing: 12px; - - VerticalLayout { - alignment: center; - Cover { - source: root.track.cover; - is-book: root.track.is-book; - radius: 8px; - height: 40px; - width: root.track.is-book ? 33px : 40px; - } - } - - VerticalLayout { - alignment: center; - Text { - text: root.track.title; - font-size: Theme.text-body; - font-weight: 800; - color: Theme.paper; - overflow: elide; - } - Text { - text: root.track.subtitle; - font-size: Theme.text-tiny; - font-weight: 700; - color: Theme.text-sub.with-alpha(0.75); - overflow: elide; - } - } - - VerticalLayout { - alignment: center; - Text { - text: root.track.duration; - font-family: Theme.mono; - font-size: Theme.text-small; - font-weight: 700; - color: Theme.text-dim.with-alpha(0.8); - } - } - } - - TouchArea { - clicked => { root.play(); } - } -} - -// The root screen: three shelves, each scrolling sideways. -export component ShelfView inherits Flickable { - in property <[Shelf]> shelves; - in property shelf-row; - in property sel-index; - callback enter-group(int); // shelf index - callback enter-category(int, string); // shelf index, category key - - content-height: layout.preferred-height; - content-width: self.width; - - layout := VerticalLayout { - padding-left: 32px; - padding-right: Theme.rail-clearance; - padding-top: 14px; - // Clear of the player bar, which floats over this rather than displacing it. - padding-bottom: 210px; - spacing: 25px; - - for shelf[s] in root.shelves: GlassPanel { - height: Metrics.shelf-height + 74px; - // The panel itself is a target: it opens the whole group. - TouchArea { - clicked => { root.enter-group(s); } - } - - VerticalLayout { - padding: 14px; - spacing: 10px; - - HorizontalLayout { - spacing: 10px; - alignment: start; - // A round frosted badge behind the emoji, which otherwise renders - // thin and low-contrast straight on the stage gradient. - Rectangle { - width: 34px; - height: 34px; - border-radius: 17px; - background: Theme.paper.with-alpha(0.2); - border-width: 1px; - border-color: Theme.paper.with-alpha(0.4); - Text { - text: shelf.icon; - font-size: 17px; - vertical-alignment: center; - horizontal-alignment: center; - } - } - VerticalLayout { - alignment: center; - Text { - text: shelf.label; - font-size: Theme.text-heading; - font-weight: 900; - color: Theme.paper; - } - } - VerticalLayout { - alignment: center; - Text { - text: "›"; - font-size: 17px; - font-weight: 900; - color: Theme.paper.with-alpha(0.6); - } - } - } - - if shelf.tiles.length == 0: Text { - text: shelf.empty-label; - font-size: Theme.text-small; - font-weight: 700; - color: Theme.text-dim.with-alpha(0.6); - horizontal-alignment: center; - vertical-alignment: center; - } - - if shelf.tiles.length > 0: Flickable { - content-width: shelf.tiles.length * (Theme.shelf-tile + 12px) + 20px; - content-height: self.height; - // Keep the focused tile in view as the arrow keys walk past the fold. - content-x: root.shelf-row != s - ? 0px - : -max(0px, min( - self.content-width - self.width, - root.sel-index * (Theme.shelf-tile + 12px) - self.width / 2 + Theme.shelf-tile / 2)); - animate content-x { duration: 180ms; easing: ease-out; } - - HorizontalLayout { - spacing: 12px; - padding: 10px; - alignment: start; - for tile[i] in shelf.tiles: CategoryCard { - tile: tile; - selected: root.shelf-row == s && root.sel-index == i; - open => { root.enter-category(s, tile.key); } - } - } - } - } - } - } -} - -// Inside a group, or showing search results. -// -// Exactly one of the three lists is ever populated, and that is a property of the query -// model rather than a coincidence worth defending against: track hits only exist in -// track-search mode, which forces the album list empty; the category list only exists -// when there is neither a query nor a chosen category, which is exactly when the album -// list is empty. So this shows one list filling the page, instead of stacking three -// inside a scroller and nesting a second scroller in it. -export component ResultsView inherits VerticalLayout { - in property group-label; - in property category-heading; - in property album-heading; - in property show-search; - in property search-text; - in property mode-label; - in property count-label; - in property <[TrackRow]> songs; - in property <[CategoryRow]> category-grid; - in property <[GridRow]> grid; - in property sel-index; - /// Which row of the visible grid holds the selection. Every row is the same height - /// by construction, so following the keyboard is arithmetic, not measurement. - in property sel-row; - in property empty; - callback play-song(string, int); - callback open-category(string); - callback open-album(string); - callback play-album(string); - - spacing: 6px; - padding-left: 32px; - padding-right: Theme.rail-clearance; - - if root.group-label != "": Text { - text: root.group-label; - font-size: 15px; - font-weight: 800; - color: Theme.paper; - horizontal-alignment: center; - } - - if root.show-search: HorizontalLayout { - alignment: center; - spacing: 12px; - padding-bottom: 4px; - - Rectangle { - background: Theme.paper; - border-radius: Theme.pill; - drop-shadow-color: #00101759; - drop-shadow-blur: 14px; - drop-shadow-offset-y: 4px; - width: chip.preferred-width; - height: chip.preferred-height; - - chip := HorizontalLayout { - padding-left: 10px; - padding-right: 20px; - padding-top: 10px; - padding-bottom: 10px; - spacing: 10px; - - Rectangle { - background: Theme.ink; - border-radius: Theme.pill; - width: label.preferred-width; - label := HorizontalLayout { - padding-left: 10px; - padding-right: 10px; - padding-top: 4px; - padding-bottom: 4px; - Text { - text: root.mode-label; - font-size: Theme.text-small + 1px; - font-weight: 800; - color: white; - } - } - } - Text { - text: root.search-text; - font-size: Theme.text-heading; - font-weight: 800; - color: Theme.ink; - vertical-alignment: center; - } - } - } - - VerticalLayout { - alignment: center; - Text { - text: root.count-label; - font-size: Theme.text-small + 1px; - font-weight: 700; - color: Theme.text-dim.with-alpha(0.85); - } - } - } - - if root.empty: VerticalLayout { - alignment: center; - Text { - text: "Nichts gefunden — probier andere Buchstaben!"; - font-size: Theme.text-heading; - font-weight: 800; - color: Theme.paper; - horizontal-alignment: center; - } - } - - // --- track hits ----------------------------------------------------------- - // Capped at 40 rows upstream, so this one does not need virtualizing. Held to a - // readable column rather than the full width, which is what lets a long album - // line elide instead of running the width of a 1080p screen. The heading rides - // inside that column so the two are aligned with each other, not with the page. - if root.songs.length > 0: Flickable { - content-height: songs-column.preferred-height + 210px; - content-width: self.width; - HorizontalLayout { - alignment: center; - songs-column := VerticalLayout { - // Without this the column is centred in whatever vertical space is - // left over, which on a short result list leaves it floating. - alignment: start; - width: 860px; - spacing: 8px; - Text { - text: "Titel"; - font-size: Theme.text-heading; - font-weight: 900; - color: Theme.paper; - } - GlassPanel { - height: song-rows.preferred-height; - song-rows := VerticalLayout { - padding: 12px; - spacing: 6px; - for song in root.songs: TrackListRow { - track: song; - selected: root.sel-index == song.nav; - play => { root.play-song(song.album-id, song.index); } - } - } - } - } - } - } - - // --- categories ----------------------------------------------------------- - if root.category-grid.length > 0: Text { - text: root.category-heading; - font-size: Theme.text-heading; - font-weight: 900; - color: Theme.paper; - } - if root.category-grid.length > 0: ListView { - for row in root.category-grid: HorizontalLayout { - height: Metrics.category-row-height; - spacing: Theme.grid-gap; - alignment: start; - for tile in row.tiles: CategoryCard { - tile: tile; - tile-width: Theme.card-width; - selected: root.sel-index == tile.nav; - open => { root.open-category(tile.key); } - } - } - } - - // --- albums --------------------------------------------------------------- - if root.grid.length > 0: Text { - text: root.album-heading; - font-size: Theme.text-heading; - font-weight: 900; - color: Theme.paper; - } - if root.grid.length > 0: album-list := ListView { - // Follow the keyboard without measuring anything. - content-y: -max(0px, min( - max(0px, self.content-height - self.height), - root.sel-row * Metrics.row-height)); - animate content-y { duration: 180ms; easing: ease-out; } - - for row in root.grid: HorizontalLayout { - height: Metrics.row-height; - spacing: Theme.grid-gap; - alignment: start; - for tile in row.tiles: AlbumCard { - width: Theme.card-width; - tile: tile; - selected: root.sel-index == tile.nav; - open => { root.open-album(tile.id); } - play => { root.play-album(tile.id); } - } - } - } -} diff --git a/slint-frontend/ui/models.slint b/slint-frontend/ui/models.slint deleted file mode 100644 index 25002a0..0000000 --- a/slint-frontend/ui/models.slint +++ /dev/null @@ -1,151 +0,0 @@ -// The shapes that cross between Rust and the UI. -// -// Everything here is already *presentation*: labels are formatted, durations are -// strings, covers are decoded images. Nothing in the .slint files formats a number or -// picks a word, so the German copy and the duration format live in one place (Rust) -// rather than being split across two languages. -// -// `nav` is each item's position in the single flat selection order that runs songs -> -// categories -> albums, so one pair of arrow keys walks the whole page. It is assigned -// in Rust, where the three lists are built together. - -export struct AlbumTile { - id: string, - title: string, - artist: string, - // "12 Songs · 45:03 · 🧸" - already assembled, including the figurine marker. - meta: string, - cover: image, - is-book: bool, - locked: bool, - // This album is the one playing; drawn with the accent ring rather than the - // selection ring, and outranked by it when they coincide. - current: bool, - nav: int, -} - -// The grid is handed to the UI pre-chunked into rows, because a `ListView` virtualizes -// its model and a flat list of 343 cards has no rows to skip. This is the direct -// counterpart of the web app's `content-visibility: auto` on `.grid > .card`, which -// exists for exactly the same measurement: 340 live cards saturated the Pi's renderer. -export struct GridRow { - tiles: [AlbumTile], -} - -export struct CategoryTile { - key: string, - // "7 Hörbücher" - count-label: string, - // Up to four covers in a 2x2, or one filling the tile when the category holds a - // single album. Four fields rather than an array: the count is fixed and small, and - // this keeps the tile a plain struct. - cover1: image, - cover2: image, - cover3: image, - cover4: image, - cover-count: int, - is-book: bool, - nav: int, -} - -export struct CategoryRow { - tiles: [CategoryTile], -} - -// One of the three shelves on the root screen. -export struct Shelf { - label: string, - icon: string, - tiles: [CategoryTile], - empty-label: string, -} - -export struct TrackRow { - title: string, - subtitle: string, - duration: string, - cover: image, - is-book: bool, - current: bool, - locked: bool, - album-id: string, - index: int, - nav: int, -} - -// What the player bar and the full-screen view both say. Shared so the two cannot -// drift apart on a copy change. -export struct NowPlaying { - title: string, - subtitle: string, - // "Song 3 von 8" / "Kapitel 3 von 8" - counter: string, - cover: image, - hero-cover: image, - is-book: bool, - has-album: bool, - // "−4:12", the time left in this track. - clock-label: string, - // "Noch 38:20 im Album" - empty for a podcast, where it means nothing. - remaining-label: string, -} - -// ------------------------------------------------------------- Mein Zimmer -- - -// One device card on the room page. Lights and shutters share a struct rather than -// having one each, because the grid renders them in the order Home Assistant's config -// lists them and two models would lose that order. `kind` is what the view branches on. -export struct RoomCard { - entity-id: string, - name: string, - // "light" | "shutter" - kind: string, - on: bool, - - // --- light --- - is-color: bool, - // 0..5, matching the five brightness buttons. - level: int, - tint: color, - // Which of the six swatches the light is currently set to, or -1. - swatch: int, - - // --- shutter --- - // Percent *closed*: 0 is fully open. Home Assistant reports the opposite; the - // inversion happens once, in Rust. - closed-percent: float, - // "Fährt runter …", or the preset name the position matches. - status: string, - // "" | "up" | "down" - moving: string, - supports-position: bool, - // Which of the four presets the position matches, or -1. - preset: int, -} - -// The room grid, pre-chunked into rows for the same reason the album grid is: the -// view lays rows out, and how many fit is a function of the window width, which Rust -// is the one that knows. -export struct RoomRow { - cards: [RoomCard], -} - -export struct SceneTile { - entity-id: string, - name: string, - active: bool, -} - -// One of the six colours a colour-capable light can be set to. -export struct Swatch { - name: string, - tint: color, -} - -// One entry in the vertical tab rail. Built in Rust like every other string on -// screen, so the page order, the icons and the German labels live in one place. -export struct Tab { - id: string, - icon: string, - label: string, -} diff --git a/slint-frontend/ui/player.slint b/slint-frontend/ui/player.slint deleted file mode 100644 index 9403a9c..0000000 --- a/slint-frontend/ui/player.slint +++ /dev/null @@ -1,413 +0,0 @@ -import { Theme } from "theme.slint"; -import { Cover, Transport, ProgressBar, VolumeBars, RoundButton, GlassPanel } from "widgets.slint"; -import { NowPlaying, TrackRow } from "models.slint"; - -// The bar along the bottom of the browse screen. -export component PlayerBar inherits Rectangle { - in property now; - in property playing; - in property progress; - in property smooth; - in property volume; - callback toggle(); - callback next(); - callback previous(); - callback seek(float); - callback set-volume(int); - callback mute(); - callback open-play-view(); - - height: 96px; - background: Theme.bar-bg; - - // The accent rule along the top is what separates the bar from the stage without a - // shadow, which over a gradient reads as a smudge. - Rectangle { - y: 0; - height: 3px; - width: 100%; - background: Theme.accent.with-alpha(0.5); - } - - HorizontalLayout { - padding-left: 24px; - padding-right: 24px; - padding-top: 16px; - padding-bottom: 16px; - spacing: 18px; - - // Cover: the way back into the full-screen view. - VerticalLayout { - alignment: center; - Rectangle { - width: root.now.is-book ? 46px : 56px; - height: 56px; - Cover { - source: root.now.cover; - is-book: root.now.is-book; - radius: 10px; - width: 100%; - height: 100%; - } - TouchArea { - clicked => { root.open-play-view(); } - } - } - } - - VerticalLayout { - alignment: center; - width: 240px; - Text { - text: root.now.title; - font-size: Theme.text-body; - font-weight: 800; - color: Theme.paper; - overflow: elide; - } - Text { - text: root.now.subtitle; - font-size: Theme.text-small; - font-weight: 600; - color: Theme.text-sub.with-alpha(0.75); - overflow: elide; - } - if root.now.has-album: Text { - text: root.now.counter; - font-size: Theme.text-tiny; - font-weight: 800; - color: Theme.accent-dim; - overflow: elide; - } - } - - VerticalLayout { - alignment: center; - horizontal-stretch: 1; - ProgressBar { - progress: root.progress; - smooth: root.smooth; - on-dark: true; - bar-height: 10px; - seek(fraction) => { root.seek(fraction); } - } - } - - VerticalLayout { - alignment: center; - Transport { - playing: root.playing; - size: 48px; - toggle => { root.toggle(); } - next => { root.next(); } - previous => { root.previous(); } - } - } - - VerticalLayout { - alignment: center; - VolumeBars { - volume: root.volume; - bar-width: 11px; - bar-height: 30px; - set-volume(v) => { root.set-volume(v); } - mute => { root.mute(); } - } - } - } -} - -// The full-screen now-playing view. -export component PlayView inherits VerticalLayout { - in property now; - in property playing; - in property progress; - in property smooth; - in property volume; - callback toggle(); - callback next(); - callback previous(); - callback seek(float); - callback set-volume(int); - callback mute(); - callback open-album(); - - alignment: center; - spacing: 18px; - padding: 32px; - - HorizontalLayout { - alignment: center; - Rectangle { - height: 320px; - width: root.now.is-book ? 262px : 320px; - drop-shadow-color: #0016208c; - drop-shadow-blur: 40px; - drop-shadow-offset-y: 20px; - - if root.now.has-album: Cover { - source: root.now.hero-cover; - is-book: root.now.is-book; - radius: 28px; - width: 100%; - height: 100%; - } - if !root.now.has-album: Rectangle { - background: #2a4f57; - border-radius: 28px; - } - TouchArea { - clicked => { root.open-album(); } - } - } - } - - VerticalLayout { - spacing: 6px; - Text { - text: root.now.title; - font-size: Theme.text-hero; - font-weight: 900; - color: Theme.paper; - horizontal-alignment: center; - wrap: word-wrap; - } - Text { - text: root.now.subtitle; - font-size: Theme.text-heading; - font-weight: 700; - color: Theme.text-dim.with-alpha(0.8); - horizontal-alignment: center; - overflow: elide; - } - if root.now.has-album: Text { - text: root.now.counter; - font-size: 15px; - font-weight: 800; - color: Theme.accent-dim; - horizontal-alignment: center; - } - } - - HorizontalLayout { - alignment: center; - VerticalLayout { - width: 680px; - spacing: 8px; - ProgressBar { - progress: root.progress; - smooth: root.smooth; - bar-height: 14px; - seek(fraction) => { root.seek(fraction); } - } - HorizontalLayout { - Text { - text: root.now.clock-label; - font-family: Theme.mono; - font-size: Theme.text-small + 1px; - font-weight: 800; - color: Theme.text-dim.with-alpha(0.8); - } - Rectangle { horizontal-stretch: 1; } - Text { - text: root.now.remaining-label; - font-size: Theme.text-small + 1px; - font-weight: 800; - color: Theme.text-dim.with-alpha(0.8); - } - } - } - } - - Transport { - playing: root.playing; - size: 72px; - toggle => { root.toggle(); } - next => { root.next(); } - previous => { root.previous(); } - } - - VolumeBars { - volume: root.volume; - bar-width: 18px; - bar-height: 44px; - set-volume(v) => { root.set-volume(v); } - mute => { root.mute(); } - } -} - -// The track list for one album, over a dimmed stage. -export component AlbumSheet inherits Rectangle { - in property title; - in property subtitle; - in property meta; - in property cover; - in property is-book; - in property <[TrackRow]> tracks; - in property sel-index; - callback close(); - callback play(int); - callback play-all(); - - background: Theme.backdrop.with-alpha(0.6); - - // Catches every click that misses the sheet, so tapping the dimmed area closes it. - TouchArea { - clicked => { root.close(); } - } - - Rectangle { - width: 640px; - height: min(root.height - 80px, 720px); - background: Theme.sheet-bg; - border-radius: Theme.radius-sheet; - drop-shadow-color: #0016208c; - drop-shadow-blur: 60px; - drop-shadow-offset-y: 24px; - - // Without this the backdrop's TouchArea above would receive clicks aimed at the - // sheet's own background and close it. - TouchArea { } - - VerticalLayout { - padding: 26px; - spacing: 16px; - - HorizontalLayout { - spacing: 18px; - Cover { - source: root.cover; - is-book: root.is-book; - width: root.is-book ? 123px : 150px; - height: 150px; - } - VerticalLayout { - alignment: start; - spacing: 6px; - Text { - text: root.title; - font-size: Theme.text-title; - font-weight: 900; - color: Theme.ink; - wrap: word-wrap; - } - Text { - text: root.subtitle; - font-size: Theme.text-body; - font-weight: 700; - color: Theme.ink.with-alpha(0.7); - overflow: elide; - } - Text { - text: root.meta; - font-size: Theme.text-small; - font-weight: 800; - color: Theme.count-pink; - overflow: elide; - } - Rectangle { - height: 8px; - } - Rectangle { - background: play-all-touch.pressed ? Theme.accent-dim : Theme.accent; - border-radius: Theme.pill; - width: play-all.preferred-width; - height: play-all.preferred-height; - play-all := HorizontalLayout { - padding-left: 18px; - padding-right: 18px; - padding-top: 9px; - padding-bottom: 9px; - Text { - text: "▶ Alle abspielen"; - font-size: Theme.text-small + 1px; - font-weight: 800; - color: white; - } - } - play-all-touch := TouchArea { - clicked => { root.play-all(); } - } - } - } - VerticalLayout { - alignment: start; - RoundButton { - label: "×"; - size: 40px; - clicked => { root.close(); } - } - } - } - - Flickable { - content-height: list.preferred-height; - content-width: self.width; - // Follow the keyboard through the list without measuring: every row is - // the same height, so the selected one's offset is arithmetic. - content-y: -max(0px, min( - max(0px, self.content-height - self.height), - root.sel-index * 52px - self.height / 2)); - animate content-y { duration: 150ms; easing: ease-out; } - - list := VerticalLayout { - spacing: 4px; - for track[i] in root.tracks: Rectangle { - height: 48px; - background: track.current ? Theme.accent.with-alpha(0.18) : transparent; - border-radius: 14px; - border-width: root.sel-index == i ? 3px : 0px; - border-color: Theme.accent; - - HorizontalLayout { - padding-left: 14px; - padding-right: 14px; - spacing: 12px; - VerticalLayout { - alignment: center; - Rectangle { - width: 30px; - height: 30px; - border-radius: 15px; - background: track.current ? Theme.accent : Theme.ink.with-alpha(0.12); - Text { - text: i + 1; - font-size: Theme.text-tiny; - font-weight: 800; - color: track.current ? white : Theme.ink; - vertical-alignment: center; - horizontal-alignment: center; - } - } - } - VerticalLayout { - alignment: center; - Text { - text: track.title; - font-size: Theme.text-small + 1px; - font-weight: 700; - color: Theme.ink; - overflow: elide; - } - } - VerticalLayout { - alignment: center; - Text { - text: track.duration; - font-family: Theme.mono; - font-size: Theme.text-tiny; - font-weight: 700; - color: Theme.ink.with-alpha(0.6); - } - } - } - - TouchArea { - clicked => { root.play(i); } - } - } - } - } - } - } -} diff --git a/slint-frontend/ui/room.slint b/slint-frontend/ui/room.slint deleted file mode 100644 index 1ca4d6e..0000000 --- a/slint-frontend/ui/room.slint +++ /dev/null @@ -1,460 +0,0 @@ -// "Mein Zimmer" - the Home Assistant room panel. -// -// Its own violet hue rather than the player's teal, so the two pages read as siblings. -// Lights and shutters are drawn from one `RoomCard` model in Home Assistant's own -// config order; `kind` is what each card branches on. - -import { Theme } from "theme.slint"; -import { RoomCard, RoomRow, SceneTile, Swatch } from "models.slint"; - -component ToggleSwitch inherits Rectangle { - in property on; - callback clicked(); - - width: 74px; - height: 42px; - border-radius: 21px; - background: root.on ? Theme.room-accent : Theme.room-toggle-off; - animate background { duration: 140ms; } - - Rectangle { - x: root.on ? parent.width - self.width - 4px : 4px; - y: 4px; - width: 34px; - height: 34px; - border-radius: 17px; - background: white; - drop-shadow-color: #0f051d66; - drop-shadow-blur: 6px; - drop-shadow-offset-y: 2px; - animate x { duration: 140ms; easing: ease-out; } - } - - TouchArea { - clicked => { root.clicked(); } - } -} - -// A flat labelled button - the shutter presets and the up/stop/down row. -component RoomButton inherits Rectangle { - in property label; - in property active; - in property active-fill: Theme.room-accent; - in property text-size: 14px; - callback clicked(); - - border-radius: 14px; - background: root.active ? root.active-fill : (touch.pressed ? Theme.room-off-bg : Theme.room-btn); - animate background { duration: 120ms; } - - Text { - text: root.label; - font-size: root.text-size; - font-weight: 800; - color: root.active ? white : Theme.room-btn-fg; - vertical-alignment: center; - horizontal-alignment: center; - } - - touch := TouchArea { - clicked => { root.clicked(); } - } -} - -component RoomCardFrame inherits Rectangle { - in property glow; - in property glow-tint; - - background: Theme.room-card.with-alpha(0.55); - border-radius: 22px; - // Standing in for the web card's `0 0 0 3px color-mix(...)` ring: a lit lamp's own - // colour around its card is how the page reads at a glance from across the room. - border-width: root.glow ? 3px : 0px; - border-color: root.glow-tint; - drop-shadow-color: #0f051d59; - drop-shadow-blur: 24px; - drop-shadow-offset-y: 8px; -} - -component LightCard inherits RoomCardFrame { - in property card; - in property <[Swatch]> swatches; - callback toggle(); - callback set-level(int); - callback set-swatch(int); - - glow: root.card.on; - glow-tint: root.card.tint; - - VerticalLayout { - padding-left: 20px; - padding-right: 20px; - padding-top: 18px; - padding-bottom: 20px; - spacing: 14px; - - HorizontalLayout { - spacing: 14px; - Rectangle { - width: 56px; - height: 56px; - border-radius: 16px; - background: root.card.on ? root.card.tint : Theme.room-off-bg; - drop-shadow-color: root.card.on ? root.card.tint : transparent; - drop-shadow-blur: 22px; - // A pendant for a colour lamp, a ceiling fitting for a plain one - - // drawn rather than iconographic, because there is no icon font here. - Rectangle { - width: 34px; - height: 34px; - x: 11px; - y: 11px; - if root.card.is-color: Path { - width: 100%; - height: 100%; - viewbox-width: 48; - viewbox-height: 48; - stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg; - stroke-width: 3px; - commands: "M 24 7 A 17 17 0 1 1 23.9 7 Z"; - } - if !root.card.is-color: Path { - width: 100%; - height: 100%; - viewbox-width: 48; - viewbox-height: 48; - stroke: root.card.on ? Theme.room-btn-fg : Theme.room-off-fg; - stroke-width: 3px; - commands: "M 9 30 L 24 11 L 39 30 Z M 24 4 L 24 11"; - } - } - } - VerticalLayout { - alignment: center; - horizontal-stretch: 1; - Text { - text: root.card.name; - font-size: 19px; - font-weight: 900; - color: Theme.room-ink; - overflow: elide; - } - } - VerticalLayout { - alignment: center; - ToggleSwitch { - on: root.card.on; - clicked => { root.toggle(); } - } - } - } - - // Five steps rather than a slider: a child aiming at a bar hits a level. - HorizontalLayout { - spacing: 6px; - height: 46px; - opacity: root.card.on ? 1.0 : 0.55; - for step[i] in [1, 2, 3, 4, 5]: Rectangle { - border-radius: 12px; - background: root.card.on && root.card.level >= step - ? root.card.tint - : Theme.room-off-bg; - animate background { duration: 120ms; } - TouchArea { - clicked => { root.set-level(step); } - } - } - } - - if root.card.is-color: HorizontalLayout { - spacing: 10px; - height: 46px; - alignment: start; - for swatch[i] in root.swatches: Rectangle { - width: 46px; - height: 46px; - border-radius: 23px; - background: swatch.tint; - border-width: 4px; - border-color: root.card.on && root.card.swatch == i - ? Theme.room-btn-fg - : Theme.room-card.with-alpha(0.8); - drop-shadow-color: #0f051d59; - drop-shadow-blur: 8px; - drop-shadow-offset-y: 3px; - TouchArea { - clicked => { root.set-swatch(i); } - } - } - } - } -} - -component ShutterCard inherits RoomCardFrame { - in property card; - in property <[string]> presets; - callback set-preset(int); - callback open(); - callback stop(); - callback close(); - - VerticalLayout { - padding-left: 20px; - padding-right: 20px; - padding-top: 18px; - padding-bottom: 20px; - spacing: 16px; - - HorizontalLayout { - spacing: 14px; - alignment: start; - Rectangle { - width: 56px; - height: 56px; - border-radius: 16px; - background: Theme.room-icon-bg; - Path { - x: 11px; - y: 11px; - width: 34px; - height: 34px; - viewbox-width: 48; - viewbox-height: 48; - stroke: Theme.room-icon-fg; - stroke-width: 3px; - commands: "M 7 7 L 41 7 L 41 41 L 7 41 Z M 7 16 L 41 16 M 7 24 L 41 24 M 7 32 L 41 32"; - } - } - VerticalLayout { - alignment: center; - Text { - text: root.card.name; - font-size: 19px; - font-weight: 900; - color: Theme.room-ink; - overflow: elide; - } - Text { - text: root.card.status; - font-size: 13px; - font-weight: 700; - color: Theme.room-sub.with-alpha(0.8); - } - } - } - - HorizontalLayout { - spacing: 18px; - - // The window itself: sky behind, slats drawn down from the top to however - // closed the shutter is. Real covers report their own position, so this - // only ever reflects Home Assistant - no client-side movement animation. - Rectangle { - width: 104px; - height: 140px; - border-radius: 12px; - border-width: 3px; - border-color: Theme.room-icon-fg; - clip: true; - background: @linear-gradient(180deg, Theme.room-window-a 0%, Theme.room-window-b 100%); - - Rectangle { - x: 0; - y: 0; - width: 100%; - height: parent.height * clamp(root.card.closed-percent / 100, 0.0, 1.0); - clip: true; - animate height { duration: 200ms; easing: linear; } - // The slats, as a fixed ladder clipped by the parent's height. - for slat[i] in [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]: Rectangle { - y: i * 12px; - width: 100%; - height: 12px; - background: Theme.room-slat-a; - Rectangle { - y: 9px; - width: 100%; - height: 3px; - background: Theme.room-slat-b; - } - } - } - } - - VerticalLayout { - spacing: 10px; - horizontal-stretch: 1; - - if root.card.supports-position: VerticalLayout { - spacing: 8px; - for row[r] in [0, 1]: HorizontalLayout { - spacing: 8px; - height: 48px; - for col[c] in [0, 1]: RoomButton { - label: root.presets[r * 2 + c]; - active: root.card.preset == r * 2 + c; - clicked => { root.set-preset(r * 2 + c); } - } - } - } - - HorizontalLayout { - spacing: 8px; - height: 52px; - RoomButton { - label: "▲"; - text-size: 20px; - active: root.card.moving == "up"; - clicked => { root.open(); } - } - RoomButton { - label: "■"; - text-size: 18px; - active: true; - active-fill: Theme.room-stop; - clicked => { root.stop(); } - } - RoomButton { - label: "▼"; - text-size: 20px; - active: root.card.moving == "down"; - clicked => { root.close(); } - } - } - } - } - } -} - -export component RoomView inherits Rectangle { - in property <[RoomRow]> rows; - in property <[SceneTile]> scenes; - in property <[Swatch]> swatches; - in property <[string]> presets; - in property loading; - callback toggle-light(string); - callback set-level(string, int); - callback set-swatch(string, int); - callback set-preset(string, int); - callback shutter(string, string); // entity, "open" | "stop" | "close" - callback activate-scene(string); - - background: @linear-gradient(180deg, - Theme.room-top 0%, - Theme.room-mid 45%, - Theme.room-deep 100%); - - VerticalLayout { - padding-top: 18px; - spacing: 6px; - - Text { - text: "Mein Zimmer"; - font-size: 34px; - font-weight: 900; - color: Theme.room-paper; - horizontal-alignment: center; - } - - if root.loading: VerticalLayout { - alignment: center; - Text { - text: "Einen Moment …"; - font-size: 22px; - font-weight: 800; - color: Theme.room-paper; - horizontal-alignment: center; - } - } - - if !root.loading: Flickable { - content-height: body.preferred-height; - content-width: self.width; - - body := VerticalLayout { - padding-left: 32px; - padding-right: Theme.rail-clearance; - padding-top: 14px; - padding-bottom: 64px; - spacing: 30px; - alignment: start; - - if root.scenes.length > 0: VerticalLayout { - spacing: 14px; - Text { - text: "Szenen"; - font-size: 19px; - font-weight: 900; - color: Theme.room-paper; - horizontal-alignment: center; - } - HorizontalLayout { - alignment: center; - spacing: 12px; - for scene in root.scenes: Rectangle { - height: 52px; - width: pill.preferred-width; - border-radius: 26px; - background: scene.active - ? Theme.room-paper - : Theme.room-paper.with-alpha(0.18); - animate background { duration: 140ms; } - pill := HorizontalLayout { - padding-left: 20px; - padding-right: 20px; - spacing: 8px; - VerticalLayout { - alignment: center; - Text { - text: "✨"; - font-size: 22px; - } - } - VerticalLayout { - alignment: center; - Text { - text: scene.name; - font-size: 15px; - font-weight: 800; - color: scene.active ? Theme.room-btn-fg : Theme.room-paper; - } - } - } - TouchArea { - clicked => { root.activate-scene(scene.entity-id); } - } - } - } - } - - // Both kinds share one cell width, so the grid stays a grid whatever - // mix of devices Home Assistant reports. - for row in root.rows: HorizontalLayout { - alignment: center; - spacing: 20px; - for card in row.cards: Rectangle { - width: Theme.room-card-width; - height: card.kind == "shutter" ? 268px : 246px; - if card.kind == "light": LightCard { - width: 100%; - card: card; - swatches: root.swatches; - toggle => { root.toggle-light(card.entity-id); } - set-level(n) => { root.set-level(card.entity-id, n); } - set-swatch(i) => { root.set-swatch(card.entity-id, i); } - } - if card.kind == "shutter": ShutterCard { - width: 100%; - card: card; - presets: root.presets; - set-preset(i) => { root.set-preset(card.entity-id, i); } - open => { root.shutter(card.entity-id, "open"); } - stop => { root.shutter(card.entity-id, "stop"); } - close => { root.shutter(card.entity-id, "close"); } - } - } - } - } - } - } -} diff --git a/slint-frontend/ui/theme.slint b/slint-frontend/ui/theme.slint deleted file mode 100644 index b116fda..0000000 --- a/slint-frontend/ui/theme.slint +++ /dev/null @@ -1,107 +0,0 @@ -// The palette, converted once from the web front-end's oklch tokens -// (`web/src/styles/app.css`) into the sRGB hex Slint speaks. The oklch source value is -// kept beside each one, because that is the form the design is actually maintained in - -// if a colour needs adjusting, adjust the oklch and re-convert (`src/oklch.rs`), rather -// than nudging the hex and letting the two drift. - -export global Theme { - // ------------------------------------------------------------------ colours -- - out property ink: #113339; // oklch(30% 0.04 210) - out property paper: #eef7f9; // oklch(97% 0.01 210) - out property accent: #de73bd; // oklch(70% 0.16 340) - out property accent-dim: #f292d3; // oklch(78% 0.14 340) - out property sea-deep: #001b21; // oklch(20% 0.045 210) - out property shadow: #001017; // oklch(15% 0.05 210) - - // The stage gradient, which in the web app sits under an animated canvas. There is - // no canvas here: a full-viewport repaint loop is the one cost a Pi cannot absorb, - // and `?pi=1` already removes it on the device this replaces. - out property stage-top: #397d88; // oklch(55% 0.07 210) - out property stage-mid: #0e4b54; // oklch(38% 0.06 210) - - out property text-dim: #c9dbdf; // oklch(88% 0.02 210) - out property text-sub: #c0d2d5; // oklch(85% 0.02 210) - out property count-pink: #7d0563; // oklch(40% 0.17 340) - - out property card-music: #e4f1f4; // oklch(95% 0.015 210) - out property card-book: #ffd5ab; // oklch(92% 0.09 55) - out property book-edge-1: #ffc298; // oklch(86% 0.09 55) - out property book-edge-2: #e5a880; // oklch(78% 0.09 55) - - out property bar-bg: #00171e; // oklch(18% 0.05 210) - out property sheet-bg: #e9f4f6; // oklch(96% 0.012 210) - out property backdrop: #000e12; // oklch(15% 0.03 210) - // Two track colours, not one with an alpha: the seek bar sits on the pale stage in - // the play view and on the near-black player bar in the browse view, and a single - // translucent fill that reads correctly on one is invisible on the other. - out property progress-track: #1b3236; // oklch(30% 0.03 210) - out property progress-track-dark: #2e4f57; - out property vol-empty: #c9dbdf; // oklch(88% 0.02 210) - - // Glass, without the glass. `backdrop-filter` has no counterpart here, so these are - // exactly the web app's own `data-blur="off"` fallback: a flat translucent fill. - // That is a real loss of depth and the honest substitute for it - faking a blur by - // compositing a pre-blurred copy would reintroduce the per-frame cost the blur was - // dropped to avoid. - out property glass: @linear-gradient(160deg, #eef7f924 0%, #eef7f90d 100%); - out property glass-border: #eef7f929; - out property row-idle: #f7fcfd1a; - out property row-active: #f7fcfd38; - - // --------------------------------------------------- Mein Zimmer (violet) -- - // Its own hue (~298-310) rather than the player's teal (~210), so the two pages - // read as siblings rather than one bleeding into the other. Same oklch-converted- - // once treatment as above. - out property room-ink: #181322; // oklch(20% 0.03 300) - out property room-paper: #f6f4fb; // oklch(97% 0.01 302) - out property room-accent: #986dd0; // oklch(62% 0.15 302) - out property room-card: #f3f0f9; // oklch(96% 0.012 300) - out property room-top: #885cb5; // oklch(56% 0.14 305) - out property room-mid: #4d2f77; // oklch(38% 0.12 300) - out property room-deep: #1e0c37; // oklch(21% 0.08 298) - out property room-icon-bg: #dad3e9; // oklch(88% 0.03 300) - out property room-icon-fg: #3e3451; // oklch(35% 0.05 300) - out property room-off-bg: #d9d5e3; // oklch(88% 0.02 300) - out property room-off-fg: #736f7c; // oklch(55% 0.02 300) - out property room-btn: #dcd8e6; // oklch(89% 0.02 300) - out property room-btn-fg: #2c243a; // oklch(28% 0.04 300) - out property room-stop: #e85854; // oklch(65% 0.18 25) - out property room-toggle-off: #cbc9d0; - out property room-sub: #585264; // oklch(45% 0.03 300) - out property room-slat-a: #a19baf; // oklch(70% 0.03 300) - out property room-slat-b: #7d778a; // oklch(58% 0.03 300) - out property room-window-a: #87d1e8; // oklch(82% 0.08 220) - out property room-window-b: #50afb4; // oklch(70% 0.09 200) - out property room-card-width: 380px; - - // ------------------------------------------------------------------- metrics -- - out property radius-panel: 22px; - out property radius-card: 16px; - out property radius-row: 12px; - out property radius-sheet: 26px; - out property pill: 999px; - - // The album grid's track width. The web grid is `minmax(180px, 1fr)`; keeping the - // same number keeps the same number of columns at 1920, which is what the shelf - // rows and the measured card height below were both tuned against. - out property card-width: 180px; - out property grid-gap: 24px; - out property shelf-tile: 148px; - - // How much of the right edge the page must leave clear for nothing to render - // underneath the corner controls. - out property rail-clearance: 88px; - - // ---------------------------------------------------------------- typography -- - // Heavy throughout: 700/800/900. Durations and counters use the monospace family so - // a ticking clock does not reflow the row it sits in. - out property font: "Nunito"; - out property mono: "monospace"; - - out property text-hero: 44px; - out property text-title: 26px; - out property text-heading: 20px; - out property text-body: 16px; - out property text-small: 13px; - out property text-tiny: 12px; -} diff --git a/slint-frontend/ui/widgets.slint b/slint-frontend/ui/widgets.slint deleted file mode 100644 index bdb8ddd..0000000 --- a/slint-frontend/ui/widgets.slint +++ /dev/null @@ -1,299 +0,0 @@ -import { Theme } from "theme.slint"; - -// A frosted surface wrapped around a shelf or a list, so it reads as something -// floating over the stage rather than text on a gradient. -export component GlassPanel inherits Rectangle { - background: Theme.glass; - border-width: 1px; - border-color: Theme.glass-border; - border-radius: Theme.radius-panel; - drop-shadow-color: #00101759; - drop-shadow-blur: 24px; - drop-shadow-offset-y: 8px; -} - -// Album art. `source` is whatever the cover store has decoded; until it has one, the -// Rust side hands over generated art built from the album's own three colours, so this -// never has an empty frame to show. -// -// The shape comes from the media type and from nowhere else: square for an album, -// taller than wide for an audiobook, everywhere either appears. -export component Cover inherits Rectangle { - in property source; - in property is-book: false; - in property radius: Theme.radius-card; - - clip: true; - border-radius: root.radius; - // A book keeps a sliver of its generated spine showing past real square artwork, so - // the shelf metaphor survives contact with a photograph. - background: root.is-book ? Theme.book-edge-2 : transparent; - - Image { - source: root.source; - image-fit: cover; - width: root.is-book ? parent.width * 1.05 : parent.width; - height: parent.height; - x: 0; - } -} - -// Prev / next / play. Every glyph is drawn from rectangles and a clip, not an icon -// font: there is no icon font on the device, and a missing glyph is a blank button. -export component TransportButton inherits Rectangle { - in property size: 48px; - in property primary: false; - in property playing: false; - in property direction: "play"; // "play" | "next" | "previous" - callback clicked(); - - width: root.size; - height: root.size; - border-radius: root.size / 2; - background: root.primary - ? (touch.pressed ? Theme.accent-dim : Theme.accent) - : (touch.pressed ? #2a4f57 : Theme.ink); - drop-shadow-color: root.primary ? #de73bd80 : #00101766; - drop-shadow-blur: root.primary ? 20px : 10px; - drop-shadow-offset-y: 4px; - animate background { duration: 100ms; } - - // Pause: two bars. Play and the skips: a triangle, clipped out of a square. - if root.direction == "play" && root.playing: HorizontalLayout { - alignment: center; - spacing: root.size * 0.1; - Rectangle { - width: root.size * 0.1; - height: root.size * 0.34; - border-radius: 2px; - background: white; - } - Rectangle { - width: root.size * 0.1; - height: root.size * 0.34; - border-radius: 2px; - background: white; - } - } - - if root.direction != "play" || !root.playing: HorizontalLayout { - alignment: center; - spacing: root.size * 0.06; - // "Previous" is the same triangle mirrored, plus the bar it butts against. - if root.direction == "previous": Rectangle { - width: root.size * 0.07; - height: root.size * 0.3; - border-radius: 1px; - background: white; - } - Rectangle { - width: root.size * 0.3; - height: root.size * 0.3; - Path { - width: 100%; - height: 100%; - fill: white; - viewbox-width: 100; - viewbox-height: 100; - commands: root.direction == "previous" - ? "M 100 0 L 100 100 L 0 50 Z" - : "M 6 0 L 100 50 L 6 100 Z"; - } - } - if root.direction == "next": Rectangle { - width: root.size * 0.07; - height: root.size * 0.3; - border-radius: 1px; - background: white; - } - } - - touch := TouchArea { - clicked => { root.clicked(); } - } -} - -export component Transport inherits HorizontalLayout { - in property playing; - in property size: 48px; - callback toggle(); - callback next(); - callback previous(); - - alignment: center; - spacing: root.size * 0.21; - - VerticalLayout { - alignment: center; - TransportButton { - direction: "previous"; - size: root.size; - clicked => { root.previous(); } - } - } - VerticalLayout { - alignment: center; - TransportButton { - direction: "play"; - primary: true; - playing: root.playing; - // The play button is the one target a child aims at from across the room. - size: root.size * 1.26; - clicked => { root.toggle(); } - } - } - VerticalLayout { - alignment: center; - TransportButton { - direction: "next"; - size: root.size; - clicked => { root.next(); } - } - } -} - -// The seek bar. -// -// `position` is only pushed twice a second, so the fill is *animated* between frames -// rather than recomputed on a client-side clock. That puts the interpolation on the -// render side, where it costs a property evaluation per frame instead of a layout pass, -// and it is why there is no equivalent of the web client's `usePlaybackClock` here. -// -// The animation is suppressed whenever `smooth` goes false - while dragging, and for -// the frame a track changes on - because sliding the fill across two unrelated -// positions is exactly the "it moves slightly backwards" artefact the web UI has. -export component ProgressBar inherits Rectangle { - in property progress; // 0..1 - in property interactive: true; - in property bar-height: 10px; - in property smooth: true; - /// Set on the player bar, whose background is darker than the seek bar's own - /// track would otherwise show against. - in property on-dark: false; - callback seek(float); - - height: max(root.bar-height, 28px); - - track := Rectangle { - y: (parent.height - root.bar-height) / 2; - height: root.bar-height; - width: 100%; - border-radius: root.bar-height / 2; - background: root.on-dark ? Theme.progress-track-dark : Theme.progress-track.with-alpha(0.6); - clip: true; - - fill := Rectangle { - x: 0; - width: parent.width * clamp(root.progress, 0.0, 1.0); - height: 100%; - border-radius: root.bar-height / 2; - background: Theme.accent; - animate width { - duration: root.smooth ? 500ms : 0ms; - easing: linear; - } - } - } - - // A grab handle only while it is worth having one: at player-bar size the bar is - // the target, and a knob would be smaller than the thing it sits on. - if root.interactive && root.bar-height >= 12px: Rectangle { - x: max(0px, min(parent.width - self.width, track.width * clamp(root.progress, 0.0, 1.0) - self.width / 2)); - y: (parent.height - self.height) / 2; - width: root.bar-height * 1.7; - height: root.bar-height * 1.7; - border-radius: self.width / 2; - background: Theme.paper; - drop-shadow-color: #00101773; - drop-shadow-blur: 8px; - } - - if root.interactive: TouchArea { - width: 100%; - height: 100%; - // Both a click and a drag resolve to "the position under the finger", so - // scrubbing works without a separate drag state machine. - moved => { - if (self.pressed) { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); } - } - clicked => { root.seek(clamp(self.mouse-x / track.width, 0.0, 1.0)); } - } -} - -// Volume as five discrete steps, not a continuous slider: a child aiming at a bar hits -// a level, where a slider would hand back whatever pixel they landed on. -export component VolumeBars inherits HorizontalLayout { - in property volume; // 0..100 - in property bar-width: 11px; - in property bar-height: 30px; - callback set-volume(int); - callback mute(); - - spacing: root.bar-width * 0.3; - alignment: center; - - VerticalLayout { - alignment: center; - Rectangle { - width: root.bar-height * 0.8; - height: root.bar-height * 0.8; - border-radius: self.width / 2; - background: speaker.has-hover ? #ffffff26 : transparent; - Text { - text: root.volume == 0 ? "🔇" : "🔊"; - font-size: root.bar-width * 1.6; - vertical-alignment: center; - horizontal-alignment: center; - } - speaker := TouchArea { - clicked => { root.mute(); } - } - } - } - - for level[i] in [20, 40, 60, 80, 100]: VerticalLayout { - alignment: center; - Rectangle { - width: root.bar-width; - // Stepped heights, so the control reads as a volume ramp at a glance. - height: root.bar-height * (0.45 + 0.55 * (i + 1) / 5); - border-radius: 5px; - background: root.volume >= level ? Theme.accent : Theme.vol-empty.with-alpha(0.4); - animate background { duration: 120ms; } - TouchArea { - clicked => { root.set-volume(level); } - } - } - } -} - -// A round icon button - back, close, and the fixed corner controls. -export component RoundButton inherits Rectangle { - in property label; - in property size: 44px; - in property highlighted: false; - callback clicked(); - - width: root.size; - height: root.size; - border-radius: root.size / 2; - background: root.highlighted - ? Theme.accent - : (touch.pressed ? Theme.text-dim : Theme.paper.with-alpha(0.95)); - drop-shadow-color: #00101766; - drop-shadow-blur: 18px; - drop-shadow-offset-y: 6px; - - Text { - text: root.label; - font-size: root.size * 0.45; - font-weight: 900; - color: root.highlighted ? white : Theme.ink; - vertical-alignment: center; - horizontal-alignment: center; - } - - touch := TouchArea { - clicked => { root.clicked(); } - } -}