diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index a1cd008..8c39efa 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -13,3 +13,23 @@ jobs:
- uses: actions/checkout@v4
- name: Build (universal)
run: swift build -c release --arch arm64 --arch x86_64
+
+ build-windows:
+ runs-on: windows-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ - name: Build
+ working-directory: windows
+ run: cargo build --release
+ - name: Stage the app with its images and sounds
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force dist/TinyHand/assets/sounds | Out-Null
+ Copy-Item windows/target/release/tinyhand.exe dist/TinyHand/TinyHand.exe
+ Copy-Item assets/hand_*.png dist/TinyHand/assets/
+ Copy-Item assets/sounds/tap_*.wav dist/TinyHand/assets/sounds/
+ - uses: actions/upload-artifact@v4
+ with:
+ name: TinyHand-windows
+ path: dist/TinyHand
diff --git a/.github/workflows/release-windows.yml b/.github/workflows/release-windows.yml
new file mode 100644
index 0000000..0ccc8d5
--- /dev/null
+++ b/.github/workflows/release-windows.yml
@@ -0,0 +1,63 @@
+name: Release (Windows)
+
+on:
+ push:
+ tags: ['windows-v*']
+ workflow_dispatch:
+
+concurrency:
+ group: release-windows-${{ github.ref_name }}
+ cancel-in-progress: true
+
+jobs:
+ release:
+ runs-on: windows-latest
+ timeout-minutes: 40
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Read the version
+ shell: pwsh
+ run: |
+ $version = (Select-String -Path windows/Cargo.toml -Pattern '^version = "(.*)"').Matches[0].Groups[1].Value
+ "VERSION=$version" >> $env:GITHUB_ENV
+
+ - name: Build
+ working-directory: windows
+ run: cargo build --release
+
+ - name: Package
+ shell: pwsh
+ run: |
+ $dir = "dist/TinyHand"
+ New-Item -ItemType Directory -Force "$dir/assets/sounds" | Out-Null
+ Copy-Item windows/target/release/tinyhand.exe "$dir/TinyHand.exe"
+ Copy-Item assets/hand_*.png "$dir/assets/"
+ Copy-Item assets/sounds/tap_*.wav "$dir/assets/sounds/"
+ Copy-Item LICENSE "$dir/LICENSE"
+ Copy-Item windows/README.md "$dir/README.md"
+ $zip = "dist/TinyHand-Windows-$env:VERSION.zip"
+ Compress-Archive -Path $dir -DestinationPath $zip
+ $hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLower()
+ "$hash TinyHand-Windows-$env:VERSION.zip" | Out-File "$zip.sha256" -Encoding ascii
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: TinyHand-Windows
+ path: dist/*.zip*
+
+ - name: Publish the release
+ if: startsWith(github.ref, 'refs/tags/windows-v')
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ $files = "dist/TinyHand-Windows-$env:VERSION.zip", "dist/TinyHand-Windows-$env:VERSION.zip.sha256"
+ gh release view $env:GITHUB_REF_NAME *> $null
+ if ($LASTEXITCODE -eq 0) {
+ gh release upload $env:GITHUB_REF_NAME @files --clobber
+ } else {
+ gh release create $env:GITHUB_REF_NAME @files --title "TinyHand for Windows $env:VERSION" --notes-file windows/RELEASE_NOTES.md --prerelease
+ }
diff --git a/README.md b/README.md
index e1f8647..d8e70b9 100644
--- a/README.md
+++ b/README.md
@@ -3,32 +3,40 @@
# TinyHand
[](https://github.com/nilbuild/tinyhand/releases/latest)
-[](https://tinyhand.app)
+[](https://tinyhand.app)
[](./LICENSE)
-A tiny hand on a stick that points at things on your Mac screen.
+A tiny hand on a stick that points at things on your screen.
[](https://github.com/nilbuild/tinyhand/releases/latest/download/TinyHand.dmg)
[](https://tinyhand.app)
-macOS 13 or later, Apple Silicon and Intel.
+**macOS** 13 or later, Apple Silicon and Intel. **Windows** 10 or 11, 64-bit (build from source for now, see [windows/](windows/README.md)).
## Install
+### macOS
+
Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** when asked. It needs that to turn your clicks into taps. TinyHand updates itself.
+### Windows
+
+No permission prompt and no installer yet. Build it with Rust and run it, as described in [windows/README.md](windows/README.md).
+
## Use
-| | |
-|---|---|
-| Click the menu bar icon, or **⌃⌥H** | Turn the pointer on or off |
-| Right-click the menu bar icon | Menu and Settings |
-| Click | Tap (nothing underneath gets clicked) |
-| Hold and drag | Draw on the screen |
-| **⌃⌥X** | Clear the drawing |
-| **Esc** | Clear everything and turn the pointer off |
+| macOS | Windows | |
+|---|---|---|
+| Click the menu bar icon, or **⌃⌥H** | Click the tray icon, or **Ctrl+Alt+H** | Turn the pointer on or off |
+| Right-click the menu bar icon | Right-click the tray icon | Menu and Settings |
+| Click | Click | Tap (nothing underneath gets clicked) |
+| Hold and drag | Hold and drag | Draw on the screen |
+| **⌃⌥X** | **Ctrl+Alt+X** | Clear the drawing |
+| **Esc** | **Esc** | Clear everything and turn the pointer off |
+| Hold **⌥** and click | Hold **Alt** and click | Click for real, with your normal cursor |
+| | **Ctrl+Alt+C** | Let plain clicks through to apps while drags still draw (Windows) |
While the pointer is on, a small toolbar at the bottom of the screen lets you pick one of
36 hands or a laser dot, mute the tap sound, or exit.
diff --git a/windows/.gitignore b/windows/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/windows/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/windows/Cargo.lock b/windows/Cargo.lock
new file mode 100644
index 0000000..f30b319
--- /dev/null
+++ b/windows/Cargo.lock
@@ -0,0 +1,2543 @@
+# 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 = "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 = "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 = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[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"
+
+[[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 = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[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 = "cc"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ff2a8126fff0f8c839080d619675a585e61720bb12d2baceae45769fd66f155"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[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 = "combine"
+version = "4.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[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 = "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 = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
+[[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 = "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",
+ "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 = "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"
+
+[[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 = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[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.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "626d4928326b646e70f65ed36fc7cf9f206bdb68a0019ee7c6b4709da4e084c4"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[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-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-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-macro",
+ "futures-task",
+ "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 = "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 = "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 = "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.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 = "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 = "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 = "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 = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[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 = "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 = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "libredox"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
+dependencies = [
+ "libc",
+]
+
+[[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 = "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 = "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 = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.2",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-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 = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[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",
+ "objc2",
+ "objc2-core-foundation",
+ "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-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",
+ "objc2",
+ "objc2-core-foundation",
+]
+
+[[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",
+ "objc2",
+ "objc2-core-foundation",
+ "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 = "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 = "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_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",
+]
+
+[[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 = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[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 = "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.19+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 = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[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 = "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 = "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 = "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"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+]
+
+[[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_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_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.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782"
+dependencies = [
+ "serde_core",
+]
+
+[[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 = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[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.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46"
+
+[[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 = "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 = "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 = "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-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 = "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.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "png",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinyhand"
+version = "0.1.0"
+dependencies = [
+ "raw-window-handle",
+ "serde_json",
+ "tiny-skia",
+ "windows",
+ "winresource",
+ "wry",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[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.2.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned 1.1.3",
+ "toml_datetime 1.1.3+spec-1.1.0",
+ "toml_parser",
+ "toml_writer",
+ "winnow 1.0.5",
+]
+
+[[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.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49"
+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",
+ "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",
+ "serde",
+ "serde_spanned 0.6.9",
+ "toml_datetime 0.6.3",
+ "winnow 0.5.40",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.19+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218"
+dependencies = [
+ "indexmap",
+ "toml_datetime 1.1.3+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.5",
+]
+
+[[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.5",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.2.0+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "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 = "version-compare"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "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",
+ "windows-core",
+]
+
+[[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",
+ "windows-core",
+]
+
+[[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 = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-version"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
+dependencies = [
+ "windows-link",
+]
+
+[[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_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[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_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[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_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[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.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "winresource"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
+dependencies = [
+ "toml 1.2.1+spec-1.1.0",
+ "version_check",
+]
+
+[[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",
+ "block2",
+ "cookie",
+ "crossbeam-channel",
+ "dirs",
+ "dom_query",
+ "dpi",
+ "dunce",
+ "gdkx11",
+ "gtk",
+ "http",
+ "javascriptcore-rs",
+ "jni",
+ "libc",
+ "ndk",
+ "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",
+ "windows-core",
+ "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 = "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 = "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"
diff --git a/windows/Cargo.toml b/windows/Cargo.toml
new file mode 100644
index 0000000..1346823
--- /dev/null
+++ b/windows/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "tinyhand"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+raw-window-handle = "0.6.2"
+serde_json = "1.0.154"
+tiny-skia = "0.12.0"
+windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Media", "Win32_UI_HiDpi", "Win32_Graphics_Dwm", "Win32_System_Console", "Win32_Security", "Win32_UI_Accessibility", "Win32_Media_Audio", "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_UI_Shell_PropertiesSystem", "Win32_Devices_FunctionDiscovery", "Win32_System_Variant"] }
+wry = "0.57.1"
+
+[build-dependencies]
+tiny-skia = "0.12.0"
+winresource = "0.1.34"
diff --git a/windows/README.md b/windows/README.md
new file mode 100644
index 0000000..70332b1
--- /dev/null
+++ b/windows/README.md
@@ -0,0 +1,96 @@
+# TinyHand for Windows
+
+The Windows version of [TinyHand](../README.md): a tiny hand on a stick that points at things on your screen. It uses the same hands, sounds and logo as the Mac app, and behaves the same way: click to tap, drag to draw, a toolbar to pick a hand.
+
+Written in Rust. It draws with a layered window and a low-level mouse hook, so there is no runtime to install apart from WebView2 for the settings window (already part of Windows 11).
+
+## Requirements
+
+- Windows 10 or 11, 64-bit. Developed and tested on Windows 11.
+- To build: [Rust](https://rustup.rs) (stable) and the MSVC build tools with the Windows SDK. The "Desktop development with C++" workload of Visual Studio Build Tools has both.
+- The WebView2 runtime for the settings window. It ships with Windows 11 and current Windows 10.
+
+## Build and run
+
+```powershell
+cd windows
+cargo run --release
+```
+
+The app looks for `assets/` (hand images and tap sounds) next to the exe, then in this repository. To use the exe on its own, copy it together with `assets/hand_*.png` and `assets/sounds/`.
+
+Turn the pointer on with **Ctrl+Alt+H**, or click the tray icon.
+
+## Use
+
+| | |
+|---|---|
+| Click the tray icon, or **Ctrl+Alt+H** | Turn the pointer on or off |
+| Right-click the tray icon | Menu and Settings |
+| Click | Tap (nothing underneath gets clicked) |
+| Hold and drag | Draw on the screen |
+| **Ctrl+Alt+X** | Clear the drawing |
+| **Ctrl+Alt+C** | Click through on or off |
+| **Esc** | Clear everything and turn the pointer off |
+| Hold **Alt** and click | Click for real, with your normal cursor |
+
+**Click through** is Windows only. With it on, plain clicks go to the app under the pointer and the hand still taps. A drag draws and never reaches the app, so it cannot select text underneath. Hold **Alt** when you need a real drag.
+
+The toolbar at the bottom of the screen appears when you turn the pointer on, and again when you move the mouse near it. It picks the hand or laser, toggles click through and the tap sound, and exits. The hold-to-click key can be changed to Ctrl or Shift, or turned off, in Settings.
+
+## Settings
+
+Right-click the tray icon and choose **Settings**. Everything applies as you change it.
+
+- **General**: click through, the hold-to-click key, the shortcuts, reset.
+- **Pointers**: which hands show on the toolbar (up to 8), their order, and the one in use.
+- **Appearance**: pointer size and shadow.
+- **Drawing**: how long strokes last, and the ink color.
+- **Sound**: tap sound for hands and for the laser, and which of the six sounds.
+- **About**.
+
+Settings are stored in `%APPDATA%\TinyHand\settings.txt`. A log with any crash details is in `%APPDATA%\TinyHand\log.txt`.
+
+## How it works
+
+| File | Role |
+|---|---|
+| `src/main.rs` | The message loop, tray icon, hotkeys, and the pieces below wired together |
+| `src/hook.rs` | Low-level mouse and keyboard hooks. They only set flags and post messages, so the system never waits on TinyHand |
+| `src/renderer.rs` | A thread that draws at the display's refresh rate and sleeps when nothing moves |
+| `src/arm.rs`, `src/hand.rs` | The hand and stick: measuring each image, aiming, the shadow |
+| `src/scene.rs` | Ink strokes and the laser dot |
+| `src/toolbar.rs` | The toolbar, drawn by hand and scaled per monitor |
+| `src/sound.rs` | Tap sounds through WASAPI, on their own thread |
+| `src/settings.rs`, `settings.html` | The settings window, a WebView2 page created on demand and destroyed on close |
+| `src/monitor.rs` | Which monitor the cursor is on, and its display scaling |
+
+Things worth knowing if you change the code:
+
+- Input is never injected from inside a hook callback. Replayed clicks and keys are posted to the main window and sent from there, because injecting inside the hook can stall all input.
+- Every window the app owns refuses focus, and hands it straight back if Windows gives it away.
+- The real cursors are blanked while the pointer is on and always restored on exit, on a crash and at startup.
+
+## Differences from the Mac app
+
+- Click through mode, and Ctrl+Alt+C for it.
+- No Accessibility permission to grant.
+- Windows menus, flyouts and the taskbar are detected and let clicks through, in place of the Mac menu bar handling.
+- The pointer pauses while Snipping Tool or the Win+Shift+S overlay has focus.
+- Not yet ported: a recorder to change the shortcuts, "make your own hand", the toolbar ink color button, opening at login, the first-run hint, auto-update and an installer.
+
+## Known limits
+
+- Windows does not let a program see or block input going to an app running as administrator, so TinyHand cannot draw over or tap such windows.
+- The overlay covers the monitor the cursor is on, and drawings clear when you move to another monitor, as on the Mac.
+- The toolbar and hands scale per monitor, but mixed display scaling has only been checked with a simulated second monitor.
+
+## Troubleshooting
+
+- **The cursor looks missing after a crash**: start TinyHand again. It restores the normal cursors on launch.
+- **Nothing happens when you click**: press **Esc** or **Ctrl+Alt+H** to turn the pointer off. Both work even if drawing is stuck.
+- **A hotkey does nothing**: another program may already use that combination.
+
+## License
+
+MIT, the same as the rest of the repository.
diff --git a/windows/RELEASE_NOTES.md b/windows/RELEASE_NOTES.md
new file mode 100644
index 0000000..90f461d
--- /dev/null
+++ b/windows/RELEASE_NOTES.md
@@ -0,0 +1,30 @@
+A first preview of TinyHand for Windows: a tiny hand on a stick that points at things on your screen. It uses the same hands, sounds and logo as the Mac app.
+
+## Try it
+
+1. Download `TinyHand-Windows-0.1.0.zip` and unzip it anywhere.
+2. Run `TinyHand.exe`. A tray icon appears.
+3. Press **Ctrl+Alt+H**, or click the tray icon, to turn the pointer on.
+
+Click to tap, hold and drag to draw, **Esc** to exit. The toolbar at the bottom of the screen picks the hand. Right-click the tray icon for Settings.
+
+Windows 10 or 11, 64-bit. Settings needs the WebView2 runtime, which comes with Windows 11 and current Windows 10.
+
+## What works
+
+- The 36 hands and the laser, with shadow, tap sounds and drawing that fades.
+- A toolbar you can customize, and per-monitor placement and scaling.
+- **Click through**: plain clicks reach the app under the pointer while drags still draw. Toggle with **Ctrl+Alt+C**.
+- Hold **Alt** to click for real with your normal cursor.
+- A settings window for all of the above.
+
+## Known limits
+
+- **This build is not code-signed.** Windows SmartScreen may warn the first time you run it. Choose "More info", then "Run anyway".
+- Apps running as administrator cannot be drawn on or tapped, because Windows blocks input hooks across that boundary.
+- Not done yet: an installer, auto-update, a shortcut recorder, "make your own hand", the toolbar ink color button and opening at login.
+- Mixed display scaling has only been checked with a simulated second monitor.
+
+If something looks wrong, a log is in `%APPDATA%\TinyHand\log.txt`.
+
+Based on TinyHand for Mac by Kamran Ahmed. MIT license.
diff --git a/windows/build.rs b/windows/build.rs
new file mode 100644
index 0000000..26c53cb
--- /dev/null
+++ b/windows/build.rs
@@ -0,0 +1,61 @@
+//! Builds the app icon from the shared logo, so the exe carries the official mark.
+
+use std::path::PathBuf;
+
+use tiny_skia::{FilterQuality, Pixmap, PixmapPaint, Transform};
+
+const LOGO: &str = "../.github/logo.png";
+const SIZES: [u32; 7] = [16, 24, 32, 48, 64, 128, 256];
+
+fn scaled(src: &Pixmap, size: u32) -> Pixmap {
+ // Halve until close, then one bilinear step, so small sizes stay crisp.
+ let mut cur = src.clone();
+ while cur.width() / 2 >= size {
+ let (w, h) = (cur.width() / 2, cur.height() / 2);
+ let mut next = Pixmap::new(w, h).unwrap();
+ next.draw_pixmap(0, 0, cur.as_ref(), &PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }, Transform::from_scale(0.5, 0.5), None);
+ cur = next;
+ }
+ let mut out = Pixmap::new(size, size).unwrap();
+ let k = size as f32 / cur.width() as f32;
+ out.draw_pixmap(0, 0, cur.as_ref(), &PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() }, Transform::from_scale(k, k), None);
+ out
+}
+
+fn main() {
+ println!("cargo:rerun-if-changed={LOGO}");
+ println!("cargo:rerun-if-changed=build.rs");
+ if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
+ return;
+ }
+ let logo = Pixmap::load_png(LOGO).expect("logo.png");
+ let images: Vec<(u32, Vec)> = SIZES.iter().map(|&s| (s, scaled(&logo, s).encode_png().expect("png"))).collect();
+
+ // An .ico is a small directory followed by the images. PNG payloads are allowed.
+ let mut ico = vec![0, 0, 1, 0];
+ ico.extend((images.len() as u16).to_le_bytes());
+ let mut offset = 6 + 16 * images.len() as u32;
+ for (size, png) in &images {
+ let dim = if *size >= 256 { 0 } else { *size as u8 };
+ ico.extend([dim, dim, 0, 0]);
+ ico.extend(1u16.to_le_bytes());
+ ico.extend(32u16.to_le_bytes());
+ ico.extend((png.len() as u32).to_le_bytes());
+ ico.extend(offset.to_le_bytes());
+ offset += png.len() as u32;
+ }
+ for (_, png) in &images {
+ ico.extend(png);
+ }
+ let path = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("tinyhand.ico");
+ std::fs::write(&path, ico).unwrap();
+
+ let mut res = winresource::WindowsResource::new();
+ res.set_icon(path.to_str().unwrap());
+ res.set("ProductName", "TinyHand for Windows");
+ res.set("FileDescription", "TinyHand, a tiny hand pointer");
+ res.set("LegalCopyright", "MIT License. Based on TinyHand by Kamran Ahmed.");
+ if let Err(e) = res.compile() {
+ println!("cargo:warning=could not embed the app icon: {e}");
+ }
+}
diff --git a/windows/src/arm.rs b/windows/src/arm.rs
new file mode 100644
index 0000000..618880a
--- /dev/null
+++ b/windows/src/arm.rs
@@ -0,0 +1,228 @@
+use tiny_skia::*;
+
+use crate::hand::HandAsset;
+use crate::scene::Rc;
+
+const MIN_ROD: f32 = 0.05;
+const GRIP_LEAN: f32 = 0.30;
+const GRIP_DEPTH: f32 = 0.12;
+const GRIP_FOLLOW: f32 = 0.08;
+const LIFT: f32 = 0.06;
+/// The shadow is a soft blur, so it is drawn and blurred at a quarter of the size.
+const SHADOW_SCALE: f32 = 0.25;
+/// The follow and press rates were tuned at 120 ticks per second.
+const TICK_HZ: f32 = 120.0;
+
+/// Geometry is in y-up screen space, like the Mac layers. Draw flips it.
+#[derive(Clone, Copy)]
+pub struct Pose {
+ rotation: f32,
+ rod_len: f32,
+ hand_pts: f32,
+ width: f32,
+ grip: (f32, f32),
+ lift: f32,
+ blur: f32,
+ opacity: f32,
+}
+
+impl Pose {
+ pub fn key(&self) -> Vec {
+ [self.rotation, self.rod_len, self.hand_pts, self.width, self.grip.0, self.lift, self.blur]
+ .iter()
+ .map(|v| (v * 100.0).round() as i32)
+ .collect()
+ }
+}
+
+pub struct Arm {
+ pub asset: HandAsset,
+ grip_x: Option,
+ grip_target: f32,
+}
+
+impl Arm {
+ pub fn new(asset: HandAsset) -> Self {
+ Self { asset, grip_x: None, grip_target: 0.0 }
+ }
+
+ pub fn settling(&self) -> bool {
+ self.grip_x.is_some_and(|g| (self.grip_target - g).abs() > 0.5)
+ }
+
+ /// cursor is in screen pixels, y down.
+ pub fn aim(&mut self, cursor: (f32, f32), screen: (f32, f32), press: f32, size: f32, dt: f32) -> Pose {
+ let (sw, sh) = screen;
+ let a = &mut self.asset;
+ let (cx, cy) = (cursor.0, sh - cursor.1);
+
+ let target = (cx + GRIP_LEAN * sw).clamp(sw * 0.15, sw * 1.05);
+ let follow = 1.0 - (1.0 - GRIP_FOLLOW).powf(dt * TICK_HZ);
+ let gx = self.grip_x.map_or(target, |g| g + (target - g) * follow);
+ self.grip_x = Some(gx);
+ self.grip_target = target;
+ let grip = (gx, -GRIP_DEPTH * sh);
+
+ a.fit(size * sh / (a.wrist - a.hand_top));
+
+ let (dx, dy) = (cx - grip.0, cy - grip.1);
+ let reach = dx.hypot(dy);
+ let aim_angle = dy.atan2(dx);
+
+ let hand_pts = size * sh * (1.0 - 0.06 * press);
+ let s = hand_pts / (a.wrist - a.hand_top);
+ let width = s * a.aspect;
+ let tip_pts = (a.wrist - a.tip) * s;
+ let tip_off_x = (a.tip_x - a.rod_x) * width;
+
+ let along = (reach * reach - tip_off_x * tip_off_x).max(0.0).sqrt();
+ let rod_len = (along - tip_pts).max(MIN_ROD * sh);
+ let tip_angle = (rod_len + tip_pts).atan2(tip_off_x);
+
+ let lift = LIFT * sh * (1.0 - 0.8 * press);
+ Pose {
+ rotation: aim_angle - tip_angle,
+ rod_len,
+ hand_pts,
+ width,
+ grip,
+ lift,
+ blur: (lift * 0.35).max(1.0) + 5.0 * press,
+ opacity: 0.26 + 0.02 * press,
+ }
+ }
+
+ fn corners(&self, p: &Pose, sh: f32) -> [(f32, f32); 4] {
+ let anchor = self.asset.rod_x * p.width;
+ let (sin, cos) = p.rotation.sin_cos();
+ let top = p.rod_len + p.hand_pts;
+ [(0.0, 0.0), (p.width, 0.0), (p.width, top), (0.0, top)].map(|(x, y)| {
+ let (lx, ly) = (x - anchor, y);
+ (p.grip.0 + cos * lx - sin * ly, sh - (p.grip.1 + sin * lx + cos * ly))
+ })
+ }
+
+ pub fn bbox(&self, p: &Pose, sh: f32) -> Rc {
+ let c = self.corners(p, sh);
+ let pad = 6.0 + p.lift * 1.1 + p.blur * 2.0;
+ let min = |f: fn(&(f32, f32)) -> f32| c.iter().map(f).fold(f32::MAX, f32::min);
+ let max = |f: fn(&(f32, f32)) -> f32| c.iter().map(f).fold(f32::MIN, f32::max);
+ Rc { l: (min(|c| c.0) - pad).floor() as i32, t: (min(|c| c.1) - pad).floor() as i32, r: (max(|c| c.0) + pad).ceil() as i32, b: (max(|c| c.1) + pad).ceil() as i32 }
+ }
+
+ pub fn draw(&self, pix: &mut PixmapMut, p: &Pose, sh: f32, shadow: bool) {
+ let (sin, cos) = p.rotation.sin_cos();
+ let base = Transform::from_row(1.0, 0.0, 0.0, -1.0, 0.0, sh)
+ .pre_concat(Transform::from_translate(p.grip.0, p.grip.1))
+ .pre_concat(Transform::from_row(cos, sin, -sin, cos, 0.0, 0.0))
+ .pre_concat(Transform::from_translate(-self.asset.rod_x * p.width, 0.0));
+ if shadow {
+ self.draw_shadow(pix, p, base);
+ }
+ self.parts(pix, base, p);
+ }
+
+ /// base maps group-local space (origin at the rod's bottom-left, y up) to the target.
+ fn parts(&self, pix: &mut PixmapMut, base: Transform, p: &Pose) {
+ self.part(pix, base, p, Part::Rod);
+ self.part(pix, base, p, Part::Hand);
+ }
+
+ fn part(&self, pix: &mut PixmapMut, base: Transform, p: &Pose, part: Part) {
+ let a = &self.asset;
+ let img = &a.display;
+ let (iw, ih) = (img.width() as f32, img.height() as f32);
+ let mut paint = Paint::default();
+ paint.shader = Pattern::new(img.as_ref(), SpreadMode::Pad, FilterQuality::Bilinear, 1.0, Transform::identity());
+ paint.anti_alias = true;
+ let sx = p.width / iw;
+ let (v_top, v_wrist, v_end) = (a.hand_top * ih, a.wrist * ih, a.rod_end * ih);
+ let columns = |(lo, hi): (f32, f32)| ((lo * iw - 2.0).max(0.0), (hi * iw + 2.0).min(iw));
+
+ match part {
+ Part::Rod => {
+ let k = p.rod_len / (v_end - v_wrist).max(1.0);
+ let ts = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k, 0.0, p.rod_len + k * v_wrist));
+ let (l, r) = columns((a.rod_min_x, a.rod_max_x));
+ if let Some(rect) = Rect::from_ltrb(l, v_wrist - 1.0, r, v_end.max(v_wrist + 1.0)) {
+ pix.fill_rect(rect, &paint, ts, None);
+ }
+ }
+ Part::Hand => {
+ let k = p.hand_pts / (v_wrist - v_top).max(1.0);
+ let ts = base.pre_concat(Transform::from_row(sx, 0.0, 0.0, -k, 0.0, p.rod_len + p.hand_pts + k * v_top));
+ let (l, r) = columns(a.hand_columns());
+ if let Some(rect) = Rect::from_ltrb(l, v_top, r, v_wrist + 1.0) {
+ pix.fill_rect(rect, &paint, ts, None);
+ }
+ }
+ }
+ }
+
+ /// One shadow per piece, like the Mac layers, each in a buffer cropped to just that piece.
+ fn draw_shadow(&self, pix: &mut PixmapMut, p: &Pose, base: Transform) {
+ let a = &self.asset;
+ let (hand_l, hand_r) = a.hand_columns();
+ for (part, cols, rows) in [
+ (Part::Rod, (a.rod_min_x, a.rod_max_x), (0.0, p.rod_len)),
+ (Part::Hand, (hand_l, hand_r), (p.rod_len, p.rod_len + p.hand_pts)),
+ ] {
+ self.shadow_piece(pix, p, base, part, (cols.0 * p.width, cols.1 * p.width), rows);
+ }
+ }
+
+ /// Drawn and blurred at reduced size in unrotated local space, then rotated into place.
+ fn shadow_piece(&self, pix: &mut PixmapMut, p: &Pose, base: Transform, part: Part, x: (f32, f32), y: (f32, f32)) {
+ let pad = (p.blur * 2.0).ceil() + 2.0;
+ let (x0, x1, y0, y1) = (x.0 - pad, x.1 + pad, y.0 - pad, y.1 + pad);
+ let k = SHADOW_SCALE;
+ let (w, h) = ((((x1 - x0) * k).ceil() as u32).max(1), (((y1 - y0) * k).ceil() as u32).max(1));
+ let Some(mut sil) = Pixmap::new(w, h) else { return };
+ let to_buf = Transform::from_row(k, 0.0, 0.0, -k, -x0 * k, y1 * k);
+ self.part(&mut sil.as_mut(), to_buf, p, part);
+
+ let mut alpha: Vec = sil.data().chunks_exact(4).map(|px| px[3]).collect();
+ let radius = (p.blur / 2.0 * k).round().max(1.0) as usize;
+ box_blur(&mut alpha, w as usize, h as usize, radius);
+ for (px, a) in sil.data_mut().chunks_exact_mut(4).zip(&alpha) {
+ px.copy_from_slice(&[0, 0, 0, (*a as f32 * p.opacity) as u8]);
+ }
+
+ let offset = Transform::from_translate(p.lift * 0.6, p.lift);
+ let from_buf = Transform::from_row(1.0 / k, 0.0, 0.0, -1.0 / k, x0, y1);
+ let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() };
+ pix.draw_pixmap(0, 0, sil.as_ref(), &paint, offset.pre_concat(base).pre_concat(from_buf), None);
+ }
+}
+
+#[derive(Clone, Copy)]
+enum Part {
+ Rod,
+ Hand,
+}
+
+fn pass(src: &[u8], dst: &mut [u8], lines: usize, len: usize, line_step: usize, step: usize, r: usize) {
+ let win = (2 * r + 1) as u32;
+ for l in 0..lines {
+ let b = l * line_step;
+ let mut sum: u32 = (0..=r.min(len - 1)).map(|i| src[b + i * step] as u32).sum();
+ for i in 0..len {
+ dst[b + i * step] = (sum / win) as u8;
+ if i + r + 1 < len {
+ sum += src[b + (i + r + 1) * step] as u32;
+ }
+ if i >= r {
+ sum -= src[b + (i - r) * step] as u32;
+ }
+ }
+ }
+}
+
+/// Three box passes approximate a Gaussian.
+fn box_blur(a: &mut [u8], w: usize, h: usize, r: usize) {
+ let mut tmp = vec![0u8; a.len()];
+ for _ in 0..3 {
+ pass(a, &mut tmp, h, w, w, 1, r);
+ pass(&tmp, a, w, h, 1, w, r);
+ }
+}
diff --git a/windows/src/config.rs b/windows/src/config.rs
new file mode 100644
index 0000000..812bcde
--- /dev/null
+++ b/windows/src/config.rs
@@ -0,0 +1,158 @@
+use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, Ordering::Relaxed};
+
+use serde_json::{Value, json};
+
+use crate::prefs;
+
+pub const SIZE_RANGE: (f32, f32) = (0.08, 0.20);
+const DEFAULT_SIZE: f32 = 0.13;
+const DEFAULT_FADE: f32 = 3.0;
+
+pub const INKS: [&str; 9] = ["match", "red", "orange", "yellow", "green", "blue", "purple", "white", "black"];
+const INK_RGB: [u32; 9] = [0, 0xFF291F, 0xFF8A00, 0xFFCC00, 0x30C25A, 0x0A84FF, 0x8754F2, 0xFFFFFF, 0x1D1D21];
+
+pub const INTERACT_KEYS: [&str; 4] = ["alt", "ctrl", "shift", "off"];
+
+static SIZE: AtomicU32 = AtomicU32::new(DEFAULT_SIZE.to_bits());
+static SHADOW: AtomicBool = AtomicBool::new(true);
+static FADE: AtomicU32 = AtomicU32::new(DEFAULT_FADE.to_bits());
+static INK: AtomicU8 = AtomicU8::new(0);
+static INK_BUTTON: AtomicBool = AtomicBool::new(true);
+static INTERACT: AtomicU8 = AtomicU8::new(0);
+
+pub fn pointer_size() -> f32 {
+ f32::from_bits(SIZE.load(Relaxed))
+}
+
+pub fn shadow() -> bool {
+ SHADOW.load(Relaxed)
+}
+
+/// Seconds before a stroke starts fading, or None to keep drawings until cleared.
+pub fn fade_delay() -> Option {
+ let seconds = f32::from_bits(FADE.load(Relaxed));
+ (seconds > 0.0).then_some(seconds.max(0.5))
+}
+
+pub fn show_ink_button() -> bool {
+ INK_BUTTON.load(Relaxed)
+}
+
+/// A fixed ink color the user picked, or None to match the pointer.
+pub fn ink_override() -> Option {
+ match INK.load(Relaxed) as usize {
+ 0 => None,
+ i => INK_RGB.get(i).copied(),
+ }
+}
+
+/// Virtual keys that count as the interact key, and the one to hand back to apps.
+pub fn interact_keys() -> (&'static [u32], u32) {
+ match INTERACT.load(Relaxed) {
+ 0 => (&[0xA4, 0xA5, 0x12], 0x12),
+ 1 => (&[0xA2, 0xA3, 0x11], 0x11),
+ 2 => (&[0xA0, 0xA1, 0x10], 0x10),
+ _ => (&[], 0),
+ }
+}
+
+pub const MAX_PINNED: usize = 8;
+const DEFAULT_PINNED: [&str; 6] = ["lavender", "glove", "coral", "plush", "robot", "laser"];
+
+/// The pointers shown on the toolbar, in order. Never empty, never unknown, never repeated.
+pub fn pinned() -> Vec {
+ let saved = prefs::get_str("pinned").unwrap_or_default();
+ let mut list: Vec = Vec::new();
+ for id in saved.split(',').map(str::trim) {
+ if crate::styles::is_known(id) && !list.iter().any(|x| x == id) && list.len() < MAX_PINNED {
+ list.push(id.to_string());
+ }
+ }
+ if list.is_empty() {
+ list = DEFAULT_PINNED.iter().map(|s| s.to_string()).collect();
+ }
+ list
+}
+
+pub fn set_pinned(list: &[String]) {
+ prefs::set_str("pinned", &list.join(","));
+}
+
+pub fn load() {
+ let size = prefs::get_f32("pointer_size").unwrap_or(DEFAULT_SIZE).clamp(SIZE_RANGE.0, SIZE_RANGE.1);
+ SIZE.store(size.to_bits(), Relaxed);
+ SHADOW.store(prefs::flag("shadow", true), Relaxed);
+ FADE.store(prefs::get_f32("fade_seconds").unwrap_or(DEFAULT_FADE).max(0.0).to_bits(), Relaxed);
+ INK.store(index_of(&INKS, prefs::get_str("ink_choice").as_deref()), Relaxed);
+ INK_BUTTON.store(prefs::flag("show_ink_button", true), Relaxed);
+ INTERACT.store(index_of(&INTERACT_KEYS, prefs::get_str("interact_key").as_deref()), Relaxed);
+}
+
+fn index_of(list: &[&str], name: Option<&str>) -> u8 {
+ name.and_then(|n| list.iter().position(|x| *x == n)).unwrap_or(0) as u8
+}
+
+/// Applies one change from the settings window and saves it. Returns false for unknown keys.
+pub fn set(key: &str, value: &Value) -> bool {
+ match key {
+ "pointer_size" => {
+ let v = value.as_f64().unwrap_or(DEFAULT_SIZE as f64) as f32;
+ let v = v.clamp(SIZE_RANGE.0, SIZE_RANGE.1);
+ SIZE.store(v.to_bits(), Relaxed);
+ prefs::set_f32("pointer_size", v);
+ }
+ "shadow" => {
+ let on = value.as_bool().unwrap_or(true);
+ SHADOW.store(on, Relaxed);
+ prefs::set_flag("shadow", on);
+ }
+ "fade_seconds" => {
+ let v = (value.as_f64().unwrap_or(DEFAULT_FADE as f64) as f32).max(0.0);
+ FADE.store(v.to_bits(), Relaxed);
+ prefs::set_f32("fade_seconds", v);
+ }
+ "ink_choice" => {
+ let name = value.as_str().unwrap_or("match");
+ INK.store(index_of(&INKS, Some(name)), Relaxed);
+ prefs::set_str("ink_choice", INKS[INK.load(Relaxed) as usize]);
+ }
+ "show_ink_button" => {
+ let on = value.as_bool().unwrap_or(true);
+ INK_BUTTON.store(on, Relaxed);
+ prefs::set_flag("show_ink_button", on);
+ }
+ "interact_key" => {
+ let name = value.as_str().unwrap_or("alt");
+ INTERACT.store(index_of(&INTERACT_KEYS, Some(name)), Relaxed);
+ prefs::set_str("interact_key", INTERACT_KEYS[INTERACT.load(Relaxed) as usize]);
+ }
+ _ => return false,
+ }
+ true
+}
+
+pub fn reset() {
+ for key in ["pointer_size", "shadow", "fade_seconds", "ink_choice", "show_ink_button", "interact_key", "tap_sound", "sound_hands", "sound_laser", "pinned"] {
+ prefs::remove(key);
+ }
+ load();
+}
+
+/// Everything the settings page shows.
+pub fn snapshot() -> Value {
+ json!({
+ "pointer_size": pointer_size(),
+ "size_min": SIZE_RANGE.0,
+ "size_max": SIZE_RANGE.1,
+ "shadow": shadow(),
+ "fade_seconds": f32::from_bits(FADE.load(Relaxed)),
+ "ink_choice": INKS[INK.load(Relaxed) as usize],
+ "show_ink_button": show_ink_button(),
+ "interact_key": INTERACT_KEYS[INTERACT.load(Relaxed) as usize],
+ "tap_sound": prefs::tap_sound(),
+ "sound_hands": prefs::flag("sound_hands", true),
+ "sound_laser": prefs::flag("sound_laser", false),
+ "click_through": prefs::flag("click_through", false),
+ "version": env!("CARGO_PKG_VERSION"),
+ })
+}
diff --git a/windows/src/cursor.rs b/windows/src/cursor.rs
new file mode 100644
index 0000000..822df03
--- /dev/null
+++ b/windows/src/cursor.rs
@@ -0,0 +1,39 @@
+use windows::Win32::Foundation::HINSTANCE;
+use windows::Win32::System::LibraryLoader::GetModuleHandleW;
+use windows::Win32::UI::WindowsAndMessaging::*;
+
+const IDS: [SYSTEM_CURSOR_ID; 13] = [
+ OCR_NORMAL,
+ OCR_IBEAM,
+ OCR_WAIT,
+ OCR_CROSS,
+ OCR_UP,
+ OCR_SIZENWSE,
+ OCR_SIZENESW,
+ OCR_SIZEWE,
+ OCR_SIZENS,
+ OCR_SIZEALL,
+ OCR_NO,
+ OCR_HAND,
+ OCR_APPSTARTING,
+];
+
+pub fn hide() {
+ let and = [0xFFu8; 128];
+ let xor = [0u8; 128];
+ unsafe {
+ let hinst = GetModuleHandleW(None).map(|m| HINSTANCE(m.0)).ok();
+ for id in IDS {
+ if let Ok(c) = CreateCursor(hinst, 0, 0, 32, 32, and.as_ptr().cast(), xor.as_ptr().cast()) {
+ let _ = SetSystemCursor(c, id);
+ }
+ }
+ }
+}
+
+/// Reloads the user's cursors. No broadcast: SPIF_SENDCHANGE makes the shell redraw.
+pub fn restore() {
+ unsafe {
+ let _ = SystemParametersInfoW(SPI_SETCURSORS, 0, None, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0));
+ }
+}
diff --git a/windows/src/hand.rs b/windows/src/hand.rs
new file mode 100644
index 0000000..5c65697
--- /dev/null
+++ b/windows/src/hand.rs
@@ -0,0 +1,161 @@
+use std::path::PathBuf;
+
+use tiny_skia::*;
+
+/// Where the art and sounds live. TH_ASSETS wins, then next to the exe, then the repo.
+pub fn find_asset(rel: &str) -> Option {
+ let exe = std::env::current_exe().ok()?;
+ let dir = exe.parent()?;
+ let mut roots = vec![];
+ if let Ok(p) = std::env::var("TH_ASSETS") {
+ roots.push(PathBuf::from(p));
+ }
+ roots.push(dir.join("assets"));
+ roots.push(PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../assets")));
+ roots.into_iter().map(|r| r.join(rel)).find(|p| p.exists())
+}
+
+fn find(name: &str) -> Option {
+ find_asset(&format!("{name}.png"))
+}
+
+/// Buffers are BGRA, so swap red and blue once at load.
+fn swap_rb(p: &mut Pixmap) {
+ for px in p.data_mut().chunks_exact_mut(4) {
+ px.swap(0, 2);
+ }
+}
+
+pub struct HandAsset {
+ full: Pixmap,
+ pub display: Pixmap,
+ pub aspect: f32,
+ pub hand_top: f32,
+ pub tip: f32,
+ pub wrist: f32,
+ pub rod_end: f32,
+ pub tip_x: f32,
+ pub rod_x: f32,
+ min_x: f32,
+ max_x: f32,
+ /// Columns that actually hold the stick, so thin parts are not painted full width.
+ pub rod_min_x: f32,
+ pub rod_max_x: f32,
+}
+
+impl HandAsset {
+ pub fn hand_columns(&self) -> (f32, f32) {
+ (self.min_x, self.max_x)
+ }
+
+ /// Square crop of just the hand, for toolbar buttons.
+ pub fn thumbnail(&self, px: u32) -> Pixmap {
+ let (w, h) = (self.full.width() as f32, self.full.height() as f32);
+ let side = ((self.max_x - self.min_x) * w).max((self.wrist - self.hand_top) * h) * 1.06;
+ let (cx, cy) = ((self.min_x + self.max_x) / 2.0 * w, (self.hand_top + self.wrist) / 2.0 * h);
+ let scale = px as f32 / side;
+ let small = resized(&self.full, ((h * scale).round() as u32).max(1));
+ let k = small.height() as f32 / h;
+ let mut out = Pixmap::new(px, px).expect("thumbnail");
+ let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() };
+ let ts = Transform::from_translate(px as f32 / 2.0 - cx * k, px as f32 / 2.0 - cy * k);
+ out.draw_pixmap(0, 0, small.as_ref(), &paint, ts, None);
+ out
+ }
+
+ pub fn load(id: &str) -> Option {
+ let mut full = Pixmap::load_png(find(&format!("hand_{id}"))?).ok()?;
+ swap_rb(&mut full);
+ let m = measure(&full)?;
+ Some(Self { display: full.clone(), full, ..m })
+ }
+
+ /// Keeps the texture near its on-screen size so minification stays crisp.
+ pub fn fit(&mut self, rest_px: f32) {
+ let step = 64u32;
+ let needed = (rest_px.ceil() as u32).div_ceil(step) * step;
+ let full_h = self.full.height();
+ let target = if needed * 10 >= full_h * 9 { full_h } else { needed.max(step) };
+ if target != self.display.height() {
+ self.display = resized(&self.full, target);
+ }
+ }
+
+ fn blank(full: Pixmap) -> Self {
+ Self { display: full.clone(), full, aspect: 0.0, hand_top: 0.0, tip: 0.0, wrist: 0.0, rod_end: 0.0, tip_x: 0.5, rod_x: 0.5, min_x: 0.0, max_x: 1.0, rod_min_x: 0.0, rod_max_x: 1.0 }
+ }
+}
+
+fn measure(p: &Pixmap) -> Option {
+ let (w, h) = (p.width() as usize, p.height() as usize);
+ let data = p.data();
+ let solid = |x: usize, y: usize| 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| {
+ let xs: Vec = (0..w).filter(|&x| solid(x, y)).collect();
+ match (xs.first(), xs.last()) {
+ (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 handle = (from..h).find(|&y| counts[y] as f64 > 1.8 * rod_w as f64);
+ let rod_end = handle.unwrap_or(last_row);
+
+ let fh = h as f32;
+ let mut asset = HandAsset::blank(p.clone());
+ asset.aspect = w as f32 / fh;
+ asset.hand_top = ((top as f32 - 0.004 * fh).max(0.0)) / fh;
+ asset.tip = top as f32 / fh;
+ asset.wrist = wrist as f32 / fh;
+ asset.rod_end = rod_end as f32 / fh;
+ asset.tip_x = center((h - 1).min(top + h / 200));
+ asset.rod_x = center((wrist + rod_end) / 2);
+ let xs = || (top..wrist).flat_map(|y| (0..w).filter(move |&x| solid(x, y)));
+ asset.min_x = xs().min().unwrap_or(0) as f32 / w as f32;
+ asset.max_x = xs().max().unwrap_or(w - 1) as f32 / w as f32;
+ let rod_rows = || (wrist..=rod_end.min(h - 1)).flat_map(|y| (0..w).filter(move |&x| solid(x, y)));
+ asset.rod_min_x = rod_rows().min().unwrap_or(0) as f32 / w as f32;
+ asset.rod_max_x = (rod_rows().max().unwrap_or(w - 1) + 1) as f32 / w as f32;
+ Some(asset)
+}
+
+fn halve(src: &Pixmap) -> Pixmap {
+ let (w, h) = (src.width() as usize / 2, src.height() as usize / 2);
+ let mut out = Pixmap::new(w as u32, h as u32).expect("halved pixmap");
+ let sw = src.width() as usize;
+ let (s, d) = (src.data(), out.data_mut());
+ for y in 0..h {
+ for x in 0..w {
+ for c in 0..4 {
+ let i = |dx: usize, dy: usize| s[((2 * y + dy) * sw + 2 * x + dx) * 4 + c] as u32;
+ d[(y * w + x) * 4 + c] = ((i(0, 0) + i(1, 0) + i(0, 1) + i(1, 1) + 2) / 4) as u8;
+ }
+ }
+ }
+ out
+}
+
+pub fn resized(full: &Pixmap, height: u32) -> Pixmap {
+ let mut cur = full.clone();
+ while cur.height() / 2 >= height && cur.width() >= 2 {
+ cur = halve(&cur);
+ }
+ let width = ((cur.width() as f32 * height as f32 / cur.height() as f32).round() as u32).max(1);
+ let mut out = Pixmap::new(width, height).expect("resized pixmap");
+ let ts = Transform::from_scale(width as f32 / cur.width() as f32, height as f32 / cur.height() as f32);
+ let paint = PixmapPaint { quality: FilterQuality::Bilinear, ..Default::default() };
+ out.draw_pixmap(0, 0, cur.as_ref(), &paint, ts, None);
+ out
+}
diff --git a/windows/src/hook.rs b/windows/src/hook.rs
new file mode 100644
index 0000000..b184005
--- /dev/null
+++ b/windows/src/hook.rs
@@ -0,0 +1,275 @@
+use std::ffi::c_void;
+use std::sync::atomic::{AtomicBool, AtomicI32, AtomicIsize, AtomicU32, Ordering::Relaxed};
+
+use windows::Win32::Foundation::*;
+use windows::Win32::System::LibraryLoader::GetModuleHandleW;
+use windows::Win32::System::Threading::*;
+use windows::Win32::UI::Input::KeyboardAndMouse::*;
+use windows::Win32::UI::WindowsAndMessaging::*;
+use windows::core::PCWSTR;
+
+/// The window thread does the actual present.
+pub const WM_PRESENT: u32 = WM_APP + 3;
+pub static PRESENT_PENDING: AtomicBool = AtomicBool::new(false);
+pub static ACTIVE: AtomicBool = AtomicBool::new(false);
+pub static HELD: AtomicBool = AtomicBool::new(false);
+/// Plain clicks reach the apps. Drags draw and stay out of the apps.
+pub static CLICK_THROUGH: AtomicBool = AtomicBool::new(false);
+/// Set by the render thread while it sleeps. The hook wakes it on mouse moves.
+pub static IDLE: AtomicBool = AtomicBool::new(false);
+pub static DOWN_SEQ: AtomicU32 = AtomicU32::new(0);
+pub static UP_SEQ: AtomicU32 = AtomicU32::new(0);
+pub static DOWN_X: AtomicI32 = AtomicI32::new(0);
+pub static DOWN_Y: AtomicI32 = AtomicI32::new(0);
+
+static WAKE: AtomicIsize = AtomicIsize::new(0);
+static SWALLOWED: AtomicBool = AtomicBool::new(false);
+/// Clicks inside these rects reach the windows under the overlay. Slot 0 is the taskbar,
+/// 1 the toolbar, the rest open menus, flyouts and Start.
+pub const PASS_SLOTS: usize = 8;
+static PASS: [[AtomicI32; 4]; PASS_SLOTS] = [const { [const { AtomicI32::new(0) }; 4] }; PASS_SLOTS];
+/// Every click goes through while TinyHand's own menu is open.
+pub static PAUSE: AtomicBool = AtomicBool::new(false);
+/// Cursor zone that reveals the toolbar.
+pub static ZONE: [AtomicI32; 4] = [const { AtomicI32::new(0) }; 4];
+
+pub fn set_zone(r: RECT) {
+ for (slot, v) in ZONE.iter().zip([r.left, r.top, r.right, r.bottom]) {
+ slot.store(v, Relaxed);
+ }
+}
+
+pub fn in_zone(x: i32, y: i32) -> bool {
+ x >= ZONE[0].load(Relaxed) && y >= ZONE[1].load(Relaxed) && x < ZONE[2].load(Relaxed) && y < ZONE[3].load(Relaxed)
+}
+
+pub fn init() {
+ let event = unsafe { CreateEventW(None, false, false, PCWSTR::null()) }.expect("wake event");
+ WAKE.store(event.0 as isize, Relaxed);
+}
+
+fn handle() -> HANDLE {
+ HANDLE(WAKE.load(Relaxed) as *mut c_void)
+}
+
+pub fn wake() {
+ unsafe {
+ let _ = SetEvent(handle());
+ }
+}
+
+pub fn wait(ms: u32) {
+ unsafe {
+ WaitForSingleObject(handle(), ms);
+ }
+}
+
+pub fn set_passthrough(index: usize, r: RECT) {
+ for (slot, v) in PASS[index].iter().zip([r.left, r.top, r.right, r.bottom]) {
+ slot.store(v, Relaxed);
+ }
+}
+
+fn in_passthrough(p: POINT) -> bool {
+ PASS.iter().any(|r| p.x >= r[0].load(Relaxed) && p.y >= r[1].load(Relaxed) && p.x < r[2].load(Relaxed) && p.y < r[3].load(Relaxed))
+}
+
+/// Past this distance a press becomes a stroke. Matches the renderer.
+pub const DRAG_PIXELS: f32 = 4.0;
+/// A held press that may still turn out to be a plain click.
+static PENDING: AtomicBool = AtomicBool::new(false);
+static BUTTON: AtomicI32 = AtomicI32::new(0);
+
+pub fn cancel_pending() {
+ PENDING.store(false, Relaxed);
+}
+
+/// Marks input TinyHand injects itself, so it is never mistaken for the user's.
+const MAGIC: usize = 0x5448_414E;
+static TEST_INPUT: AtomicBool = AtomicBool::new(false);
+
+/// Real input is anything not injected. The test switch also counts others' injected input.
+fn is_real(flags: u32, extra: usize) -> bool {
+ flags & 1 == 0 || (TEST_INPUT.load(Relaxed) && extra != MAGIC)
+}
+
+fn mouse_input(flags: MOUSE_EVENT_FLAGS) -> INPUT {
+ INPUT {
+ r#type: INPUT_MOUSE,
+ Anonymous: INPUT_0 { mi: MOUSEINPUT { dx: 0, dy: 0, mouseData: 0, dwFlags: flags, time: 0, dwExtraInfo: MAGIC } },
+ }
+}
+
+/// Injecting input from inside a hook callback stalls the whole input system until Windows
+/// times the hook out, so the hook only posts these and the window thread does the work.
+pub const WM_REPLAY_CLICK: u32 = WM_APP + 5;
+pub const WM_REPLAY_KEY: u32 = WM_APP + 6;
+
+fn post_to_window(msg: u32, wp: usize, lp: isize) {
+ let hwnd = HWND(TARGET.load(Relaxed) as *mut c_void);
+ unsafe {
+ let _ = PostMessageW(Some(hwnd), msg, WPARAM(wp), LPARAM(lp));
+ }
+}
+
+fn replay_click(x: i32, y: i32, button: i32) {
+ let packed = ((y as i16 as u16 as isize) << 16) | (x as i16 as u16 as isize);
+ post_to_window(WM_REPLAY_CLICK, button as usize, packed);
+}
+
+/// Window thread only.
+pub fn send_click(lp: LPARAM, button: usize) {
+ let (x, y) = ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32);
+ let (down, up) = match button {
+ 0 => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP),
+ 1 => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP),
+ _ => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP),
+ };
+ unsafe {
+ let _ = SetCursorPos(x, y);
+ SendInput(&[mouse_input(down), mouse_input(up)], size_of::() as i32);
+ }
+}
+
+/// Window thread only. Gives the app its Alt back, then the key, in order.
+pub fn send_alt_and_key(vk: usize, lp: LPARAM) {
+ let (scan, extended) = ((lp.0 & 0xFFFF) as u32, (lp.0 >> 16) & 1 != 0);
+ let inputs = [key_input(crate::config::interact_keys().1, 0, false), key_input(vk as u32, scan, extended)];
+ unsafe {
+ SendInput(&inputs, size_of::() as i32);
+ }
+}
+
+/// Alt is held to click for real. The key is hidden from apps while it is.
+pub const WM_INTERACT: u32 = WM_APP + 4;
+pub static INTERACT: AtomicBool = AtomicBool::new(false);
+/// Alt joined another key (Alt+Tab), so it was handed back to the app.
+static PASSED: AtomicBool = AtomicBool::new(false);
+static TARGET: AtomicIsize = AtomicIsize::new(0);
+
+pub fn reset_keys() {
+ INTERACT.store(false, Relaxed);
+ PASSED.store(false, Relaxed);
+ cancel_pending();
+}
+
+fn post_interact(on: bool) {
+ let hwnd = HWND(TARGET.load(Relaxed) as *mut c_void);
+ unsafe {
+ let _ = PostMessageW(Some(hwnd), WM_INTERACT, WPARAM(on as usize), LPARAM(0));
+ }
+ wake();
+}
+
+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: MAGIC,
+ },
+ },
+ }
+}
+
+unsafe extern "system" fn key_proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT {
+ if code == HC_ACTION as i32 && ACTIVE.load(Relaxed) {
+ let k = unsafe { &*(lp.0 as *const KBDLLHOOKSTRUCT) };
+ if k.flags.0 & LLKHF_INJECTED.0 == 0 {
+ let msg = wp.0 as u32;
+ let down = msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN;
+ let is_alt = crate::config::interact_keys().0.contains(&k.vkCode);
+ if is_alt && down && !PASSED.load(Relaxed) {
+ if !INTERACT.swap(true, Relaxed) {
+ post_interact(true);
+ }
+ return LRESULT(1);
+ }
+ if is_alt && !down && !PASSED.swap(false, Relaxed) && INTERACT.swap(false, Relaxed) {
+ post_interact(false);
+ return LRESULT(1);
+ }
+ if !is_alt && down && INTERACT.load(Relaxed) && !PASSED.load(Relaxed) {
+ PASSED.store(true, Relaxed);
+ INTERACT.store(false, Relaxed);
+ post_interact(false);
+ let extended = (k.flags.0 & LLKHF_EXTENDED.0 != 0) as isize;
+ post_to_window(WM_REPLAY_KEY, k.vkCode as usize, (k.scanCode as isize & 0xFFFF) | (extended << 16));
+ return LRESULT(1);
+ }
+ }
+ }
+ unsafe { CallNextHookEx(None, code, wp, lp) }
+}
+
+unsafe extern "system" fn proc(code: i32, wp: WPARAM, lp: LPARAM) -> LRESULT {
+ if code == HC_ACTION as i32 && ACTIVE.load(Relaxed) {
+ let info = unsafe { &*(lp.0 as *const MSLLHOOKSTRUCT) };
+ let msg = wp.0 as u32;
+ if msg == WM_MOUSEMOVE {
+ if PENDING.load(Relaxed) {
+ let (dx, dy) = (info.pt.x - DOWN_X.load(Relaxed), info.pt.y - DOWN_Y.load(Relaxed));
+ if ((dx * dx + dy * dy) as f32).sqrt() >= DRAG_PIXELS {
+ PENDING.store(false, Relaxed);
+ }
+ }
+ if IDLE.swap(false, Relaxed) {
+ wake();
+ }
+ } else if is_real(info.flags, info.dwExtraInfo) {
+ match msg {
+ WM_LBUTTONDOWN | WM_RBUTTONDOWN | WM_MBUTTONDOWN => {
+ SWALLOWED.store(false, Relaxed);
+ if !INTERACT.load(Relaxed) && !PAUSE.load(Relaxed) && !in_passthrough(info.pt) {
+ DOWN_X.store(info.pt.x, Relaxed);
+ DOWN_Y.store(info.pt.y, Relaxed);
+ HELD.store(true, Relaxed);
+ DOWN_SEQ.fetch_add(1, Relaxed);
+ SWALLOWED.store(true, Relaxed);
+ crate::sound::play();
+ // Click-through holds the press until it is clear whether it is a click or a stroke.
+ PENDING.store(CLICK_THROUGH.load(Relaxed), Relaxed);
+ BUTTON.store(if msg == WM_LBUTTONDOWN { 0 } else if msg == WM_RBUTTONDOWN { 1 } else { 2 }, Relaxed);
+ wake();
+ return LRESULT(1);
+ }
+ }
+ WM_LBUTTONUP | WM_RBUTTONUP | WM_MBUTTONUP => {
+ if SWALLOWED.swap(false, Relaxed) {
+ HELD.store(false, Relaxed);
+ UP_SEQ.fetch_add(1, Relaxed);
+ if PENDING.swap(false, Relaxed) {
+ replay_click(DOWN_X.load(Relaxed), DOWN_Y.load(Relaxed), BUTTON.load(Relaxed));
+ }
+ wake();
+ return LRESULT(1);
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ unsafe { CallNextHookEx(None, code, wp, lp) }
+}
+
+/// Own thread, so the callback never waits on rendering.
+pub fn start(target: HWND) {
+ TARGET.store(target.0 as isize, Relaxed);
+ TEST_INPUT.store(std::env::var_os("TH_TEST_INPUT").is_some(), Relaxed);
+ std::thread::spawn(|| unsafe {
+ let hinst = GetModuleHandleW(None).map(|m| HINSTANCE(m.0)).ok();
+ if SetWindowsHookExW(WH_MOUSE_LL, Some(proc), hinst, 0).is_err() {
+ return;
+ }
+ let _ = SetWindowsHookExW(WH_KEYBOARD_LL, Some(key_proc), hinst, 0);
+ let mut msg = MSG::default();
+ while GetMessageW(&mut msg, None, 0, 0).as_bool() {
+ let _ = TranslateMessage(&msg);
+ DispatchMessageW(&msg);
+ }
+ });
+}
diff --git a/windows/src/icon.rs b/windows/src/icon.rs
new file mode 100644
index 0000000..c4a66fd
--- /dev/null
+++ b/windows/src/icon.rs
@@ -0,0 +1,69 @@
+use std::ffi::c_void;
+use std::ptr::null_mut;
+
+use tiny_skia::Pixmap;
+use windows::Win32::Graphics::Gdi::*;
+use windows::Win32::UI::HiDpi::{GetDpiForSystem, GetSystemMetricsForDpi};
+use windows::Win32::UI::WindowsAndMessaging::*;
+
+/// The official TinyHand mark, shared with the Mac app and the README.
+const LOGO: &[u8] = include_bytes!("../../.github/logo.png");
+
+/// A window icon at the size Windows wants for this display scaling.
+pub fn logo_for(metric: SYSTEM_METRICS_INDEX) -> HICON {
+ let size = unsafe { GetSystemMetricsForDpi(metric, GetDpiForSystem()) }.max(16);
+ logo(size)
+}
+
+pub fn logo(size: i32) -> HICON {
+ let Ok(src) = Pixmap::decode_png(LOGO) else { return HICON::default() };
+ let px = crate::hand::resized(&src, size as u32);
+ let (w, h) = (px.width() as i32, px.height() as i32);
+ unsafe {
+ let bmi = BITMAPINFO {
+ bmiHeader: BITMAPINFOHEADER {
+ biSize: size_of::() as u32,
+ biWidth: w,
+ biHeight: -h,
+ biPlanes: 1,
+ biBitCount: 32,
+ biCompression: BI_RGB.0,
+ ..Default::default()
+ },
+ ..Default::default()
+ };
+ let mut bits: *mut c_void = null_mut();
+ let Ok(color) = CreateDIBSection(None, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) else { return HICON::default() };
+ // Icons carry plain (not premultiplied) BGRA.
+ let out = std::slice::from_raw_parts_mut(bits.cast::(), (w * h * 4) as usize);
+ for (d, s) in out.chunks_exact_mut(4).zip(px.data().chunks_exact(4)) {
+ let a = s[3] as u32;
+ let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a / 2) / a).min(255) as u8 };
+ d.copy_from_slice(&[un(s[2]), un(s[1]), un(s[0]), s[3]]);
+ }
+ let mask = CreateBitmap(w, h, 1, 1, None);
+ let info = ICONINFO { fIcon: true.into(), xHotspot: 0, yHotspot: 0, hbmMask: mask, hbmColor: color };
+ let icon = CreateIconIndirect(&info).unwrap_or_default();
+ let _ = DeleteObject(color.into());
+ let _ = DeleteObject(mask.into());
+ icon
+ }
+}
+
+/// The logo as a data URL, for the settings page.
+pub fn data_url() -> String {
+ format!("data:image/png;base64,{}", base64(LOGO))
+}
+
+pub fn base64(bytes: &[u8]) -> String {
+ const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
+ for chunk in bytes.chunks(3) {
+ let n = (chunk[0] as u32) << 16 | (*chunk.get(1).unwrap_or(&0) as u32) << 8 | *chunk.get(2).unwrap_or(&0) as u32;
+ out.push(TABLE[(n >> 18) as usize & 63] as char);
+ out.push(TABLE[(n >> 12) as usize & 63] as char);
+ out.push(if chunk.len() > 1 { TABLE[(n >> 6) as usize & 63] as char } else { '=' });
+ out.push(if chunk.len() > 2 { TABLE[n as usize & 63] as char } else { '=' });
+ }
+ out
+}
diff --git a/windows/src/log.rs b/windows/src/log.rs
new file mode 100644
index 0000000..137f6e2
--- /dev/null
+++ b/windows/src/log.rs
@@ -0,0 +1,24 @@
+use std::io::Write;
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+const MAX_BYTES: u64 = 256 * 1024;
+
+fn file() -> Option {
+ std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("TinyHand").join("log.txt"))
+}
+
+/// Appends a line to %APPDATA%\TinyHand\log.txt. Release builds have no console.
+pub fn write(message: &str) {
+ let Some(path) = file() else { return };
+ if let Some(dir) = path.parent() {
+ let _ = std::fs::create_dir_all(dir);
+ }
+ if std::fs::metadata(&path).is_ok_and(|m| m.len() > MAX_BYTES) {
+ let _ = std::fs::remove_file(&path);
+ }
+ let secs = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs());
+ if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
+ let _ = writeln!(f, "[{secs}] {message}");
+ }
+}
diff --git a/windows/src/main.rs b/windows/src/main.rs
new file mode 100644
index 0000000..152d53c
--- /dev/null
+++ b/windows/src/main.rs
@@ -0,0 +1,787 @@
+#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
+
+mod arm;
+mod config;
+mod cursor;
+mod hand;
+mod hook;
+mod icon;
+mod log;
+mod monitor;
+mod prefs;
+mod renderer;
+mod scene;
+mod settings;
+mod sound;
+mod styles;
+mod surface;
+mod thumbs;
+mod toolbar;
+
+use std::cell::RefCell;
+use std::ffi::c_void;
+use std::sync::atomic::{AtomicIsize, Ordering::Relaxed};
+use std::sync::{Arc, Mutex};
+
+use windows::Win32::Foundation::*;
+use windows::Win32::Graphics::Dwm::*;
+use windows::Win32::Graphics::Gdi::{COLOR_WINDOW, GetSysColorBrush};
+use windows::Win32::Media::timeBeginPeriod;
+use windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole, SetConsoleCtrlHandler};
+use windows::Win32::System::LibraryLoader::GetModuleHandleW;
+use windows::Win32::System::Threading::{OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW};
+use windows::Win32::UI::Accessibility::{HWINEVENTHOOK, SetWinEventHook};
+use windows::Win32::UI::HiDpi::*;
+use windows::Win32::UI::Input::KeyboardAndMouse::*;
+use windows::Win32::UI::Shell::*;
+use windows::Win32::UI::WindowsAndMessaging::*;
+use windows::core::{BOOL, PCWSTR, PWSTR, w};
+
+use renderer::{Renderer, lock};
+use toolbar::{Action, Toolbar};
+
+const TIMER_TASKBAR: usize = 1;
+const HK_TOGGLE: i32 = 1;
+const HK_CLEAR: i32 = 2;
+const HK_ESC: i32 = 3;
+const HK_THROUGH: i32 = 4;
+const WM_TRAY: u32 = WM_APP + 10;
+const CMD_TOGGLE: usize = 1;
+const CMD_QUIT: usize = 2;
+const CMD_THROUGH: usize = 3;
+const CMD_SETTINGS: usize = 4;
+
+static HWND_RAW: AtomicIsize = AtomicIsize::new(0);
+
+const TIMER_INITIAL: usize = 3;
+const TIMER_RAISE: usize = 4;
+const TIMER_POPUPS: usize = 5;
+
+struct App {
+ hwnd: HWND,
+ renderer: Arc>,
+ toolbar: Toolbar,
+ active: bool,
+ /// A capture tool has focus. TinyHand steps aside until it leaves.
+ suspended: bool,
+ /// Cursor is in the toolbar's reveal zone.
+ near: bool,
+ /// The toolbar shows for a few seconds after turning on.
+ initial: bool,
+ style_id: String,
+ settings: Option,
+}
+
+const YIELD_TO: [&str; 3] = ["screenclippinghost.exe", "snippingtool.exe", "screensketch.exe"];
+
+/// Lower-cased exe name of the process that owns a window.
+fn process_name(hwnd: HWND) -> Option {
+ if hwnd.0.is_null() {
+ return None;
+ }
+ let mut pid = 0u32;
+ unsafe {
+ GetWindowThreadProcessId(hwnd, Some(&mut pid));
+ let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?;
+ let mut buf = [0u16; 512];
+ let mut len = buf.len() as u32;
+ let ok = QueryFullProcessImageNameW(process, PROCESS_NAME_WIN32, PWSTR(buf.as_mut_ptr()), &mut len).is_ok();
+ let _ = CloseHandle(process);
+ let path = String::from_utf16_lossy(&buf[..len as usize]).to_lowercase();
+ ok.then(|| path.rsplit('\\').next().unwrap_or("").to_string())
+ }
+}
+
+fn should_yield(hwnd: HWND) -> bool {
+ process_name(hwnd).is_some_and(|n| YIELD_TO.contains(&n.as_str()))
+}
+
+const SHELL_HOSTS: [&str; 3] = ["startmenuexperiencehost.exe", "searchhost.exe", "shellexperiencehost.exe"];
+
+/// Windows' own popups. Clicks on them must reach Windows, or menus and flyouts cannot be used.
+fn is_system_popup(hwnd: HWND) -> bool {
+ let mut class = [0u16; 64];
+ let n = unsafe { GetClassNameW(hwnd, &mut class) } as usize;
+ match String::from_utf16_lossy(&class[..n]).as_str() {
+ "#32768" | "Shell_TrayWnd" | "Shell_SecondaryTrayWnd" | "TopLevelWindowForOverflowXamlIsland" | "NotifyIconOverflowWindow" | "XamlExplorerHostIslandWindow" => true,
+ // Apps host their UI in these too, so only the shell's own count.
+ "Windows.UI.Core.CoreWindow" => process_name(hwnd).is_some_and(|n| SHELL_HOSTS.contains(&n.as_str())),
+ _ => false,
+ }
+}
+
+unsafe extern "system" fn collect_popup(hwnd: HWND, lp: LPARAM) -> BOOL {
+ let found = unsafe { &mut *(lp.0 as *mut Vec) };
+ if found.len() < hook::PASS_SLOTS - 2 && unsafe { IsWindowVisible(hwnd) }.as_bool() && is_system_popup(hwnd) {
+ let mut r = RECT::default();
+ if unsafe { GetWindowRect(hwnd, &mut r) }.is_ok() && r.right > r.left && r.bottom > r.top {
+ found.push(r);
+ }
+ }
+ BOOL(1)
+}
+
+/// Keeps the hook's pass-through list up to date with whatever system popups are open.
+fn scan_popups() {
+ let mut found: Vec = Vec::new();
+ unsafe {
+ let _ = EnumWindows(Some(collect_popup), LPARAM((&raw mut found) as isize));
+ }
+ for slot in 0..hook::PASS_SLOTS - 2 {
+ hook::set_passthrough(2 + slot, found.get(slot).copied().unwrap_or_default());
+ }
+}
+
+unsafe extern "system" fn on_foreground(_: HWINEVENTHOOK, _: u32, hwnd: HWND, _: i32, _: i32, _: u32, _: u32) {
+ if hwnd.0.is_null() {
+ return;
+ }
+ let yielding = should_yield(hwnd);
+ APP.with(|cell| {
+ if let Some(app) = cell.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) {
+ app.set_suspended(yielding);
+ app.raise();
+ }
+ });
+}
+
+thread_local! {
+ static APP: RefCell