From 90f68eee9032299c16c7ebd03b484bec8c5a44e5 Mon Sep 17 00:00:00 2001 From: phuc Date: Tue, 3 Nov 2026 21:56:28 +0700 Subject: [PATCH 1/3] Add cross-platform desktop app (Rust + Tauri), Windows first desktop/ is a Rust workspace meant to take TinyHand beyond macOS: - core: platform-free engine ported from the Swift app (hand measuring, stick physics, ink, prefs) with a span rasteriser for the hand and rod (~1.6 ms/frame at 1080p) and unit tests. - app: Tauri shell with tray, global shortcuts, toolbar and Settings windows (English/Vietnamese), and a Windows overlay: low-level mouse hook, keyboard hook that keeps the interact key from apps, blank system cursors, layered window, cpal tap sounds. - marketing: script that renders feature screenshots from the real parts. .github/workflows/windows.yml builds the NSIS installer on Windows. The Swift macOS app is unchanged. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows.yml | 33 + .gitignore | 4 + desktop/Cargo.lock | 5510 +++++++++++++++++++ desktop/Cargo.toml | 15 + desktop/README.md | 96 + desktop/app/Cargo.toml | 36 + desktop/app/build.rs | 27 + desktop/app/capabilities/default.json | 7 + desktop/app/icons/128x128.png | Bin 0 -> 9125 bytes desktop/app/icons/32x32.png | Bin 0 -> 1773 bytes desktop/app/icons/icon.ico | Bin 0 -> 42313 bytes desktop/app/icons/icon.png | Bin 0 -> 63151 bytes desktop/app/icons/tray_off.png | Bin 0 -> 3102 bytes desktop/app/icons/tray_on.png | Bin 0 -> 3930 bytes desktop/app/src/app.rs | 383 ++ desktop/app/src/assets.rs | 74 + desktop/app/src/audio.rs | 282 + desktop/app/src/commands.rs | 81 + desktop/app/src/i18n.rs | 40 + desktop/app/src/log.rs | 33 + desktop/app/src/main.rs | 77 + desktop/app/src/platform/mod.rs | 34 + desktop/app/src/platform/other.rs | 35 + desktop/app/src/platform/windows/cursor.rs | 50 + desktop/app/src/platform/windows/hook.rs | 341 ++ desktop/app/src/platform/windows/mod.rs | 54 + desktop/app/src/platform/windows/overlay.rs | 691 +++ desktop/app/src/platform/windows/sound.rs | 9 + desktop/app/src/sounds.rs | 18 + desktop/app/tauri.conf.json | 32 + desktop/core/Cargo.toml | 14 + desktop/core/examples/bench.rs | 34 + desktop/core/examples/hero.rs | 39 + desktop/core/examples/render.rs | 27 + desktop/core/examples/scene.rs | 152 + desktop/core/examples/thumbs.rs | 16 + desktop/core/src/arm.rs | 167 + desktop/core/src/asset.rs | 443 ++ desktop/core/src/catalog.rs | 113 + desktop/core/src/engine.rs | 443 ++ desktop/core/src/geom.rs | 117 + desktop/core/src/ink.rs | 161 + desktop/core/src/interact.rs | 105 + desktop/core/src/lib.rs | 21 + desktop/core/src/prefs.rs | 145 + desktop/core/src/raster.rs | 200 + desktop/marketing/make_screens.py | 208 + desktop/marketing/slides/base.css | 8 + desktop/marketing/slides/chart.html | 15 + desktop/marketing/slides/english.html | 10 + desktop/marketing/slides/math.html | 15 + desktop/marketing/slides/quiz.html | 11 + desktop/ui/i18n.js | 204 + desktop/ui/icon.png | Bin 0 -> 9125 bytes desktop/ui/settings.html | 450 ++ desktop/ui/toolbar.html | 175 + 56 files changed, 11255 insertions(+) create mode 100644 .github/workflows/windows.yml create mode 100644 desktop/Cargo.lock create mode 100644 desktop/Cargo.toml create mode 100644 desktop/README.md create mode 100644 desktop/app/Cargo.toml create mode 100644 desktop/app/build.rs create mode 100644 desktop/app/capabilities/default.json create mode 100644 desktop/app/icons/128x128.png create mode 100644 desktop/app/icons/32x32.png create mode 100644 desktop/app/icons/icon.ico create mode 100644 desktop/app/icons/icon.png create mode 100644 desktop/app/icons/tray_off.png create mode 100644 desktop/app/icons/tray_on.png create mode 100644 desktop/app/src/app.rs create mode 100644 desktop/app/src/assets.rs create mode 100644 desktop/app/src/audio.rs create mode 100644 desktop/app/src/commands.rs create mode 100644 desktop/app/src/i18n.rs create mode 100644 desktop/app/src/log.rs create mode 100644 desktop/app/src/main.rs create mode 100644 desktop/app/src/platform/mod.rs create mode 100644 desktop/app/src/platform/other.rs create mode 100644 desktop/app/src/platform/windows/cursor.rs create mode 100644 desktop/app/src/platform/windows/hook.rs create mode 100644 desktop/app/src/platform/windows/mod.rs create mode 100644 desktop/app/src/platform/windows/overlay.rs create mode 100644 desktop/app/src/platform/windows/sound.rs create mode 100644 desktop/app/src/sounds.rs create mode 100644 desktop/app/tauri.conf.json create mode 100644 desktop/core/Cargo.toml create mode 100644 desktop/core/examples/bench.rs create mode 100644 desktop/core/examples/hero.rs create mode 100644 desktop/core/examples/render.rs create mode 100644 desktop/core/examples/scene.rs create mode 100644 desktop/core/examples/thumbs.rs create mode 100644 desktop/core/src/arm.rs create mode 100644 desktop/core/src/asset.rs create mode 100644 desktop/core/src/catalog.rs create mode 100644 desktop/core/src/engine.rs create mode 100644 desktop/core/src/geom.rs create mode 100644 desktop/core/src/ink.rs create mode 100644 desktop/core/src/interact.rs create mode 100644 desktop/core/src/lib.rs create mode 100644 desktop/core/src/prefs.rs create mode 100644 desktop/core/src/raster.rs create mode 100644 desktop/marketing/make_screens.py create mode 100644 desktop/marketing/slides/base.css create mode 100644 desktop/marketing/slides/chart.html create mode 100644 desktop/marketing/slides/english.html create mode 100644 desktop/marketing/slides/math.html create mode 100644 desktop/marketing/slides/quiz.html create mode 100644 desktop/ui/i18n.js create mode 100644 desktop/ui/icon.png create mode 100644 desktop/ui/settings.html create mode 100644 desktop/ui/toolbar.html diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..01a6dad --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,33 @@ +name: Windows + +on: + push: + branches: [main] + paths: ["desktop/**", "assets/**", ".github/workflows/windows.yml"] + pull_request: + paths: ["desktop/**", "assets/**", ".github/workflows/windows.yml"] + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + timeout-minutes: 30 + defaults: + run: + working-directory: desktop + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + workspaces: desktop + - name: Test core + run: cargo test -p tinyhand-core + - name: Build installer + working-directory: desktop/app + run: npx --yes @tauri-apps/cli@2 build + - uses: actions/upload-artifact@v4 + with: + name: TinyHand-windows + path: desktop/target/release/bundle/nsis/*.exe + if-no-files-found: error diff --git a/.gitignore b/.gitignore index b7af770..1025474 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ dist/ *.dmg *.zip assets/unused/ + +# Rust / Tauri (desktop/) +desktop/target/ +desktop/app/gen/ diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock new file mode 100644 index 0000000..f0a46b2 --- /dev/null +++ b/desktop/Cargo.lock @@ -0,0 +1,5510 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" + +[[package]] +name = "alloc-stdlib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "alsa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +dependencies = [ + "alsa-sys", + "bitflags 2.13.2", + "cfg-if", + "libc", +] + +[[package]] +name = "alsa-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +dependencies = [ + "libc", + "pkg-config", +] + +[[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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" +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", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +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", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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", + "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.9", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto-launch" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf" +dependencies = [ + "dirs 6.0.0", + "os_info", + "smappservice-rs", + "thiserror 2.0.21", + "windows-registry", + "windows-result 0.4.1", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[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", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[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.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[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 = "brotli" +version = "9.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "6.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "cargo_toml" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" +dependencies = [ + "semver", + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +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 = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +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.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "coreaudio-rs" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + +[[package]] +name = "coreaudio-sys" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" +dependencies = [ + "bindgen", +] + +[[package]] +name = "cpal" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +dependencies = [ + "alsa", + "core-foundation-sys", + "coreaudio-rs", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.8.0", + "ndk-context", + "oboe", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[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-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[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 = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" +dependencies = [ + "quote", + "syn 3.0.9", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.9", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dasp_sample" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + +[[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 = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[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 = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + +[[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.9", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.8+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased-serde" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[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 = "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 = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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.9", +] + +[[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-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[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.9", +] + +[[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-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[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", + "windows-link 0.2.1", +] + +[[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 = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types 0.7.0", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.21", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[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.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 = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 0.62.2", +] + +[[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 = "ico" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75e94d66081e4d8dcd098bd8dc5254800fea14a26389b298410158d834de167" +dependencies = [ + "byteorder", + "png 0.18.1", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[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", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[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.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" +dependencies = [ + "serde", + "serde_json", +] + +[[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 = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[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 = "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.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[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.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types 0.8.3", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.5.0+25.2.9519653", + "num_enum", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.2", + "jni-sys 0.3.1", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[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 = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[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", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[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", +] + +[[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", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[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", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[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", + "libc", + "objc2", + "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", + "objc2-core-foundation", +] + +[[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", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", +] + +[[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", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "oboe" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" +dependencies = [ + "jni", + "ndk 0.8.0", + "ndk-context", + "num-derive", + "num-traits", + "oboe-sys", +] + +[[package]] +name = "oboe-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bb09a4a2b1d668170cfe0a7d5bc103f8999fb316c98099b6a9939c9f2e79d" +dependencies = [ + "cc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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 = "plist" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285" +dependencies = [ + "base64 0.23.1", + "indexmap 2.14.2", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "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", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[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 = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.17+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[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 = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +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 = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[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_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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 = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[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 = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "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 = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.2", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[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-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[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.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +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.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64 0.23.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.2", + "jiff", + "schemars 0.9.0", + "schemars 1.2.3", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +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 = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smappservice-rs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-service-management", + "thiserror 2.0.21", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk 0.9.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[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.9", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5777b06fe6435126ae8d816eba269e050e1e10d302cf5461e9c49eccabc3548" +dependencies = [ + "bitflags 2.13.2", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fb0244332cfb9cee7e2a568bf5da29a36ed8fdbd717f6f47a66b6b7b358c072" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 7.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.21", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows 0.62.2", +] + +[[package]] +name = "tauri-build" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adddb0e648752aaac5da9c3c3c6a68e1e87cab55e3b757b18a9441ef8c3ac614" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 7.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "static_vcruntime", + "strsim", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb37631cf71dab5e8ab55e1bdd701e18a52653d90b78e71d0e5598b1e0a61316" +dependencies = [ + "base64 0.23.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.18.1", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.21", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc38db58854c10fb5f91a887ed8bf82d3239bb92b5d8db86eda09e96d9963d1" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-autostart" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93ff17919fe09852d269bd37b1d3d2e993b9dbb514afe7acbf3346c1d3627e2d" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.21", +] + +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + +[[package]] +name = "tauri-runtime" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.62.2", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1aa9d8b7a120f879f44fdc1053f7b7dbf416bf0bbc6a72b714dec46b782b52f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows 0.62.2", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52bb6904475a6ca662f15ef787f18ade61d5740a1ee12c45d3b748efdb6f796" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.8+spec-1.1.0", +] + +[[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", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinyhand" +version = "0.1.0" +dependencies = [ + "cpal", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-global-shortcut", + "tauri-plugin-single-instance", + "tinyhand-core", + "windows 0.61.3", +] + +[[package]] +name = "tinyhand-core" +version = "0.1.0" +dependencies = [ + "png 0.17.16", + "serde", + "serde_json", + "tiny-skia", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap 2.14.2", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "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 = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs 7.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.21", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" +dependencies = [ + "icu_properties", + "regex", + "serde", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[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 = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[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.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +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 = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" +dependencies = [ + "thiserror 2.0.21", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "windows-sys 0.61.2", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[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 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[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.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[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", + "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-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759c50ddad0ef9913bb5496cb9d509a50433a3b78cb0238d61105526589cf0e9" +dependencies = [ + "base64 0.23.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 7.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk 0.9.0", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.21", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[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 = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "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", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "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 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + +[[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.9", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.9", + "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.9", + "winnow 1.0.4", +] diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml new file mode 100644 index 0000000..7dfb663 --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,15 @@ +[workspace] +members = ["core", "app"] +resolver = "2" + +[profile.release] +lto = true +codegen-units = 1 +strip = true + +# Image decoding and rasterising are far too slow unoptimised; keep dev builds usable. +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev.package.tinyhand-core] +opt-level = 2 diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 0000000..243a159 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,96 @@ +# TinyHand for Windows (Rust + Tauri) + +Cross-platform TinyHand, starting with Windows. The macOS app in `../Sources` +stays the shipping Mac build for now. This workspace is meant to take it over +once its overlay is ported (see "Porting to macOS" below). + +``` +desktop/ + core/ tinyhand-core: pure Rust, no OS calls. Hand image measuring, arm + physics, ink, prefs, catalog, and the renderer. Unit-tested. + app/ tinyhand: the Tauri app. Tray, global shortcuts, settings/toolbar + windows, and platform/ (the OS-specific overlay). + ui/ toolbar.html and settings.html (plain HTML/JS, no bundler). +``` + +## How it works on Windows + +| macOS (Swift) | Windows (`app/src/platform/windows`) | +|---|---| +| `CGEventTap` + Accessibility permission | `WH_MOUSE_LL` low-level hook (`hook.rs`). No permission needed | +| `NSWindow` at screenSaver level + Core Animation | Layered `WS_EX_TRANSPARENT \| TOPMOST` window, painted by `tinyhand_core::Engine` via `UpdateLayeredWindowIndirect` with a dirty rect (`overlay.rs`) | +| `CGDisplayHideCursor` + private CGS calls | Every system cursor swapped for a blank one, restored with `SPI_SETCURSORS` on exit, on panic and on next launch (`cursor.rs`) | +| Carbon hotkeys | `tauri-plugin-global-shortcut` (Ctrl+Alt+H, Ctrl+Alt+X, Esc while on) | +| Menu bar item | Tray icon (left click toggles, right click menu) | +| Dock / menu bar pass-through | Taskbar strips, the toolbar and the Settings window pass clicks through | +| Hold ⌥ to click for real | Hold Alt (or Ctrl/Shift). Alt-clicks are followed by a dummy key so Alt doesn't open app menus | +| AVAudioEngine | `PlaySound` from memory (`sound.rs`) | +| `SMAppService` | `tauri-plugin-autostart` (HKCU Run key) | + +The overlay thread sleeps on a channel: a click wakes it at once. While the +mouse moves or something animates, it ticks at the monitor's refresh rate, +and at 20 Hz when idle. Only the changed rectangle is re-rendered. The hand +and rod use a small span rasteriser (`core/src/raster.rs`) that only visits +covered pixels. Ink and the laser dot use tiny-skia. + +Rendering cost per moving frame (Apple M-series, `cargo run -p tinyhand-core --release --example bench`): +about 1.6 ms at 1920×1080 and 6.4 ms at 3840×2160, with shadow. + +## Build + +Windows (native): + +```powershell +cd desktop\app +npx @tauri-apps/cli@2 dev # run +npx @tauri-apps/cli@2 build # NSIS installer in desktop\target\release\bundle\nsis +``` + +From macOS (cross-compile; links a real `.exe`, can't run it): + +```sh +rustup target add x86_64-pc-windows-msvc +cargo install cargo-xwin +cd desktop && cargo xwin build -p tinyhand --release --target x86_64-pc-windows-msvc +``` + +CI (`.github/workflows/windows.yml`) tests the core and uploads the installer as an artifact. + +Core only, any OS: + +```sh +cd desktop +cargo test -p tinyhand-core +cargo run -p tinyhand-core --release --example render -- robot frame.png # render a frame to PNG +cargo run -p tinyhand-core --release --example bench +``` + +`cargo run -p tinyhand` on macOS starts the tray, Settings and toolbar with a +stub overlay, which is handy for UI work. + +## Known gaps / next steps + +- **Not yet run on real Windows hardware.** It compiles and links for + x86_64-pc-windows-msvc, and the core is tested. First thing to check on a + Windows machine: cursor hiding, hook behaviour, toolbar clicks not stealing + focus. +- Over windows running as admin (Task Manager, elevated terminals), Windows + doesn't let a normal app's hook see the clicks, so they go through. Staying + above the Start menu also needs `uiAccess="true"`, which needs a signed + build installed under Program Files. +- Apps that draw their own custom cursor (some games, design tools) still + show it. A fallback is the Magnification API's `MagShowSystemCursor`. +- Sign the installer (e.g. Azure Trusted Signing). An unsigned app that hooks + the mouse and hides the cursor will get SmartScreen and antivirus warnings. +- Still missing compared to the Mac app: custom hands (HandMaker), the + first-run hint bubble, an ink colour button in the toolbar, editable + shortcuts, and auto-update (`tauri-plugin-updater`). +- Performance headroom: on 4K screens a DirectComposition backend (GPU + transforms, like Core Animation on the Mac) would make frames nearly free. + +## Porting to macOS + +`tinyhand-core` already has everything except the OS layer. A +`platform/macos.rs` would provide the same `Overlay` API using a CGEventTap, +an `NSWindow` (via `objc2`) fed by `Engine::render`, and the existing +cursor-hiding approach from `CursorHider.swift`. diff --git a/desktop/app/Cargo.toml b/desktop/app/Cargo.toml new file mode 100644 index 0000000..43b6e00 --- /dev/null +++ b/desktop/app/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "tinyhand" +version = "0.1.0" +edition = "2021" +description = "A tiny hand on a stick that points at things on your screen." +license = "MIT" +default-run = "tinyhand" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tinyhand-core = { path = "../core" } +tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri-plugin-global-shortcut = "2" +tauri-plugin-autostart = "2" +tauri-plugin-single-instance = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +cpal = "0.15" + +[target.'cfg(windows)'.dependencies.windows] +version = "0.61" +features = [ + "Win32_Foundation", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_Media", + "Win32_Media_Audio", + "Win32_System_LibraryLoader", + "Win32_System_Threading", + "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] diff --git a/desktop/app/build.rs b/desktop/app/build.rs new file mode 100644 index 0000000..7207434 --- /dev/null +++ b/desktop/app/build.rs @@ -0,0 +1,27 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +fn main() { + // A build stamp (UTC) shown in Settings and the log, so it's obvious + // which build is running. + let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + println!("cargo:rustc-env=TINYHAND_BUILD={}", stamp(secs)); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=../ui"); + tauri_build::build() +} + +/// Seconds since 1970 → "YYYY-MM-DD HH:MM UTC" (civil-from-days, no deps). +fn stamp(secs: u64) -> String { + let days = (secs / 86_400) as i64; + let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(mo <= 2); + format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC") +} diff --git a/desktop/app/capabilities/default.json b/desktop/app/capabilities/default.json new file mode 100644 index 0000000..74d249d --- /dev/null +++ b/desktop/app/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "The toolbar and settings windows: app commands plus listening for state events.", + "windows": ["toolbar", "settings"], + "permissions": ["core:default"] +} diff --git a/desktop/app/icons/128x128.png b/desktop/app/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..7395d8c732abc2818f19fddd2e018e1d73ff55fc GIT binary patch literal 9125 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 P00000NkvXXu0mjfjtX6H literal 0 HcmV?d00001 diff --git a/desktop/app/icons/icon.ico b/desktop/app/icons/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4811c848c61d41789d356d10bbe3839f89d55e09 GIT binary patch literal 42313 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
qwrC1eY^l_pzgaOs-~ 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 literal 0 HcmV?d00001 diff --git a/desktop/app/icons/tray_off.png b/desktop/app/icons/tray_off.png new file mode 100644 index 0000000000000000000000000000000000000000..9d71435d412f48208abbc0388a0c3e4787df4f74 GIT binary patch literal 3102 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 literal 0 HcmV?d00001 diff --git a/desktop/app/src/app.rs b/desktop/app/src/app.rs new file mode 100644 index 0000000..ffa8775 --- /dev/null +++ b/desktop/app/src/app.rs @@ -0,0 +1,383 @@ +//! App state and the glue between the tray, shortcuts, windows and overlay. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Mutex; + +use serde::Serialize; +use tauri::image::Image; +use tauri::menu::{Menu, MenuItem, PredefinedMenuItem}; +use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder, Wry}; +use tauri_plugin_autostart::ManagerExt as _; +use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; +use tinyhand_core::{catalog, Look, Prefs, Settings}; + +use crate::assets::Assets; +use crate::platform::{Overlay, OverlayConfig, OverlayEvent}; +use crate::{i18n, log, sounds}; + +struct TrayItems { + toggle: MenuItem, + clear: MenuItem, + settings: MenuItem, + quit: MenuItem, +} + +pub struct App { + pub prefs: Mutex, + prefs_path: PathBuf, + pub overlay: Overlay, + active: AtomicBool, + pub assets: Assets, + tray: Mutex>, +} + +fn toggle_key() -> Shortcut { + Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::KeyH) +} + +fn clear_key() -> Shortcut { + Shortcut::new(Some(Modifiers::CONTROL | Modifiers::ALT), Code::KeyX) +} + +fn escape_key() -> Shortcut { + Shortcut::new(None, Code::Escape) +} + +const TRAY_ON: &[u8] = include_bytes!("../icons/tray_on.png"); +const TRAY_OFF: &[u8] = include_bytes!("../icons/tray_off.png"); + +pub fn setup(app: &mut tauri::App) -> Result<(), Box> { + let handle = app.handle().clone(); + let data_dir = app.path().app_config_dir()?; + log::init(data_dir.clone()); + log::write(format!("TinyHand {} (build {}) starting", app.package_info().version, env!("TINYHAND_BUILD"))); + + let prefs_path = data_dir.join("prefs.json"); + let prefs = std::fs::read_to_string(&prefs_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .unwrap_or_default() + .sanitized(); + // Open Settings at launch until the welcome and language choice are done. + let first_run = !prefs.welcomed || prefs.language.is_empty(); + + let events_handle = handle.clone(); + let overlay = Overlay::new(move |event| match event { + OverlayEvent::Reveal(on) => { + let _ = events_handle.emit_to("toolbar", "reveal", on); + } + }); + + app.manage(App { + prefs: Mutex::new(prefs), + prefs_path, + overlay, + active: AtomicBool::new(false), + assets: Assets::new(app.path().resource_dir().ok()), + tray: Mutex::new(None), + }); + + build_tray(&handle)?; + let shortcuts = handle.global_shortcut(); + for s in [toggle_key(), clear_key()] { + if let Err(e) = shortcuts.register(s) { + log::write(format!("couldn't register {s:?}: {e}")); + } + } + configure_overlay(&handle); + ensure_toolbar(&handle); + + if first_run { + open_settings(&handle); + } + Ok(()) +} + +pub fn shutdown(app: &AppHandle) { + if let Some(state) = app.try_state::() { + state.overlay.deactivate(); + } +} + +fn build_tray(app: &AppHandle) -> tauri::Result<()> { + let text = i18n::text(&app.state::().prefs.lock().unwrap().language); + let toggle_item = MenuItem::with_id(app, "toggle", text.turn_on, true, None::<&str>)?; + let clear = MenuItem::with_id(app, "clear", text.clear, true, None::<&str>)?; + let settings = MenuItem::with_id(app, "settings", text.settings, true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", text.quit, true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[&toggle_item, &clear, &PredefinedMenuItem::separator(app)?, &settings, &PredefinedMenuItem::separator(app)?, &quit], + )?; + *app.state::().tray.lock().unwrap() = Some(TrayItems { toggle: toggle_item, clear, settings, quit }); + + TrayIconBuilder::with_id("main") + .icon(Image::from_bytes(TRAY_OFF)?) + .tooltip("TinyHand") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "toggle" => toggle(app), + "clear" => app.state::().overlay.clear_ink(), + "settings" => open_settings(app), + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } = event { + toggle(tray.app_handle()); + } + }) + .build(app)?; + Ok(()) +} + +pub fn on_shortcut(app: &AppHandle, shortcut: &Shortcut) { + if *shortcut == toggle_key() { + toggle(app); + } else if *shortcut == clear_key() { + app.state::().overlay.clear_ink(); + } else if *shortcut == escape_key() { + set_active(app, false); + } +} + +pub fn is_active(app: &AppHandle) -> bool { + app.state::().active.load(Ordering::SeqCst) +} + +pub fn toggle(app: &AppHandle) { + set_active(app, !is_active(app)); +} + +pub fn set_active(app: &AppHandle, on: bool) { + let state = app.state::(); + if state.active.swap(on, Ordering::SeqCst) == on { + return; + } + log::write(if on { "pointer on" } else { "pointer off" }); + let shortcuts = app.global_shortcut(); + if on { + let toolbar = ensure_toolbar(app); + size_toolbar(app); + state.overlay.set_pass_windows(pass_windows(app)); + state.overlay.activate(toolbar.as_ref().and_then(crate::platform::window_handle)); + // Esc only belongs to TinyHand while the pointer is on. + if let Err(e) = shortcuts.register(escape_key()) { + log::write(format!("couldn't register Esc: {e}")); + } + if !cfg!(windows) { + if let Some(t) = toolbar { + let _ = t.show(); + } + } + } else { + let _ = shortcuts.unregister(escape_key()); + state.overlay.deactivate(); + if !cfg!(windows) { + if let Some(t) = app.get_webview_window("toolbar") { + let _ = t.hide(); + } + } + } + if let Some(tray) = app.tray_by_id("main") { + if let Ok(icon) = Image::from_bytes(if on { TRAY_ON } else { TRAY_OFF }) { + let _ = tray.set_icon(Some(icon)); + } + } + refresh_texts(app); + emit_state(app); +} + +/// Tray menu, tooltip and window titles in the current language. +fn refresh_texts(app: &AppHandle) { + let state = app.state::(); + let text = i18n::text(&state.prefs.lock().unwrap().language); + let on = is_active(app); + if let Some(items) = state.tray.lock().unwrap().as_ref() { + let _ = items.toggle.set_text(if on { text.turn_off } else { text.turn_on }); + let _ = items.clear.set_text(text.clear); + let _ = items.settings.set_text(text.settings); + let _ = items.quit.set_text(text.quit); + } + if let Some(tray) = app.tray_by_id("main") { + let _ = tray.set_tooltip(Some(if on { text.tooltip_on } else { "TinyHand" })); + } + if let Some(w) = app.get_webview_window("settings") { + let _ = w.set_title(text.settings_title); + } +} + +/// Windows whose clicks should go through to them while the pointer is on. +fn pass_windows(app: &AppHandle) -> Vec { + app.get_webview_window("settings").as_ref().and_then(crate::platform::window_handle).into_iter().collect() +} + +/// Size the toolbar from its contents up front. The page also measures +/// itself, but a hidden webview may not lay out until it's shown. +/// Keep in step with the CSS in ui/toolbar.html. +pub fn size_toolbar(app: &AppHandle) { + let Some(w) = app.get_webview_window("toolbar") else { return }; + let state = app.state::(); + let n = { + let prefs = state.prefs.lock().unwrap(); + prefs.pinned.len() + usize::from(!prefs.pinned.contains(&prefs.style)) + } as f64; + // body 14px padding, bar 10/8px padding + 1px border, 40px buttons, + // 6px gaps, a 1px divider, then ink, sound and exit. + let items = n + 4.0; + let width = 28.0 + 22.0 + n * 40.0 + 1.0 + 3.0 * 40.0 + (items - 1.0) * 6.0; + let height = 28.0 + 18.0 + 40.0; + let _ = w.set_size(tauri::LogicalSize::new(width, height)); +} + +pub fn update_prefs(app: &AppHandle, change: impl FnOnce(&mut Prefs)) { + let state = app.state::(); + { + let mut prefs = state.prefs.lock().unwrap(); + let mut next = prefs.clone(); + change(&mut next); + *prefs = next.sanitized(); + if let Some(dir) = state.prefs_path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Ok(json) = serde_json::to_string_pretty(&*prefs) { + if let Err(e) = std::fs::write(&state.prefs_path, json) { + log::write(format!("couldn't save prefs: {e}")); + } + } + } + configure_overlay(app); + refresh_texts(app); + emit_state(app); +} + +/// Send the current prefs to the overlay. Decoding a hand takes ~50 ms, so +/// do it off the main thread. +pub fn configure_overlay(app: &AppHandle) { + let app = app.clone(); + std::thread::spawn(move || { + let state = app.state::(); + let prefs = state.prefs.lock().unwrap().clone(); + let look = if prefs.is_laser() { + Look::Laser + } else { + match state.assets.hand(&prefs.style) { + Some(a) => Look::Hand(a), + None => Look::Laser, + } + }; + state.overlay.configure(OverlayConfig { + look, + settings: Settings { + size: prefs.pointer_size, + shadow: prefs.show_shadow, + fade_seconds: prefs.fade_seconds, + ink: prefs.ink_rgb(), + }, + sound: prefs.sound_on().then(|| sounds::bytes(&prefs.tap_sound)), + interact: prefs.interact_key, + }); + }); +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct StyleInfo { + id: &'static str, + title: &'static str, + ink: String, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UiState { + active: bool, + prefs: Prefs, + styles: Vec, + default_pinned: &'static [&'static str], + sounds: Vec<(&'static str, &'static str)>, + inks: Vec<(&'static str, &'static str, Option)>, + launch_at_login: bool, + version: String, + build: &'static str, + platform_ready: bool, +} + +pub fn ui_state(app: &AppHandle) -> UiState { + let state = app.state::(); + let hex = |c: u32| format!("#{c:06x}"); + let mut styles: Vec = + catalog::HANDS.iter().map(|s| StyleInfo { id: s.id, title: s.title, ink: hex(s.ink) }).collect(); + styles.push(StyleInfo { id: catalog::LASER, title: catalog::title(catalog::LASER), ink: hex(catalog::RED) }); + let prefs = state.prefs.lock().unwrap().clone(); + UiState { + active: is_active(app), + prefs, + styles, + default_pinned: catalog::DEFAULT_PINNED, + sounds: catalog::SOUNDS.to_vec(), + inks: catalog::INKS.iter().map(|(id, t, c)| (*id, *t, c.map(hex))).collect(), + launch_at_login: app.autolaunch().is_enabled().unwrap_or(false), + version: app.package_info().version.to_string(), + build: env!("TINYHAND_BUILD"), + platform_ready: cfg!(windows), + } +} + +pub fn emit_state(app: &AppHandle) { + let _ = app.emit("state", ui_state(app)); +} + +pub fn ensure_toolbar(app: &AppHandle) -> Option { + if let Some(w) = app.get_webview_window("toolbar") { + return Some(w); + } + let builder = WebviewWindowBuilder::new(app, "toolbar", WebviewUrl::App("toolbar.html".into())) + .title("TinyHand toolbar") + .inner_size(420.0, 72.0) + .decorations(false) + .resizable(false) + .always_on_top(true) + .skip_taskbar(true) + .shadow(false) + .focused(false) + .visible(false); + #[cfg(windows)] + let builder = builder.transparent(true); + match builder.build() { + Ok(w) => { + crate::platform::prepare_toolbar(&w); + Some(w) + } + Err(e) => { + log::write(format!("couldn't create toolbar: {e}")); + None + } + } +} + +pub fn open_settings(app: &AppHandle) { + if let Some(w) = app.get_webview_window("settings") { + let _ = w.unminimize(); + let _ = w.show(); + let _ = w.set_focus(); + return; + } + let title = i18n::text(&app.state::().prefs.lock().unwrap().language).settings_title; + match WebviewWindowBuilder::new(app, "settings", WebviewUrl::App("settings.html".into())) + .title(title) + .inner_size(640.0, 760.0) + .min_inner_size(520.0, 520.0) + .center() + .build() + { + Ok(_) => { + let state = app.state::(); + state.overlay.set_pass_windows(pass_windows(app)); + } + Err(e) => log::write(format!("couldn't open settings: {e}")), + } +} diff --git a/desktop/app/src/assets.rs b/desktop/app/src/assets.rs new file mode 100644 index 0000000..4b07407 --- /dev/null +++ b/desktop/app/src/assets.rs @@ -0,0 +1,74 @@ +//! Finding, decoding and caching hand images. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use tinyhand_core::HandAsset; + +pub struct Assets { + dirs: Vec, + /// Decoded hands, most recent last. Each is ~6 MB, so keep only two. + hands: Mutex)>>, + thumbs: Mutex>>, +} + +const KEEP: usize = 2; + +impl Assets { + pub fn new(resource_dir: Option) -> Self { + let mut dirs = Vec::new(); + if let Some(r) = resource_dir { + dirs.push(r.join("hands")); + dirs.push(r); + } + // `cargo run` from the repo. + dirs.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets")); + Self { dirs, hands: Mutex::new(Vec::new()), thumbs: Mutex::new(HashMap::new()) } + } + + fn read(&self, id: &str) -> Option> { + if !tinyhand_core::catalog::hand(id).is_some() { + return None; + } + let name = format!("hand_{id}.png"); + self.dirs.iter().find_map(|d| std::fs::read(d.join(&name)).ok()) + } + + pub fn hand(&self, id: &str) -> Option> { + { + let mut hands = self.hands.lock().ok()?; + if let Some(i) = hands.iter().position(|(k, _)| k == id) { + let hit = hands.remove(i); + let asset = hit.1.clone(); + hands.push(hit); + return Some(asset); + } + } + let asset = match HandAsset::from_png(&self.read(id)?) { + Ok(a) => Arc::new(a), + Err(e) => { + crate::log::write(format!("hand {id}: {e}")); + return None; + } + }; + let mut hands = self.hands.lock().ok()?; + hands.push((id.to_string(), asset.clone())); + while hands.len() > KEEP { + hands.remove(0); + } + Some(asset) + } + + /// 128×128 PNG of the hand, for the toolbar and settings. + pub fn thumbnail(&self, id: &str) -> Option> { + if let Some(hit) = self.thumbs.lock().ok()?.get(id) { + return Some(hit.clone()); + } + // Decode without touching the two-hand cache. + let asset = HandAsset::from_png(&self.read(id)?).ok()?; + let png = asset.thumbnail_png(128); + self.thumbs.lock().ok()?.insert(id.to_string(), png.clone()); + Some(png) + } +} diff --git a/desktop/app/src/audio.rs b/desktop/app/src/audio.rs new file mode 100644 index 0000000..166535f --- /dev/null +++ b/desktop/app/src/audio.rs @@ -0,0 +1,282 @@ +//! Tap sounds through a long-lived output stream (cpal: WASAPI on Windows, +//! CoreAudio on macOS). Port of `TapSound.swift`. +//! +//! The taps are 50–140 ms long. Opening the audio device per sound (as +//! `PlaySound` does) loses them while a sleeping device wakes up, which is +//! worse on virtual machines. So, like the macOS app's AVAudioEngine, the +//! stream stays open (playing silence) while the pointer is on and for 30 s +//! after the last sound; taps are mixed straight into it. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; +use cpal::{FromSample, SizedSample}; + +const IDLE_CLOSE: Duration = Duration::from_secs(30); + +#[cfg_attr(not(windows), allow(dead_code))] +enum Cmd { + Play(&'static [u8]), + /// Keep the stream open (pointer on) or let it close after a while. + Hold(bool), +} + +static TX: OnceLock> = OnceLock::new(); + +fn sender() -> &'static Sender { + TX.get_or_init(|| { + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("tinyhand-audio".into()) + .spawn(move || { + let mut player = Player::default(); + loop { + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(Cmd::Play(wav)) => player.play(wav), + Ok(Cmd::Hold(on)) => player.hold(on), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => return, + } + player.maybe_close(); + } + }) + .expect("audio thread"); + tx + }) +} + +/// Play a WAV (PCM 16-bit), cutting off the previous tap. +pub fn play(wav: &'static [u8]) { + let _ = sender().send(Cmd::Play(wav)); +} + +/// While held, the device stays awake so the first tap isn't lost. +#[cfg_attr(not(windows), allow(dead_code))] +pub fn hold(on: bool) { + let _ = sender().send(Cmd::Hold(on)); +} + +/// What the audio callback reads: the current tap and how far into it we are. +#[derive(Default)] +struct Voice { + samples: Option>, + pos: usize, +} + +struct Output { + _stream: cpal::Stream, + voice: Arc>, + rate: u32, + broken: Arc, +} + +#[derive(Default)] +struct Player { + out: Option, + /// Decoded + resampled sounds, keyed by WAV address and device rate. + cache: HashMap<(usize, u32), Arc<[f32]>>, + held: bool, + last_use: Option, +} + +impl Player { + fn hold(&mut self, on: bool) { + self.held = on; + self.last_use = Some(Instant::now()); + if on { + self.ensure_open(); + } + } + + fn play(&mut self, wav: &'static [u8]) { + self.last_use = Some(Instant::now()); + if !self.ensure_open() { + fallback(wav); + return; + } + let out = self.out.as_ref().expect("open"); + let key = (wav.as_ptr() as usize, out.rate); + let samples = match self.cache.get(&key) { + Some(s) => s.clone(), + None => match decode(wav, out.rate) { + Some(s) => { + self.cache.insert(key, s.clone()); + s + } + None => return, + }, + }; + if let Ok(mut v) = out.voice.lock() { + v.samples = Some(samples); + v.pos = 0; + } + } + + fn maybe_close(&mut self) { + let broken = self.out.as_ref().is_some_and(|o| o.broken.load(Ordering::Relaxed)); + let idle = !self.held && self.last_use.is_none_or(|t| t.elapsed() > IDLE_CLOSE); + if self.out.is_some() && (broken || idle) { + if broken { + crate::log::write("audio stream failed; will reopen"); + } + self.out = None; + } + } + + /// Open the default output if needed. Reopens after device errors + /// (e.g. headphones unplugged). + fn ensure_open(&mut self) -> bool { + if self.out.as_ref().is_some_and(|o| o.broken.load(Ordering::Relaxed)) { + self.out = None; + } + if self.out.is_none() { + match open() { + Ok(o) => self.out = Some(o), + Err(e) => crate::log::write(format!("audio: {e}")), + } + } + self.out.is_some() + } +} + +fn open() -> Result { + let host = cpal::default_host(); + let device = host.default_output_device().ok_or("no output device")?; + let supported = device.default_output_config().map_err(|e| e.to_string())?; + let format = supported.sample_format(); + let config: cpal::StreamConfig = supported.into(); + let voice = Arc::new(Mutex::new(Voice::default())); + let broken = Arc::new(AtomicBool::new(false)); + let stream = match format { + cpal::SampleFormat::F32 => build::(&device, &config, &voice, &broken), + cpal::SampleFormat::I16 => build::(&device, &config, &voice, &broken), + cpal::SampleFormat::U16 => build::(&device, &config, &voice, &broken), + cpal::SampleFormat::I32 => build::(&device, &config, &voice, &broken), + other => return Err(format!("unsupported sample format {other:?}")), + }?; + stream.play().map_err(|e| e.to_string())?; + crate::log::write(format!("audio open: {} Hz, {} ch, {format:?}", config.sample_rate.0, config.channels)); + Ok(Output { _stream: stream, voice, rate: config.sample_rate.0, broken }) +} + +fn build>( + device: &cpal::Device, + config: &cpal::StreamConfig, + voice: &Arc>, + broken: &Arc, +) -> Result { + let channels = config.channels as usize; + let voice = voice.clone(); + let broken = broken.clone(); + device + .build_output_stream( + config, + move |data: &mut [T], _| { + // Never block the audio thread: if the lock is busy, play silence. + let mut guard = voice.try_lock().ok(); + for frame in data.chunks_mut(channels) { + let s = match guard.as_deref_mut() { + Some(Voice { samples: Some(buf), pos }) if *pos < buf.len() => { + *pos += 1; + buf[*pos - 1] + } + _ => 0.0, + }; + let v = T::from_sample(s); + for out in frame.iter_mut() { + *out = v; + } + } + }, + move |e| { + crate::log::write(format!("audio stream error: {e}")); + broken.store(true, Ordering::Relaxed); + }, + None, + ) + .map_err(|e| e.to_string()) +} + +/// PCM WAV → mono f32 at `rate` (linear resampling; fine for short clicks). +fn decode(wav: &[u8], rate: u32) -> Option> { + if wav.len() < 12 || &wav[0..4] != b"RIFF" || &wav[8..12] != b"WAVE" { + return None; + } + let (mut channels, mut src_rate, mut bits, mut data) = (0u16, 0u32, 0u16, None); + let mut i = 12; + while i + 8 <= wav.len() { + let id = &wav[i..i + 4]; + let len = u32::from_le_bytes(wav[i + 4..i + 8].try_into().ok()?) as usize; + let body = wav.get(i + 8..(i + 8 + len).min(wav.len()))?; + match id { + b"fmt " if body.len() >= 16 => { + channels = u16::from_le_bytes([body[2], body[3]]); + src_rate = u32::from_le_bytes(body[4..8].try_into().ok()?); + bits = u16::from_le_bytes([body[14], body[15]]); + } + b"data" => data = Some(body), + _ => {} + } + i += 8 + len + (len & 1); + } + let data = data?; + if bits != 16 || channels == 0 || src_rate == 0 { + return None; + } + let ch = channels as usize; + let mono: Vec = data + .chunks_exact(2 * ch) + .map(|f| f.chunks_exact(2).map(|s| i16::from_le_bytes([s[0], s[1]]) as f32 / 32768.0).sum::() / ch as f32) + .collect(); + if src_rate == rate || mono.is_empty() { + return Some(mono.into()); + } + let step = src_rate as f64 / rate as f64; + let n = ((mono.len() as f64) / step) as usize; + let out: Vec = (0..n) + .map(|k| { + let x = k as f64 * step; + let j = x as usize; + let t = (x - j as f64) as f32; + let a = mono[j]; + let b = *mono.get(j + 1).unwrap_or(&a); + a + (b - a) * t + }) + .collect(); + Some(out.into()) +} + +/// If no stream can be opened at all, fall back to the OS one-shot player. +fn fallback(wav: &'static [u8]) { + crate::platform::play_sound(wav); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Needs a real output device (plays silence briefly). `cargo test -- --ignored` + #[test] + #[ignore] + fn opens_default_output() { + let out = open().expect("open output"); + assert!(out.rate >= 8000); + std::thread::sleep(Duration::from_millis(300)); + assert!(!out.broken.load(Ordering::Relaxed)); + } + + #[test] + fn decodes_and_resamples_taps() { + let wav = crate::sounds::bytes("thock"); + let same = decode(wav, 44_100).unwrap(); + assert!(same.len() > 4000, "{}", same.len()); + assert!(same.iter().any(|s| s.abs() > 0.05)); + let up = decode(wav, 48_000).unwrap(); + let ratio = up.len() as f64 / same.len() as f64; + assert!((ratio - 48_000.0 / 44_100.0).abs() < 0.01, "{ratio}"); + } +} diff --git a/desktop/app/src/commands.rs b/desktop/app/src/commands.rs new file mode 100644 index 0000000..9968e40 --- /dev/null +++ b/desktop/app/src/commands.rs @@ -0,0 +1,81 @@ +//! Commands the toolbar and settings pages call. + +use tauri::{AppHandle, LogicalSize, Manager}; +use tauri_plugin_autostart::ManagerExt as _; +use tinyhand_core::{catalog, Prefs}; + +use crate::app::{self, UiState}; + +#[tauri::command] +pub fn get_state(app: AppHandle) -> UiState { + app::ui_state(&app) +} + +#[tauri::command] +pub fn set_prefs(app: AppHandle, prefs: Prefs) { + app::update_prefs(&app, |p| { + // The page doesn't own this flag; keep it. + let welcomed = p.welcomed || prefs.welcomed; + *p = prefs; + p.welcomed = welcomed; + }); +} + +#[tauri::command] +pub fn reset_prefs(app: AppHandle) { + app::update_prefs(&app, |p| { + // Keep the language: resetting shouldn't ask again. + *p = Prefs { welcomed: true, language: p.language.clone(), ..Prefs::default() }; + }); +} + +#[tauri::command] +pub fn pick_style(app: AppHandle, id: String) { + if catalog::is_style(&id) { + app::update_prefs(&app, |p| p.style = id); + } +} + +#[tauri::command] +pub fn toggle_sound(app: AppHandle) { + app::update_prefs(&app, |p| { + let on = p.sound_on(); + p.set_sound_on(!on); + }); +} + +#[tauri::command] +pub fn set_active(app: AppHandle, on: bool) { + app::set_active(&app, on); +} + +#[tauri::command] +pub fn clear_ink(app: AppHandle) { + app.state::().overlay.clear_ink(); +} + +#[tauri::command] +pub fn open_settings(app: AppHandle) { + app::open_settings(&app); +} + +#[tauri::command] +pub fn preview_sound(id: String) { + crate::audio::play(crate::sounds::bytes(&id)); +} + +#[tauri::command] +pub fn set_launch_at_login(app: AppHandle, on: bool) -> Result<(), String> { + let auto = app.autolaunch(); + let result = if on { auto.enable() } else { auto.disable() }; + app::emit_state(&app); + result.map_err(|e| e.to_string()) +} + +/// The toolbar page reports its content size; the overlay keeps it centred. +#[tauri::command] +pub fn toolbar_resize(app: AppHandle, width: f64, height: f64) { + if let Some(w) = app.get_webview_window("toolbar") { + let _ = w.set_size(LogicalSize::new(width.clamp(80.0, 1600.0), height.clamp(40.0, 400.0))); + } +} diff --git a/desktop/app/src/i18n.rs b/desktop/app/src/i18n.rs new file mode 100644 index 0000000..9c370e4 --- /dev/null +++ b/desktop/app/src/i18n.rs @@ -0,0 +1,40 @@ +//! Strings shown by the Rust side: the tray menu, its tooltip and window +//! titles. The pages' strings live in ui/i18n.js. + +pub struct Text { + pub turn_on: &'static str, + pub turn_off: &'static str, + pub clear: &'static str, + pub settings: &'static str, + pub quit: &'static str, + pub tooltip_on: &'static str, + pub settings_title: &'static str, +} + +const EN: Text = Text { + turn_on: "Turn pointer on\tCtrl+Alt+H", + turn_off: "Turn pointer off\tCtrl+Alt+H", + clear: "Clear drawing\tCtrl+Alt+X", + settings: "Settings…", + quit: "Quit TinyHand", + tooltip_on: "TinyHand: on", + settings_title: "TinyHand Settings", +}; + +const VI: Text = Text { + turn_on: "Bật con trỏ\tCtrl+Alt+H", + turn_off: "Tắt con trỏ\tCtrl+Alt+H", + clear: "Xóa nét vẽ\tCtrl+Alt+X", + settings: "Cài đặt…", + quit: "Thoát TinyHand", + tooltip_on: "TinyHand: đang bật", + settings_title: "Cài đặt TinyHand", +}; + +/// `lang` is the saved choice; empty (not chosen yet) uses English. +pub fn text(lang: &str) -> &'static Text { + match lang { + "vi" => &VI, + _ => &EN, + } +} diff --git a/desktop/app/src/log.rs b/desktop/app/src/log.rs new file mode 100644 index 0000000..14abb10 --- /dev/null +++ b/desktop/app/src/log.rs @@ -0,0 +1,33 @@ +//! A tiny append-only log in the app's data folder, for bug reports. +//! Windows: %APPDATA%\app.tinyhand.desktop\tinyhand.log + +use std::io::Write; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static FILE: OnceLock>> = OnceLock::new(); + +pub fn init(dir: PathBuf) { + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("tinyhand.log"); + // Keep it small: start over once it passes 1 MB. + if std::fs::metadata(&path).map(|m| m.len() > 1_000_000).unwrap_or(false) { + let _ = std::fs::remove_file(&path); + } + let file = std::fs::OpenOptions::new().create(true).append(true).open(path).ok(); + let _ = FILE.set(Mutex::new(file)); +} + +pub fn write(msg: impl AsRef) { + let secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0); + let line = format!("{secs:.3} {}\n", msg.as_ref()); + if cfg!(debug_assertions) { + eprint!("{line}"); + } + if let Some(Ok(mut guard)) = FILE.get().map(|m| m.lock()) { + if let Some(f) = guard.as_mut() { + let _ = f.write_all(line.as_bytes()); + } + } +} diff --git a/desktop/app/src/main.rs b/desktop/app/src/main.rs new file mode 100644 index 0000000..1d50acc --- /dev/null +++ b/desktop/app/src/main.rs @@ -0,0 +1,77 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod app; +mod assets; +mod audio; +mod commands; +mod i18n; +mod log; +mod platform; +mod sounds; + +use tauri::{Manager, RunEvent}; +use tauri_plugin_global_shortcut::ShortcutState; + +fn main() { + platform::early_init(); + + let app = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { + // Launching again opens Settings instead of a second pointer. + app::open_settings(app); + })) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(|app, shortcut, event| { + if event.state() == ShortcutState::Pressed { + app::on_shortcut(app, shortcut); + } + }) + .build(), + ) + .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, None)) + .register_asynchronous_uri_scheme_protocol("thumb", |ctx, request, responder| { + let app = ctx.app_handle().clone(); + let id = request.uri().path().trim_start_matches('/').to_string(); + std::thread::spawn(move || { + let state = app.state::(); + let response = match state.assets.thumbnail(&id) { + Some(png) => tauri::http::Response::builder() + .header("Content-Type", "image/png") + .header("Cache-Control", "max-age=31536000") + .body(png), + None => tauri::http::Response::builder().status(404).body(Vec::new()), + }; + responder.respond(response.expect("valid response")); + }); + }) + .invoke_handler(tauri::generate_handler![ + commands::get_state, + commands::set_prefs, + commands::reset_prefs, + commands::pick_style, + commands::toggle_sound, + commands::set_active, + commands::clear_ink, + commands::open_settings, + commands::preview_sound, + commands::set_launch_at_login, + commands::toolbar_resize, + ]) + .setup(|app| { + app::setup(app)?; + Ok(()) + }) + .build(tauri::generate_context!()) + .expect("TinyHand failed to start"); + + app.run(|app, event| match event { + // No windows is normal: TinyHand lives in the tray. + RunEvent::ExitRequested { api, code: None, .. } => api.prevent_exit(), + RunEvent::Exit => { + app::shutdown(app); + platform::shutdown(); + } + _ => {} + }); +} diff --git a/desktop/app/src/platform/mod.rs b/desktop/app/src/platform/mod.rs new file mode 100644 index 0000000..8399ac2 --- /dev/null +++ b/desktop/app/src/platform/mod.rs @@ -0,0 +1,34 @@ +//! OS-specific pieces: the click-swallowing mouse hook, the transparent +//! overlay window, hiding the system cursor, and tap sounds. +//! +//! Windows is implemented. Other platforms get a stub so the app (tray, +//! settings, toolbar) still builds and runs for UI work; the macOS product +//! remains the Swift app until its overlay is ported here. + +use tinyhand_core::prefs::InteractKey; +use tinyhand_core::{Look, Settings}; + +#[cfg_attr(not(windows), allow(dead_code))] +pub struct OverlayConfig { + pub look: Look, + pub settings: Settings, + /// `None` when muted. + pub sound: Option<&'static [u8]>, + pub interact: InteractKey, +} + +#[cfg_attr(not(windows), allow(dead_code))] +pub enum OverlayEvent { + /// Show or fade out the toolbar (it hides while you're away from it). + Reveal(bool), +} + +#[cfg(windows)] +mod windows; +#[cfg(windows)] +pub use self::windows::*; + +#[cfg(not(windows))] +mod other; +#[cfg(not(windows))] +pub use self::other::*; diff --git a/desktop/app/src/platform/other.rs b/desktop/app/src/platform/other.rs new file mode 100644 index 0000000..46bcc48 --- /dev/null +++ b/desktop/app/src/platform/other.rs @@ -0,0 +1,35 @@ +//! Stand-in for platforms without an overlay yet. Logs what it would do. + +use super::{OverlayConfig, OverlayEvent}; + +pub struct Overlay; + +impl Overlay { + pub fn new(_on_event: impl Fn(OverlayEvent) + Send + 'static) -> Self { + Overlay + } + + pub fn configure(&self, _cfg: OverlayConfig) {} + + pub fn activate(&self, _toolbar: Option) { + crate::log::write("overlay: not implemented on this platform yet"); + } + + pub fn deactivate(&self) {} + + pub fn clear_ink(&self) {} + + pub fn set_pass_windows(&self, _windows: Vec) {} +} + +pub fn early_init() {} + +pub fn shutdown() {} + +pub fn play_sound(_wav: &'static [u8]) {} + +pub fn prepare_toolbar(_window: &tauri::WebviewWindow) {} + +pub fn window_handle(_window: &tauri::WebviewWindow) -> Option { + None +} diff --git a/desktop/app/src/platform/windows/cursor.rs b/desktop/app/src/platform/windows/cursor.rs new file mode 100644 index 0000000..13b1943 --- /dev/null +++ b/desktop/app/src/platform/windows/cursor.rs @@ -0,0 +1,50 @@ +//! Hiding the cursor for every app. +//! +//! Windows has no "hide the cursor everywhere" call (`ShowCursor` only affects +//! our own thread), so we replace each system cursor with a blank one and put +//! the user's scheme back with `SPI_SETCURSORS`. The swap only lasts for the +//! session, and we restore on exit, on panic and on the next launch. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use windows::Win32::UI::WindowsAndMessaging::{ + CreateCursor, SetSystemCursor, SystemParametersInfoW, SPI_SETCURSORS, SYSTEM_CURSOR_ID, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, +}; + +static HIDDEN: AtomicBool = AtomicBool::new(false); + +/// OCR_NORMAL, IBEAM, WAIT, CROSS, UP, SIZENWSE, SIZENESW, SIZEWE, SIZENS, +/// SIZEALL, NO, HAND, APPSTARTING, HELP, PIN, PERSON. +const IDS: [u32; 16] = [ + 32512, 32513, 32514, 32515, 32516, 32642, 32643, 32644, 32645, 32646, 32648, 32649, 32650, 32651, 32671, 32672, +]; + +pub fn hide() { + if HIDDEN.swap(true, Ordering::SeqCst) { + return; + } + // AND mask all ones + XOR mask all zeros = fully transparent. + let and = [0xFFu8; 32 * 32 / 8]; + let xor = [0u8; 32 * 32 / 8]; + for id in IDS { + unsafe { + // SetSystemCursor takes ownership, so each id needs its own copy. + if let Ok(blank) = CreateCursor(None, 0, 0, 32, 32, and.as_ptr().cast(), xor.as_ptr().cast()) { + let _ = SetSystemCursor(blank, SYSTEM_CURSOR_ID(id)); + } + } + } +} + +pub fn show() { + if HIDDEN.swap(false, Ordering::SeqCst) { + restore(); + } +} + +/// Reload the user's cursor scheme. +pub fn restore() { + unsafe { + let _ = SystemParametersInfoW(SPI_SETCURSORS, 0, None, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0)); + } +} diff --git a/desktop/app/src/platform/windows/hook.rs b/desktop/app/src/platform/windows/hook.rs new file mode 100644 index 0000000..a8621f0 --- /dev/null +++ b/desktop/app/src/platform/windows/hook.rs @@ -0,0 +1,341 @@ +//! The low-level mouse hook. Equivalent of `InputTap.swift`. +//! +//! While the pointer is on, button presses are swallowed (the app underneath +//! never sees them) and reported to the overlay as taps. Moves always pass, +//! so a "drag" reaches other apps as a plain move, just like on macOS. +//! Presses pass through when the interact key is held or when they land on +//! a pass-through rect (taskbar, toolbar, settings window). +//! +//! A keyboard hook on the same thread keeps the interact key itself away +//! from apps (see `tinyhand_core::interact`), so Alt+click isn't seen as +//! Alt+click by Chrome and friends. +//! +//! Windows calls the hooks on the thread that installed them, and drops a +//! hook if a call takes longer than ~300 ms, so they live on their own thread +//! with nothing else to do. + +use std::cell::RefCell; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::mpsc::Sender; +use std::sync::{mpsc, Arc, Mutex, OnceLock}; +use std::thread::JoinHandle; + +use windows::Win32::Foundation::{HINSTANCE, LPARAM, LRESULT, POINT, RECT, WPARAM}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::Threading::{GetCurrentThread, GetCurrentThreadId, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}; +use tinyhand_core::interact::{InteractFilter, KeyAction}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, VIRTUAL_KEY, +}; +use windows::Win32::UI::WindowsAndMessaging::{ + CallNextHookEx, GetMessageW, PeekMessageW, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, HC_ACTION, KBDLLHOOKSTRUCT, + LLKHF_EXTENDED, MSG, MSLLHOOKSTRUCT, PM_NOREMOVE, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_APP, WM_KEYDOWN, WM_KEYUP, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONDOWN, WM_MBUTTONUP, WM_MOUSEMOVE, WM_QUIT, WM_RBUTTONDOWN, + WM_RBUTTONUP, WM_XBUTTONDOWN, WM_XBUTTONUP, +}; + +use super::overlay::Msg; + +pub enum HookEvent { + /// The mouse moved while the overlay was idling; wake it up. + Moved, + Down { left: bool }, + Up, + InteractDown, + InteractUp, +} + +pub struct Shared { + pub active: AtomicBool, + /// Virtual key that lets clicks through while held; 0 = off. + pub interact_vk: AtomicI32, + /// Screen rects (physical pixels) where presses go through. + pub pass: Mutex>, + /// Set by the overlay while it ticks slowly; the next move wakes it. + pub idle: AtomicBool, + /// The cursor is over one of our own windows (Settings); set by the overlay. + pub over_window: AtomicBool, + /// The interact key is held right now (the system never sees it). + pub interact_held: AtomicBool, + tx: Mutex>, + interacting: AtomicBool, + passing: AtomicBool, +} + +static SHARED: OnceLock> = OnceLock::new(); + +impl Shared { + pub fn new(tx: Sender) -> Arc { + Arc::new(Shared { + active: AtomicBool::new(false), + interact_vk: AtomicI32::new(0), + pass: Mutex::new(Vec::new()), + idle: AtomicBool::new(false), + over_window: AtomicBool::new(false), + interact_held: AtomicBool::new(false), + tx: Mutex::new(tx), + interacting: AtomicBool::new(false), + passing: AtomicBool::new(false), + }) + } + + /// A press is currently going through because of the interact key. + pub fn interacting(&self) -> bool { + self.interacting.load(Ordering::Relaxed) + } + + pub fn reset(&self) { + self.interact_held.store(false, Ordering::Relaxed); + self.interacting.store(false, Ordering::Relaxed); + self.passing.store(false, Ordering::Relaxed); + } + + fn send(&self, e: HookEvent) { + if let Ok(tx) = self.tx.lock() { + let _ = tx.send(Msg::Hook(e)); + } + } + + /// Returns true to swallow the event. + fn handle(&self, msg: u32, info: &MSLLHOOKSTRUCT) -> bool { + // Injected input is handled like real input on purpose: VM mouse + // integration and remote-desktop tools deliver clicks that way. + if !self.active.load(Ordering::Relaxed) { + return false; + } + match msg { + WM_MOUSEMOVE => { + if self.idle.swap(false, Ordering::Relaxed) { + self.send(HookEvent::Moved); + } + false + } + WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN | WM_XBUTTONDOWN => { + if self.interact_held.load(Ordering::Relaxed) { + self.interacting.store(true, Ordering::Relaxed); + self.send(HookEvent::InteractDown); + return false; + } + let pass = self.over_window.load(Ordering::Relaxed) + || self.pass.lock().map(|r| r.iter().any(|r| contains(r, info.pt))).unwrap_or(false); + if pass { + self.passing.store(true, Ordering::Relaxed); + return false; + } + self.send(HookEvent::Down { left: msg == WM_LBUTTONDOWN }); + true + } + WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP | WM_XBUTTONUP => { + if self.interacting.swap(false, Ordering::Relaxed) { + self.send(HookEvent::InteractUp); + return false; + } + if self.passing.swap(false, Ordering::Relaxed) { + return false; + } + self.send(HookEvent::Up); + true + } + _ => false, + } + } +} + +/// Marks keys we inject ourselves, so the keyboard hook lets them by. +const OURS: usize = 0x7448_616E; +const REPLAY: u32 = WM_APP + 1; + +struct Held { + vk: u32, + scan: u32, + extended: bool, +} + +thread_local! { + // Both hooks and the replay queue live on the hook thread only. + static FILTER: RefCell = RefCell::new(InteractFilter::default()); + static HELD: RefCell> = const { RefCell::new(None) }; + static PENDING: RefCell> = const { RefCell::new(Vec::new()) }; + static THREAD: RefCell = const { RefCell::new(0) }; +} + +/// Is `vk` (as the keyboard hook reports it, left/right specific) the +/// interact key `generic` (VK_MENU / VK_CONTROL / VK_SHIFT)? +fn is_interact(generic: i32, vk: u32) -> bool { + match generic { + 0x12 => matches!(vk, 0x12 | 0xA4 | 0xA5), + 0x11 => matches!(vk, 0x11 | 0xA2 | 0xA3), + 0x10 => matches!(vk, 0x10 | 0xA0 | 0xA1), + _ => false, + } +} + +fn key_input(vk: u32, scan: u32, extended: bool) -> INPUT { + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(vk as u16), + wScan: scan as u16, + dwFlags: if extended { KEYEVENTF_EXTENDEDKEY } else { KEYBD_EVENT_FLAGS(0) }, + time: 0, + dwExtraInfo: OURS, + }, + }, + } +} + +impl Shared { + /// Returns true to swallow the key event. + fn key(&self, msg: u32, info: &KBDLLHOOKSTRUCT) -> bool { + if info.dwExtraInfo == OURS { + return false; + } + let generic = self.interact_vk.load(Ordering::Relaxed); + if !self.active.load(Ordering::Relaxed) || generic == 0 { + FILTER.with(|f| f.borrow_mut().reset()); + self.interact_held.store(false, Ordering::Relaxed); + return false; + } + let down = matches!(msg, WM_KEYDOWN | WM_SYSKEYDOWN); + if !down && !matches!(msg, WM_KEYUP | WM_SYSKEYUP) { + return false; + } + let target = is_interact(generic, info.vkCode); + let extended = info.flags.0 & LLKHF_EXTENDED.0 != 0; + if target && down { + HELD.with(|h| *h.borrow_mut() = Some(Held { vk: info.vkCode, scan: info.scanCode, extended })); + } + let action = FILTER.with(|f| { + let mut f = f.borrow_mut(); + let a = f.key(target, down); + self.interact_held.store(f.held(), Ordering::Relaxed); + a + }); + match action { + KeyAction::Pass => false, + KeyAction::Swallow => true, + KeyAction::Replay => { + // Injecting from inside the hook would land after this key, + // so queue it and send right after the hook returns. + PENDING.with(|p| { + let mut p = p.borrow_mut(); + if let Some(h) = HELD.with(|h| h.borrow().as_ref().map(|h| key_input(h.vk, h.scan, h.extended))) { + p.push(h); + } + p.push(key_input(info.vkCode, info.scanCode, extended)); + }); + let tid = THREAD.with(|t| *t.borrow()); + unsafe { + let _ = PostThreadMessageW(tid, REPLAY, WPARAM(0), LPARAM(0)); + } + true + } + } + } +} + +fn flush_replay() { + let inputs = PENDING.with(|p| std::mem::take(&mut *p.borrow_mut())); + if !inputs.is_empty() { + unsafe { + SendInput(&inputs, std::mem::size_of::() as i32); + } + } +} + +unsafe extern "system" fn keyboard_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if code == HC_ACTION as i32 { + if let Some(shared) = SHARED.get() { + let info = &*(lparam.0 as *const KBDLLHOOKSTRUCT); + if shared.key(wparam.0 as u32, info) { + return LRESULT(1); + } + } + } + CallNextHookEx(None, code, wparam, lparam) +} + +pub fn contains(r: &RECT, p: POINT) -> bool { + p.x >= r.left && p.x < r.right && p.y >= r.top && p.y < r.bottom +} + +unsafe extern "system" fn hook_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if code == HC_ACTION as i32 { + if let Some(shared) = SHARED.get() { + let info = &*(lparam.0 as *const MSLLHOOKSTRUCT); + if shared.handle(wparam.0 as u32, info) { + return LRESULT(1); + } + } + } + CallNextHookEx(None, code, wparam, lparam) +} + +/// An installed hook; dropping it removes the hook and stops its thread. +pub struct Hook { + thread_id: u32, + join: Option>, +} + +impl Hook { + pub fn install(shared: &Arc) -> Option { + let _ = SHARED.set(shared.clone()); + let (ready_tx, ready_rx) = mpsc::channel(); + let join = std::thread::Builder::new() + .name("tinyhand-hook".into()) + .spawn(move || unsafe { + let mut msg = MSG::default(); + // Make sure this thread has a message queue before anyone posts to it. + let _ = PeekMessageW(&mut msg, None, 0, 0, PM_NOREMOVE); + let module = GetModuleHandleW(None).ok().map(|m| HINSTANCE(m.0)); + let hook = match SetWindowsHookExW(WH_MOUSE_LL, Some(hook_proc), module, 0) { + Ok(h) => h, + Err(e) => { + crate::log::write(format!("SetWindowsHookExW failed: {e}")); + let _ = ready_tx.send(None); + return; + } + }; + let keyboard = match SetWindowsHookExW(WH_KEYBOARD_LL, Some(keyboard_proc), module, 0) { + Ok(h) => Some(h), + Err(e) => { + // Clicks still work; the interact key just leaks into apps. + crate::log::write(format!("keyboard hook failed: {e}")); + None + } + }; + THREAD.with(|t| *t.borrow_mut() = GetCurrentThreadId()); + FILTER.with(|f| f.borrow_mut().reset()); + let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_TIME_CRITICAL); + let _ = ready_tx.send(Some(GetCurrentThreadId())); + while GetMessageW(&mut msg, None, 0, 0).0 > 0 { + if msg.message == REPLAY { + flush_replay(); + } + } + if let Some(k) = keyboard { + let _ = UnhookWindowsHookEx(k); + } + let _ = UnhookWindowsHookEx(hook); + }) + .ok()?; + match ready_rx.recv() { + Ok(Some(thread_id)) => Some(Hook { thread_id, join: Some(join) }), + _ => { + let _ = join.join(); + None + } + } + } +} + +impl Drop for Hook { + fn drop(&mut self) { + unsafe { + let _ = PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)); + } + if let Some(j) = self.join.take() { + let _ = j.join(); + } + } +} diff --git a/desktop/app/src/platform/windows/mod.rs b/desktop/app/src/platform/windows/mod.rs new file mode 100644 index 0000000..a13e9e1 --- /dev/null +++ b/desktop/app/src/platform/windows/mod.rs @@ -0,0 +1,54 @@ +//! Windows implementation. +//! +//! * `hook`: a low-level mouse hook (`WH_MOUSE_LL`) that swallows clicks while +//! the pointer is on. No permission prompt is needed on Windows. +//! * `overlay`: a click-through, always-on-top layered window per monitor, +//! painted by the shared `tinyhand_core::Engine`. +//! * `cursor`: hides the system cursor by swapping every system cursor for a +//! blank one, and restores the user's scheme afterwards (and on crash). +//! * `sound`: tap sounds via `PlaySound`. + +mod cursor; +mod hook; +mod overlay; +mod sound; + +pub use overlay::Overlay; +pub use sound::play as play_sound; + +use windows::Win32::Foundation::HWND; +use windows::Win32::UI::HiDpi::{SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2}; +use windows::Win32::UI::WindowsAndMessaging::{GetWindowLongPtrW, SetWindowLongPtrW, GWL_EXSTYLE, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW}; + +pub fn early_init() { + unsafe { + // Physical pixels everywhere, so hook, monitor and window coordinates agree. + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + } + // If an earlier run died with the cursor hidden, bring it back. + cursor::restore(); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + cursor::restore(); + crate::log::write(format!("panic: {info}")); + previous(info); + })); +} + +pub fn shutdown() { + cursor::show(); +} + +pub fn window_handle(window: &tauri::WebviewWindow) -> Option { + window.hwnd().ok().map(|h| h.0 as isize) +} + +/// Clicking the toolbar must not pull focus away from the app being presented. +pub fn prepare_toolbar(window: &tauri::WebviewWindow) { + let Some(h) = window_handle(window) else { return }; + unsafe { + let hwnd = HWND(h as _); + let ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); + SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex | (WS_EX_NOACTIVATE.0 | WS_EX_TOOLWINDOW.0) as isize); + } +} diff --git a/desktop/app/src/platform/windows/overlay.rs b/desktop/app/src/platform/windows/overlay.rs new file mode 100644 index 0000000..fc53fb0 --- /dev/null +++ b/desktop/app/src/platform/windows/overlay.rs @@ -0,0 +1,691 @@ +//! The overlay: a borderless, click-through, topmost layered window covering +//! the monitor the cursor is on. Equivalent of `OverlayController.swift`. +//! +//! Everything runs on one thread that owns the window. It sleeps on a +//! channel, so a click (from the hook) or a command wakes it instantly; while +//! the mouse moves or something animates it ticks at the monitor's refresh +//! rate, and otherwise at 20 Hz. +//! +//! Each tick the shared engine returns the rectangle that changed; only that +//! part is rendered and handed to `UpdateLayeredWindowIndirect`. + +use std::sync::atomic::Ordering; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tinyhand_core::prefs::InteractKey; +use tinyhand_core::{Engine, Look, Settings}; +use windows::core::{w, BOOL, PCWSTR}; +use windows::Win32::Foundation::{COLORREF, HWND, LPARAM, LRESULT, POINT, RECT, SIZE, WPARAM}; +use windows::Win32::Graphics::Gdi::{ + CreateCompatibleDC, CreateDIBSection, DeleteDC, DeleteObject, EnumDisplayMonitors, EnumDisplaySettingsW, GdiFlush, GetDC, GetMonitorInfoW, + MonitorFromPoint, ReleaseDC, SelectObject, AC_SRC_ALPHA, AC_SRC_OVER, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, BLENDFUNCTION, DEVMODEW, + DIB_RGB_COLORS, ENUM_CURRENT_SETTINGS, HBITMAP, HDC, HGDIOBJ, HMONITOR, MONITORINFO, MONITORINFOEXW, MONITOR_DEFAULTTONEAREST, +}; +use windows::Win32::Media::{timeBeginPeriod, timeEndPeriod}; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::HiDpi::GetDpiForWindow; +use windows::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DefWindowProcW, DispatchMessageW, GetAncestor, GetCursorPos, GetWindow, WindowFromPoint, GA_ROOT, GW_HWNDNEXT, GetWindowRect, IsWindow, PeekMessageW, + RegisterClassExW, SetWindowPos, ShowWindow, TranslateMessage, UpdateLayeredWindowIndirect, HTTRANSPARENT, HWND_TOPMOST, MSG, PM_REMOVE, + SWP_ASYNCWINDOWPOS, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SW_HIDE, SW_SHOWNOACTIVATE, ULW_ALPHA, UPDATELAYEREDWINDOWINFO, WM_NCHITTEST, + WNDCLASSEXW, WS_EX_LAYERED, WS_EX_NOACTIVATE, WS_EX_TOOLWINDOW, WS_EX_TOPMOST, WS_EX_TRANSPARENT, WS_POPUP, +}; + +use super::hook::{contains, Hook, HookEvent, Shared}; +use super::cursor; +use crate::log; +use crate::platform::{OverlayConfig, OverlayEvent}; + +pub enum Msg { + Configure(OverlayConfig), + Activate(Option), + Deactivate, + ClearInk, + PassWindows(Vec), + Hook(HookEvent), +} + +pub struct Overlay { + tx: Sender, +} + +impl Overlay { + pub fn new(on_event: impl Fn(OverlayEvent) + Send + 'static) -> Self { + let (tx, rx) = mpsc::channel(); + let shared = Shared::new(tx.clone()); + std::thread::Builder::new() + .name("tinyhand-overlay".into()) + .spawn(move || Runner::new(rx, shared, Box::new(on_event)).run()) + .expect("overlay thread"); + Overlay { tx } + } + + pub fn configure(&self, cfg: OverlayConfig) { + let _ = self.tx.send(Msg::Configure(cfg)); + } + + pub fn activate(&self, toolbar: Option) { + let _ = self.tx.send(Msg::Activate(toolbar)); + } + + pub fn deactivate(&self) { + let _ = self.tx.send(Msg::Deactivate); + } + + pub fn clear_ink(&self) { + let _ = self.tx.send(Msg::ClearInk); + } + + pub fn set_pass_windows(&self, windows: Vec) { + let _ = self.tx.send(Msg::PassWindows(windows)); + } +} + +/// A top-down 32-bit DIB selected into a memory DC. +struct Surface { + dc: HDC, + bitmap: HBITMAP, + old: HGDIOBJ, + bits: *mut u8, + width: i32, + height: i32, +} + +impl Surface { + fn new(width: i32, height: i32) -> Option { + unsafe { + let screen = GetDC(None); + let dc = CreateCompatibleDC(Some(screen)); + ReleaseDC(None, screen); + let info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB.0, + ..Default::default() + }, + ..Default::default() + }; + let mut bits = std::ptr::null_mut(); + let bitmap = match CreateDIBSection(Some(dc), &info, DIB_RGB_COLORS, &mut bits, None, 0) { + Ok(b) if !bits.is_null() => b, + _ => { + let _ = DeleteDC(dc); + log::write(format!("CreateDIBSection {width}x{height} failed")); + return None; + } + }; + let old = SelectObject(dc, bitmap.into()); + Some(Surface { dc, bitmap, old, bits: bits.cast(), width, height }) + } + } + + fn pixels(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.bits, (self.width * self.height * 4) as usize) } + } +} + +impl Drop for Surface { + fn drop(&mut self) { + unsafe { + SelectObject(self.dc, self.old); + let _ = DeleteObject(self.bitmap.into()); + let _ = DeleteDC(self.dc); + } + } +} + +struct Runner { + rx: Receiver, + shared: Arc, + on_event: Box, + engine: Engine, + look: Option, + sound: Option<&'static [u8]>, + interact: InteractKey, + hook: Option, + hwnd: Option, + surface: Option, + monitor: RECT, + work: RECT, + frame_interval: Duration, + toolbar: Option, + toolbar_rect: Option, + pass_windows: Vec, + taskbars: Vec, + active: bool, + revealed: bool, + reveal_until: Instant, + cursor_hidden: bool, + cursor_hidden_was_real: bool, + last_tick: Instant, + last_move: Instant, + last_maintain: Instant, + last_raise: Instant, + last_pt: POINT, +} + +const IDLE: Duration = Duration::from_millis(50); +const STILL_AFTER: Duration = Duration::from_millis(500); + +impl Runner { + fn new(rx: Receiver, shared: Arc, on_event: Box) -> Self { + let now = Instant::now(); + Runner { + rx, + shared, + on_event, + engine: Engine::new(Look::Laser, Settings::default()), + look: None, + sound: None, + interact: InteractKey::Alt, + hook: None, + hwnd: None, + surface: None, + monitor: RECT::default(), + work: RECT::default(), + frame_interval: Duration::from_micros(16_667), + toolbar: None, + toolbar_rect: None, + pass_windows: Vec::new(), + taskbars: Vec::new(), + active: false, + revealed: false, + reveal_until: now, + cursor_hidden: false, + cursor_hidden_was_real: false, + last_tick: now, + last_move: now, + last_maintain: now, + last_raise: now, + last_pt: POINT::default(), + } + } + + fn run(mut self) { + loop { + let msg = if !self.active { + match self.rx.recv() { + Ok(m) => Some(m), + Err(_) => return, + } + } else { + let now = Instant::now(); + let busy = now.duration_since(self.last_move) < STILL_AFTER || self.engine.is_busy(now); + self.shared.idle.store(!busy, Ordering::Relaxed); + let wait = if busy { self.frame_interval } else { IDLE }.saturating_sub(now.duration_since(self.last_tick)); + match self.rx.recv_timeout(wait) { + Ok(m) => Some(m), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => return, + } + }; + if let Some(m) = msg { + self.handle(m); + while let Ok(m) = self.rx.try_recv() { + self.handle(m); + } + } + self.pump(); + if self.active { + self.tick(); + } + } + } + + fn pump(&self) { + unsafe { + let mut msg = MSG::default(); + while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + } + } + + fn handle(&mut self, msg: Msg) { + match msg { + Msg::Configure(cfg) => self.configure(cfg), + Msg::Activate(toolbar) => self.activate(toolbar), + Msg::Deactivate => self.deactivate(), + Msg::ClearInk => self.engine.clear_ink(), + Msg::PassWindows(list) => { + self.pass_windows = list.into_iter().map(|h| HWND(h as _)).collect(); + self.last_maintain = Instant::now() - Duration::from_secs(1); + } + Msg::Hook(e) => self.hook_event(e), + } + } + + fn configure(&mut self, cfg: OverlayConfig) { + let same_look = match (&self.look, &cfg.look) { + (Some(Look::Laser), Look::Laser) => true, + (Some(Look::Hand(a)), Look::Hand(b)) => Arc::ptr_eq(a, b), + _ => false, + }; + if !same_look { + self.engine.set_look(cfg.look.clone()); + self.look = Some(cfg.look); + } + self.engine.set_settings(cfg.settings); + self.sound = cfg.sound; + self.interact = cfg.interact; + let vk = match cfg.interact { + InteractKey::Alt => 0x12, + InteractKey::Ctrl => 0x11, + InteractKey::Shift => 0x10, + InteractKey::Off => 0, + }; + self.shared.interact_vk.store(vk, Ordering::Relaxed); + } + + fn hook_event(&mut self, e: HookEvent) { + if !self.active { + return; + } + match e { + HookEvent::Moved => self.last_move = Instant::now(), + HookEvent::Down { left } => { + if let Some(wav) = self.sound { + crate::audio::play(wav); + } + let pt = cursor_pos(); + let at = ((pt.x - self.monitor.left) as f32, (pt.y - self.monitor.top) as f32); + self.engine.mouse_down(left, at); + self.set_revealed(false); + log::write(format!("press at {},{} left={left}", at.0 as i32, at.1 as i32)); + } + HookEvent::Up => { + log::write(format!("release, drew={}", self.engine.is_drawing())); + self.engine.mouse_up(Instant::now()); + } + HookEvent::InteractDown | HookEvent::InteractUp => {} + } + } + + fn ensure_window(&mut self) -> Option { + if let Some(h) = self.hwnd { + return Some(h); + } + unsafe { + let instance = GetModuleHandleW(None).ok()?; + let class = w!("TinyHandOverlay"); + let wc = WNDCLASSEXW { + cbSize: std::mem::size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: instance.into(), + lpszClassName: class, + ..Default::default() + }; + RegisterClassExW(&wc); + let hwnd = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE, + class, + w!("TinyHand"), + WS_POPUP, + 0, + 0, + 1, + 1, + None, + None, + Some(instance.into()), + None, + ); + match hwnd { + Ok(h) => { + self.hwnd = Some(h); + Some(h) + } + Err(e) => { + log::write(format!("CreateWindowExW failed: {e}")); + None + } + } + } + } + + fn activate(&mut self, toolbar: Option) { + if self.active { + return; + } + let Some(hwnd) = self.ensure_window() else { return }; + self.active = true; + unsafe { + // Let the channel wait wake us at frame rate, not every 15.6 ms. + timeBeginPeriod(1); + } + self.shared.reset(); + self.shared.active.store(true, Ordering::SeqCst); + // Wake the audio device now, so the first tap isn't swallowed. + crate::audio::hold(true); + self.hook = Hook::install(&self.shared); + if self.hook.is_none() { + log::write("mouse hook not installed; clicks will go through"); + } + self.toolbar = toolbar.map(|h| HWND(h as _)); + self.toolbar_rect = None; + self.monitor = RECT::default(); + let now = Instant::now(); + self.last_tick = now; + self.last_move = now; + self.last_maintain = now - Duration::from_secs(1); + self.reveal_until = now + Duration::from_secs(3); + self.revealed = false; + self.set_cursor_hidden(true); + unsafe { + if let Some(t) = self.toolbar { + let _ = ShowWindow(t, SW_SHOWNOACTIVATE); + } + let _ = ShowWindow(hwnd, SW_SHOWNOACTIVATE); + } + self.keep_order(); + self.tick(); + log::write(format!("overlay on, hook={}", self.hook.is_some())); + } + + fn deactivate(&mut self) { + if !self.active { + return; + } + self.active = false; + self.shared.active.store(false, Ordering::SeqCst); + self.hook = None; + crate::audio::hold(false); + self.engine.mouse_up(Instant::now()); + self.engine.clear_ink(); + self.surface = None; + self.monitor = RECT::default(); + self.set_cursor_hidden(false); + unsafe { + timeEndPeriod(1); + if let Some(h) = self.hwnd { + let _ = ShowWindow(h, SW_HIDE); + } + if let Some(t) = self.toolbar { + let _ = ShowWindow(t, SW_HIDE); + } + } + self.revealed = false; + } + + fn set_cursor_hidden(&mut self, hidden: bool) { + if hidden != self.cursor_hidden { + self.cursor_hidden = hidden; + if hidden { + cursor::hide(); + } else { + cursor::show(); + } + } + } + + fn tick(&mut self) { + let now = Instant::now(); + let dt = now.duration_since(self.last_tick).as_secs_f32().min(0.1); + self.last_tick = now; + let pt = cursor_pos(); + if pt != self.last_pt { + self.last_pt = pt; + self.last_move = now; + } + self.follow_monitor(pt); + if now.duration_since(self.last_maintain) > Duration::from_millis(250) { + self.maintain(now); + } + self.keep_order(); + + let interacting = self.shared.interacting() || self.shared.interact_held.load(Ordering::Relaxed); + // Settings counts only where it's actually the window under the + // cursor, not wherever its rectangle is (it may be covered). + let over_window = self.over_pass_window(pt); + self.shared.over_window.store(over_window, Ordering::Relaxed); + let over_other = over_window || self.taskbars.iter().any(|r| contains(r, pt)); + let real_cursor = !self.engine.is_drawing() && (interacting || over_other); + if real_cursor != self.cursor_hidden_was_real { + self.cursor_hidden_was_real = real_cursor; + let why = if interacting { "interact key" } else if over_window { "settings window" } else { "taskbar" }; + log::write(if real_cursor { format!("real cursor ({why}) at {},{}", pt.x, pt.y) } else { format!("hand back at {},{}", pt.x, pt.y) }); + } + self.set_cursor_hidden(!real_cursor); + self.engine.set_visible(!real_cursor); + self.update_reveal(pt, now); + + let local = ((pt.x - self.monitor.left) as f32, (pt.y - self.monitor.top) as f32); + if let Some(dirty) = self.engine.frame(now, dt, local) { + self.present(dirty, now); + } + } + + /// Move the overlay to whichever monitor the cursor is on. + fn follow_monitor(&mut self, pt: POINT) { + unsafe { + let hmon = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFOEXW::default(); + info.monitorInfo.cbSize = std::mem::size_of::() as u32; + if !GetMonitorInfoW(hmon, &mut info.monitorInfo as *mut MONITORINFO).as_bool() { + return; + } + let m = info.monitorInfo.rcMonitor; + self.work = info.monitorInfo.rcWork; + if m == self.monitor && self.surface.is_some() { + return; + } + self.monitor = m; + let (w, h) = (m.right - m.left, m.bottom - m.top); + self.surface = None; + self.surface = Surface::new(w, h); + self.engine.set_screen(w as u32, h as u32); + self.frame_interval = refresh_interval(&info.szDevice); + if let Some(hwnd) = self.hwnd { + let _ = SetWindowPos(hwnd, Some(HWND_TOPMOST), m.left, m.top, w, h, SWP_NOACTIVATE); + } + self.toolbar_rect = None; + self.last_maintain = Instant::now() - Duration::from_secs(1); + log::write(format!("overlay on monitor {w}x{h} at {},{} ({:?}/frame)", m.left, m.top, self.frame_interval)); + } + } + + /// Slow-changing things, a few times a second: where the taskbars and + /// pass-through windows are, toolbar placement, staying on top. + fn maintain(&mut self, now: Instant) { + self.last_maintain = now; + self.taskbars = taskbar_rects(); + self.pass_windows.retain(|h| unsafe { IsWindow(Some(*h)).as_bool() }); + self.place_toolbar(); + if now.duration_since(self.last_raise) > Duration::from_secs(1) { + // Other always-on-top apps can cover us; climb back to the top. + self.last_raise = now; + if let Some(h) = self.hwnd { + unsafe { + let _ = SetWindowPos(h, Some(HWND_TOPMOST), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } + } + } + self.publish_pass_rects(); + } + + /// The hand must draw over the toolbar. Both are topmost, and Windows + /// raises the toolbar when it's shown, resized or clicked, so check every + /// tick and tuck it back directly under the overlay when needed. + fn keep_order(&self) { + let (Some(overlay), Some(toolbar)) = (self.hwnd, self.toolbar) else { return }; + if is_above(overlay, toolbar) { + return; + } + unsafe { + let _ = SetWindowPos(toolbar, Some(overlay), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + } + } + + /// Keep the toolbar centred just above the taskbar on the current monitor. + fn place_toolbar(&mut self) { + let Some(t) = self.toolbar else { return }; + unsafe { + let mut r = RECT::default(); + if GetWindowRect(t, &mut r).is_err() { + return; + } + let (tw, th) = (r.right - r.left, r.bottom - r.top); + let margin = (16 * GetDpiForWindow(t).max(96) / 96) as i32; + let x = self.work.left + (self.work.right - self.work.left - tw) / 2; + let y = self.work.bottom - th - margin; + if r.left != x || r.top != y { + // Move only; z-order is keep_order's job. + let _ = SetWindowPos(t, None, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS); + } + self.toolbar_rect = Some(RECT { left: x, top: y, right: x + tw, bottom: y + th }); + } + } + + fn publish_pass_rects(&self) { + let mut rects = self.taskbars.clone(); + if self.revealed { + rects.extend(self.toolbar_rect); + } + if let Ok(mut pass) = self.shared.pass.lock() { + *pass = rects; + } + } + + /// Is the top-level window under the cursor one of ours (Settings)? + /// The overlay itself is layered + transparent, so hit-testing skips it. + fn over_pass_window(&self, pt: POINT) -> bool { + if self.pass_windows.is_empty() { + return false; + } + unsafe { + let hit = WindowFromPoint(pt); + if hit.is_invalid() { + return false; + } + let root = GetAncestor(hit, GA_ROOT); + self.pass_windows.contains(&root) + } + } + + /// The toolbar fades out unless the cursor is near it (port of the + /// macOS reveal zone: a bit wider than the bar and 90pt above it). + fn update_reveal(&mut self, pt: POINT, now: Instant) { + let Some(r) = self.toolbar_rect else { return }; + let scale = self.toolbar.map(|t| unsafe { GetDpiForWindow(t) }).unwrap_or(96).max(96) as i32; + let s = |v: i32| v * scale / 96; + let zone = RECT { left: r.left - s(80), top: r.top - s(100), right: r.right + s(80), bottom: r.bottom + s(10) }; + let near = contains(&zone, pt); + let reveal = !self.engine.is_drawing() && (near || now < self.reveal_until); + self.set_revealed(reveal); + } + + fn set_revealed(&mut self, reveal: bool) { + if reveal != self.revealed { + self.revealed = reveal; + (self.on_event)(OverlayEvent::Reveal(reveal)); + self.publish_pass_rects(); + } + } + + fn present(&mut self, dirty: tinyhand_core::IRect, now: Instant) { + let (Some(hwnd), Some(surface)) = (self.hwnd, self.surface.as_mut()) else { return }; + let stride = surface.width as usize * 4; + unsafe { + let _ = GdiFlush(); + } + self.engine.render_bgra(dirty, now, surface.pixels(), stride); + let dst = POINT { x: self.monitor.left, y: self.monitor.top }; + let size = SIZE { cx: surface.width, cy: surface.height }; + let src = POINT::default(); + let blend = BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: 255, AlphaFormat: AC_SRC_ALPHA as u8 }; + let rect = RECT { left: dirty.x0, top: dirty.y0, right: dirty.x1, bottom: dirty.y1 }; + let info = UPDATELAYEREDWINDOWINFO { + cbSize: std::mem::size_of::() as u32, + hdcDst: HDC::default(), + pptDst: &dst, + psize: &size, + hdcSrc: surface.dc, + pptSrc: &src, + crKey: COLORREF(0), + pblend: &blend, + dwFlags: ULW_ALPHA, + prcDirty: &rect, + }; + unsafe { + if !UpdateLayeredWindowIndirect(hwnd, &info).as_bool() { + log::write(format!("UpdateLayeredWindowIndirect failed: {}", windows::core::Error::from_win32())); + } + } + } +} + +unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT { + if msg == WM_NCHITTEST { + return LRESULT(HTTRANSPARENT as isize); + } + DefWindowProcW(hwnd, msg, wparam, lparam) +} + +/// Is `a` higher than `b` in the z-order? Walks down from `a`. +fn is_above(a: HWND, b: HWND) -> bool { + let mut w = a; + for _ in 0..512 { + match unsafe { GetWindow(w, GW_HWNDNEXT) } { + Ok(next) if !next.is_invalid() => { + if next == b { + return true; + } + w = next; + } + _ => return false, + } + } + false +} + +fn cursor_pos() -> POINT { + let mut pt = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut pt); + } + pt +} + +fn refresh_interval(device: &[u16; 32]) -> Duration { + let mut mode = DEVMODEW { dmSize: std::mem::size_of::() as u16, ..Default::default() }; + let hz = unsafe { + if EnumDisplaySettingsW(PCWSTR(device.as_ptr()), ENUM_CURRENT_SETTINGS, &mut mode).as_bool() { + mode.dmDisplayFrequency + } else { + 60 + } + }; + let hz = if hz <= 1 { 60 } else { hz.clamp(30, 240) }; + Duration::from_secs_f64(1.0 / hz as f64) +} + +/// The strip each monitor reserves for a taskbar (monitor minus work area). +fn taskbar_rects() -> Vec { + unsafe extern "system" fn each(hmon: HMONITOR, _: HDC, _: *mut RECT, data: LPARAM) -> BOOL { + let out = &mut *(data.0 as *mut Vec); + let mut info = MONITORINFO { cbSize: std::mem::size_of::() as u32, ..Default::default() }; + if GetMonitorInfoW(hmon, &mut info).as_bool() { + let (m, w) = (info.rcMonitor, info.rcWork); + let strips = [ + RECT { left: m.left, top: w.bottom, right: m.right, bottom: m.bottom }, + RECT { left: m.left, top: m.top, right: m.right, bottom: w.top }, + RECT { left: m.left, top: m.top, right: w.left, bottom: m.bottom }, + RECT { left: w.right, top: m.top, right: m.right, bottom: m.bottom }, + ]; + out.extend(strips.into_iter().filter(|r| r.right > r.left && r.bottom > r.top)); + } + BOOL(1) + } + let mut out: Vec = Vec::new(); + unsafe { + let _ = EnumDisplayMonitors(None, None, Some(each), LPARAM(&mut out as *mut _ as isize)); + } + out +} diff --git a/desktop/app/src/platform/windows/sound.rs b/desktop/app/src/platform/windows/sound.rs new file mode 100644 index 0000000..65335f0 --- /dev/null +++ b/desktop/app/src/platform/windows/sound.rs @@ -0,0 +1,9 @@ +use windows::core::PCWSTR; +use windows::Win32::Media::Audio::{PlaySoundW, SND_ASYNC, SND_MEMORY, SND_NODEFAULT}; + +/// Play a WAV from memory without blocking; a new tap cuts off the last one. +pub fn play(wav: &'static [u8]) { + unsafe { + let _ = PlaySoundW(PCWSTR(wav.as_ptr().cast()), None, SND_MEMORY | SND_ASYNC | SND_NODEFAULT); + } +} diff --git a/desktop/app/src/sounds.rs b/desktop/app/src/sounds.rs new file mode 100644 index 0000000..917d1b4 --- /dev/null +++ b/desktop/app/src/sounds.rs @@ -0,0 +1,18 @@ +//! Tap sounds, embedded (they're ~10 KB each) so a tap never waits on disk. + +macro_rules! wav { + ($name:literal) => { + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/sounds/tap_", $name, ".wav")) + }; +} + +pub fn bytes(id: &str) -> &'static [u8] { + match id { + "click" => wav!("click"), + "pop" => wav!("pop"), + "wood" => wav!("wood"), + "boop" => wav!("boop"), + "squeak" => wav!("squeak"), + _ => wav!("thock"), + } +} diff --git a/desktop/app/tauri.conf.json b/desktop/app/tauri.conf.json new file mode 100644 index 0000000..dd0210b --- /dev/null +++ b/desktop/app/tauri.conf.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "TinyHand", + "version": "0.1.0", + "identifier": "app.tinyhand.desktop", + "build": { + "frontendDist": "../ui" + }, + "app": { + "withGlobalTauri": true, + "windows": [], + "security": { + "csp": "default-src 'self'; img-src 'self' thumb: http://thumb.localhost data:; style-src 'self' 'unsafe-inline'" + } + }, + "bundle": { + "active": true, + "targets": ["nsis"], + "category": "Productivity", + "shortDescription": "A tiny hand on a stick that points at things on your screen.", + "copyright": "MIT License", + "icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.png", "icons/icon.ico"], + "resources": { + "../../assets/hand_*.png": "hands/" + }, + "windows": { + "nsis": { + "installMode": "currentUser" + } + } + } +} diff --git a/desktop/core/Cargo.toml b/desktop/core/Cargo.toml new file mode 100644 index 0000000..7baf769 --- /dev/null +++ b/desktop/core/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "tinyhand-core" +version = "0.1.0" +edition = "2021" +description = "Platform-independent TinyHand logic: hand assets, arm physics, ink and rendering." +license = "MIT" + +[dependencies] +png = "0.17" +serde = { version = "1", features = ["derive"] } +tiny-skia = "0.11" + +[dev-dependencies] +serde_json = "1" diff --git a/desktop/core/examples/bench.rs b/desktop/core/examples/bench.rs new file mode 100644 index 0000000..c3b2f72 --- /dev/null +++ b/desktop/core/examples/bench.rs @@ -0,0 +1,34 @@ +//! Time a moving-cursor frame (dirty rect only), as the overlay would at 1080p and 4K. +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tinyhand_core::{Engine, HandAsset, Look, Settings}; + +fn main() { + let path = format!("{}/../../assets/hand_lavender.png", env!("CARGO_MANIFEST_DIR")); + let asset = Arc::new(HandAsset::from_png(&std::fs::read(path).unwrap()).unwrap()); + for (w, h) in [(1920u32, 1080u32), (3840, 2160)] { + let shadow = std::env::var("SHADOW").map(|v| v != "0").unwrap_or(true); + let mut e = Engine::new(Look::Hand(asset.clone()), Settings { shadow, ..Settings::default() }); + e.set_screen(w, h); + let mut buf = vec![0u8; (w * h * 4) as usize]; + let t0 = Instant::now(); + let mut total = Duration::ZERO; + let mut px = 0u64; + let frames = 240; + for i in 0..frames { + let t = t0 + Duration::from_millis(i * 8); + let cur = (w as f32 * 0.2 + i as f32 * 4.0, h as f32 * 0.4 + (i as f32 * 0.05).sin() * 200.0); + let start = Instant::now(); + if let Some(d) = e.frame(t, 1.0 / 120.0, cur) { + e.render_bgra(d, t, &mut buf, (w * 4) as usize); + if i > 0 { + px += (d.width() * d.height()) as u64; + } + } + if i > 0 { + total += start.elapsed(); + } + } + println!("{w}x{h}: {:.2} ms/frame, {:.0} kpx dirty/frame", total.as_secs_f64() * 1000.0 / (frames - 1) as f64, px as f64 / 1000.0 / (frames - 1) as f64); + } +} diff --git a/desktop/core/examples/hero.rs b/desktop/core/examples/hero.rs new file mode 100644 index 0000000..b8c47b1 --- /dev/null +++ b/desktop/core/examples/hero.rs @@ -0,0 +1,39 @@ +//! Marketing still: a hand on a stick pointing at the end of a hand-drawn +//! loop, rendered by the real engine, transparent background. +//! cargo run -p tinyhand-core --release --example hero -- +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tinyhand_core::{catalog, Engine, HandAsset, IRect, Look, Settings}; + +fn main() { + let args: Vec = std::env::args().collect(); + let id = args.get(1).map(String::as_str).unwrap_or("lavender"); + let out = args.get(2).map(String::as_str).unwrap_or("hero.png"); + let path = format!("{}/../../assets/hand_{id}.png", env!("CARGO_MANIFEST_DIR")); + let asset = Arc::new(HandAsset::from_png(&std::fs::read(path).unwrap()).unwrap()); + let (w, h) = (1200u32, 800u32); + let settings = Settings { size: 0.2, fade_seconds: 0.0, ink: catalog::rgb(catalog::style_ink(id)), ..Settings::default() }; + let mut e = Engine::new(Look::Hand(asset), settings); + e.set_screen(w, h); + let t0 = Instant::now(); + let (cx, cy, rx, ry) = (430.0f32, 300.0f32, 190.0f32, 95.0f32); + let start = (cx + rx, cy - 10.0); + e.frame(t0, 1.0, start); + e.mouse_down(true, start); + // A slightly wobbly loop that overshoots, like a real hand-drawn circle. + let steps = 90; + for i in 0..=steps { + let a = i as f32 / steps as f32 * std::f32::consts::TAU * 1.08; + let wob = 1.0 + 0.04 * (a * 3.0).sin(); + let p = (cx + rx * wob * a.cos(), cy - 10.0 - ry * wob * a.sin()); + e.frame(t0 + Duration::from_millis(i as u64 * 8), 1.0 / 120.0, p); + } + let end = t0 + Duration::from_millis(steps as u64 * 8 + 8); + e.mouse_up(end); + let last = (cx + rx * 1.02, cy - 10.0 - ry * 0.45); + for i in 0..240 { + e.frame(end + Duration::from_millis(i * 8), 1.0 / 120.0, last); + } + let pm = e.render(IRect::new(0, 0, w as i32, h as i32), end + Duration::from_secs(2)).unwrap(); + pm.save_png(out).unwrap(); +} diff --git a/desktop/core/examples/render.rs b/desktop/core/examples/render.rs new file mode 100644 index 0000000..e4332ae --- /dev/null +++ b/desktop/core/examples/render.rs @@ -0,0 +1,27 @@ +//! Render one frame to a PNG, for eyeballing the overlay without Windows. +//! cargo run -p tinyhand-core --example render -- [shadow 0|1] +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tinyhand_core::{Engine, HandAsset, IRect, Look, Settings}; + +fn main() { + let args: Vec = std::env::args().collect(); + let id = args.get(1).map(String::as_str).unwrap_or("lavender"); + let out = args.get(2).map(String::as_str).unwrap_or("frame.png"); + let shadow = args.get(3).map(|s| s != "0").unwrap_or(true); + let look = if id == "laser" { + Look::Laser + } else { + let path = format!("{}/../../assets/hand_{id}.png", env!("CARGO_MANIFEST_DIR")); + Look::Hand(Arc::new(HandAsset::from_png(&std::fs::read(path).unwrap()).unwrap())) + }; + let mut e = Engine::new(look, Settings { shadow, ..Settings::default() }); + let (w, h) = (1280, 800); + e.set_screen(w, h); + let t = Instant::now(); + let started = Instant::now(); + e.frame(t, 1.0, (640.0, 360.0)); + let pm = e.render(IRect::new(0, 0, w as i32, h as i32), t + Duration::from_millis(1)).unwrap(); + pm.save_png(out).unwrap(); + eprintln!("rendered in {:?}", started.elapsed()); +} diff --git a/desktop/core/examples/scene.rs b/desktop/core/examples/scene.rs new file mode 100644 index 0000000..8f66ac6 --- /dev/null +++ b/desktop/core/examples/scene.rs @@ -0,0 +1,152 @@ +//! Render a scripted moment of the overlay (hand, ink, laser) to a transparent +//! PNG, using the real engine. Used by marketing/make_screens.py. +//! +//! Script on stdin, one command per line: +//! screen W H monitor size (default 1600 1000) +//! hand ID | laser pointer style +//! size F hand length as a fraction of the height +//! ink RRGGBB ink colour (default: the style's) +//! move X Y move there and let the stick settle +//! draw X Y X Y ... press, drag through the points, release +//! loop CX CY RX RY press, draw a hand-drawn ellipse, release +//! press hold the button down (the "tap" squash) +//! render OUT.png write the frame +use std::io::Read; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tinyhand_core::{catalog, Engine, HandAsset, IRect, Look, Settings}; + +struct Sim { + e: Engine, + t: Instant, + cursor: (f32, f32), +} + +impl Sim { + fn tick(&mut self, p: (f32, f32)) { + self.t += Duration::from_micros(8333); + self.cursor = p; + self.e.frame(self.t, 1.0 / 120.0, p); + } + + fn glide(&mut self, to: (f32, f32)) { + let from = self.cursor; + let d = (to.0 - from.0).hypot(to.1 - from.1); + let n = (d / 5.0).ceil().max(1.0) as usize; + for i in 1..=n { + let k = i as f32 / n as f32; + self.tick((from.0 + (to.0 - from.0) * k, from.1 + (to.1 - from.1) * k)); + } + } + + fn settle(&mut self) { + for _ in 0..360 { + let p = self.cursor; + self.tick(p); + } + } + + fn stroke(&mut self, pts: &[(f32, f32)]) { + let Some(&first) = pts.first() else { return }; + self.glide(first); + self.e.mouse_down(true, first); + for &p in &pts[1..] { + self.glide(p); + } + self.e.mouse_up(self.t); + } +} + +fn main() { + let mut script = String::new(); + std::io::stdin().read_to_string(&mut script).unwrap(); + let (mut w, mut h) = (1600u32, 1000u32); + let mut style = "lavender".to_string(); + let mut size = 0.13f32; + let mut ink: Option = None; + let mut sim: Option = None; + + for line in script.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { + let mut it = line.split_whitespace(); + let cmd = it.next().unwrap(); + let nums: Vec = line.split_whitespace().skip(1).filter_map(|s| s.parse().ok()).collect(); + // Settings commands must come before the first action. + match cmd { + "screen" => { + (w, h) = (nums[0] as u32, nums[1] as u32); + continue; + } + "hand" => { + style = it.next().unwrap().to_string(); + continue; + } + "laser" => { + style = catalog::LASER.to_string(); + continue; + } + "size" => { + size = nums[0]; + continue; + } + "ink" => { + ink = Some(u32::from_str_radix(it.next().unwrap(), 16).unwrap()); + continue; + } + _ => {} + } + let s = sim.get_or_insert_with(|| { + let look = if style == catalog::LASER { + Look::Laser + } else { + let path = format!("{}/../../assets/hand_{style}.png", env!("CARGO_MANIFEST_DIR")); + Look::Hand(Arc::new(HandAsset::from_png(&std::fs::read(path).unwrap()).unwrap())) + }; + let settings = Settings { + size, + fade_seconds: 0.0, + ink: catalog::rgb(ink.unwrap_or_else(|| catalog::style_ink(&style))), + ..Settings::default() + }; + let mut e = Engine::new(look, settings); + e.set_screen(w, h); + let t = Instant::now(); + let start = (w as f32 * 0.5, h as f32 * 0.5); + e.frame(t, 1.0, start); + Sim { e, t, cursor: start } + }); + match cmd { + "move" => { + s.glide((nums[0], nums[1])); + s.settle(); + } + "draw" => { + let pts: Vec<(f32, f32)> = nums.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + s.stroke(&pts); + } + "loop" => { + let (cx, cy, rx, ry) = (nums[0], nums[1], nums[2], nums[3]); + let pts: Vec<(f32, f32)> = (0..=72) + .map(|i| { + let a = i as f32 / 72.0 * std::f32::consts::TAU * 1.1 - 0.4; + let wob = 1.0 + 0.035 * (a * 3.0).sin(); + (cx + rx * wob * a.cos(), cy - ry * wob * a.sin()) + }) + .collect(); + s.stroke(&pts); + } + "press" => { + let p = s.cursor; + s.e.mouse_down(true, p); + for _ in 0..30 { + s.tick(p); + } + } + "render" => { + let out = it.next().unwrap(); + let pm = s.e.render(IRect::new(0, 0, w as i32, h as i32), s.t).unwrap(); + pm.save_png(out).unwrap(); + } + other => panic!("unknown command {other}"), + } + } +} diff --git a/desktop/core/examples/thumbs.rs b/desktop/core/examples/thumbs.rs new file mode 100644 index 0000000..99e0d9a --- /dev/null +++ b/desktop/core/examples/thumbs.rs @@ -0,0 +1,16 @@ +//! Write 128px thumbnails for every built-in hand (for UI mock-ups). +//! cargo run -p tinyhand-core --release --example thumbs -- [px] [id,id,...] +fn main() { + let mut args = std::env::args().skip(1); + let out = args.next().expect("out dir"); + let px: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(128); + let only: Option> = args.next().map(|s| s.split(',').map(str::to_string).collect()); + for h in tinyhand_core::catalog::HANDS { + if only.as_ref().is_some_and(|o| !o.iter().any(|x| x == h.id)) { + continue; + } + let path = format!("{}/../../assets/hand_{}.png", env!("CARGO_MANIFEST_DIR"), h.id); + let asset = tinyhand_core::HandAsset::from_png(&std::fs::read(path).unwrap()).unwrap(); + std::fs::write(format!("{out}/{}.png", h.id), asset.thumbnail_png(px)).unwrap(); + } +} diff --git a/desktop/core/src/arm.rs b/desktop/core/src/arm.rs new file mode 100644 index 0000000..7499189 --- /dev/null +++ b/desktop/core/src/arm.rs @@ -0,0 +1,167 @@ +//! The hand-on-a-stick pose: the stick is held from below the bottom edge of +//! the screen, off to the right, and the fingertip always lands exactly on +//! the cursor. Port of `ArmView.aim(cursor:)`. + +use crate::asset::HandAsset; +use crate::geom::Affine; + +const GRIP_LEAN: f32 = 0.30; +const GRIP_DEPTH: f32 = 0.12; +const GRIP_FOLLOW: f32 = 0.08; +const PRESS_DECAY: f32 = 0.18; +const LIFT: f32 = 0.06; +const MIN_ROD: f32 = 0.05; +/// The macOS app runs its physics at 120 ticks a second; scale to real time. +const TICK_HZ: f32 = 120.0; + +#[derive(Default)] +pub struct Arm { + grip_x: Option, + grip_target: Option, + pub press: f32, + pub held: bool, +} + +/// Where to draw the two image slices, in screen pixels (y down). +pub struct HandPose { + /// Display-image pixels → screen, for the hand slice. + pub hand: Affine, + /// Display-image pixels → screen, for the (stretched) rod slice. + pub rod: Affine, + /// `[x0, y0, x1, y1]` of each slice in display-image pixels. + pub hand_src: [f32; 4], + pub rod_src: [f32; 4], + pub shadow_offset: (f32, f32), + pub shadow_alpha: f32, + /// Rounded pose, to skip redraws when nothing visible changed. + pub key: [i32; 6], +} + +pub struct LaserPose { + pub x: f32, + pub y: f32, + pub r: f32, + pub press: f32, + pub key: [i32; 6], +} + +fn ease(factor: f32, dt: f32) -> f32 { + 1.0 - (1.0 - factor).powf(dt * TICK_HZ) +} + +impl Arm { + pub fn press(&mut self) { + self.press = 1.0; + } + + pub fn step(&mut self, dt: f32, cursor_x: f32, screen_w: f32) { + let target = (cursor_x + GRIP_LEAN * screen_w).clamp(screen_w * 0.15, screen_w * 1.05); + self.grip_x = Some(match self.grip_x { + Some(g) => g + (target - g) * ease(GRIP_FOLLOW, dt), + None => target, + }); + self.grip_target = Some(target); + if !self.held { + self.press *= 1.0 - ease(PRESS_DECAY, dt); + if self.press < 0.001 { + self.press = 0.0; + } + } + } + + pub fn reset(&mut self) { + *self = Arm::default(); + } + + pub fn is_settling(&self) -> bool { + if self.press > 0.002 { + return true; + } + match (self.grip_x, self.grip_target) { + (Some(g), Some(t)) => (t - g).abs() > 0.5, + _ => false, + } + } + + /// `size` is the hand length as a fraction of the screen height; + /// `disp_w`/`disp_h` are the pixel size of the display copy being drawn. + #[allow(clippy::too_many_arguments)] + pub fn hand_pose( + &self, + cursor: (f32, f32), + screen: (f32, f32), + a: &HandAsset, + size: f32, + shadow: bool, + disp_w: f32, + disp_h: f32, + ) -> HandPose { + let (w, h) = screen; + let gx = self.grip_x.unwrap_or(cursor.0 + GRIP_LEAN * w); + // Work in y-up coordinates like the macOS code, flip at the end. + let gy = -GRIP_DEPTH * h; + let (cx, cy) = (cursor.0, h - cursor.1); + let (dx, dy) = (cx - gx, cy - gy); + let reach = dx.hypot(dy); + let aim = dy.atan2(dx); + + let hand_px = size * h * (1.0 - 0.06 * self.press); + let s = hand_px / (a.wrist - a.hand_top).max(1e-3); + let width_px = s * a.aspect; + let hand_tip = (a.wrist - a.tip) * s; + let tip_off_x = (a.tip_x - a.rod_x) * width_px; + + let along = (reach * reach - tip_off_x * tip_off_x).max(0.0).sqrt(); + let rod_len = (along - hand_tip).max(MIN_ROD * h); + let tip_angle = (rod_len + hand_tip).atan2(tip_off_x); + let rotation = aim - tip_angle; + + let kx = width_px / disp_w; + let ky = s / disp_h; + // The cuff's soft bottom rim sits just under the detected wrist row. + // Draw it unstretched as part of the hand slice; the stretched rod + // starts below it and tucks 1px under. + let hand_bottom = a.hand_bottom() * disp_h; + let rod_bottom = a.rod_end * disp_h; + let rim = (hand_bottom - a.wrist * disp_h) * ky; + let kr = (rod_len - rim + 1.0).max(1.0) / (rod_bottom - hand_bottom).max(1.0); + let left = -a.rod_x * width_px; + // Image pixels → stick-local (origin at the grip, +y along the stick). + let hand_local = Affine::new(kx, 0.0, left, 0.0, -ky, rod_len + a.wrist * s); + let rod_local = Affine::new(kx, 0.0, left, 0.0, -kr, rod_bottom * kr); + let (sin, cos) = rotation.sin_cos(); + let world = Affine::new(cos, -sin, gx, sin, cos, gy); + let flip = Affine::new(1.0, 0.0, 0.0, 0.0, -1.0, h); + + let lift = LIFT * h * (1.0 - 0.8 * self.press); + let r = |v: f32, m: f32| (v * m).round() as i32; + HandPose { + hand: hand_local.then(world).then(flip), + rod: rod_local.then(world).then(flip), + // Trim slices to their content (+2px for filtering) so the + // rasteriser doesn't walk acres of transparent pixels. + hand_src: [ + (a.hand_min_x.min(a.rod_min_x) * disp_w - 2.0).max(0.0), + a.hand_top * disp_h, + (a.hand_max_x.max(a.rod_max_x) * disp_w + 2.0).min(disp_w), + hand_bottom, + ], + rod_src: [(a.rod_min_x * disp_w - 2.0).max(0.0), hand_bottom, (a.rod_max_x * disp_w + 2.0).min(disp_w), rod_bottom], + shadow_offset: (0.6 * lift, lift), + shadow_alpha: if shadow { 0.26 + 0.02 * self.press } else { 0.0 }, + key: [r(rotation, 2000.0), r(rod_len, 4.0), r(hand_px, 4.0), r(gx, 4.0), r(lift, 4.0), r(self.press, 200.0)], + } + } + + pub fn laser_pose(&self, cursor: (f32, f32), screen_h: f32, size: f32) -> LaserPose { + let r = (screen_h * 0.0038 * size / 0.13).max(4.0) * (1.0 + 0.2 * self.press); + let k = |v: f32| (v * 4.0).round() as i32; + LaserPose { + x: cursor.0, + y: cursor.1, + r, + press: self.press, + key: [k(cursor.0), k(cursor.1), k(r), (self.press * 200.0).round() as i32, 0, 0], + } + } +} diff --git a/desktop/core/src/asset.rs b/desktop/core/src/asset.rs new file mode 100644 index 0000000..a6efb17 --- /dev/null +++ b/desktop/core/src/asset.rs @@ -0,0 +1,443 @@ +//! Hand images: decoding, measuring where the hand, wrist and rod are, and +//! preparing display-sized copies. Port of `HandAsset` in `PointerStyle.swift`. + +use tiny_skia::{IntSize, Pixmap}; + +/// Straight (non-premultiplied) RGBA8 image. +#[derive(Clone)] +pub struct Rgba { + pub width: u32, + pub height: u32, + pub data: Vec, +} + +impl Rgba { + pub fn alpha(&self, x: u32, y: u32) -> u8 { + self.data[((y * self.width + x) * 4 + 3) as usize] + } +} + +/// A hand-on-a-stick image plus where its parts are, as fractions of the +/// image size (y measured from the top). +pub struct HandAsset { + pub image: Rgba, + /// width / height + pub aspect: f32, + pub hand_top: f32, + /// Fingertip row. + pub tip: f32, + pub wrist: f32, + /// Where the rod ends (the handle starts). + pub rod_end: f32, + pub tip_x: f32, + pub rod_x: f32, + pub hand_min_x: f32, + pub hand_max_x: f32, + /// Horizontal extent of anything visible below the wrist (rod + handle). + pub rod_min_x: f32, + pub rod_max_x: f32, +} + +#[derive(Debug)] +pub struct AssetError(pub String); + +impl std::fmt::Display for AssetError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for AssetError {} + +pub fn decode_png(bytes: &[u8]) -> Result { + let mut decoder = png::Decoder::new(bytes); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder.read_info().map_err(|e| AssetError(format!("not a PNG: {e}")))?; + let mut buf = vec![0; reader.output_buffer_size()]; + let info = reader.next_frame(&mut buf).map_err(|e| AssetError(format!("bad PNG: {e}")))?; + let (w, h) = (info.width, info.height); + let src = &buf[..info.buffer_size()]; + let px = (w * h) as usize; + let mut data = Vec::with_capacity(px * 4); + match info.color_type { + png::ColorType::Rgba => data.extend_from_slice(src), + png::ColorType::Rgb => { + for c in src.chunks_exact(3) { + data.extend_from_slice(&[c[0], c[1], c[2], 255]); + } + } + png::ColorType::GrayscaleAlpha => { + for c in src.chunks_exact(2) { + data.extend_from_slice(&[c[0], c[0], c[0], c[1]]); + } + } + png::ColorType::Grayscale => { + for &g in src { + data.extend_from_slice(&[g, g, g, 255]); + } + } + png::ColorType::Indexed => return Err(AssetError("unexpected indexed PNG".into())), + } + Ok(Rgba { width: w, height: h, data }) +} + +pub fn encode_png(img: &Rgba) -> Vec { + let mut out = Vec::new(); + { + let mut enc = png::Encoder::new(&mut out, img.width, img.height); + enc.set_color(png::ColorType::Rgba); + enc.set_depth(png::BitDepth::Eight); + if let Ok(mut w) = enc.write_header() { + let _ = w.write_image_data(&img.data); + } + } + out +} + +impl HandAsset { + pub fn from_png(bytes: &[u8]) -> Result { + let image = decode_png(bytes)?; + HandAsset::measure(image).ok_or_else(|| AssetError("couldn't find a hand on a stick in that image".into())) + } + + /// Find the fingertip, wrist and rod from the alpha channel. + pub fn measure(image: Rgba) -> Option { + let (w, h) = (image.width as usize, image.height as usize); + if w == 0 || h == 0 { + return None; + } + let solid = |x: usize, y: usize| image.data[(y * w + x) * 4 + 3] > 128; + let counts: Vec = (0..h).map(|y| (0..w).filter(|&x| solid(x, y)).count()).collect(); + let center = |y: usize| -> f32 { + let lo = (0..w).find(|&x| solid(x, y)); + let hi = (0..w).rev().find(|&x| solid(x, y)); + match (lo, hi) { + (Some(lo), Some(hi)) => (lo + hi) as f32 / 2.0 / w as f32, + _ => 0.5, + } + }; + + let top = counts.iter().position(|&c| c > 0)?; + let search_end = h.min(top + (0.45 * h as f64) as usize); + let max_w = *counts[top..search_end].iter().max()?; + if max_w == 0 { + return None; + } + let fist = (top..h).find(|&y| counts[y] as f64 > 0.6 * max_w as f64)?; + let wrist = (fist..h).find(|&y| (counts[y] as f64) < 0.35 * max_w as f64)?; + let rod_w = counts[(h - 1).min(wrist + h / 50)]; + let last_row = counts.iter().rposition(|&c| c > 0).unwrap_or(h - 1); + let from = (h - 1).min(wrist + h / 33); + let rod_end = (from..h).find(|&y| counts[y] as f64 > 1.8 * rod_w as f64).unwrap_or(last_row); + + let (mut min_x, mut max_x) = (w, 0); + for y in top..wrist { + for x in 0..w { + if solid(x, y) { + min_x = min_x.min(x); + max_x = max_x.max(x); + } + } + } + + // Anything visible below the wrist, at any alpha. + let (mut rod_lo, mut rod_hi) = (w, 0); + for y in wrist..h { + for x in 0..w { + if image.data[(y * w + x) * 4 + 3] > 0 { + rod_lo = rod_lo.min(x); + rod_hi = rod_hi.max(x + 1); + } + } + } + if rod_hi <= rod_lo { + (rod_lo, rod_hi) = (0, w); + } + + let fh = h as f32; + Some(HandAsset { + rod_min_x: rod_lo as f32 / w as f32, + rod_max_x: rod_hi as f32 / w as f32, + aspect: w as f32 / fh, + hand_top: (top as f32 - 0.004 * fh).max(0.0) / fh, + tip: top as f32 / fh, + wrist: wrist as f32 / fh, + rod_end: rod_end as f32 / fh, + tip_x: center((h - 1).min(top + h / 200)), + rod_x: center((wrist + rod_end) / 2), + hand_min_x: min_x as f32 / w as f32, + hand_max_x: max_x as f32 / w as f32, + image, + }) + } + + /// Fraction of the height where the hand slice ends and the stretched rod + /// begins: just under the wrist, past the cuff's soft bottom rim. + pub fn hand_bottom(&self) -> f32 { + let one_px = 2.0 / self.image.height as f32; + (self.wrist + 0.006).min(self.rod_end - one_px) + } + + /// Square crop around the hand, scaled to `px`×`px`, as PNG bytes. + pub fn thumbnail_png(&self, px: u32) -> Vec { + let (w, h) = (self.image.width as f32, self.image.height as f32); + let hand_w = (self.hand_max_x - self.hand_min_x) * w; + let hand_h = (self.wrist - self.hand_top) * h; + let side = hand_w.max(hand_h) * 1.06; + let cx = (self.hand_min_x + self.hand_max_x) / 2.0 * w; + let cy = (self.hand_top + self.wrist) / 2.0 * h; + let x0 = (cx - side / 2.0).max(0.0) as u32; + let y0 = (cy - side / 2.0).max(0.0) as u32; + let x1 = ((cx + side / 2.0) as u32).min(self.image.width); + let y1 = ((cy + side / 2.0) as u32).min(self.image.height); + let crop = crop(&premultiply(&self.image), x0, y0, x1, y1); + let scale = px as f32 / crop.width.max(crop.height) as f32; + let tw = ((crop.width as f32 * scale).round() as u32).clamp(1, px); + let th = ((crop.height as f32 * scale).round() as u32).clamp(1, px); + let small = resize_area(&crop, tw, th); + // Centre on a square transparent canvas. + let mut out = Rgba { width: px, height: px, data: vec![0; (px * px * 4) as usize] }; + let (ox, oy) = ((px - tw) / 2, (px - th) / 2); + for y in 0..th { + let src = (y * tw * 4) as usize; + let dst = (((oy + y) * px + ox) * 4) as usize; + out.data[dst..dst + (tw * 4) as usize].copy_from_slice(&small.data[src..src + (tw * 4) as usize]); + } + encode_png(&unpremultiply(out)) + } + + /// A copy of the image `height` pixels tall plus a blurred black shadow + /// of it, ready to draw. + pub fn display(&self, height: u32, shadow_blur: f32) -> DisplayHand { + let height = height.clamp(16, self.image.height); + let width = ((self.image.width as f32 * height as f32 / self.image.height as f32).round() as u32).max(1); + let pm = premultiply(&self.image); + let small = if height == self.image.height { pm } else { resize_area(&pm, width, height) }; + // Blur the hand and rod shadows apart: the rod slice is stretched, and + // any cuff shadow bleeding into it would turn into a long smear. + let split = ((self.hand_bottom() * height as f32).round() as u32).min(height); + let shadow_hand = shadow_of(&rows_only(&small, 0, split), shadow_blur); + let rod = self.rod_profile(&small, split, shadow_blur); + DisplayHand { + height, + pixmap: to_pixmap(small), + shadow_hand: to_pixmap(shadow_hand), + rod, + blur: shadow_blur, + } + } + + /// The rod is the same all along its length (it gets stretched anyway), + /// so it is drawn from one averaged cross-section: a 1-D lookup per pixel + /// instead of sampling the image. + fn rod_profile(&self, img: &Rgba, split: u32, blur: f32) -> RodProfile { + let (w, h) = (img.width as usize, img.height as usize); + let y0 = (split as usize).min(h - 1); + let y1 = ((self.rod_end * h as f32) as usize).clamp(y0 + 1, h); + // Average the middle half of the rod. + let a = y0 + (y1 - y0) / 4; + let b = (y0 + (y1 - y0) * 3 / 4).clamp(a + 1, h); + let x0 = ((self.rod_min_x * w as f32).floor() as usize).saturating_sub(2); + let x1 = (((self.rod_max_x * w as f32).ceil() as usize) + 2).min(w); + let colors: Vec<[f32; 4]> = (x0..x1) + .map(|x| { + let mut acc = [0f32; 4]; + for y in a..b { + let i = (y * w + x) * 4; + for c in 0..4 { + acc[c] += img.data[i + c] as f32; + } + } + acc.map(|v| v / (b - a) as f32 / 255.0) + }) + .collect(); + // Shadow: the cross-section's alpha, blurred, padded by the blur. + let pad = (blur * 1.5).ceil() as usize; + let mut alpha = vec![0f32; pad]; + alpha.extend(colors.iter().map(|c| c[3])); + alpha.extend(std::iter::repeat(0.0).take(pad)); + let r = (blur / 2.0).round() as usize; + if r > 0 { + for _ in 0..3 { + alpha = blur_1d(&alpha, r); + } + } + RodProfile { + x0: x0 as f32, + x1: x1 as f32, + colors, + shadow_x0: x0 as f32 - pad as f32, + shadow_x1: x1 as f32 + pad as f32, + shadow: alpha, + } + } +} + +pub struct DisplayHand { + pub height: u32, + /// Premultiplied RGBA. + pub pixmap: Pixmap, + /// Black, blurred; only alpha matters. + pub shadow_hand: Pixmap, + pub rod: RodProfile, + pub blur: f32, +} + +/// One averaged row across the rod and its blurred shadow, in display-image +/// pixels. Colours are premultiplied, 0..1; column `i` is centred on `x0 + i + 0.5`. +pub struct RodProfile { + pub x0: f32, + pub x1: f32, + pub colors: Vec<[f32; 4]>, + pub shadow_x0: f32, + pub shadow_x1: f32, + pub shadow: Vec, +} + +fn blur_1d(src: &[f32], r: usize) -> Vec { + let n = src.len(); + let norm = 1.0 / (2 * r + 1) as f32; + (0..n) + .map(|i| { + let lo = i.saturating_sub(r); + let hi = (i + r + 1).min(n); + src[lo..hi].iter().sum::() * norm + }) + .collect() +} + +/// Copy of `img` with every row outside `y0..y1` cleared. +fn rows_only(img: &Rgba, y0: u32, y1: u32) -> Rgba { + let mut out = img.clone(); + let row = (img.width * 4) as usize; + for (y, chunk) in out.data.chunks_exact_mut(row).enumerate() { + if (y as u32) < y0 || (y as u32) >= y1 { + chunk.fill(0); + } + } + out +} + +fn to_pixmap(img: Rgba) -> Pixmap { + let size = IntSize::from_wh(img.width, img.height).expect("non-empty image"); + Pixmap::from_vec(img.data, size).expect("premultiplied RGBA of the right size") +} + +fn premultiply(img: &Rgba) -> Rgba { + let mut data = img.data.clone(); + for p in data.chunks_exact_mut(4) { + let a = p[3] as u32; + for c in &mut p[..3] { + *c = ((*c as u32 * a + 127) / 255) as u8; + } + } + Rgba { width: img.width, height: img.height, data } +} + +fn unpremultiply(mut img: Rgba) -> Rgba { + for p in img.data.chunks_exact_mut(4) { + let a = p[3] as u32; + if a > 0 && a < 255 { + for c in &mut p[..3] { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + img +} + +fn crop(img: &Rgba, x0: u32, y0: u32, x1: u32, y1: u32) -> Rgba { + let (w, h) = (x1.saturating_sub(x0).max(1), y1.saturating_sub(y0).max(1)); + let mut data = Vec::with_capacity((w * h * 4) as usize); + for y in y0..y0 + h { + let start = ((y * img.width + x0) * 4) as usize; + data.extend_from_slice(&img.data[start..start + (w * 4) as usize]); + } + Rgba { width: w, height: h, data } +} + +/// Area-averaging downscale (box filter), done as two separable passes. +/// Expects premultiplied pixels so transparent edges don't darken. +pub fn resize_area(img: &Rgba, nw: u32, nh: u32) -> Rgba { + let horiz = resample_axis(&img.data, img.width as usize, img.height as usize, nw as usize, true); + let vert = resample_axis(&horiz, nw as usize, img.height as usize, nh as usize, false); + Rgba { width: nw, height: nh, data: vert } +} + +fn resample_axis(src: &[u8], w: usize, h: usize, n: usize, horizontal: bool) -> Vec { + let (len, lines) = if horizontal { (w, h) } else { (h, w) }; + let (out_w, out_h) = if horizontal { (n, h) } else { (w, n) }; + let mut out = vec![0u8; out_w * out_h * 4]; + let scale = len as f32 / n as f32; + // Precompute, for each output sample, the source range and edge weights. + let spans: Vec<(usize, usize, f32, f32)> = (0..n) + .map(|i| { + let s0 = i as f32 * scale; + let s1 = ((i + 1) as f32 * scale).min(len as f32); + let a = s0.floor() as usize; + let b = (s1.ceil() as usize).min(len).max(a + 1); + let first_w = 1.0 - (s0 - a as f32); + let last_w = 1.0 - (b as f32 - s1); + (a, b, first_w, last_w) + }) + .collect(); + for line in 0..lines { + for (i, &(a, b, fw, lw)) in spans.iter().enumerate() { + let mut acc = [0f32; 4]; + let mut total = 0f32; + for s in a..b { + let wgt = if b - a == 1 { 1.0 } else if s == a { fw } else if s == b - 1 { lw } else { 1.0 }; + let idx = if horizontal { (line * w + s) * 4 } else { (s * w + line) * 4 }; + for c in 0..4 { + acc[c] += src[idx + c] as f32 * wgt; + } + total += wgt; + } + let o = if horizontal { (line * out_w + i) * 4 } else { (i * out_w + line) * 4 }; + for c in 0..4 { + out[o + c] = (acc[c] / total.max(1e-6)).round().clamp(0.0, 255.0) as u8; + } + } + } + out +} + +/// Black image with the blurred alpha of `img` (three box-blur passes ≈ gaussian). +fn shadow_of(img: &Rgba, blur: f32) -> Rgba { + let (w, h) = (img.width as usize, img.height as usize); + let mut alpha: Vec = img.data.chunks_exact(4).map(|p| p[3] as f32).collect(); + let r = (blur / 2.0).round().max(0.0) as usize; + if r > 0 { + let mut tmp = vec![0f32; w * h]; + for _ in 0..3 { + box_blur(&alpha, &mut tmp, w, h, r, true); + box_blur(&tmp, &mut alpha, w, h, r, false); + } + } + let mut data = vec![0u8; w * h * 4]; + for (i, a) in alpha.iter().enumerate() { + data[i * 4 + 3] = a.round().clamp(0.0, 255.0) as u8; + } + Rgba { width: img.width, height: img.height, data } +} + +fn box_blur(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize, horizontal: bool) { + let (len, lines) = if horizontal { (w, h) } else { (h, w) }; + let at = |line: usize, i: usize| if horizontal { line * w + i } else { i * w + line }; + let norm = 1.0 / (2 * r + 1) as f32; + for line in 0..lines { + let mut sum = 0f32; + for i in 0..=r.min(len - 1) { + sum += src[at(line, i)]; + } + for i in 0..len { + dst[at(line, i)] = sum * norm; + if i + r + 1 < len { + sum += src[at(line, i + r + 1)]; + } + if i >= r { + sum -= src[at(line, i - r)]; + } + } + } +} diff --git a/desktop/core/src/catalog.rs b/desktop/core/src/catalog.rs new file mode 100644 index 0000000..b59b3d4 --- /dev/null +++ b/desktop/core/src/catalog.rs @@ -0,0 +1,113 @@ +//! Built-in hands, tap sounds and ink colours. Mirrors `PointerStyle.swift`, +//! `TapSound.swift` and `Preferences.swift` in the macOS app. + +pub const LASER: &str = "laser"; +pub const PURPLE: u32 = 0x8754F2; +pub const RED: u32 = 0xFF291F; + +pub struct HandStyle { + pub id: &'static str, + pub title: &'static str, + pub ink: u32, +} + +const fn h(id: &'static str, title: &'static str, ink: u32) -> HandStyle { + HandStyle { id, title, ink } +} + +pub const HANDS: &[HandStyle] = &[ + h("lavender", "Lavender toy", PURPLE), + h("glove", "Cursor glove", PURPLE), + h("coral", "Coral vinyl", RED), + h("plush", "Plush", 0x2F86F0), + h("robot", "Robot", 0x16A87A), + h("pink", "Bubblegum", 0xEE4F98), + h("blue", "Sky", 0x2F86F0), + h("mint", "Mint", 0x16A87A), + h("lemon", "Lemon", 0xF2B705), + h("black", "Midnight", 0x1D1D21), + h("chrome", "Chrome", 0x6B7380), + h("holo", "Holo", PURPLE), + h("fuzzy", "Fuzzy", PURPLE), + h("wood", "Wood", 0x9A5E2C), + h("marble", "Marble", 0x6B7380), + h("terrazzo", "Terrazzo", 0xF0561D), + h("glass", "Glass", 0x1597E5), + h("jelly", "Jelly", 0xA33FE6), + h("pastel", "Pastel", 0xEE4F98), + h("pixel", "Pixel", 0x1D1D21), + h("lowpoly", "Low poly", PURPLE), + h("knit", "Knit", PURPLE), + h("skeleton", "Skeleton", 0x9A5E2C), + h("astro", "Astronaut", 0x2F86F0), + h("space", "Space", RED), + h("monster", "Monster", 0x3E9E2E), + h("water", "Water", 0x1597E5), + h("lava", "Lava", 0xF0561D), + h("grass", "Grass", 0x3E9E2E), + h("cactus", "Cactus", 0x3E9E2E), + h("choco", "Chocolate", 0x9A5E2C), + h("cloud", "Cloud", 0x2F86F0), + h("mech", "Mech", 0xF0561D), + h("neon", "Neon", 0xE0309A), + h("leaf", "Leaf", 0x3E9E2E), + h("cookie", "Gingerbread", 0x9A5E2C), +]; + +pub const DEFAULT_PINNED: &[&str] = &["lavender", "glove", "coral", "plush", "robot", LASER]; +pub const MAX_PINNED: usize = 8; + +pub fn hand(id: &str) -> Option<&'static HandStyle> { + HANDS.iter().find(|s| s.id == id) +} + +pub fn is_style(id: &str) -> bool { + id == LASER || hand(id).is_some() +} + +pub fn title(id: &str) -> &'static str { + if id == LASER { + return "Laser dot"; + } + hand(id).map(|s| s.title).unwrap_or("Hand") +} + +/// Ink colour that matches a pointer style. +pub fn style_ink(id: &str) -> u32 { + if id == LASER { + return RED; + } + hand(id).map(|s| s.ink).unwrap_or(PURPLE) +} + +pub const SOUNDS: &[(&str, &str)] = &[ + ("thock", "Thock"), + ("click", "Click"), + ("pop", "Pop"), + ("wood", "Wood block"), + ("boop", "Boop"), + ("squeak", "Squeak"), +]; + +pub const INKS: &[(&str, &str, Option)] = &[ + ("match", "Match the pointer", None), + ("red", "Red", Some(RED)), + ("orange", "Orange", Some(0xFF8A00)), + ("yellow", "Yellow", Some(0xFFCC00)), + ("green", "Green", Some(0x30C25A)), + ("blue", "Blue", Some(0x0A84FF)), + ("purple", "Purple", Some(PURPLE)), + ("white", "White", Some(0xFFFFFF)), + ("black", "Black", Some(0x1D1D21)), +]; + +pub fn ink_color(choice: &str, style: &str) -> u32 { + INKS.iter() + .find(|(id, _, _)| *id == choice) + .and_then(|(_, _, c)| *c) + .unwrap_or_else(|| style_ink(style)) +} + +pub fn rgb(hex: u32) -> [u8; 3] { + [(hex >> 16) as u8, (hex >> 8) as u8, hex as u8] +} diff --git a/desktop/core/src/engine.rs b/desktop/core/src/engine.rs new file mode 100644 index 0000000..93d22c6 --- /dev/null +++ b/desktop/core/src/engine.rs @@ -0,0 +1,443 @@ +//! The overlay's frame loop, minus the OS. A platform layer: +//! +//! 1. calls [`Engine::set_screen`] with the monitor size, +//! 2. forwards button presses with [`Engine::mouse_down`] / [`Engine::mouse_up`], +//! 3. calls [`Engine::frame`] every tick with the cursor in monitor pixels, and +//! 4. when that returns a dirty rect, copies [`Engine::render_bgra`] (or +//! [`Engine::render`]) into its transparent window. + +use std::sync::Arc; +use std::time::Instant; + +use tiny_skia::{Color, FillRule, GradientStop, Paint, PathBuilder, Pixmap, Point, RadialGradient, SpreadMode, Transform}; + +use crate::arm::{Arm, HandPose, LaserPose}; +use crate::asset::{DisplayHand, HandAsset}; +use crate::geom::{merge, Affine, IRect}; +use crate::ink::Ink; +use crate::raster; + +#[derive(Clone)] +pub enum Look { + Hand(Arc), + Laser, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Settings { + pub size: f32, + pub shadow: bool, + pub fade_seconds: f32, + pub ink: [u8; 3], +} + +impl Default for Settings { + fn default() -> Self { + Self { size: 0.13, shadow: true, fade_seconds: 3.0, ink: crate::catalog::rgb(crate::catalog::PURPLE) } + } +} + +enum Pose { + Hand(HandPose), + Laser(LaserPose), +} + +pub struct Engine { + w: u32, + h: u32, + look: Look, + display: Option, + settings: Settings, + arm: Arm, + ink: Ink, + visible: bool, + cursor: (f32, f32), + down: Option<(f32, f32)>, + left_down: bool, + pose: Option, + last_bounds: Option, + last_key: Option<[i32; 6]>, + pending: Option, +} + +const LASER_RED: [u8; 3] = [255, 41, 31]; + +impl Engine { + pub fn new(look: Look, settings: Settings) -> Self { + Self { + w: 0, + h: 0, + look, + display: None, + settings, + arm: Arm::default(), + ink: Ink::default(), + visible: true, + cursor: (0.0, 0.0), + down: None, + left_down: false, + pose: None, + last_bounds: None, + last_key: None, + pending: None, + } + } + + pub fn screen(&self) -> (u32, u32) { + (self.w, self.h) + } + + /// Move to a monitor of this size. Clears drawings and repaints everything. + pub fn set_screen(&mut self, w: u32, h: u32) { + if (w, h) != (self.w, self.h) { + self.display = None; + } + self.w = w; + self.h = h; + self.ink.clear(); + self.arm.reset(); + self.down = None; + self.pose = None; + self.last_bounds = None; + self.last_key = None; + self.pending = Some(self.full()); + } + + pub fn set_look(&mut self, look: Look) { + self.look = look; + self.display = None; + self.invalidate_pose(); + } + + pub fn set_settings(&mut self, s: Settings) { + if s.size != self.settings.size { + self.display = None; + } + self.settings = s; + self.invalidate_pose(); + } + + pub fn set_visible(&mut self, visible: bool) { + if visible != self.visible { + self.visible = visible; + self.invalidate_pose(); + } + } + + /// A button went down with the cursor at `at` (monitor pixels). + pub fn mouse_down(&mut self, left: bool, at: (f32, f32)) { + self.cursor = at; + self.arm.press(); + self.arm.held = true; + self.down = Some(at); + self.left_down = left; + } + + pub fn mouse_up(&mut self, now: Instant) { + self.arm.held = false; + self.down = None; + self.ink.end(now); + } + + pub fn clear_ink(&mut self) { + self.pending = merge(self.pending, self.ink.clear()); + } + + pub fn is_drawing(&self) -> bool { + self.ink.is_drawing() + } + + pub fn has_ink(&self) -> bool { + !self.ink.is_empty() + } + + /// True while something is still animating even if the mouse is still. + pub fn is_busy(&self, now: Instant) -> bool { + self.arm.is_settling() || self.ink.is_drawing() || self.ink.is_fading(now, self.settings.fade_seconds) + } + + fn full(&self) -> IRect { + IRect::new(0, 0, self.w as i32, self.h as i32) + } + + fn invalidate_pose(&mut self) { + self.pending = merge(self.pending, self.last_bounds.take()); + self.last_key = None; + self.pose = None; + } + + fn ink_color(&self) -> [u8; 3] { + match self.look { + Look::Laser => LASER_RED, + Look::Hand(_) => self.settings.ink, + } + } + + fn ink_width(&self) -> f32 { + (self.h as f32 * 0.0055).max(3.0) + } + + fn ensure_display(&mut self) { + let Look::Hand(a) = &self.look else { return }; + if self.display.is_some() || self.h == 0 { + return; + } + // Pick a copy close to the drawn size so drawing is ~1:1 and sharp. + let full = a.image.height; + let rest = self.settings.size * self.h as f32 / (a.wrist - a.hand_top).max(1e-3); + let step = 64.0; + let needed = ((rest / step).ceil() * step) as u32; + let height = if needed >= full * 9 / 10 { full } else { needed.max(64) }; + let blur = 0.021 * self.h as f32 * height as f32 / rest.max(1.0); + self.display = Some(a.display(height, blur)); + } + + /// Advance one tick. Returns the area of the screen that must be redrawn. + pub fn frame(&mut self, now: Instant, dt: f32, cursor: (f32, f32)) -> Option { + if self.w == 0 || self.h == 0 { + return None; + } + self.cursor = cursor; + + if let (Some(start), true) = (self.down, self.left_down) { + if !self.ink.is_drawing() && (cursor.0 - start.0).hypot(cursor.1 - start.1) >= 4.0 { + let r = self.ink.begin(start, self.ink_color(), self.ink_width()); + self.pending = merge(self.pending, Some(r)); + } + if self.ink.is_drawing() { + self.pending = merge(self.pending, self.ink.extend(cursor)); + } + } + self.pending = merge(self.pending, self.ink.tick(now, self.settings.fade_seconds)); + + self.arm.step(dt, cursor.0, self.w as f32); + + if self.visible { + self.ensure_display(); + let screen = (self.w as f32, self.h as f32); + let pose = match (&self.look, &self.display) { + (Look::Hand(a), Some(d)) => Some(Pose::Hand(self.arm.hand_pose( + cursor, + screen, + a, + self.settings.size, + self.settings.shadow, + d.pixmap.width() as f32, + d.pixmap.height() as f32, + ))), + (Look::Laser, _) => Some(Pose::Laser(self.arm.laser_pose(cursor, screen.1, self.settings.size))), + _ => None, + }; + if let Some(pose) = pose { + let key = match &pose { + Pose::Hand(p) => p.key, + Pose::Laser(p) => p.key, + }; + if Some(key) != self.last_key { + let bounds = self.bounds(&pose); + self.pending = merge(merge(self.pending, self.last_bounds), Some(bounds)); + self.last_bounds = Some(bounds); + self.last_key = Some(key); + self.pose = Some(pose); + } + } + } + + self.pending.take().and_then(|r| r.intersect(self.full())) + } + + fn bounds(&self, pose: &Pose) -> IRect { + match pose { + Pose::Laser(p) => { + let g = p.r * (3.2 + p.press) + 2.0; + IRect::around(p.x - g, p.y - g, p.x + g, p.y + g, 1.0) + } + Pose::Hand(p) => { + let Some(d) = self.display.as_ref() else { return IRect::new(0, 0, 0, 0) }; + let blur = d.blur; + let (mut lo, mut hi) = ((f32::MAX, f32::MAX), (f32::MIN, f32::MIN)); + let mut add = |t: &Affine, r: &[f32; 4], off: (f32, f32)| { + for (x, y) in [(r[0], r[1]), (r[2], r[1]), (r[0], r[3]), (r[2], r[3])] { + let (sx, sy) = t.apply(x, y); + let (sx, sy) = (sx + off.0, sy + off.1); + lo = (lo.0.min(sx), lo.1.min(sy)); + hi = (hi.0.max(sx), hi.1.max(sy)); + } + }; + add(&p.hand, &p.hand_src, (0.0, 0.0)); + add(&p.rod, &p.rod_src, (0.0, 0.0)); + if p.shadow_alpha > 0.0 { + add(&p.hand, &hand_shadow_src(p, d), p.shadow_offset); + add(&p.rod, &rod_shadow_src(p, d), p.shadow_offset); + } + IRect::around(lo.0, lo.1, hi.0, hi.1, blur * 1.5 + 3.0) + } + } + } + + /// Render `dirty` (screen pixels) into a premultiplied RGBA pixmap of the + /// same size. + pub fn render(&self, dirty: IRect, now: Instant) -> Option { + let mut pm = Pixmap::new(dirty.width().max(1) as u32, dirty.height().max(1) as u32)?; + let offset = Transform::from_translate(-dirty.x0 as f32, -dirty.y0 as f32); + self.ink.render(&mut pm, offset, now, self.settings.fade_seconds, dirty); + if self.visible { + match (&self.pose, &self.display) { + (Some(Pose::Hand(p)), Some(d)) => draw_hand(&mut pm, p, d, (dirty.x0 as f32, dirty.y0 as f32)), + (Some(Pose::Laser(p)), _) => draw_laser(&mut pm, p, offset), + _ => {} + } + } + Some(pm) + } + + /// Render `dirty` straight into a top-down BGRA (premultiplied) buffer + /// covering the whole screen, as Windows layered windows expect. + pub fn render_bgra(&self, dirty: IRect, now: Instant, dst: &mut [u8], stride: usize) { + let Some(pm) = self.render(dirty, now) else { return }; + let row = dirty.width() as usize * 4; + for (y, src) in pm.data().chunks_exact(row).enumerate() { + let start = (dirty.y0 as usize + y) * stride + dirty.x0 as usize * 4; + let Some(out) = dst.get_mut(start..start + row) else { break }; + for (o, s) in out.chunks_exact_mut(4).zip(src.chunks_exact(4)) { + o[0] = s[2]; + o[1] = s[1]; + o[2] = s[0]; + o[3] = s[3]; + } + } + } +} + +/// A slice grown by the shadow blur so the shadow isn't cut off. +fn grow(src: [f32; 4], d: &DisplayHand, vertical: bool) -> [f32; 4] { + let m = d.blur * 1.5; + let (w, h) = (d.pixmap.width() as f32, d.pixmap.height() as f32); + let [x0, y0, x1, y1] = src; + let (y0, y1) = if vertical { ((y0 - m).max(0.0), (y1 + m).min(h)) } else { (y0, y1) }; + [(x0 - m).max(0.0), y0, (x1 + m).min(w), y1] +} + +fn hand_shadow_src(p: &HandPose, d: &DisplayHand) -> [f32; 4] { + grow(p.hand_src, d, true) +} + +fn rod_shadow_src(p: &HandPose, d: &DisplayHand) -> [f32; 4] { + [d.rod.shadow_x0, p.rod_src[1], d.rod.shadow_x1, p.rod_src[3]] +} + +fn draw_hand(pm: &mut Pixmap, p: &HandPose, d: &DisplayHand, origin: (f32, f32)) { + let (width, height) = (pm.width() as usize, pm.height() as usize); + let mut t = raster::Target { data: pm.data_mut(), width, height, origin }; + if p.shadow_alpha > 0.0 { + let shift = Affine::translate(p.shadow_offset.0, p.shadow_offset.1); + raster::rod_shadow(&mut t, &d.rod, p.rod.then(shift), rod_shadow_src(p, d), p.shadow_alpha); + raster::shadow_image(&mut t, &d.shadow_hand, p.hand.then(shift), hand_shadow_src(p, d), p.shadow_alpha); + } + raster::rod(&mut t, &d.rod, p.rod, [d.rod.x0, p.rod_src[1], d.rod.x1, p.rod_src[3]]); + raster::image(&mut t, &d.pixmap, p.hand, p.hand_src, 1.0); +} + +fn circle(pm: &mut Pixmap, x: f32, y: f32, r: f32, paint: &Paint, offset: Transform) { + if let Some(path) = PathBuilder::from_circle(x, y, r) { + pm.fill_path(&path, paint, FillRule::Winding, offset, None); + } +} + +fn draw_laser(pm: &mut Pixmap, p: &LaserPose, offset: Transform) { + let [r, g, b] = LASER_RED; + let glow = p.r * (3.2 + p.press); + let stops = vec![ + GradientStop::new(0.0, Color::from_rgba8(r, g, b, 150)), + GradientStop::new(0.3, Color::from_rgba8(r, g, b, 70)), + GradientStop::new(1.0, Color::from_rgba8(r, g, b, 0)), + ]; + let center = Point::from_xy(p.x, p.y); + if let Some(shader) = RadialGradient::new(center, center, glow, stops, SpreadMode::Pad, Transform::identity()) { + let paint = Paint { shader, anti_alias: true, ..Default::default() }; + circle(pm, p.x, p.y, glow, &paint, offset); + } + let mut paint = Paint { anti_alias: true, ..Default::default() }; + paint.set_color_rgba8(r, g, b, 255); + circle(pm, p.x, p.y, p.r, &paint, offset); + paint.set_color_rgba8(255, 219, 209, 242); + circle(pm, p.x, p.y, p.r * 0.45, &paint, offset); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn lavender() -> Arc { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/hand_lavender.png"); + Arc::new(HandAsset::from_png(&std::fs::read(path).expect("asset")).expect("measure")) + } + + #[test] + fn measures_builtin_hand() { + let a = lavender(); + assert!(a.tip < a.wrist && a.wrist < a.rod_end, "tip {} wrist {} rod_end {}", a.tip, a.wrist, a.rod_end); + assert!(a.rod_x > 0.2 && a.rod_x < 0.8); + } + + #[test] + fn fingertip_lands_on_cursor() { + let a = lavender(); + let mut arm = Arm::default(); + let screen = (1920.0, 1080.0); + for cursor in [(300.0, 200.0), (1500.0, 900.0), (960.0, 540.0), (50.0, 1000.0)] { + arm.step(1.0, cursor.0, screen.0); + let (dw, dh) = (a.image.width as f32, a.image.height as f32); + let p = arm.hand_pose(cursor, screen, &a, 0.13, true, dw, dh); + let (tx, ty) = p.hand.apply(a.tip_x * dw, a.tip * dh); + assert!((tx - cursor.0).abs() < 1.5 && (ty - cursor.1).abs() < 1.5, "tip at {tx},{ty} for cursor {cursor:?}"); + } + } + + #[test] + fn renders_hand_and_ink() { + let mut e = Engine::new(Look::Hand(lavender()), Settings::default()); + e.set_screen(1280, 800); + let t0 = Instant::now(); + let mut cur = (400.0, 300.0); + let first = e.frame(t0, 1.0, cur).expect("first frame paints"); + assert_eq!(first, IRect::new(0, 0, 1280, 800)); + // Draw a stroke. + e.mouse_down(true, cur); + for i in 1..30 { + cur = (400.0 + i as f32 * 8.0, 300.0 + (i as f32 * 0.3).sin() * 40.0); + e.frame(t0 + Duration::from_millis(i * 8), 1.0 / 120.0, cur); + } + e.mouse_up(t0 + Duration::from_millis(300)); + let pm = e.render(IRect::new(0, 0, 1280, 800), t0 + Duration::from_millis(300)).unwrap(); + let alpha_at = |x: u32, y: u32| pm.pixel(x, y).unwrap().alpha(); + // Ink along the stroke, nothing in the far top-left corner. + assert!(alpha_at(480, 300 + ((10.0f32 * 0.3).sin() * 40.0) as u32) > 0 || alpha_at(480, 306) > 0); + assert_eq!(alpha_at(5, 5), 0); + // The hand is drawn just below the fingertip. + assert!(alpha_at(cur.0 as u32, cur.1 as u32 + 6) > 100, "hand below the tip"); + + if let Ok(dir) = std::env::var("TINYHAND_RENDER_DIR") { + pm.save_png(format!("{dir}/frame.png")).unwrap(); + } + + // Ink fades away after the fade delay. + let later = t0 + Duration::from_secs(5); + assert!(e.frame(later, 1.0 / 120.0, cur).is_some()); + assert!(!e.has_ink()); + } + + #[test] + fn idle_frames_paint_nothing() { + let mut e = Engine::new(Look::Laser, Settings::default()); + e.set_screen(800, 600); + let t = Instant::now(); + e.frame(t, 1.0, (100.0, 100.0)); + for _ in 0..400 { + e.frame(t, 1.0 / 120.0, (100.0, 100.0)); + } + assert_eq!(e.frame(t, 1.0 / 120.0, (100.0, 100.0)), None); + assert!(!e.is_busy(t)); + } +} diff --git a/desktop/core/src/geom.rs b/desktop/core/src/geom.rs new file mode 100644 index 0000000..7dd5eee --- /dev/null +++ b/desktop/core/src/geom.rs @@ -0,0 +1,117 @@ +/// Integer pixel rectangle, half-open: `x0..x1`, `y0..y1`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IRect { + pub x0: i32, + pub y0: i32, + pub x1: i32, + pub y1: i32, +} + +impl IRect { + pub fn new(x0: i32, y0: i32, x1: i32, y1: i32) -> Self { + Self { x0, y0, x1, y1 } + } + + pub fn width(&self) -> i32 { + self.x1 - self.x0 + } + + pub fn height(&self) -> i32 { + self.y1 - self.y0 + } + + pub fn is_empty(&self) -> bool { + self.x1 <= self.x0 || self.y1 <= self.y0 + } + + /// Smallest integer rect covering the float box, grown by `pad`. + pub fn around(min_x: f32, min_y: f32, max_x: f32, max_y: f32, pad: f32) -> Self { + Self { + x0: (min_x - pad).floor() as i32, + y0: (min_y - pad).floor() as i32, + x1: (max_x + pad).ceil() as i32, + y1: (max_y + pad).ceil() as i32, + } + } + + pub fn union(self, other: IRect) -> IRect { + if self.is_empty() { + return other; + } + if other.is_empty() { + return self; + } + IRect { + x0: self.x0.min(other.x0), + y0: self.y0.min(other.y0), + x1: self.x1.max(other.x1), + y1: self.y1.max(other.y1), + } + } + + pub fn intersect(self, other: IRect) -> Option { + let r = IRect { + x0: self.x0.max(other.x0), + y0: self.y0.max(other.y0), + x1: self.x1.min(other.x1), + y1: self.y1.min(other.y1), + }; + (!r.is_empty()).then_some(r) + } + + pub fn contains(&self, x: i32, y: i32) -> bool { + x >= self.x0 && x < self.x1 && y >= self.y0 && y < self.y1 + } +} + +pub fn merge(a: Option, b: Option) -> Option { + match (a, b) { + (Some(a), Some(b)) => Some(a.union(b)), + (a, None) => a, + (None, b) => b, + } +} + +/// 2D affine map: `x' = a·x + b·y + c`, `y' = d·x + e·y + f`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Affine { + pub a: f32, + pub b: f32, + pub c: f32, + pub d: f32, + pub e: f32, + pub f: f32, +} + +impl Affine { + pub const IDENTITY: Affine = Affine { a: 1.0, b: 0.0, c: 0.0, d: 0.0, e: 1.0, f: 0.0 }; + + pub fn new(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> Self { + Self { a, b, c, d, e, f } + } + + pub fn translate(x: f32, y: f32) -> Self { + Self::new(1.0, 0.0, x, 0.0, 1.0, y) + } + + /// Apply `self` first, then `outer`. + pub fn then(self, outer: Affine) -> Affine { + let s = outer; + Affine { + a: s.a * self.a + s.b * self.d, + b: s.a * self.b + s.b * self.e, + c: s.a * self.c + s.b * self.f + s.c, + d: s.d * self.a + s.e * self.d, + e: s.d * self.b + s.e * self.e, + f: s.d * self.c + s.e * self.f + s.f, + } + } + + pub fn apply(&self, x: f32, y: f32) -> (f32, f32) { + (self.a * x + self.b * y + self.c, self.d * x + self.e * y + self.f) + } + + pub fn to_skia(self) -> tiny_skia::Transform { + tiny_skia::Transform::from_row(self.a, self.d, self.b, self.e, self.c, self.f) + } +} diff --git a/desktop/core/src/ink.rs b/desktop/core/src/ink.rs new file mode 100644 index 0000000..9cabc71 --- /dev/null +++ b/desktop/core/src/ink.rs @@ -0,0 +1,161 @@ +//! Freehand drawing. Port of `Ink.swift`: smooth quadratic strokes with a soft +//! shadow that fade out a few seconds after the button is released. + +use std::time::Instant; + +use tiny_skia::{Color, LineCap, LineJoin, Paint, Path, PathBuilder, Pixmap, Stroke as SkStroke, Transform}; + +use crate::geom::{merge, IRect}; + +const FADE_DURATION: f32 = 0.6; +const MIN_STEP: f32 = 1.5; +const SHADOW_OFFSET: (f32, f32) = (1.0, 1.5); + +pub struct Stroke { + pts: Vec<(f32, f32)>, + color: [u8; 3], + width: f32, + ended: Option, + min: (f32, f32), + max: (f32, f32), +} + +impl Stroke { + fn pad(&self) -> f32 { + self.width / 2.0 + 4.0 + } + + fn bounds(&self) -> IRect { + IRect::around(self.min.0, self.min.1, self.max.0 + SHADOW_OFFSET.0, self.max.1 + SHADOW_OFFSET.1, self.pad()) + } + + fn alpha(&self, now: Instant, fade_seconds: f32) -> f32 { + let Some(ended) = self.ended else { return 1.0 }; + if fade_seconds <= 0.0 { + return 1.0; + } + let delay = fade_seconds.max(0.5); + let t = now.saturating_duration_since(ended).as_secs_f32(); + if t < delay { + return 1.0; + } + let u = ((t - delay) / FADE_DURATION).min(1.0); + 1.0 - u * u + } + + fn path(&self) -> Option { + let mut pb = PathBuilder::new(); + let first = *self.pts.first()?; + pb.move_to(first.0, first.1); + if self.pts.len() == 1 { + pb.line_to(first.0 + 0.01, first.1); + } + for w in self.pts.windows(2) { + let (prev, p) = (w[0], w[1]); + pb.quad_to(prev.0, prev.1, (prev.0 + p.0) / 2.0, (prev.1 + p.1) / 2.0); + } + if self.pts.len() > 1 { + let last = self.pts[self.pts.len() - 1]; + pb.line_to(last.0, last.1); + } + pb.finish() + } +} + +#[derive(Default)] +pub struct Ink { + strokes: Vec, + drawing: bool, +} + +impl Ink { + pub fn is_drawing(&self) -> bool { + self.drawing + } + + pub fn is_empty(&self) -> bool { + self.strokes.is_empty() + } + + pub fn begin(&mut self, p: (f32, f32), color: [u8; 3], width: f32) -> IRect { + let s = Stroke { pts: vec![p], color, width, ended: None, min: p, max: p }; + let b = s.bounds(); + self.strokes.push(s); + self.drawing = true; + b + } + + /// Add a point to the current stroke; returns the area that changed. + pub fn extend(&mut self, p: (f32, f32)) -> Option { + if !self.drawing { + return None; + } + let s = self.strokes.last_mut()?; + let last = *s.pts.last()?; + if (p.0 - last.0).hypot(p.1 - last.1) < MIN_STEP { + return None; + } + s.pts.push(p); + s.min = (s.min.0.min(p.0), s.min.1.min(p.1)); + s.max = (s.max.0.max(p.0), s.max.1.max(p.1)); + // The curve between the last few points is what moved. + let tail = &s.pts[s.pts.len().saturating_sub(3)..]; + let (mut lo, mut hi) = (tail[0], tail[0]); + for q in tail { + lo = (lo.0.min(q.0), lo.1.min(q.1)); + hi = (hi.0.max(q.0), hi.1.max(q.1)); + } + Some(IRect::around(lo.0, lo.1, hi.0 + SHADOW_OFFSET.0, hi.1 + SHADOW_OFFSET.1, s.pad())) + } + + pub fn end(&mut self, now: Instant) { + if self.drawing { + if let Some(s) = self.strokes.last_mut() { + s.ended = Some(now); + } + } + self.drawing = false; + } + + pub fn clear(&mut self) -> Option { + let area = self.strokes.iter().map(Stroke::bounds).reduce(IRect::union); + self.strokes.clear(); + self.drawing = false; + area + } + + pub fn is_fading(&self, now: Instant, fade_seconds: f32) -> bool { + fade_seconds > 0.0 && self.strokes.iter().any(|s| s.ended.is_some() && s.alpha(now, fade_seconds) < 1.0) + } + + /// Drop finished strokes; returns the area that needs repainting because + /// a stroke is fading or just disappeared. + pub fn tick(&mut self, now: Instant, fade_seconds: f32) -> Option { + let mut dirty = None; + self.strokes.retain(|s| { + let a = s.alpha(now, fade_seconds); + if a < 1.0 { + dirty = merge(dirty, Some(s.bounds())); + } + a > 0.0 + }); + dirty + } + + pub fn render(&self, pm: &mut Pixmap, offset: Transform, now: Instant, fade_seconds: f32, clip: IRect) { + for s in &self.strokes { + if s.bounds().intersect(clip).is_none() { + continue; + } + let alpha = s.alpha(now, fade_seconds); + let Some(path) = s.path() else { continue }; + let stroke = SkStroke { width: s.width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + let mut paint = Paint { anti_alias: true, ..Default::default() }; + paint.set_color(Color::from_rgba(0.0, 0.0, 0.0, 0.18 * alpha).unwrap_or(Color::TRANSPARENT)); + let shadow = offset.pre_translate(SHADOW_OFFSET.0, SHADOW_OFFSET.1); + pm.stroke_path(&path, &paint, &stroke, shadow, None); + paint.set_color_rgba8(s.color[0], s.color[1], s.color[2], (alpha * 255.0).round() as u8); + pm.stroke_path(&path, &paint, &stroke, offset, None); + } + } +} diff --git a/desktop/core/src/interact.rs b/desktop/core/src/interact.rs new file mode 100644 index 0000000..d0372a9 --- /dev/null +++ b/desktop/core/src/interact.rs @@ -0,0 +1,105 @@ +//! The "hold a key to click for real" key, without leaking it into clicks. +//! +//! macOS strips the modifier from the click (`event.flags.remove`). Windows +//! can't change modifier state from a mouse hook, so instead the keyboard +//! hook keeps the key from apps entirely while the pointer is on: Alt+click +//! then reaches Chrome as a plain click instead of "download link". +//! +//! Shortcuts still work: if another key is pressed while the interact key is +//! held (Alt+Tab, Ctrl+C), the held key is handed back to the system just +//! before it ("replay"). + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum KeyAction { + /// Let the event through. + Pass, + /// Drop the event. + Swallow, + /// Drop this key, then inject the held interact key's press followed by + /// this key's press, so apps see the shortcut in the right order. + Replay, +} + +#[derive(Default, Debug)] +pub struct InteractFilter { + held: bool, + replayed: bool, +} + +impl InteractFilter { + /// The interact key is physically down. + pub fn held(&self) -> bool { + self.held + } + + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Decide what to do with a key event. `is_interact` says whether this + /// key is the interact key (either side, e.g. left or right Alt). + pub fn key(&mut self, is_interact: bool, down: bool) -> KeyAction { + if is_interact { + if down { + self.held = true; + // Auto-repeat after a replay can go through; apps already saw the press. + if self.replayed { + KeyAction::Pass + } else { + KeyAction::Swallow + } + } else { + let replayed = self.replayed; + self.held = false; + self.replayed = false; + if replayed { + KeyAction::Pass + } else { + KeyAction::Swallow + } + } + } else if down && self.held && !self.replayed { + self.replayed = true; + KeyAction::Replay + } else { + KeyAction::Pass + } + } +} + +#[cfg(test)] +mod tests { + use super::KeyAction::*; + use super::*; + + #[test] + fn hold_alone_never_reaches_apps() { + let mut f = InteractFilter::default(); + assert_eq!(f.key(true, true), Swallow); + assert!(f.held()); + assert_eq!(f.key(true, true), Swallow, "auto-repeat"); + assert_eq!(f.key(true, false), Swallow); + assert!(!f.held()); + } + + #[test] + fn shortcut_is_replayed_once() { + let mut f = InteractFilter::default(); + assert_eq!(f.key(true, true), Swallow); // Alt down + assert_eq!(f.key(false, true), Replay); // Tab down → inject Alt, Tab + assert_eq!(f.key(false, false), Pass); // Tab up + assert_eq!(f.key(false, true), Pass); // Tab again (Alt already given) + assert_eq!(f.key(true, true), Pass); // Alt auto-repeat + assert_eq!(f.key(true, false), Pass); // Alt up pairs with the replayed down + assert!(!f.held()); + // Next hold starts clean. + assert_eq!(f.key(true, true), Swallow); + } + + #[test] + fn other_keys_untouched_when_not_held() { + let mut f = InteractFilter::default(); + assert_eq!(f.key(false, true), Pass); + assert_eq!(f.key(false, false), Pass); + } +} diff --git a/desktop/core/src/lib.rs b/desktop/core/src/lib.rs new file mode 100644 index 0000000..b1b2b35 --- /dev/null +++ b/desktop/core/src/lib.rs @@ -0,0 +1,21 @@ +//! Platform-independent TinyHand logic. +//! +//! Everything here is pure Rust with no OS calls, so it can drive the Windows +//! overlay today and a macOS or Linux overlay later. A platform layer feeds the +//! [`Engine`] cursor positions and button events, and copies the pixels it +//! renders onto a transparent, click-through window. + +pub mod arm; +pub mod asset; +pub mod catalog; +pub mod engine; +pub mod geom; +pub mod ink; +pub mod interact; +pub mod prefs; +mod raster; + +pub use asset::HandAsset; +pub use engine::{Engine, Look, Settings}; +pub use geom::IRect; +pub use prefs::Prefs; diff --git a/desktop/core/src/prefs.rs b/desktop/core/src/prefs.rs new file mode 100644 index 0000000..6649753 --- /dev/null +++ b/desktop/core/src/prefs.rs @@ -0,0 +1,145 @@ +use serde::{Deserialize, Serialize}; + +use crate::catalog; + +pub const POINTER_SIZE_MIN: f32 = 0.08; +pub const POINTER_SIZE_MAX: f32 = 0.20; + +/// Languages the UI has strings for. +pub const LANGUAGES: &[&str] = &["en", "vi"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum InteractKey { + Alt, + Ctrl, + Shift, + Off, +} + +/// User settings, stored as JSON. Unknown or missing fields fall back to the +/// defaults, so old files keep loading as fields are added. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Prefs { + pub style: String, + pub pinned: Vec, + /// Hand length as a fraction of the screen height. + pub pointer_size: f32, + pub show_shadow: bool, + /// Seconds before a drawing fades; 0 keeps it until cleared. + pub fade_seconds: f32, + pub hand_sound: bool, + pub laser_sound: bool, + pub tap_sound: String, + pub interact_key: InteractKey, + pub ink: String, + pub welcomed: bool, + /// UI language code ("en", "vi"); empty until the user picks one. + pub language: String, +} + +impl Default for Prefs { + fn default() -> Self { + Self { + style: "lavender".into(), + pinned: catalog::DEFAULT_PINNED.iter().map(|s| s.to_string()).collect(), + pointer_size: 0.13, + show_shadow: true, + fade_seconds: 3.0, + hand_sound: true, + laser_sound: false, + tap_sound: "thock".into(), + interact_key: InteractKey::Alt, + ink: "match".into(), + welcomed: false, + language: String::new(), + } + } +} + +impl Prefs { + /// Clamp values and drop anything that no longer exists. + pub fn sanitized(mut self) -> Self { + let d = Prefs::default(); + if !catalog::is_style(&self.style) { + self.style = d.style.clone(); + } + let mut seen = Vec::new(); + self.pinned.retain(|p| catalog::is_style(p) && !seen.contains(p) && { + seen.push(p.clone()); + true + }); + self.pinned.truncate(catalog::MAX_PINNED); + if self.pinned.is_empty() { + self.pinned = d.pinned.clone(); + } + if !self.pointer_size.is_finite() { + self.pointer_size = d.pointer_size; + } + self.pointer_size = self.pointer_size.clamp(POINTER_SIZE_MIN, POINTER_SIZE_MAX); + if !self.fade_seconds.is_finite() || self.fade_seconds < 0.0 { + self.fade_seconds = d.fade_seconds; + } + if !catalog::SOUNDS.iter().any(|(id, _)| *id == self.tap_sound) { + self.tap_sound = d.tap_sound.clone(); + } + if !LANGUAGES.contains(&self.language.as_str()) { + self.language.clear(); + } + if !catalog::INKS.iter().any(|(id, _, _)| *id == self.ink) { + self.ink = d.ink; + } + self + } + + pub fn is_laser(&self) -> bool { + self.style == catalog::LASER + } + + pub fn sound_on(&self) -> bool { + if self.is_laser() { + self.laser_sound + } else { + self.hand_sound + } + } + + pub fn set_sound_on(&mut self, on: bool) { + if self.is_laser() { + self.laser_sound = on; + } else { + self.hand_sound = on; + } + } + + pub fn ink_rgb(&self) -> [u8; 3] { + catalog::rgb(catalog::ink_color(&self.ink, &self.style)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_fields_use_defaults() { + let p: Prefs = serde_json::from_str(r#"{"style":"robot"}"#).unwrap_or_default(); + assert_eq!(p.style, "robot"); + assert_eq!(p.pointer_size, 0.13); + } + + #[test] + fn sanitize_drops_bad_values() { + let p = Prefs { + style: "nope".into(), + pinned: vec!["robot".into(), "robot".into(), "x".into()], + pointer_size: 9.0, + ..Prefs::default() + } + .sanitized(); + assert_eq!(p.style, "lavender"); + assert_eq!(p.pinned, vec!["robot".to_string()]); + assert_eq!(p.pointer_size, POINTER_SIZE_MAX); + } +} diff --git a/desktop/core/src/raster.rs b/desktop/core/src/raster.rs new file mode 100644 index 0000000..07d4d70 --- /dev/null +++ b/desktop/core/src/raster.rs @@ -0,0 +1,200 @@ +//! A tiny purpose-built rasteriser for the hand and rod. +//! +//! Each slice is an image rectangle under an affine map. For every screen row +//! we solve for the exact span of pixels the rotated rectangle covers and only +//! visit those, mapping each pixel centre back into the image. Cost scales +//! with the pixels actually drawn, not with the (large, mostly empty) +//! bounding box of a diagonal stick. +//! +//! Destination is premultiplied RGBA8 (tiny-skia's layout), composited +//! source-over. + +use crate::asset::RodProfile; +use crate::geom::Affine; + +pub struct Target<'a> { + pub data: &'a mut [u8], + pub width: usize, + pub height: usize, + /// Screen position of the buffer's top-left pixel. + pub origin: (f32, f32), +} + +/// Inverse of an affine map, or `None` if it's degenerate. +fn invert(t: Affine) -> Option { + let det = t.a * t.e - t.b * t.d; + if det.abs() < 1e-12 { + return None; + } + let inv = 1.0 / det; + let (a, b, d, e) = (t.e * inv, -t.b * inv, -t.d * inv, t.a * inv); + Some(Affine::new(a, b, -(a * t.c + b * t.f), d, e, -(d * t.c + e * t.f))) +} + +/// Visit every target pixel whose centre maps (through the inverse of `t`) +/// into `src` = `[x0, y0, x1, y1]`, calling `shade(u, v, pixel)`. +fn spans(target: &mut Target, t: Affine, src: [f32; 4], mut shade: impl FnMut(f32, f32, &mut [u8])) { + let Some(inv) = invert(t) else { return }; + let [x0, y0, x1, y1] = src; + let (ox, oy) = target.origin; + let width = target.width; + for row in 0..target.height { + let sy = oy + row as f32 + 0.5; + // u = inv.a·sx + inv.b·sy + inv.c, v = inv.d·sx + inv.e·sy + inv.f, + // with sx = ox + col + 0.5. Solve both ranges for col. + let mut lo = 0f32; + let mut hi = width as f32; + for (k, c, min, max) in [ + (inv.a, inv.b * sy + inv.c + inv.a * (ox + 0.5), x0, x1), + (inv.d, inv.e * sy + inv.f + inv.d * (ox + 0.5), y0, y1), + ] { + if k.abs() < 1e-9 { + if c < min || c > max { + hi = -1.0; + } + } else { + let (p, q) = ((min - c) / k, (max - c) / k); + lo = lo.max(p.min(q)); + hi = hi.min(p.max(q)); + } + } + if hi < lo { + continue; + } + let start = lo.ceil().max(0.0) as usize; + let end = (hi.floor() as usize + 1).min(width); + if start >= end { + continue; + } + let sx = ox + start as f32 + 0.5; + let mut u = inv.a * sx + inv.b * sy + inv.c; + let mut v = inv.d * sx + inv.e * sy + inv.f; + let line = &mut target.data[row * width * 4..(row + 1) * width * 4]; + for px in line[start * 4..end * 4].chunks_exact_mut(4) { + shade(u, v, px); + u += inv.a; + v += inv.d; + } + } +} + +#[inline(always)] +fn over(px: &mut [u8], c: [f32; 4]) { + // c is premultiplied 0..255. + if c[3] <= 0.0 { + return; + } + let k = 1.0 - c[3] / 255.0; + for i in 0..4 { + px[i] = (c[i] + px[i] as f32 * k + 0.5).min(255.0) as u8; + } +} + +/// Bilinear sample of a premultiplied RGBA8 image at image coords (u, v), +/// transparent outside. +#[inline(always)] +fn bilinear(img: &[u8], w: usize, h: usize, u: f32, v: f32) -> [f32; 4] { + let fx = u - 0.5; + let fy = v - 0.5; + let x0 = fx.floor(); + let y0 = fy.floor(); + let tx = fx - x0; + let ty = fy - y0; + let (x0, y0) = (x0 as isize, y0 as isize); + let fetch = |x: isize, y: isize| -> [f32; 4] { + if x < 0 || y < 0 || x >= w as isize || y >= h as isize { + return [0.0; 4]; + } + let i = (y as usize * w + x as usize) * 4; + [img[i] as f32, img[i + 1] as f32, img[i + 2] as f32, img[i + 3] as f32] + }; + let (a, b, c, d) = (fetch(x0, y0), fetch(x0 + 1, y0), fetch(x0, y0 + 1), fetch(x0 + 1, y0 + 1)); + let mut out = [0f32; 4]; + for i in 0..4 { + let top = a[i] + (b[i] - a[i]) * tx; + let bot = c[i] + (d[i] - c[i]) * tx; + out[i] = top + (bot - top) * ty; + } + out +} + +/// Draw an image slice. `opacity` scales the whole thing. +pub fn image(target: &mut Target, img: &tiny_skia::Pixmap, t: Affine, src: [f32; 4], opacity: f32) { + let (w, h) = (img.width() as usize, img.height() as usize); + let data = img.data(); + spans(target, t, src, |u, v, px| { + let mut c = bilinear(data, w, h, u, v); + if opacity < 1.0 { + c = c.map(|x| x * opacity); + } + over(px, c); + }); +} + +/// Draw a black shadow from an image's alpha. +pub fn shadow_image(target: &mut Target, img: &tiny_skia::Pixmap, t: Affine, src: [f32; 4], opacity: f32) { + let (w, h) = (img.width() as usize, img.height() as usize); + let data = img.data(); + spans(target, t, src, |u, v, px| { + let a = bilinear(data, w, h, u, v)[3] * opacity; + over(px, [0.0, 0.0, 0.0, a]); + }); +} + +#[inline(always)] +fn lerp_column(cols: &[T], x0: f32, u: f32, zero: T, mix: impl Fn(T, T, f32) -> T) -> T { + let f = u - x0 - 0.5; + let i = f.floor(); + let t = f - i; + let i = i as isize; + let at = |k: isize| if k < 0 || k >= cols.len() as isize { zero } else { cols[k as usize] }; + mix(at(i), at(i + 1), t) +} + +/// Draw the rod: its averaged cross-section extruded along the slice. +pub fn rod(target: &mut Target, p: &RodProfile, t: Affine, src: [f32; 4]) { + spans(target, t, src, |u, _v, px| { + let c = lerp_column(&p.colors, p.x0, u, [0.0; 4], |a, b, t| { + [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t] + }); + over(px, c.map(|x| x * 255.0)); + }); +} + +pub fn rod_shadow(target: &mut Target, p: &RodProfile, t: Affine, src: [f32; 4], opacity: f32) { + spans(target, t, src, |u, _v, px| { + let a = lerp_column(&p.shadow, p.shadow_x0, u, 0.0, |a, b, t| a + (b - a) * t); + over(px, [0.0, 0.0, 0.0, a * 255.0 * opacity]); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inverse_round_trips() { + let t = Affine::new(0.8, -0.6, 100.0, 0.6, 0.8, -40.0); + let inv = invert(t).unwrap(); + let (x, y) = t.apply(12.0, 34.0); + let (u, v) = inv.apply(x, y); + assert!((u - 12.0).abs() < 1e-3 && (v - 34.0).abs() < 1e-3); + } + + #[test] + fn spans_cover_exactly_the_rect() { + // A 10×4 rect, scaled by 2 and moved to (5, 3): covers x 5..25, y 3..11. + let mut buf = vec![0u8; 40 * 20 * 4]; + let mut tgt = Target { data: &mut buf, width: 40, height: 20, origin: (0.0, 0.0) }; + let t = Affine::new(2.0, 0.0, 5.0, 0.0, 2.0, 3.0); + let mut n = 0; + spans(&mut tgt, t, [0.0, 0.0, 10.0, 4.0], |_, _, px| { + px[3] = 255; + n += 1; + }); + assert_eq!(n, 20 * 8); + assert_eq!(buf[((3 * 40) + 5) * 4 + 3], 255); + assert_eq!(buf[((2 * 40) + 5) * 4 + 3], 0); + assert_eq!(buf[((3 * 40) + 25) * 4 + 3], 0); + } +} diff --git a/desktop/marketing/make_screens.py b/desktop/marketing/make_screens.py new file mode 100644 index 0000000..feaa809 --- /dev/null +++ b/desktop/marketing/make_screens.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Build TinyHand feature screenshots from the app's real parts. + +* pointer, ink and laser: rendered by tinyhand-core (examples/scene.rs) +* toolbar and Settings: the app's own ui/*.html, rendered by Chrome with a + mocked Tauri bridge +* backgrounds: sample lesson slides in marketing/slides + +Usage: python3 marketing/make_screens.py +Needs: cargo, Google Chrome, Pillow. +""" +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +from PIL import Image + +ROOT = Path(__file__).resolve().parent.parent # desktop/ +REPO = ROOT.parent +UI = ROOT / "ui" +SLIDES = Path(__file__).resolve().parent / "slides" +CHROME = os.environ.get("CHROME", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome") +W, H = 1600, 1000 + + +def chrome(*args): + subprocess.run([CHROME, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--allow-file-access-from-files", + "--virtual-time-budget=4000", *args], check=True, capture_output=True) + + +def screenshot(url, out, size, scale=1, transparent=False): + extra = ["--default-background-color=00000000"] if transparent else [] + chrome(f"--window-size={size[0]},{size[1]}", f"--force-device-scale-factor={scale}", *extra, f"--screenshot={out}", url) + + +def measure(url, ids, size=(W, H)): + """Bounding boxes of elements by id, in CSS px.""" + html = Path(url[len("file://"):]).read_text() + probe = ("" % json.dumps(ids)) + tmp = Path(url[len("file://"):]).with_suffix(".probe.html") + tmp.write_text(html.replace("", probe + "")) + out = subprocess.run([CHROME, "--headless=new", "--allow-file-access-from-files", "--virtual-time-budget=4000", + f"--window-size={size[0]},{size[1]}", "--dump-dom", f"file://{tmp}"], capture_output=True, text=True).stdout + tmp.unlink() + raw = re.search(r"(.*?)", out, re.S).group(1) + return json.loads(raw.replace(""", '"')) + + +def cargo_example(name): + subprocess.run(["cargo", "build", "-q", "--release", "-p", "tinyhand-core", "--example", name], cwd=ROOT, check=True) + return ROOT / "target" / "release" / "examples" / name + + +def scene(binary, script, out): + subprocess.run([str(binary)], input=script, text=True, check=True, cwd=ROOT) + return Image.open(out).convert("RGBA") + + +TAURI_MOCK = """ +""" + + +def ui_page(work, name, state, extra=""): + src = (UI / name).read_text() + src = src.replace('', TAURI_MOCK % json.dumps(state) + '', 1) + src = src.replace("", extra + "") + page = work / ("mock_" + name) + page.write_text(src) + return page + + +def state_for(lang, style="lavender"): + catalog = (ROOT / "core" / "src" / "catalog.rs").read_text() + hands = re.findall(r'h\("(\w+)", "([^"]+)", (\w+)\)', catalog) + styles = [{"id": i, "title": t, "ink": "#8754f2"} for i, t, _ in hands] + [{"id": "laser", "title": "Laser dot", "ink": "#ff291f"}] + pinned = ["lavender", "glove", "coral", "plush", "robot", "laser"] + return { + "active": True, + "prefs": {"style": style, "pinned": pinned, "pointerSize": 0.13, "showShadow": True, "fadeSeconds": 3, "handSound": True, + "laserSound": False, "tapSound": "thock", "interactKey": "alt", "ink": "match", "welcomed": True, "language": lang}, + "styles": styles, "defaultPinned": pinned, + "sounds": [["thock", "Thock"], ["click", "Click"], ["pop", "Pop"], ["wood", "Wood block"], ["boop", "Boop"], ["squeak", "Squeak"]], + "inks": [["match", "Match the pointer", None], ["red", "Red", "#ff291f"], ["orange", "Orange", "#ff8a00"], ["yellow", "Yellow", "#ffcc00"], + ["green", "Green", "#30c25a"], ["blue", "Blue", "#0a84ff"], ["purple", "Purple", "#8754f2"], ["white", "White", "#ffffff"], + ["black", "Black", "#1d1d21"]], + "launchAtLogin": False, "version": "0.1.0", "build": "", "platformReady": True, + } + + +def crop_alpha(img): + return img.crop(img.getbbox()) + + +def save(img, out): + img.convert("RGB").save(out, "WEBP", quality=88, method=6) + print("wrote", out, img.size) + + +def main(): + out_dir = Path(sys.argv[1] if len(sys.argv) > 1 else "screens").resolve() + out_dir.mkdir(parents=True, exist_ok=True) + work = Path(tempfile.mkdtemp(prefix="tinyhand-screens-")) + try: + scene_bin = cargo_example("scene") + thumbs_bin = cargo_example("thumbs") + (work / "thumbs").mkdir() + subprocess.run([str(thumbs_bin), str(work / "thumbs"), "128"], check=True, cwd=ROOT) + for f in ("i18n.js", "icon.png"): + shutil.copy(UI / f, work / f) + + def slide(name, scale=1): + shot = work / f"{name}@{scale}.png" + screenshot(f"file://{SLIDES / (name + '.html')}", shot, (W, H), scale=scale) + return Image.open(shot).convert("RGBA") + + def overlay(script, base, scale=1): + ov = scene(scene_bin, f"screen {W * scale} {H * scale}\n{script}\nrender {work / 'ov.png'}\n", work / "ov.png") + img = base.copy() + img.alpha_composite(ov) + return img + + # 1. Point: the hand rests on the unknown in the exercise. + r = measure(f"file://{SLIDES / 'math.html'}", ["q"])["q"] + q = (r[0] + r[2] * 0.45, r[1] + r[3] * 0.62) + save(overlay(f"hand lavender\nsize 0.15\nmove {q[0]:.0f} {q[1]:.0f}", slide("math")), out_dir / "point.webp") + + # 2. Tap: pressed on answer C. + r = measure(f"file://{SLIDES / 'quiz.html'}", ["c"])["c"] + c = (r[0] + r[2] * 0.8, r[1] + r[3] * 0.5) + save(overlay(f"hand coral\nsize 0.15\nmove {c[0]:.0f} {c[1]:.0f}\npress", slide("quiz")), out_dir / "tap.webp") + + # 3. Draw: circle one word, underline another. + m = measure(f"file://{SLIDES / 'english.html'}", ["elephant", "monkey"]) + e, k = m["elephant"], m["monkey"] + loop = f"loop {e[0] + e[2] / 2:.0f} {e[1] + e[3] / 2:.0f} {e[2] / 2 + 8:.0f} {e[3] / 2 + 3:.0f}" + y = k[1] + k[3] * 0.92 + underline = f"draw {k[0] - 4:.0f} {y:.0f} {k[0] + k[2] * 0.5:.0f} {y + 3:.0f} {k[0] + k[2] + 6:.0f} {y - 2:.0f}" + save(overlay(f"hand plush\nink e8590c\nsize 0.15\n{loop}\n{underline}\nmove {k[0] + k[2] + 40:.0f} {y + 40:.0f}", slide("english")), + out_dir / "draw.webp") + + # 4. Laser dot on the tallest bar. The dot is small on a real screen, + # so render at 2x and crop around it, like zooming in. + r = measure(f"file://{SLIDES / 'chart.html'}", ["topval"])["topval"] + dot = (r[0] + r[2] / 2 + 34, r[1] + r[3] * 0.35) + # size 0.20 is the largest the Settings slider allows. + big = overlay(f"laser\nsize 0.20\nmove {dot[0] * 2:.0f} {dot[1] * 2:.0f}", slide("chart", 2), 2) + x0 = int(min(max(dot[0] * 2 - W * 0.62, 0), W)) + y0 = int(min(max(dot[1] * 2 - H * 0.24, 0), H)) + save(big.crop((x0, y0, x0 + W, y0 + H)), out_dir / "laser.webp") + + # 5. Toolbar: the real toolbar at the bottom; the laser dot hovers the + # robot hand, about to switch to it (a hand would cover the bar). + tb_state = state_for("vi", "laser") + tb_page = ui_page(work, "toolbar.html", tb_state, + "") + screenshot(f"file://{tb_page}", work / "toolbar.png", (700, 200), scale=2, transparent=True) + dom = subprocess.run([CHROME, "--headless=new", "--allow-file-access-from-files", "--virtual-time-budget=4000", + "--window-size=700,200", "--dump-dom", f"file://{tb_page}"], capture_output=True, text=True).stdout + btn = [v * 2 for v in json.loads(re.search(r"(.*?)", dom).group(1))] + tb_full = Image.open(work / "toolbar.png").convert("RGBA") + box = tb_full.getbbox() + tb = tb_full.crop(box) + # Everything at 2x, then crop the bottom of the screen: a close-up. + base = slide("english", 2) + tx, ty = (W * 2 - tb.width) // 2, H * 2 - tb.height - 56 + base.alpha_composite(tb, (tx, ty)) + bx = tx + (btn[0] - box[0]) + btn[2] * 0.5 + by = ty + (btn[1] - box[1]) + btn[3] * 0.45 + big = overlay(f"laser\nsize 0.20\nmove {bx:.0f} {by:.0f}", base, 2) + # Close-up around the bar, 16:10, scaled to the common width. + cw = int(tb.width * 1.3) + ch = cw * H // W + x0 = tx + tb.width // 2 - cw // 2 + y0 = min(ty + tb.height + 40, H * 2) - ch + save(big.crop((x0, y0, x0 + cw, y0 + ch)).resize((W, H), Image.LANCZOS), out_dir / "toolbar.webp") + + # 6. Settings, Pointer section, in each language. + for lang in ("vi", "en"): + st = state_for(lang, "coral") + page = ui_page(work, "settings.html", st, + "") + shot = work / f"settings_{lang}.png" + screenshot(f"file://{page}", shot, (640, 1300), scale=2) + dom = subprocess.run([CHROME, "--headless=new", "--allow-file-access-from-files", "--virtual-time-budget=4000", + "--window-size=640,1300", "--dump-dom", f"file://{page}"], capture_output=True, text=True).stdout + s = json.loads(re.search(r"(.*?)", dom).group(1)) + img = Image.open(shot).convert("RGBA") + pad = 16 + top = max(0, s[1] - pad) + img = img.crop((0, int(top * 2), img.width, int(min(s[1] + 640, 1300) * 2))) + save(img, out_dir / f"settings-{lang}.webp") + finally: + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == "__main__": + main() diff --git a/desktop/marketing/slides/base.css b/desktop/marketing/slides/base.css new file mode 100644 index 0000000..0b08e29 --- /dev/null +++ b/desktop/marketing/slides/base.css @@ -0,0 +1,8 @@ +/* Sample lesson slides used as backgrounds for TinyHand screenshots (1600×1000). */ +* { box-sizing: border-box; } +html, body { margin: 0; width: 1600px; height: 1000px; overflow: hidden; } +body { font-family: "Be Vietnam Pro", "Segoe UI", system-ui, sans-serif; color: #1f1d2b; background: #f4f2fb; } +.slide { position: absolute; inset: 0; background: linear-gradient(160deg, #ffffff 0%, #fbfaff 60%, #f3efff 100%); } +.slide::after { content: ""; position: absolute; right: -140px; bottom: -160px; width: 520px; height: 520px; border-radius: 50%; background: rgba(135, 84, 242, .07); } +h1 { position: absolute; left: 120px; top: 92px; margin: 0; font-size: 64px; font-weight: 800; letter-spacing: -1px; } +.tag { position: absolute; left: 124px; top: 56px; font-size: 22px; font-weight: 700; color: #8754f2; letter-spacing: 2px; text-transform: uppercase; } diff --git a/desktop/marketing/slides/chart.html b/desktop/marketing/slides/chart.html new file mode 100644 index 0000000..0c7bb49 --- /dev/null +++ b/desktop/marketing/slides/chart.html @@ -0,0 +1,15 @@ +
+
Sinh hoạt lớp
+

Số bạn đạt điểm giỏi theo tháng

+
+
11
Th 9
+
15
Th 10
+
18
Th 11
+
23
Th 12
+
+
diff --git a/desktop/marketing/slides/english.html b/desktop/marketing/slides/english.html new file mode 100644 index 0000000..57b69f1 --- /dev/null +++ b/desktop/marketing/slides/english.html @@ -0,0 +1,10 @@ +
+
Tiếng Anh · Unit 5
+

Comparatives: bigger, smaller

+
The elephant is much bigger than the monkey.
+
big → bigger
small → smaller
tall → taller
+
diff --git a/desktop/marketing/slides/math.html b/desktop/marketing/slides/math.html new file mode 100644 index 0000000..987d3cf --- /dev/null +++ b/desktop/marketing/slides/math.html @@ -0,0 +1,15 @@ +
+
Toán lớp 3
+

Bài 3: Bảng nhân 7

+
a) 7 × 3 = 21
+
b) 7 × 6 = 42
+
c) 7 × 8 = ?
+
d) 7 × 9 = 63
+
Mẹo nhớ5, 6, 7, 8
→ 56 = 7 × 8
+
diff --git a/desktop/marketing/slides/quiz.html b/desktop/marketing/slides/quiz.html new file mode 100644 index 0000000..2ea33e3 --- /dev/null +++ b/desktop/marketing/slides/quiz.html @@ -0,0 +1,11 @@ +
+
Câu hỏi nhanh
+

