From 053160160e76c10427a60ecc38228638c28ac380 Mon Sep 17 00:00:00 2001
From: Abdul Rehman Khan <99591456+AbdulRehman56@users.noreply.github.com>
Date: Sun, 8 Nov 2026 18:39:38 +0500
Subject: [PATCH 1/3] feat(linux): propose experimental GNOME and X11 port
---
.github/workflows/linux.yml | 124 ++
README.md | 13 +
linux/.gitignore | 3 +
linux/COMPATIBILITY.md | 101 ++
linux/CONTRIBUTING.md | 94 ++
linux/Cargo.lock | 1237 +++++++++++++++++
linux/Cargo.toml | 24 +
linux/README.md | 77 +
linux/RELEASE_NOTES.md | 79 ++
linux/UPSTREAM_PR.md | 9 +
.../tinyhand@abdulrehmankhan56/extension.js | 753 ++++++++++
.../tinyhand@abdulrehmankhan56/metadata.json | 10 +
.../tinyhand@abdulrehmankhan56/preferences.js | 433 ++++++
.../tinyhand@abdulrehmankhan56/prefs.js | 8 +
...nome.shell.extensions.tinyhand.gschema.xml | 16 +
.../tinyhand@abdulrehmankhan56/settings.js | 26 +
linux/scripts/install-release.sh | 72 +
linux/scripts/install.sh | 134 ++
linux/scripts/package.sh | 163 +++
linux/scripts/publish.sh | 131 ++
linux/src/app.rs | 691 +++++++++
linux/src/asset.rs | 478 +++++++
linux/src/config.rs | 270 ++++
linux/src/main.rs | 170 +++
linux/src/model.rs | 19 +
linux/src/pose.rs | 248 ++++
linux/src/render.rs | 539 +++++++
linux/src/sound.rs | 36 +
linux/src/tray.rs | 128 ++
linux/src/x11.rs | 476 +++++++
linux/tests/companion-render.mjs | 52 +
linux/tests/companion.test.cjs | 242 ++++
linux/tests/preferences-layout.mjs | 121 ++
33 files changed, 6977 insertions(+)
create mode 100644 .github/workflows/linux.yml
create mode 100644 linux/.gitignore
create mode 100644 linux/COMPATIBILITY.md
create mode 100644 linux/CONTRIBUTING.md
create mode 100644 linux/Cargo.lock
create mode 100644 linux/Cargo.toml
create mode 100644 linux/README.md
create mode 100644 linux/RELEASE_NOTES.md
create mode 100644 linux/UPSTREAM_PR.md
create mode 100644 linux/companion/tinyhand@abdulrehmankhan56/extension.js
create mode 100644 linux/companion/tinyhand@abdulrehmankhan56/metadata.json
create mode 100644 linux/companion/tinyhand@abdulrehmankhan56/preferences.js
create mode 100644 linux/companion/tinyhand@abdulrehmankhan56/prefs.js
create mode 100644 linux/companion/tinyhand@abdulrehmankhan56/schemas/org.gnome.shell.extensions.tinyhand.gschema.xml
create mode 100644 linux/companion/tinyhand@abdulrehmankhan56/settings.js
create mode 100755 linux/scripts/install-release.sh
create mode 100755 linux/scripts/install.sh
create mode 100755 linux/scripts/package.sh
create mode 100755 linux/scripts/publish.sh
create mode 100644 linux/src/app.rs
create mode 100644 linux/src/asset.rs
create mode 100644 linux/src/config.rs
create mode 100644 linux/src/main.rs
create mode 100644 linux/src/model.rs
create mode 100644 linux/src/pose.rs
create mode 100644 linux/src/render.rs
create mode 100644 linux/src/sound.rs
create mode 100644 linux/src/tray.rs
create mode 100644 linux/src/x11.rs
create mode 100644 linux/tests/companion-render.mjs
create mode 100644 linux/tests/companion.test.cjs
create mode 100644 linux/tests/preferences-layout.mjs
diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml
new file mode 100644
index 0000000..cff1470
--- /dev/null
+++ b/.github/workflows/linux.yml
@@ -0,0 +1,124 @@
+name: Linux
+
+on:
+ push:
+ branches: [linux-port]
+ pull_request:
+ paths:
+ - 'linux/**'
+ - 'assets/**'
+ - '.github/workflows/linux.yml'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ check-and-package:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ toolchain: '1.94.1'
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Install packaging and verification dependencies
+ run: sudo apt-get update && sudo apt-get install -y python3-pil libglib2.0-bin gjs gir1.2-gtk-4.0 gir1.2-adw-1 xvfb dbus-x11
+ - name: Check Rust
+ run: cargo check --manifest-path linux/Cargo.toml --locked
+ - name: Test Rust
+ run: cargo test --manifest-path linux/Cargo.toml --locked
+ - name: Test companion logic
+ run: node linux/tests/companion.test.cjs
+ - name: Validate JavaScript and shell syntax
+ shell: bash
+ run: |
+ for file in linux/companion/tinyhand@abdulrehmankhan56/*.js; do
+ node --input-type=module --check < "$file"
+ done
+ bash -n linux/scripts/*.sh
+ glib-compile-schemas --strict --dry-run linux/companion/tinyhand@abdulrehmankhan56/schemas
+ - name: Package Linux releases
+ run: ./linux/scripts/package.sh
+ - name: Verify repeatable extension packaging
+ shell: bash
+ run: |
+ zip_path=linux/dist/tinyhand@abdulrehmankhan56.shell-extension.zip
+ before=$(sha256sum "$zip_path")
+ ./linux/scripts/package.sh --extension-only
+ test "$(sha256sum "$zip_path")" = "$before"
+ - name: Verify rendering and preferences
+ shell: bash
+ run: |
+ cargo run --manifest-path linux/Cargo.toml --locked -- --selftest
+ extension_dir=$(mktemp -d)
+ unzip -q linux/dist/tinyhand@abdulrehmankhan56.shell-extension.zip -d "$extension_dir"
+ gjs -m linux/tests/companion-render.mjs "$PWD" "$extension_dir"
+ test_config=$(mktemp -d -t tinyhand-layout.XXXXXX)
+ XDG_CONFIG_HOME="$test_config" xvfb-run -a dbus-run-session -- gjs -m linux/tests/preferences-layout.mjs
+ - name: Verify bundle install and uninstall
+ shell: bash
+ run: |
+ test_dir=$(mktemp -d)
+ tar -xzf linux/dist/tinyhand-linux-x86_64.tar.gz -C "$test_dir"
+ bundle="$test_dir/tinyhand-linux-x86_64"
+ test_home="$test_dir/home with spaces"
+ mkdir -p "$test_home/.config/tinyhand" "$test_home/.local/share/gnome-shell/extensions"
+ echo preserved > "$test_home/.config/tinyhand/config.json"
+ echo preserved > "$test_home/.local/share/gnome-shell/extensions/sentinel"
+ HOME="$test_home" "$bundle/install.sh"
+ "$test_home/.local/bin/tinyhand" --version
+ test -x "$test_home/.local/bin/tinyhand-settings"
+ test -f "$test_home/.local/share/tinyhand/assets/hand_lavender.png"
+ HOME="$test_home" "$bundle/install.sh" --uninstall
+ test ! -e "$test_home/.local/bin/tinyhand"
+ test ! -e "$test_home/.local/share/tinyhand"
+ test "$(cat "$test_home/.config/tinyhand/config.json")" = preserved
+ test "$(cat "$test_home/.local/share/gnome-shell/extensions/sentinel")" = preserved
+ - name: Generate checksums
+ working-directory: linux/dist
+ run: sha256sum *.zip *.tar.gz > SHA256SUMS
+ - uses: actions/upload-artifact@v4
+ with:
+ name: tinyhand-linux
+ path: |
+ linux/dist/*.zip
+ linux/dist/*.tar.gz
+ linux/dist/SHA256SUMS
+ if-no-files-found: error
+
+ publish-prerelease:
+ if: github.repository == 'AbdulRehman56/tinyhand' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/linux-port'
+ needs: check-and-package
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ concurrency:
+ group: tinyhand-linux-release
+ cancel-in-progress: false
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/download-artifact@v4
+ with:
+ name: tinyhand-linux
+ path: linux/dist
+ - name: Publish versioned Linux prerelease once
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ shell: bash
+ run: |
+ release_tag="v$(python3 -c 'import json; print(json.load(open("linux/companion/tinyhand@abdulrehmankhan56/metadata.json"))["version-name"])')"
+ [[ "$release_tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-linux\.[0-9]+$ ]]
+ if gh release view "$release_tag" >/dev/null 2>&1; then
+ echo "Release already exists; leaving its tag and artifacts unchanged."
+ exit 0
+ fi
+ (cd linux/dist && sha256sum --check SHA256SUMS)
+ gh release create "$release_tag" \
+ --target "$GITHUB_SHA" --prerelease --latest=false \
+ --title "TinyHand for Linux $release_tag (experimental)" \
+ --notes-file linux/RELEASE_NOTES.md \
+ linux/dist/*.zip linux/dist/*.tar.gz linux/dist/SHA256SUMS
diff --git a/README.md b/README.md
index e1f8647..0b4feb0 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,19 @@ Open `TinyHand.dmg`, drag TinyHand to Applications and allow **Accessibility** w
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.
+## Experimental Linux port
+
+An optional Linux implementation is available in [linux/](./linux/README.md):
+a GNOME Shell 45–50 companion for Wayland and a Rust X11 app, with visual settings
+and per-user installation. GNOME 45–49 are experimental (API/mocks only); 50.1 is
+the only desktop-tested version. Settings need GTK 4.8+ and libadwaita 1.4+.
+See the compatibility assessment for limits; full macOS parity remains incomplete.
+
+TinyHand's original app, concept, artwork and sounds are by
+[Kamran Ahmed (@nilbuild)](https://github.com/nilbuild). The independent Linux
+port is maintained by [AbdulRehman56](https://github.com/AbdulRehman56).
+See the [Linux guide](./linux/README.md) for installation, packaging and known gaps.
+
## License
MIT. See [LICENSE](LICENSE).
diff --git a/linux/.gitignore b/linux/.gitignore
new file mode 100644
index 0000000..cd08455
--- /dev/null
+++ b/linux/.gitignore
@@ -0,0 +1,3 @@
+/target/
+**/gschemas.compiled
+/dist/
diff --git a/linux/COMPATIBILITY.md b/linux/COMPATIBILITY.md
new file mode 100644
index 0000000..0dd9328
--- /dev/null
+++ b/linux/COMPATIBILITY.md
@@ -0,0 +1,101 @@
+# GNOME compatibility and maintenance
+
+The extension targets GNOME Shell **45–50**. **45–49 are experimental**: their
+versioned APIs were inspected and our fallbacks are covered by automated mocks,
+but those desktops have not been run or tested here. GNOME **50.1** is the only
+desktop used during development; the latest compatibility changes have been
+checked with mocks and installed Mutter 18 API introspection, without reloading
+the active extension. No cross-distribution or full macOS parity claim is made.
+
+## Versioned API assessment
+
+| GNOME | Cursor tracker lookup | Cursor visibility | Evidence |
+| --- | --- | --- | --- |
+| 45 | `Meta.CursorTracker.get_for_display(global.display)` | Save visibility; `set_pointer_visible`; restore saved value | Versioned headers + mock; no live test |
+| 46 | Display lookup | Legacy setter | Versioned headers + mock; no live test |
+| 47 | Display lookup | Legacy setter | Versioned headers + mock; no live test |
+| 48 | `global.backend.get_cursor_tracker()` | Legacy setter | Versioned headers + mock; no live test |
+| 49 | Backend lookup | Balanced `inhibit_cursor_visibility` / `uninhibit_cursor_visibility` | Versioned headers + mock; no live test |
+| 50 | Backend lookup | Balanced inhibitor | Versioned headers + mock; installed Mutter 18 API check; earlier 50.1 desktop development |
+
+The adapter detects capabilities, rather than parsing a distribution's version
+string. It prefers the backend lookup, falls back to the display API, and keeps
+the system cursor unchanged when neither API is available. Modern inhibition is
+acquired only once and released when disabled or when Alt enables pass-through.
+The legacy setter restores the visibility observed before TinyHand hid it;
+it cannot coordinate concurrent cursor changes as reliably as the new inhibitor.
+Disable cursor hiding in settings if another extension controls cursor visibility.
+
+All six Mutter versions expose the same sound call:
+`display.get_sound_player().play_from_file(file, description, cancellable)`.
+No guessed argument-count fallback or external audio subprocess is needed in
+Shell. Playback is optional; missing sound support does not stop drawing.
+The cancellable is cancelled during disable.
+
+Shell code uses Cairo and GdkPixbuf, never imports GTK/Gdk/libadwaita, and draws
+through `St.DrawingArea`'s repaint/context APIs. Those APIs were checked in the
+45.0 and 50.0 headers. ESM extension and preference entry points are available
+from GNOME 45. Preferences open through the extension's `openPreferences()`.
+
+## Preferences baseline
+
+Settings require **GTK 4.8+ and libadwaita 1.4+**, independently of the Shell
+version. `Gtk.Picture.content-fit` appeared in 4.8; `Adw.SwitchRow` appeared in
+1.4. The preferences builder reports these minimums before building its UI if
+the dependencies are too old. A Shell metadata version does not guarantee the
+distribution has the necessary settings libraries.
+
+`Adw.PreferencesWindow` and the page/group APIs predate this baseline. Although
+PreferencesWindow is deprecated since libadwaita 1.6, it remains available and
+matches GNOME's extension-preferences window contract. Replacing it with a
+dialog would require a separate standalone-settings migration and cannot be
+assumed compatible with older Shell preferences hosts.
+
+## Checks and release policy
+
+- `node linux/tests/companion.test.cjs` runs each 45–50 API shape through lookup,
+ cursor hiding/restoration, sound arguments and repeated disable. It also tests
+ preservation of an already hidden cursor, absent APIs, and dependency errors.
+- Rust tests and rendering self-tests exercise the independent X11 implementation.
+- CI pins Rust 1.94.1, uses `Cargo.lock`, and uses Ubuntu 24.04 to build the x86_64
+ binary against a lower glibc baseline
+ than the maintainer's local glibc 2.43. The included `BUILD-INFO.txt` records the
+ actual build host. This is not a statically linked, universal Linux binary.
+- Archive timestamps are normalized (`SOURCE_DATE_EPOCH` may override the default).
+ CI compares two extension builds in the same environment. Reproducing bytes
+ across different Pillow/zlib/compiler versions is not promised.
+- CI renders all 36 hands from the actual unpacked extension ZIP and runs GTK
+ settings/layout under Xvfb. These do not emulate a GNOME compositor or establish
+ live input, shortcut, cursor, scaling or other-extension compatibility.
+- The ZIP bundles resized alpha artwork, sounds, schemas, license and credits;
+ it needs neither the Rust binary nor a separately cloned source/assets tree.
+- Release tags and assets are immutable in the publication workflow. For a
+ compatibility change after publication, increment the extension integer/version
+ name and release tag, update notes, and publish a new prerelease. Never silently
+ replace an existing tag's assets.
+- Before promoting an older GNOME version from experimental, test enable/disable,
+ Point/Draw, Alt pass-through, cursor restoration, shortcuts, audio and settings
+ on that actual desktop, recording distribution and library versions.
+
+## Primary sources inspected
+
+Mutter cursor headers:
+[45.0](https://github.com/GNOME/mutter/blob/45.0/src/meta/meta-cursor-tracker.h),
+[46.0](https://github.com/GNOME/mutter/blob/46.0/src/meta/meta-cursor-tracker.h),
+[47.0](https://github.com/GNOME/mutter/blob/47.0/src/meta/meta-cursor-tracker.h),
+[48.0](https://github.com/GNOME/mutter/blob/48.0/src/meta/meta-cursor-tracker.h),
+[49.0](https://github.com/GNOME/mutter/blob/49.0/src/meta/meta-cursor-tracker.h),
+[50.0](https://github.com/GNOME/mutter/blob/50.0/src/meta/meta-cursor-tracker.h).
+Corresponding `meta-backend.h` and `meta-sound-player.h` headers were inspected
+at each of those tags; display sound-player getters were checked at 45.0/50.0.
+
+Shell drawing:
+[45.0](https://github.com/GNOME/gnome-shell/blob/45.0/src/st/st-drawing-area.h),
+[50.0](https://github.com/GNOME/gnome-shell/blob/50.0/src/st/st-drawing-area.h).
+Entry points and preferences:
+[GNOME 45 porting guide](https://gjs.guide/extensions/upgrading/gnome-shell-45.html),
+[45.0 Extension class](https://github.com/GNOME/gnome-shell/blob/45.0/js/extensions/extension.js).
+Widget requirements:
+[SwitchRow](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/class.SwitchRow.html),
+[PreferencesWindow](https://gnome.pages.gitlab.gnome.org/libadwaita/doc/1-latest/class.PreferencesWindow.html),
+[Picture.content-fit](https://docs.gtk.org/gtk4/property.Picture.content-fit.html).
diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md
new file mode 100644
index 0000000..ff4bceb
--- /dev/null
+++ b/linux/CONTRIBUTING.md
@@ -0,0 +1,94 @@
+# Contributing to the Linux port
+
+TinyHand is the original work of **[Kamran Ahmed (@nilbuild)](https://github.com/nilbuild)**:
+[upstream source](https://github.com/nilbuild/tinyhand) · [tinyhand.app](https://tinyhand.app).
+[AbdulRehman56](https://github.com/AbdulRehman56) maintains this independent Linux fork.
+Retain creator attribution and the unchanged MIT notice, **Copyright (c) 2026 Kamran Ahmed**,
+in source distributions, packages, and release materials.
+
+## Scope and setup
+
+Work from the fork's `linux-port` branch. Keep Linux changes in `linux/` where practical;
+leave the original macOS source untouched unless a separately explained change is necessary.
+Do not claim upstream acceptance or full macOS parity.
+
+```sh
+git clone --branch linux-port https://github.com/AbdulRehman56/tinyhand.git
+cd tinyhand
+cargo build --manifest-path linux/Cargo.toml
+```
+
+The X11 app uses pure-Rust dependencies and needs no system development headers.
+Use a normal user account, not sudo. Cargo needs access to dependencies on the first
+build; `--offline` works only after they are cached. Node.js runs companion logic tests.
+GJS rendering/settings checks require the appropriate GNOME libraries; settings needs
+GTK 4.8+ and libadwaita 1.4+, and its layout test needs a graphical desktop.
+
+## Development checks
+
+Run from the repository root:
+
+```sh
+cargo test --manifest-path linux/Cargo.toml
+cargo run --manifest-path linux/Cargo.toml -- --selftest
+node --test linux/tests/companion.test.cjs
+gjs -m linux/tests/companion-render.mjs
+glib-compile-schemas --strict --dry-run linux/companion/tinyhand@abdulrehmankhan56/schemas
+
+# Graphical desktop required; isolate writes from your real settings:
+test_config=$(mktemp -d -t tinyhand-layout.XXXXXX)
+XDG_CONFIG_HOME="$test_config" gjs -m linux/tests/preferences-layout.mjs
+```
+
+The self-test writes sample poses to `/tmp/tinyhand-selftest/`. Inspect rendering and
+fingertip alignment as well as exit status. For a cached/offline Rust check, add
+`--offline` to the Cargo commands. Report skipped checks and their reasons honestly.
+
+For desktop testing, inspect and run `./linux/scripts/install.sh --extension` as your
+normal user, then enable `tinyhand@abdulrehmankhan56`. A new extension directory may
+require logout/login before discovery; copied files are not a guarantee of live reload.
+Never run the X11 app and companion simultaneously.
+
+## Pull requests and bug reports
+
+- Describe the problem, the change, and how to reproduce or verify it.
+- State your distribution, session (GNOME Wayland or X11), GNOME Shell version, display
+ scaling/monitor setup, and relevant extension conflicts.
+- List exact checks run, results, and untested scope. Metadata targets GNOME 45–50;
+ 45–49 remain experimental with versioned API/mocked coverage, never claimed as
+ desktop-tested. GNOME 50.1 is the only desktop used in development; the latest
+ changes have not been hot-loaded. Follow [COMPATIBILITY.md](./COMPATIBILITY.md)
+ before promoting a version or raising the GTK/libadwaita minimums.
+- For interaction changes, test Point/Draw, Alt pass-through, cursor restoration,
+ shortcuts, settings persistence, enable/disable, and sound where applicable.
+- For settings changes, test all affected controls and the isolated GTK layout test.
+- Keep patches focused. Avoid unrelated formatting, generated build output, or personal config.
+- Include screenshots only when captured from the actual implementation; do not imply
+ a mockup or offline render proves live Shell behavior.
+
+An upstream PR should clearly describe this as an optional, experimental Linux port,
+credit the original creator, explain packaging and dependencies, and separate verified
+behavior from remaining gaps. Submit only the tested scope; upstream review and acceptance
+remain the original project's decision.
+
+## Release checklist
+
+Keep [release notes](./RELEASE_NOTES.md) accurate and preserve attribution in every artifact.
+Verify install/uninstall in a clean per-user environment before publishing packages.
+The initial prerelease tag is `v1.0.2-linux.1`; the extension ZIP and Linux tarball
+installer must be built and checked before being advertised as downloadable. The ZIP
+should be self-contained, with resized alpha artwork for Shell rendering; leave source
+artwork unchanged. The portable tarball installer should not require Cargo.
+Use native Shell preferences launching and sound playback; do not introduce a Gdk import
+into Shell code. Preferences previews and X11 audio may still require `paplay`.
+The prepared publication can be run with `./linux/scripts/publish.sh` from a
+`linux-port` checkout with an authenticated GitHub CLI and a writable `.git` directory.
+It commits only the listed publication files, refuses unrelated or staged changes,
+prepares an upstream branch with a minimal root README addition, pushes both branches,
+sets the fork's default branch, opens the upstream draft PR, and waits for the release
+workflow. It leaves an existing release unchanged. The tag comes from metadata's
+`version-name` with a `v` prefix; update that field, the integer extension version,
+and release notes together for each follow-up. The workflow builds
+on Ubuntu 24.04 and only publishes after its verification job succeeds.
+The GNOME Extensions listing is not submitted; maintainer upload is pending:
+do not add a published/approved badge or claim availability before approval and publication.
diff --git a/linux/Cargo.lock b/linux/Cargo.lock
new file mode 100644
index 0000000..03af764
--- /dev/null
+++ b/linux/Cargo.lock
@@ -0,0 +1,1237 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "arc-swap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[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 = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[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-queue"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae"
+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 = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "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 = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "futures-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 = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix",
+ "windows-link",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png 0.18.1",
+]
+
+[[package]]
+name = "indexmap"
+version = "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 = "js-sys"
+version = "0.3.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "ksni"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bcc1cb43797140f74b711f1c4a49f48a7a7429f211c5fb78264e7bcd1e99caf"
+dependencies = [
+ "arc-swap",
+ "crossbeam-queue",
+ "futures-util",
+ "pastey",
+ "serde",
+ "tokio",
+ "zbus",
+]
+
+[[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 = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
+
+[[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 = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "pastey"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.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_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+
+[[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 = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[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 = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "png 0.17.16",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinyhand-linux"
+version = "1.0.2"
+dependencies = [
+ "ab_glyph",
+ "dirs",
+ "image",
+ "ksni",
+ "serde",
+ "serde_json",
+ "tiny-skia",
+ "x11rb",
+]
+
+[[package]]
+name = "tokio"
+version = "1.54.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[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.25.19+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd43688c4858d7264ae1cf9a9f874a67c3feb74a883b1cd7bebd6a4ea8307218"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[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",
+]
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "uuid"
+version = "1.29.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a06f08a856b009bc7bbaf7d3e79808595d032dd19074c0c2ef26e49e03e6bdc"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
+dependencies = [
+ "cfg-if",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.130"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+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.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+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_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "winnow"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "x11rb"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44"
+dependencies = [
+ "gethostname",
+ "rustix",
+ "x11rb-protocol",
+]
+
+[[package]]
+name = "x11rb-protocol"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8"
+
+[[package]]
+name = "zbus"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
+dependencies = [
+ "async-broadcast",
+ "async-recursion",
+ "async-trait",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix",
+ "serde",
+ "serde_repr",
+ "tokio",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "zbus_macros"
+version = "5.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zbus_names"
+version = "4.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
+dependencies = [
+ "serde",
+ "winnow",
+ "zvariant",
+]
+
+[[package]]
+name = "zcheapstr"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zvariant"
+version = "5.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_derive"
+version = "5.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.9",
+ "winnow",
+]
diff --git a/linux/Cargo.toml b/linux/Cargo.toml
new file mode 100644
index 0000000..54358bb
--- /dev/null
+++ b/linux/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "tinyhand-linux"
+version = "1.0.2"
+edition = "2021"
+description = "TinyHand for Linux: a tiny hand on a stick that points at things on your screen"
+license = "MIT"
+
+[[bin]]
+name = "tinyhand"
+path = "src/main.rs"
+
+[dependencies]
+x11rb = { version = "0.14", features = ["xinput", "xfixes", "shape", "xtest", "xinerama"] }
+tiny-skia = "0.11"
+image = { version = "0.25", default-features = false, features = ["png"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+dirs = "5"
+ksni = { version = "0.3", features = ["blocking"] }
+ab_glyph = "0.2"
+
+[profile.release]
+opt-level = 3
+lto = "thin"
diff --git a/linux/README.md b/linux/README.md
new file mode 100644
index 0000000..86f977c
--- /dev/null
+++ b/linux/README.md
@@ -0,0 +1,77 @@
+# TinyHand for Linux
+
+TinyHand is a tiny hand on a stick that points at things on your screen: click to play a tap sound, or hold and drag to draw ink that fades after 3 seconds. This Linux port (branch `linux-port`) is based on [nilbuild/tinyhand](https://github.com/nilbuild/tinyhand) and is MIT-licensed.
+
+## Which mode do I need?
+
+| Session | Mode |
+| --- | --- |
+| Xorg/X11 | The Rust app, `tinyhand`, hides the real cursor, swallows clicks for tapping/drawing, lets panels/dock areas pass through, and provides a StatusNotifier tray menu (toggle / next hand / clear / quit). Hold Alt to interact normally. |
+| GNOME Wayland (45–50; 45–49 experimental) | The companion Shell extension draws the hand as a shell overlay. **Point** mode allows normal clicks without ink. **Draw** mode captures pointer drags, avoiding text selection underneath; hold **Alt** to interact normally. The system cursor can be hidden. The bottom toolbar offers hand previews, Point/Draw, ink colors, sound, clear, settings, and hide. |
+
+Both modes share `~/.config/tinyhand/config.json`. Run only one mode at a time. This is an experimental prerelease; GNOME 50.1 is the only desktop-tested version. The latest changes have API/mocked coverage, not a fresh live Shell test. See [COMPATIBILITY.md](./COMPATIBILITY.md) for the version matrix, library minimums and testing limits.
+
+## Install
+
+```sh
+./linux/scripts/install.sh # X11 app + assets + icon, from repo root
+./linux/scripts/install.sh --extension # + GNOME Wayland companion
+```
+
+Enable the extension with `gnome-extensions enable tinyhand@abdulrehmankhan56` or use the Extensions app. If it does not show up, log out and back in once.
+
+Rust/Cargo is needed to build the X11 app; no system development headers are required. The companion uses GJS, Cairo and GdkPixbuf shipped with GNOME; Shell code does not import Gdk, Gtk or Adw. Preferences run separately with GTK4/libadwaita. Installation stays under your home directory and does not enable the extension automatically.
+
+For packages that need no Cargo, see the fork's [releases](https://github.com/AbdulRehman56/tinyhand/releases). Build the self-contained extension ZIP and portable X11 tarball with `./linux/scripts/package.sh`, or use `--extension-only` to skip Rust. Packaging needs Python 3 with Pillow and `glib-compile-schemas`. Source artwork remains unchanged; packages contain resized RGBA copies. Check the tarball's `BUILD-INFO.txt` for architecture and glibc requirements.
+
+## Usage
+
+- Open **TinyHand Settings** from your app launcher, run `tinyhand-settings`, or use the toolbar gear. The macOS-inspired sidebar has a visual picker for all 37 pointers plus appearance, drawing, and sound controls. Changes apply immediately; no JSON editing needed. Settings requires GJS, GTK 4.8+, and libadwaita 1.4+.
+- `tinyhand` — run the app (needs X11; with `WAYLAND_DISPLAY` set it warns and explains).
+- Hotkeys in both modes: Ctrl+Alt+H toggle · Ctrl+Alt+X clear ink · Ctrl+Alt+P next hand.
+- `tinyhand --selftest` renders sample poses to `/tmp/tinyhand-selftest/*.png` (checks assets and fingertip accuracy); `tinyhand --config` prints the config path; `tinyhand --help` shows help.
+
+## Development checks
+
+```sh
+cargo test --manifest-path linux/Cargo.toml
+cargo run --manifest-path linux/Cargo.toml -- --selftest
+node --test linux/tests/companion.test.cjs
+gjs -m linux/tests/companion-render.mjs
+# On a graphical desktop, validates actual allocations and isolated setting writes:
+test_config=$(mktemp -d -t tinyhand-layout.XXXXXX)
+XDG_CONFIG_HOME="$test_config" gjs -m linux/tests/preferences-layout.mjs
+glib-compile-schemas --strict --dry-run linux/companion/tinyhand@abdulrehmankhan56/schemas
+```
+
+Offline rendering and logic checks cannot verify shortcut conflicts, click-through behavior, or compatibility with other GNOME extensions. Test those on your desktop after enabling the companion.
+
+## Config (`~/.config/tinyhand/config.json`)
+
+Config is shared by both apps and hot-reloads on save.
+
+| Key | Values / meaning |
+| --- | --- |
+| `pointer.style` | `lavender`, `glove`, `coral`, `plush`, `robot`, `pink`, `blue`, `mint`, `lemon`, `black`, `chrome`, `holo`, `fuzzy`, `wood`, `marble`, `terrazzo`, `glass`, `jelly`, `pastel`, `pixel`, `lowpoly`, `knit`, `skeleton`, `astro`, `space`, `monster`, `water`, `lava`, `grass`, `cactus`, `choco`, `cloud`, `mech`, `neon`, `leaf`, `cookie`, or `laser` |
+| `pointer.size` | Hand size as a fraction of screen height, `0.08`–`0.20` (default `0.13`). |
+| `pointer.shadow` | Boolean. |
+| `interaction.mode`, `interaction.hideCursor` | GNOME only: `point` (default, normal clicks/no ink) or `draw` (captures drags); hide the system cursor by default. Cursor visibility is restored when TinyHand is disabled. |
+| `ink.choice` | `match` (each hand's matching color), or `red` / `orange` / `yellow` / `green` / `blue` / `purple` / `white` / `black`. |
+| `ink.fade` | Seconds; `0` keeps strokes until cleared. |
+| `sound.hand`, `sound.laser` | Booleans for tap sounds on hand and laser clicks. |
+| `sound.tap` | `thock`, `click`, `pop`, `wood`, `boop`, or `squeak`. |
+| `interact.key` | X11 only: `alt`, `ctrl`, `super`, or `off`; hold this key so clicks pass through to apps under the hand. |
+| `hotkeys.toggle`, `hotkeys.clear`, `hotkeys.cycle` | X11 shortcuts; defaults are `ctrl+alt+h`, `ctrl+alt+x`, `ctrl+alt+p`. GNOME uses the extension's GSettings `toggle`, `clear`, and `cycle` keys instead. |
+
+## Troubleshooting
+
+- “no 32-bit RGBA visual…”: the X11 session has no compositor.
+- Running the X11 app inside Wayland only affects XWayland windows; prefer the extension there.
+- The tray icon needs an AppIndicator extension (for example, dash-to-panel with appindicator).
+- X11 tap sounds and preferences sound previews use `paplay` (PulseAudio/PipeWire); Shell taps use GNOME's sound player.
+- If assets are missing, run `linux/scripts/install.sh`, or run from the repo root (the app also looks in ancestor `assets/` directories).
+- Config saves hot-reload. Cycling selects five pinned hands and laser; select any of the other 31 hands using `pointer.style`.
+- GNOME currently uses the whole stage for sizing/anchoring; mixed-scale and multi-monitor layouts need testing. The minimum rod length can offset the fingertip near the bottom-right corner. Very quick clicks can be missed by pointer polling.
+- To disable on GNOME: `gnome-extensions disable tinyhand@abdulrehmankhan56`. To remove installed files: `./linux/scripts/install.sh --uninstall` (keeps config; add `--purge` to remove it).
+
+Upstream: https://github.com/nilbuild/tinyhand · Fork: https://github.com/AbdulRehman56/tinyhand (branch `linux-port`) · MIT.
diff --git a/linux/RELEASE_NOTES.md b/linux/RELEASE_NOTES.md
new file mode 100644
index 0000000..b13daa1
--- /dev/null
+++ b/linux/RELEASE_NOTES.md
@@ -0,0 +1,79 @@
+# Linux release notes
+
+## v1.0.2-linux.1 — experimental prerelease
+
+Check the fork's [releases page](https://github.com/AbdulRehman56/tinyhand/releases)
+for publication status and downloads. Packages comprise a self-contained GNOME extension
+ZIP, an x86_64 X11 tarball with a per-user installer, and `SHA256SUMS`. Installation
+requires no Cargo. The ZIP bundles 36 resized RGBA hands and six original sounds without
+changing source assets. The GNOME Extensions (EGO) listing is **not submitted or approved**;
+maintainer upload is pending.
+
+GitHub Actions builds the X11 binary on Ubuntu 24.04 (glibc 2.39). Check the included
+`BUILD-INFO.txt` for the actual build host and architecture. Older glibc systems should
+build from source. Locally built artifacts may require newer glibc than CI artifacts.
+
+### Credits and license
+
+TinyHand was created by **[Kamran Ahmed (@nilbuild)](https://github.com/nilbuild)**.
+The original project is [nilbuild/tinyhand](https://github.com/nilbuild/tinyhand), with
+the original app at [tinyhand.app](https://tinyhand.app).
+This independent Linux port is maintained by [AbdulRehman56](https://github.com/AbdulRehman56).
+The MIT license and **Copyright (c) 2026 Kamran Ahmed** remain unchanged.
+This is not an upstream-endorsed or accepted Linux release.
+
+### Included
+
+- GNOME Shell companion for Wayland targeting **GNOME Shell 45–50**; 45–49 are
+ experimental and have not been desktop-tested.
+- Capability-based cursor tracker lookup (display on 45–47, backend on 48+),
+ balanced visibility inhibition on 49+, and saved visibility restoration on 45–48.
+- Clear settings dependency errors for GTK below 4.8 or libadwaita below 1.4.
+- Native GNOME preferences release Draw-mode capture while TinyHand Settings is
+ focused, so settings controls can be used without holding Alt.
+- Point mode for normal clicks and Draw mode for captured drawing, Alt pass-through,
+ optional system-cursor hiding, and an on-screen toolbar.
+- macOS-inspired GTK4/libadwaita settings with all **37 pointers**, appearance,
+ ink, and sound controls, sharing `~/.config/tinyhand/config.json` with the Rust app.
+- Experimental Rust X11 overlay, click capture, tap sounds, tray menu, and shortcuts.
+- Per-user source installer and uninstall support; no sudo needed. New extension
+ directories may require the next login before Shell discovers them.
+
+### Verification scope
+
+**GNOME 50.1 is the only desktop-tested GNOME version.** Metadata targets 45–50;
+45–49 have versioned API review and automated mocks only. The latest compatibility
+changes were not hot-loaded into the active desktop. Installed Mutter 18/Cairo APIs
+were checked by introspection; this is not a fresh live input or compositor test.
+See [COMPATIBILITY.md](./COMPATIBILITY.md) for the evidence and version matrix.
+The X11 path remains experimental; do not infer broad desktop certification.
+
+Verification includes offline Rust tests and rendering self-tests, Node companion logic
+checks, GJS rendering checks, schema validation, and GTK settings/layout tests.
+The current local suite passes 9 Rust tests and 23 Node tests, including all six
+version-shaped cursor/sound/disable fixtures and settings dependency errors.
+GJS rendered all 36 hands with the latest Cairo-only Shell code, and the GTK settings
+layout/save tests passed again on the maintainer's desktop. The release pipeline
+repeats these checks, renders the unpacked extension ZIP, and verifies tarball
+install/uninstall with an isolated home directory. A passing pipeline is required
+before automated release publication.
+See [CONTRIBUTING.md](./CONTRIBUTING.md) for reproducible commands. Offline Cargo runs
+require cached dependencies. These checks are not substitutes for live Shell input tests
+and do not prove click-through or shortcut behavior across all desktops.
+
+### Known gaps and cautions
+
+- Not full macOS parity: custom hands, pin reordering, and a complete hotkey settings
+ UI are not implemented. GNOME hotkeys use GSettings; X11 hotkeys use JSON config.
+- Mixed-scale/multi-monitor layouts and other GNOME versions need further testing.
+ Whole-stage sizing/anchoring and minimum rod length can affect fingertip alignment.
+- Very quick clicks can be missed by pointer polling. Shortcut conflicts and interaction
+ with other Shell extensions require desktop testing.
+- Use the companion on GNOME Wayland, not the Rust overlay inside XWayland; do not run
+ both modes together. Other Wayland compositors have no native integration here.
+- X11 tray visibility may require AppIndicator support. X11 tap sounds and preferences
+ sound previews require `paplay`; Shell sounds use `Meta.SoundPlayer` instead.
+- Copying updated extension files does not promise immediate live hot-loading.
+
+Install from source using the [root README](../README.md); consult the
+[Linux technical guide](./README.md) for configuration and troubleshooting.
diff --git a/linux/UPSTREAM_PR.md b/linux/UPSTREAM_PR.md
new file mode 100644
index 0000000..5af0e29
--- /dev/null
+++ b/linux/UPSTREAM_PR.md
@@ -0,0 +1,9 @@
+This adds an optional, experimental Linux implementation alongside the existing macOS app: a GNOME Shell 45–50 companion for Wayland and a Rust overlay for X11. GNOME 45–49 are experimental, with versioned API inspection and mocked coverage only. TinyHand's concept, original app, artwork and sounds are by Kamran Ahmed (@nilbuild), https://github.com/nilbuild/tinyhand and https://tinyhand.app. I maintain this independent Linux port; the original MIT notice is preserved.
+
+The macOS sources and original assets are unchanged. Linux code lives in `linux/`; the root README only gains a Linux section. The port includes Point/Draw modes, Alt pass-through, a toolbar, GTK4/libadwaita visual settings for 36 hands plus a laser, tap sounds, shortcuts, a per-user installer, packaging scripts, and Linux CI. The extension ZIP contains resized alpha artwork and all sounds; the X11 tarball installs without Cargo.
+
+Local verification: 9 Rust tests, 23 companion tests (including GNOME 45–50 API shapes and native-preferences input release), Rust rendering self-test, schema validation, JavaScript/shell syntax, ZIP content checks, and isolated tarball install/uninstall. All 36 hands rendered with the latest Cairo-only Shell code; GTK settings layout/save checks also passed again on the maintainer's desktop. GNOME 50.1 is the only desktop-tested Shell version; the latest compatibility changes were not hot-loaded. Installed Mutter 18/Cairo APIs were introspected. CI repeats the rendering and settings tests, including the actual unpacked ZIP. Preferences require GTK 4.8+ and libadwaita 1.4+. `linux/COMPATIBILITY.md` records sources and test limits.
+
+This is a draft for feedback, with incomplete macOS parity. Custom hands, pin reordering, complete hotkey settings, mixed-scale/multi-monitor behavior, shortcut conflicts, and interaction with other extensions need further work/testing. X11 remains experimental; other Wayland compositors are unsupported. GNOME Extensions submission is still pending. The fork's Linux prerelease workflow publishes only after all checks pass and is restricted to `AbdulRehman56/tinyhand`; it cannot publish an upstream release.
+
+Linux documentation: `linux/README.md`, `linux/CONTRIBUTING.md`, and `linux/RELEASE_NOTES.md`.
diff --git a/linux/companion/tinyhand@abdulrehmankhan56/extension.js b/linux/companion/tinyhand@abdulrehmankhan56/extension.js
new file mode 100644
index 0000000..f28ae8c
--- /dev/null
+++ b/linux/companion/tinyhand@abdulrehmankhan56/extension.js
@@ -0,0 +1,753 @@
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import St from 'gi://St';
+import GLib from 'gi://GLib';
+import Gio from 'gi://Gio';
+import Clutter from 'gi://Clutter';
+import GdkPixbuf from 'gi://GdkPixbuf';
+import Shell from 'gi://Shell';
+import Meta from 'gi://Meta';
+// Cairo is a built-in GJS module, not a GI typelib.
+import cairo from 'cairo';
+
+const DEFAULTS = {
+ enabled: true,
+ pointer: {style: 'lavender', size: 0.13, shadow: true},
+ ink: {choice: 'match', fade: 3.0},
+ sound: {hand: true, laser: false, tap: 'thock'},
+ interact: {key: 'alt', keepsHand: false},
+ interaction: {mode: 'point', hideCursor: true},
+ hotkeys: {},
+ seenWelcome: false,
+};
+const DEFAULT_PINNED = ['lavender', 'glove', 'coral', 'plush', 'robot', 'laser'];
+const COLORS = {
+ red: [1, 0.16, 0.12], orange: [1, 0.54, 0], yellow: [1, 0.8, 0],
+ green: [0.19, 0.76, 0.35], blue: [0.04, 0.52, 1], purple: [0.53, 0.33, 0.95],
+ white: [1, 1, 1], black: [0.11, 0.11, 0.13],
+};
+const STYLE_INK = {
+ lavender: 'purple', glove: 'purple', coral: 'red', plush: 'blue', robot: 'green',
+ pink: 'purple', blue: 'blue', mint: 'green', lemon: 'red', black: 'black',
+ chrome: 'white', holo: 'purple', fuzzy: 'purple', wood: 'orange', marble: 'white',
+ terrazzo: 'red', glass: 'blue', jelly: 'purple', pastel: 'purple', pixel: 'black',
+ lowpoly: 'purple', knit: 'purple', skeleton: 'orange', astro: 'blue', space: 'red',
+ monster: 'green', water: 'blue', lava: 'red', grass: 'green', cactus: 'green',
+ choco: 'orange', cloud: 'blue', mech: 'red', neon: 'purple', leaf: 'green', cookie: 'orange',
+ laser: 'red',
+};
+const BINDINGS = ['toggle', 'clear', 'cycle'];
+const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
+const now = () => GLib.get_monotonic_time() / 1e6;
+const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+
+function merge(base, extra) {
+ // Never share nested defaults with live configuration objects.
+ const result = {};
+ for (const [key, value] of Object.entries(base)) {
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
+ continue;
+ result[key] = object(value) ? merge(value, {}) : value;
+ }
+ if (!object(extra))
+ return result;
+ for (const [key, value] of Object.entries(extra)) {
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
+ continue;
+ result[key] = object(value) && object(base[key]) ? merge(base[key], value) : value;
+ }
+ return result;
+}
+
+function readJson(path) {
+ try {
+ const [ok, bytes] = GLib.file_get_contents(path);
+ const value = ok ? JSON.parse(new TextDecoder().decode(bytes)) : {};
+ return object(value) ? value : {};
+ } catch (_) {
+ return {};
+ }
+}
+
+function writeJson(path, value) {
+ GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o700);
+ GLib.file_set_contents(path, JSON.stringify(value, null, 2));
+}
+
+export default class TinyHandExtension extends Extension {
+ enable() {
+ this._sources = new Set();
+ this._signals = [];
+ this._bound = [];
+ this._assets = new Map();
+ this._strokes = [];
+ this._active = null;
+ this._press = 0;
+ this._gripX = null;
+ this._last = null;
+ this._held = false;
+ this._dirty = true;
+ this._running = true;
+ this._cursorTracker = this._getCursorTracker();
+ this._cursorHidden = false;
+ this._cursorWasVisible = null;
+ this._soundCancellable = new Gio.Cancellable();
+ this._configPath = GLib.build_filenamev([GLib.get_user_config_dir(), 'tinyhand', 'config.json']);
+ this._geometryPath = GLib.build_filenamev([GLib.get_user_cache_dir(), 'tinyhand', 'hand-geometry.json']);
+ this._geometry = readJson(this._geometryPath);
+ this._assetDir = this._findAssets();
+ this._config = this._readConfig();
+
+ this._area = new St.DrawingArea({reactive: false, track_hover: false, can_focus: false});
+ this._connect(this._area, 'repaint', () => this._paint());
+ this._connect(this._area, 'button-press-event', () => Clutter.EVENT_STOP);
+ this._connect(this._area, 'button-release-event', () => Clutter.EVENT_STOP);
+ global.stage.add_child(this._area);
+ this._createToolbar();
+ this._connect(global.stage, 'notify::width', () => this._resize());
+ this._connect(global.stage, 'notify::height', () => this._resize());
+ this._connect(this._toolbar, 'notify::width', () => this._positionToolbar());
+ this._resize();
+
+ try {
+ this._settings = this.getSettings();
+ this._bindingSettings = this.getSettings('org.gnome.shell.extensions.tinyhand.bindings');
+ for (const name of BINDINGS) {
+ const sync = () => {
+ const accelerator = this._settings.get_string(name);
+ this._bindingSettings.set_strv(name, accelerator ? [accelerator] : []);
+ };
+ sync();
+ this._connect(this._settings, `changed::${name}`, sync);
+ // Actual GNOME signature: (name, settings, Meta flags, Shell modes, callback).
+ Main.wm.addKeybinding(name, this._bindingSettings, Meta.KeyBindingFlags.NONE,
+ Shell.ActionMode.ALL, () => {
+ if (name === 'toggle') this._toggle();
+ else if (name === 'clear') this._clear();
+ else this._cycle();
+ });
+ this._bound.push(name);
+ }
+ this._connect(this._settings, 'changed::show-toolbar', () => this._visibility());
+ } catch (error) {
+ console.warn(`TinyHand: compile/install the settings schema to enable shortcuts: ${error}`);
+ }
+ try {
+ GLib.mkdir_with_parents(GLib.path_get_dirname(this._configPath), 0o700);
+ this._monitor = Gio.File.new_for_path(this._configPath).monitor_file(Gio.FileMonitorFlags.NONE, null);
+ this._connect(this._monitor, 'changed', () => this._reload());
+ } catch (error) {
+ console.warn(`TinyHand: cannot watch configuration: ${error}`);
+ }
+ this._visibility();
+ this._schedule(16);
+ }
+
+ _connect(object_, signal, callback) {
+ this._signals.push([object_, object_.connect(signal, callback)]);
+ }
+
+ _readConfig() {
+ const config = merge(DEFAULTS, readJson(this._configPath));
+ for (const key of ['pointer', 'ink', 'sound', 'interact', 'interaction', 'hotkeys']) {
+ if (!object(config[key])) config[key] = {...DEFAULTS[key]};
+ }
+ config.enabled = typeof config.enabled === 'boolean' ? config.enabled : true;
+ config.pointer.style = typeof config.pointer.style === 'string' && /^[a-z0-9_-]+$/.test(config.pointer.style)
+ ? config.pointer.style : 'lavender';
+ config.pointer.size = Number.isFinite(config.pointer.size) ? clamp(config.pointer.size, 0.08, 0.20) : 0.13;
+ config.ink.fade = Number.isFinite(config.ink.fade) ? config.ink.fade : 3;
+ config.interaction.mode = config.interaction.mode === 'draw' ? 'draw' : 'point';
+ config.interaction.hideCursor = config.interaction.hideCursor !== false;
+ return config;
+ }
+
+ _reload() {
+ const oldStyle = this._config.pointer.style;
+ const oldMode = this._config.interaction.mode;
+ this._config = this._readConfig();
+ if (oldStyle !== this._config.pointer.style)
+ this._assets.delete(oldStyle);
+ if (!this._config.enabled || oldMode !== this._config.interaction.mode)
+ this._finishStroke();
+ // A formerly missing asset directory may now have been installed.
+ if (!this._assetDir) {
+ this._assetDir = this._findAssets();
+ this._assets.clear();
+ }
+ this._dirty = true;
+ this._visibility();
+ }
+
+ _update(patch) {
+ try {
+ // Merge into the latest disk contents, not our defaults, preserving other clients' keys.
+ writeJson(this._configPath, merge(readJson(this._configPath), patch));
+ this._reload();
+ } catch (error) {
+ console.warn(`TinyHand: cannot save configuration: ${error}`);
+ this._config = merge(this._config, patch);
+ this._dirty = true;
+ this._visibility();
+ }
+ }
+
+ _findAssets() {
+ const candidates = [GLib.build_filenamev([this.path, 'assets']),
+ GLib.build_filenamev([GLib.get_user_data_dir(), 'tinyhand', 'assets'])];
+ let dir = this.path;
+ while (dir) {
+ candidates.push(GLib.build_filenamev([dir, 'assets']));
+ const parent = GLib.path_get_dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
+ candidates.push(GLib.build_filenamev([GLib.get_home_dir(), 'Documents', 'Projects', 'tinyhand', 'assets']));
+ return candidates.find(path => GLib.file_test(path, GLib.FileTest.IS_DIR)) ?? null;
+ }
+
+ _toggle() {
+ this._finishStroke();
+ this._update({enabled: !this._config.enabled});
+ }
+
+ _cycle() {
+ const i = DEFAULT_PINNED.indexOf(this._config.pointer.style);
+ this._update({pointer: {style: DEFAULT_PINNED[(i + 1) % DEFAULT_PINNED.length]}});
+ }
+
+ _clear() {
+ this._strokes = [];
+ this._active = null;
+ this._dirty = true;
+ }
+
+ _createToolbar() {
+ this._toolbar = new St.BoxLayout({
+ vertical: false, reactive: true, style_class: 'tinyhand-toolbar',
+ style: 'background-color: rgba(28,28,32,0.94); color: white; border: 1px solid rgba(255,255,255,0.16); border-radius: 16px; padding: 8px 10px; spacing: 6px;',
+ });
+ this._hint = new St.Label({reactive: false, visible: false,
+ style: 'background-color: rgba(24,24,28,0.96); color: white; font-size: 12px; border-radius: 8px; padding: 6px 10px;'});
+ global.stage.add_child(this._hint);
+ const button = (child, description, callback, parent = this._toolbar) => {
+ const actor = new St.Button({child, reactive: true, can_focus: true, track_hover: true,
+ accessible_name: description, style_class: 'button',
+ style: 'min-width: 36px; min-height: 36px; padding: 2px; border-radius: 10px;'});
+ this._connect(actor, 'clicked', callback);
+ this._connect(actor, 'notify::hover', () => {
+ this._hint.text = description;
+ this._hint.visible = actor.hover && this._toolbar.visible;
+ this._hint.set_position(Math.max(0, this._toolbar.x + (this._toolbar.width - this._hint.width) / 2),
+ Math.max(0, this._toolbar.y - 42));
+ });
+ parent.add_child(actor);
+ return actor;
+ };
+ this._styleButtons = new Map();
+ for (const style of DEFAULT_PINNED) {
+ let image;
+ const thumbnail = style === 'laser' ? null : this._thumbnail(style);
+ if (thumbnail) {
+ image = new St.Icon({gicon: new Gio.FileIcon({file: Gio.File.new_for_path(thumbnail)}), icon_size: 36});
+ } else {
+ image = new St.Icon({icon_name: 'media-record-symbolic', icon_size: 24,
+ style: style === 'laser' ? 'color: #ff453a;' : 'color: #c4a7ff;'});
+ }
+ const title = style === 'laser' ? 'Laser' : style[0].toUpperCase() + style.slice(1);
+ this._styleButtons.set(style, button(image, title, () => this._update({pointer: {style}})));
+ }
+ this._toolbar.add_child(new St.Widget({style: 'width: 1px; margin: 5px 4px; background-color: rgba(255,255,255,0.18);'}));
+ this._modeIcon = new St.Icon({icon_name: 'input-mouse-symbolic', icon_size: 18});
+ this._modeButton = button(this._modeIcon, 'Point / Draw mode (hold Alt to interact)', () =>
+ this._update({interaction: {mode: this._config.interaction.mode === 'draw' ? 'point' : 'draw'}}));
+ this._inkDot = new St.Widget({width: 18, height: 18, x_align: Clutter.ActorAlign.CENTER,
+ y_align: Clutter.ActorAlign.CENTER});
+ button(this._inkDot, 'Ink color', () => {
+ this._inkTray.visible = !this._inkTray.visible;
+ this._positionToolbar();
+ });
+ this._soundIcon = new St.Icon({icon_name: 'audio-volume-high-symbolic', icon_size: 18});
+ button(this._soundIcon, 'Toggle tap sounds', () => this._update({sound: {hand: !this._config.sound.hand}}));
+ button(new St.Icon({icon_name: 'edit-clear-symbolic', icon_size: 18}), 'Clear drawing', () => this._clear());
+ button(new St.Icon({icon_name: 'preferences-system-symbolic', icon_size: 18}), 'Customize TinyHand', () => this._openSettings());
+ button(new St.Icon({icon_name: 'window-close-symbolic', icon_size: 16}), 'Hide TinyHand', () => this._update({enabled: false}));
+ global.stage.add_child(this._toolbar);
+ this._inkTray = new St.BoxLayout({reactive: true, visible: false,
+ style: 'background-color: rgba(28,28,32,0.96); border: 1px solid rgba(255,255,255,0.16); border-radius: 14px; padding: 6px; spacing: 4px;'});
+ for (const choice of ['match', ...Object.keys(COLORS)]) {
+ const dot = new St.Widget({width: 18, height: 18,
+ x_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER});
+ const rgb = COLORS[choice] ?? COLORS.purple;
+ dot.style = `background-color: rgb(${rgb.map(v => Math.round(v * 255)).join(',')}); border: 2px solid rgba(255,255,255,0.55); border-radius: 12px;`;
+ const title = choice === 'match' ? 'Match hand color' : choice[0].toUpperCase() + choice.slice(1);
+ button(dot, title, () => {
+ this._update({ink: {choice}});
+ this._inkTray.hide();
+ }, this._inkTray);
+ }
+ global.stage.add_child(this._inkTray);
+ this._connect(this._inkTray, 'notify::width', () => this._positionToolbar());
+ }
+
+ _thumbnail(style) {
+ try {
+ const cache = GLib.build_filenamev([GLib.get_user_cache_dir(), 'tinyhand', 'thumbnails']);
+ GLib.mkdir_with_parents(cache, 0o700);
+ const path = GLib.build_filenamev([cache, `${style}.png`]);
+ if (GLib.file_test(path, GLib.FileTest.IS_REGULAR)) return path;
+ const asset = this._asset(style);
+ if (!asset) return null;
+ const x = Math.max(0, Math.floor(asset.g.handMinX * asset.width));
+ const width = Math.min(asset.width - x, Math.ceil((asset.g.handMaxX - asset.g.handMinX) * asset.width) + 1);
+ const image = asset.hand.new_subpixbuf(x, 0, width, asset.hand.height);
+ const ratio = 64 / Math.max(image.width, image.height);
+ const scaled = image.scale_simple(Math.max(1, Math.round(image.width * ratio)),
+ Math.max(1, Math.round(image.height * ratio)), GdkPixbuf.InterpType.BILINEAR);
+ const square = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, true, 8, 72, 72);
+ square.fill(0);
+ scaled.copy_area(0, 0, scaled.width, scaled.height, square,
+ Math.floor((72 - scaled.width) / 2), Math.floor((72 - scaled.height) / 2));
+ square.savev(path, 'png', [], []);
+ return path;
+ } catch (error) {
+ console.warn(`TinyHand: thumbnail unavailable: ${error}`);
+ return null;
+ }
+ }
+
+ _openSettings() {
+ try {
+ this.openPreferences();
+ } catch (error) {
+ console.warn(`TinyHand: cannot open settings: ${error}`);
+ }
+ }
+
+ _overControls(x, y) {
+ return [this._toolbar, this._inkTray].some(actor => actor?.visible &&
+ x >= actor.x && y >= actor.y && x <= actor.x + actor.width && y <= actor.y + actor.height);
+ }
+
+ _getCursorTracker() {
+ // Mutter 48 moved this from CursorTracker to Backend.
+ return global.backend?.get_cursor_tracker?.() ??
+ Meta.CursorTracker?.get_for_display?.(global.display) ?? null;
+ }
+
+ _hideSystemCursor(hide) {
+ if (hide === this._cursorHidden) return;
+ const tracker = this._cursorTracker;
+ if (typeof tracker?.inhibit_cursor_visibility === 'function' &&
+ typeof tracker?.uninhibit_cursor_visibility === 'function') {
+ if (hide) tracker.inhibit_cursor_visibility();
+ else tracker.uninhibit_cursor_visibility();
+ } else if (typeof tracker?.set_pointer_visible === 'function' &&
+ typeof tracker?.get_pointer_visible === 'function') {
+ // Mutter 45–48 has no inhibitor. Restore the state we actually changed.
+ if (hide) {
+ this._cursorWasVisible = tracker.get_pointer_visible();
+ tracker.set_pointer_visible(false);
+ } else {
+ tracker.set_pointer_visible(this._cursorWasVisible ?? true);
+ this._cursorWasVisible = null;
+ }
+ } else {
+ return;
+ }
+ this._cursorHidden = hide;
+ }
+
+ _positionToolbar() {
+ this._toolbar.set_position(Math.max(0, (global.stage.width - this._toolbar.width) / 2),
+ Math.max(0, global.stage.height - 70));
+ if (this._inkTray) this._inkTray.set_position(Math.max(0, this._toolbar.x + (this._toolbar.width - this._inkTray.width) / 2),
+ Math.max(0, this._toolbar.y - 62));
+ }
+
+ _resize() {
+ this._area.set_size(global.stage.width, global.stage.height);
+ this._positionToolbar();
+ this._dirty = true;
+ }
+
+ _visibility() {
+ this._area.visible = this._config.enabled;
+ if (!this._config.enabled) {
+ this._area.reactive = false;
+ this._hideSystemCursor(false);
+ }
+ this._toolbar.visible = this._config.enabled && (this._settings?.get_boolean('show-toolbar') ?? true);
+ this._soundIcon.icon_name = this._config.sound.hand ? 'audio-volume-high-symbolic' : 'audio-volume-muted-symbolic';
+ this._modeIcon.icon_name = this._config.interaction.mode === 'draw' ? 'draw-freehand-symbolic' : 'input-mouse-symbolic';
+ this._modeButton.style = 'min-width: 36px; min-height: 36px; padding: 2px; border-radius: 10px;' +
+ (this._config.interaction.mode === 'draw' ? 'background-color: rgba(185,153,255,0.24); border: 1px solid #c4a7ff;' : 'border: 1px solid transparent;');
+ const choice = this._config.ink.choice === 'match' ? STYLE_INK[this._config.pointer.style] : this._config.ink.choice;
+ const rgb = COLORS[choice] ?? COLORS.purple;
+ this._inkDot.style = `background-color: rgb(${rgb.map(v => Math.round(v * 255)).join(',')}); border: 2px solid rgba(255,255,255,0.55); border-radius: 12px;`;
+ for (const [style, actor] of this._styleButtons) {
+ actor.style = 'min-width: 36px; min-height: 36px; padding: 2px; border-radius: 10px;' +
+ (style === this._config.pointer.style ? 'background-color: rgba(185,153,255,0.24); border: 1px solid #c4a7ff;' : 'border: 1px solid transparent;');
+ }
+ if (!this._toolbar.visible) {
+ this._inkTray.hide();
+ this._hint.hide();
+ }
+ }
+
+ _schedule(ms) {
+ const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ms, () => {
+ this._sources.delete(id);
+ if (!this._running) return GLib.SOURCE_REMOVE;
+ let delay = 48;
+ try {
+ delay = this._tick();
+ } catch (error) {
+ console.warn(`TinyHand: update failed: ${error}`);
+ }
+ if (this._running) this._schedule(delay);
+ return GLib.SOURCE_REMOVE;
+ });
+ this._sources.add(id);
+ }
+
+ _tick() {
+ const [x, y, mods] = global.get_pointer();
+ const held = (mods & 0x100) !== 0;
+ const down = held && !this._held;
+ const moved = !this._last || x !== this._last[0] || y !== this._last[1];
+ this._held = held;
+ this._last = [x, y];
+ if (!this._config.enabled) {
+ this._finishStroke();
+ this._area.reactive = false;
+ this._hideSystemCursor(false);
+ return 48;
+ }
+ const overToolbar = this._overControls(x, y);
+ const window = global.display.focus_window;
+ const appId = window?.get_gtk_application_id?.() ?? window?.get_wm_class?.() ?? '';
+ const settingsFocused = appId.toLowerCase().includes('tinyhand') ||
+ (appId === 'org.gnome.Shell.Extensions' && window?.get_title?.() === 'TinyHand Settings');
+ const interacting = (mods & 0x8) !== 0 || settingsFocused || Main.overview.visible;
+ const drawing = this._config.interaction.mode === 'draw' && !interacting;
+ // Only Draw mode becomes an input surface; Point mode remains click-through.
+ this._area.reactive = drawing;
+ this._hideSystemCursor(this._config.interaction.hideCursor && !interacting && !overToolbar);
+ if (down && !overToolbar) {
+ this._press = 1;
+ this._tap();
+ } else if (!held) {
+ this._press *= 0.82;
+ if (this._press < 0.002) this._press = 0;
+ }
+ const target = clamp(x + 0.30 * global.stage.width, 0.15 * global.stage.width, 1.05 * global.stage.width);
+ this._gripX = this._gripX === null ? target : this._gripX + (target - this._gripX) * 0.08;
+ const settling = Math.abs(target - this._gripX) > 0.5;
+ if (!settling) this._gripX = target;
+ // Toolbar clicks must not leave ink or trigger an underlying application.
+ if (held && drawing && !overToolbar) {
+ if (!this._active) {
+ const choice = this._config.ink.choice;
+ const color = COLORS[choice === 'match' ? STYLE_INK[this._config.pointer.style] : choice] ?? COLORS.purple;
+ this._active = {points: [[x, y]], color, start: now()};
+ this._strokes.push(this._active);
+ } else if (moved) {
+ this._active.points.push([x, y]);
+ }
+ this._active.start = now();
+ } else {
+ this._finishStroke();
+ }
+ const time = now();
+ const previousCount = this._strokes.length;
+ this._strokes = this._strokes.filter(stroke => stroke === this._active || this._alpha(stroke, time) > 0);
+ const fading = this._config.ink.fade > 0 && this._strokes.length > 0;
+ const busy = moved || held || this._press > 0 || settling || fading;
+ if (busy || this._dirty || previousCount !== this._strokes.length) {
+ this._area.queue_repaint();
+ this._dirty = false;
+ }
+ return busy ? 16 : 48;
+ }
+
+ _finishStroke() {
+ if (this._active) this._active.start = now();
+ this._active = null;
+ }
+
+ _alpha(stroke, time) {
+ const fade = this._config.ink.fade;
+ return stroke === this._active || fade <= 0 ? 1 : clamp(1 - (time - stroke.start - fade) / 0.6, 0, 1);
+ }
+
+ _tap() {
+ const laser = this._config.pointer.style === 'laser';
+ if (laser ? !this._config.sound.laser : !this._config.sound.hand) return;
+ const tap = laser ? 'boop' : this._config.sound.tap;
+ if (!this._assetDir || typeof tap !== 'string' || !/^[a-z0-9_-]+$/.test(tap)) return;
+ const wav = GLib.build_filenamev([this._assetDir, 'sounds', `tap_${tap}.wav`]);
+ if (!GLib.file_test(wav, GLib.FileTest.IS_REGULAR)) return;
+ try {
+ global.display.get_sound_player().play_from_file(Gio.File.new_for_path(wav),
+ 'TinyHand tap', this._soundCancellable);
+ } catch (_) {
+ // Sound is optional if the compositor has no audio backend.
+ }
+ }
+
+ _measure(pixbuf) {
+ const w = pixbuf.width, h = pixbuf.height;
+ const stride = pixbuf.rowstride, channels = pixbuf.n_channels;
+ const pixels = pixbuf.get_pixels();
+ const rows = [];
+ for (let y = 0; y < h; y++) {
+ let first = -1, last = -1, count = 0;
+ for (let x = 0; x < w; x++) {
+ if (!pixbuf.has_alpha || pixels[y * stride + x * channels + channels - 1] > 128) {
+ if (first < 0) first = x;
+ last = x;
+ count++;
+ }
+ }
+ rows.push({first, last, count});
+ }
+ const top = rows.findIndex(row => row.count > 0);
+ if (top < 0) throw new Error('Empty hand image');
+ let maxWidth = 0;
+ for (let y = top; y < Math.min(h, top + 0.45 * h); y++)
+ maxWidth = Math.max(maxWidth, rows[y].count);
+ const fist = rows.findIndex((row, y) => y >= top && row.count > 0.6 * maxWidth);
+ const wrist = rows.findIndex((row, y) => y > fist && row.count < 0.35 * maxWidth);
+ if (wrist <= top || wrist >= h - 1) throw new Error('Invalid hand wrist');
+ const rodWidth = rows[Math.min(wrist + Math.floor(h / 50), h - 1)].count;
+ let rodEnd = rows.findIndex((row, y) => y >= wrist + Math.floor(h / 33) && row.count > 1.8 * rodWidth);
+ if (rodEnd < 0) {
+ rodEnd = h - 1;
+ while (rodEnd > wrist && rows[rodEnd].count === 0) rodEnd--;
+ }
+ if (rodEnd <= wrist || rodWidth === 0) throw new Error('Invalid hand rod');
+ const populatedRow = index => {
+ if (rows[index].count > 0) return rows[index];
+ for (let distance = 1; distance < h; distance++) {
+ for (const y of [index - distance, index + distance]) {
+ if (y >= top && y < h && rows[y].count > 0) return rows[y];
+ }
+ }
+ return rows[top];
+ };
+ const tipRow = populatedRow(Math.min(top + Math.floor(h / 200), h - 1));
+ const rodRow = populatedRow(Math.floor((wrist + rodEnd) / 2));
+ let minX = w, maxX = 0;
+ for (let y = top; y < wrist; y++) {
+ if (!rows[y].count) continue;
+ minX = Math.min(minX, rows[y].first);
+ maxX = Math.max(maxX, rows[y].last);
+ }
+ return {aspect: w / h, handTop: Math.max(0, top - 0.004 * h) / h,
+ tip: top / h, wrist: wrist / h, rodEnd: rodEnd / h,
+ tipX: (tipRow.first + tipRow.last) / 2 / w, rodX: (rodRow.first + rodRow.last) / 2 / w,
+ handMinX: minX / w, handMaxX: maxX / w};
+ }
+
+ _validGeometry(g) {
+ return object(g) && ['aspect', 'handTop', 'tip', 'wrist', 'rodEnd', 'tipX', 'rodX', 'handMinX', 'handMaxX']
+ .every(key => Number.isFinite(g[key])) && g.aspect > 0 && g.handTop >= 0 &&
+ g.handTop <= g.tip && g.tip < g.wrist && g.wrist < g.rodEnd && g.rodEnd <= 1 &&
+ g.tipX >= 0 && g.tipX <= 1 && g.rodX >= 0 && g.rodX <= 1;
+ }
+
+ _asset(style) {
+ if (this._assets.has(style)) return this._assets.get(style);
+ let asset = null;
+ try {
+ if (!this._assetDir) return null;
+ const path = GLib.build_filenamev([this._assetDir, `hand_${style}.png`]);
+ const pixbuf = GdkPixbuf.Pixbuf.new_from_file(path);
+ let g = this._geometry[style];
+ if (!this._validGeometry(g)) {
+ g = this._measure(pixbuf);
+ this._geometry[style] = g;
+ try { writeJson(this._geometryPath, this._geometry); } catch (_) { /* Cache is optional. */ }
+ }
+ const top = clamp(Math.floor(g.handTop * pixbuf.height), 0, pixbuf.height - 2);
+ const wrist = clamp(Math.floor(g.wrist * pixbuf.height), top + 1, pixbuf.height - 1);
+ const end = clamp(Math.floor(g.rodEnd * pixbuf.height), wrist + 1, pixbuf.height);
+ // Cairo loads PNGs directly. Never import Gdk/Gtk/Adw into Shell.
+ const source = cairo.ImageSurface.createFromPNG(path);
+ const surface = (y, height, shadow = false) => {
+ const image = new cairo.ImageSurface(cairo.Format.ARGB32, pixbuf.width, height);
+ const cr = new cairo.Context(image);
+ if (shadow) {
+ cr.setSourceRGBA(0, 0, 0, 1);
+ cr.maskSurface(source, 0, -y);
+ } else {
+ cr.setSourceSurface(source, 0, -y);
+ cr.paint();
+ }
+ cr.$dispose();
+ return image;
+ };
+ const slice = (image, y, height) => image.new_subpixbuf(0, y, image.width, height);
+ asset = {g, width: pixbuf.width, hand: slice(pixbuf, top, wrist - top),
+ surfaceHand: surface(top, wrist - top), surfaceRod: surface(wrist, end - wrist),
+ shadowHand: surface(top, wrist - top, true), shadowRod: surface(wrist, end - wrist, true)};
+ } catch (error) {
+ console.warn(`TinyHand: cannot load hand ${style}: ${error}`);
+ }
+ this._assets.set(style, asset);
+ return asset;
+ }
+
+ _paint() {
+ const cr = this._area.get_context();
+ try {
+ cr.setOperator(cairo.Operator.SOURCE);
+ cr.setSourceRGBA(0, 0, 0, 0);
+ cr.paint();
+ cr.setOperator(cairo.Operator.OVER);
+ if (!this._config.enabled || !this._last) return;
+ this._paintInk(cr);
+ if (this._config.pointer.style === 'laser') this._paintLaser(cr);
+ else this._paintHand(cr);
+ } catch (error) {
+ console.warn(`TinyHand: paint failed: ${error}`);
+ } finally {
+ cr.$dispose();
+ }
+ }
+
+ _paintInk(cr) {
+ const time = now();
+ const width = Math.max(3, global.stage.height * 0.0055);
+ cr.setLineWidth(width);
+ cr.setLineCap(cairo.LineCap.ROUND);
+ cr.setLineJoin(cairo.LineJoin.ROUND);
+ for (const stroke of this._strokes) {
+ const points = stroke.points;
+ if (!points.length) continue;
+ cr.setSourceRGBA(...stroke.color, this._alpha(stroke, time));
+ if (points.length === 1) {
+ cr.arc(points[0][0], points[0][1], width / 2, 0, Math.PI * 2);
+ cr.fill();
+ continue;
+ }
+ let current = points[0];
+ cr.moveTo(...current);
+ for (let i = 1; i < points.length; i++) {
+ const control = points[i - 1];
+ const end = [(control[0] + points[i][0]) / 2, (control[1] + points[i][1]) / 2];
+ // Cairo has cubic curves only: exactly convert the specified quadratic.
+ cr.curveTo(current[0] + 2 / 3 * (control[0] - current[0]),
+ current[1] + 2 / 3 * (control[1] - current[1]),
+ end[0] + 2 / 3 * (control[0] - end[0]), end[1] + 2 / 3 * (control[1] - end[1]), ...end);
+ current = end;
+ }
+ cr.lineTo(...points[points.length - 1]);
+ cr.stroke();
+ }
+ }
+
+ _paintLaser(cr) {
+ const [x, y] = this._last;
+ const r = Math.max(4, global.stage.height * 0.0038 * this._config.pointer.size / 0.13) * (1 + 0.2 * this._press);
+ const glow = new cairo.RadialGradient(x, y, 0, x, y, r * 3.2);
+ glow.addColorStopRGBA(0, 1, 40 / 255, 30 / 255, 0.43);
+ glow.addColorStopRGBA(1, 1, 40 / 255, 30 / 255, 0);
+ cr.setSource(glow);
+ cr.arc(x, y, r * 3.2, 0, Math.PI * 2);
+ cr.fill();
+ cr.setSourceRGBA(1, 40 / 255, 30 / 255, 1);
+ cr.setLineWidth(Math.max(1, r * 0.12));
+ cr.arc(x, y, r, 0, Math.PI * 2);
+ cr.stroke();
+ cr.setSourceRGBA(1, 219 / 255, 209 / 255, 0.95);
+ cr.arc(x, y, r * 0.45, 0, Math.PI * 2);
+ cr.fill();
+ }
+
+ _paintHand(cr) {
+ const asset = this._asset(this._config.pointer.style);
+ if (!asset || this._gripX === null) return;
+ const g = asset.g;
+ const H = global.stage.height;
+ const handPts = this._config.pointer.size * H * (1 - 0.06 * this._press);
+ const s = handPts / (g.wrist - g.handTop);
+ const widthPts = s * g.aspect;
+ const handTipPts = (g.wrist - g.tip) * s;
+ const tipOffX = (g.tipX - g.rodX) * widthPts;
+ const gx = this._gripX, gy = 1.12 * H;
+ const dx = this._last[0] - gx, dy = this._last[1] - gy;
+ const reach = Math.hypot(dx, dy);
+ const along = Math.sqrt(Math.max(0, reach * reach - tipOffX * tipOffX));
+ // Preserve the native minimum length. If this clamp binds (e.g. the
+ // bottom-right corner), exact fingertip coincidence is geometrically
+ // impossible without changing the specified grip, hand size or minimum.
+ const rodLen = Math.max(0.05 * H, along - handTipPts);
+ const rotation = Math.atan2(dy, dx) - Math.atan2(-(rodLen + handTipPts), tipOffX);
+ const draw = (hand, rod, alpha, offsetX = 0, offsetY = 0) => {
+ cr.save();
+ cr.translate(gx, gy);
+ cr.translate(offsetX, offsetY);
+ cr.rotate(rotation);
+ for (const [image, y, height] of [[rod, -rodLen, rodLen], [hand, -rodLen - handPts, handPts]]) {
+ cr.save();
+ cr.translate(-g.rodX * widthPts, y);
+ cr.scale(widthPts / asset.width, height / image.getHeight());
+ cr.setSourceSurface(image, 0, 0);
+ cr.paintWithAlpha(alpha);
+ cr.restore();
+ }
+ cr.restore();
+ };
+ if (this._config.pointer.shadow) {
+ const lift = 0.06 * H * (1 - 0.8 * this._press);
+ const angle = -rotation;
+ const sx = lift * 0.6 * Math.cos(angle) - lift * Math.sin(angle);
+ const sy = lift * 0.6 * Math.sin(angle) + lift * Math.cos(angle);
+ draw(asset.shadowHand, asset.shadowRod, 0.26 + 0.02 * this._press, sx, sy);
+ }
+ draw(asset.surfaceHand, asset.surfaceRod, 1);
+ }
+
+ disable() {
+ this._running = false;
+ this._hideSystemCursor(false);
+ this._cursorTracker = null;
+ this._cursorWasVisible = null;
+ this._soundCancellable?.cancel();
+ this._soundCancellable = null;
+ for (const id of this._sources ?? []) GLib.source_remove(id);
+ this._sources?.clear();
+ for (const [object_, id] of this._signals ?? []) object_.disconnect(id);
+ this._signals = null;
+ this._monitor?.cancel();
+ this._monitor = null;
+ for (const name of this._bound ?? []) Main.wm.removeKeybinding(name);
+ this._area?.destroy();
+ this._toolbar?.destroy();
+ this._inkTray?.destroy();
+ this._hint?.destroy();
+ this._assets?.clear();
+ this._area = null;
+ this._toolbar = null;
+ this._soundIcon = null;
+ this._modeIcon = null;
+ this._modeButton = null;
+ this._inkDot = null;
+ this._inkTray = null;
+ this._hint = null;
+ this._styleButtons = null;
+ this._settings = null;
+ this._bindingSettings = null;
+ this._assets = null;
+ this._geometry = null;
+ this._strokes = null;
+ this._active = null;
+ this._last = null;
+ this._config = null;
+ this._sources = null;
+ this._bound = null;
+ this._assetDir = null;
+ }
+}
diff --git a/linux/companion/tinyhand@abdulrehmankhan56/metadata.json b/linux/companion/tinyhand@abdulrehmankhan56/metadata.json
new file mode 100644
index 0000000..d403471
--- /dev/null
+++ b/linux/companion/tinyhand@abdulrehmankhan56/metadata.json
@@ -0,0 +1,10 @@
+{
+ "uuid": "tinyhand@abdulrehmankhan56",
+ "name": "TinyHand for GNOME",
+ "description": "A tiny hand on a stick that follows your cursor, with tap sounds, fading ink and visual settings. Point normally or capture drags in Draw mode; hold Alt to interact with apps.\n\nTargets GNOME 45–50; 45–49 are experimental (API/mocked checks only). GNOME 50.1 is the only desktop-tested version. Settings require GTK 4.8+ and libadwaita 1.4+.\n\nLinux port of TinyHand by Kamran Ahmed (@nilbuild): https://github.com/nilbuild/tinyhand. Original artwork, sounds and concept are credited to the upstream project. Linux port maintained by AbdulRehman56.",
+ "url": "https://github.com/AbdulRehman56/tinyhand",
+ "shell-version": ["45", "46", "47", "48", "49", "50"],
+ "settings-schema": "org.gnome.shell.extensions.tinyhand",
+ "version": 3,
+ "version-name": "1.0.2-linux.1"
+}
diff --git a/linux/companion/tinyhand@abdulrehmankhan56/preferences.js b/linux/companion/tinyhand@abdulrehmankhan56/preferences.js
new file mode 100644
index 0000000..e412235
--- /dev/null
+++ b/linux/companion/tinyhand@abdulrehmankhan56/preferences.js
@@ -0,0 +1,433 @@
+import Gtk from 'gi://Gtk?version=4.0';
+import Gdk from 'gi://Gdk?version=4.0';
+import Adw from 'gi://Adw?version=1';
+import GdkPixbuf from 'gi://GdkPixbuf';
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+
+const DEFAULTS = {
+ enabled: true,
+ pointer: {style: 'lavender', size: 0.13, shadow: true},
+ ink: {choice: 'match', fade: 3.0},
+ sound: {hand: true, laser: false, tap: 'thock'},
+ interaction: {mode: 'point', hideCursor: true},
+};
+const STYLES = [
+ 'lavender', 'glove', 'coral', 'plush', 'robot', 'pink', 'blue', 'mint',
+ 'lemon', 'black', 'chrome', 'holo', 'fuzzy', 'wood', 'marble', 'terrazzo',
+ 'glass', 'jelly', 'pastel', 'pixel', 'lowpoly', 'knit', 'skeleton', 'astro',
+ 'space', 'monster', 'water', 'lava', 'grass', 'cactus', 'choco', 'cloud',
+ 'mech', 'neon', 'leaf', 'cookie', 'laser',
+];
+const COLORS = ['match', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'white', 'black'];
+const TAPS = ['thock', 'click', 'pop', 'boop', 'squeak', 'wood'];
+const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+const title = value => ({lowpoly: 'Low Poly', choco: 'Chocolate', holo: 'Holographic',
+ astro: 'Astronaut', mech: 'Mechanical', match: 'Match pointer'}[value] ??
+ value.charAt(0).toUpperCase() + value.slice(1));
+
+function merge(base, patch) {
+ const result = Object.create(null);
+ for (const source of [base, patch]) {
+ if (!isObject(source)) continue;
+ for (const [key, value] of Object.entries(source)) {
+ if (['__proto__', 'constructor', 'prototype'].includes(key)) continue;
+ result[key] = isObject(value)
+ ? merge(isObject(result[key]) ? result[key] : {}, value) : value;
+ }
+ }
+ return result;
+}
+
+function findAssets(assetDir) {
+ const bundled = Gio.File.new_for_uri(import.meta.url).get_parent().get_child('assets').get_path();
+ const candidates = assetDir ? [assetDir, bundled] : [bundled];
+ candidates.push(GLib.build_filenamev([GLib.get_user_data_dir(), 'tinyhand', 'assets']));
+ let dir = Gio.File.new_for_uri(import.meta.url).get_parent();
+ while (dir) {
+ candidates.push(dir.get_child('assets').get_path());
+ dir = dir.get_parent();
+ }
+ return candidates.find(path => GLib.file_test(path, GLib.FileTest.IS_DIR)) ?? null;
+}
+
+// Measure only the upper hand, ending where the broad wrist narrows to the rod.
+// Downsampling before scanning keeps all 36 previews cheap to construct.
+function handTexture(path) {
+ const image = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 256, 384, true);
+ const pixels = image.get_pixels();
+ const rows = [];
+ for (let y = 0; y < image.height; y++) {
+ let left = image.width, right = -1, count = 0;
+ for (let x = 0; x < image.width; x++) {
+ if (!image.has_alpha || pixels[y * image.rowstride + x * image.n_channels + image.n_channels - 1] > 96) {
+ left = Math.min(left, x);
+ right = x;
+ count++;
+ }
+ }
+ rows.push({left, right, count});
+ }
+ const top = rows.findIndex(row => row.count > 0);
+ if (top < 0) throw new Error('Empty preview image');
+ const maxWidth = Math.max(...rows.slice(top, Math.floor(image.height * 0.5)).map(row => row.count));
+ const fist = rows.findIndex((row, y) => y >= top && row.count > maxWidth * 0.6);
+ let bottom = rows.findIndex((row, y) => y > fist && row.count < maxWidth * 0.35);
+ if (bottom <= top) bottom = Math.floor(image.height * 0.48);
+ const hand = rows.slice(top, bottom);
+ const left = Math.max(0, Math.min(...hand.filter(row => row.count).map(row => row.left)) - 3);
+ const right = Math.min(image.width, Math.max(...hand.map(row => row.right)) + 4);
+ const y = Math.max(0, top - 3);
+ const crop = image.new_subpixbuf(left, y, right - left, Math.min(image.height, bottom + 2) - y);
+ return Gdk.Texture.new_for_pixbuf(crop);
+}
+
+/** Fill an Adw.PreferencesWindow; return an idempotent cleanup function.
+ * @param {Adw.PreferencesWindow} window
+ * @param {string|null} assetDir optional assets directory
+ */
+export function buildPreferences(window, assetDir = null) {
+ if (Gtk.check_version(4, 8, 0) || typeof Adw.SwitchRow !== 'function')
+ throw new Error('TinyHand Settings requires GTK 4.8 or later and libadwaita 1.4 or later.');
+ const assets = findAssets(assetDir);
+ const directory = GLib.build_filenamev([GLib.get_user_config_dir(), 'tinyhand']);
+ const file = Gio.File.new_for_path(GLib.build_filenamev([directory, 'config.json']));
+ let syncing = false;
+ let closed = false;
+ let monitor = null;
+ let reloadSource = 0;
+ let config = merge(DEFAULTS, {});
+ const bindings = [];
+ const errors = [];
+ const signals = [];
+ const connect = (object, name, callback) => {
+ const id = object.connect(name, callback);
+ signals.push([object, id]);
+ return id;
+ };
+ window.set_title('TinyHand Settings');
+ window.set_default_size(920, 680);
+ // Keep the native preferences host, but mirror TinyHand's compact sidebar
+ // and card-based settings rather than a long, tabbed GNOME form.
+ const host = new Adw.PreferencesPage({title: 'TinyHand'});
+ const workspaceGroup = new Adw.PreferencesGroup();
+ const workspace = new Gtk.Box({spacing: 16, height_request: 510, width_request: 720,
+ hexpand: true, vexpand: true});
+ const stack = new Gtk.Stack({hexpand: true, vexpand: true,
+ transition_type: Gtk.StackTransitionType.CROSSFADE});
+ const sidebar = new Gtk.StackSidebar({stack, width_request: 155, valign: Gtk.Align.FILL,
+ vexpand: true});
+ sidebar.add_css_class('navigation-sidebar');
+ const rail = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL, spacing: 12, vexpand: true});
+ rail.append(sidebar);
+ const brand = new Gtk.Label({label: 'TinyHand', xalign: 0, margin_start: 12,
+ margin_bottom: 12, valign: Gtk.Align.END});
+ brand.add_css_class('heading');
+ rail.append(brand);
+ workspace.append(rail);
+ workspace.append(new Gtk.Separator({orientation: Gtk.Orientation.VERTICAL}));
+ workspace.append(stack);
+ workspaceGroup.add(workspace);
+ host.add(workspaceGroup);
+ window.add(host);
+
+ function showError(message) {
+ for (const row of errors) {
+ row.subtitle = message ?? '';
+ row.visible = Boolean(message);
+ }
+ }
+
+ function read() {
+ try {
+ const [, bytes, etag] = file.load_contents(null);
+ const value = JSON.parse(new TextDecoder().decode(bytes));
+ if (!isObject(value)) throw new Error('The configuration must contain a JSON object.');
+ return {value, etag};
+ } catch (error) {
+ if (error.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
+ return {value: {}, etag: null};
+ throw error;
+ }
+ }
+
+ function refresh() {
+ if (closed) return;
+ try {
+ config = merge(DEFAULTS, read().value);
+ syncing = true;
+ for (const update of bindings) update(config);
+ showError(null);
+ } catch (error) {
+ showError(`Cannot read config.json: ${error.message}. The file has not been changed.`);
+ } finally {
+ syncing = false;
+ }
+ }
+
+ function save(patch) {
+ if (syncing || closed) return;
+ try {
+ // Read on EVERY interaction, preserving unknown keys and other clients' edits.
+ // An etag conflict refuses the write instead of overwriting a concurrent edit.
+ const {value, etag} = read();
+ if (GLib.mkdir_with_parents(directory, 0o700) !== 0)
+ throw new Error('Cannot create the configuration directory');
+ file.replace_contents(JSON.stringify(merge(value, patch), null, 2) + '\n',
+ etag, false, Gio.FileCreateFlags.PRIVATE, null);
+ refresh();
+ } catch (error) {
+ // Revert the unsaved control where possible without hiding the error.
+ syncing = true;
+ try { for (const update of bindings) update(config); }
+ finally { syncing = false; }
+ showError(`Could not save: ${error.message}. Existing configuration was not overwritten.`);
+ }
+ }
+
+ function page(name, icon) {
+ const result = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL, spacing: 20,
+ margin_end: 8, margin_bottom: 16});
+ const heading = new Gtk.Label({label: name, xalign: 0, margin_top: 4});
+ heading.add_css_class('title-1');
+ result.append(heading);
+ const scroller = new Gtk.ScrolledWindow({child: result, hexpand: true,
+ hscrollbar_policy: Gtk.PolicyType.NEVER, vscrollbar_policy: Gtk.PolicyType.AUTOMATIC});
+ const stackPage = stack.add_titled(scroller, name.toLowerCase(), name);
+ stackPage.icon_name = icon;
+ const status = new Adw.PreferencesGroup();
+ const error = new Adw.ActionRow({title: 'Configuration needs attention', visible: false,
+ subtitle_lines: 0});
+ error.add_css_class('error');
+ status.add(error);
+ result.append(status);
+ errors.push(error);
+ return result;
+ }
+ function group(parent, name, description) {
+ const result = new Adw.PreferencesGroup({title: name, description});
+ parent.append(result);
+ return result;
+ }
+ function switchRow(parent, name, subtitle, section, key, fallback) {
+ const row = new Adw.SwitchRow({title: name, subtitle});
+ parent.add(row);
+ bindings.push(value => {
+ const current = section ? value[section]?.[key] : value[key];
+ row.active = typeof current === 'boolean' ? current : fallback;
+ });
+ connect(row, 'notify::active', () => save(section ? {[section]: {[key]: row.active}} : {[key]: row.active}));
+ return row;
+ }
+ function combo(parent, name, subtitle, values, section, key, fallback, labels = null) {
+ const row = new Adw.ComboRow({title: name, subtitle,
+ model: Gtk.StringList.new(labels ?? values.map(title))});
+ parent.add(row);
+ bindings.push(value => {
+ const index = values.indexOf(value[section]?.[key]);
+ row.selected = index < 0 ? values.indexOf(fallback) : index;
+ });
+ connect(row, 'notify::selected', () => {
+ if (values[row.selected]) save({[section]: {[key]: values[row.selected]}});
+ });
+ return row;
+ }
+ function slider(parent, name, subtitle, section, key, low, high, step, fallback, format) {
+ const row = new Adw.ActionRow({title: name, subtitle});
+ const scale = new Gtk.Scale({orientation: Gtk.Orientation.HORIZONTAL,
+ adjustment: new Gtk.Adjustment({lower: low, upper: high, step_increment: step,
+ page_increment: step * 5}), draw_value: false, hexpand: true,
+ width_request: 160, valign: Gtk.Align.CENTER});
+ const label = new Gtk.Label({width_chars: 10, xalign: 1, valign: Gtk.Align.CENTER});
+ scale.update_property([Gtk.AccessibleProperty.LABEL], [name]);
+ const box = new Gtk.Box({spacing: 12, valign: Gtk.Align.CENTER});
+ box.append(scale);
+ box.append(label);
+ row.add_suffix(box);
+ parent.add(row);
+ bindings.push(value => {
+ const number = value[section]?.[key];
+ const current = Number.isFinite(number) ? Math.max(low, Math.min(high, number)) : fallback;
+ scale.set_value(current);
+ label.label = format(current);
+ });
+ connect(scale, 'value-changed', () => {
+ const value = Math.round(scale.get_value() / step) * step;
+ label.label = format(value);
+ save({[section]: {[key]: Number(value.toFixed(3))}});
+ });
+ }
+
+ const generalPage = page('General', 'preferences-system-symbolic');
+ const general = group(generalPage, 'TinyHand', 'A tiny companion for your cursor. Changes apply immediately.');
+ switchRow(general, 'Turn the pointer on or off', 'Show the hand and its drawing overlay.', null, 'enabled', true);
+ const interaction = group(generalPage, 'Clicking for real', 'Point normally, or draw without selecting text underneath.');
+ combo(interaction, 'Pointer mode', 'Point: normal clicks, no ink. Draw: capture clicks and draw. Hold Alt to interact normally.',
+ ['point', 'draw'], 'interaction', 'mode', 'point',
+ ['Point', 'Draw']);
+ switchRow(interaction, 'Hide system cursor', 'Show only TinyHand instead of two pointers.',
+ 'interaction', 'hideCursor', true);
+ const pointersPage = page('Pointers', 'input-mouse-symbolic');
+ const gallery = group(pointersPage, 'Choose a pointer', '36 hands and a laser dot. Click a card to use it.');
+ const selection = new Gtk.Label({xalign: 0, margin_bottom: 8});
+ gallery.add(selection);
+ const flow = new Gtk.FlowBox({selection_mode: Gtk.SelectionMode.NONE,
+ min_children_per_line: 3, max_children_per_line: 4, row_spacing: 8, column_spacing: 8,
+ homogeneous: true, valign: Gtk.Align.START});
+ // One scroll surface per section: nesting another scroller clips the gallery
+ // and makes wheel/keyboard navigation unpredictable.
+ gallery.add(flow);
+ const tiles = new Map();
+ for (const style of STYLES) {
+ const box = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL, spacing: 6,
+ margin_top: 8, margin_bottom: 8, margin_start: 4, margin_end: 4});
+ let preview;
+ try {
+ if (style === 'laser') {
+ // A native vector-like dot, not a hand image or emoji.
+ preview = new Gtk.DrawingArea({height_request: 64, width_request: 60});
+ preview.set_draw_func((_area, cr, width, height) => {
+ cr.setSourceRGBA(1, 0.12, 0.12, 0.16);
+ cr.arc(width / 2, height / 2, 19, 0, Math.PI * 2);
+ cr.fill();
+ cr.setSourceRGBA(0.95, 0.08, 0.08, 1);
+ cr.arc(width / 2, height / 2, 7, 0, Math.PI * 2);
+ cr.fill();
+ });
+ } else {
+ if (!assets) throw new Error('Assets not found');
+ preview = new Gtk.Picture({paintable: handTexture(GLib.build_filenamev([assets, `hand_${style}.png`])),
+ height_request: 64, width_request: 60, can_shrink: true,
+ content_fit: Gtk.ContentFit.CONTAIN});
+ }
+ } catch (_) {
+ preview = new Gtk.Image({icon_name: 'image-missing-symbolic', pixel_size: 48, height_request: 64});
+ }
+ box.append(preview);
+ box.append(new Gtk.Label({label: title(style), ellipsize: 3}));
+ const tile = new Gtk.ToggleButton({child: box, tooltip_text: title(style)});
+ tile.update_property([Gtk.AccessibleProperty.LABEL], [title(style)]);
+ connect(tile, 'clicked', () => save({pointer: {style}}));
+ flow.insert(tile, -1);
+ tiles.set(style, tile);
+ }
+ bindings.push(value => {
+ const style = value.pointer?.style ?? DEFAULTS.pointer.style;
+ selection.label = `Selected: ${STYLES.includes(style) ? title(style) : style}`;
+ for (const [name, tile] of tiles) {
+ tile.active = name === style;
+ if (tile.active) tile.add_css_class('suggested-action');
+ else tile.remove_css_class('suggested-action');
+ }
+ });
+ if (!assets) gallery.description = 'Assets were not found. Install the TinyHand assets to see hand previews.';
+
+ const appearance = page('Appearance', 'applications-graphics-symbolic');
+ const pointer = group(appearance, 'Pointer', 'Make your companion feel at home.');
+ slider(pointer, 'Size', 'Relative to the screen height.', 'pointer', 'size', 0.08, 0.20, 0.01, 0.13,
+ value => `${Math.round(value * 100)}%`);
+ switchRow(pointer, 'Shadow under the pointer', 'Add depth beneath the hand.', 'pointer', 'shadow', true);
+
+ const drawing = page('Drawing', 'document-edit-symbolic');
+ const ink = group(drawing, 'Ink', 'Customize the marks your pointer leaves behind.');
+ combo(ink, 'Ink colour', 'Matches each pointer, or uses your chosen colour.', COLORS, 'ink', 'choice', 'match');
+ slider(ink, 'Lines fade', 'Set to 0 to keep until cleared.', 'ink', 'fade', 0, 30, 1, 3,
+ value => value === 0 ? 'Permanent' : `${Math.round(value)} s`);
+
+ const soundPage = page('Sound', 'audio-speakers-symbolic');
+ const soundCards = group(soundPage, 'Tap sound', 'Click a sound to hear it. It plays each time the hand taps.');
+ const soundFlow = new Gtk.FlowBox({selection_mode: Gtk.SelectionMode.NONE,
+ min_children_per_line: 2, max_children_per_line: 3, row_spacing: 8, column_spacing: 8,
+ homogeneous: true});
+ soundCards.add(soundFlow);
+ const soundTiles = new Map();
+ for (const choice of ['off', ...TAPS]) {
+ const box = new Gtk.Box({orientation: Gtk.Orientation.VERTICAL, spacing: 8,
+ margin_top: 12, margin_bottom: 12, margin_start: 8, margin_end: 8});
+ box.append(new Gtk.Image({icon_name: choice === 'off' ? 'audio-volume-muted-symbolic' : 'audio-x-generic-symbolic', pixel_size: 22}));
+ box.append(new Gtk.Label({label: title(choice)}));
+ const tile = new Gtk.ToggleButton({child: box, tooltip_text: title(choice)});
+ tile.update_property([Gtk.AccessibleProperty.LABEL], [choice === 'off' ? 'Tap sound off' : `Preview ${title(choice)}`]);
+ connect(tile, 'clicked', () => {
+ if (choice === 'off') save({sound: {hand: false}});
+ else {
+ save({sound: {hand: true, tap: choice}});
+ playSound(choice);
+ }
+ });
+ soundFlow.insert(tile, -1);
+ soundTiles.set(choice, tile);
+ }
+ bindings.push(value => {
+ const selected = value.sound?.hand === false ? 'off' : value.sound?.tap ?? 'thock';
+ for (const [choice, tile] of soundTiles) {
+ tile.active = choice === selected;
+ if (tile.active) tile.add_css_class('suggested-action');
+ else tile.remove_css_class('suggested-action');
+ }
+ });
+ const sounds = group(soundPage, 'Options', 'Choose when TinyHand plays a sound.');
+ switchRow(sounds, 'Hand sounds', 'Play a tap when using a hand pointer.', 'sound', 'hand', true);
+ switchRow(sounds, 'Laser dot taps too', 'Off by default, for quiet pointing.', 'sound', 'laser', false);
+ const tap = combo(sounds, 'Tap sound', 'Preview any sound before choosing.', TAPS, 'sound', 'tap', 'thock');
+ const testRow = new Adw.ActionRow({title: 'Preview sound', subtitle: 'Plays even when tap feedback is disabled.'});
+ const test = new Gtk.Button({label: 'Test Sound', valign: Gtk.Align.CENTER});
+ testRow.add_suffix(test);
+ testRow.activatable_widget = test;
+ sounds.add(testRow);
+ function playSound(choice) {
+ try {
+ if (!assets) throw new Error('Sound assets were not found');
+ const path = GLib.build_filenamev([assets, 'sounds', `tap_${choice}.wav`]);
+ const process = Gio.Subprocess.new(['paplay', path], Gio.SubprocessFlags.STDERR_PIPE);
+ process.communicate_utf8_async(null, null, (source, result) => {
+ try {
+ const [, , stderr] = source.communicate_utf8_finish(result);
+ if (!closed && !source.get_successful()) showError(`Sound preview failed: ${stderr.trim()}`);
+ } catch (error) {
+ if (!closed) showError(`Sound preview failed: ${error.message}`);
+ }
+ });
+ } catch (error) { showError(`Sound preview failed: ${error.message}`); }
+ }
+ connect(test, 'clicked', () => playSound(TAPS[tap.selected] ?? 'thock'));
+
+ const reset = group(generalPage, 'Defaults', 'Reset the settings shown here. Other configuration is preserved.');
+ const resetRow = new Adw.ActionRow({title: 'Restore default appearance, ink and sound'});
+ const resetButton = new Gtk.Button({label: 'Reset Defaults', valign: Gtk.Align.CENTER});
+ resetRow.add_suffix(resetButton);
+ resetRow.activatable_widget = resetButton;
+ reset.add(resetRow);
+ connect(resetButton, 'clicked', () => save(DEFAULTS));
+ // Open on the visual picker: this is a customization window first.
+ stack.visible_child_name = 'pointers';
+
+ function cleanup() {
+ if (closed) return;
+ closed = true;
+ if (reloadSource) GLib.source_remove(reloadSource);
+ reloadSource = 0;
+ monitor?.cancel();
+ for (const [object, id] of signals) object.disconnect(id);
+ signals.length = 0;
+ }
+ connect(window, 'close-request', () => { cleanup(); return false; });
+ connect(window, 'unrealize', cleanup);
+ refresh();
+ try {
+ if (GLib.mkdir_with_parents(directory, 0o700) !== 0)
+ throw new Error('Cannot create the configuration directory');
+ // Watch the parent: atomic replacements from other clients remain observable.
+ monitor = Gio.File.new_for_path(directory).monitor_directory(Gio.FileMonitorFlags.NONE, null);
+ connect(monitor, 'changed', (_monitor, changed, other) => {
+ if (![changed?.get_basename(), other?.get_basename()].includes('config.json')) return;
+ if (reloadSource) GLib.source_remove(reloadSource);
+ reloadSource = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => {
+ reloadSource = 0;
+ refresh();
+ return GLib.SOURCE_REMOVE;
+ });
+ });
+ } catch (error) { showError(`External changes cannot be monitored: ${error.message}`); }
+ return cleanup;
+}
diff --git a/linux/companion/tinyhand@abdulrehmankhan56/prefs.js b/linux/companion/tinyhand@abdulrehmankhan56/prefs.js
new file mode 100644
index 0000000..7a06290
--- /dev/null
+++ b/linux/companion/tinyhand@abdulrehmankhan56/prefs.js
@@ -0,0 +1,8 @@
+import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+import {buildPreferences} from './preferences.js';
+
+export default class TinyHandPreferences extends ExtensionPreferences {
+ fillPreferencesWindow(window) {
+ buildPreferences(window);
+ }
+}
diff --git a/linux/companion/tinyhand@abdulrehmankhan56/schemas/org.gnome.shell.extensions.tinyhand.gschema.xml b/linux/companion/tinyhand@abdulrehmankhan56/schemas/org.gnome.shell.extensions.tinyhand.gschema.xml
new file mode 100644
index 0000000..2fc7bf5
--- /dev/null
+++ b/linux/companion/tinyhand@abdulrehmankhan56/schemas/org.gnome.shell.extensions.tinyhand.gschema.xml
@@ -0,0 +1,16 @@
+
+
+
+ '<Control><Alt>h'Toggle TinyHand
+ '<Control><Alt>x'Clear ink
+ '<Control><Alt>p'Cycle pointer style
+ trueShow the floating toolbar
+
+
+
+ ['<Control><Alt>h']
+ ['<Control><Alt>x']
+ ['<Control><Alt>p']
+
+
diff --git a/linux/companion/tinyhand@abdulrehmankhan56/settings.js b/linux/companion/tinyhand@abdulrehmankhan56/settings.js
new file mode 100644
index 0000000..93a3c76
--- /dev/null
+++ b/linux/companion/tinyhand@abdulrehmankhan56/settings.js
@@ -0,0 +1,26 @@
+// Standalone entry point, so settings can open without reloading GNOME Shell.
+import Adw from 'gi://Adw?version=1';
+import GLib from 'gi://GLib';
+import {buildPreferences} from './preferences.js';
+
+const app = new Adw.Application({application_id: 'io.github.AbdulRehman56.TinyHand.Settings'});
+app.connect('activate', () => {
+ if (app.active_window) {
+ app.active_window.present();
+ return;
+ }
+ const window = new Adw.PreferencesWindow({application: app, title: 'TinyHand',
+ default_width: 620, default_height: 740, search_enabled: false});
+ buildPreferences(window);
+ if (ARGV.includes('--smoke-test')) {
+ GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
+ print('TinyHand settings window constructed successfully.');
+ window.close();
+ app.quit();
+ return GLib.SOURCE_REMOVE;
+ });
+ } else {
+ window.present();
+ }
+});
+app.run([]);
diff --git a/linux/scripts/install-release.sh b/linux/scripts/install-release.sh
new file mode 100755
index 0000000..f57ec92
--- /dev/null
+++ b/linux/scripts/install-release.sh
@@ -0,0 +1,72 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: ./install.sh [--uninstall]
+Install this extracted TinyHand Linux bundle under ~/.local, without sudo
+or Cargo. Requires an X11 desktop; settings need GJS, GTK 4.8+ and libadwaita 1.4+.
+Tap sounds use paplay. --uninstall removes only release-installed paths,
+leaving your configuration and GNOME Shell extension untouched.
+EOF
+}
+uninstall=false
+for arg in "$@"; do
+ case "$arg" in
+ --uninstall) uninstall=true ;;
+ --help|-h) usage; exit 0 ;;
+ *) usage >&2; exit 1 ;;
+ esac
+done
+BIN="$HOME/.local/bin"
+DATA="$HOME/.local/share/tinyhand"
+DESKTOP="$HOME/.local/share/applications/tinyhand-settings.desktop"
+ICON="$HOME/.local/share/icons/hicolor/256x256/apps/tinyhand.png"
+if $uninstall; then
+ rm -f -- "$BIN/tinyhand" "$BIN/tinyhand-settings" "$DESKTOP" "$ICON"
+ # These subdirectories contain release-owned assets only, not user config.
+ rm -rf -- "$DATA/assets" "$DATA/settings"
+ rmdir -- "$DATA" 2>/dev/null || true
+ printf 'Removed TinyHand release files. Configuration and GNOME extension were kept.\n'
+ exit 0
+fi
+BUNDLE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+for file in bin/tinyhand bin/tinyhand-settings share/tinyhand/settings/settings.js share/tinyhand/settings/preferences.js share/tinyhand/assets/hand_lavender.png; do
+ [[ -f "$BUNDLE/$file" ]] || { printf 'Incomplete bundle: missing %s\n' "$file" >&2; exit 1; }
+done
+mkdir -p "$BIN" "$DATA/assets" "$DATA/settings" "$(dirname -- "$DESKTOP")" "$(dirname -- "$ICON")"
+install -m 755 "$BUNDLE/bin/tinyhand" "$BIN/tinyhand"
+# Installed settings always use the installed data location, not the extraction.
+cat > "$BIN/tinyhand-settings" <<'EOF'
+#!/usr/bin/env sh
+exec gjs -m "$HOME/.local/share/tinyhand/settings/settings.js" "$@"
+EOF
+chmod 755 "$BIN/tinyhand-settings"
+cp -R "$BUNDLE/share/tinyhand/assets/." "$DATA/assets/"
+install -m 644 "$BUNDLE/share/tinyhand/settings/"{preferences,settings}.js "$DATA/settings/"
+install -m 644 "$BUNDLE/share/tinyhand/assets/hand_lavender.png" "$ICON"
+# Desktop Exec quoting also escapes characters special inside double quotes.
+DESKTOP_EXEC=${BIN//\\/\\\\}
+DESKTOP_EXEC=${DESKTOP_EXEC//\"/\\\"}
+DESKTOP_EXEC=${DESKTOP_EXEC//\$/\\\$}
+DESKTOP_EXEC=${DESKTOP_EXEC//\`/\\\`}
+DESKTOP_EXEC=${DESKTOP_EXEC//%/%%}
+cat > "$DESKTOP" </dev/null 2>&1; then
+ update-desktop-database "$HOME/.local/share/applications" >/dev/null 2>&1 || true
+fi
+printf 'Installed TinyHand: %s/tinyhand\nSettings: %s/tinyhand-settings\n' "$BIN" "$BIN"
+printf 'Use X11 for the app; install the separate GNOME extension for Wayland.\n'
+case ":$PATH:" in
+ *":$BIN:"*) ;;
+ *) printf 'Add %s to PATH, or run the full paths above.\n' "$BIN" ;;
+esac
diff --git a/linux/scripts/install.sh b/linux/scripts/install.sh
new file mode 100755
index 0000000..d95fb45
--- /dev/null
+++ b/linux/scripts/install.sh
@@ -0,0 +1,134 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage:
+ install.sh do bin+assets+icon (X11 app)
+ install.sh --extension additionally install GNOME Shell companion
+ install.sh --autostart additionally add X11-only autostart
+ install.sh --uninstall remove installed files (keeps config)
+ install.sh --uninstall --purge also remove config
+EOF
+}
+
+SCRIPT_DIR=$(dirname "$(readlink -f "$0")")
+REPO_ROOT=$(cd "$SCRIPT_DIR/../.." && pwd)
+DATA_DIR="$HOME/.local/share/tinyhand"
+BIN_DIR="$HOME/.local/bin"
+ICON_DIR="$HOME/.local/share/icons/hicolor/256x256/apps"
+EXTENSION_ID=tinyhand@abdulrehmankhan56
+EXTENSION_DIR="$HOME/.local/share/gnome-shell/extensions/$EXTENSION_ID"
+AUTOSTART_FILE="$HOME/.config/autostart/tinyhand.desktop"
+CONFIG_FILE="$HOME/.config/tinyhand/config.json"
+want_extension=false
+want_autostart=false
+uninstall=false
+purge=false
+
+for arg in "$@"; do
+ case "$arg" in
+ --extension) want_extension=true ;;
+ --help|-h) usage; exit 0 ;;
+ --autostart) want_autostart=true ;;
+ --uninstall) uninstall=true ;;
+ --purge) purge=true ;;
+ *) usage >&2; exit 1 ;;
+ esac
+done
+
+if $uninstall; then
+ rm -f "$BIN_DIR/tinyhand" "$BIN_DIR/tinyhand-settings" "$ICON_DIR/tinyhand.png" "$AUTOSTART_FILE" "$HOME/.local/share/applications/tinyhand-settings.desktop"
+ rm -rf "$DATA_DIR" "$EXTENSION_DIR"
+ if $purge; then rm -f "$CONFIG_FILE"; fi
+ printf 'Removed TinyHand binary, assets, icon, autostart, and extension.\n'
+ if ! $purge; then printf 'Kept config: %s\n' "$CONFIG_FILE"; fi
+ exit 0
+fi
+
+mkdir -p "$BIN_DIR"
+if ! command -v cargo >/dev/null 2>&1; then
+ printf 'Warning: cargo not found; skipping binary build. Install Rust and rerun.\n' >&2
+else
+ (cd "$REPO_ROOT/linux" && cargo build --release)
+ cp "$REPO_ROOT/linux/target/release/tinyhand" "$BIN_DIR/tinyhand"
+ printf 'Installed binary: %s\n' "$BIN_DIR/tinyhand"
+fi
+
+mkdir -p "$DATA_DIR/assets/sounds"
+cp "$REPO_ROOT"/assets/hand_*.png "$DATA_DIR/assets/"
+cp "$REPO_ROOT"/assets/sounds/tap_*.wav "$DATA_DIR/assets/sounds/"
+printf 'Installed hand and sound assets: %s\n' "$DATA_DIR/assets"
+
+# Native GTK4/libadwaita settings are usable independently of the Shell overlay.
+SETTINGS_SRC="$REPO_ROOT/linux/companion/$EXTENSION_ID"
+mkdir -p "$DATA_DIR/settings" "$HOME/.local/share/applications"
+cp "$SETTINGS_SRC/preferences.js" "$SETTINGS_SRC/settings.js" "$DATA_DIR/settings/"
+cat > "$BIN_DIR/tinyhand-settings" <<'EOF'
+#!/usr/bin/env sh
+exec gjs -m "$HOME/.local/share/tinyhand/settings/settings.js" "$@"
+EOF
+chmod +x "$BIN_DIR/tinyhand-settings"
+cat > "$HOME/.local/share/applications/tinyhand-settings.desktop" </dev/null 2>&1; then
+ gtk-update-icon-cache -f "$HOME/.local/share/icons/hicolor" >/dev/null 2>&1 || true
+fi
+printf 'Installed tray icon: %s/tinyhand.png\n' "$ICON_DIR"
+
+if $want_extension; then
+ EXTENSION_SRC="$REPO_ROOT/linux/companion/$EXTENSION_ID"
+ if [[ ! -d "$EXTENSION_SRC" ]]; then
+ printf 'GNOME companion source not found: %s\n' "$EXTENSION_SRC" >&2
+ exit 1
+ fi
+ mkdir -p "$(dirname "$EXTENSION_DIR")"
+ mkdir -p "$EXTENSION_DIR"
+ cp -R "$EXTENSION_SRC/." "$EXTENSION_DIR/"
+ glib-compile-schemas --strict "$EXTENSION_DIR/schemas/"
+ printf 'Installed GNOME extension: %s\n' "$EXTENSION_DIR"
+ printf 'Enable via Extensions app or: gnome-extensions enable %s\n' "$EXTENSION_ID"
+fi
+
+if $want_autostart; then
+ mkdir -p "$(dirname "$AUTOSTART_FILE")"
+ cat > "$DATA_DIR/start-x11.sh" <<'EOF'
+#!/usr/bin/env sh
+[ "${XDG_SESSION_TYPE:-}" = x11 ] || exit 0
+exec "$HOME/.local/bin/tinyhand" run
+EOF
+ chmod +x "$DATA_DIR/start-x11.sh"
+ cat > "$AUTOSTART_FILE" <&2; usage >&2; exit 1 ;;
+ esac
+done
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+LINUX_DIR=$(cd -- "$SCRIPT_DIR/.." && pwd)
+REPO_ROOT=$(cd -- "$LINUX_DIR/.." && pwd)
+DIST="$LINUX_DIR/dist"
+EXTENSION_ID=tinyhand@abdulrehmankhan56
+EXTENSION_SRC="$LINUX_DIR/companion/$EXTENSION_ID"
+for dep in python3 glib-compile-schemas; do
+ command -v "$dep" >/dev/null || { printf 'Missing build dependency: %s\n' "$dep" >&2; exit 1; }
+done
+python3 -c 'from PIL import Image' || { printf 'Install Pillow: python3 -m pip install Pillow\n' >&2; exit 1; }
+if ! $extension_only; then
+ [[ $(uname -s) == Linux ]] || { printf 'Build portable bundles on Linux.\n' >&2; exit 1; }
+ command -v cargo >/dev/null || { printf 'Missing build dependency: cargo\n' >&2; exit 1; }
+ cargo build --manifest-path "$LINUX_DIR/Cargo.toml" --locked --release
+fi
+mkdir -p "$DIST"
+STAGE=$(mktemp -d "$DIST/.package.XXXXXX")
+trap 'rm -rf -- "$STAGE"' EXIT
+mkdir -p "$STAGE/extension/schemas"
+for file in metadata.json extension.js prefs.js preferences.js; do
+ cp "$EXTENSION_SRC/$file" "$STAGE/extension/$file"
+done
+cp "$EXTENSION_SRC"/schemas/*.gschema.xml "$STAGE/extension/schemas/"
+glib-compile-schemas --strict "$STAGE/extension/schemas"
+cp "$REPO_ROOT/LICENSE" "$STAGE/extension/LICENSE"
+cat > "$STAGE/extension/CREDITS" <<'EOF'
+TinyHand original app and hand/sound assets: Kamran Ahmed (@nilbuild).
+Upstream: https://github.com/nilbuild/tinyhand
+Website: https://tinyhand.app
+Linux port: AbdulRehman56, https://github.com/AbdulRehman56/tinyhand
+Distributed under the included MIT LICENSE.
+Release PNGs are resized copies of the original artwork, preserving alpha.
+EOF
+python3 - "$REPO_ROOT" "$STAGE/extension" "$DIST/$EXTENSION_ID.shell-extension.zip" <<'PY'
+import os
+import shutil
+import sys
+import time
+import zipfile
+from pathlib import Path
+from PIL import Image
+
+root, stage, output = map(Path, sys.argv[1:])
+hands = sorted((root / 'assets').glob('hand_*.png'))
+sounds = sorted((root / 'assets/sounds').glob('tap_*.wav'))
+if len(hands) != 36 or len(sounds) != 6:
+ raise SystemExit(f'Expected 36 hands and 6 sounds; found {len(hands)} and {len(sounds)}')
+(stage / 'assets/sounds').mkdir(parents=True)
+for source in hands:
+ with Image.open(source) as original:
+ if original.width * 3 != original.height * 2:
+ raise SystemExit(f'Unexpected hand aspect ratio: {source}')
+ image = original.convert('RGBA').resize((384, 576), Image.Resampling.LANCZOS)
+ image.save(stage / 'assets' / source.name, optimize=True, compress_level=9)
+for source in sounds:
+ shutil.copyfile(source, stage / 'assets/sounds' / source.name)
+epoch = int(os.environ.get('SOURCE_DATE_EPOCH', '1704067200'))
+timestamp = time.gmtime(max(315532800, min(epoch, 4354819198)))[:6]
+with zipfile.ZipFile(output, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
+ for path in sorted(stage.rglob('*')):
+ if path.is_file():
+ info = zipfile.ZipInfo(path.relative_to(stage).as_posix(), timestamp)
+ info.compress_type = zipfile.ZIP_DEFLATED
+ info.create_system = 3
+ info.external_attr = 0o100644 << 16
+ archive.writestr(info, path.read_bytes(), compresslevel=9)
+print(f'{output}: {output.stat().st_size:,} bytes ({output.stat().st_size / 1048576:.2f} MiB)')
+if output.stat().st_size >= 10 * 1024 * 1024:
+ raise SystemExit('Extension exceeds the 10 MiB packaging budget')
+PY
+
+if ! $extension_only; then
+ ARCH=$(uname -m)
+ BUNDLE_NAME="tinyhand-linux-$ARCH"
+ BUNDLE="$STAGE/$BUNDLE_NAME"
+ mkdir -p "$BUNDLE/bin" "$BUNDLE/share/tinyhand/settings"
+ TARGET_DIR=$(cargo metadata --manifest-path "$LINUX_DIR/Cargo.toml" --locked --no-deps --format-version 1 | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
+ cp "$TARGET_DIR/release/tinyhand" "$BUNDLE/bin/tinyhand"
+ cp -R "$STAGE/extension/assets" "$BUNDLE/share/tinyhand/assets"
+ cp "$EXTENSION_SRC/preferences.js" "$EXTENSION_SRC/settings.js" "$BUNDLE/share/tinyhand/settings/"
+ cp "$STAGE/extension/LICENSE" "$STAGE/extension/CREDITS" "$BUNDLE/"
+ cp "$SCRIPT_DIR/install-release.sh" "$BUNDLE/install.sh"
+ cat > "$BUNDLE/bin/tinyhand-settings" <<'EOF'
+#!/usr/bin/env sh
+PREFIX=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+exec gjs -m "$PREFIX/share/tinyhand/settings/settings.js" "$@"
+EOF
+ chmod 755 "$BUNDLE/bin/tinyhand" "$BUNDLE/bin/tinyhand-settings" "$BUNDLE/install.sh"
+ cat > "$BUNDLE/README.txt" <<'EOF'
+TinyHand for Linux — portable X11 app
+
+Run ./install.sh to install under ~/.local (no sudo, Rust or Cargo required).
+Start ~/.local/bin/tinyhand in an X11 session; open TinyHand Settings from
+your app launcher or run ~/.local/bin/tinyhand-settings.
+Settings require GJS, GTK 4.8+ and libadwaita 1.4+. Tap sounds use paplay.
+To remove installed release files: ./install.sh --uninstall (keeps config).
+For GNOME Wayland, install the separate .shell-extension.zip with
+gnome-extensions install --force PATH and enable tinyhand@abdulrehmankhan56.
+The portable bundle does not install or enable a GNOME extension.
+This binary is not compatible with every Linux distribution. Check the
+architecture and build-host glibc version in BUILD-INFO.txt; older glibc
+systems should build the app from source instead.
+See LICENSE and CREDITS for original artwork and Linux port attribution.
+EOF
+ {
+ printf 'TinyHand Linux binary build information\nArchitecture: %s\n' "$ARCH"
+ printf 'Build-host libc: '
+ getconf GNU_LIBC_VERSION
+ printf 'Rust compiler: '
+ rustc --version
+ printf '\nBuilt on this libc version; compatibility with older libc versions is not guaranteed.\n'
+ } > "$BUNDLE/BUILD-INFO.txt"
+ python3 - "$STAGE" "$BUNDLE_NAME" "$DIST/$BUNDLE_NAME.tar.gz" <<'PY'
+import gzip
+import os
+import sys
+import tarfile
+from pathlib import Path
+
+stage, name, output = sys.argv[1:]
+epoch = int(os.environ.get('SOURCE_DATE_EPOCH', '1704067200'))
+def normalize(info):
+ info.uid = info.gid = 0
+ info.uname = info.gname = ''
+ info.mtime = epoch
+ return info
+with open(output, 'wb') as raw, gzip.GzipFile(filename='', mode='wb', fileobj=raw, mtime=epoch) as compressed:
+ with tarfile.open(fileobj=compressed, mode='w') as archive:
+ archive.add(Path(stage) / name, arcname=name, filter=normalize)
+size = Path(output).stat().st_size
+print(f'{output}: {size:,} bytes ({size / 1048576:.2f} MiB)')
+PY
+fi
diff --git a/linux/scripts/publish.sh b/linux/scripts/publish.sh
new file mode 100755
index 0000000..c4ae924
--- /dev/null
+++ b/linux/scripts/publish.sh
@@ -0,0 +1,131 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ ${1:-} == --help || ${1:-} == -h ]]; then
+ cat <<'EOF'
+Usage: ./linux/scripts/publish.sh
+
+Commit the reviewed publication files on linux-port, prepare an upstream
+branch with a minimal README addition, push both to the public fork, make
+linux-port its default, open the upstream draft PR, and wait for the checked
+GitHub Actions prerelease. Requires Git, Python 3, tar, authenticated GitHub
+CLI (gh), a writable .git directory, network access, and permission to publish.
+Does not submit the extension to extensions.gnome.org.
+EOF
+ exit 0
+fi
+[[ $# == 0 ]] || { printf 'Use --help for usage.\n' >&2; exit 1; }
+SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd)
+cd "$ROOT"
+command -v gh >/dev/null || { printf 'Install GitHub CLI and run gh auth login first.\n' >&2; exit 1; }
+for dependency in python3 tar; do command -v "$dependency" >/dev/null; done
+[[ $(git branch --show-current) == linux-port ]] || { printf 'Run from the linux-port branch.\n' >&2; exit 1; }
+reviewed_files=(
+ README.md linux/.gitignore linux/README.md linux/CONTRIBUTING.md linux/COMPATIBILITY.md
+ linux/RELEASE_NOTES.md linux/UPSTREAM_PR.md linux/src/config.rs
+ linux/companion/tinyhand@abdulrehmankhan56/extension.js
+ linux/companion/tinyhand@abdulrehmankhan56/metadata.json
+ linux/companion/tinyhand@abdulrehmankhan56/preferences.js
+ linux/tests/companion-render.mjs linux/tests/companion.test.cjs
+ linux/scripts/install-release.sh linux/scripts/package.sh linux/scripts/publish.sh
+ .github/workflows/linux.yml
+)
+git diff --cached --quiet || { printf 'Preserve or commit your staged changes first.\n' >&2; exit 1; }
+while IFS= read -r -d '' changed_path; do
+ allowed=false
+ for path in "${reviewed_files[@]}"; do
+ if [[ "$changed_path" == "$path" ]]; then allowed=true; break; fi
+ done
+ $allowed || { printf 'Preserve unrelated changes to %s before publishing.\n' "$changed_path" >&2; exit 1; }
+done < <(git diff --name-only -z HEAD; git ls-files --others --exclude-standard -z)
+if [[ -n $(git status --porcelain) ]]; then
+ git add -- "${reviewed_files[@]}"
+ git diff --cached --check
+ git commit -m 'release(linux): prepare attributed GNOME and X11 prerelease'
+fi
+gh auth status --hostname github.com >/dev/null
+[[ $(gh repo view AbdulRehman56/tinyhand --json visibility --jq .visibility) == PUBLIC ]] || {
+ printf 'Expected the existing public fork; refusing to change repository visibility.\n' >&2
+ exit 1
+}
+if ! git show-ref --verify --quiet refs/heads/linux-port-upstream; then
+ git fetch https://github.com/nilbuild/tinyhand.git main
+ upstream_base=$(git rev-parse FETCH_HEAD)
+ pr_worktree=$(mktemp -d /tmp/opencode/tinyhand-upstream.XXXXXX)
+ git worktree add -b linux-port-upstream "$pr_worktree" "$upstream_base"
+ git archive linux-port linux .github/workflows/linux.yml | tar -x -C "$pr_worktree"
+ python3 - "$pr_worktree/README.md" <<'PY'
+import sys
+from pathlib import Path
+readme = Path(sys.argv[1])
+source = readme.read_text()
+marker = '## License\n'
+if marker not in source:
+ raise SystemExit('Upstream README changed; add the Linux section manually before publishing.')
+section = '''## Experimental Linux port
+
+An optional Linux implementation is available in [linux/](./linux/README.md):
+a GNOME Shell 45–50 companion for Wayland and a Rust X11 app, with visual settings
+and per-user installation. GNOME 45–49 are experimental (API/mocks only); 50.1 is
+the only desktop-tested version. Settings need GTK 4.8+ and libadwaita 1.4+.
+See the compatibility assessment for limits; full macOS parity remains incomplete.
+
+TinyHand's original app, concept, artwork and sounds are by
+[Kamran Ahmed (@nilbuild)](https://github.com/nilbuild). The independent Linux
+port is maintained by [AbdulRehman56](https://github.com/AbdulRehman56).
+See the [Linux guide](./linux/README.md) for installation, packaging and known gaps.
+
+'''
+readme.write_text(source.replace(marker, section + marker, 1))
+PY
+ git -C "$pr_worktree" add README.md linux .github/workflows/linux.yml
+ git -C "$pr_worktree" diff --cached --check
+ git -C "$pr_worktree" commit -m 'feat(linux): propose experimental GNOME and X11 port'
+ git worktree remove "$pr_worktree"
+fi
+for path in linux .github/workflows/linux.yml; do
+ [[ $(git rev-parse "linux-port:$path") == $(git rev-parse "linux-port-upstream:$path") ]] || {
+ printf 'The prepared upstream branch is stale for %s; update it before publishing.\n' "$path" >&2
+ exit 1
+ }
+done
+git -c credential.helper= -c 'credential.helper=!gh auth git-credential' push --atomic \
+ https://github.com/AbdulRehman56/tinyhand.git \
+ linux-port:refs/heads/linux-port linux-port-upstream:refs/heads/linux-port-upstream
+gh repo edit AbdulRehman56/tinyhand --default-branch linux-port \
+ --description 'Independent Linux port of TinyHand by Kamran Ahmed (@nilbuild). GNOME 45-50 (45-49 experimental) and X11.'
+pr_url=$(gh pr list --repo nilbuild/tinyhand --state open \
+ --head AbdulRehman56:linux-port-upstream --json url --jq '.[0].url // empty')
+if [[ -z "$pr_url" ]]; then
+ pr_url=$(gh pr create --repo nilbuild/tinyhand --base main \
+ --head AbdulRehman56:linux-port-upstream --draft \
+ --title 'Add experimental Linux port (GNOME 45-50 and X11)' \
+ --body-file linux/UPSTREAM_PR.md)
+fi
+printf 'Upstream PR: %s\n' "$pr_url"
+commit_sha=$(git rev-parse linux-port)
+find_run() {
+ local run_id=''
+ for attempt in {1..15}; do
+ run_id=$(gh run list --repo AbdulRehman56/tinyhand --workflow linux.yml \
+ --branch linux-port --commit "$commit_sha" --limit 1 \
+ --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)
+ if [[ -n "$run_id" ]]; then printf '%s\n' "$run_id"; return 0; fi
+ sleep 2
+ done
+ return 1
+}
+if ! run_id=$(find_run); then
+ # A fork may have workflows disabled, or a retry may have no new push event.
+ gh workflow enable linux.yml --repo AbdulRehman56/tinyhand
+ gh workflow run linux.yml --repo AbdulRehman56/tinyhand --ref linux-port
+ if ! run_id=$(find_run); then
+ printf 'No release workflow run found. Check https://github.com/AbdulRehman56/tinyhand/actions and enable Actions if needed, then rerun this script.\n' >&2
+ exit 1
+ fi
+fi
+gh run watch "$run_id" --repo AbdulRehman56/tinyhand --exit-status
+release_tag="v$(python3 -c 'import json; print(json.load(open("linux/companion/tinyhand@abdulrehmankhan56/metadata.json"))["version-name"])')"
+gh release view "$release_tag" --repo AbdulRehman56/tinyhand --json url --jq .url
+printf 'GNOME listing still needs a maintainer upload of the extension ZIP.\n'
diff --git a/linux/src/app.rs b/linux/src/app.rs
new file mode 100644
index 0000000..9e1e683
--- /dev/null
+++ b/linux/src/app.rs
@@ -0,0 +1,691 @@
+// TinyHand application state + event loop for X11. Single-threaded on purpose:
+// every X call happens on the same thread that renders frames.
+
+use crate::asset::{self, Assets, InkChoice};
+use crate::config::{self, Config};
+use crate::model::Screen;
+use crate::pose::{Pose, PoseState};
+use crate::render;
+use crate::sound;
+use crate::tray::{self, Cmd};
+use crate::x11::{PtrEvent, PtrEventKind, X};
+use std::time::{Duration, Instant};
+
+struct Stroke {
+ points: Vec<(f64, f64)>,
+ color: [f32; 4],
+ width: f64,
+ start: Instant,
+}
+
+impl Stroke {
+ fn alpha(&self, fade: f64) -> f64 {
+ if fade <= 0.0 {
+ return 1.0;
+ }
+ let elapsed = self.start.elapsed().as_secs_f64();
+ if elapsed <= fade {
+ return 1.0;
+ }
+ let t = (elapsed - fade) / 0.6;
+ (1.0 - t).clamp(0.0, 1.0)
+ }
+}
+
+pub struct App {
+ x: X,
+ cfg: Config,
+ assets: Assets,
+ ink_choice: InkChoice,
+ style: String,
+ pose_state: PoseState,
+ laser: bool,
+ enabled: bool,
+ strokes: Vec,
+ active: Option>,
+ overlay: u32,
+ frame: tiny_skia::Pixmap,
+ zones: Vec<(i32, i32, i32, i32)>,
+ interact_mask: u16,
+ hotkeys: Vec<(u8, u16)>,
+ tray_rx: Option>,
+ tray: tray::Tray,
+ cursor_last_move: Instant,
+ cursor_prev: (i32, i32),
+ quit: bool,
+ // GA-style one-shot config watcher mtime
+ cfg_mtime: Option,
+}
+
+const BUSY: Duration = Duration::from_millis(10);
+const IDLE: Duration = Duration::from_millis(60);
+const SLEEP: Duration = Duration::from_millis(250);
+
+impl App {
+ pub fn init() -> Result {
+ let x = X::connect()?;
+ let cfg = config::load();
+ if !cfg.pointer.size.is_finite() || !(0.08..=0.20).contains(&cfg.pointer.size) {
+ return Err("pointer.size must be between 0.08 and 0.20".into());
+ }
+ let style = cfg.pointer.style.clone();
+ let laser = style == "laser";
+ let ink_choice = InkChoice::parse(&cfg.ink.choice);
+
+ // Pass-through zones: strip outside _NET_WORKAREA (panels/docks).
+ let zones = query_workarea_zones(&x);
+
+ // Hotkeys.
+ let mut hotkeys = Vec::new();
+ for (spec, name) in [
+ (cfg.hotkeys.toggle.as_str(), "toggle"),
+ (cfg.hotkeys.clear.as_str(), "clear"),
+ (cfg.hotkeys.cycle.as_str(), "cycle"),
+ ] {
+ if let Some((code, mods)) = parse_hotkey(&x, spec) {
+ hotkeys.push((code, mods));
+ } else {
+ log(&format!("hotkey {name} '{}' could not be resolved", spec));
+ hotkeys.push((0, 0));
+ }
+ }
+ x.grab_hotkeys(
+ &hotkeys
+ .iter()
+ .copied()
+ .filter(|(key, _)| *key != 0)
+ .collect::>(),
+ );
+
+ // Overlay window: whole virtual screen.
+ let overlay = x.create_overlay_win(x.root_w, x.root_h)?;
+ let frame =
+ tiny_skia::Pixmap::new(x.root_w, x.root_h).ok_or("failed to allocate frame buffer")?;
+
+ let interact_mask = interact_mask(&cfg.interact.key);
+
+ // Tray (best-effort; if DBus isn't around we still run).
+ let (tx, rx) = std::sync::mpsc::channel();
+ let tray_rx = Some(rx);
+ let tray = tray::Tray::spawn(cfg.clone(), tx);
+
+ let cfg_mtime = std::fs::metadata(config::config_path())
+ .and_then(|m| m.modified())
+ .ok();
+
+ let mut app = Self {
+ x,
+ cfg: cfg.clone(),
+ assets: Assets::new(),
+ ink_choice,
+ style,
+ pose_state: PoseState::new(cfg.pointer.size),
+ laser,
+ enabled: cfg.enabled.unwrap_or(true),
+ strokes: Vec::new(),
+ active: None,
+ overlay,
+ frame,
+ zones,
+ interact_mask,
+ hotkeys,
+ tray_rx,
+ tray,
+ cursor_last_move: Instant::now(),
+ cursor_prev: (0, 0),
+ quit: false,
+ cfg_mtime,
+ };
+ if !app.laser && app.assets.get(&app.style).is_none() {
+ return Err(format!(
+ "hand asset '{}' missing or invalid; run linux/scripts/install.sh",
+ app.style
+ ));
+ }
+ app.preload();
+ Ok(app)
+ }
+
+ fn preload(&mut self) {
+ if !self.laser {
+ self.assets.get(&self.style);
+ }
+ }
+
+ fn current_screen(&self, x: i32, y: i32) -> Screen {
+ self.x.screen_at(x, y)
+ }
+
+ fn ink_color(&self) -> [f32; 4] {
+ if self.ink_choice == InkChoice::Match {
+ if let Some(s) = asset::BUILT_INS.iter().find(|s| s.id == self.style) {
+ return asset::ink_rgb(s.ink);
+ }
+ return asset::ink_rgb(InkChoice::Purple);
+ }
+ asset::ink_rgb(self.ink_choice)
+ }
+
+ fn stroke_width(screen_h: f64) -> f64 {
+ (3.0f64).max(screen_h * 0.0055)
+ }
+
+ pub fn run(mut self) -> i32 {
+ if self.enabled {
+ if let Err(e) = self.set_enabled(true) {
+ log(&format!("startup failed: {e}"));
+ return 1;
+ }
+ }
+ log("tinyhand (linux x11) started");
+ while !self.quit {
+ let timeout = self.tick();
+ let tick_when = Instant::now() + timeout;
+ loop {
+ let remaining = tick_when
+ .checked_duration_since(Instant::now())
+ .unwrap_or(Duration::from_millis(0));
+ match self.x.wait_event(Some(remaining)) {
+ Ok(Some(ev)) => {
+ self.handle_event(ev);
+ if self.quit {
+ break;
+ }
+ }
+ Ok(None) => break,
+ Err(e) => {
+ log(&format!("X11 connection failed: {e}"));
+ self.shutdown();
+ return 1;
+ }
+ }
+ }
+ }
+ self.shutdown();
+ 0
+ }
+
+ fn tick_timeout(&self) -> Duration {
+ if !self.enabled {
+ return SLEEP;
+ }
+ if self.active.is_some()
+ || self.pose_state.is_settling()
+ || self.cursor_last_move.elapsed() < Duration::from_millis(120)
+ || self
+ .strokes
+ .iter()
+ .any(|s| s.alpha(self.cfg.ink.fade) < 1.0)
+ {
+ BUSY
+ } else {
+ IDLE
+ }
+ }
+
+ fn set_enabled(&mut self, on: bool) -> Result<(), String> {
+ self.enabled = on;
+ if on {
+ self.preload();
+ self.x.map_win(self.overlay);
+ self.x.raise(self.overlay);
+ } else {
+ self.x.unmap_win(self.overlay);
+ self.x.ungrab_pointer();
+ self.x.show_cursor();
+ self.poses_clear();
+ }
+ self.save_enabled();
+ Ok(())
+ }
+
+ fn poses_clear(&mut self) {
+ self.pose_state = PoseState::new(self.cfg.pointer.size);
+ self.active = None;
+ }
+
+ fn save_enabled(&mut self) {
+ self.cfg.enabled = Some(self.enabled);
+ config::save(&self.cfg);
+ self.tray.sync(&self.cfg);
+ }
+
+ fn next_style(&mut self) {
+ let pool: Vec<&str> = asset::DEFAULT_PINNED.to_vec();
+ let cur = pool
+ .iter()
+ .position(|s| **s == self.style)
+ .map(|i| i + 1)
+ .unwrap_or(0);
+ let next = pool[cur % pool.len()];
+ self.set_style(next);
+ }
+
+ fn set_style(&mut self, style: &str) {
+ self.style = style.to_string();
+ self.laser = style == "laser";
+ self.cfg.pointer.style = style.to_string();
+ config::save(&self.cfg);
+ self.preload();
+ self.poses_clear();
+ }
+
+ fn clear_ink(&mut self) {
+ self.strokes.clear();
+ self.active = None;
+ }
+
+ // ---------------------------------------------------------------------
+
+ fn handle_event(&mut self, ev: x11rb::protocol::Event) {
+ use x11rb::protocol::Event as E;
+ match ev {
+ E::KeyPress(ke) => self.on_key(ke.detail, u16::from(ke.state)),
+ E::ButtonPress(be) => self.on_ptr(PtrEvent {
+ kind: PtrEventKind::Press,
+ detail: be.detail,
+ x: be.root_x as i32,
+ y: be.root_y as i32,
+ mods: u16::from(be.state),
+ }),
+ E::ButtonRelease(be) => self.on_ptr(PtrEvent {
+ kind: PtrEventKind::Release,
+ detail: be.detail,
+ x: be.root_x as i32,
+ y: be.root_y as i32,
+ mods: u16::from(be.state),
+ }),
+ E::MotionNotify(me) => self.on_ptr(PtrEvent {
+ kind: PtrEventKind::Motion,
+ detail: 0,
+ x: me.root_x as i32,
+ y: me.root_y as i32,
+ mods: u16::from(me.state),
+ }),
+ _ => {}
+ }
+ }
+
+ fn on_key(&mut self, keycode: u8, _state: u16) {
+ for (i, (code, _)) in self.hotkeys.iter().enumerate() {
+ if *code == keycode {
+ match i {
+ 0 => {
+ let on = !self.enabled;
+ let _ = self.set_enabled(on);
+ }
+ 1 => self.clear_ink(),
+ 2 => self.next_style(),
+ _ => {}
+ }
+ return;
+ }
+ }
+ }
+
+ fn on_ptr(&mut self, ev: PtrEvent) {
+ if !self.enabled {
+ return;
+ }
+ self.cursor_prev = (ev.x, ev.y);
+ self.cursor_last_move = Instant::now();
+ match ev.kind {
+ PtrEventKind::Motion => {
+ if self.x.grabbed {
+ if let Some(active) = &mut self.active {
+ if let Some(last) = active.last() {
+ if (ev.x as f64 - last.0).hypot(ev.y as f64 - last.1) >= 1.5 {
+ active.push((ev.x as f64, ev.y as f64));
+ }
+ }
+ }
+ }
+ }
+ PtrEventKind::Press => {
+ // Swallowed clicks while grabbed become taps; interact key,
+ // pass-through zones and scroll replay the press for real input.
+ // (The physical release then reaches the app natively.)
+ let pass_through = !self.x.grabbed
+ || self.zone_hit(ev.x, ev.y)
+ || (ev.mods & self.interact_mask) != 0
+ || ev.detail >= 4;
+ if pass_through {
+ let replay = self.x.grabbed;
+ self.active = None;
+ self.pose_state.release();
+ self.x.ungrab_pointer();
+ self.x.show_cursor();
+ if replay {
+ self.x.fake_button_down(ev.detail);
+ }
+ return;
+ }
+ // Swallow: tap!
+ self.pose_state.press();
+ if self.laser {
+ sound::play_laser(&self.cfg.sound);
+ } else {
+ sound::play_hand(&self.cfg.sound);
+ }
+ if ev.detail == 1 && self.x.grabbed {
+ self.active = Some(vec![(ev.x as f64, ev.y as f64)]);
+ }
+ }
+ PtrEventKind::Release => {
+ if ev.detail == 1 {
+ if let Some(points) = self.active.take() {
+ if points.len() > 1 {
+ self.strokes.push(Stroke {
+ points,
+ color: self.ink_color(),
+ width: Self::stroke_width(self.y_screen_h()),
+ start: Instant::now(),
+ });
+ }
+ }
+ self.pose_state.release();
+ }
+ }
+ }
+ }
+
+ fn y_screen_h(&self) -> f64 {
+ self.x.screens[0].height as f64
+ }
+
+ fn zone_hit(&self, x: i32, y: i32) -> bool {
+ self.zones
+ .iter()
+ .any(|z| x >= z.0 && x < z.0 + z.2 && y >= z.1 && y < z.1 + z.3)
+ }
+
+ // -------------------------------------------------------------------
+
+ fn tick(&mut self) -> Duration {
+ // Tray commands.
+ while let Some(cmd) = self.tray_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
+ match cmd {
+ Cmd::Toggle => {
+ let _ = self.set_enabled(!self.enabled);
+ }
+ Cmd::NextHand => self.next_style(),
+ Cmd::Clear => self.clear_ink(),
+ Cmd::Quit => {
+ self.quit = true;
+ return SLEEP;
+ }
+ }
+ }
+
+ // Config file hot-reload (mtime).
+ if let Ok(meta) = std::fs::metadata(config::config_path()) {
+ if let Ok(m) = meta.modified() {
+ if self.cfg_mtime.map(|t| t != m).unwrap_or(true) {
+ self.cfg_mtime = Some(m);
+ match config::try_load() {
+ Ok(cfg) => self.apply_config(cfg),
+ Err(e) => log(&format!("config reload ignored: {e}")),
+ }
+ }
+ }
+ }
+
+ if !self.enabled {
+ return SLEEP;
+ }
+
+ let (px, py, mods) = self.x.pointer();
+ let interact = mods & self.interact_mask != 0;
+ let zone = self.zone_hit(px, py);
+ // Never steal a release belonging to an underlying application's press.
+ let want_grab = !zone && !interact && (self.x.grabbed || mods & 0x1f00 == 0);
+
+ if want_grab != self.x.grabbed {
+ if want_grab {
+ self.x.grab_pointer();
+ if self.x.grabbed {
+ self.x.hide_cursor();
+ }
+ } else {
+ self.active = None;
+ self.pose_state.release();
+ self.x.ungrab_pointer();
+ self.x.show_cursor();
+ }
+ }
+
+ // The hand pose is drawn in monitor-local coordinates, then offset.
+ let screen = self.current_screen(px, py);
+ let cursor_local = ((px - screen.x) as f64, (py - screen.y) as f64);
+ let sw = screen.width as f64;
+ let sh = screen.height as f64;
+ let geo = self
+ .assets
+ .get(&self.style)
+ .map(|a| a.geo)
+ .unwrap_or_default();
+
+ let mut pose: Option = if self.laser {
+ None
+ } else {
+ Some(
+ self.pose_state
+ .aim(cursor_local, sw, sh, &geo, self.active.is_some()),
+ )
+ };
+
+ // A fresh transparent frame prevents motion trails and accumulated ink
+ // opacity. Borrow stroke data directly; no per-frame point cloning.
+ self.frame.fill(tiny_skia::Color::TRANSPARENT);
+ self.strokes.retain(|s| s.alpha(self.cfg.ink.fade) > 0.0);
+ for s in &self.strokes {
+ let a = s.alpha(self.cfg.ink.fade);
+ if a > 0.003 {
+ render::draw_stroke(&mut self.frame, &s.points, s.color, s.width, a);
+ }
+ }
+ if let Some(active) = &self.active {
+ let color = self.ink_color();
+ let width = Self::stroke_width(sh);
+ render::draw_stroke(&mut self.frame, active, color, width, 1.0);
+ }
+ // Hand poses were computed in monitor-local space; shift the grip into
+ // virtual-screen coordinates for drawing.
+ if let Some(p) = &mut pose {
+ p.grip_x += screen.x as f64;
+ p.grip_y += screen.y as f64;
+ }
+ if interact && !self.cfg.interact.keeps_hand {
+ // Ink remains visible while the real pointer interacts with apps.
+ } else if let Some(p) = &pose {
+ if self.cfg.pointer.shadow {
+ if let Some(a) = self.assets.get(&self.style) {
+ render::draw_shadow(&mut self.frame, &a, p);
+ }
+ if let Some(a) = self.assets.get(&self.style) {
+ render::draw_hand(&mut self.frame, &a, p, 1.0);
+ }
+ } else if let Some(a) = self.assets.get(&self.style) {
+ render::draw_hand(&mut self.frame, &a, p, 1.0);
+ }
+ } else {
+ let r = self
+ .pose_state
+ .laser_radius(sh, self.pose_state.press_amount());
+ render::draw_laser(&mut self.frame, (px as f64, py as f64), r);
+ }
+
+ self.x.put_region(self.overlay, &self.frame, None);
+
+ self.tick_timeout()
+ }
+
+ fn apply_config(&mut self, cfg: Config) {
+ if !cfg.pointer.size.is_finite() || !(0.08..=0.20).contains(&cfg.pointer.size) {
+ log("config reload ignored: pointer.size must be between 0.08 and 0.20");
+ return;
+ }
+ if cfg.pointer.style != "laser" && self.assets.get(&cfg.pointer.style).is_none() {
+ log("config reload ignored: hand asset missing or invalid");
+ return;
+ }
+ let reset_pose = cfg.pointer.style != self.style
+ || (cfg.pointer.size - self.cfg.pointer.size).abs() > 1e-9;
+ self.style = cfg.pointer.style.clone();
+ self.laser = self.style == "laser";
+ self.ink_choice = InkChoice::parse(&cfg.ink.choice);
+ self.interact_mask = interact_mask(&cfg.interact.key);
+ self.x.ungrab_keys();
+ // Keep action indices stable even if a binding cannot be resolved.
+ self.hotkeys = [&cfg.hotkeys.toggle, &cfg.hotkeys.clear, &cfg.hotkeys.cycle]
+ .iter()
+ .map(|spec| parse_hotkey(&self.x, spec).unwrap_or((0, 0)))
+ .collect();
+ let grabs: Vec<_> = self
+ .hotkeys
+ .iter()
+ .copied()
+ .filter(|(key, _)| *key != 0)
+ .collect();
+ self.x.grab_hotkeys(&grabs);
+ let enabled = cfg.enabled.unwrap_or(true);
+ self.cfg = cfg;
+ self.tray.sync(&self.cfg);
+ if reset_pose {
+ self.poses_clear();
+ }
+ self.preload();
+ if enabled != self.enabled {
+ self.enabled = enabled;
+ if enabled {
+ self.x.map_win(self.overlay);
+ } else {
+ self.x.unmap_win(self.overlay);
+ self.x.ungrab_pointer();
+ self.x.show_cursor();
+ self.poses_clear();
+ }
+ }
+ }
+
+ fn shutdown(&mut self) {
+ self.x.ungrab_pointer();
+ self.x.show_cursor();
+ self.x.unmap_win(self.overlay);
+ log("tinyhand: bye");
+ }
+}
+
+fn interact_mask(key: &str) -> u16 {
+ match key {
+ "ctrl" | "control" => 0x4,
+ "super" => 0x40,
+ "alt" => 0x8,
+ _ => 0,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn strokes_expire_but_zero_fade_is_persistent() {
+ let mut stroke = Stroke {
+ points: vec![(2.0, 2.0), (18.0, 2.0)],
+ color: [1.0, 0.0, 0.0, 1.0],
+ width: 2.0,
+ start: Instant::now() - Duration::from_secs(2),
+ };
+ assert_eq!(stroke.alpha(1.0), 0.0);
+ assert_eq!(stroke.alpha(0.0), 1.0);
+ stroke.start = Instant::now() - Duration::from_millis(1300);
+ assert!((0.45..0.55).contains(&stroke.alpha(1.0)));
+ }
+}
+
+fn query_workarea_zones(x: &X) -> Vec<(i32, i32, i32, i32)> {
+ use x11rb::protocol::xproto::ConnectionExt as _;
+ let mut zones = Vec::new();
+ if let Ok(cookie) = x.conn.intern_atom(false, b"_NET_WORKAREA") {
+ if let Ok(atom) = cookie.reply() {
+ let property = atom.atom;
+ if let Ok(prop) = x
+ .conn
+ .get_property(false, x.root, property, 6u32 /* CARDINAL */, 0, 4)
+ {
+ if let Ok(p) = prop.reply() {
+ if p.value.len() >= 16 {
+ let v: Vec = p
+ .value
+ .chunks_exact(4)
+ .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]]))
+ .collect();
+ let (wx, wy, ww, wh) = (v[0] as i32, v[1] as i32, v[2] as i32, v[3] as i32);
+ if ww > 0 && wh > 0 {
+ let (rw, rh) = (x.root_w as i32, x.root_h as i32);
+ if wy > 0 {
+ zones.push((0, 0, rw, wy));
+ }
+ if wy + wh < rh {
+ zones.push((0, wy + wh, rw, rh - (wy + wh)));
+ }
+ if wx > 0 {
+ zones.push((0, wy, wx, wh));
+ }
+ if wx + ww < rw {
+ zones.push((wx + ww, wy, rw - (wx + ww), wh));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ if zones.is_empty() {
+ // Fallback: GNOME-ish top 28px + bottom 64px.
+ let (rw, rh) = (x.root_w as i32, x.root_h as i32);
+ zones.push((0, 0, rw, 28));
+ zones.push((0, rh - 64, rw, 64));
+ }
+ zones
+}
+
+fn parse_hotkey(x: &X, spec: &str) -> Option<(u8, u16)> {
+ let mut mods = 0u16;
+ let mut key = None;
+ for part in spec.split('+') {
+ match part.to_ascii_lowercase().as_str() {
+ "ctrl" | "control" => mods |= 0x4,
+ "alt" | "option" => mods |= 0x8,
+ "shift" => mods |= 0x1,
+ "super" | "meta" => mods |= 0x40,
+ other => {
+ let ch = other.chars().next()?;
+ let ks = ch as u32;
+ key = x.keycode_for_keysym(ks);
+ }
+ }
+ }
+ key.map(|k| (k, mods))
+}
+
+#[allow(soft_unstable)]
+fn log(msg: &str) {
+ let dir = dirs::state_dir()
+ .map(|d| d.join("tinyhand"))
+ .unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
+ let _ = std::fs::create_dir_all(&dir);
+ let ts = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(dir.join("log.txt"))
+ {
+ use std::io::Write;
+ let _ = writeln!(f, "{} {msg}", ts);
+ }
+}
diff --git a/linux/src/asset.rs b/linux/src/asset.rs
new file mode 100644
index 0000000..9ee89bf
--- /dev/null
+++ b/linux/src/asset.rs
@@ -0,0 +1,478 @@
+// Hand assets: PNG loading, alpha-geometry measurement (port of PointerStyle.swift
+// measure()), thumbnails, and precomputed drop-shadow silhouettes.
+
+use crate::config;
+use crate::pose::HandGeometry;
+use image::RgbaImage;
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum InkChoice {
+ Match,
+ Red,
+ Orange,
+ Yellow,
+ Green,
+ Blue,
+ Purple,
+ White,
+ Black,
+}
+
+impl InkChoice {
+ pub fn parse(s: &str) -> InkChoice {
+ match s {
+ "red" => InkChoice::Red,
+ "orange" => InkChoice::Orange,
+ "yellow" => InkChoice::Yellow,
+ "green" => InkChoice::Green,
+ "blue" => InkChoice::Blue,
+ "purple" => InkChoice::Purple,
+ "white" => InkChoice::White,
+ "black" => InkChoice::Black,
+ _ => InkChoice::Match,
+ }
+ }
+}
+
+pub fn ink_rgb(choice: InkChoice) -> [f32; 4] {
+ match choice {
+ InkChoice::Red => [1.0, 0.16, 0.12, 1.0],
+ InkChoice::Orange => [1.0, 0.54, 0.0, 1.0],
+ InkChoice::Yellow => [1.0, 0.8, 0.0, 1.0],
+ InkChoice::Green => [0.19, 0.76, 0.35, 1.0],
+ InkChoice::Blue => [0.04, 0.52, 1.0, 1.0],
+ InkChoice::Purple => [0.53, 0.33, 0.95, 1.0],
+ InkChoice::White => [1.0, 1.0, 1.0, 1.0],
+ InkChoice::Black => [0.11, 0.11, 0.13, 1.0],
+ InkChoice::Match => [0.53, 0.33, 0.95, 1.0],
+ }
+}
+
+pub struct StyleInfo {
+ pub id: &'static str,
+ pub title: &'static str,
+ pub ink: InkChoice,
+}
+
+pub const BUILT_INS: &[StyleInfo] = &[
+ StyleInfo {
+ id: "lavender",
+ title: "Lavender toy",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "glove",
+ title: "Cursor glove",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "coral",
+ title: "Coral vinyl",
+ ink: InkChoice::Red,
+ },
+ StyleInfo {
+ id: "plush",
+ title: "Plush",
+ ink: InkChoice::Blue,
+ },
+ StyleInfo {
+ id: "robot",
+ title: "Robot",
+ ink: InkChoice::Green,
+ },
+ StyleInfo {
+ id: "pink",
+ title: "Bubblegum",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "blue",
+ title: "Sky",
+ ink: InkChoice::Blue,
+ },
+ StyleInfo {
+ id: "mint",
+ title: "Mint",
+ ink: InkChoice::Green,
+ },
+ StyleInfo {
+ id: "lemon",
+ title: "Lemon",
+ ink: InkChoice::Red,
+ },
+ StyleInfo {
+ id: "black",
+ title: "Midnight",
+ ink: InkChoice::Black,
+ },
+ StyleInfo {
+ id: "chrome",
+ title: "Chrome",
+ ink: InkChoice::White,
+ },
+ StyleInfo {
+ id: "holo",
+ title: "Holo",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "fuzzy",
+ title: "Fuzzy",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "wood",
+ title: "Wood",
+ ink: InkChoice::Orange,
+ },
+ StyleInfo {
+ id: "marble",
+ title: "Marble",
+ ink: InkChoice::White,
+ },
+ StyleInfo {
+ id: "terrazzo",
+ title: "Terrazzo",
+ ink: InkChoice::Red,
+ },
+ StyleInfo {
+ id: "glass",
+ title: "Glass",
+ ink: InkChoice::Blue,
+ },
+ StyleInfo {
+ id: "jelly",
+ title: "Jelly",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "pastel",
+ title: "Pastel",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "pixel",
+ title: "Pixel",
+ ink: InkChoice::Black,
+ },
+ StyleInfo {
+ id: "lowpoly",
+ title: "Low poly",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "knit",
+ title: "Knit",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "skeleton",
+ title: "Skeleton",
+ ink: InkChoice::Orange,
+ },
+ StyleInfo {
+ id: "astro",
+ title: "Astronaut",
+ ink: InkChoice::Blue,
+ },
+ StyleInfo {
+ id: "space",
+ title: "Space",
+ ink: InkChoice::Red,
+ },
+ StyleInfo {
+ id: "monster",
+ title: "Monster",
+ ink: InkChoice::Green,
+ },
+ StyleInfo {
+ id: "water",
+ title: "Water",
+ ink: InkChoice::Blue,
+ },
+ StyleInfo {
+ id: "lava",
+ title: "Lava",
+ ink: InkChoice::Red,
+ },
+ StyleInfo {
+ id: "grass",
+ title: "Grass",
+ ink: InkChoice::Green,
+ },
+ StyleInfo {
+ id: "cactus",
+ title: "Cactus",
+ ink: InkChoice::Green,
+ },
+ StyleInfo {
+ id: "choco",
+ title: "Chocolate",
+ ink: InkChoice::Orange,
+ },
+ StyleInfo {
+ id: "cloud",
+ title: "Cloud",
+ ink: InkChoice::Blue,
+ },
+ StyleInfo {
+ id: "mech",
+ title: "Mech",
+ ink: InkChoice::Red,
+ },
+ StyleInfo {
+ id: "neon",
+ title: "Neon",
+ ink: InkChoice::Purple,
+ },
+ StyleInfo {
+ id: "leaf",
+ title: "Leaf",
+ ink: InkChoice::Green,
+ },
+ StyleInfo {
+ id: "cookie",
+ title: "Gingerbread",
+ ink: InkChoice::Orange,
+ },
+];
+
+pub const DEFAULT_PINNED: [&str; 6] = ["lavender", "glove", "coral", "plush", "robot", "laser"];
+
+pub struct HandAsset {
+ pub name: String,
+ pub geo: HandGeometry,
+ /// Full-resolution premultiplied RGBA pixmap of the composed hand-on-a-stick PNG.
+ pub pixmap: tiny_skia::Pixmap,
+ /// Blurred black silhouette used as drop shadow.
+ pub silhouette: tiny_skia::Pixmap,
+}
+
+fn decode_rgba(path: &PathBuf) -> Option {
+ Some(image::open(path).ok()?.into_rgba8())
+}
+
+fn measure(img: &RgbaImage) -> Option {
+ let w = img.width() as usize;
+ let h = img.height() as usize;
+ let alpha = |x: usize, y: usize| img.get_pixel(x as u32, y as u32)[3];
+
+ let count_row = |y: usize| -> usize { (0..w).filter(|&x| alpha(x, y) > 128).count() };
+ let center_row = |y: usize| -> f64 {
+ let mut lo: Option = None;
+ let mut hi = 0usize;
+ for x in 0..w {
+ if alpha(x, y) > 128 {
+ if lo.is_none() {
+ lo = Some(x);
+ }
+ hi = x;
+ }
+ }
+ match lo {
+ Some(l) => (l + hi) as f64 / 2.0 / w as f64,
+ None => 0.5,
+ }
+ };
+
+ let fh = h as f64;
+ let top = (0..h).find(|&y| count_row(y) > 0)?;
+ let search_end = h.min(top + (0.45 * fh) as usize);
+ let max_w = (top..search_end).map(count_row).max()?;
+ let fist = (top..h).find(|&y| (count_row(y) as f64) > 0.6 * max_w as f64)?;
+ let wrist = (fist..h).find(|&y| (count_row(y) as f64) < 0.35 * max_w as f64)?;
+ let rod_w = count_row(h.min(wrist + h / 50));
+ let last_row = (0..h).rev().find(|&y| count_row(y) > 0).unwrap_or(h - 1);
+ let from = h.min(wrist + h / 33);
+ let handle = (from..h).find(|&y| (count_row(y) as f64) > 1.8 * rod_w as f64);
+ let rod_end = handle.unwrap_or(last_row);
+
+ let mut min_x = w;
+ let mut max_x = 0usize;
+ for y in top..wrist {
+ for x in 0..w {
+ if alpha(x, y) > 128 {
+ min_x = min_x.min(x);
+ max_x = max_x.max(x);
+ }
+ }
+ }
+ if min_x > max_x {
+ min_x = max_x;
+ }
+
+ let tip_x = center_row(h.min(top + h / 200));
+ let rod_x = center_row((wrist + rod_end) / 2);
+
+ Some(HandGeometry {
+ aspect: w as f64 / fh,
+ hand_top: ((top as f64) - 0.004 * fh).max(0.0) / fh,
+ tip: top as f64 / fh,
+ wrist: wrist as f64 / fh,
+ rod_end: rod_end as f64 / fh,
+ tip_x,
+ rod_x,
+ hand_min_x: min_x as f64 / w as f64,
+ hand_max_x: max_x as f64 / w as f64,
+ })
+}
+
+/// Two-round separable box blur of the alpha plane; output silhouette is black + smoothed alpha.
+fn box_blur_alpha(src: RgbaImage, radius: usize) -> tiny_skia::Pixmap {
+ let w = src.width() as usize;
+ let h = src.height() as usize;
+ let r = radius.max(1);
+
+ let mut a: Vec = src.pixels().map(|p| p[3] as f32).collect();
+ let mut tmp = vec![0f32; w * h];
+
+ // One round = horizontal pass a->tmp + vertical pass tmp->a. Repeat twice.
+ for _ in 0..2 {
+ for y in 0..h {
+ for x in 0..w {
+ let lo = x.saturating_sub(r);
+ let hi = (x + r).min(w - 1);
+ let mut acc = 0f32;
+ for xx in lo..=hi {
+ acc += a[y * w + xx];
+ }
+ tmp[y * w + x] = acc / (hi - lo + 1) as f32;
+ }
+ }
+ for y in 0..h {
+ for x in 0..w {
+ let lo = y.saturating_sub(r);
+ let hi = (y + r).min(h - 1);
+ let mut acc = 0f32;
+ for yy in lo..=hi {
+ acc += tmp[yy * w + x];
+ }
+ a[y * w + x] = acc / (hi - lo + 1) as f32;
+ }
+ }
+ }
+
+ let mut pix = tiny_skia::Pixmap::new(w as u32, h as u32).unwrap();
+ {
+ let data = pix.data_mut();
+ for (i, v) in a.iter().enumerate() {
+ let o = i * 4;
+ data[o] = 0;
+ data[o + 1] = 0;
+ data[o + 2] = 0;
+ data[o + 3] = *v as u8;
+ }
+ }
+ pix
+}
+
+fn make_silhouette(src: &RgbaImage) -> tiny_skia::Pixmap {
+ box_blur_alpha(src.clone(), (src.height() as f32 * 0.02) as usize)
+}
+
+pub struct Assets {
+ cache: Mutex>>,
+}
+
+impl Assets {
+ pub fn new() -> Self {
+ Self {
+ cache: Mutex::new(HashMap::new()),
+ }
+ }
+
+ pub fn get(&self, style: &str) -> Option> {
+ if style == "laser" {
+ return None;
+ }
+ if let Some(hit) = self.cache.lock().unwrap().get(style) {
+ return Some(hit.clone());
+ }
+ let slug = format!("hand_{style}");
+ let path = config::resolve_asset(&slug)?;
+ let img = decode_rgba(&path)?;
+ let geo = measure(&img)?;
+ let mut pixmap = tiny_skia::Pixmap::new(img.width(), img.height())?;
+ {
+ let data = pixmap.data_mut();
+ for (i, p) in img.pixels().enumerate() {
+ let o = i * 4;
+ let (r, g, b, a) = (p[0] as u32, p[1] as u32, p[2] as u32, p[3] as u32);
+ data[o] = ((r * a / 255) as u8).max(if a > 0 { 1 } else { 0 });
+ data[o + 1] = (g * a / 255) as u8;
+ data[o + 2] = (b * a / 255) as u8;
+ data[o + 3] = a as u8;
+ }
+ }
+ let silhouette = make_silhouette(&img);
+ let asset = Arc::new(HandAsset {
+ name: slug,
+ geo,
+ pixmap,
+ silhouette,
+ });
+ let mut cache = self.cache.lock().unwrap();
+ if cache.len() > 4 {
+ let keys: Vec = cache.keys().take(1).cloned().collect();
+ for k in keys {
+ cache.remove(&k);
+ }
+ }
+ cache.insert(style.to_string(), asset.clone());
+ Some(asset)
+ }
+}
+
+/// Crop a square region around the hand and scale it to a size×size thumbnail.
+pub fn thumbnail(asset: &HandAsset, size: u32) -> tiny_skia::Pixmap {
+ let img = &asset.pixmap;
+ let (iw, ih) = (img.width() as f64, img.height() as f64);
+ let g = &asset.geo;
+ let hand_w = (g.hand_max_x - g.hand_min_x) * iw;
+ let hand_h = (g.wrist - g.hand_top) * ih;
+ let mut side = hand_w.max(hand_h) * 1.06;
+ let cx = (g.hand_min_x + g.hand_max_x) / 2.0 * iw;
+ let cy = (g.hand_top + g.wrist) / 2.0 * ih;
+ let mut x0 = (cx - side / 2.0).max(0.0);
+ let mut y0 = (cy - side / 2.0).max(0.0);
+ if x0 + side > iw {
+ x0 = (iw - side).max(0.0);
+ }
+ if y0 + side > ih {
+ y0 = (ih - side).max(0.0);
+ }
+ side = side.min(
+ iw.min(ih) as f64
+ - if iw.min(ih) as f64 - x0.max(y0) < side {
+ (x0 - y0).abs()
+ } else {
+ 0.0
+ },
+ );
+ let side = side.max(1.0);
+
+ let mut thumb = tiny_skia::Pixmap::new(size, size).unwrap();
+ let dst = thumb.data_mut();
+ let scale = size as f64 / side;
+ for iy in 0..size {
+ for ix in 0..size {
+ let sx = (x0 + ix as f64 / scale) as usize;
+ let sy = (y0 + iy as f64 / scale) as usize;
+ let s = img.pixel(
+ (sx.min(iw as usize - 1)) as u32,
+ (sy.min(ih as usize - 1)) as u32,
+ );
+ if let Some(c) = s {
+ let o = ((iy * size + ix) * 4) as usize;
+ dst[o] = c.red();
+ dst[o + 1] = c.green();
+ dst[o + 2] = c.blue();
+ dst[o + 3] = c.alpha();
+ }
+ }
+ }
+ thumb
+}
diff --git a/linux/src/config.rs b/linux/src/config.rs
new file mode 100644
index 0000000..c582596
--- /dev/null
+++ b/linux/src/config.rs
@@ -0,0 +1,270 @@
+// Config file shared by the Rust app (X11) and the GNOME Wayland companion.
+// JSON schema:
+// {
+// "flag_hotkeys_warning_shown": false,
+// "pointer": { "style": "lavender", "size": 0.13, "shadow": true },
+// "ink": { "choice": "match", "fade": 3.0 },
+// "sound": { "hand": true, "laser": false, "tap": "thock" },
+// "interact":{ "key": "alt", "keepsHand": false },
+// "hotkeys": { "toggle": "ctrl+alt+h", "clear": "ctrl+alt+x", "cycle": "ctrl+alt+p" }
+// }
+
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+
+pub const KEYS: [&str; 8] = [
+ "red", "orange", "yellow", "green", "blue", "purple", "white", "black",
+];
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct PointerCfg {
+ pub style: String,
+ pub size: f64,
+ pub shadow: bool,
+}
+
+impl Default for PointerCfg {
+ fn default() -> Self {
+ Self {
+ style: "lavender".into(),
+ size: 0.13,
+ shadow: true,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct InkCfg {
+ pub choice: String, // "match" | red|orange|yellow|green|blue|purple|white|black
+ pub fade: f64,
+}
+
+impl Default for InkCfg {
+ fn default() -> Self {
+ Self {
+ choice: "match".into(),
+ fade: 3.0,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct SoundCfg {
+ pub hand: bool,
+ pub laser: bool,
+ pub tap: String, // thock|click|pop|wood|boop|squeak
+}
+
+impl Default for SoundCfg {
+ fn default() -> Self {
+ Self {
+ hand: true,
+ laser: false,
+ tap: "thock".into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct InteractCfg {
+ pub key: String, // alt|ctrl|super|off
+ pub keeps_hand: bool,
+}
+
+impl Default for InteractCfg {
+ fn default() -> Self {
+ Self {
+ key: "alt".into(),
+ keeps_hand: false,
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct HotkeysCfg {
+ pub toggle: String,
+ pub clear: String,
+ pub cycle: String,
+}
+
+impl Default for HotkeysCfg {
+ fn default() -> Self {
+ Self {
+ toggle: "ctrl+alt+h".into(),
+ clear: "ctrl+alt+x".into(),
+ cycle: "ctrl+alt+p".into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+#[serde(default, rename_all = "camelCase")]
+pub struct Config {
+ pub enabled: Option,
+ pub pointer: PointerCfg,
+ pub ink: InkCfg,
+ pub sound: SoundCfg,
+ pub interact: InteractCfg,
+ pub hotkeys: HotkeysCfg,
+ pub seen_welcome: bool,
+}
+
+pub fn config_path() -> PathBuf {
+ dirs::config_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join("tinyhand")
+ .join("config.json")
+}
+
+pub fn data_dir() -> PathBuf {
+ dirs::data_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join("tinyhand")
+}
+
+pub fn cache_dir() -> PathBuf {
+ dirs::cache_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join("tinyhand")
+}
+
+pub fn load() -> Config {
+ let path = config_path();
+ if let Ok(text) = std::fs::read_to_string(&path) {
+ match serde_json::from_str::(&text) {
+ Ok(v) => return v,
+ Err(e) => {
+ eprintln!("tinyhand: config parse failed, using defaults: {e}");
+ }
+ }
+ }
+ Config::default()
+}
+
+/// Reload must not replace live settings with defaults during a partial write.
+pub fn try_load() -> Result {
+ let text = std::fs::read_to_string(config_path()).map_err(|e| e.to_string())?;
+ serde_json::from_str(&text).map_err(|e| e.to_string())
+}
+
+pub fn ensure_exists() -> std::io::Result<()> {
+ ensure_at(&config_path())
+}
+
+fn ensure_at(path: &std::path::Path) -> std::io::Result<()> {
+ use std::io::Write;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ match std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(path)
+ {
+ Ok(mut file) => {
+ let text = serde_json::to_string_pretty(&Config::default())?;
+ file.write_all(text.as_bytes())
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
+ Err(e) => Err(e),
+ }
+}
+
+pub fn save(cfg: &Config) {
+ let path = config_path();
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ if let Ok(text) = serde_json::to_string_pretty(cfg) {
+ let _ = std::fs::write(&path, text);
+ }
+}
+
+fn find_asset_dir(relative_path: &str) -> Option {
+ let mut candidates = Vec::new();
+ let mut dirs = Vec::new();
+ if let Ok(exe) = std::env::current_exe() {
+ let mut d = exe.parent().map(|p| p.to_path_buf());
+ for _ in 0..6 {
+ if let Some(p) = d {
+ dirs.push(p.clone());
+ d = p.parent().map(|q| q.to_path_buf());
+ }
+ }
+ }
+ if let Ok(cwd) = std::env::current_dir() {
+ let mut d = Some(cwd);
+ for _ in 0..6 {
+ if let Some(p) = d {
+ dirs.push(p.clone());
+ d = p.parent().map(|q| q.to_path_buf());
+ }
+ }
+ }
+ // Data dir first (install layout), then exe/cwd ancestors (dev run + repo layout).
+ if let Some(data) = dirs::data_dir() {
+ candidates.push(data.join("tinyhand").join("assets"));
+ }
+ for d in dirs {
+ candidates.push(d.join("assets"));
+ candidates.push(d);
+ }
+ candidates.dedup();
+ for dir in candidates {
+ let f = dir.join(relative_path);
+ if f.exists() {
+ return Some(dir);
+ }
+ }
+ None
+}
+
+/// Resolve an asset lookup: {data_dir}/assets/.png, then any `assets/`
+/// dir found walking up from the executable or CWD (dev runs from linux/ or repo root).
+pub fn resolve_asset(name: &str) -> Option {
+ let relative_path = format!("{name}.png");
+ find_asset_dir(&relative_path).map(|d| d.join(relative_path))
+}
+
+pub fn resolve_sound(tap: &str) -> Option {
+ [format!("sounds/tap_{tap}.wav"), format!("tap_{tap}.wav")]
+ .into_iter()
+ .find_map(|relative_path| find_asset_dir(&relative_path).map(|d| d.join(relative_path)))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn bundled_tap_sounds_resolve_to_wav_files() {
+ for tap in ["thock", "click", "pop", "wood", "boop", "squeak"] {
+ let path = resolve_sound(tap).unwrap_or_else(|| panic!("Missing tap sound: {tap}"));
+ assert_eq!(path.extension().unwrap(), "wav");
+ assert!(path.is_file());
+ }
+ assert!(resolve_asset("hand_lavender").unwrap().is_file());
+ assert!(resolve_sound("nonexistent").is_none());
+ }
+
+ #[test]
+ fn defaults_are_created_only_when_absent() {
+ let dir = std::env::temp_dir().join(format!("tinyhand-config-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("config.json");
+ ensure_at(&path).unwrap();
+ let cfg: Config = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
+ assert_eq!(cfg.pointer.style, "lavender");
+ // Even malformed existing user data must never be overwritten.
+ std::fs::write(&path, b"user settings").unwrap();
+ ensure_at(&path).unwrap();
+ assert_eq!(std::fs::read(&path).unwrap(), b"user settings");
+ std::fs::remove_file(path).unwrap();
+ std::fs::remove_dir(dir).unwrap();
+ }
+}
diff --git a/linux/src/main.rs b/linux/src/main.rs
new file mode 100644
index 0000000..077e8c4
--- /dev/null
+++ b/linux/src/main.rs
@@ -0,0 +1,170 @@
+// TinyHand for Linux — entry point.
+//
+// Dead_code is allowed crate-wide: several modules carry parity-complete
+// helpers (thumbnails, text rasterizer, UI window plumbing) that the X11 app's
+// v1 builds its tray/selftest around but does not yet call directly.
+#![allow(dead_code)]
+
+mod app;
+mod asset;
+mod config;
+mod model;
+mod pose;
+mod render;
+mod sound;
+mod tray;
+mod x11;
+
+use std::path::PathBuf;
+
+const VERSION: &str = env!("CARGO_PKG_VERSION");
+
+fn usage() -> String {
+ "tinyhand — a tiny hand on a stick that points at things on your screen
+
+USAGE:
+ tinyhand run TinyHand (X11 full experience)
+ tinyhand --selftest render sample poses to /tmp/tinyhand-selftest/*.png (no X needed)
+ tinyhand --config print the config file path
+ tinyhand --version print version
+ tinyhand --help this text
+
+Hotkeys (default): Ctrl+Alt+H toggle · Ctrl+Alt+X clear drawing · Ctrl+Alt+P next hand
+Config: ~/.config/tinyhand/config.json
+GNOME Wayland: install the companion shell extension instead (see linux/README.md)"
+ .into()
+}
+
+fn session_is_x() -> Result<(), String> {
+ let display = std::env::var("DISPLAY").unwrap_or_default();
+ let wayland = std::env::var("WAYLAND_DISPLAY").is_ok();
+ if display.is_empty() {
+ if wayland {
+ return Err("This is a Wayland session without XWayland.\n\
+ TinyHand's native app needs X11. On GNOME, install the companion\n\
+ shell extension instead: linux/scripts/install.sh --extension\n\
+ (full guide: linux/README.md)"
+ .into());
+ }
+ return Err("DISPLAY is not set: TinyHand needs an X11 session.".into());
+ }
+ if wayland {
+ eprintln!(
+ "warning: running inside XWayland on Wayland.\n\
+ Global cursor hiding / click capture only affect X11 apps there.\n\
+ On GNOME, prefer the companion shell extension (linux/scripts/install.sh --extension)."
+ );
+ }
+ Ok(())
+}
+
+fn selftest() -> Result<(), String> {
+ let out_dir = PathBuf::from("/tmp/tinyhand-selftest");
+ std::fs::create_dir_all(&out_dir).map_err(|e| e.to_string())?;
+ let assets = asset::Assets::new();
+
+ let cases: &[(&str, (f64, f64), f64, bool)] = &[
+ ("lavender", (1350.0, 380.0), 0.13, false),
+ ("lavender", (300.0, 150.0), 0.13, true),
+ ("coral", (960.0, 540.0), 0.20, false),
+ ("robot", (200.0, 900.0), 0.10, false),
+ ("glove", (1700.0, 850.0), 0.15, true),
+ ];
+
+ for (i, (style, cursor, size, held)) in cases.iter().enumerate() {
+ let a = assets.get(style).ok_or(format!("asset {style} missing"))?;
+ let mut st = pose::PoseState::new(*size);
+ if *held {
+ // two ticks so press & easing settle like a real session
+ let _ = st.aim(*cursor, 1920.0, 1080.0, &a.geo, true);
+ }
+ let p = st.aim(*cursor, 1920.0, 1080.0, &a.geo, *held);
+ let (tx, ty) = p.tip_point(&a.geo);
+ if ((tx - cursor.0).abs() > 1.0) || ((ty - cursor.1).abs() > 1.0) {
+ return Err(format!(
+ "selftest: tip mismatch for {style}: tip=({tx:.1},{ty:.1}) cursor={cursor:?}"
+ ));
+ }
+
+ let mut pix = tiny_skia::Pixmap::new(1920, 1080).unwrap();
+ render::draw_shadow(&mut pix, &a, &p);
+ render::draw_hand(&mut pix, &a, &p, 1.0);
+ if i == 1 {
+ // also draw a sample stroke for the draw-mode check
+ render::draw_stroke(
+ &mut pix,
+ &[
+ (300.0, 150.0),
+ (380.0, 220.0),
+ (520.0, 240.0),
+ (700.0, 210.0),
+ ],
+ asset::ink_rgb(asset::InkChoice::Red),
+ 6.0,
+ 1.0,
+ );
+ }
+ // save as PNG (un-premultiply into straight RGBA)
+ let mut img = image::RgbaImage::new(pix.width(), pix.height());
+ for (x, y, o) in img.enumerate_pixels_mut() {
+ let px = pix.pixel(x, y).unwrap();
+ let a = px.alpha() as u32;
+ let (r, g, b) = if a == 0 {
+ (0u8, 0u8, 0u8)
+ } else {
+ let un = |v: u8| ((v as u32 * 255 + 127) / a.max(1)) as u8;
+ (un(px.red()), un(px.green()), un(px.blue()))
+ };
+ *o = image::Rgba([r, g, b, px.alpha()]);
+ }
+ let name = format!("{}-{}.png", style, i);
+ img.save(out_dir.join(&name)).map_err(|e| e.to_string())?;
+ println!("selftest: {name} tip=({tx:.1},{ty:.1}) match");
+ }
+ println!("selftest OK -> {}", out_dir.display());
+ Ok(())
+}
+
+fn main() {
+ let args: Vec = std::env::args().collect();
+ let arg = args.get(1).map(String::as_str).unwrap_or("run");
+ match arg {
+ "--help" | "-h" => {
+ println!("{}", usage());
+ }
+ "--version" | "-V" => {
+ println!("tinyhand {VERSION} (linux)");
+ }
+ "--config" | "--settings" => {
+ if let Err(e) = config::ensure_exists() {
+ eprintln!("tinyhand: could not create config: {e}");
+ std::process::exit(1);
+ }
+ println!("{}", config::config_path().display());
+ }
+ "--selftest" => match selftest() {
+ Ok(()) => {}
+ Err(e) => {
+ eprintln!("selftest failed: {e}");
+ std::process::exit(1);
+ }
+ },
+ "run" | "" => match session_is_x() {
+ Ok(()) => match app::App::init() {
+ Ok(app) => std::process::exit(app.run()),
+ Err(e) => {
+ eprintln!("tinyhand: {e}");
+ std::process::exit(1);
+ }
+ },
+ Err(e) => {
+ eprintln!("tinyhand: {e}");
+ std::process::exit(2);
+ }
+ },
+ other => {
+ eprintln!("tinyhand: unknown argument '{other}'\n\n{}", usage());
+ std::process::exit(1);
+ }
+ }
+}
diff --git a/linux/src/model.rs b/linux/src/model.rs
new file mode 100644
index 0000000..5212552
--- /dev/null
+++ b/linux/src/model.rs
@@ -0,0 +1,19 @@
+// Public data model shared between modules.
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct Screen {
+ pub x: i32,
+ pub y: i32,
+ pub width: i32,
+ pub height: i32,
+}
+
+impl Screen {
+ pub fn contains(&self, x: i32, y: i32) -> bool {
+ x >= self.x && x < self.x + self.width && y >= self.y && y < self.y + self.height
+ }
+ pub fn index_of(screens: &[Self], x: i32, y: i32) -> Option {
+ screens.iter().position(|s| s.contains(x, y))
+ }
+}
diff --git a/linux/src/pose.rs b/linux/src/pose.rs
new file mode 100644
index 0000000..4de4608
--- /dev/null
+++ b/linux/src/pose.rs
@@ -0,0 +1,248 @@
+// Port of TinyHand/ArmView.swift aiming math to top-left-origin (y-down) screen space.
+//
+// Original AppKit math uses y-up coordinates with the grip anchored below the screen
+// bottom edge. Here everything is y-down: the grip is anchored BELOW the visible
+// screen (screen_h * 1.12), and the rod points "up" toward the cursor.
+
+#[derive(Debug, Clone, Copy, Default)]
+pub struct HandGeometry {
+ pub aspect: f64, // image w / image h
+ pub hand_top: f64, // y fraction: top of the hand (finger)
+ pub tip: f64, // y fraction: exact tip of pointing finger
+ pub wrist: f64, // y fraction: bottom of fist
+ pub rod_end: f64, // y fraction: bottom of the rod
+ pub tip_x: f64, // x fraction: tip x center
+ pub rod_x: f64, // x fraction: rod x center
+ pub hand_min_x: f64,
+ pub hand_max_x: f64,
+}
+
+pub struct PoseState {
+ pub hand_len_frac: f64,
+ press_amount: f64,
+ grip_x: Option,
+ grip_target: Option,
+}
+
+pub const MIN_ROD_FRAC: f64 = 0.05;
+pub const GRIP_LEAN_FRAC: f64 = 0.30;
+pub const GRIP_DEPTH_FRAC: f64 = 0.12;
+pub const GRIP_FOLLOW: f64 = 0.08;
+pub const LIFT_FRAC: f64 = 0.06;
+pub const PRESS_SHRINK: f64 = 0.06;
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Pose {
+ pub rotation: f64,
+ pub rod_len: f64,
+ pub hand_pts: f64,
+ pub width_pts: f64,
+ pub grip_x: f64,
+ pub grip_y: f64,
+ pub lift: f64,
+ pub press: f64,
+ pub shadow_dx: f64,
+ pub shadow_dy: f64,
+}
+
+impl Pose {
+ /// World position of the fingertip given the pose (y-down coords).
+ /// Tip fractions are normalized by the hand span exactly like `aim` does.
+ pub fn tip_point(&self, g: &HandGeometry) -> (f64, f64) {
+ // hand_pts is the scaled span length (span * s); recover per-fraction pts.
+ let hand_tip_pts = (g.wrist - g.tip) / (g.wrist - g.hand_top) * self.hand_pts;
+ let tip_off_x = (g.tip_x - g.rod_x) * self.width_pts;
+ // Vector from grip along rod axis to the tip in y-down space.
+ // Local space: rod points up = (tipOffX, -(rodLen + handTipPts)).
+ let (vx, vy) = rotate(tip_off_x, -(self.rod_len + hand_tip_pts), self.rotation);
+ (self.grip_x + vx, self.grip_y + vy)
+ }
+}
+
+pub fn rotate(x: f64, y: f64, r: f64) -> (f64, f64) {
+ let (s, c) = r.sin_cos();
+ (x * c - y * s, x * s + y * c)
+}
+
+impl PoseState {
+ pub fn new(size_frac: f64) -> Self {
+ Self {
+ hand_len_frac: size_frac,
+ press_amount: 0.0,
+ grip_x: None,
+ grip_target: None,
+ }
+ }
+
+ pub fn press(&mut self) {
+ self.press_amount = 1.0;
+ }
+
+ pub fn release(&mut self) {
+ self.press_amount = 0.0;
+ }
+
+ fn current_lift(&self, screen_h: f64) -> f64 {
+ LIFT_FRAC * screen_h * (1.0 - 0.8 * self.press_amount)
+ }
+
+ pub fn press_amount(&self) -> f64 {
+ self.press_amount
+ }
+
+ pub fn is_settling(&self) -> bool {
+ if self.press_amount > 0.002 {
+ return true;
+ }
+ match (self.grip_x, self.grip_target) {
+ (Some(gx), Some(t)) => (t - gx).abs() > 0.5,
+ _ => false,
+ }
+ }
+
+ /// One animation tick. `held` = button currently pressed (or pen drag).
+ /// Returns None for laser (caller draws the dot itself).
+ pub fn aim(
+ &mut self,
+ cursor: (f64, f64),
+ screen_w: f64,
+ screen_h: f64,
+ g: &HandGeometry,
+ held: bool,
+ ) -> Pose {
+ let target_x =
+ (cursor.0 + GRIP_LEAN_FRAC * screen_w).clamp(screen_w * 0.15, screen_w * 1.05);
+ let gx = match self.grip_x {
+ None => target_x,
+ Some(px) => px + (target_x - px) * GRIP_FOLLOW,
+ };
+ self.grip_x = Some(gx);
+ self.grip_target = Some(target_x);
+
+ if !held {
+ self.press_amount += (0.0 - self.press_amount) * 0.18;
+ }
+
+ // y-down: grip anchored below screen bottom.
+ let grip = (gx, (1.0 + GRIP_DEPTH_FRAC) * screen_h);
+
+ let dx = cursor.0 - grip.0;
+ let dy = cursor.1 - grip.1;
+ let reach = (dx * dx + dy * dy).sqrt();
+ let aim_angle = dy.atan2(dx);
+
+ let hand_pts = self.hand_len_frac * screen_h * (1.0 - PRESS_SHRINK * self.press_amount);
+ let s = hand_pts / (g.wrist - g.hand_top);
+ let width_pts = s * g.aspect;
+ let hand_tip_pts = (g.wrist - g.tip) * s;
+ let tip_off_x = (g.tip_x - g.rod_x) * width_pts;
+
+ // Along-rod distance from grip to the tip's orthogonal foot.
+ let along = (0.0f64).max(reach * reach - tip_off_x * tip_off_x).sqrt();
+ let mut rod_len = along - hand_tip_pts;
+ rod_len = rod_len.max(MIN_ROD_FRAC * screen_h);
+ // Angle of the rod axis in y-down space: rod points "up" (negative y).
+ let tip_angle = (-(rod_len + hand_tip_pts)).atan2(tip_off_x);
+ let rotation = aim_angle - tip_angle;
+
+ let lift = self.current_lift(screen_h);
+ let sx = lift * 0.6;
+ let sy = lift; // shadow falls downward in y-down space
+ // Rotate with -rotation (same as AppKit's use of -rotation in y-up space).
+ let (sdx, sdy) = rotate(sx, sy, -rotation);
+
+ Pose {
+ rotation,
+ rod_len,
+ hand_pts,
+ width_pts,
+ grip_x: grip.0,
+ grip_y: grip.1,
+ lift,
+ press: self.press_amount,
+ shadow_dx: sdx,
+ shadow_dy: sdy,
+ }
+ }
+
+ /// Laser dot radius (y-down space), in pixels.
+ pub fn laser_radius(&self, screen_h: f64, press: f64) -> f64 {
+ let r = 4.0f64.max(screen_h * 0.0038 * self.hand_len_frac / 0.13) * (1.0 + 0.2 * press);
+ r
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn geo() -> HandGeometry {
+ HandGeometry {
+ aspect: 1024.0 / 1536.0,
+ hand_top: 0.02,
+ tip: 0.023,
+ wrist: 0.52,
+ rod_end: 1.0,
+ tip_x: 0.50,
+ rod_x: 0.52,
+ hand_min_x: 0.1,
+ hand_max_x: 0.9,
+ }
+ }
+
+ #[test]
+ fn tip_lands_on_cursor() {
+ let mut st = PoseState::new(0.13);
+ let g = geo();
+ let cursor = (900.0, 500.0);
+ let pose = st.aim(cursor, 1920.0, 1080.0, &g, false);
+ let (tx, ty) = pose.tip_point(&g);
+ assert!(
+ (tx - cursor.0).abs() < 0.5 && (ty - cursor.1).abs() < 0.5,
+ "tip=({tx:.2},{ty:.2}) cursor={cursor:?} pose={pose:?}"
+ );
+ }
+
+ #[test]
+ fn tip_lands_on_cursor_second_click() {
+ let mut st = PoseState::new(0.13);
+ let g = geo();
+ for cursor in [
+ (100.0, 100.0),
+ (1800.0, 900.0),
+ (960.0, 540.0),
+ (50.0, 1000.0),
+ ] {
+ let pose = st.aim(cursor, 1920.0, 1080.0, &g, false);
+ let (tx, ty) = pose.tip_point(&g);
+ assert!(
+ (tx - cursor.0).abs() < 0.5 && (ty - cursor.1).abs() < 0.5,
+ "tip=({tx:.2},{ty:.2}) cursor={cursor:?}"
+ );
+ st.press();
+ let _ = st.aim(cursor, 1920.0, 1080.0, &g, true);
+ }
+ }
+
+ #[test]
+ fn tip_left_of_rod_axis() {
+ // Asymmetric tip (tipX != rodX) must still land on the cursor.
+ let mut st = PoseState::new(0.15);
+ let mut g = geo();
+ g.tip_x = 0.60;
+ let cursor = (400.0, 300.0);
+ let pose = st.aim(cursor, 1920.0, 1080.0, &g, false);
+ let (tx, ty) = pose.tip_point(&g);
+ assert!((tx - cursor.0).abs() < 0.5 && (ty - cursor.1).abs() < 0.5);
+ }
+
+ #[test]
+ fn press_squeezes_hand() {
+ let mut st = PoseState::new(0.13);
+ let g = geo();
+ let a = st.aim((500.0, 500.0), 1920.0, 1080.0, &g, false);
+ st.press();
+ let b = st.aim((500.0, 500.0), 1920.0, 1080.0, &g, true);
+ assert!(b.hand_pts < a.hand_pts);
+ }
+}
diff --git a/linux/src/render.rs b/linux/src/render.rs
new file mode 100644
index 0000000..9edb135
--- /dev/null
+++ b/linux/src/render.rs
@@ -0,0 +1,539 @@
+// Frame renderer: rotated hand blits, shadow, laser dot, ink strokes, simple text.
+// All coordinates are y-down screen pixels of the overlay's monitor.
+
+use crate::asset::HandAsset;
+use crate::pose::{rotate, Pose};
+
+pub struct Canvas<'a> {
+ pub pix: &'a mut tiny_skia::Pixmap,
+}
+
+impl<'a> Canvas<'a> {
+ pub fn rect_fill(&mut self, rect: tiny_skia::Rect, color: [f32; 4]) {
+ let mut paint = tiny_skia::Paint::default();
+ paint.set_color_rgba8(
+ (color[0] * 255.0) as u8,
+ (color[1] * 255.0) as u8,
+ (color[2] * 255.0) as u8,
+ (color[3] * 255.0) as u8,
+ );
+ paint.anti_alias = true;
+ self.pix
+ .fill_rect(rect, &paint, tiny_skia::Transform::default(), None);
+ }
+}
+
+fn over(dst: &mut [u8], src: [f32; 4]) {
+ let da = dst[3] as f32 / 255.0;
+ let sa = src[3].clamp(0.0, 1.0);
+ let oa = sa + da * (1.0 - sa);
+ if oa <= 0.0001 {
+ dst[0] = 0;
+ dst[1] = 0;
+ dst[2] = 0;
+ dst[3] = 0;
+ return;
+ }
+ for i in 0..3 {
+ // Both the sampled source and tiny-skia destination are premultiplied.
+ let s = src[i].clamp(0.0, sa);
+ let d = dst[i] as f32 / 255.0;
+ let v = s + d * (1.0 - sa);
+ dst[i] = (v * 255.0).clamp(0.0, 255.0).round() as u8;
+ }
+ dst[3] = (oa * 255.0).clamp(0.0, 255.0).round() as u8;
+}
+
+#[inline]
+fn sample_bilinear(pix: &tiny_skia::Pixmap, fx: f64, fy: f64) -> Option<[f32; 4]> {
+ let w = pix.width() as i64;
+ let h = pix.height() as i64;
+ if w == 0 || h == 0 {
+ return None;
+ }
+ let x0 = (fx.floor() as i64).clamp(0, w - 1);
+ let y0 = (fy.floor() as i64).clamp(0, h - 1);
+ let tx = (fx - x0 as f64).clamp(0.0, 1.0) as f32;
+ let ty = (fy - y0 as f64).clamp(0.0, 1.0) as f32;
+ let data = pix.data();
+ let mut acc = [0f32; 4];
+ for (dy, wy) in [(0i64, 1.0f32 - ty), (1i64, ty)] {
+ for (dx, wx) in [(0i64, 1.0f32 - tx), (1i64, tx)] {
+ let x = (x0 + dx).clamp(0, w - 1);
+ let y = (y0 + dy).clamp(0, h - 1);
+ let o = ((y * w + x) * 4) as usize;
+ let weight = wy * wx;
+ acc[0] += (data[o] as f32 / 255.0) * weight;
+ acc[1] += (data[o + 1] as f32 / 255.0) * weight;
+ acc[2] += (data[o + 2] as f32 / 255.0) * weight;
+ acc[3] += (data[o + 3] as f32 / 255.0) * weight;
+ }
+ }
+ Some(acc)
+}
+
+/// Rotated slice blit of the hand asset: draws rod + hand portions around the grip.
+pub fn draw_hand(pix: &mut tiny_skia::Pixmap, asset: &HandAsset, pose: &Pose, extra_alpha: f64) {
+ let src = &asset.pixmap;
+ let g = &asset.geo;
+ let iw = src.width() as f64;
+ let ih = src.height() as f64;
+
+ let hw = pose.width_pts;
+ let rod_top_local = -(pose.rod_len + pose.hand_pts);
+ // local y ranges: hand [rod_top_local, -rod_len), rod [-rod_len, 0)
+ // local x: [-hw/2, hw/2]
+
+ // corner local points of the whole group (hand top edge to grip bottom)
+ let l = [
+ (-hw / 2.0, rod_top_local),
+ (hw / 2.0, rod_top_local),
+ (hw / 2.0, 0.0),
+ (-hw / 2.0, 0.0),
+ ];
+ let mut min_x = f64::MAX;
+ let mut min_y = f64::MAX;
+ let mut max_x = f64::MIN;
+ let mut max_y = f64::MIN;
+ for (lx, ly) in l {
+ let (wx, wy) = rotate(lx, ly, pose.rotation);
+ let wx = wx + pose.grip_x;
+ let wy = wy + pose.grip_y;
+ min_x = min_x.min(wx);
+ max_x = max_x.max(wx);
+ min_y = min_y.min(wy);
+ max_y = max_y.max(wy);
+ }
+
+ let pad = 2.0;
+ let x0 = (min_x - pad).floor().max(0.0) as i32;
+ let y0 = (min_y - pad).floor().max(0.0) as i32;
+ let x1 = (max_x + pad).ceil().min(pix.width() as f64 - 1.0) as i32;
+ let y1 = (max_y + pad).ceil().min(pix.height() as f64 - 1.0) as i32;
+
+ let cos = pose.rotation.cos();
+ let sin = pose.rotation.sin();
+ let alpha = extra_alpha.clamp(0.0, 1.0);
+
+ let dst_w = pix.width() as i32;
+ let data = pix.data_mut();
+ for py in y0..=y1 {
+ for px in x0..=x1 {
+ // map world -> local (inverse rotate), relative to grip
+ let dx = px as f64 + 0.5 - pose.grip_x;
+ let dy = py as f64 + 0.5 - pose.grip_y;
+ let lx = cos * dx + sin * dy;
+ let ly = -sin * dx + cos * dy;
+ if lx < -hw / 2.0 - 0.5 || lx > hw / 2.0 + 0.5 {
+ continue;
+ }
+ let (in_hand, y_frac) = if ly < -pose.rod_len {
+ if ly < rod_top_local {
+ continue;
+ }
+ (true, (ly - rod_top_local) / pose.hand_pts)
+ } else {
+ if ly >= 0.0 {
+ continue;
+ }
+ (false, (ly + pose.rod_len) / pose.rod_len)
+ };
+ let u = lx / hw + 0.5; // 0..1 across full image width
+ let src_x_f = (g.rod_x + u - 0.5) * iw; // full image width maps to local width
+ let src_y_f = if in_hand {
+ (g.hand_top + y_frac * (g.wrist - g.hand_top)) * ih
+ } else {
+ (g.wrist + y_frac * (g.rod_end - g.wrist)) * ih
+ };
+ if let Some(s) = sample_bilinear(src, src_x_f, src_y_f) {
+ let mul = alpha; // f64
+ let sa = [
+ (s[0] as f64 * mul) as f32,
+ (s[1] as f64 * mul) as f32,
+ (s[2] as f64 * mul) as f32,
+ ((s[3] as f64) * mul) as f32,
+ ];
+ if sa[3] <= 0.0005 {
+ continue;
+ }
+ let o = ((py * dst_w + px) * 4) as usize;
+ over(&mut data[o..o + 4], sa);
+ }
+ }
+ }
+}
+
+/// Rotated blit of the precomputed blurred silhouette (drop shadow).
+pub fn draw_shadow(pix: &mut tiny_skia::Pixmap, asset: &HandAsset, pose: &Pose) {
+ let src = &asset.silhouette;
+ let g = &asset.geo;
+ let iw = src.width() as f64;
+ let ih = src.height() as f64;
+
+ let hw = pose.width_pts;
+ let rod_top_local = -(pose.rod_len + pose.hand_pts);
+
+ let l = [
+ (-hw / 2.0, rod_top_local),
+ (hw / 2.0, rod_top_local),
+ (hw / 2.0, 0.0),
+ (-hw / 2.0, 0.0),
+ ];
+ let mut min_x = f64::MAX;
+ let mut min_y = f64::MAX;
+ let mut max_x = f64::MIN;
+ let mut max_y = f64::MIN;
+ for (lx, ly) in l {
+ let (wx, wy) = rotate(lx, ly, pose.rotation);
+ let wx = wx + pose.grip_x + pose.shadow_dx;
+ let wy = wy + pose.grip_y + pose.shadow_dy;
+ min_x = min_x.min(wx);
+ max_x = max_x.max(wx);
+ min_y = min_y.min(wy);
+ max_y = max_y.max(wy);
+ }
+
+ let pad = 2.0;
+ let x0 = (min_x - pad).floor().max(0.0) as i32;
+ let y0 = (min_y - pad).floor().max(0.0) as i32;
+ let x1 = (max_x + pad).ceil().min(pix.width() as f64 - 1.0) as i32;
+ let y1 = (max_y + pad).ceil().min(pix.height() as f64 - 1.0) as i32;
+ let _ = y1;
+
+ let cos = pose.rotation.cos();
+ let sin = pose.rotation.sin();
+
+ let dst_w = pix.width() as i32;
+ let data = pix.data_mut();
+ for py in y0..=y1 {
+ for px in x0..=x1 {
+ let dx = px as f64 + 0.5 - pose.grip_x - pose.shadow_dx;
+ let dy = py as f64 + 0.5 - pose.grip_y - pose.shadow_dy;
+ let lx = cos * dx + sin * dy;
+ let ly = -sin * dx + cos * dy;
+ if lx < -hw / 2.0 || lx > hw / 2.0 {
+ continue;
+ }
+ if ly >= 0.0 || ly < rod_top_local {
+ continue;
+ }
+ let src_x_f = (g.rod_x + lx / hw) * iw;
+ // Match the hand's independently scaled hand and rod slices.
+ let src_y_f = if ly < -pose.rod_len {
+ let fraction = (ly - rod_top_local) / pose.hand_pts;
+ (g.hand_top + fraction * (g.wrist - g.hand_top)) * ih
+ } else {
+ let fraction = (ly + pose.rod_len) / pose.rod_len;
+ (g.wrist + fraction * (g.rod_end - g.wrist)) * ih
+ };
+ if let Some(s) = sample_bilinear(src, src_x_f, src_y_f) {
+ let op = 0.26 + 0.02 * pose.press;
+ let sa = [0.0, 0.0, 0.0, (((s[3] as f64) * op).min(1.0)) as f32];
+ let o = ((py * dst_w + px) * 4) as usize;
+ over(&mut data[o..o + 4], sa);
+ }
+ }
+ }
+}
+
+/// Laser dot: red glow ring + bright core.
+pub fn draw_laser(pix: &mut tiny_skia::Pixmap, cursor: (f64, f64), r: f64) {
+ if r < 1.0 {
+ return;
+ }
+ let (cx, cy) = (cursor.0 as f32, cursor.1 as f32);
+ let r = r as f32;
+ // glow
+ let r_outer = (r * 3.2).max(2.0);
+ let rect = tiny_skia::Rect::from_xywh(cx - r_outer, cy - r_outer, 2.0 * r_outer, 2.0 * r_outer)
+ .unwrap();
+ let stops = vec![
+ tiny_skia::GradientStop::new(0.0, tiny_skia::Color::from_rgba8(255, 40, 30, 110)),
+ tiny_skia::GradientStop::new(0.35, tiny_skia::Color::from_rgba8(255, 40, 30, 70)),
+ tiny_skia::GradientStop::new(1.0, tiny_skia::Color::from_rgba8(255, 40, 30, 0)),
+ ];
+ if let Some(shader) = tiny_skia::RadialGradient::new(
+ tiny_skia::Point::from_xy(cx, cy),
+ tiny_skia::Point::from_xy(cx, cy),
+ r_outer,
+ stops,
+ tiny_skia::SpreadMode::Pad,
+ tiny_skia::Transform::default(),
+ ) {
+ let mut paint = tiny_skia::Paint::default();
+ paint.anti_alias = true;
+ paint.shader = shader;
+ pix.fill_rect(rect, &paint, tiny_skia::Transform::default(), None);
+ }
+
+ // ring
+ let mut path = tiny_skia::PathBuilder::new();
+ path.push_circle(cx, cy, r);
+ let path = path.finish().unwrap();
+ let mut paint = tiny_skia::Paint::default();
+ paint.anti_alias = true;
+ paint.set_color_rgba8(255, 40, 30, 255);
+ let stroke = tiny_skia::Stroke {
+ width: (r * 0.12).max(1.0),
+ line_cap: tiny_skia::LineCap::Round,
+ line_join: tiny_skia::LineJoin::Round,
+ ..tiny_skia::Stroke::default()
+ };
+ pix.stroke_path(
+ &path,
+ &paint,
+ &stroke,
+ tiny_skia::Transform::default(),
+ None,
+ );
+
+ // core
+ let core = r * 0.45;
+ let mut core_path = tiny_skia::PathBuilder::new();
+ core_path.push_circle(cx, cy, core);
+ let core_path = core_path.finish().unwrap();
+ let mut core_paint = tiny_skia::Paint::default();
+ core_paint.anti_alias = true;
+ core_paint.set_color_rgba8(255, 219, 209, 242);
+ pix.fill_path(
+ &core_path,
+ &core_paint,
+ tiny_skia::FillRule::Winding,
+ tiny_skia::Transform::default(),
+ None,
+ );
+}
+
+/// Build a smoothed stroke path (midpoint quadratic technique, port of Ink.redraw()).
+pub fn stroke_path(points: &[(f64, f64)]) -> Option {
+ if points.is_empty() {
+ return None;
+ }
+ let mut pb = tiny_skia::PathBuilder::new();
+ let first = points[0];
+ pb.move_to(first.0 as f32, first.1 as f32);
+ if points.len() == 1 {
+ pb.line_to(first.0 as f32, first.1 as f32);
+ }
+ for i in 1..points.len() {
+ let p = points[i];
+ let prev = points[i - 1];
+ let mid = ((prev.0 + p.0) / 2.0, (prev.1 + p.1) / 2.0);
+ pb.quad_to(prev.0 as f32, prev.1 as f32, mid.0 as f32, mid.1 as f32);
+ }
+ if points.len() > 1 {
+ let last = points[points.len() - 1];
+ pb.line_to(last.0 as f32, last.1 as f32);
+ }
+ pb.finish()
+}
+
+pub fn draw_stroke(
+ pix: &mut tiny_skia::Pixmap,
+ points: &[(f64, f64)],
+ color: [f32; 4],
+ width: f64,
+ alpha: f64,
+) {
+ let path = attach_stroke(points);
+ if let Some(path) = path {
+ let mut paint = tiny_skia::Paint::default();
+ paint.anti_alias = true;
+ let a = (color[3] as f64 * alpha).min(1.0) as f32;
+ paint.set_color_rgba8(
+ (color[0] * 255.0) as u8,
+ (color[1] * 255.0) as u8,
+ (color[2] * 255.0) as u8,
+ (a * 255.0) as u8,
+ );
+ let stroke = tiny_skia::Stroke {
+ width: width.max(1.0) as f32,
+ line_cap: tiny_skia::LineCap::Round,
+ line_join: tiny_skia::LineJoin::Round,
+ ..tiny_skia::Stroke::default()
+ };
+ pix.stroke_path(
+ &path,
+ &paint,
+ &stroke,
+ tiny_skia::Transform::default(),
+ None,
+ );
+ }
+}
+
+fn attach_stroke(points: &[(f64, f64)]) -> Option {
+ stroke_path(points)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn source_over_keeps_premultiplied_channels() {
+ let mut dst = [0, 0, 128, 128];
+ over(&mut dst, [0.5, 0.0, 0.0, 0.5]);
+ assert_eq!(dst, [128, 0, 64, 192]);
+ let mut transparent = [0; 4];
+ over(&mut transparent, [0.25, 0.0, 0.0, 0.5]);
+ assert_eq!(transparent, [64, 0, 0, 128]);
+ }
+
+ #[test]
+ fn shadow_uses_hand_and_rod_slices_and_hand_opacity_is_premultiplied() {
+ let mut src = tiny_skia::Pixmap::new(8, 100).unwrap();
+ // Only the hand slice is opaque. A whole-image stretch would produce a
+ // long shadow well into the rod instead of stopping at the wrist.
+ for y in 10..30 {
+ for x in 0..8 {
+ let offset = (y * 8 + x) * 4;
+ src.data_mut()[offset..offset + 4].copy_from_slice(&[128, 0, 0, 128]);
+ }
+ }
+ let asset = HandAsset {
+ name: "test".into(),
+ geo: crate::pose::HandGeometry {
+ hand_top: 0.1,
+ wrist: 0.3,
+ rod_end: 0.9,
+ rod_x: 0.5,
+ ..Default::default()
+ },
+ silhouette: src.clone(),
+ pixmap: src,
+ };
+ let pose = Pose {
+ rotation: 0.0,
+ rod_len: 80.0,
+ hand_pts: 20.0,
+ width_pts: 8.0,
+ grip_x: 10.0,
+ grip_y: 110.0,
+ lift: 0.0,
+ press: 0.0,
+ shadow_dx: 0.0,
+ shadow_dy: 0.0,
+ };
+ let mut shadow = tiny_skia::Pixmap::new(24, 120).unwrap();
+ draw_shadow(&mut shadow, &asset, &pose);
+ assert!(shadow.pixel(10, 20).unwrap().alpha() > 0);
+ assert_eq!(shadow.pixel(10, 45).unwrap().alpha(), 0);
+ let mut hand = tiny_skia::Pixmap::new(24, 120).unwrap();
+ draw_hand(&mut hand, &asset, &pose, 0.5);
+ let pixel = hand.pixel(10, 20).unwrap();
+ assert_eq!((pixel.red(), pixel.alpha()), (64, 64));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Text: ab_glyph rasterization straight into pixels. Decoration only; falls
+// back to no-op when no system font exists.
+// ---------------------------------------------------------------------------
+
+pub struct Text {
+ font: Option>,
+}
+
+impl Text {
+ pub fn new() -> Self {
+ let font_path = [
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
+ "/usr/share/fonts/dejavu/DejaVuSans.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
+ "/usr/share/fonts/truetype/freefont/FreeSans.ttf",
+ ]
+ .iter()
+ .find_map(|p| std::fs::read(p).ok());
+ let font = font_path.and_then(|bytes| {
+ let leaked: &'static [u8] = Box::leak(bytes.into_boxed_slice());
+ ab_glyph::FontRef::try_from_slice(leaked).ok()
+ });
+ Self { font }
+ }
+
+ pub fn width(&self, text: &str, px: f32) -> f32 {
+ use ab_glyph::{Font as _, ScaleFont as _};
+ match &self.font {
+ None => text.chars().count() as f32 * px * 0.6,
+ Some(f) => {
+ let scaled = f.clone().into_scaled(ab_glyph::PxScale::from(px));
+ let mut w = 0f32;
+ for ch in text.chars() {
+ let id = f.glyph_id(ch);
+ w += scaled.h_advance(id);
+ }
+ w
+ }
+ }
+ }
+
+ pub fn draw(
+ &self,
+ pix: &mut tiny_skia::Pixmap,
+ text: &str,
+ px: f32,
+ x: f64,
+ y: f64,
+ color: [f32; 4],
+ center: bool,
+ ) {
+ use ab_glyph::{Font as _, ScaleFont as _};
+ let font = match &self.font {
+ Some(f) => f.clone(),
+ None => return,
+ };
+ let scaled = font.clone().into_scaled(ab_glyph::PxScale::from(px));
+ let mut sx = x as f32
+ - if center {
+ self.width(text, px) / 2.0
+ } else {
+ 0.0
+ };
+ let baseline = y as f32 + px * 0.8;
+ for ch in text.chars() {
+ let id = font.glyph_id(ch);
+ let glyph = id.with_scale_and_position(px, ab_glyph::Point { x: sx, y: baseline });
+ if let Some(outlined) = scaled.outline_glyph(glyph) {
+ let (r, g, b) = (
+ (color[0] * 255.0) as u8,
+ (color[1] * 255.0) as u8,
+ (color[2] * 255.0) as u8,
+ );
+ let base = color[3].clamp(0.0, 1.0);
+ let bounds = outlined.px_bounds();
+ let ox = bounds.min.x;
+ let oy = bounds.min.y;
+ outlined.draw(|gx, gy, v| {
+ if v <= 0.003 {
+ return;
+ }
+ let xd = (ox + gx as f32).floor() as i32;
+ let yd = (oy + gy as f32).floor() as i32;
+ if xd < 0 || yd < 0 {
+ return;
+ }
+ let xd = xd as u32;
+ let yd = yd as u32;
+ if yd >= pix.height() || xd >= pix.width() {
+ return;
+ }
+ let o = ((yd * pix.width() + xd) * 4) as usize;
+ // alpha blend v over existing pixel
+ let data = pix.data_mut();
+ let sa = base * v;
+ over(
+ &mut data[o..o + 4],
+ [
+ r as f32 / 255.0 * sa,
+ g as f32 / 255.0 * sa,
+ b as f32 / 255.0 * sa,
+ sa,
+ ],
+ );
+ });
+ }
+ sx += scaled.h_advance(id);
+ }
+ }
+}
diff --git a/linux/src/sound.rs b/linux/src/sound.rs
new file mode 100644
index 0000000..ddc59a6
--- /dev/null
+++ b/linux/src/sound.rs
@@ -0,0 +1,36 @@
+// Tap sounds via paplay (PulseAudio/PipeWire surely present on the user's desktop).
+
+use crate::config;
+use std::process::{Command, Stdio};
+
+pub fn play(tap: &str) {
+ if let Some(path) = config::resolve_sound(tap) {
+ // Spawn and wait on a worker so playback never blocks the X11 loop and
+ // every successfully spawned child is reaped.
+ let _ = std::thread::Builder::new()
+ .name("tinyhand-sound".into())
+ .spawn(move || {
+ if let Ok(mut child) = Command::new("paplay")
+ .arg(path)
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .spawn()
+ {
+ let _ = child.wait();
+ }
+ });
+ }
+}
+
+pub fn play_hand(cfg: &config::SoundCfg) {
+ if cfg.hand {
+ play(&cfg.tap);
+ }
+}
+
+pub fn play_laser(cfg: &config::SoundCfg) {
+ if cfg.laser {
+ play("boop");
+ }
+}
diff --git a/linux/src/tray.rs b/linux/src/tray.rs
new file mode 100644
index 0000000..9e08291
--- /dev/null
+++ b/linux/src/tray.rs
@@ -0,0 +1,128 @@
+// StatusNotifier tray via ksni (pure-Rust zbus; no C deps).
+
+use crate::config::Config;
+use std::sync::mpsc::Sender;
+use std::sync::{Arc, Mutex};
+
+#[derive(Debug, Clone, Copy)]
+pub enum Cmd {
+ Toggle,
+ NextHand,
+ Clear,
+ Quit,
+}
+
+pub struct Shared {
+ pub cfg: Mutex,
+ pub tx: Sender,
+}
+
+struct TinyTray {
+ shared: Arc,
+}
+
+impl ksni::Tray for TinyTray {
+ fn id(&self) -> String {
+ "tinyhand".into()
+ }
+
+ fn icon_name(&self) -> String {
+ "tinyhand".into()
+ }
+
+ fn title(&self) -> String {
+ "TinyHand".into()
+ }
+
+ fn menu(&self) -> Vec> {
+ use ksni::menu::*;
+ let enabled = self
+ .shared
+ .cfg
+ .lock()
+ .map(|c| c.enabled.unwrap_or(true))
+ .unwrap_or(true);
+ let mk = |send: Cmd| -> Box {
+ let tx = self.shared.tx.clone();
+ Box::new(move |_: &mut Self| {
+ let _ = tx.send(send);
+ })
+ };
+ vec![
+ CheckmarkItem {
+ label: "Show pointer".into(),
+ checked: enabled,
+ activate: mk(Cmd::Toggle),
+ ..Default::default()
+ }
+ .into(),
+ StandardItem {
+ label: "Next hand".into(),
+ activate: mk(Cmd::NextHand),
+ ..Default::default()
+ }
+ .into(),
+ StandardItem {
+ label: "Clear drawing".into(),
+ activate: mk(Cmd::Clear),
+ ..Default::default()
+ }
+ .into(),
+ MenuItem::Separator,
+ StandardItem {
+ label: "Quit".into(),
+ activate: mk(Cmd::Quit),
+ ..Default::default()
+ }
+ .into(),
+ ]
+ }
+}
+
+pub struct Tray {
+ handle: Option>,
+ enabled: bool,
+}
+
+impl Tray {
+ pub fn spawn(cfg: Config, tx: Sender) -> Self {
+ let enabled = cfg.enabled.unwrap_or(true);
+ let shared = Arc::new(Shared {
+ cfg: Mutex::new(cfg),
+ tx,
+ });
+ let svc = TinyTray { shared };
+ use ksni::blocking::TrayMethods;
+ let handle = match svc.spawn() {
+ Ok(handle) => Some(handle),
+ Err(error) => {
+ eprintln!("tinyhand: tray unavailable (hotkeys still work): {error}");
+ None
+ }
+ };
+ Self { handle, enabled }
+ }
+
+ pub fn sync(&mut self, cfg: &Config) {
+ let enabled = cfg.enabled.unwrap_or(true);
+ if self.enabled == enabled {
+ return;
+ }
+ self.enabled = enabled;
+ if let Some(handle) = &self.handle {
+ handle.update(move |tray| {
+ if let Ok(mut cfg) = tray.shared.cfg.lock() {
+ cfg.enabled = Some(enabled);
+ }
+ });
+ }
+ }
+}
+
+impl Drop for Tray {
+ fn drop(&mut self) {
+ if let Some(handle) = &self.handle {
+ handle.shutdown();
+ }
+ }
+}
diff --git a/linux/src/x11.rs b/linux/src/x11.rs
new file mode 100644
index 0000000..2f7611f
--- /dev/null
+++ b/linux/src/x11.rs
@@ -0,0 +1,476 @@
+// X11 toolbox (x11rb only, no system C deps): connection, monitors, pointer query,
+// cursor hiding, input capture with pass-through replay, passive hotkey grabs,
+// ARGB overlay windows with empty input shape, and chunked frame uploads.
+//
+// The full TinyHand experience requires a real X11 (Xorg) session. On GNOME Wayland
+// the companion shell extension provides the same UX instead.
+
+use crate::model::Screen;
+use std::time::Duration;
+use x11rb::connection::Connection;
+use x11rb::protocol::shape::ConnectionExt as _;
+use x11rb::protocol::shape::SK;
+use x11rb::protocol::shape::SO;
+use x11rb::protocol::xfixes::ConnectionExt as _;
+use x11rb::protocol::xproto::*;
+use x11rb::protocol::xtest::ConnectionExt as _;
+use x11rb::rust_connection::RustConnection;
+use x11rb::CURRENT_TIME;
+
+pub struct X {
+ pub conn: RustConnection,
+ pub screen_num: usize,
+ pub root: u32,
+ pub screens: Vec,
+ pub root_w: u32,
+ pub root_h: u32,
+ argb_visual: Visualid,
+ argb_cmap: Colormap,
+ pub grabbed: bool,
+ pub cursor_hidden: bool,
+ gcs: std::collections::HashMap,
+}
+
+pub struct PtrEvent {
+ pub kind: PtrEventKind,
+ pub detail: u8,
+ pub x: i32,
+ pub y: i32,
+ pub mods: u16,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum PtrEventKind {
+ Press,
+ Release,
+ Motion,
+}
+
+impl X {
+ pub fn connect() -> Result {
+ let (conn, screen_num) = x11rb::connect(None).map_err(|e| e.to_string())?;
+ // HideCursor/ShowCursor require XFixes 4 and version negotiation first.
+ let fixes = conn
+ .xfixes_query_version(4, 0)
+ .map_err(|e| e.to_string())?
+ .reply()
+ .map_err(|e| e.to_string())?;
+ if fixes.major_version < 4 {
+ return Err("TinyHand requires XFixes 4 for cursor hiding".into());
+ }
+ let (root, root_w, root_h, argb_visual) = {
+ let setup = conn.setup();
+ let screen = &setup.roots[screen_num];
+ let root = screen.root;
+
+ // Locate a depth-32 (ARGB) visual; required for a translucent overlay.
+ let mut argb: Option = None;
+ for d in &screen.allowed_depths {
+ if d.depth != 32 {
+ continue;
+ }
+ for v in &d.visuals {
+ if v.red_mask == 0xff0000 && v.green_mask == 0xff00 && v.blue_mask == 0xff {
+ argb = Some(v.visual_id);
+ break;
+ }
+ }
+ }
+ let argb =
+ argb.ok_or("no 32-bit RGBA visual: a composited desktop is required for TinyHand")?;
+ (
+ root,
+ screen.width_in_pixels as u32,
+ screen.height_in_pixels as u32,
+ argb,
+ )
+ };
+
+ let argb_cmap = conn.generate_id().map_err(|e| e.to_string())?;
+ conn.create_colormap(ColormapAlloc::NONE, argb_cmap, root, argb_visual)
+ .map_err(|e| e.to_string())?;
+ conn.flush().map_err(|e| e.to_string())?;
+
+ // Monitors via Xinerama; fall back to the root screen as one monitor.
+ let mut screens = Vec::new();
+ if let Ok(reply) = x11rb::protocol::xinerama::query_screens(&conn) {
+ if let Ok(r) = reply.reply() {
+ for s in &r.screen_info {
+ screens.push(Screen {
+ x: s.x_org as i32,
+ y: s.y_org as i32,
+ width: s.width as i32,
+ height: s.height as i32,
+ });
+ }
+ }
+ }
+ if screens.is_empty() {
+ screens.push(Screen {
+ x: 0,
+ y: 0,
+ width: root_w as i32,
+ height: root_h as i32,
+ });
+ }
+
+ Ok(Self {
+ conn,
+ screen_num,
+ root,
+ root_w,
+ root_h,
+ screens,
+ argb_visual,
+ argb_cmap,
+ grabbed: false,
+ cursor_hidden: false,
+ gcs: std::collections::HashMap::new(),
+ })
+ }
+
+ pub fn screen_at(&self, x: i32, y: i32) -> Screen {
+ crate::model::Screen::index_of(&self.screens, x, y)
+ .and_then(|i| self.screens.get(i).cloned())
+ .unwrap_or_else(|| self.screens[0].clone())
+ }
+
+ // -- pointer ---------------------------------------------------------
+
+ /// Root pointer position + modifier/button mask (Button1Mask bit 8, Mod1=Alt bit 3,
+ /// Control bit 2, Shift bit 0, Super (Mod4) bit 6).
+ pub fn pointer(&self) -> (i32, i32, u16) {
+ match self.conn.query_pointer(self.root) {
+ Ok(cookie) => match cookie.reply() {
+ Ok(r) => (r.root_x as i32, r.root_y as i32, u16::from(r.mask)),
+ Err(_) => (0, 0, 0),
+ },
+ Err(_) => (0, 0, 0),
+ }
+ }
+
+ // -- cursor hiding -----------------------------------------------------
+
+ pub fn hide_cursor(&mut self) {
+ if !self.cursor_hidden {
+ self.cursor_hidden = self
+ .conn
+ .xfixes_hide_cursor(self.root)
+ .ok()
+ .is_some_and(|cookie| cookie.check().is_ok());
+ }
+ }
+
+ pub fn show_cursor(&mut self) {
+ if self.cursor_hidden {
+ if self
+ .conn
+ .xfixes_show_cursor(self.root)
+ .ok()
+ .is_some_and(|cookie| cookie.check().is_ok())
+ {
+ self.cursor_hidden = false;
+ }
+ }
+ }
+
+ // -- input capture ------------------------------------------------------
+
+ pub fn grab_pointer(&mut self) {
+ if self.grabbed {
+ return;
+ }
+ let mask = EventMask::BUTTON_PRESS | EventMask::BUTTON_RELEASE | EventMask::POINTER_MOTION;
+ self.grabbed = self
+ .conn
+ .grab_pointer(
+ false,
+ self.root,
+ mask,
+ GrabMode::ASYNC,
+ GrabMode::ASYNC,
+ self.root,
+ 0u32, // no cursor
+ CURRENT_TIME,
+ )
+ .ok()
+ .and_then(|cookie| cookie.reply().ok())
+ .is_some_and(|reply| reply.status == GrabStatus::SUCCESS);
+ let _ = self.conn.flush();
+ }
+
+ pub fn ungrab_pointer(&mut self) {
+ if !self.grabbed {
+ return;
+ }
+ let _ = self.conn.ungrab_pointer(CURRENT_TIME);
+ let _ = self.conn.flush();
+ self.grabbed = false;
+ }
+
+ // -- synthetic input (XTest) ---------------------------------------------
+
+ pub fn fake_button_down(&self, button: u8) {
+ let _ = self.conn.xtest_fake_input(
+ BUTTON_PRESS_EVENT,
+ button,
+ 0u32,
+ self.root,
+ 0i16,
+ 0i16,
+ 0u8,
+ );
+ let _ = self.conn.flush();
+ }
+
+ pub fn fake_button_up(&self, button: u8) {
+ let _ = self.conn.xtest_fake_input(
+ BUTTON_RELEASE_EVENT,
+ button,
+ 0u32,
+ self.root,
+ 0i16,
+ 0i16,
+ 0u8,
+ );
+ let _ = self.conn.flush();
+ }
+
+ // -- hotkeys ---------------------------------------------------------------
+
+ /// combos are (keycode, ModMask-bits).
+ pub fn grab_hotkeys(&self, combos: &[(u8, u16)]) {
+ for (key, mods) in combos {
+ for extra in [0u16, 2, 16, 18] {
+ // 2=NumLock(Mod2), 16=CapsLock(LOCK), 18=both
+ let mask_mods = ModMask::from(u16::from(*mods) | extra);
+ let _ = self.conn.grab_key(
+ false,
+ self.root,
+ mask_mods,
+ *key,
+ GrabMode::ASYNC,
+ GrabMode::ASYNC,
+ );
+ }
+ }
+ let _ = self.conn.flush();
+ }
+
+ pub fn ungrab_keys(&self) {
+ let _ = self.conn.ungrab_key(0u8, self.root, ModMask::ANY);
+ let _ = self.conn.flush();
+ }
+
+ /// Resolve a keysym (single lowercase letter or digit as ASCII) to a keycode.
+ pub fn keycode_for_keysym(&self, keysym: u32) -> Option {
+ let setup = self.conn.setup();
+ let min = setup.min_keycode;
+ let max = setup.max_keycode;
+ if max < min {
+ return None;
+ }
+ let count = (max - min + 1).min(200) as u8;
+ let reply = self
+ .conn
+ .get_keyboard_mapping(min, count)
+ .ok()?
+ .reply()
+ .ok()?;
+ let per = reply.keysyms_per_keycode as usize;
+ if per == 0 {
+ return None;
+ }
+ let cols = reply.keysyms.len() / per;
+ for i in 0..cols.min(count as usize) {
+ for k in 0..per {
+ if reply.keysyms[i * per + k] == keysym {
+ return Some(min + i as u8);
+ }
+ }
+ }
+ None
+ }
+
+ // -- windows -----------------------------------------------------------------
+
+ pub fn create_overlay_win(&self, w: u32, h: u32) -> Result {
+ let win = self.conn.generate_id().map_err(|e| e.to_string())?;
+ self.conn
+ .create_window(
+ 32,
+ win,
+ self.root,
+ 0,
+ 0,
+ w.max(1) as u16,
+ h.max(1) as u16,
+ 0,
+ WindowClass::INPUT_OUTPUT,
+ self.argb_visual,
+ &CreateWindowAux::new()
+ .colormap(self.argb_cmap)
+ .override_redirect(1u32),
+ )
+ .map_err(|e| e.to_string())?;
+
+ // Empty input shape => all pointer input passes through to windows below.
+ let _ =
+ self.conn
+ .shape_rectangles(SO::SET, SK::INPUT, ClipOrdering::UNSORTED, win, 0, 0, &[]);
+ self.conn.flush().map_err(|e| e.to_string())?;
+ Ok(win)
+ }
+
+ /// A UI window we own input for (tray popovers are elsewhere; this is reserved
+ /// for future toolbar/settings panes).
+ pub fn create_ui_win(&self, w: u32, h: u32) -> Result {
+ let win = self.conn.generate_id().map_err(|e| e.to_string())?;
+ self.conn
+ .create_window(
+ 32,
+ win,
+ self.root,
+ 0,
+ 0,
+ w.max(1) as u16,
+ h.max(1) as u16,
+ 0,
+ WindowClass::INPUT_OUTPUT,
+ self.argb_visual,
+ &CreateWindowAux::new()
+ .colormap(self.argb_cmap)
+ .override_redirect(0u32),
+ )
+ .map_err(|e| e.to_string())?;
+ self.conn.flush().map_err(|e| e.to_string())?;
+ Ok(win)
+ }
+
+ pub fn move_win(&self, win: u32, x: i32, y: i32, w: u32, h: u32) {
+ let _ = self.conn.configure_window(
+ win,
+ &ConfigureWindowAux::new()
+ .x(x as i32)
+ .y(y as i32)
+ .width(w.max(1) as u32)
+ .height(h.max(1) as u32),
+ );
+ let _ = self.conn.flush();
+ }
+
+ pub fn map_win(&self, win: u32) {
+ let _ = self.conn.map_window(win);
+ self.raise(win);
+ }
+
+ pub fn unmap_win(&self, win: u32) {
+ let _ = self.conn.unmap_window(win);
+ let _ = self.conn.flush();
+ }
+
+ pub fn raise(&self, win: u32) {
+ let _ = self
+ .conn
+ .configure_window(win, &ConfigureWindowAux::new().stack_mode(StackMode::ABOVE));
+ let _ = self.conn.flush();
+ }
+
+ fn gc_for(&mut self, win: u32) -> u32 {
+ if let Some(gc) = self.gcs.get(&win) {
+ return *gc;
+ }
+ let gc = self.conn.generate_id().unwrap_or(0);
+ let _ = self.conn.create_gc(gc, win, &CreateGCAux::new());
+ self.gcs.insert(win, gc);
+ gc
+ }
+
+ /// Upload a region of an RGBA8 (tiny-skia byte order) pixmap to an ARGB window.
+ /// Swizzles R<->B and chunks the upload by row bands to respect request size
+ /// limits. `region` = (x, y, w, h); None for the whole pixmap.
+ pub fn put_region(
+ &mut self,
+ win: u32,
+ pix: &tiny_skia::Pixmap,
+ region: Option<(i32, i32, u32, u32)>,
+ ) {
+ let pw = pix.width();
+ let ph = pix.height();
+ let (rx, ry, rw, rh) = region.unwrap_or((0, 0, pw, ph));
+ let x = rx.clamp(0, pw as i32 - 1);
+ let y = ry.clamp(0, ph as i32 - 1);
+ let w = rw.min(pw - x.max(0) as u32);
+ let h = rh.min(ph - y.max(0) as u32);
+ if w == 0 || h == 0 {
+ return;
+ }
+ let gc = self.gc_for(win);
+ let src = pix.data();
+ // Band size: cap ~512KB per request.
+ let rows_per_band = ((512 * 1024) / (w as usize * 4)).clamp(1, 1024) as u32;
+ let mut band = vec![0u8; (w * rows_per_band * 4) as usize];
+ let mut yy = y as i32;
+ let bottom = y as i32 + h as i32;
+ while yy < bottom {
+ let remaining = bottom - yy;
+ let rows = (rows_per_band as i32).min(remaining).max(0) as u32;
+ if rows == 0 {
+ break;
+ }
+ for r in 0..rows {
+ let src_row = ((yy as u32 + r) as usize) * pw as usize * 4 + (x as usize * 4);
+ let dst_row = (r * w * 4) as usize;
+ for px in 0..w as usize {
+ let so = src_row + px * 4;
+ let doff = dst_row + px * 4;
+ // tiny-skia bytes: R G B A (premul). X ARGB32 little-endian: B G R A.
+ band[doff] = src[so + 2];
+ band[doff + 1] = src[so + 1];
+ band[doff + 2] = src[so];
+ band[doff + 3] = src[so + 3];
+ }
+ }
+ let _ = self.conn.put_image(
+ ImageFormat::Z_PIXMAP,
+ win,
+ gc,
+ w as u16,
+ rows as u16,
+ x as i16,
+ yy as i16,
+ 0,
+ 32,
+ &band[..(rows * w * 4) as usize],
+ );
+ yy += rows as i32;
+ }
+ let _ = self.conn.flush();
+ }
+
+ pub fn flush(&self) {
+ let _ = self.conn.flush();
+ }
+
+ /// Wait for an event, polling with a short sleep (x11rb 0.14 dropped the
+ /// blocking-with-timeout API). Returns None once the deadline passes.
+ pub fn wait_event(
+ &self,
+ timeout: Option,
+ ) -> Result