diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..401976b --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,47 @@ +name: Windows + +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: + +jobs: + build: + runs-on: windows-latest + timeout-minutes: 30 + permissions: + contents: write + 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 + - 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/.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..039de82 --- /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 = "1.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..ab75c9b --- /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. 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: + +```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..b83cad5 --- /dev/null +++ b/desktop/app/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "tinyhand" +version = "1.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 0000000..7395d8c Binary files /dev/null and b/desktop/app/icons/128x128.png differ diff --git a/desktop/app/icons/32x32.png b/desktop/app/icons/32x32.png new file mode 100644 index 0000000..0b45902 Binary files /dev/null and b/desktop/app/icons/32x32.png differ diff --git a/desktop/app/icons/icon.ico b/desktop/app/icons/icon.ico new file mode 100644 index 0000000..4811c84 Binary files /dev/null and b/desktop/app/icons/icon.ico differ diff --git a/desktop/app/icons/icon.png b/desktop/app/icons/icon.png new file mode 100644 index 0000000..b965f2a Binary files /dev/null and b/desktop/app/icons/icon.png differ diff --git a/desktop/app/icons/tray_off.png b/desktop/app/icons/tray_off.png new file mode 100644 index 0000000..9d71435 Binary files /dev/null and b/desktop/app/icons/tray_off.png differ diff --git a/desktop/app/icons/tray_on.png b/desktop/app/icons/tray_on.png new file mode 100644 index 0000000..c899bd4 Binary files /dev/null and b/desktop/app/icons/tray_on.png differ 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..3b1d892 --- /dev/null +++ b/desktop/app/src/platform/windows/hook.rs @@ -0,0 +1,344 @@ +//! 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::{ + 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, + 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 })); + } + // 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, system_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..dfc3be4 --- /dev/null +++ b/desktop/app/tauri.conf.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "TinyHand", + "version": "1.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..cd6300e --- /dev/null +++ b/desktop/core/src/interact.rs @@ -0,0 +1,146 @@ +//! 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, + /// 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 { + /// 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). + /// `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; + 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 swallowed_press = self.held && !self.given && !system_down; + self.held = false; + self.given = false; + if swallowed_press { + KeyAction::Swallow + } else { + KeyAction::Pass + } + } + } else if down && self.held && !self.given { + self.given = 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, false), Swallow); + assert!(f.held()); + 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, 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, false), Swallow); + } + + #[test] + fn other_keys_untouched_when_not_held() { + let mut f = InteractFilter::default(); + 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 + } +} 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 0000000..7395d8c Binary files /dev/null and b/desktop/ui/icon.png differ diff --git a/desktop/ui/settings.html b/desktop/ui/settings.html new file mode 100644 index 0000000..d9fe0ba --- /dev/null +++ b/desktop/ui/settings.html @@ -0,0 +1,450 @@ + + + + + +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 + + + + +
+
+
+
+ + + +
+ + + +