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, String> { + let deadline = timeout + .map(|t| std::time::Instant::now() + t) + .unwrap_or_else(std::time::Instant::now); + loop { + match self.conn.poll_for_event() { + Ok(Some(ev)) => return Ok(Some(ev)), + Ok(None) => {} + Err(e) => return Err(e.to_string()), + } + if std::time::Instant::now() >= deadline { + return Ok(None); + } + std::thread::sleep(Duration::from_millis(2)); + } + } +} diff --git a/linux/tests/companion-render.mjs b/linux/tests/companion-render.mjs new file mode 100644 index 0000000..3105fa8 --- /dev/null +++ b/linux/tests/companion-render.mjs @@ -0,0 +1,52 @@ +// Real GJS/Cairo asset smoke test, without enabling a Shell extension. +// Run from repository root: gjs -m linux/tests/companion-render.mjs +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; +import GdkPixbuf from 'gi://GdkPixbuf'; +import cairo from 'cairo'; + +const root = ARGV[0] ?? GLib.get_current_dir(); +const extensionRoot = ARGV[1] ?? `${root}/linux/companion/tinyhand@abdulrehmankhan56`; +const assetsRoot = ARGV[1] ? `${extensionRoot}/assets` : `${root}/assets`; +const sourcePath = `${extensionRoot}/extension.js`; +const [, bytes] = GLib.file_get_contents(sourcePath); +const source = new TextDecoder().decode(bytes) + .replace(/^import .*;\n/gm, '') + .replace('export default class TinyHandExtension', 'class TinyHandExtension'); +const stage = {width: 1920, height: 1080}; +const TinyHand = new Function('Extension', 'GLib', 'Gio', 'GdkPixbuf', 'cairo', 'global', + `${source}\nreturn TinyHandExtension;`)(class {}, GLib, Gio, GdkPixbuf, cairo, {stage}); +const ext = new TinyHand(); +ext._assetDir = assetsRoot; +ext._assets = new Map(); +ext._geometry = {}; +// Cache writes are optional; keep this test from changing the user's config/cache. +ext._geometryPath = '/dev/null/tinyhand-test.json'; +ext._config = {pointer: {style: 'lavender', size: 0.13, shadow: true}, ink: {fade: 3}}; +ext._last = [900, 500]; +ext._gripX = 1476; +ext._press = 0; +ext._active = null; +ext._strokes = [{points: [[100, 100], [200, 150], [300, 120]], color: [1, 0, 0], + start: GLib.get_monotonic_time() / 1e6}]; + +const [, files] = GLib.spawn_sync(null, ['find', assetsRoot, '-maxdepth', '1', '-name', 'hand_*.png'], + null, GLib.SpawnFlags.SEARCH_PATH, null); +const styles = new TextDecoder().decode(files).trim().split('\n') + .map(path => GLib.path_get_basename(path).replace(/^hand_|\.png$/g, '')); +if (styles.length !== 36) throw new Error(`Expected 36 hands, got ${styles.length}`); +for (const style of styles) { + ext._config.pointer.style = style; + if (!ext._asset(style)) throw new Error(`Cannot load ${style}`); + const surface = new cairo.ImageSurface(cairo.Format.ARGB32, stage.width, stage.height); + const cr = new cairo.Context(surface); + try { + ext._paintInk(cr); + ext._paintHand(cr); + ext._paintLaser(cr); + } finally { + cr.$dispose(); + } + ext._assets.clear(); +} +print(`PASS: ${styles.length} hands, shadows, ink, and laser rendered with GJS/Cairo (not live Shell).`); diff --git a/linux/tests/companion.test.cjs b/linux/tests/companion.test.cjs new file mode 100644 index 0000000..c922560 --- /dev/null +++ b/linux/tests/companion.test.cjs @@ -0,0 +1,242 @@ +// Offline logic checks; these do not replace testing inside GNOME Shell. +const {test} = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const root = path.join(__dirname, '../companion/tinyhand@abdulrehmankhan56'); +const rawSource = fs.readFileSync(path.join(root, 'extension.js'), 'utf8'); +const source = rawSource + .replace(/^import .*;\n/gm, '') + .replace('export default class TinyHandExtension', 'class TinyHandExtension'); +let disk = {}; +let pointer = [100, 100, 0]; +const context = vm.createContext({ + Extension: class {}, TextDecoder, console, + Main: {overview: {visible: false}}, + global: {stage: {width: 1920, height: 1080}, display: {focus_window: null}, + get_pointer: () => pointer}, + GLib: {get_monotonic_time: () => 10e6, + file_get_contents: () => [true, Buffer.from(JSON.stringify(disk))]}, +}); +vm.runInContext(`${source}\nthis.TinyHand = TinyHandExtension; this.mergeConfig = merge;`, context); + +test('metadata uses the GNOME integer version format', () => { + const metadata = JSON.parse(fs.readFileSync(path.join(root, 'metadata.json'))); + assert.equal(Number.isInteger(metadata.version), true); + assert.ok(metadata['shell-version'].includes('50')); +}); + +test('Shell entry point never imports GTK libraries', () => { + assert.ok(!/from ['"]gi:\/\/(Gdk(?:[?'\"])|Gtk|Adw)/.test(rawSource)); +}); + +test('partial config falls back safely and clamps hand size', () => { + disk = {pointer: {size: 999}, ink: null, enabled: null, custom: 'preserve'}; + const config = new context.TinyHand()._readConfig(); + assert.equal(config.pointer.size, 0.20); + assert.equal(config.pointer.style, 'lavender'); + assert.equal(config.ink.fade, 3); + assert.equal(config.enabled, true); + assert.equal(config.custom, 'preserve'); +}); + +test('merge preserves unknown nested settings without sharing defaults', () => { + const base = {pointer: {style: 'robot', custom: 7}}; + const merged = context.mergeConfig(base, {pointer: {style: 'laser'}}); + assert.equal(merged.pointer.style, 'laser'); + assert.equal(merged.pointer.custom, 7); + assert.equal(base.pointer.style, 'robot'); +}); + +test('ink holds, fades, expires, and supports permanent strokes', () => { + const ext = new context.TinyHand(); + ext._config = {ink: {fade: 3}}; + ext._active = null; + const stroke = {start: 0}; + assert.equal(ext._alpha(stroke, 2), 1); + assert.ok(Math.abs(ext._alpha(stroke, 3.3) - 0.5) < 1e-8); + assert.equal(ext._alpha(stroke, 4), 0); + ext._config.ink.fade = 0; + assert.equal(ext._alpha(stroke, 1000), 1); +}); + +test('style cycling wraps through pinned hands and laser', () => { + const ext = new context.TinyHand(); + ext._config = {pointer: {style: 'robot'}}; + ext._update = patch => { ext._config.pointer.style = patch.pointer.style; }; + ext._cycle(); + assert.equal(ext._config.pointer.style, 'laser'); + ext._cycle(); + assert.equal(ext._config.pointer.style, 'lavender'); +}); + +function tickingExtension(mode) { + const ext = new context.TinyHand(); + ext._config = {enabled: true, pointer: {style: 'lavender'}, ink: {choice: 'match', fade: 3}, + interaction: {mode, hideCursor: true}, sound: {hand: false, laser: false}}; + ext._gripX = null; + ext._last = null; + ext._held = false; + ext._press = 0; + ext._strokes = []; + ext._active = null; + ext._dirty = true; + ext._area = {reactive: false, queue_repaint() {}}; + ext._overControls = () => false; + ext._hideSystemCursor = hide => { ext.cursorHidden = hide; }; + return ext; +} + +test('Point mode never draws or captures normal application clicks', () => { + pointer = [100, 100, 0x100]; + const ext = tickingExtension('point'); + ext._tick(); + assert.equal(ext._area.reactive, false); + assert.equal(ext._strokes.length, 0); + assert.equal(ext.cursorHidden, true); +}); + +test('Draw mode captures pointer input; Alt releases it and restores cursor', () => { + pointer = [100, 100, 0x100]; + const ext = tickingExtension('draw'); + ext._tick(); + assert.equal(ext._area.reactive, true); + assert.equal(ext._strokes.length, 1); + assert.equal(ext.cursorHidden, true); + pointer = [120, 120, 0x108]; + ext._tick(); + assert.equal(ext._area.reactive, false); + assert.equal(ext._active, null); + assert.equal(ext.cursorHidden, false); +}); + +for (const [name, appId, title, captured] of [ + ['native TinyHand preferences', 'org.gnome.Shell.Extensions', 'TinyHand Settings', false], + ['standalone TinyHand settings', 'io.github.AbdulRehman56.TinyHand.Settings', 'TinyHand Settings', false], + ['another extension preferences window', 'org.gnome.Shell.Extensions', 'Other Settings', true], + ['unrelated app with the same title', 'org.example.Editor', 'TinyHand Settings', true], +]) { + test(`Draw mode handles focused ${name}`, () => { + context.global.display.focus_window = {get_gtk_application_id: () => appId, get_title: () => title}; + try { + pointer = [100, 100, 0x100]; + const ext = tickingExtension('draw'); + ext._tick(); + assert.equal(ext._area.reactive, captured); + assert.equal(ext.cursorHidden, captured); + assert.equal(ext._strokes.length, captured ? 1 : 0); + } finally { + context.global.display.focus_window = null; + } + }); +} + +test('cursor visibility inhibition is balanced, including repeated ticks', () => { + const ext = new context.TinyHand(); + let hides = 0, restores = 0; + ext._cursorHidden = false; + ext._cursorTracker = {inhibit_cursor_visibility() { hides++; }, + uninhibit_cursor_visibility() { restores++; }}; + ext._hideSystemCursor(true); + ext._hideSystemCursor(true); + ext._hideSystemCursor(false); + ext._hideSystemCursor(false); + assert.equal(hides, 1); + assert.equal(restores, 1); +}); + +// Shapes come from the versioned Mutter headers linked in COMPATIBILITY.md. +// These exercise our adapter, not a live compositor or those upstream APIs. +function versionFixture(version, initiallyVisible = true) { + let visible = initiallyVisible; + let inhibits = 0; + const calls = []; + const tracker = {get_pointer_visible: () => visible}; + if (version >= 49) { + tracker.inhibit_cursor_visibility = () => { inhibits++; }; + tracker.uninhibit_cursor_visibility = () => { inhibits--; }; + } else { + tracker.set_pointer_visible = value => { visible = value; }; + } + const display = {get_sound_player: () => ({play_from_file: (...args) => calls.push(args)})}; + const backend = version >= 48 ? {get_cursor_tracker: () => tracker} : {}; + const Meta = {CursorTracker: version < 48 ? {get_for_display: value => { + assert.equal(value, display); + return tracker; + }} : {}}; + const context = vm.createContext({Extension: class {}, console, TextDecoder, Meta, + Main: {}, global: {display, backend}, + GLib: {build_filenamev: paths => paths.join('/'), file_test: () => true, + FileTest: {IS_REGULAR: 1}}, + Gio: {File: {new_for_path: filePath => ({path: filePath})}}, + }); + vm.runInContext(`${source}\nthis.TinyHand = TinyHandExtension;`, context); + const ext = new context.TinyHand(); + ext._cursorHidden = false; + ext._soundCancellable = {cancelled: false, cancel() { this.cancelled = true; }}; + return {ext, tracker, calls, context, visible: () => visible, inhibits: () => inhibits}; +} + +for (const version of [45, 46, 47, 48, 49, 50]) { + test(`GNOME ${version} API shape: lookup, cursor restoration, sound and disable`, () => { + const fixture = versionFixture(version); + const {ext, tracker, calls} = fixture; + ext._cursorTracker = ext._getCursorTracker(); + assert.equal(ext._cursorTracker, tracker); + ext._hideSystemCursor(true); + ext._hideSystemCursor(true); + assert.equal(version >= 49 ? fixture.inhibits() : fixture.visible(), version >= 49 ? 1 : false); + ext._assetDir = '/bundled/assets'; + ext._config = {pointer: {style: 'lavender'}, sound: {hand: true, tap: 'thock'}}; + const cancellable = ext._soundCancellable; + ext._tap(); + assert.equal(calls.length, 1); + assert.equal(calls[0][0].path, '/bundled/assets/sounds/tap_thock.wav'); + assert.equal(calls[0][1], 'TinyHand tap'); + assert.equal(calls[0][2], cancellable); + ext.disable(); + ext.disable(); + assert.equal(fixture.inhibits(), 0); + assert.equal(fixture.visible(), true); + assert.equal(cancellable.cancelled, true); + assert.equal(ext._cursorTracker, null); + }); +} + +test('legacy cursor restoration preserves a cursor already hidden by another client', () => { + const fixture = versionFixture(45, false); + fixture.ext._cursorTracker = fixture.ext._getCursorTracker(); + fixture.ext._hideSystemCursor(true); + fixture.ext._hideSystemCursor(false); + assert.equal(fixture.visible(), false); +}); + +test('absent cursor and sound APIs degrade without preventing drawing', () => { + const fixture = versionFixture(50); + fixture.context.global.backend = {}; + fixture.context.Meta.CursorTracker = {}; + fixture.context.global.display = {}; + fixture.ext._cursorTracker = fixture.ext._getCursorTracker(); + assert.equal(fixture.ext._cursorTracker, null); + fixture.ext._hideSystemCursor(true); + assert.equal(fixture.ext._cursorHidden, false); + fixture.ext._assetDir = '/bundled/assets'; + fixture.ext._config = {pointer: {style: 'lavender'}, sound: {hand: true, tap: 'thock'}}; + assert.doesNotThrow(() => fixture.ext._tap()); +}); + +const preferencesSource = fs.readFileSync(path.join(root, 'preferences.js'), 'utf8') + .replace(/^import .*;\n/gm, '') + .replace('export function buildPreferences', 'function buildPreferences') + .replaceAll('import.meta.url', "'file:///test/preferences.js'"); +for (const [name, Gtk, Adw] of [ + ['GTK before 4.8', {check_version: () => 'GTK too old'}, {SwitchRow: class {}}], + ['libadwaita before 1.4', {check_version: () => null}, {}], +]) { + test(`preferences explain missing minimum dependency: ${name}`, () => { + const build = new Function('Gtk', 'Adw', `${preferencesSource}\nreturn buildPreferences;`)(Gtk, Adw); + assert.throws(() => build({}), /GTK 4\.8.*libadwaita 1\.4/); + }); +} diff --git a/linux/tests/preferences-layout.mjs b/linux/tests/preferences-layout.mjs new file mode 100644 index 0000000..d85175b --- /dev/null +++ b/linux/tests/preferences-layout.mjs @@ -0,0 +1,121 @@ +// Run from the repo root with a temporary XDG_CONFIG_HOME, on a graphical desktop. +import Gtk from 'gi://Gtk?version=4.0'; +import Adw from 'gi://Adw?version=1'; +import GLib from 'gi://GLib'; +import System from 'system'; +import {buildPreferences} from '../companion/tinyhand@abdulrehmankhan56/preferences.js'; + +const app = new Adw.Application({application_id: 'io.github.AbdulRehman56.TinyHand.LayoutTest'}); +let failed = false; +function assert(condition, message) { + if (!condition) throw new Error(message); +} +function descendants(root) { + const result = [root]; + for (let child = root.get_first_child(); child; child = child.get_next_sibling()) + result.push(...descendants(child)); + return result; +} +app.connect('activate', () => { + const window = new Adw.PreferencesWindow({application: app, search_enabled: false}); + const cleanup = buildPreferences(window); + window.opacity = ARGV[0] ? 1 : 0; + window.present(); + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 700, () => { + let checkingPages = false; + try { + const widgets = descendants(window); + const sidebar = widgets.find(widget => widget instanceof Gtk.StackSidebar); + assert(sidebar && sidebar.get_height() >= 250, 'Sidebar is vertically clipped'); + const names = descendants(sidebar).filter(widget => widget instanceof Gtk.Label) + .map(widget => widget.label); + for (const name of ['General', 'Pointers', 'Appearance', 'Drawing', 'Sound']) + assert(names.includes(name), `Missing sidebar section: ${name}`); + for (const label of descendants(sidebar).filter(widget => widget instanceof Gtk.Label && names.includes(widget.label))) + assert(label.get_height() >= 16, `Clipped navigation label: ${label.label}`); + const gallery = widgets.find(widget => widget instanceof Gtk.FlowBox && + widget.get_child_at_index(36)); + assert(gallery, 'Missing 37-pointer gallery'); + const positions = [0, 1, 2].map(index => gallery.get_child_at_index(index).get_allocation().x); + assert(new Set(positions).size === 3, 'Gallery does not fit three columns'); + const selected = descendants(gallery).find(widget => widget instanceof Gtk.ToggleButton && widget.active); + assert(selected, 'Pointer selection has no active card'); + for (const child of descendants(selected).filter(widget => widget instanceof Gtk.Picture || widget instanceof Gtk.Label)) + assert(child.get_width() > 0 && child.get_height() > 0, 'Selected card content is not allocated'); + const stack = widgets.find(widget => widget instanceof Gtk.Stack && widget.get_child_by_name('pointers')); + assert(stack, 'Missing settings section stack'); + for (const name of ['general', 'pointers', 'appearance', 'drawing', 'sound']) + assert(stack.get_child_by_name(name), `Missing settings content: ${name}`); + // Only exercise writes when the caller explicitly isolates config. + // Running this test against the real desktop remains read-only. + const configDir = GLib.get_user_config_dir(); + if (configDir.startsWith(`${GLib.get_tmp_dir()}/`) && + GLib.path_get_basename(configDir).startsWith('tinyhand-layout.')) { + const configPath = `${GLib.get_user_config_dir()}/tinyhand/config.json`; + const read = () => new TextDecoder().decode(GLib.file_get_contents(configPath)[1]); + GLib.file_set_contents(configPath, JSON.stringify({custom: {keep: 42}, pointer: {style: 'lavender'}})); + gallery.get_child_at_index(1).get_child().emit('clicked'); + let saved = JSON.parse(read()); + assert(saved.pointer.style === 'glove' && saved.custom.keep === 42, 'Gallery did not preserve settings'); + const mode = widgets.find(widget => widget instanceof Adw.ComboRow && widget.title === 'Pointer mode'); + mode.selected = 1; + assert(JSON.parse(read()).interaction.mode === 'draw', 'Draw mode was not saved'); + const cursor = widgets.find(widget => widget instanceof Adw.SwitchRow && widget.title === 'Hide system cursor'); + cursor.active = false; + assert(JSON.parse(read()).interaction.hideCursor === false, 'Cursor visibility was not saved'); + const valid = read(); + GLib.file_set_contents(configPath, '{'); + gallery.get_child_at_index(2).get_child().emit('clicked'); + assert(read() === '{', 'UI overwrote malformed JSON'); + GLib.file_set_contents(configPath, valid); + print('PASS: hand selection, interaction mode, cursor switch, unknown-key preservation, and malformed-file protection.'); + } + if (ARGV[0]) { + const workspace = stack.get_parent(); + const paintable = new Gtk.WidgetPaintable({widget: workspace}); + const snapshot = new Gtk.Snapshot(); + paintable.snapshot(snapshot, workspace.get_width(), workspace.get_height()); + const node = snapshot.to_node(); + assert(node, 'Cannot snapshot settings workspace'); + window.get_renderer().render_texture(node, null).save_to_png(ARGV[0]); + } + print(`PASS: five sidebar sections visible; sidebar ${sidebar.get_width()}x${sidebar.get_height()}; 37 pointers in at least three columns.`); + const pageNames = ['general', 'appearance', 'drawing', 'sound']; + let index = 0; + stack.set_visible_child_name(pageNames[index]); + checkingPages = true; + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + try { + const page = stack.get_visible_child(); + assert(page.get_width() >= 400 && page.get_height() >= 400, + `Settings section is cramped or unallocated: ${pageNames[index]}`); + print(`PASS: ${pageNames[index]} allocated ${page.get_width()}x${page.get_height()}.`); + index++; + if (index < pageNames.length) { + stack.set_visible_child_name(pageNames[index]); + return GLib.SOURCE_CONTINUE; + } + } catch (error) { + failed = true; + printerr(error.stack); + } + cleanup(); + window.close(); + app.quit(); + return GLib.SOURCE_REMOVE; + }); + } catch (error) { + failed = true; + printerr(error.stack); + } finally { + if (!checkingPages) { + cleanup(); + window.close(); + app.quit(); + } + } + return GLib.SOURCE_REMOVE; + }); +}); +app.run([]); +if (failed) System.exit(1); From 86eb6f2b841b23283e972b8b6ef411f0e158cd32 Mon Sep 17 00:00:00 2001 From: Abdul Rehman Khan <99591456+AbdulRehman56@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:14:30 +0500 Subject: [PATCH 2/3] fix(linux): prepare GNOME-compatible release metadata --- .github/workflows/linux.yml | 2 +- linux/COMPATIBILITY.md | 2 +- linux/CONTRIBUTING.md | 14 ++++++++------ linux/RELEASE_NOTES.md | 16 +++++++++++++--- linux/UPSTREAM_PR.md | 2 +- linux/VERSION | 1 + .../tinyhand@abdulrehmankhan56/metadata.json | 4 ++-- linux/scripts/package.sh | 6 ++++++ linux/scripts/publish.sh | 6 +++--- linux/tests/companion.test.cjs | 1 + 10 files changed, 37 insertions(+), 17 deletions(-) create mode 100644 linux/VERSION diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index cff1470..44b9f5b 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -110,7 +110,7 @@ jobs: 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$(cat linux/VERSION)" [[ "$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." diff --git a/linux/COMPATIBILITY.md b/linux/COMPATIBILITY.md index 0dd9328..cbdafe7 100644 --- a/linux/COMPATIBILITY.md +++ b/linux/COMPATIBILITY.md @@ -71,7 +71,7 @@ assumed compatible with older Shell preferences hosts. 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 + name and `linux/VERSION` 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 diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md index ff4bceb..b04c022 100644 --- a/linux/CONTRIBUTING.md +++ b/linux/CONTRIBUTING.md @@ -75,7 +75,7 @@ remain the original project's decision. 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 +The initial prerelease tag was `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. @@ -86,9 +86,11 @@ The prepared publication can be run with `./linux/scripts/publish.sh` from a 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 +workflow. It leaves an existing release unchanged. The tag comes from `linux/VERSION` +with a `v` prefix. GNOME's human-readable `version-name` permits only letters, +digits, spaces and dots, not semver hyphens. Update `linux/VERSION`, that display +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. +The [GNOME Extensions listing](https://extensions.gnome.org/extension/11682/tinyhand-for-gnome/) +is submitted and awaiting review. Do not add an approved badge or claim store +installation availability before approval and publication. diff --git a/linux/RELEASE_NOTES.md b/linux/RELEASE_NOTES.md index b13daa1..75d8041 100644 --- a/linux/RELEASE_NOTES.md +++ b/linux/RELEASE_NOTES.md @@ -1,13 +1,23 @@ # Linux release notes -## v1.0.2-linux.1 — experimental prerelease +## v1.0.2-linux.2 — GNOME submission compatibility + +- Separate the human-readable GNOME version (`1.0.2 Linux 2`) from the GitHub + prerelease tag in `linux/VERSION`. GNOME rejects hyphens in its version-name field. +- Packaging and regression checks now enforce the store's version-name character set. +- No runtime behavior changes. The earlier `v1.0.2-linux.1` artifacts remain unchanged. +- Submitted to [GNOME Extensions](https://extensions.gnome.org/extension/11682/tinyhand-for-gnome/), + [review 78099](https://extensions.gnome.org/review/78099). Status at submission: **Unreviewed**, + not approved. Store download/install availability depends on GNOME review. + +## v1.0.2-linux.1 — initial 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. +changing source assets. The follow-up `v1.0.2-linux.2` was submitted to GNOME Extensions; +see its review status above. Submission is not approval. 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 diff --git a/linux/UPSTREAM_PR.md b/linux/UPSTREAM_PR.md index 5af0e29..a908006 100644 --- a/linux/UPSTREAM_PR.md +++ b/linux/UPSTREAM_PR.md @@ -4,6 +4,6 @@ The macOS sources and original assets are unchanged. Linux code lives in `linux/ 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. +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](https://extensions.gnome.org/extension/11682/tinyhand-for-gnome/) is awaiting review, not approved. 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/VERSION b/linux/VERSION new file mode 100644 index 0000000..84c9dad --- /dev/null +++ b/linux/VERSION @@ -0,0 +1 @@ +1.0.2-linux.2 diff --git a/linux/companion/tinyhand@abdulrehmankhan56/metadata.json b/linux/companion/tinyhand@abdulrehmankhan56/metadata.json index d403471..d7c3d4f 100644 --- a/linux/companion/tinyhand@abdulrehmankhan56/metadata.json +++ b/linux/companion/tinyhand@abdulrehmankhan56/metadata.json @@ -5,6 +5,6 @@ "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" + "version": 4, + "version-name": "1.0.2 Linux 2" } diff --git a/linux/scripts/package.sh b/linux/scripts/package.sh index 48f6eb2..853196d 100755 --- a/linux/scripts/package.sh +++ b/linux/scripts/package.sh @@ -38,6 +38,12 @@ 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; } +python3 - "$EXTENSION_SRC/metadata.json" <<'PY' +import json, re, sys +metadata = json.load(open(sys.argv[1])) +if not re.fullmatch(r'[A-Za-z0-9. ]+', metadata.get('version-name', '')): + raise SystemExit('GNOME version-name must contain only ASCII letters, digits, spaces and dots') +PY 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; } diff --git a/linux/scripts/publish.sh b/linux/scripts/publish.sh index c4ae924..df2339a 100755 --- a/linux/scripts/publish.sh +++ b/linux/scripts/publish.sh @@ -22,7 +22,7 @@ command -v gh >/dev/null || { printf 'Install GitHub CLI and run gh auth login f 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 + README.md linux/.gitignore linux/README.md linux/CONTRIBUTING.md linux/COMPATIBILITY.md linux/VERSION linux/RELEASE_NOTES.md linux/UPSTREAM_PR.md linux/src/config.rs linux/companion/tinyhand@abdulrehmankhan56/extension.js linux/companion/tinyhand@abdulrehmankhan56/metadata.json @@ -126,6 +126,6 @@ if ! run_id=$(find_run); then 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"])')" +release_tag="v$(cat linux/VERSION)" 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' +printf 'GNOME listing/review: https://extensions.gnome.org/extension/11682/tinyhand-for-gnome/ (check approval status separately).\n' diff --git a/linux/tests/companion.test.cjs b/linux/tests/companion.test.cjs index c922560..18cc3ac 100644 --- a/linux/tests/companion.test.cjs +++ b/linux/tests/companion.test.cjs @@ -26,6 +26,7 @@ test('metadata uses the GNOME integer version format', () => { const metadata = JSON.parse(fs.readFileSync(path.join(root, 'metadata.json'))); assert.equal(Number.isInteger(metadata.version), true); assert.ok(metadata['shell-version'].includes('50')); + assert.match(metadata['version-name'], /^[A-Za-z0-9. ]+$/, 'GNOME store rejects hyphenated version names'); }); test('Shell entry point never imports GTK libraries', () => { From 051445dd373a80b15a1b05e0d654d98e4aeba550 Mon Sep 17 00:00:00 2001 From: Abdul Rehman Khan <99591456+AbdulRehman56@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:16:35 +0500 Subject: [PATCH 3/3] fix(scripts): make upstream PR detection idempotent --- linux/scripts/publish.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/linux/scripts/publish.sh b/linux/scripts/publish.sh index df2339a..9cc7e0c 100755 --- a/linux/scripts/publish.sh +++ b/linux/scripts/publish.sh @@ -95,8 +95,8 @@ git -c credential.helper= -c 'credential.helper=!gh auth git-credential' push -- 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') +pr_url=$(gh api 'repos/nilbuild/tinyhand/pulls?state=open&head=AbdulRehman56%3Alinux-port-upstream' \ + --jq '.[0].html_url // empty') if [[ -z "$pr_url" ]]; then pr_url=$(gh pr create --repo nilbuild/tinyhand --base main \ --head AbdulRehman56:linux-port-upstream --draft \