Thủ đô của Việt Nam là thành phố nào?

+
AHuế
+
BĐà Nẵng
+
CHà Nội
+
DTP. Hồ Chí Minh
+
diff --git a/desktop/ui/i18n.js b/desktop/ui/i18n.js new file mode 100644 index 0000000..f68c895 --- /dev/null +++ b/desktop/ui/i18n.js @@ -0,0 +1,204 @@ +// UI strings for the toolbar and Settings. The tray menu lives in +// app/src/i18n.rs. To add a language: add it to LANGUAGES, give it a table in +// STRINGS (missing keys fall back to English), and add it to i18n.rs. +(function () { + const LANGUAGES = [ + ['en', 'English'], + ['vi', 'Tiếng Việt'], + ]; + + const STRINGS = { + en: { + tagline: 'A tiny hand on a stick that points at things on your screen.', + powerOn: 'Turn pointer on', + powerOff: 'Turn pointer off', + unsupported: "The pointer overlay isn't available on this platform yet. Settings still work.", + welcomeHtml: + '

Welcome! TinyHand lives in the system tray, next to the clock.

    ' + + '
  • Click the tray icon or press Ctrl+Alt+H to turn the pointer on or off.
  • ' + + '
  • Click to tap. Nothing underneath gets clicked. Hold and drag to draw.
  • ' + + '
  • Hold {key} to click things for real. Esc turns the pointer off.
