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 [![Latest release](https://img.shields.io/github/v/release/nilbuild/tinyhand?display_name=tag&sort=semver&cacheSeconds=3600)](https://github.com/nilbuild/tinyhand/releases/latest) -[![Platform: macOS](https://img.shields.io/badge/platform-macOS-lightgrey)](https://tinyhand.app) +[![Platform: macOS and Windows](https://img.shields.io/badge/platform-macOS%20%7C%20Windows-lightgrey)](https://tinyhand.app) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./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. TinyHand: a tiny hand for your Mac [![Download for macOS](https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/nilbuild/tinyhand/releases/latest/download/TinyHand.dmg) [![Visit website](https://img.shields.io/badge/Visit_website-ffffff?style=for-the-badge&logo=safari&logoColor=black)](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> = const { RefCell::new(None) }; +} + +impl App { + fn toggle(&mut self) { + if self.active { + self.deactivate(); + } else { + self.activate(); + } + } + + fn activate(&mut self) { + self.active = true; + self.suspended = should_yield(unsafe { GetForegroundWindow() }); + if !self.suspended { + self.start(); + } + } + + fn deactivate(&mut self) { + let running = self.active && !self.suspended; + self.active = false; + if running { + self.stop(); + } + } + + fn set_suspended(&mut self, suspended: bool) { + if suspended == self.suspended { + return; + } + self.suspended = suspended; + if self.active { + if suspended { + self.stop(); + } else { + self.start(); + } + } + } + + fn start(&mut self) { + lock(&self.renderer).reset(); + self.refresh_taskbar(); + scan_popups(); + hook::reset_keys(); + hook::HELD.store(false, Relaxed); + hook::ACTIVE.store(true, Relaxed); + cursor::hide(); + unsafe { + let _ = RegisterHotKey(Some(self.hwnd), HK_ESC, MOD_NOREPEAT, VK_ESCAPE.0 as u32); + SetTimer(Some(self.hwnd), TIMER_TASKBAR, 500, None); + SetTimer(Some(self.hwnd), TIMER_INITIAL, 3000, None); + SetTimer(Some(self.hwnd), TIMER_RAISE, 250, None); + SetTimer(Some(self.hwnd), TIMER_POPUPS, 100, None); + let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); + } + sound::open(); + self.near = false; + self.initial = true; + self.toolbar.show(); + self.toolbar.set_wanted(true); + hook::wake(); + } + + fn sound_key(&self) -> &'static str { + if self.style_id == "laser" { "sound_laser" } else { "sound_hands" } + } + + /// Hands tap by default and the laser stays quiet, each with its own switch. + fn refresh_sound(&mut self) { + let on = prefs::flag(self.sound_key(), self.style_id != "laser"); + sound::set_enabled(on); + self.toolbar.set_sound(on); + } + + fn toggle_sound(&mut self) { + let key = self.sound_key(); + prefs::set_flag(key, !prefs::flag(key, self.style_id != "laser")); + self.refresh_sound(); + } + + fn toggle_click_through(&mut self) { + let on = !hook::CLICK_THROUGH.load(Relaxed); + hook::CLICK_THROUGH.store(on, Relaxed); + hook::cancel_pending(); + if hook::HELD.swap(false, Relaxed) { + hook::UP_SEQ.fetch_add(1, Relaxed); + } + self.toolbar.set_click_through(on); + prefs::set_flag("click_through", on); + hook::wake(); + } + + fn pick(&mut self, id: &str) { + let (style, ink) = styles::load(id); + lock(&self.renderer).set_style(style, ink); + prefs::save_style(id); + let pinned = config::pinned(); + let rebuild = !pinned.iter().any(|p| *p == self.style_id) || !pinned.iter().any(|p| p == id); + self.style_id = id.to_string(); + if rebuild { + let list = self.toolbar_styles(); + self.toolbar.set_styles(&list); + } + self.toolbar.select(id); + self.refresh_sound(); + hook::wake(); + } + + fn stop(&mut self) { + hook::ACTIVE.store(false, Relaxed); + hook::HELD.store(false, Relaxed); + hook::reset_keys(); + cursor::restore(); + lock(&self.renderer).reset(); + sound::close(); + for slot in 2..hook::PASS_SLOTS { + hook::set_passthrough(slot, RECT::default()); + } + self.toolbar.hide(); + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER_TASKBAR); + let _ = KillTimer(Some(self.hwnd), TIMER_INITIAL); + let _ = KillTimer(Some(self.hwnd), TIMER_RAISE); + let _ = KillTimer(Some(self.hwnd), TIMER_POPUPS); + let _ = UnregisterHotKey(Some(self.hwnd), HK_ESC); + let _ = ShowWindow(self.hwnd, SW_HIDE); + } + } + + /// Some windows (Paint) open above even topmost windows, which hides the hand + /// while the real cursor is already blanked. Put both back on top, toolbar last. + fn raise(&self) { + if !self.active || self.suspended { + return; + } + let place = |hwnd: HWND| unsafe { + let _ = SetWindowPos(hwnd, Some(HWND_TOPMOST), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + }; + place(self.hwnd); + place(self.toolbar.hwnd); + } + + fn refresh_taskbar(&self) { + let mut r = RECT::default(); + unsafe { + if let Ok(bar) = FindWindowW(w!("Shell_TrayWnd"), PCWSTR::null()) { + let _ = GetWindowRect(bar, &mut r); + } + } + hook::set_passthrough(0, r); + } + + fn on_message(&mut self, hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> Option { + if msg == WM_ACTIVATE && (hwnd == self.hwnd || hwnd == self.toolbar.hwnd) && wp.0 & 0xFFFF != 0 { + self.give_back_focus(HWND(lp.0 as *mut c_void)); + return Some(LRESULT(0)); + } + if self.settings.as_ref().is_some_and(|s| s.hwnd == hwnd) { + match msg { + WM_SIZE => self.settings.as_ref()?.resize(), + WM_CLOSE => self.close_settings(), + _ => return None, + } + return Some(LRESULT(0)); + } + if hwnd == self.toolbar.hwnd { + let (result, action) = self.toolbar.handle(msg, wp, lp)?; + match action { + Some(Action::Pick(id)) => self.pick(&id), + Some(Action::ToggleClickThrough) => self.toggle_click_through(), + Some(Action::ToggleSound) => self.toggle_sound(), + Some(Action::Exit) => self.deactivate(), + None => {} + } + return Some(result); + } + match msg { + WM_TIMER if wp.0 == TIMER_INITIAL => { + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER_INITIAL); + } + self.initial = false; + self.toolbar.set_wanted(self.near); + } + WM_TIMER if wp.0 == TIMER_RAISE => self.raise(), + WM_TIMER if wp.0 == TIMER_POPUPS => scan_popups(), + WM_TIMER => self.refresh_taskbar(), + hook::WM_REPLAY_CLICK => hook::send_click(lp, wp.0), + hook::WM_REPLAY_KEY => hook::send_alt_and_key(wp.0, lp), + hook::WM_INTERACT => { + if self.active && !self.suspended { + if wp.0 != 0 { + cursor::restore(); + } else { + cursor::hide(); + } + } + } + toolbar::WM_SCREEN_CHANGED => { + if self.active && !self.suspended { + self.toolbar.follow_cursor(); + } + } + toolbar::WM_TB_WANT => { + self.near = wp.0 != 0; + if self.active && !self.suspended { + self.toolbar.set_wanted(self.near || self.initial); + } + } + hook::WM_PRESENT => { + hook::PRESENT_PENDING.store(false, Relaxed); + if self.active { + lock(&self.renderer).present(); + } + } + WM_HOTKEY => match wp.0 as i32 { + HK_TOGGLE => self.toggle(), + HK_CLEAR => { + lock(&self.renderer).clear_ink(); + hook::wake(); + } + HK_ESC => self.deactivate(), + HK_THROUGH => self.toggle_click_through(), + _ => {} + }, + WM_TRAY => { + if (lp.0 as u32) & 0xFFFF == WM_LBUTTONUP { + self.toggle(); + } + } + WM_COMMAND => self.command(wp.0 & 0xFFFF), + _ => return None, + } + Some(LRESULT(0)) + } + + fn command(&mut self, cmd: usize) { + match cmd { + CMD_TOGGLE => self.toggle(), + CMD_THROUGH => self.toggle_click_through(), + CMD_SETTINGS => self.open_settings(), + CMD_QUIT => unsafe { PostQuitMessage(0) }, + _ => {} + } + } + + fn open_settings(&mut self) { + if let Some(s) = &self.settings { + s.focus(); + return; + } + // Settings is an ordinary window, so the pointer turns off first, as on the Mac. + self.deactivate(); + self.settings = settings::SettingsWindow::open(|message| { + APP.with(|c| { + if let Some(app) = c.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) { + app.on_settings_message(&message); + } + }); + }); + } + + fn close_settings(&mut self) { + self.settings = None; + } + + /// The pinned pointers, plus the current one if it is not pinned, as on the Mac. + fn toolbar_styles(&self) -> Vec { + let mut list = config::pinned(); + if !list.contains(&self.style_id) { + list.push(self.style_id.clone()); + } + list + } + + fn push_settings(&self) { + let Some(s) = &self.settings else { return }; + let mut state = config::snapshot(); + state["style"] = serde_json::json!(self.style_id); + state["pinned"] = serde_json::json!(config::pinned()); + state["max_pinned"] = serde_json::json!(config::MAX_PINNED); + state["hands"] = serde_json::Value::Array(styles::all().map(|(id, title)| serde_json::json!({ "id": id, "title": title })).collect()); + s.send(&format!("window.__state({state})")); + s.send(&format!("window.__thumbs({})", serde_json::json!(thumbs::all()))); + } + + fn notice(&self, text: &str) { + if let Some(s) = &self.settings { + s.send(&format!("window.__notice({})", serde_json::json!(text))); + } + } + + fn apply_pinned(&mut self) { + let list = self.toolbar_styles(); + self.toolbar.set_styles(&list); + self.toolbar.select(&self.style_id.clone()); + self.push_settings(); + } + + fn pin(&mut self, id: &str) { + let mut list = config::pinned(); + if let Some(at) = list.iter().position(|x| x == id) { + if list.len() == 1 { + return self.notice("Keep at least one pointer in the toolbar."); + } + list.remove(at); + } else if !styles::is_known(id) { + return; + } else if list.len() >= config::MAX_PINNED { + return self.notice("The toolbar is full. Remove one first."); + } else { + list.push(id.to_string()); + } + config::set_pinned(&list); + self.apply_pinned(); + } + + fn reorder(&mut self, ids: &[String]) { + let mut list: Vec = Vec::new(); + for id in ids { + if styles::is_known(id) && !list.contains(id) && list.len() < config::MAX_PINNED { + list.push(id.clone()); + } + } + if !list.is_empty() { + config::set_pinned(&list); + self.apply_pinned(); + } + } + + fn on_settings_message(&mut self, message: &str) { + let Ok(msg) = serde_json::from_str::(message) else { return }; + match msg["type"].as_str() { + Some("get") | Some("thumbs") => self.push_settings(), + Some("pin") => self.pin(msg["id"].as_str().unwrap_or("")), + Some("reorder") => { + let ids: Vec = msg["ids"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(); + self.reorder(&ids); + } + Some("pick") => { + let id = msg["id"].as_str().unwrap_or("").to_string(); + if styles::is_known(&id) { + self.pick(&id); + self.push_settings(); + } + } + Some("set") => self.set_setting(msg["key"].as_str().unwrap_or(""), &msg["value"]), + Some("preview_sound") => sound::preview(msg["name"].as_str().unwrap_or("")), + Some("open_url") => open_url(msg["url"].as_str().unwrap_or("")), + Some("reset") => { + config::reset(); + sound::set_choice(&prefs::tap_sound()); + self.refresh_sound(); + lock(&self.renderer).refresh(); + self.push_settings(); + } + _ => {} + } + } + + fn set_setting(&mut self, key: &str, value: &serde_json::Value) { + match key { + "tap_sound" => { + let name = value.as_str().unwrap_or("thock"); + if sound::CHOICES.contains(&name) { + prefs::set_str("tap_sound", name); + sound::set_choice(name); + sound::preview(name); + } + } + "sound_hands" | "sound_laser" => { + prefs::set_flag(key, value.as_bool().unwrap_or(false)); + self.refresh_sound(); + } + "click_through" => { + if value.as_bool().unwrap_or(false) != hook::CLICK_THROUGH.load(Relaxed) { + self.toggle_click_through(); + } + } + _ => { + if config::set(key, value) { + lock(&self.renderer).refresh(); + hook::wake(); + } + } + } + } + + /// The full-screen overlay must never hold keyboard focus. When Windows hands it over + /// (for example after a flyout closes), pass it back to the window that had it. + fn give_back_focus(&self, previous: HWND) { + let ours = |h: HWND| h == self.hwnd || h == self.toolbar.hwnd; + unsafe { + if !previous.0.is_null() && !ours(previous) && IsWindowVisible(previous).as_bool() { + let _ = SetForegroundWindow(previous); + return; + } + let mut next = GetWindow(self.hwnd, GW_HWNDNEXT).ok(); + while let Some(w) = next { + let tool = GetWindowLongPtrW(w, GWL_EXSTYLE) & WS_EX_TOOLWINDOW.0 as isize != 0; + if !ours(w) && IsWindowVisible(w).as_bool() && !tool && GetWindowTextLengthW(w) > 0 { + let _ = SetForegroundWindow(w); + return; + } + next = GetWindow(w, GW_HWNDNEXT).ok(); + } + } + } +} + +/// Opens a link in the default browser. Only TinyHand's own sites, never arbitrary input. +fn open_url(url: &str) { + let allowed = ["https://github.com/nilbuild/", "https://github.com/MUL7ii/", "https://tinyhand.app", "https://kamran.fyi"]; + if !allowed.iter().any(|p| url.starts_with(p)) { + return; + } + let wide: Vec = url.encode_utf16().chain(std::iter::once(0)).collect(); + unsafe { + ShellExecuteW(None, w!("open"), PCWSTR(wide.as_ptr()), PCWSTR::null(), PCWSTR::null(), SW_SHOWNORMAL); + } +} + +/// Runs outside the app borrow and its own tiny window. While it is open the overlay keeps +/// redrawing and every click goes through, so the menu can actually be used. +fn show_tray_menu(tray: HWND) { + let active = APP.with(|c| c.try_borrow().ok().and_then(|s| s.as_ref().map(|a| a.active))).unwrap_or(false); + unsafe { + let Ok(menu) = CreatePopupMenu() else { return }; + let label = if active { w!("Turn pointer off") } else { w!("Turn pointer on") }; + let _ = AppendMenuW(menu, MF_STRING, CMD_TOGGLE, label); + let checked = if hook::CLICK_THROUGH.load(Relaxed) { MF_CHECKED } else { MF_UNCHECKED }; + let _ = AppendMenuW(menu, MF_STRING | checked, CMD_THROUGH, w!("Click through\tCtrl+Alt+C")); + let _ = AppendMenuW(menu, MF_STRING, CMD_SETTINGS, w!("Settings…")); + let _ = AppendMenuW(menu, MF_STRING, CMD_QUIT, w!("Quit TinyHand")); + let mut p = POINT::default(); + let _ = GetCursorPos(&mut p); + hook::PAUSE.store(true, Relaxed); + if active { + cursor::restore(); + } + let _ = SetForegroundWindow(tray); + let cmd = TrackPopupMenu(menu, TPM_RIGHTBUTTON | TPM_RETURNCMD | TPM_NONOTIFY, p.x, p.y, Some(0), tray, None); + let _ = PostMessageW(Some(tray), WM_NULL, WPARAM(0), LPARAM(0)); + let _ = DestroyMenu(menu); + hook::PAUSE.store(false, Relaxed); + APP.with(|c| { + if let Some(app) = c.try_borrow_mut().ok().as_mut().and_then(|s| s.as_mut()) { + if app.active && !app.suspended { + cursor::hide(); + } + app.command(cmd.0 as usize); + } + }); + } +} + +unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wp: WPARAM, lp: LPARAM) -> LRESULT { + if msg == WM_TRAY && (lp.0 as u32) & 0xFFFF == WM_RBUTTONUP { + show_tray_menu(hwnd); + return LRESULT(0); + } + // try_borrow_mut: ShowWindow and friends re-enter this proc. + let handled = APP.with(|cell| { + let mut slot = cell.try_borrow_mut().ok()?; + slot.as_mut()?.on_message(hwnd, msg, wp, lp) + }); + handled.unwrap_or_else(|| unsafe { DefWindowProcW(hwnd, msg, wp, lp) }) +} + +fn tray_data(hwnd: HWND) -> NOTIFYICONDATAW { + NOTIFYICONDATAW { cbSize: size_of::() as u32, hWnd: hwnd, uID: 1, ..Default::default() } +} + +fn add_tray(hwnd: HWND) { + let mut nid = tray_data(hwnd); + nid.uFlags = NIF_MESSAGE | NIF_ICON | NIF_TIP; + nid.uCallbackMessage = WM_TRAY; + nid.hIcon = icon::logo_for(SM_CXSMICON); + for (d, s) in nid.szTip.iter_mut().zip("TinyHand".encode_utf16()) { + *d = s; + } + unsafe { + let _ = Shell_NotifyIconW(NIM_ADD, &nid); + } +} + +fn remove_tray(hwnd: HWND) { + unsafe { + let _ = Shell_NotifyIconW(NIM_DELETE, &tray_data(hwnd)); + } +} + +/// Ctrl+C in a dev console: put the cursors back before dying. +unsafe extern "system" fn on_ctrl(_: u32) -> BOOL { + cursor::restore(); + remove_tray(HWND(HWND_RAW.load(Relaxed) as *mut c_void)); + std::process::exit(0) +} + +fn main() { + cursor::restore(); + let default_panic = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + cursor::restore(); + let thread = std::thread::current(); + log::write(&format!("panic on '{}': {info}\n{}", thread.name().unwrap_or("?"), std::backtrace::Backtrace::force_capture())); + default_panic(info); + })); + + unsafe { + let _ = SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + timeBeginPeriod(1); + + let hinst = HINSTANCE(GetModuleHandleW(None).expect("module").0); + let class = w!("TinyHandOverlay"); + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: class, + ..Default::default() + }); + + let (w, h) = (GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN)); + let hwnd = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + class, + w!("TinyHand"), + WS_POPUP, + 0, + 0, + w, + h, + None, + None, + Some(hinst), + None, + ) + .expect("overlay window"); + HWND_RAW.store(hwnd.0 as isize, Relaxed); + + // Without this the shell fades the window out on hide, leaving old ink on screen. + let off = BOOL(1); + let _ = DwmSetWindowAttribute(hwnd, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); + + let bar_class = w!("TinyHandToolbar"); + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: bar_class, + ..Default::default() + }); + let bar = CreateWindowExW( + WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW, + bar_class, + w!("TinyHand toolbar"), + WS_POPUP, + 0, + 0, + 1, + 1, + None, + None, + Some(hinst), + None, + ) + .expect("toolbar window"); + let _ = DwmSetWindowAttribute(bar, DWMWA_TRANSITIONS_FORCEDISABLED, (&raw const off).cast(), size_of::() as u32); + + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: settings::CLASS, + hCursor: LoadCursorW(None, IDC_ARROW).unwrap_or_default(), + hbrBackground: GetSysColorBrush(COLOR_WINDOW), + ..Default::default() + }); + + // The tray icon and its menu get their own tiny window, so the overlay never takes focus. + let tray_class = w!("TinyHandTray"); + RegisterClassExW(&WNDCLASSEXW { + cbSize: size_of::() as u32, + lpfnWndProc: Some(wndproc), + hInstance: hinst, + lpszClassName: tray_class, + ..Default::default() + }); + let tray = CreateWindowExW(WS_EX_TOOLWINDOW, tray_class, w!("TinyHand tray"), WS_POPUP, 0, 0, 0, 0, None, None, Some(hinst), None).expect("tray window"); + HWND_RAW.store(tray.0 as isize, Relaxed); + + config::load(); + let id = std::env::args().nth(1).or_else(prefs::load_style).unwrap_or_else(|| "lavender".into()); + let (style, ink) = styles::load(&id); + let mut shown = config::pinned(); + if !shown.contains(&id) { + shown.push(id.clone()); + } + let shown: Vec<&str> = shown.iter().map(String::as_str).collect(); + let mut toolbar = Toolbar::new(bar, &shown, &id); + thumbs::build_in_background(); + let through = prefs::flag("click_through", false); + hook::CLICK_THROUGH.store(through, Relaxed); + toolbar.set_click_through(through); + sound::load(); + sound::set_choice(&prefs::tap_sound()); + let renderer = Arc::new(Mutex::new(Renderer::new(hwnd, w, h, style, ink))); + let mut app = App { hwnd, renderer: renderer.clone(), toolbar, active: false, suspended: false, near: false, initial: false, style_id: id.clone(), settings: None }; + app.refresh_sound(); + APP.with(|a| *a.borrow_mut() = Some(app)); + SetWinEventHook(EVENT_SYSTEM_FOREGROUND, EVENT_SYSTEM_FOREGROUND, None, Some(on_foreground), 0, 0, WINEVENT_OUTOFCONTEXT); + hook::init(); + hook::start(hwnd); + let _ = std::thread::Builder::new().name("render".into()).spawn(move || renderer::run(renderer)); + add_tray(tray); + // Release builds have no console of their own. Borrow the terminal's so Ctrl+C reaches us. + let _ = AttachConsole(ATTACH_PARENT_PROCESS); + let _ = SetConsoleCtrlHandler(Some(on_ctrl), true); + + let mods = MOD_CONTROL | MOD_ALT | MOD_NOREPEAT; + let _ = RegisterHotKey(Some(hwnd), HK_TOGGLE, mods, 'H' as u32); + let _ = RegisterHotKey(Some(hwnd), HK_CLEAR, mods, 'X' as u32); + let _ = RegisterHotKey(Some(hwnd), HK_THROUGH, mods, 'C' as u32); + + let mut msg = MSG::default(); + while GetMessageW(&mut msg, None, 0, 0).as_bool() { + let _ = TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + hook::ACTIVE.store(false, Relaxed); + cursor::restore(); + remove_tray(tray); + } +} diff --git a/windows/src/monitor.rs b/windows/src/monitor.rs new file mode 100644 index 0000000..960dfad --- /dev/null +++ b/windows/src/monitor.rs @@ -0,0 +1,24 @@ +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}; + +pub struct Info { + /// The whole monitor, in virtual-desktop pixels. + pub full: RECT, + /// The monitor minus the taskbar. + pub work: RECT, + /// Display scaling: 1.0 is 100%. + pub scale: f32, +} + +/// The monitor under a point. +pub fn at(p: (f32, f32)) -> Info { + unsafe { + let monitor = MonitorFromPoint(POINT { x: p.0 as i32, y: p.1 as i32 }, MONITOR_DEFAULTTONEAREST); + let mut info = MONITORINFO { cbSize: size_of::() as u32, ..Default::default() }; + let _ = GetMonitorInfoW(monitor, &mut info); + let (mut dx, mut dy) = (96u32, 96u32); + let _ = GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dx, &mut dy); + Info { full: info.rcMonitor, work: info.rcWork, scale: dx.max(96) as f32 / 96.0 } + } +} diff --git a/windows/src/prefs.rs b/windows/src/prefs.rs new file mode 100644 index 0000000..ab8e416 --- /dev/null +++ b/windows/src/prefs.rs @@ -0,0 +1,74 @@ +use std::path::PathBuf; +use std::sync::Mutex; + +fn file() -> Option { + std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("TinyHand").join("settings.txt")) +} + +/// Settings live in memory and are written back whole on every change, so hot paths never touch disk. +static CACHE: Mutex>> = Mutex::new(None); + +fn with(f: impl FnOnce(&mut Vec<(String, String)>) -> T) -> T { + let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let all = guard.get_or_insert_with(|| { + let text = file().and_then(|p| std::fs::read_to_string(p).ok()).unwrap_or_default(); + text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).collect() + }); + f(all) +} + +fn save(all: &[(String, String)]) { + let Some(path) = file() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let text: String = all.iter().map(|(k, v)| format!("{k}={v}\n")).collect(); + let _ = std::fs::write(path, text); +} + +pub fn get_str(key: &str) -> Option { + with(|all| all.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone())).filter(|v| !v.is_empty()) +} + +pub fn set_str(key: &str, value: &str) { + with(|all| { + all.retain(|(k, _)| k != key); + all.push((key.into(), value.into())); + save(all); + }); +} + +pub fn remove(key: &str) { + with(|all| { + all.retain(|(k, _)| k != key); + save(all); + }); +} + +pub fn get_f32(key: &str) -> Option { + get_str(key)?.parse().ok() +} + +pub fn set_f32(key: &str, value: f32) { + set_str(key, &value.to_string()); +} + +pub fn load_style() -> Option { + get_str("style") +} + +pub fn save_style(id: &str) { + set_str("style", id); +} + +pub fn tap_sound() -> String { + get_str("tap_sound").unwrap_or_else(|| "thock".into()) +} + +pub fn flag(key: &str, default: bool) -> bool { + get_str(key).map_or(default, |v| v == "1") +} + +pub fn set_flag(key: &str, on: bool) { + set_str(key, if on { "1" } else { "0" }); +} diff --git a/windows/src/renderer.rs b/windows/src/renderer.rs new file mode 100644 index 0000000..0ecbe24 --- /dev/null +++ b/windows/src/renderer.rs @@ -0,0 +1,324 @@ +use std::ffi::c_void; +use std::sync::atomic::Ordering::Relaxed; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::Instant; + +use tiny_skia::PixmapMut; +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Dwm::DwmFlush; +use windows::Win32::System::Threading::*; +use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, PostMessageW}; + +use crate::hook; +use crate::scene::{Ink, Pending, Rc, draw_laser}; +use crate::styles::Style; +use crate::toolbar::{WM_SCREEN_CHANGED, WM_TB_WANT}; +use crate::surface::Surface; + +const DRAG_THRESHOLD: f32 = 4.0; +const TICK_HZ: f32 = 120.0; + +pub enum Next { + Frame, + Sleep(Option), +} + +pub struct Renderer { + hwnd: HWND, + surface: Surface, + ink: Ink, + style: Style, + style_ink: u32, + cursor: (f32, f32), + key: Vec, + shape: Option, + down_at: (f32, f32), + press: f32, + held: bool, + stale_press: bool, + down_seq: u32, + up_seq: u32, + last: Instant, + drew: bool, + near_toolbar: bool, +} + +// The raw HWND and DIB pointer are only used behind the mutex. +unsafe impl Send for Renderer {} + +fn cursor_pos() -> (f32, f32) { + let mut p = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut p); + } + (p.x as f32, p.y as f32) +} + +fn monitor_rect(at: (f32, f32)) -> RECT { + crate::monitor::at(at).full +} + +impl Renderer { + pub fn new(hwnd: HWND, w: i32, h: i32, style: Style, ink_rgb: u32) -> Self { + let mut ink = Ink::new(w, h); + ink.set_color(crate::config::ink_override().unwrap_or(ink_rgb)); + Self { + hwnd, + surface: Surface::new(w, h), + ink, + style, + style_ink: ink_rgb, + cursor: (0.0, 0.0), + key: vec![], + shape: None, + down_at: (0.0, 0.0), + press: 0.0, + held: false, + stale_press: false, + down_seq: 0, + up_seq: 0, + last: Instant::now(), + drew: false, + near_toolbar: false, + } + } + + /// Follows the cursor onto another monitor. Returns true when the screen changed. + fn sync_screen(&mut self, at: (f32, f32)) -> bool { + let r = monitor_rect(at); + let (w, h) = (r.right - r.left, r.bottom - r.top); + if w <= 0 || h <= 0 || ((r.left, r.top) == self.surface.origin && (w, h) == (self.surface.w, self.surface.h)) { + return false; + } + self.surface = Surface::new(w, h); + self.surface.origin = (r.left, r.top); + self.ink.resize(w, h); + self.key.clear(); + self.shape = None; + self.drew = true; + true + } + + pub fn set_style(&mut self, style: Style, ink_rgb: u32) { + self.style = style; + self.style_ink = ink_rgb; + self.refresh(); + } + + /// Picks up changed settings: ink color, pointer size, shadow. + pub fn refresh(&mut self) { + self.ink.set_color(crate::config::ink_override().unwrap_or(self.style_ink)); + self.key.clear(); + } + + /// Blank slate and an empty frame. Window thread only. + pub fn reset(&mut self) { + self.near_toolbar = false; + self.sync_screen(cursor_pos()); + self.stale_press = false; + self.surface.clear(); + self.ink.clear(); + self.ink.take_dirty(); + self.key.clear(); + self.shape = None; + self.press = 0.0; + self.held = false; + self.cursor = cursor_pos(); + self.down_seq = hook::DOWN_SEQ.load(Relaxed); + self.up_seq = hook::UP_SEQ.load(Relaxed); + self.last = Instant::now(); + self.surface.present_full(self.hwnd); + } + + pub fn clear_ink(&mut self) { + self.ink.clear(); + } + + pub fn cursor_moved(&self) -> bool { + cursor_pos() != self.cursor + } + + pub fn frame(&mut self) -> Next { + let dt = self.last.elapsed().as_secs_f32(); + self.last = Instant::now(); + let global = cursor_pos(); + let mut busy = global != self.cursor; + self.cursor = global; + if self.sync_screen(global) { + // A press that began on the old screen cannot carry a stroke onto this one. + self.stale_press = hook::HELD.load(Relaxed); + unsafe { + let _ = PostMessageW(Some(self.hwnd), WM_SCREEN_CHANGED, WPARAM(0), LPARAM(0)); + } + } + let (ox, oy) = (self.surface.origin.0 as f32, self.surface.origin.1 as f32); + let at = (global.0 - ox, global.1 - oy); + + let down = hook::DOWN_SEQ.load(Relaxed); + let up = hook::UP_SEQ.load(Relaxed); + if up != self.up_seq { + self.up_seq = up; + self.ink.end(); + } + if down != self.down_seq { + self.down_seq = down; + self.press = 1.0; + self.stale_press = false; + self.down_at = (hook::DOWN_X.load(Relaxed) as f32 - ox, hook::DOWN_Y.load(Relaxed) as f32 - oy); + } + self.held = hook::HELD.load(Relaxed); + if !self.held { + self.stale_press = false; + } + + if self.held { + busy = true; + if self.stale_press { + } else if self.ink.is_drawing() { + self.ink.extend(at); + } else if (at.0 - self.down_at.0).hypot(at.1 - self.down_at.1) >= DRAG_THRESHOLD { + self.ink.begin(self.down_at); + self.ink.extend(at); + } + } else if self.press > 0.002 { + self.press *= 0.82f32.powf(dt * TICK_HZ); + busy = true; + } else { + self.press = 0.0; + } + busy |= self.ink.tick(); + + let near = !self.ink.is_drawing() && hook::in_zone(global.0 as i32, global.1 as i32); + if near != self.near_toolbar { + self.near_toolbar = near; + unsafe { + let _ = PostMessageW(Some(self.hwnd), WM_TB_WANT, WPARAM(near as usize), LPARAM(0)); + } + } + + busy |= self.draw(at, dt); + + if busy { + return Next::Frame; + } + match self.ink.pending() { + Pending::Fading => Next::Frame, + Pending::After(d) => Next::Sleep(Some(d.as_millis() as u32 + 1)), + Pending::Nothing => Next::Sleep(None), + } + } + + /// Returns true while the pointer is still easing toward its target. + fn draw(&mut self, at: (f32, f32), dt: f32) -> bool { + let (w, h) = (self.surface.w, self.surface.h); + let screen = (w as f32, h as f32); + + let mut pose = None; + let (key, shape, settling) = match &mut self.style { + Style::Hand(arm) => { + let p = arm.aim(at, screen, self.press, crate::config::pointer_size(), dt); + pose = Some(p); + (p.key(), arm.bbox(&p, screen.1), arm.settling()) + } + Style::Laser => { + let r = laser_radius(screen.1, self.press); + (vec![at.0 as i32 * 100, at.1 as i32 * 100, (r * 100.0) as i32], Rc::around(at.0, at.1, r * 3.2 + 3.0), false) + } + }; + + // Holding Alt shows the real cursor, so the pointer steps out of the way. + let hidden = hook::INTERACT.load(Relaxed); + let mut key = key; + key.push(hidden as i32); + let shape = (!hidden).then_some(shape); + + let ink_dirty = self.ink.take_dirty(); + if key == self.key && ink_dirty.is_none() { + return settling; + } + let dirty = [ink_dirty, shape, self.shape].into_iter().flatten().reduce(Rc::union); + self.key = key; + self.shape = shape; + let Some(d) = dirty.and_then(|d| d.clip(w, h)) else { return settling }; + + let stride = w as usize * 4; + let src = self.ink.layer.data(); + let dst = self.surface.bytes_mut(); + for y in d.t as usize..d.b as usize { + let range = y * stride + d.l as usize * 4..y * stride + d.r as usize * 4; + dst[range.clone()].copy_from_slice(&src[range]); + } + if hidden { + self.drew = true; + return settling; + } + if let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) { + match (&self.style, pose) { + (Style::Hand(arm), Some(p)) => arm.draw(&mut pix, &p, screen.1, crate::config::shadow()), + _ => draw_laser(&mut pix, at, laser_radius(screen.1, self.press)), + } + } + self.drew = true; + settling + } + + pub fn present(&self) { + self.surface.present(self.hwnd); + } + + pub fn hwnd_raw(&self) -> isize { + self.hwnd.0 as isize + } +} + +fn laser_radius(screen_h: f32, press: f32) -> f32 { + (screen_h * 0.0038).max(4.0) * (1.0 + 0.2 * press) +} + +pub fn lock(r: &Mutex) -> MutexGuard<'_, Renderer> { + r.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Frames follow the compositor's vsync. Idle blocks on an event, so CPU is zero. +pub fn run(shared: Arc>) { + unsafe { + let _ = SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_ABOVE_NORMAL); + } + loop { + if !hook::ACTIVE.load(Relaxed) { + hook::IDLE.store(true, Relaxed); + hook::wait(INFINITE); + continue; + } + let frame = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut r = lock(&shared); + let next = r.frame(); + (next, std::mem::take(&mut r.drew), r.hwnd_raw()) + })); + // A panic here would freeze the overlay while the hook keeps swallowing clicks. Log it and carry on. + let Ok((next, drew, hwnd)) = frame else { + crate::log::write("render frame panicked, retrying"); + std::thread::sleep(std::time::Duration::from_millis(100)); + continue; + }; + if drew && !hook::PRESENT_PENDING.swap(true, Relaxed) { + unsafe { + let _ = PostMessageW(Some(HWND(hwnd as *mut c_void)), hook::WM_PRESENT, WPARAM(0), LPARAM(0)); + } + } + match next { + Next::Frame => unsafe { + let _ = DwmFlush(); + }, + Next::Sleep(ms) => { + hook::IDLE.store(true, Relaxed); + if lock(&shared).cursor_moved() { + hook::IDLE.store(false, Relaxed); + continue; + } + hook::wait(ms.unwrap_or(INFINITE)); + hook::IDLE.store(false, Relaxed); + } + } + } +} diff --git a/windows/src/scene.rs b/windows/src/scene.rs new file mode 100644 index 0000000..0951439 --- /dev/null +++ b/windows/src/scene.rs @@ -0,0 +1,247 @@ +use std::time::{Duration, Instant}; + +use tiny_skia::*; + +const FADE_FOR: f32 = 0.6; + +/// Every buffer here is BGRA, so colours swap red and blue. +pub fn px(r: u8, g: u8, b: u8, a: u8) -> Color { + Color::from_rgba8(b, g, r, a) +} + +#[derive(Clone, Copy)] +pub struct Rc { + pub l: i32, + pub t: i32, + pub r: i32, + pub b: i32, +} + +impl Rc { + pub fn around(x: f32, y: f32, rad: f32) -> Self { + Self { l: (x - rad).floor() as i32, t: (y - rad).floor() as i32, r: (x + rad).ceil() as i32, b: (y + rad).ceil() as i32 } + } + + pub fn union(self, o: Rc) -> Rc { + Rc { l: self.l.min(o.l), t: self.t.min(o.t), r: self.r.max(o.r), b: self.b.max(o.b) } + } + + pub fn clip(self, w: i32, h: i32) -> Option { + let c = Rc { l: self.l.max(0), t: self.t.max(0), r: self.r.min(w), b: self.b.min(h) }; + (c.l < c.r && c.t < c.b).then_some(c) + } +} + +pub fn union_opt(a: Option, b: Rc) -> Rc { + a.map_or(b, |a| a.union(b)) +} + +struct Stroke { + pts: Vec<(f32, f32)>, + ended: Option, +} + +impl Stroke { + fn age(&self) -> Option { + self.ended.map(|t| t.elapsed().as_secs_f32()) + } + + fn alpha(&self) -> f32 { + match (self.age(), crate::config::fade_delay()) { + (Some(a), Some(delay)) => 1.0 - ((a - delay) / FADE_FOR).clamp(0.0, 1.0), + _ => 1.0, + } + } + + fn fading(&self) -> bool { + matches!((self.age(), crate::config::fade_delay()), (Some(a), Some(delay)) if a > delay) + } + + fn path(&self) -> Option { + let mut pb = PathBuilder::new(); + let first = self.pts.first()?; + pb.move_to(first.0, first.1); + if self.pts.len() == 1 { + pb.line_to(first.0, first.1); + } + for w in self.pts.windows(2) { + let (a, b) = (w[0], w[1]); + pb.quad_to(a.0, a.1, (a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + } + if let (true, Some(l)) = (self.pts.len() > 1, self.pts.last()) { + pb.line_to(l.0, l.1); + } + pb.finish() + } +} + +pub enum Pending { + Fading, + After(Duration), + Nothing, +} + +/// Strokes are painted once into a persistent layer. Only fading rebuilds it. +pub struct Ink { + strokes: Vec, + pub layer: Pixmap, + drawing: bool, + color: Color, + width: f32, + dirty: Option, +} + +impl Ink { + pub fn new(w: i32, h: i32) -> Self { + Self { + strokes: vec![], + layer: Pixmap::new(w as u32, h as u32).expect("ink layer"), + drawing: false, + color: px(255, 41, 31, 255), + width: (h as f32 * 0.0055).max(3.0), + dirty: None, + } + } + + /// Starts over at a new screen size. Strokes do not carry between screens. + pub fn resize(&mut self, w: i32, h: i32) { + self.layer = Pixmap::new(w as u32, h as u32).expect("ink layer"); + self.strokes.clear(); + self.drawing = false; + self.width = (h as f32 * 0.0055).max(3.0); + self.dirty = None; + } + + pub fn set_color(&mut self, rgb: u32) { + self.color = px((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, 255); + } + + pub fn is_drawing(&self) -> bool { + self.drawing + } + + pub fn take_dirty(&mut self) -> Option { + self.dirty.take() + } + + fn paint(&mut self, path: &Path, alpha: f32) { + let mut c = self.color; + c.set_alpha(alpha); + let mut paint = Paint::default(); + paint.set_color(c); + paint.anti_alias = true; + let stroke = tiny_skia::Stroke { width: self.width, line_cap: LineCap::Round, line_join: LineJoin::Round, ..Default::default() }; + self.layer.stroke_path(path, &paint, &stroke, Transform::identity(), None); + let b = path.bounds(); + let pad = self.width / 2.0 + 2.0; + let r = Rc { l: (b.left() - pad).floor() as i32, t: (b.top() - pad).floor() as i32, r: (b.right() + pad).ceil() as i32, b: (b.bottom() + pad).ceil() as i32 }; + self.dirty = Some(union_opt(self.dirty, r)); + } + + pub fn begin(&mut self, p: (f32, f32)) { + self.strokes.push(Stroke { pts: vec![p], ended: None }); + self.drawing = true; + let mut pb = PathBuilder::new(); + pb.move_to(p.0, p.1); + pb.line_to(p.0, p.1); + if let Some(path) = pb.finish() { + self.paint(&path, 1.0); + } + } + + pub fn extend(&mut self, p: (f32, f32)) { + let Some(s) = self.strokes.last_mut().filter(|_| self.drawing) else { return }; + let l = s.pts[s.pts.len() - 1]; + if (p.0 - l.0).hypot(p.1 - l.1) < 1.5 { + return; + } + s.pts.push(p); + let n = s.pts.len(); + let mid = |a: (f32, f32), b: (f32, f32)| ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0); + let mut pb = PathBuilder::new(); + if n == 2 { + pb.move_to(s.pts[0].0, s.pts[0].1); + } else { + let m = mid(s.pts[n - 3], s.pts[n - 2]); + let e = mid(s.pts[n - 2], s.pts[n - 1]); + pb.move_to(m.0, m.1); + pb.quad_to(s.pts[n - 2].0, s.pts[n - 2].1, e.0, e.1); + } + pb.line_to(p.0, p.1); + if let Some(path) = pb.finish() { + self.paint(&path, 1.0); + } + } + + pub fn end(&mut self) { + if let Some(s) = self.strokes.last_mut().filter(|_| self.drawing) { + s.ended = Some(Instant::now()); + } + self.drawing = false; + } + + pub fn clear(&mut self) { + self.strokes.clear(); + self.drawing = false; + self.layer.fill(Color::TRANSPARENT); + self.dirty = Some(self.full()); + } + + fn full(&self) -> Rc { + Rc { l: 0, t: 0, r: self.layer.width() as i32, b: self.layer.height() as i32 } + } + + /// Returns true while strokes are fading. + pub fn tick(&mut self) -> bool { + let before = self.strokes.len(); + self.strokes.retain(|s| s.alpha() > 0.0); + let fading = self.strokes.iter().any(Stroke::fading); + if fading || self.strokes.len() != before { + self.layer.fill(Color::TRANSPARENT); + let paths: Vec<_> = self.strokes.iter().filter_map(|s| s.path().map(|p| (p, s.alpha()))).collect(); + for (path, alpha) in paths { + self.paint(&path, alpha); + } + self.dirty = Some(self.full()); + } + fading + } + + pub fn pending(&self) -> Pending { + let Some(delay) = crate::config::fade_delay() else { return Pending::Nothing }; + if self.strokes.iter().any(Stroke::fading) { + return Pending::Fading; + } + self.strokes + .iter() + .filter_map(Stroke::age) + .map(|a| Duration::from_secs_f32((delay - a).max(0.0))) + .min() + .map_or(Pending::Nothing, Pending::After) + } +} + +pub fn draw_laser(pix: &mut PixmapMut, at: (f32, f32), r: f32) { + let glow = RadialGradient::new( + Point::from_xy(at.0, at.1), + 0.0, + Point::from_xy(at.0, at.1), + r * 3.2, + vec![GradientStop::new(0.0, px(255, 41, 31, 150)), GradientStop::new(1.0, px(255, 41, 31, 0))], + SpreadMode::Pad, + Transform::identity(), + ); + if let (Some(shader), Some(rect)) = (glow, Rect::from_xywh(at.0 - r * 3.2, at.1 - r * 3.2, r * 6.4, r * 6.4)) { + let mut paint = Paint::default(); + paint.shader = shader; + pix.fill_rect(rect, &paint, Transform::identity(), None); + } + for (radius, color) in [(r, px(255, 41, 31, 255)), (r * 0.45, px(255, 220, 209, 242))] { + let mut p = Paint::default(); + p.set_color(color); + p.anti_alias = true; + if let Some(path) = PathBuilder::from_circle(at.0, at.1, radius) { + pix.fill_path(&path, &p, FillRule::Winding, Transform::identity(), None); + } + } +} diff --git a/windows/src/settings.html b/windows/src/settings.html new file mode 100644 index 0000000..3b079f9 --- /dev/null +++ b/windows/src/settings.html @@ -0,0 +1,362 @@ + + + + + +TinyHand Settings + + + + +
+ + + + diff --git a/windows/src/settings.rs b/windows/src/settings.rs new file mode 100644 index 0000000..791ee2e --- /dev/null +++ b/windows/src/settings.rs @@ -0,0 +1,108 @@ +use std::num::NonZeroIsize; + +use raw_window_handle::{HandleError, HasWindowHandle, RawWindowHandle, Win32WindowHandle, WindowHandle}; +use windows::Win32::Foundation::*; +use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::UI::WindowsAndMessaging::*; +use windows::core::w; +use wry::dpi::{PhysicalPosition, PhysicalSize}; +use wry::{Rect, WebView, WebViewBuilder}; + +pub const CLASS: windows::core::PCWSTR = w!("TinyHandSettings"); + +const PAGE: &str = include_str!("settings.html"); + +struct Parent(HWND); + +impl HasWindowHandle for Parent { + fn window_handle(&self) -> Result, HandleError> { + let raw = NonZeroIsize::new(self.0.0 as isize).ok_or(HandleError::Unavailable)?; + // SAFETY: the handle stays valid while Parent is borrowed, and the window outlives the webview. + Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::Win32(Win32WindowHandle::new(raw))) }) + } +} + +/// The settings window. Dropping it tears down WebView2 completely, so a closed +/// settings window costs no memory. +pub struct SettingsWindow { + pub hwnd: HWND, + webview: Option, +} + +impl SettingsWindow { + pub fn open(on_message: impl Fn(String) + 'static) -> Option { + let (client_w, client_h) = (780, 560); + unsafe { + let style = WS_OVERLAPPED | WS_CAPTION | WS_SYSMENU | WS_MINIMIZEBOX; + let mut frame = RECT { left: 0, top: 0, right: client_w, bottom: client_h }; + let _ = AdjustWindowRect(&mut frame, style, false); + let (w, h) = (frame.right - frame.left, frame.bottom - frame.top); + let x = (GetSystemMetrics(SM_CXSCREEN) - w) / 2; + let y = (GetSystemMetrics(SM_CYSCREEN) - h) / 2; + let hinst = HINSTANCE(GetModuleHandleW(None).ok()?.0); + let hwnd = match CreateWindowExW(WINDOW_EX_STYLE(0), CLASS, w!("TinyHand Settings"), style, x, y, w, h, None, None, Some(hinst), None) { + Ok(h) => h, + Err(e) => { + crate::log::write(&format!("settings: window failed: {e}")); + return None; + } + }; + for (kind, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] { + let _ = SendMessageW(hwnd, WM_SETICON, Some(WPARAM(kind as usize)), Some(LPARAM(crate::icon::logo_for(metric).0 as isize))); + } + let webview = WebViewBuilder::new() + .with_html(PAGE.replace("__LOGO__", &crate::icon::data_url())) + .with_ipc_handler(move |request| on_message(request.into_body())) + .with_bounds(Rect { position: PhysicalPosition::new(0, 0).into(), size: PhysicalSize::new(client_w as u32, client_h as u32).into() }) + .build_as_child(&Parent(hwnd)); + match webview { + Ok(webview) => { + let _ = ShowWindow(hwnd, SW_SHOW); + let _ = SetForegroundWindow(hwnd); + Some(Self { hwnd, webview: Some(webview) }) + } + Err(e) => { + crate::log::write(&format!("settings: WebView2 failed: {e}")); + let _ = DestroyWindow(hwnd); + None + } + } + } + } + + pub fn resize(&self) { + let mut r = RECT::default(); + unsafe { + let _ = GetClientRect(self.hwnd, &mut r); + } + if let Some(webview) = &self.webview { + let _ = webview.set_bounds(Rect { + position: PhysicalPosition::new(0, 0).into(), + size: PhysicalSize::new((r.right - r.left).max(1) as u32, (r.bottom - r.top).max(1) as u32).into(), + }); + } + } + + pub fn send(&self, script: &str) { + if let Some(webview) = &self.webview { + let _ = webview.evaluate_script(script); + } + } + + pub fn focus(&self) { + unsafe { + let _ = ShowWindow(self.hwnd, SW_RESTORE); + let _ = SetForegroundWindow(self.hwnd); + } + } +} + +impl Drop for SettingsWindow { + fn drop(&mut self) { + // The webview must go before its parent window. + self.webview.take(); + unsafe { + let _ = DestroyWindow(self.hwnd); + } + } +} diff --git a/windows/src/sound.rs b/windows/src/sound.rs new file mode 100644 index 0000000..aaf0d39 --- /dev/null +++ b/windows/src/sound.rs @@ -0,0 +1,260 @@ +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering::Relaxed}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::time::{Duration, Instant}; + +use windows::Win32::Media::Audio::*; +use windows::Win32::System::Com::*; + +use crate::hand::find_asset; + +pub const CHOICES: [&str; 6] = ["thock", "click", "pop", "wood", "boop", "squeak"]; + +const RATE: u32 = 44100; +/// Audio queued ahead of the speakers. Small, so a tap is heard within a few milliseconds. +const QUEUE_FRAMES: u32 = 280; +const TICK: Duration = Duration::from_millis(3); +const STOP_AFTER_IDLE: Duration = Duration::from_secs(30); + +enum Cmd { + Open, + Close, + Play, + Preview(usize), +} + +const PREVIEW_LINGER: Duration = Duration::from_millis(1500); + +static TX: OnceLock> = OnceLock::new(); +static CHOICE: AtomicUsize = AtomicUsize::new(0); +static ENABLED: AtomicBool = AtomicBool::new(true); + +/// 44.1 kHz, 16-bit, mono. Returns the samples or None for anything else. +fn pcm(bytes: &[u8]) -> Option> { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let (mut at, mut ok) = (12, false); + while at + 8 <= bytes.len() { + let size = u32::from_le_bytes(bytes[at + 4..at + 8].try_into().ok()?) as usize; + let body = bytes.get(at + 8..(at + 8 + size).min(bytes.len()))?; + match &bytes[at..at + 4] { + b"fmt " if body.len() >= 16 => { + let tag = u16::from_le_bytes([body[0], body[1]]); + let channels = u16::from_le_bytes([body[2], body[3]]); + let rate = u32::from_le_bytes(body[4..8].try_into().ok()?); + let bits = u16::from_le_bytes([body[14], body[15]]); + ok = tag == 1 && channels == 1 && rate == RATE && bits == 16; + } + b"data" => return ok.then(|| body.chunks_exact(2).map(|b| i16::from_le_bytes([b[0], b[1]])).collect()), + _ => {} + } + at += 8 + size + (size & 1); + } + None +} + +/// An open shared-mode stream. WASAPI converts our 44.1 kHz mono to the device format. +struct Stream { + client: IAudioClient, + render: IAudioRenderClient, + frames: u32, + running: bool, +} + +impl Stream { + fn open() -> windows::core::Result { + unsafe { + let enumerator: IMMDeviceEnumerator = CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL)?; + let device = enumerator.GetDefaultAudioEndpoint(eRender, eConsole)?; + let client: IAudioClient = device.Activate(CLSCTX_ALL, None)?; + let format = WAVEFORMATEX { wFormatTag: WAVE_FORMAT_PCM as u16, nChannels: 1, nSamplesPerSec: RATE, nAvgBytesPerSec: RATE * 2, nBlockAlign: 2, wBitsPerSample: 16, cbSize: 0 }; + let flags = AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY; + client.Initialize(AUDCLNT_SHAREMODE_SHARED, flags, 200_000, 0, &format, None)?; + let frames = client.GetBufferSize()?; + let render: IAudioRenderClient = client.GetService()?; + Ok(Self { client, render, frames, running: false }) + } + } + + fn start(&mut self) { + if !self.running && unsafe { self.client.Start() }.is_ok() { + self.running = true; + } + } + + fn stop(&mut self) { + if self.running { + unsafe { + let _ = self.client.Stop(); + let _ = self.client.Reset(); + } + self.running = false; + } + } + + /// Tops the queue up to a few milliseconds, with the current tap or silence. + fn pump(&mut self, voice: &mut Option<(usize, usize)>, clips: &[Vec]) { + let Ok(padding) = (unsafe { self.client.GetCurrentPadding() }) else { return }; + let room = self.frames.saturating_sub(padding); + let n = QUEUE_FRAMES.saturating_sub(padding).min(room); + if n == 0 { + return; + } + let Ok(buffer) = (unsafe { self.render.GetBuffer(n) }) else { return }; + let out = unsafe { std::slice::from_raw_parts_mut(buffer.cast::(), n as usize) }; + out.fill(0); + if let Some((clip, pos)) = voice { + let data = &clips[*clip]; + let take = (data.len() - *pos).min(out.len()); + out[..take].copy_from_slice(&data[*pos..*pos + take]); + *pos += take; + if *pos >= data.len() { + *voice = None; + } + } + unsafe { + let _ = self.render.ReleaseBuffer(n, 0); + } + } +} + +/// Everything that touches the audio device lives on this one thread. The mouse hook +/// only sends a message, because a slow or blocked hook stalls the whole system. +fn run(rx: Receiver) { + unsafe { + let _ = CoInitializeEx(None, COINIT_MULTITHREADED); + } + let clips: Vec> = CHOICES + .iter() + .map(|name| find_asset(&format!("sounds/tap_{name}.wav")).and_then(|p| std::fs::read(p).ok()).and_then(|b| pcm(&b)).unwrap_or_default()) + .collect(); + + let mut stream: Option = None; + let mut voice: Option<(usize, usize)> = None; + let mut wanted = false; + let mut last_tap = Instant::now(); + + loop { + let cmd = match stream.as_ref().is_some_and(|s| s.running) { + true => match rx.recv_timeout(TICK) { + Ok(cmd) => Some(cmd), + Err(RecvTimeoutError::Timeout) => None, + Err(RecvTimeoutError::Disconnected) => break, + }, + false => match rx.recv() { + Ok(cmd) => Some(cmd), + Err(_) => break, + }, + }; + match cmd { + Some(Cmd::Open) => { + wanted = true; + if stream.is_none() { + match Stream::open() { + Ok(s) => stream = Some(s), + Err(e) => crate::log::write(&format!("audio open failed: {e}")), + } + } + last_tap = Instant::now(); + if let Some(s) = &mut stream { + s.start(); + } + } + Some(Cmd::Close) => { + wanted = false; + voice = None; + if let Some(mut s) = stream.take() { + s.stop(); + } + } + Some(Cmd::Play) if wanted => { + let i = CHOICE.load(Relaxed); + if clips.get(i).is_some_and(|c| !c.is_empty()) { + voice = Some((i, 0)); + } + last_tap = Instant::now(); + if let Some(s) = &mut stream { + s.start(); + } + } + Some(Cmd::Preview(i)) => { + // Settings can preview a sound while the pointer is off. + if stream.is_none() { + match Stream::open() { + Ok(s) => stream = Some(s), + Err(e) => crate::log::write(&format!("audio open failed: {e}")), + } + } + if clips.get(i).is_some_and(|c| !c.is_empty()) { + voice = Some((i, 0)); + } + last_tap = Instant::now(); + if let Some(s) = &mut stream { + s.start(); + } + } + _ => {} + } + if let Some(s) = &mut stream { + if s.running { + s.pump(&mut voice, &clips); + if voice.is_none() && last_tap.elapsed() > STOP_AFTER_IDLE { + s.stop(); + } + } + } + if !wanted && voice.is_none() && last_tap.elapsed() > PREVIEW_LINGER { + if let Some(mut s) = stream.take() { + s.stop(); + } + } + } +} + +pub fn load() { + TX.get_or_init(|| { + let (tx, rx) = channel(); + let _ = std::thread::Builder::new().name("audio".into()).spawn(move || run(rx)); + tx + }); +} + +fn send(cmd: Cmd) { + if let Some(tx) = TX.get() { + let _ = tx.send(cmd); + } +} + +pub fn set_enabled(on: bool) { + ENABLED.store(on, Relaxed); +} + +pub fn set_choice(name: &str) { + if let Some(i) = CHOICES.iter().position(|c| *c == name) { + CHOICE.store(i, Relaxed); + } +} + +/// Plays one sound once, for the settings page. +pub fn preview(name: &str) { + if let Some(i) = CHOICES.iter().position(|c| *c == name) { + send(Cmd::Preview(i)); + } +} + +/// Opened while the pointer is on, like the Mac engine that stops when idle. +pub fn open() { + send(Cmd::Open); +} + +pub fn close() { + send(Cmd::Close); +} + +/// Safe to call from the mouse hook: a channel send, no device access. +pub fn play() { + if ENABLED.load(Relaxed) { + send(Cmd::Play); + } +} diff --git a/windows/src/styles.rs b/windows/src/styles.rs new file mode 100644 index 0000000..624646f --- /dev/null +++ b/windows/src/styles.rs @@ -0,0 +1,71 @@ +use crate::arm::Arm; +use crate::hand::HandAsset; + +pub enum Style { + Laser, + Hand(Arm), +} + +pub const LASER_INK: u32 = 0xFF291F; +pub const LASER: &str = "laser"; + +const PURPLE: u32 = 0x8754F2; +const RED: u32 = LASER_INK; + +/// id, name shown in settings, ink color +const HANDS: [(&str, &str, u32); 36] = [ + ("lavender", "Lavender toy", PURPLE), + ("glove", "Cursor glove", PURPLE), + ("coral", "Coral vinyl", RED), + ("plush", "Plush", 0x2F86F0), + ("robot", "Robot", 0x16A87A), + ("pink", "Bubblegum", 0xEE4F98), + ("blue", "Sky", 0x2F86F0), + ("mint", "Mint", 0x16A87A), + ("lemon", "Lemon", 0xF2B705), + ("black", "Midnight", 0x1D1D21), + ("chrome", "Chrome", 0x6B7380), + ("holo", "Holo", PURPLE), + ("fuzzy", "Fuzzy", PURPLE), + ("wood", "Wood", 0x9A5E2C), + ("marble", "Marble", 0x6B7380), + ("terrazzo", "Terrazzo", 0xF0561D), + ("glass", "Glass", 0x1597E5), + ("jelly", "Jelly", 0xA33FE6), + ("pastel", "Pastel", 0xEE4F98), + ("pixel", "Pixel", 0x1D1D21), + ("lowpoly", "Low poly", PURPLE), + ("knit", "Knit", PURPLE), + ("skeleton", "Skeleton", 0x9A5E2C), + ("astro", "Astronaut", 0x2F86F0), + ("space", "Space", RED), + ("monster", "Monster", 0x3E9E2E), + ("water", "Water", 0x1597E5), + ("lava", "Lava", 0xF0561D), + ("grass", "Grass", 0x3E9E2E), + ("cactus", "Cactus", 0x3E9E2E), + ("choco", "Chocolate", 0x9A5E2C), + ("cloud", "Cloud", 0x2F86F0), + ("mech", "Mech", 0xF0561D), + ("neon", "Neon", 0xE0309A), + ("leaf", "Leaf", 0x3E9E2E), + ("cookie", "Gingerbread", 0x9A5E2C), +]; + +/// Every pointer a user can pick, the laser last. +pub fn all() -> impl Iterator { + HANDS.iter().map(|(id, name, _)| (*id, *name)).chain(std::iter::once((LASER, "Laser dot"))) +} + +pub fn is_known(id: &str) -> bool { + id == LASER || HANDS.iter().any(|(h, _, _)| *h == id) +} + +/// Unknown or unloadable hands fall back to the laser, like the Mac app. +pub fn load(id: &str) -> (Style, u32) { + let Some(&(_, _, ink)) = HANDS.iter().find(|(name, _, _)| *name == id) else { return (Style::Laser, LASER_INK) }; + match HandAsset::load(id) { + Some(asset) => (Style::Hand(Arm::new(asset)), ink), + None => (Style::Laser, LASER_INK), + } +} diff --git a/windows/src/surface.rs b/windows/src/surface.rs new file mode 100644 index 0000000..8a3c402 --- /dev/null +++ b/windows/src/surface.rs @@ -0,0 +1,83 @@ +use std::ffi::c_void; +use std::ptr::null_mut; + +use windows::Win32::Foundation::*; +use windows::Win32::Graphics::Gdi::*; +use windows::Win32::UI::WindowsAndMessaging::*; + +/// Layered-window backing store. Pixels are premultiplied BGRA. +pub struct Surface { + pub w: i32, + pub h: i32, + /// Where the window sits on the virtual desktop. + pub origin: (i32, i32), + dc: HDC, + bmp: HBITMAP, + bits: *mut u8, +} + +impl Drop for Surface { + fn drop(&mut self) { + unsafe { + let _ = DeleteDC(self.dc); + let _ = DeleteObject(self.bmp.into()); + } + } +} + +impl Surface { + pub fn new(w: i32, h: i32) -> Self { + unsafe { + let screen = GetDC(None); + let dc = CreateCompatibleDC(Some(screen)); + 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 bmp = CreateDIBSection(Some(screen), &bmi, DIB_RGB_COLORS, &mut bits, None, 0).expect("DIB section"); + SelectObject(dc, bmp.into()); + ReleaseDC(None, screen); + Self { w, h, origin: (0, 0), dc, bmp, bits: bits.cast() } + } + } + + pub fn bytes_mut(&mut self) -> &mut [u8] { + unsafe { std::slice::from_raw_parts_mut(self.bits, self.w as usize * self.h as usize * 4) } + } + + pub fn clear(&mut self) { + self.bytes_mut().fill(0); + } + + fn blend_with(alpha: u8) -> BLENDFUNCTION { + BLENDFUNCTION { BlendOp: AC_SRC_OVER as u8, BlendFlags: 0, SourceConstantAlpha: alpha, AlphaFormat: AC_SRC_ALPHA as u8 } + } + + /// Places the window at (x, y) with the whole surface at the given opacity. + pub fn present_at(&self, hwnd: HWND, x: i32, y: i32, alpha: u8) { + let origin = POINT::default(); + let at = POINT { x, y }; + let size = SIZE { cx: self.w, cy: self.h }; + unsafe { + let _ = UpdateLayeredWindow(hwnd, None, Some(&at), Some(&size), Some(self.dc), Some(&origin), COLORREF(0), Some(&Self::blend_with(alpha)), ULW_ALPHA); + } + } + + /// Window thread only. + pub fn present_full(&self, hwnd: HWND) { + self.present_at(hwnd, self.origin.0, self.origin.1, 255); + } + + pub fn present(&self, hwnd: HWND) { + self.present_full(hwnd); + } +} diff --git a/windows/src/thumbs.rs b/windows/src/thumbs.rs new file mode 100644 index 0000000..5dfd77b --- /dev/null +++ b/windows/src/thumbs.rs @@ -0,0 +1,37 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use crate::hand::HandAsset; +use crate::icon::base64; + +const SIZE: u32 = 96; + +/// Settings thumbnails as PNG data URLs. Built once in the background after launch, +/// so opening the Pointers page is instant and nothing loads while the pointer is in use. +static CACHE: Mutex>> = Mutex::new(None); + +fn png(id: &str) -> Option { + let mut thumb = HandAsset::load(id)?.thumbnail(SIZE); + // Buffers are BGRA here, PNG wants RGBA. + for px in thumb.data_mut().chunks_exact_mut(4) { + px.swap(0, 2); + } + Some(format!("data:image/png;base64,{}", base64(&thumb.encode_png().ok()?))) +} + +pub fn build_in_background() { + let _ = std::thread::Builder::new().name("thumbs".into()).spawn(|| { + let mut all = HashMap::new(); + for (id, _) in crate::styles::all().filter(|(id, _)| *id != crate::styles::LASER) { + if let Some(url) = png(id) { + all.insert(id.to_string(), url); + } + } + *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some(all); + }); +} + +/// Every thumbnail built so far, as id to data URL. +pub fn all() -> HashMap { + CACHE.lock().unwrap_or_else(|e| e.into_inner()).clone().unwrap_or_default() +} diff --git a/windows/src/toolbar.rs b/windows/src/toolbar.rs new file mode 100644 index 0000000..33eeff2 --- /dev/null +++ b/windows/src/toolbar.rs @@ -0,0 +1,453 @@ +use std::time::Instant; + +use tiny_skia::*; +use windows::Win32::Foundation::*; +use windows::Win32::UI::HiDpi::GetDpiForWindow; +use windows::Win32::UI::Input::KeyboardAndMouse::*; +use windows::Win32::UI::WindowsAndMessaging::*; + +use crate::hand::HandAsset; +use crate::hook; +use crate::scene::{draw_laser, px}; +use crate::surface::Surface; + +pub const WM_TB_WANT: u32 = WM_APP + 22; +/// The cursor crossed onto another monitor. +pub const WM_SCREEN_CHANGED: u32 = WM_APP + 23; +const TIMER_ANIM: usize = 2; +const WM_MOUSELEAVE: u32 = 0x02A3; + +const BUTTON: f32 = 40.0; +const GAP: f32 = 6.0; +const PAD_X: f32 = 10.0; +const PAD_Y: f32 = 8.0; +const RADIUS: f32 = 16.0; +const FADE_IN: f32 = 0.15; +const FADE_OUT: f32 = 0.35; +const MARGIN_BOTTOM: f32 = 14.0; + +pub enum Action { + Pick(String), + ToggleClickThrough, + ToggleSound, + Exit, +} + +enum Kind { + Style(String), + ClickThrough, + Sound, + Exit, +} + +struct Item { + kind: Kind, + /// Large copy of the thumbnail, kept so icons can be resized for any display scaling. + source: Option, + icon: Option, + x: f32, +} + +const SOURCE_PX: u32 = 144; + +pub struct Toolbar { + pub hwnd: HWND, + items: Vec, + divider_x: f32, + surface: Surface, + scale: f32, + selected: String, + through: bool, + sound: bool, + hover: Option, + alpha: f32, + target: f32, + last: Instant, + tracking: bool, + frame: RECT, +} + +fn round_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Option { + let k = 0.5523 * r; + let mut pb = PathBuilder::new(); + pb.move_to(x + r, y); + pb.line_to(x + w - r, y); + pb.cubic_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r); + pb.line_to(x + w, y + h - r); + pb.cubic_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h); + pb.line_to(x + r, y + h); + pb.cubic_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r); + pb.line_to(x, y + r); + pb.cubic_to(x, y + r - k, x + r - k, y, x + r, y); + pb.close(); + pb.finish() +} + +fn fill(pix: &mut PixmapMut, path: &Path, color: Color) { + let mut paint = Paint::default(); + paint.set_color(color); + paint.anti_alias = true; + pix.fill_path(path, &paint, FillRule::Winding, Transform::identity(), None); +} + +impl Toolbar { + pub fn new(hwnd: HWND, styles: &[&str], selected: &str) -> Self { + let scale = unsafe { GetDpiForWindow(hwnd) }.max(96) as f32 / 96.0; + let mut items: Vec = styles + .iter() + .map(|id| Item { kind: Kind::Style(id.to_string()), source: HandAsset::load(id).map(|a| a.thumbnail(SOURCE_PX)), icon: None, x: 0.0 }) + .collect(); + for kind in [Kind::ClickThrough, Kind::Sound, Kind::Exit] { + items.push(Item { kind, source: None, icon: None, x: 0.0 }); + } + let mut bar = Self { + hwnd, + items, + divider_x: 0.0, + surface: Surface::new(1, 1), + scale: 0.0, + selected: selected.to_string(), + through: false, + sound: true, + hover: None, + alpha: 0.0, + target: 0.0, + last: Instant::now(), + tracking: false, + frame: RECT::default(), + }; + bar.layout(scale); + bar + } + + /// Replaces the pointer buttons, keeping icons already loaded. + pub fn set_styles(&mut self, styles: &[String]) { + let mut old = std::mem::take(&mut self.items); + let mut items: Vec = styles + .iter() + .map(|id| { + let kept = old.iter_mut().find(|i| matches!(&i.kind, Kind::Style(x) if x == id)).and_then(|i| i.source.take()); + let source = kept.or_else(|| HandAsset::load(id).map(|a| a.thumbnail(SOURCE_PX))); + Item { kind: Kind::Style(id.clone()), source, icon: None, x: 0.0 } + }) + .collect(); + for kind in [Kind::ClickThrough, Kind::Sound, Kind::Exit] { + items.push(Item { kind, source: None, icon: None, x: 0.0 }); + } + self.items = items; + self.layout(self.scale); + self.place(); + self.paint(); + } + + /// Sizes and positions everything for a display scaling. Called again when the toolbar + /// moves to a monitor with a different one. + fn layout(&mut self, scale: f32) { + self.scale = scale; + let icon_px = (36.0 * scale).round() as u32; + let styles = self.items.iter().filter(|i| matches!(i.kind, Kind::Style(_))).count(); + let mut x = PAD_X * scale; + for (i, item) in self.items.iter_mut().enumerate() { + if i == styles { + self.divider_x = x; + x += (1.0 + GAP) * scale; + } + item.icon = item.source.as_ref().map(|s| crate::hand::resized(s, icon_px)); + item.x = x; + x += (BUTTON + GAP) * scale; + } + x += (PAD_X - GAP) * scale; + let (w, h) = (x.round() as i32, ((BUTTON + 2.0 * PAD_Y) * scale).round() as i32); + self.surface = Surface::new(w, h); + self.hover = None; + } + + fn size(&self) -> (i32, i32) { + (self.surface.w, self.surface.h) + } + + /// Moves to the monitor the cursor is on, without changing whether it is showing. + pub fn follow_cursor(&mut self) { + if self.place() { + self.paint(); + } else { + self.present(); + } + if self.target > 0.0 { + self.set_interactive(true); + } + } + + /// Puts the toolbar at the bottom of the cursor's monitor. Returns true if that monitor's + /// display scaling differed, in which case the toolbar was rebuilt and needs repainting. + fn place(&mut self) -> bool { + let mut at = POINT::default(); + unsafe { + let _ = GetCursorPos(&mut at); + } + let monitor = crate::monitor::at((at.x as f32, at.y as f32)); + let work = monitor.work; + let rescaled = (monitor.scale - self.scale).abs() > 0.01; + if rescaled { + self.layout(monitor.scale); + } + let (w, h) = self.size(); + let left = work.left + (work.right - work.left - w) / 2; + let top = work.bottom - h - (MARGIN_BOTTOM * self.scale) as i32; + self.frame = RECT { left, top, right: left + w, bottom: top + h }; + let s = self.scale; + hook::set_zone(RECT { + left: left - (80.0 * s) as i32, + top: top - (100.0 * s) as i32, + right: left + w + (80.0 * s) as i32, + bottom: top + h + (10.0 * s) as i32, + }); + rescaled + } + + pub fn show(&mut self) { + self.place(); + self.alpha = 0.0; + self.target = 0.0; + self.set_interactive(false); + self.paint(); + unsafe { + let _ = SetWindowPos(self.hwnd, Some(HWND_TOPMOST), 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE); + let _ = ShowWindow(self.hwnd, SW_SHOWNOACTIVATE); + } + } + + pub fn hide(&mut self) { + self.alpha = 0.0; + self.target = 0.0; + self.hover = None; + self.set_interactive(false); + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER_ANIM); + let _ = ShowWindow(self.hwnd, SW_HIDE); + } + } + + pub fn set_click_through(&mut self, on: bool) { + self.through = on; + self.paint(); + } + + pub fn set_sound(&mut self, on: bool) { + self.sound = on; + self.paint(); + } + + pub fn select(&mut self, id: &str) { + self.selected = id.to_string(); + self.paint(); + } + + pub fn set_wanted(&mut self, wanted: bool) { + let target = if wanted { 1.0 } else { 0.0 }; + if target == self.target { + return; + } + self.target = target; + self.last = Instant::now(); + self.set_interactive(wanted); + unsafe { + SetTimer(Some(self.hwnd), TIMER_ANIM, 8, None); + } + } + + /// Hidden means clicks fall through the window and the hook stops letting them pass. + fn set_interactive(&self, on: bool) { + unsafe { + let ex = GetWindowLongPtrW(self.hwnd, GWL_EXSTYLE); + let flag = WS_EX_TRANSPARENT.0 as isize; + SetWindowLongPtrW(self.hwnd, GWL_EXSTYLE, if on { ex & !flag } else { ex | flag }); + } + hook::set_passthrough(1, if on { self.frame } else { RECT::default() }); + } + + fn animate(&mut self) { + let dt = self.last.elapsed().as_secs_f32(); + self.last = Instant::now(); + let rate = if self.target > self.alpha { 1.0 / FADE_IN } else { -1.0 / FADE_OUT }; + self.alpha = (self.alpha + rate * dt).clamp(0.0, 1.0); + if (self.alpha - self.target).abs() < 0.001 || (self.target == 1.0 && self.alpha >= 1.0) { + self.alpha = self.target; + unsafe { + let _ = KillTimer(Some(self.hwnd), TIMER_ANIM); + } + } + self.present(); + } + + fn present(&self) { + self.surface.present_at(self.hwnd, self.frame.left, self.frame.top, (self.alpha * 255.0).round() as u8); + } + + fn paint(&mut self) { + let (w, h) = self.size(); + let s = self.scale; + self.surface.clear(); + let Some(mut pix) = PixmapMut::from_bytes(self.surface.bytes_mut(), w as u32, h as u32) else { return }; + + if let Some(path) = round_rect(0.5, 0.5, w as f32 - 1.0, h as f32 - 1.0, RADIUS * s) { + fill(&mut pix, &path, px(18, 18, 18, 158)); + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, 31)); + paint.anti_alias = true; + let stroke = Stroke { width: 1.0, ..Default::default() }; + pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + + let (btn, top) = (BUTTON * s, PAD_Y * s); + for (i, item) in self.items.iter().enumerate() { + let chosen = match &item.kind { + Kind::Style(id) => *id == self.selected, + Kind::ClickThrough => self.through, + Kind::Sound | Kind::Exit => false, + }; + let tint = if chosen { 46 } else if self.hover == Some(i) { 24 } else { 0 }; + if tint > 0 { + if let Some(path) = round_rect(item.x, top, btn, btn, 10.0 * s) { + fill(&mut pix, &path, px(255, 255, 255, tint)); + } + } + let (cx, cy) = (item.x + btn / 2.0, top + btn / 2.0); + match (&item.kind, &item.icon) { + (_, Some(icon)) => { + let (ix, iy) = (cx - icon.width() as f32 / 2.0, cy - icon.height() as f32 / 2.0); + pix.draw_pixmap(ix.round() as i32, iy.round() as i32, icon.as_ref(), &PixmapPaint::default(), Transform::identity(), None); + } + (Kind::Style(_), None) => draw_laser(&mut pix, (cx, cy), 5.0 * s), + (Kind::Sound, None) => { + let alpha = if self.sound { 217 } else { 115 }; + let at = |x: f32, y: f32| (cx + (x - 9.0) * s, cy + (y - 8.0) * s); + let mut pb = PathBuilder::new(); + for (i, (x, y)) in [(0.0, 5.0), (4.0, 5.0), (9.0, 1.0), (9.0, 15.0), (4.0, 11.0), (0.0, 11.0)].iter().enumerate() { + let (px_, py_) = at(*x, *y); + if i == 0 { + pb.move_to(px_, py_); + } else { + pb.line_to(px_, py_); + } + } + pb.close(); + if let Some(path) = pb.finish() { + fill(&mut pix, &path, px(255, 255, 255, alpha)); + } + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, alpha)); + paint.anti_alias = true; + let stroke = Stroke { width: 1.5 * s, line_cap: LineCap::Round, ..Default::default() }; + let mut pb = PathBuilder::new(); + if self.sound { + for (x0, y0, cx_, cy_, x1, y1) in [(11.5, 5.5, 14.0, 8.0, 11.5, 10.5), (13.5, 3.5, 18.0, 8.0, 13.5, 12.5)] { + let (a, b, c) = (at(x0, y0), at(cx_, cy_), at(x1, y1)); + pb.move_to(a.0, a.1); + pb.quad_to(b.0, b.1, c.0, c.1); + } + } else { + for (x0, y0, x1, y1) in [(12.0, 5.0, 17.0, 11.0), (17.0, 5.0, 12.0, 11.0)] { + let (a, b) = (at(x0, y0), at(x1, y1)); + pb.move_to(a.0, a.1); + pb.line_to(b.0, b.1); + } + } + if let Some(path) = pb.finish() { + pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + (Kind::ClickThrough, None) => { + let arrow = [(0.0, 0.0), (0.0, 16.0), (4.2, 12.2), (7.0, 18.5), (9.6, 17.4), (6.9, 11.3), (12.4, 11.0)]; + let mut pb = PathBuilder::new(); + for (i, (ax, ay)) in arrow.iter().enumerate() { + let (px_, py_) = (cx + (ax - 6.0) * s, cy + (ay - 9.0) * s); + if i == 0 { + pb.move_to(px_, py_); + } else { + pb.line_to(px_, py_); + } + } + pb.close(); + if let Some(path) = pb.finish() { + fill(&mut pix, &path, px(255, 255, 255, 230)); + } + } + (Kind::Exit, None) => { + let d = 6.0 * s; + let mut pb = PathBuilder::new(); + pb.move_to(cx - d, cy - d); + pb.line_to(cx + d, cy + d); + pb.move_to(cx + d, cy - d); + pb.line_to(cx - d, cy + d); + if let Some(path) = pb.finish() { + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, 217)); + paint.anti_alias = true; + let stroke = Stroke { width: 1.8 * s, line_cap: LineCap::Round, ..Default::default() }; + pix.stroke_path(&path, &paint, &stroke, Transform::identity(), None); + } + } + } + } + + if let Some(rect) = Rect::from_xywh(self.divider_x, (h as f32 - 24.0 * s) / 2.0, s.max(1.0), 24.0 * s) { + let mut paint = Paint::default(); + paint.set_color(px(255, 255, 255, 46)); + pix.fill_rect(rect, &paint, Transform::identity(), None); + } + self.present(); + } + + fn hit(&self, x: i32, y: i32) -> Option { + let s = self.scale; + let (btn, top) = (BUTTON * s, PAD_Y * s); + let (x, y) = (x as f32, y as f32); + self.items.iter().position(|i| x >= i.x && x < i.x + btn && y >= top && y < top + btn) + } + + pub fn handle(&mut self, msg: u32, wp: WPARAM, lp: LPARAM) -> Option<(LRESULT, Option)> { + let point = || ((lp.0 & 0xFFFF) as i16 as i32, ((lp.0 >> 16) & 0xFFFF) as i16 as i32); + match msg { + WM_MOUSEACTIVATE => Some((LRESULT(MA_NOACTIVATE as isize), None)), + WM_TIMER if wp.0 == TIMER_ANIM => { + self.animate(); + Some((LRESULT(0), None)) + } + WM_MOUSEMOVE => { + if !self.tracking { + let mut t = TRACKMOUSEEVENT { cbSize: size_of::() as u32, dwFlags: TME_LEAVE, hwndTrack: self.hwnd, dwHoverTime: 0 }; + unsafe { + let _ = TrackMouseEvent(&mut t); + } + self.tracking = true; + } + let (x, y) = point(); + let hover = self.hit(x, y); + if hover != self.hover { + self.hover = hover; + self.paint(); + } + Some((LRESULT(0), None)) + } + WM_MOUSELEAVE => { + self.tracking = false; + if self.hover.take().is_some() { + self.paint(); + } + Some((LRESULT(0), None)) + } + WM_LBUTTONUP => { + let (x, y) = point(); + let action = self.hit(x, y).map(|i| match &self.items[i].kind { + Kind::Style(id) => Action::Pick(id.clone()), + Kind::ClickThrough => Action::ToggleClickThrough, + Kind::Sound => Action::ToggleSound, + Kind::Exit => Action::Exit, + }); + Some((LRESULT(0), action)) + } + _ => None, + } + } +}