From 6ccb3e458f80fdda4d9557c207a3d50910fa38f1 Mon Sep 17 00:00:00 2001 From: Martin Bauer Date: Sun, 20 Sep 2026 22:26:09 +0200 Subject: [PATCH] Add a native Slint front-end for the music player The React UI is sluggish on the kiosk and the reasons are browser-shaped: a compositor deciding what gets its own layer, a requestAnimationFrame loop repainting the viewport, and a JPEG decode per album card per cold start. The last three commits chased that through the service worker and the cover pipeline. This is the other direction - the same backend, the same layout, the same German copy, without a browser. Browse and play only. The typing game, the room-lights page, parent mode and the IR-remote assignment stay web-only, and nothing here touches python-backend. Four things carry the performance claim, in rough order of how much they should matter: Covers are downscaled once, ever. The backend serves one size - 640px on the long edge, no ?size= - and a grid card is 180. src/covers.rs fetches each cover once on a worker thread, resizes it, and writes a JPEG thumbnail to ~/.cache/musicmouse-slint/covers/. Two tiers and only two, which is what makes the cache worth keeping: a per-widget pixel size would give each album a dozen near-identical files and a fresh decode for each. The cache is dropped when the websocket announces a rescan, because that is the one moment the backend rewrites the art behind an unchanged cover URL - the trap sw.js had to learn about the hard way. The grid is virtualized. Rust hands the UI the album list pre-chunked into rows and the view puts those in a ListView, which instantiates only what is on screen. A flat list of 660 cards gives it no rows to skip, hence the chunking. Same purpose as content-visibility: auto on .grid > .card. The progress bar animates between the 2 Hz pushes rather than running a clock, so interpolation costs a property evaluation per frame on the render side and there is no equivalent of usePlaybackClock. It is suppressed for the frame a track changes on, so a new track jumps instead of sliding across two unrelated positions. Nothing on screen animates by itself. The ambient canvas is not ported, for the reason lib/lowPower.ts already gives: a loop repainting the viewport is a floor you cannot get under while it runs at all. The device must not use FemtoVG. On Mesa V3D it draws every runtime-loaded Image as solid black (slint-ui/slint#11785, open), which here means every album cover; Skia and the software renderer are unaffected. So `kiosk` is linuxkms + Skia and FemtoVG stays the default only for desktop development. Both profiles are verified to build; the kiosk binary links libinput/libgbm/libdrm and no X11 or Wayland at all. lib/search.ts, lib/keyboard.ts and lib/format.ts were already pure functions with their own tests, so they port across as pure Rust with theirs: 43 tests, no window required. The .slint files are layout only - nothing in them formats a number or picks a word. Verified against the real 660-album library rather than a fixture. tools/headless-shots.sh renders the UI inside a nested headless compositor and grabs a frame per screen, which is how that was checked on a machine whose session was locked; a Slint window needs a real compositor, and under bare Xvfb nothing maps at all. Co-Authored-By: Claude Opus 5 --- slint-frontend/.gitignore | 1 + slint-frontend/Cargo.lock | 5908 ++++++++++++++++++++++++ slint-frontend/Cargo.toml | 65 + slint-frontend/README.md | 168 + slint-frontend/build.rs | 3 + slint-frontend/shell.nix | 64 + slint-frontend/src/api.rs | 312 ++ slint-frontend/src/covers.rs | 406 ++ slint-frontend/src/format.rs | 61 + slint-frontend/src/keymap.rs | 833 ++++ slint-frontend/src/library.rs | 504 ++ slint-frontend/src/main.rs | 985 ++++ slint-frontend/src/oklch.rs | 95 + slint-frontend/src/ws.rs | 97 + slint-frontend/tools/headless-shots.sh | 56 + slint-frontend/ui/app.slint | 261 ++ slint-frontend/ui/browse.slint | 567 +++ slint-frontend/ui/models.slint | 91 + slint-frontend/ui/player.slint | 413 ++ slint-frontend/ui/theme.slint | 81 + slint-frontend/ui/widgets.slint | 299 ++ 21 files changed, 11270 insertions(+) create mode 100644 slint-frontend/.gitignore create mode 100644 slint-frontend/Cargo.lock create mode 100644 slint-frontend/Cargo.toml create mode 100644 slint-frontend/README.md create mode 100644 slint-frontend/build.rs create mode 100644 slint-frontend/shell.nix create mode 100644 slint-frontend/src/api.rs create mode 100644 slint-frontend/src/covers.rs create mode 100644 slint-frontend/src/format.rs create mode 100644 slint-frontend/src/keymap.rs create mode 100644 slint-frontend/src/library.rs create mode 100644 slint-frontend/src/main.rs create mode 100644 slint-frontend/src/oklch.rs create mode 100644 slint-frontend/src/ws.rs create mode 100755 slint-frontend/tools/headless-shots.sh create mode 100644 slint-frontend/ui/app.slint create mode 100644 slint-frontend/ui/browse.slint create mode 100644 slint-frontend/ui/models.slint create mode 100644 slint-frontend/ui/player.slint create mode 100644 slint-frontend/ui/theme.slint create mode 100644 slint-frontend/ui/widgets.slint diff --git a/slint-frontend/.gitignore b/slint-frontend/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/slint-frontend/.gitignore @@ -0,0 +1 @@ +target diff --git a/slint-frontend/Cargo.lock b/slint-frontend/Cargo.lock new file mode 100644 index 0000000..bdb05be --- /dev/null +++ b/slint-frontend/Cargo.lock @@ -0,0 +1,5908 @@ +# 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 new file mode 100644 index 0000000..0438d76 --- /dev/null +++ b/slint-frontend/Cargo.toml @@ -0,0 +1,65 @@ +[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 new file mode 100644 index 0000000..87d81cf --- /dev/null +++ b/slint-frontend/README.md @@ -0,0 +1,168 @@ +# 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. + +Only the music player is here. The typing game (`Tippen`), the room-lights page, parent +mode and the IR-remote assignment are all 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 +``` + +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/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 | + +Everything is clickable too. + +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 new file mode 100644 index 0000000..29ee3db --- /dev/null +++ b/slint-frontend/build.rs @@ -0,0 +1,3 @@ +fn main() { + slint_build::compile("ui/app.slint").expect("compiling ui/app.slint"); +} diff --git a/slint-frontend/shell.nix b/slint-frontend/shell.nix new file mode 100644 index 0000000..4253eea --- /dev/null +++ b/slint-frontend/shell.nix @@ -0,0 +1,64 @@ +# Dev shell for the Slint front-end. +# +# NixOS has no rustup-installed toolchain here, and Slint's renderers link against +# system GL/X11/Wayland libraries that a plain `cargo build` cannot find on its own - +# hence LD_LIBRARY_PATH below rather than just a list of buildInputs. +{ pkgs ? import { } }: + +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. The web front-end self-hosts it as + # woff2, which Slint cannot read, so the dev shell points Slint at the TTF instead + # (`SLINT_DEFAULT_FONT` takes one file and makes it the primary font). Without it + # everything still renders, in whatever sans fontconfig hands back. + SLINT_DEFAULT_FONT = + "${pkgs.nunito}/share/fonts/truetype/Nunito/Nunito[wght].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 new file mode 100644 index 0000000..7120a4e --- /dev/null +++ b/slint-frontend/src/api.rs @@ -0,0 +1,312 @@ +//! 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) + } + + 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 new file mode 100644 index 0000000..2210f8d --- /dev/null +++ b/slint-frontend/src/covers.rs @@ -0,0 +1,406 @@ +//! 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 new file mode 100644 index 0000000..981df6b --- /dev/null +++ b/slint-frontend/src/format.rs @@ -0,0 +1,61 @@ +//! 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/keymap.rs b/slint-frontend/src/keymap.rs new file mode 100644 index 0000000..ffd8748 --- /dev/null +++ b/slint-frontend/src/keymap.rs @@ -0,0 +1,833 @@ +//! 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; + +#[derive(Debug, Clone, PartialEq)] +pub struct UiState { + 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 { + 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.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+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. + 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 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 new file mode 100644 index 0000000..15a3ca6 --- /dev/null +++ b/slint-frontend/src/library.rs @@ -0,0 +1,504 @@ +//! 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 new file mode 100644 index 0000000..362f281 --- /dev/null +++ b/slint-frontend/src/main.rs @@ -0,0 +1,985 @@ +//! 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 keymap; +mod library; +mod oklch; +mod ws; + +use std::cell::RefCell; +use std::collections::VecDeque; +use std::rc::Rc; +use std::sync::mpsc::{channel, Sender}; +use std::sync::{Arc, Mutex}; + +use slint::{ModelRc, SharedString, VecModel}; + +use api::{Album, Client, PlayerState}; + +use covers::Tier; +use keymap::{Action, 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), +} + +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, +} + +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 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); + + 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))); +} + +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 base = std::env::args() + .skip_while(|a| a != "--server") + .nth(1) + .unwrap_or_else(|| api::DEFAULT_BASE_URL.to_string()); + println!("musicmouse-slint → {base}"); + + // Nunito is the face the design is drawn in. Slint takes one file as the primary + // font through `SLINT_DEFAULT_FONT`; the dev shell sets it, and a deployment can + // drop the TTF next to the binary instead. Neither present 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 local = std::path::Path::new("fonts/Nunito.ttf"); + if local.exists() { + std::env::set_var("SLINT_DEFAULT_FONT", local); + } + } + + 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::(); + + // 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}"); + } + } + })?; + } + + // 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::default(), + player: PlayerState::default(), + library: Library::default(), + covers: covers::Store::new(cover_jobs), + online: false, + loaded: false, + error: None, + track_key: String::new(), + smooth: true, + })); + + 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::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_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 new file mode 100644 index 0000000..0c1845a --- /dev/null +++ b/slint-frontend/src/oklch.rs @@ -0,0 +1,95 @@ +//! 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 new file mode 100644 index 0000000..f849754 --- /dev/null +++ b/slint-frontend/src/ws.rs @@ -0,0 +1,97 @@ +//! 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 new file mode 100755 index 0000000..a05a8e3 --- /dev/null +++ b/slint-frontend/tools/headless-shots.sh @@ -0,0 +1,56 @@ +#!/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 new file mode 100644 index 0000000..9d7f2ea --- /dev/null +++ b/slint-frontend/ui/app.slint @@ -0,0 +1,261 @@ +import { Theme } from "theme.slint"; +import { RoundButton } from "widgets.slint"; +import { ShelfView, ResultsView } from "browse.slint"; +import { PlayerBar, PlayView, AlbumSheet } from "player.slint"; +import { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying } from "models.slint"; + +export { AlbumTile, GridRow, CategoryTile, CategoryRow, Shelf, TrackRow, NowPlaying, 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 -- + 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); + + 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.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.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.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.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.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(); } + } + + 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 new file mode 100644 index 0000000..8fbe962 --- /dev/null +++ b/slint-frontend/ui/browse.slint @@ -0,0 +1,567 @@ +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 new file mode 100644 index 0000000..2a3ff21 --- /dev/null +++ b/slint-frontend/ui/models.slint @@ -0,0 +1,91 @@ +// 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, +} diff --git a/slint-frontend/ui/player.slint b/slint-frontend/ui/player.slint new file mode 100644 index 0000000..9403a9c --- /dev/null +++ b/slint-frontend/ui/player.slint @@ -0,0 +1,413 @@ +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/theme.slint b/slint-frontend/ui/theme.slint new file mode 100644 index 0000000..db13197 --- /dev/null +++ b/slint-frontend/ui/theme.slint @@ -0,0 +1,81 @@ +// 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; + + // ------------------------------------------------------------------- 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 new file mode 100644 index 0000000..bdb8ddd --- /dev/null +++ b/slint-frontend/ui/widgets.slint @@ -0,0 +1,299 @@ +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(); } + } +}