', + gotIt: 'Got it', + pointer: 'Pointer', + inToolbar: 'In your toolbar', + count: '{n} of {max}', + reset: 'Reset', + stripLabel: 'Pointers in your toolbar', + stripHint: 'Drag to reorder. Hover and click × to remove. Click one to use it now.', + allPointers: 'All pointers', + size: 'Size', + shadow: 'Shadow', + taps: 'Taps', + handSound: 'Sound when a hand taps', + laserSound: 'Sound when the laser dot taps', + tapSound: 'Tap sound', + play: 'Play', + interact: 'Hold to click for real', + interactHint: "While the pointer is on, apps never see this key, so {key}+click is a plain click (no Chrome download). Shortcuts like {key}+Tab still work.", + off: 'Off', + drawing: 'Drawing', + inkColour: 'Ink colour', + fadeAfter: 'Drawings fade after', + sec1: '1 second', + sec3: '3 seconds', + sec5: '5 seconds', + sec10: '10 seconds', + never: 'Never (clear with Ctrl+Alt+X)', + general: 'General', + language: 'Language', + login: 'Start TinyHand when I sign in', + keyToggle: 'Turn the pointer on or off', + keyToggleHtml: 'Ctrl+Alt+H or click the tray icon', + keyClear: 'Clear the drawing', + keyOff: 'Turn the pointer off', + resetAll: 'Reset all settings', + build: 'build', + full: 'The toolbar is full. Remove one above to add {name}.', + keepOne: 'Keep at least one pointer in the toolbar.', + slotTitle: '{name} (Ctrl+← / Ctrl+→ to move)', + removeName: 'Remove {name}', + removeFromBar: 'Remove {name} from the toolbar', + addToBar: 'Add {name} to the toolbar', + useName: 'Use {name}', + inBadge: '✓ In toolbar', + removeShort: 'Remove', + addShort: '+ Add to toolbar', + inkTitle: 'Ink colour', + soundOnTitle: 'Tap sound on — click to mute', + soundOffTitle: 'Tap sound off — click to unmute', + exitTitle: 'Exit (Esc)', + pickTitle: 'Choose your language', + pickHint: 'You can change this later in Settings.', + }, + vi: { + tagline: 'Một bàn tay nhỏ trên que, chỉ vào mọi thứ trên màn hình của bạn.', + powerOn: 'Bật con trỏ', + powerOff: 'Tắt con trỏ', + unsupported: 'Lớp con trỏ chưa có trên hệ điều hành này. Phần cài đặt vẫn dùng được.', + welcomeHtml: + '

Chào mừng bạn! TinyHand nằm ở khay hệ thống, cạnh đồng hồ.

    ' + + '
  • Bấm biểu tượng ở khay hoặc nhấn Ctrl+Alt+H để bật hoặc tắt con trỏ.
  • ' + + '
  • Bấm để chạm: những gì bên dưới sẽ không bị bấm. Giữ và kéo để vẽ.
  • ' + + '
  • Giữ {key} để bấm thật. Nhấn Esc để tắt con trỏ.
', + gotIt: 'Đã hiểu', + pointer: 'Con trỏ', + inToolbar: 'Trên thanh công cụ', + count: '{n}/{max}', + reset: 'Đặt lại', + stripLabel: 'Các con trỏ trên thanh công cụ', + stripHint: 'Kéo để sắp xếp. Rê chuột rồi bấm × để bỏ. Bấm vào một con trỏ để dùng ngay.', + allPointers: 'Tất cả con trỏ', + size: 'Kích thước', + shadow: 'Bóng đổ', + taps: 'Chạm', + handSound: 'Phát âm thanh khi bàn tay chạm', + laserSound: 'Phát âm thanh khi chấm laser chạm', + tapSound: 'Âm thanh chạm', + play: 'Nghe thử', + interact: 'Giữ phím này để bấm thật', + interactHint: 'Khi con trỏ đang bật, ứng dụng không nhận được phím này, nên {key}+click chỉ là một cú click bình thường (Chrome sẽ không tải xuống). Các phím tắt như {key}+Tab vẫn dùng được.', + off: 'Tắt', + drawing: 'Vẽ', + inkColour: 'Màu mực', + fadeAfter: 'Nét vẽ mờ dần sau', + sec1: '1 giây', + sec3: '3 giây', + sec5: '5 giây', + sec10: '10 giây', + never: 'Không mờ (xóa bằng Ctrl+Alt+X)', + general: 'Chung', + language: 'Ngôn ngữ', + login: 'Mở TinyHand khi đăng nhập', + keyToggle: 'Bật hoặc tắt con trỏ', + keyToggleHtml: 'Ctrl+Alt+H hoặc bấm biểu tượng ở khay', + keyClear: 'Xóa nét vẽ', + keyOff: 'Tắt con trỏ', + resetAll: 'Đặt lại mọi cài đặt', + build: 'bản dựng', + full: 'Thanh công cụ đã đầy. Hãy bỏ bớt một con trỏ ở trên để thêm {name}.', + keepOne: 'Cần giữ ít nhất một con trỏ trên thanh công cụ.', + slotTitle: '{name} (Ctrl+← / Ctrl+→ để di chuyển)', + removeName: 'Bỏ {name}', + removeFromBar: 'Bỏ {name} khỏi thanh công cụ', + addToBar: 'Thêm {name} vào thanh công cụ', + useName: 'Dùng {name}', + inBadge: '✓ Đã thêm', + removeShort: 'Bỏ', + addShort: '+ Thêm vào thanh', + inkTitle: 'Màu mực', + soundOnTitle: 'Đang bật âm thanh chạm — bấm để tắt', + soundOffTitle: 'Đang tắt âm thanh chạm — bấm để bật', + exitTitle: 'Thoát (Esc)', + pickTitle: 'Chọn ngôn ngữ', + pickHint: 'Bạn có thể đổi lại trong phần Cài đặt.', + }, + }; + + const HANDS = { + vi: { + lavender: 'Oải hương', glove: 'Găng tay con trỏ', coral: 'San hô', plush: 'Thú bông', robot: 'Rô-bốt', + pink: 'Kẹo cao su', blue: 'Bầu trời', mint: 'Bạc hà', lemon: 'Chanh vàng', black: 'Nửa đêm', + chrome: 'Crôm', holo: 'Ánh hologram', fuzzy: 'Lông xù', wood: 'Gỗ', marble: 'Cẩm thạch', + terrazzo: 'Đá mài', glass: 'Thủy tinh', jelly: 'Thạch', pastel: 'Pastel', pixel: 'Điểm ảnh', + lowpoly: 'Đa giác', knit: 'Len đan', skeleton: 'Bộ xương', astro: 'Phi hành gia', space: 'Vũ trụ', + monster: 'Quái vật', water: 'Nước', lava: 'Dung nham', grass: 'Cỏ', cactus: 'Xương rồng', + choco: 'Sô-cô-la', cloud: 'Mây', mech: 'Người máy', neon: 'Đèn neon', leaf: 'Lá cây', + cookie: 'Bánh gừng', laser: 'Chấm laser', + }, + }; + + const INKS = { + vi: { + match: 'Theo con trỏ', red: 'Đỏ', orange: 'Cam', yellow: 'Vàng', green: 'Xanh lá', blue: 'Xanh dương', + purple: 'Tím', white: 'Trắng', black: 'Đen', + }, + }; + + const SOUNDS = { + vi: { thock: 'Cộp', click: 'Tách', pop: 'Bốp', wood: 'Mõ gỗ', boop: 'Bíp', squeak: 'Chít' }, + }; + + /** Best guess before the user has chosen: the system language. */ + function guess() { + const langs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || 'en']; + for (const l of langs) { + const code = String(l).toLowerCase().split('-')[0]; + if (LANGUAGES.some(([c]) => c === code)) return code; + } + return 'en'; + } + + function lang(prefs) { + const l = prefs && prefs.language; + return l && STRINGS[l] ? l : guess(); + } + + function t(l, key, vars) { + let s = (STRINGS[l] && STRINGS[l][key]) ?? STRINGS.en[key] ?? key; + for (const [k, v] of Object.entries(vars || {})) s = s.split('{' + k + '}').join(v); + return s; + } + + /** Fill [data-i18n] (text), [data-i18n-html] (markup), [data-i18n-title] and [data-i18n-aria]. */ + function apply(l, root, vars) { + root = root || document; + document.documentElement.lang = l; + root.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(l, el.dataset.i18n, vars); }); + root.querySelectorAll('[data-i18n-html]').forEach(el => { el.innerHTML = t(l, el.dataset.i18nHtml, vars); }); + root.querySelectorAll('[data-i18n-title]').forEach(el => { el.title = t(l, el.dataset.i18nTitle, vars); }); + root.querySelectorAll('[data-i18n-aria]').forEach(el => { el.setAttribute('aria-label', t(l, el.dataset.i18nAria, vars)); }); + } + + const pick = (table, l, id, fallback) => (table[l] && table[l][id]) || fallback; + + window.I18n = { + LANGUAGES, + guess, + lang, + t, + apply, + hand: (l, id, fallback) => pick(HANDS, l, id, fallback), + ink: (l, id, fallback) => pick(INKS, l, id, fallback), + sound: (l, id, fallback) => pick(SOUNDS, l, id, fallback), + }; +})(); diff --git a/desktop/ui/icon.png b/desktop/ui/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7395d8c732abc2818f19fddd2e018e1d73ff55fc GIT binary patch literal 9125 zcmV;WBU;>vP)x4xa6$G?1EaMa3JVBICmJdZonNB`Y z#PJF9h6gjKh$y0I1Qlh~&<%uykbUWFWa;i~y;pTrcP;ncbKdtq_ug7Mi>d0WY97=t zslIh;xpnS2|MTDf=K>W}P(cM1R8T<$6;x0`1r=0KK?N05P{I2j4DT&4Aq18!TgH|x zTZY!wR@uMF+b1(D&ht!d`FyM`?~b0z?{+kF2s+N6KVKe~FJCT?0G#6on*cgPrL7GLQOn}RizoED%T5472&Jv9O_5<*ORGv$&@}bl}t65rcuRB?qXozTo7g0 zgUrEA6P%7h5W%D$#8k7}IS=kU=MXe`bj*5>%h$&|w|~<*(tGxA$_cm5mHWDVKC2`0 zJxLsb`fbv49YPEXpcg==;9>`3$_Az>?>zp8|J>z_Qwv&@j^+;5+S+QqF9?9x9UYyC z-?@J0IsF5J%_Gs+Cu7l(g+|KMV#6^+!VwHdhA|QwK|G#7DwTqv>)bRScX)?hDqk$Gh>>Eu)7O7;vf%Hpb-YO>S(^{0m zG8kN$D5m*)lzbUl3X&$^48W?W$DtT z=H|7V=SBvi_YVz(uQ}3v1iN2GQOHa_)_OT1l zS-AEpOuv8JHjepf`H=3UAVLrdQ@Ez7n&0P#s!Hzg99NZZKK1hByKExhrRZYO34kWl zo=CC!)z!B~hK3(Fa;PV?Y27A%u!DM%$al{&x?k@zFS=iFY`71*ZU#bPU4fa+y`3YE3}AlGH~&)7st|bH*C) z6#}q3B(`na7U=67e4*!H&&99*<#oMlf49rfbttsrD-&Pv1gV;)SBI)yVB(FW9{5CS z`y(UvTjM4IX`+o+<}X;VfRs74Z~wrvJ>5MQx4+t^ckb(SnT7#HRV$M}4$!(W4Z{_Q zhII&bvD+1Uwxy{>ZEkL6#aO`OCV;lKHkGvf_CLM;*ihfl<*%=NUGF;3?UITf@vzF$ zFA@=$u1GYZD^M@rqwRZ)Ov9$8CUrsyfV_L;(tYFQRX2}BN5AmqYpe9G{ar58Ff9Ql zJ#>`^iZBK=mWEE8Afjtb`@#*Ee*Wh6_I86z#PQ4_8;?()oEqES+%~)W;Gr$+)~xfa zU$a4p#}iWFtML8OfZ_;VuaA4Z-lWUJ&Ux~sC-=3qw6H>z!h$&fg=!g?INIO);E_W| z{T*9(iqVm{^Z_`o+R8%|cMPl`7fU1(f^+UqCd>zEEp~Kt6ugj?LRmk_{k5;JZ5kOK zxpLF`P3BPlpytSY>ZsLen}b!_D9(7$!U9ZgYOz?!)a2MzD;H#8lcC&v-b&Cl2U_PC5Zpu6z?fyOVU1FIXY6 zpS*n=)@+!+e@Ew*wpZGf9ou%k*D{Mf5OBNPA`l30w@W$asg+N46yhMtn*&fX2Z^Gg zzQ_%+NK8F^@Q7iWCIz)BCSMtl9oIB;-GqRq>*fsrzU|CSNp*n5OBPe^i$b!$tFs%? z;bBDvwJ1%dV)CKXV~|OJ5iq!^BvVNkoL@<+zrDRZ@366wcd!RcY+SQ(&gf`-{=x1; zNTqZ|2DvL*KgvA@5JD1w;9~yn*LKg5X_l}Oa7sb|ix)4JpN|a0E;3TOHrO|4%9W3? zog15;RcVV7jfY_vrot30#l=N5H*HN;l5I(?fJ7p`XgCtZNGxU(qSdJk#ww(hcB#gL zn_Q;KF<=pZUm{QBMF5n#O~+I+H9tHUMlzX{Ite93RxkmORw8GHsY7Afe0phd`(oaX z_R^Yx&ph#L$GTPP=dO8U4b45dgexdBvQpT%-{)669`~+uuUI(0WM-h9>Lm^wI8ddh z^!ivdCf$-UrRz9Zwtp?>uF}T1#v|)FCDRfYuHV#lpb9`ZpA^SaRv4&Ui;g{Ak=j&B zua3v#c7D921Mo4XN-5hi?>@^<>f{z?9FUp!9h2$A-xp=?e=*wG&m{qvyxPr2Yw7Y^ z&~nN=IUfR$MIdzQOrN* zL-?2bZpNImPM6#unM_GeFwh^y(L?>%|L#HT-QA7O{fBY%a38{hQRs#tD|3ioTyB@- z5}B1Xwn}6SVVCLp@@vaVDLEI0q5F|1PMH9Y9>{ozM&Qi&iRl`MkO1WL_>e?5cciq#`djyv199A z?A_gko+AU$lR7lF3tmYO)^Jcnlek0LNXmA=m-zbfZrj{Uc2l5+j=jCTtmp(l<@w7g zH%*uxQaVi2qB<-7Uj{#by(krxAr>9Qwbx&c8D}(NurCakD{~nXBGprdwK5#bfB0MZK5>aXI}TvuJ3HiK&(2N^4n&|Z1zx`g9=FRX#gMg#S^apgnY6gFb_j{%OkV6cArM$3Q zM|=M5L=gdn3t!0zfD0a?q)*ZVoEU(WqmUs2>Ru_@^1ovJ=0N{YUW-u@hMM^XDTsP> z)EYQN!L-vFF#F8YaP4)Mqvz-VHodb0uf6g%-dVj3M-TT)T_F(gNp2&p2`XE6inX%1 zb1AR?MD2_Yu7t|K0ZgwY0?aYW6LrQJU|!U=)Yk2lO`L~T{kyxmKqXYPnhU|$V$@Vp zMI*))eOI@St3UUHcD9jh`mNG_B8# z2aGYOHisY)7YXa~`J}pPTz%c;xT^UQX;G|vX$@Ze(<8|VX^XDGGqrYgum7iTA`$85*Vl*K+f+HS| ziQkuCe5}_pPBEo`9N+vo0Hu6OYR}D7J+3eucbkDKiqD=iS59U5=wvj=;B4V_>v3o?(QzU_`BEf!tdIpnHdZPq=>S( zK~|qiGCq!JY@+~aSO5pGiNK!qw0qW}&>|e2tEAN)^as$`&?v>gq?$?gXGkm<*uG^C z7!{$E!wiK(3|97aY?Gg{z(*k@$Sz_UF80I{|nW%RgzQY*lVmfiAS;_9WBVftxP zkccO24w>PWj=of^adO7rB89vA1VHiYRMK zyV8tLjt?fI1mul8zG9Jpvoq!~tH0Q04teO90!lm~A|O#I2)J#KVt-7D?ziP8fE1z`*mlN#&r@^4q-yWf5UKED^-bhPs^ zlUA(paoRZ|Apid7{S|`17NY=tP34yo16^U)IJa%lu8b*dcV(y`nt)|K}3q+ z;X}RHv-1EvUQgKxz%;GIl$!b)Jn?WVZoc7LGSWziCzQU@3I~ z2hIk{8BNrpM5|AZ20#9`L*2yM1_lS@|L^*)#}6oY>!~tD>#m}W82dC zN!|~IhcKELl^aeGAxxdp2u;&y^CY?2R==?csd%y=EjV9LpXfJh8zy1Xx*fRo<6pr~ zf6|JYsw%5I-u7TRF@YRTS)}pFw6o@*KtF&s9vwjH0k(Z>56{@)8h4vk{K)7C!jUkh zH%^nXzpl0pHB~hjicnM} z(Io1tYSUJWOh!#dF^Lv3Y`PTVlcNNdMFgb%09h=pgbx}_QUv=4`y~gU-}idG7BQq! zlI8aI4`RdG?K02LxnDpjAh>|f=f(OpTk-Ategt6(RE2^tNIcn-;PC#m%luI3^uN?OW?BVueFpb%(?A$fGM`-FP-&casi4p z9%I@}?yNt7B4F$)&9-}s(zbHmp(?7>0m8$PbTFtYR3)Q?L=+y68=YN;uyJh%d;z+f zj3_xL80sCu=fC_}Jn+!hFg!AnUIZD>pR+JZI6k&U=n$SW|M}css{j#TNRM3ZW=b(WIZ9wz z`~Xr3q`ir9SzPHJme?VGz>%J#v?qaFtVm35ON$P z(4k02*cG##6Y&B=*TF71sAPMS9#CIbkLgpVBcUhY^?0zis}pa(xk-wFQ?&*J7c#xv zx}%9Gm*C%=P5A6bCmxK*>5!v%^sKACYTLqdp?5t1DZ%yd8&4A_*3qPDFX z2zt)t%0F`S2x6lnw%?s%1uEvwISVGNE%FSQc;WZ$c7DKLpG?k$^dRopbwFMhrFRud z1k@+W1PWCLaNiH_lI9}CE3E6v!~t>>LL7>8;zFfpTN6blU^p7V zk)9(~J%F7rF#C+z2)Y8&HSA|zw7vQ^4s{)s{(+NCrjWXYHEGvfeLKE!*S{eU45Z&H z)G~-hMsV&0^RRHQZaRrxD3AAp7n%#-=Jgfz63KRPd z>`S{}opkZjPn(H2kHh0~qif(W{_@gW@CAKxRi6l^VIo*tg@6CS6WD!ZKYsS~Gq~&4 z?;unal4ZaaOf zi(fEzzU71={QwNl{rXjzoN=O5F(Trsnh@5l+K7Muk0(&AR-w*SgNGmg1@8XJ_aqU} zcMAzFS({bpvci~b^W5<&@}jW`%5r^|l><-+ru5Oku@A=_?CI;V!~mnx?-XXuoQ1kT z9pXk30p`b=w>M(@rrlP0)|iz{=OKL}9#7)V+rEo0CneXQQP0o7{A zx;wd`-L?cKYm@NUFMf?%KL2fLPLgNQDu+KgsDFxGPx#%IhoMXVgcGnS!Rk+Q$AYJ` zfV6Oj1Ee%JzVZdMiY$v5Ms zlCY|VN%&sNBWT~S7S(Fd8Y?GhCQA$yWqii?Ny?HIP8B-V#)L!x8HbP+tf1FWlD2cv z^t&$RLeF4Njx|7HU`FF~%xIi}Q67g^^`PtMVSM4nd$4QgeguPlS;|d2i+$^k?_v4< z{|7Z{6&PoTi#X=YoP#gjbPLX$c_v~)Zh|Sr!Lu*EjL&@f%ZLt-AQ1HDa1#fTdzdCB zO>4l9AN&Qr|ImM;RwbgNUC7eOVQDUq2@Z{Ke8zGCr7ZaXjw?7#$u@PTt?mpA)D>UE(<>gr>@!ct z;6T{gf6(4GhI9pw&x;@3_bC45-hV?t@g2Lfo4sO>-A!bi<5MmXkP{3Vvp-Nd9}XAL zph+=6g(D;eh$tvzeD<8PvE=+Ei13Jv5mad*=_Y<{^%}g;)`p(`0o1A0Qac~vF-!_g z!VRCkUZ$JV_vr_kKlNE$IByZ6d{}1ukeJxAV<$fG@&AccZ>*O!05x^hsF_ra+9`GD z?mmJWue%+0-}^oI6(1B~TPIoWSv!FHFU7^d!#%j_iZ7yh=_hc}fBT5!IGZ=^z^{MvM;saML!DY9 z^8^XR`j6c*rr6`)B%j(G5ar4WJ_U`q)Q~=Q!LfbUHt7nMtk7o5$y5rLee_bCdB&Mo zyLBx(yE|pJFl{4CAVQ((2>ODksjim#gEL)@)=q@<$xA*VtC3$=^&(uR3nA5ylxg56 zPp`m_pZ-l+vWF1BWVP0o`NUhS=`S=ubLz-AfeK9sDTD(EYZJwZ1<77o*?acCi=p8# z0zQA*UYCWV@lni}N=o51a)4f2)rOVttb~vGdWJwTlIQfJl z@ewrrZIdiT`~Av4AZoERER90$g~$}Bl!Klp z@+KJpk;t_@CkE65m*PS=8OGlI@6s*QavV0K7m&A*Rw!8utGBF{V#Db>9YabG+YfBV zuYUKm)Z$&5EB74Cs!2Hi!{_6cf4mtB=PU$MftVh{upX5Ua{Q2)=&XEa{S!*f0j!<+ zsdmEJ43jC0l$a846KC?Mv^z`A-cAnr>*Udch7H>`;{1i@r;{)o?cC?}Ar_0_SATpO zqsdXZ>ZKK7(>>=L0VRN)-8=E~XP?B4SKR==&o2{AoxlnmiJLUkH{ixAuSd^7FS-tQ zBQ_F+rfAsQu?2nMK1o~|j$GccAws2os;U~z`@8vmL6~OTQ933XWD9$k2c12gc;$^( z;c}IXutg2@d%f5j-V1m=h065^qI$HwGk1H6YwI?;Oe?A z>i}q*n!mr`qqyYnE=6=Sih<|=G^S0+kx@Y;J4*|Zqj6Csw5|10rU>`;wP<-r;NP^?09+qCf z37>ukbSFarAM;}0;RD!xc#pl6stGqdQt7i2W^y%ir~Ax)M%UzHe#zQ@_RJG#x~K`~ z&ijbuB*bDV3u-Vjh_##F!J5r$UxR?ogc~ z3d_dN`1u@re*!sx4UfkIm!?JP>+6T8y=?h1Q8EHpeD%4*LWq9bPM&y=P>H}!N=_5b z{L@gFB78m{iGlu`?zm~#?t4iHKz?4{nNo;Og5S|?+S z(;eRPIY(X}ZbFl_>^bkSGjZP61n~a@TyB>LhJv(XT8I5o=?K7q0AFtfpC)*B#t$$U zMJ*xV^?4a_0L|krXp5A*2%sJ9GE%|}_uE`>L!qrZDwq&h-gt)U>T1>H(Tv*Ks<-X8 z@(R@!vVs}V!p^RJc4I1$JZCgM>g?`bK^Z}-UsE-H-Ua6=^Uj&O3({BLC1oEEV6)FS@|hFst81uxrQxi)&)jy~ZLvaa{^Oz86C;UW z5f-`PwZ@}@^M9t&t7Ix^a?UFr!;`?NNu-HanTk+#$ecZAmOAgl^HEb<{nN&IlW)D{ zmRs~CfOq^PELsaUhKWtF+G?t-}{QulL1Ttk~CCiNs0m>QgO zdOwq^{eA#D!RuqD%qbMNHjH3GQ#C<>o%)7)?TlG7(J*xis%xvCs||&|dFOq1?jlD| zF%-Uhx?B`Aqgl1QhQPwQ^DZ@od8cWbR}hz@eSGK^@SK|@9*8qI#E?nsEdV8`!Y|5i zJ9&DP!$)acl*vQM92C!=+)$4xjg6qdPN+KcQm7{M;62~D`!DqQ70oLO_VS$y%CY(! zOGC=74>z254mU+J=jJB`=L?x&n%rYY?yPNQ-I7jzTv7%-agHatf}DDv^x@>~^lLfo z<8Qlq!YpX|y1iAatkR|Oaylvsqk26$Qr8YLbGbSkJA@K;z2;aP;hZRXJVe5Q2m(z!d zEihbP9~!#pj+>~a;uvm5UOieIrC#x4G0_T`B!;X+lMh68Q46v6HYoPpoytDx? + + + + +TinyHand Settings + + + + +
+
+ +
+

TinyHand

+

+
+ +
+ + + + + +
+

+
+

+ + +
+
+

+ +

+
+
+ + +
+
+ + +
+
+ +
+

+
+ + +
+
+ + +
+
+ + + +
+
+ + +

+
+
+ +
+

+
+ +
+
+
+ + +
+
+ +
+

+
+ + +
+
+ + +
+ + + + +
Ctrl+Alt+X
Esc
+
+ +
+ + +
+
+ + + + diff --git a/desktop/ui/toolbar.html b/desktop/ui/toolbar.html new file mode 100644 index 0000000..9e977fd --- /dev/null +++ b/desktop/ui/toolbar.html @@ -0,0 +1,175 @@ + + + + +TinyHand toolbar + + + + +
+
+
+
+ + + +
+ + + + From 49646e53ffee163e56ffba24cba406c8f3788b7a Mon Sep 17 00:00:00 2001 From: phuc Date: Sat, 7 Nov 2026 10:13:24 +0700 Subject: [PATCH 2/3] Windows: fix a stuck Alt/Ctrl after turning the pointer on with the hotkey The keyboard hook swallowed the release of an interact key whose press it had never seen. Ctrl+Alt+H presses Alt before the hook exists and releases it after, so Windows kept Alt down and every key typed became a shortcut. Never swallow the release of a key the system considers down. Co-Authored-By: Claude Opus 5.5 --- desktop/app/src/platform/windows/hook.rs | 7 +- desktop/core/src/interact.rs | 87 +++++++++++++++++------- 2 files changed, 69 insertions(+), 25 deletions(-) diff --git a/desktop/app/src/platform/windows/hook.rs b/desktop/app/src/platform/windows/hook.rs index a8621f0..3b1d892 100644 --- a/desktop/app/src/platform/windows/hook.rs +++ b/desktop/app/src/platform/windows/hook.rs @@ -25,7 +25,7 @@ use windows::Win32::System::LibraryLoader::GetModuleHandleW; use windows::Win32::System::Threading::{GetCurrentThread, GetCurrentThreadId, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}; use tinyhand_core::interact::{InteractFilter, KeyAction}; use windows::Win32::UI::Input::KeyboardAndMouse::{ - SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, VIRTUAL_KEY, + GetAsyncKeyState, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_EXTENDEDKEY, VIRTUAL_KEY, }; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, GetMessageW, PeekMessageW, PostThreadMessageW, SetWindowsHookExW, UnhookWindowsHookEx, HC_ACTION, KBDLLHOOKSTRUCT, @@ -205,9 +205,12 @@ impl Shared { if target && down { HELD.with(|h| *h.borrow_mut() = Some(Held { vk: info.vkCode, scan: info.scanCode, extended })); } + // Windows updates this after the hook returns, so it still tells us + // whether the system had the key down before this event. + let system_down = target && unsafe { GetAsyncKeyState(info.vkCode as i32) } < 0; let action = FILTER.with(|f| { let mut f = f.borrow_mut(); - let a = f.key(target, down); + let a = f.key(target, down, system_down); self.interact_held.store(f.held(), Ordering::Relaxed); a }); diff --git a/desktop/core/src/interact.rs b/desktop/core/src/interact.rs index d0372a9..cd6300e 100644 --- a/desktop/core/src/interact.rs +++ b/desktop/core/src/interact.rs @@ -23,7 +23,9 @@ pub enum KeyAction { #[derive(Default, Debug)] pub struct InteractFilter { held: bool, - replayed: bool, + /// The system has this press, so it must also get the release: either we + /// replayed it, or it went down before the hook was there. + given: bool, } impl InteractFilter { @@ -38,28 +40,35 @@ impl InteractFilter { /// Decide what to do with a key event. `is_interact` says whether this /// key is the interact key (either side, e.g. left or right Alt). - pub fn key(&mut self, is_interact: bool, down: bool) -> KeyAction { + /// `system_down` says the system already considers this key pressed. + /// + /// Never swallow the release of a key the system thinks is down: it would + /// stay stuck, and everything typed afterwards becomes a shortcut. That is + /// what happens when Ctrl+Alt+H turns the pointer on: Alt went down before + /// the hook was installed and comes up after. + pub fn key(&mut self, is_interact: bool, down: bool, system_down: bool) -> KeyAction { if is_interact { if down { self.held = true; - // Auto-repeat after a replay can go through; apps already saw the press. - if self.replayed { + self.given |= system_down; + // Auto-repeat of a press the system has can go through. + if self.given { KeyAction::Pass } else { KeyAction::Swallow } } else { - let replayed = self.replayed; + let swallowed_press = self.held && !self.given && !system_down; self.held = false; - self.replayed = false; - if replayed { - KeyAction::Pass - } else { + self.given = false; + if swallowed_press { KeyAction::Swallow + } else { + KeyAction::Pass } } - } else if down && self.held && !self.replayed { - self.replayed = true; + } else if down && self.held && !self.given { + self.given = true; KeyAction::Replay } else { KeyAction::Pass @@ -75,31 +84,63 @@ mod tests { #[test] fn hold_alone_never_reaches_apps() { let mut f = InteractFilter::default(); - assert_eq!(f.key(true, true), Swallow); + assert_eq!(f.key(true, true, false), Swallow); assert!(f.held()); - assert_eq!(f.key(true, true), Swallow, "auto-repeat"); - assert_eq!(f.key(true, false), Swallow); + assert_eq!(f.key(true, true, false), Swallow, "auto-repeat"); + assert_eq!(f.key(true, false, false), Swallow); assert!(!f.held()); } #[test] fn shortcut_is_replayed_once() { let mut f = InteractFilter::default(); - assert_eq!(f.key(true, true), Swallow); // Alt down - assert_eq!(f.key(false, true), Replay); // Tab down → inject Alt, Tab - assert_eq!(f.key(false, false), Pass); // Tab up - assert_eq!(f.key(false, true), Pass); // Tab again (Alt already given) - assert_eq!(f.key(true, true), Pass); // Alt auto-repeat - assert_eq!(f.key(true, false), Pass); // Alt up pairs with the replayed down + assert_eq!(f.key(true, true, false), Swallow); // Alt down + assert_eq!(f.key(false, true, false), Replay); // Tab down → inject Alt, Tab + assert_eq!(f.key(false, false, false), Pass); // Tab up + assert_eq!(f.key(false, true, false), Pass); // Tab again (Alt already given) + assert_eq!(f.key(true, true, true), Pass); // Alt auto-repeat + assert_eq!(f.key(true, false, true), Pass); // Alt up pairs with the replayed down assert!(!f.held()); // Next hold starts clean. - assert_eq!(f.key(true, true), Swallow); + assert_eq!(f.key(true, true, false), Swallow); } #[test] fn other_keys_untouched_when_not_held() { let mut f = InteractFilter::default(); - assert_eq!(f.key(false, true), Pass); - assert_eq!(f.key(false, false), Pass); + assert_eq!(f.key(false, true, false), Pass); + assert_eq!(f.key(false, false, false), Pass); + } + + /// Ctrl+Alt+H turns the pointer on, so the hook starts with Alt already down. + #[test] + fn release_of_key_pressed_before_the_hook_passes() { + let mut f = InteractFilter::default(); + assert_eq!(f.key(true, false, true), Pass); + assert!(!f.held()); + // Next hold is hidden from apps as usual. + assert_eq!(f.key(true, true, false), Swallow); + assert_eq!(f.key(true, false, false), Swallow); + } + + #[test] + fn auto_repeat_of_key_pressed_before_the_hook_passes() { + let mut f = InteractFilter::default(); + assert_eq!(f.key(true, true, true), Pass); + assert!(f.held()); + assert_eq!(f.key(false, true, true), Pass, "the system already has the key, no replay"); + assert_eq!(f.key(true, false, true), Pass); + assert!(!f.held()); + } + + /// Left and right Alt together: only one is replayed, neither may stick. + #[test] + fn both_sides_held_never_swallow_a_release_the_system_needs() { + let mut f = InteractFilter::default(); + assert_eq!(f.key(true, true, false), Swallow); // left down + assert_eq!(f.key(true, true, false), Swallow); // right down + assert_eq!(f.key(false, true, false), Replay); // replays right + assert_eq!(f.key(true, false, false), Pass); // left up + assert_eq!(f.key(true, false, true), Pass); // right up } } From f6787c7cb789b5f62a37fbf4238e91bd72bf7e87 Mon Sep 17 00:00:00 2001 From: phuc Date: Sat, 7 Nov 2026 10:13:46 +0700 Subject: [PATCH 3/3] Windows: bump to 1.1.0 and publish the installer on windows-v* tags Co-Authored-By: Claude Opus 5.5 --- .github/workflows/windows.yml | 14 ++++++++++++++ desktop/Cargo.lock | 2 +- desktop/README.md | 2 +- desktop/app/Cargo.toml | 2 +- desktop/app/tauri.conf.json | 2 +- 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 01a6dad..401976b 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -4,6 +4,8 @@ on: push: branches: [main] paths: ["desktop/**", "assets/**", ".github/workflows/windows.yml"] + # Not v*: that tag is the Mac release (release.yml). + tags: ["windows-v*"] pull_request: paths: ["desktop/**", "assets/**", ".github/workflows/windows.yml"] workflow_dispatch: @@ -12,6 +14,8 @@ jobs: build: runs-on: windows-latest timeout-minutes: 30 + permissions: + contents: write defaults: run: working-directory: desktop @@ -31,3 +35,13 @@ jobs: name: TinyHand-windows path: desktop/target/release/bundle/nsis/*.exe if-no-files-found: error + - name: Publish the release + if: startsWith(github.ref, 'refs/tags/windows-v') + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git fetch --force origin "refs/tags/$GITHUB_REF_NAME:refs/tags/$GITHUB_REF_NAME" + NOTES="$(git for-each-ref "refs/tags/$GITHUB_REF_NAME" --format='%(contents)')" + gh release create "$GITHUB_REF_NAME" target/release/bundle/nsis/*.exe \ + --title "TinyHand for Windows ${GITHUB_REF_NAME#windows-v}" --notes "$NOTES" diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index f0a46b2..039de82 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -4141,7 +4141,7 @@ dependencies = [ [[package]] name = "tinyhand" -version = "0.1.0" +version = "1.1.0" dependencies = [ "cpal", "serde", diff --git a/desktop/README.md b/desktop/README.md index 243a159..ab75c9b 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -54,7 +54,7 @@ cargo install cargo-xwin cd desktop && cargo xwin build -p tinyhand --release --target x86_64-pc-windows-msvc ``` -CI (`.github/workflows/windows.yml`) tests the core and uploads the installer as an artifact. +CI (`.github/workflows/windows.yml`) tests the core and uploads the installer as an artifact. Pushing a `windows-v1.2.3` tag also publishes it as a GitHub release; bump the version in `app/tauri.conf.json` and `app/Cargo.toml` first. Core only, any OS: diff --git a/desktop/app/Cargo.toml b/desktop/app/Cargo.toml index 43b6e00..b83cad5 100644 --- a/desktop/app/Cargo.toml +++ b/desktop/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tinyhand" -version = "0.1.0" +version = "1.1.0" edition = "2021" description = "A tiny hand on a stick that points at things on your screen." license = "MIT" diff --git a/desktop/app/tauri.conf.json b/desktop/app/tauri.conf.json index dd0210b..dfc3be4 100644 --- a/desktop/app/tauri.conf.json +++ b/desktop/app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "TinyHand", - "version": "0.1.0", + "version": "1.1.0", "identifier": "app.tinyhand.desktop", "build": { "frontendDist": "../ui"