diff --git a/.gitignore b/.gitignore
index b7af770..f018ec8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ dist/
*.dmg
*.zip
assets/unused/
+__pycache__/
+*.pyc
diff --git a/README.md b/README.md
index e1f8647..dc35d3e 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@ A tiny hand on a stick that points at things on your Mac screen.
[](https://github.com/nilbuild/tinyhand/releases/latest/download/TinyHand.dmg)
[](https://tinyhand.app)
-macOS 13 or later, Apple Silicon and Intel.
+macOS 13 or later, Apple Silicon and Intel. There is also a port for [KDE Plasma on Linux](linux/README.md).
## Install
@@ -33,6 +33,10 @@ 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.
+## Linux
+
+A Qt port for KDE Plasma lives in [`linux/`](linux/). It uses the same hands and sounds. See [linux/README.md](linux/README.md) for the install steps.
+
## License
MIT. See [LICENSE](LICENSE).
diff --git a/linux/README.md b/linux/README.md
new file mode 100644
index 0000000..e05aa2c
--- /dev/null
+++ b/linux/README.md
@@ -0,0 +1,116 @@
+# TinyHand for KDE Plasma
+
+A port of TinyHand to Linux. It runs on KDE Plasma 6 with Wayland and uses the same
+36 hand images and tap sounds as the Mac app.
+
+## What you need
+
+TinyHand is a Python program. It needs these packages:
+
+| Distribution | Command |
+|---|---|
+| Fedora | `sudo dnf install python3-pyside6 python3-numpy layer-shell-qt` |
+| Debian, Ubuntu, KDE neon | `sudo apt install python3-pyside6.qtwidgets python3-pyside6.qtdbus python3-pyside6.qtmultimedia python3-numpy liblayershellqtinterface6` |
+| Arch | `sudo pacman -S pyside6 python-numpy layer-shell-qt` |
+
+`layer-shell-qt` is part of Plasma. It lets the hand sit above every window and the panel.
+
+## Install
+
+From the repository root:
+
+```sh
+sh linux/install.sh
+```
+
+The script copies TinyHand to `~/.local/share/tinyhand`, puts a `tinyhand` command into
+`~/.local/bin`, and adds a launcher entry with an icon. Run `sh linux/install.sh --uninstall`
+to remove it again. Your settings stay in `~/.config/tinyhand/tinyhand.ini`.
+
+Start TinyHand from the launcher or with `tinyhand`. It shows a hand icon in the system tray.
+Turn on "Open at login" in the settings if you want it to start with Plasma.
+
+You can also run it from the checkout without an install: `python3 linux/run.py`.
+
+## Use
+
+| | |
+|---|---|
+| Click the tray icon, or **Ctrl+Alt+H** | Turn the pointer on or off |
+| Right-click the tray icon | Menu and Settings |
+| Click | Tap (nothing underneath gets clicked) |
+| Hold and drag | Draw on the screen |
+| Right-click | The hand steps aside for 3 seconds so that you can click the real thing |
+| **Ctrl+Alt+X** | Clear the drawing |
+| **Esc** | Clear everything and turn the pointer off |
+
+While the pointer is on, a small toolbar at the bottom of the screen lets you pick one of
+the pinned hands or the laser dot, change the ink colour, mute the tap sound, or exit.
+
+The shortcuts are registered with KDE. Change them in System Settings > Keyboard >
+Shortcuts under "TinyHand". The settings window has a button that opens that page.
+
+The command line controls a running TinyHand, for example from a Stream Deck or a script:
+
+```sh
+tinyhand --toggle # pointer on or off
+tinyhand --on
+tinyhand --off
+tinyhand --clear # clear the drawing
+tinyhand --settings # open the settings window
+tinyhand --quit
+```
+
+The same commands exist on D-Bus as `app.tinyhand` at `/app/tinyhand`, interface
+`app.tinyhand.TinyHand`, with the methods `Toggle`, `TurnOn`, `TurnOff`, `Clear`,
+`Settings`, `Reload`, `Quit`, and `IsActive`.
+
+## Your own hands
+
+Put a PNG into `~/.local/share/tinyhand/hands/`. The image must have a transparent
+background and show the hand on its stick with the fingertip at the top, like the files in
+`assets/`. The file name becomes the name in the settings window. The ink colour comes from
+the colour of the hand.
+
+## Differences from the Mac app
+
+Wayland does not let one app read the mouse position or pass clicks to another window. The
+port works within that:
+
+- The hand is drawn on a transparent layer that covers each screen. While the pointer is on,
+ the layer takes the mouse, so clicks tap and drags draw, like on the Mac.
+- There is no "hold a key to click for real". Instead, a right-click (or middle-click, see the
+ settings) hides the hand and lets clicks through for a few seconds.
+- Keyboard input keeps going to your apps while the pointer is on. Esc works through a global
+ shortcut that is active only while the pointer is on.
+- Global shortcuts come from KDE's KGlobalAccel, so they work on Wayland and X11, but only on
+ Plasma. On other desktops the tray icon and the command line still work.
+- Multiple screens: the hand follows the mouse to whichever screen it is on.
+- There is no automatic update. Pull the repository and run the install script again.
+
+X11 sessions run with a plain always-on-top window. That path has had less testing than
+Wayland.
+
+## How the code is laid out
+
+```
+linux/
+ run.py entry point for a checkout or an install directory
+ install.sh user or system install, with --uninstall
+ tinyhand/
+ __main__.py command line, forwards to the running app over D-Bus
+ app.py tray icon, D-Bus service, global shortcuts, pointer controller
+ overlay.py one transparent layer-shell window per screen
+ arm.py hand pose and painting (a port of ArmView.swift)
+ ink.py strokes and fading (a port of Ink.swift)
+ toolbar.py the bottom toolbar, ink tray and first-run hint
+ styles.py the style table and the hand image measuring (PointerStyle.swift)
+ prefs.py QSettings-backed preferences
+ shortcuts.py KGlobalAccel over D-Bus
+ sound.py tap sounds with QSoundEffect
+ layershell.py ctypes bridge to LayerShellQt
+ settings_ui.py the settings window (runs as its own process)
+ data/ desktop file and icons
+```
+
+Set `TINYHAND_DEBUG=1` to print per-second paint and event counts while the pointer is on.
diff --git a/linux/data/icons/tinyhand-tray-dark.svg b/linux/data/icons/tinyhand-tray-dark.svg
new file mode 100644
index 0000000..ea26574
--- /dev/null
+++ b/linux/data/icons/tinyhand-tray-dark.svg
@@ -0,0 +1,6 @@
+
diff --git a/linux/data/icons/tinyhand-tray-light.svg b/linux/data/icons/tinyhand-tray-light.svg
new file mode 100644
index 0000000..ecd7483
--- /dev/null
+++ b/linux/data/icons/tinyhand-tray-light.svg
@@ -0,0 +1,6 @@
+
diff --git a/linux/data/icons/tinyhand-tray-on-dark.svg b/linux/data/icons/tinyhand-tray-on-dark.svg
new file mode 100644
index 0000000..745090f
--- /dev/null
+++ b/linux/data/icons/tinyhand-tray-on-dark.svg
@@ -0,0 +1,6 @@
+
diff --git a/linux/data/icons/tinyhand-tray-on-light.svg b/linux/data/icons/tinyhand-tray-on-light.svg
new file mode 100644
index 0000000..b3d56f0
--- /dev/null
+++ b/linux/data/icons/tinyhand-tray-on-light.svg
@@ -0,0 +1,6 @@
+
diff --git a/linux/data/icons/tinyhand.png b/linux/data/icons/tinyhand.png
new file mode 100644
index 0000000..527606c
Binary files /dev/null and b/linux/data/icons/tinyhand.png differ
diff --git a/linux/data/tinyhand.desktop b/linux/data/tinyhand.desktop
new file mode 100644
index 0000000..ea9f0e5
--- /dev/null
+++ b/linux/data/tinyhand.desktop
@@ -0,0 +1,28 @@
+[Desktop Entry]
+Type=Application
+Name=TinyHand
+GenericName=Screen pointer
+Comment=A tiny hand on a stick that points at things on your screen
+Exec=tinyhand
+Icon=tinyhand
+Terminal=false
+Categories=Utility;Accessibility;Qt;KDE;
+Keywords=pointer;presentation;laser;hand;cursor;
+StartupNotify=false
+X-DBUS-ServiceName=app.tinyhand
+Actions=Toggle;Clear;Settings;
+
+[Desktop Action Toggle]
+Name=Turn pointer on or off
+Exec=tinyhand --toggle
+Icon=tinyhand
+
+[Desktop Action Clear]
+Name=Clear the drawing
+Exec=tinyhand --clear
+Icon=tinyhand
+
+[Desktop Action Settings]
+Name=Settings
+Exec=tinyhand --settings
+Icon=tinyhand
diff --git a/linux/install.sh b/linux/install.sh
new file mode 100755
index 0000000..35b2bea
--- /dev/null
+++ b/linux/install.sh
@@ -0,0 +1,68 @@
+#!/bin/sh
+# Install TinyHand for the current user (default) or system wide with PREFIX=/usr/local.
+# sh linux/install.sh install to ~/.local
+# sh linux/install.sh --uninstall
+set -eu
+
+PREFIX="${PREFIX:-$HOME/.local}"
+HERE="$(cd "$(dirname "$0")" && pwd)"
+ROOT="$(cd "$HERE/.." && pwd)"
+APP_DIR="$PREFIX/share/tinyhand"
+BIN="$PREFIX/bin/tinyhand"
+DESKTOP="$PREFIX/share/applications/tinyhand.desktop"
+ICONS="$PREFIX/share/icons/hicolor"
+
+if [ "${1:-}" = "--uninstall" ]; then
+ rm -rf "$APP_DIR"
+ rm -f "$BIN" "$DESKTOP" "$HOME/.config/autostart/tinyhand.desktop"
+ rm -f "$ICONS/512x512/apps/tinyhand.png"
+ command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$PREFIX/share/applications" || true
+ echo "TinyHand removed from $PREFIX. Settings stay in ~/.config/tinyhand."
+ exit 0
+fi
+
+if ! python3 -c "import PySide6.QtWidgets, PySide6.QtDBus, numpy" 2>/dev/null; then
+ echo "TinyHand needs python3 with PySide6 and numpy." >&2
+ echo " Fedora: sudo dnf install python3-pyside6 python3-numpy" >&2
+ echo " Debian/Ubuntu: sudo apt install python3-pyside6.qtwidgets python3-pyside6.qtdbus python3-pyside6.qtmultimedia python3-numpy" >&2
+ echo " Arch: sudo pacman -S pyside6 python-numpy" >&2
+ exit 1
+fi
+have_layer_shell=0
+for lib in /usr/lib*/libLayerShellQtInterface.so.6 /usr/lib/*/libLayerShellQtInterface.so.6; do
+ [ -e "$lib" ] && have_layer_shell=1
+done
+if [ "$have_layer_shell" = 0 ]; then
+ echo "Warning: layer-shell-qt was not found. On Wayland the hand will not sit above the panel." >&2
+ echo " Fedora: sudo dnf install layer-shell-qt Debian/Ubuntu: sudo apt install liblayershellqtinterface6" >&2
+fi
+
+mkdir -p "$APP_DIR/assets/sounds" "$PREFIX/bin" "$PREFIX/share/applications" "$ICONS/512x512/apps"
+rm -rf "$APP_DIR/tinyhand"
+cp -r "$HERE/tinyhand" "$APP_DIR/tinyhand"
+rm -rf "$APP_DIR/tinyhand/__pycache__"
+cp "$HERE/run.py" "$APP_DIR/run.py"
+cp "$ROOT/VERSION" "$APP_DIR/VERSION" 2>/dev/null || true
+cp "$ROOT"/assets/hand_*.png "$APP_DIR/assets/"
+cp "$ROOT"/assets/sounds/tap_*.wav "$APP_DIR/assets/sounds/"
+mkdir -p "$APP_DIR/data/icons"
+cp "$HERE"/data/icons/*.svg "$HERE"/data/icons/*.png "$APP_DIR/data/icons/"
+
+cat > "$BIN" < "$DESKTOP"
+cp "$HERE/data/icons/tinyhand.png" "$ICONS/512x512/apps/tinyhand.png"
+
+command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$PREFIX/share/applications" || true
+command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache -q -t "$ICONS" 2>/dev/null || true
+
+echo "Installed TinyHand to $PREFIX."
+case ":$PATH:" in
+ *":$PREFIX/bin:"*) echo "Start it with: tinyhand" ;;
+ *) echo "Start it with: $BIN ($PREFIX/bin is not in your PATH)" ;;
+esac
+echo "Shortcuts: Ctrl+Alt+H turns the pointer on or off, Ctrl+Alt+X clears the drawing."
diff --git a/linux/run.py b/linux/run.py
new file mode 100755
index 0000000..f6bc1d8
--- /dev/null
+++ b/linux/run.py
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+"""Run TinyHand from a checkout or an install directory: python3 run.py [--toggle|--settings|...]"""
+
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from tinyhand.__main__ import main # noqa: E402
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/linux/tinyhand/__init__.py b/linux/tinyhand/__init__.py
new file mode 100644
index 0000000..78cacd4
--- /dev/null
+++ b/linux/tinyhand/__init__.py
@@ -0,0 +1,7 @@
+"""TinyHand for KDE Plasma: a tiny hand on a stick that points at things on your screen."""
+
+APP_NAME = "TinyHand"
+APP_ID = "app.tinyhand"
+DBUS_SERVICE = "app.tinyhand"
+DBUS_PATH = "/app/tinyhand"
+DBUS_INTERFACE = "app.tinyhand.TinyHand"
diff --git a/linux/tinyhand/__main__.py b/linux/tinyhand/__main__.py
new file mode 100644
index 0000000..1bae744
--- /dev/null
+++ b/linux/tinyhand/__main__.py
@@ -0,0 +1,86 @@
+"""Command line entry point.
+
+ tinyhand start the tray app (or do nothing if it already runs)
+ tinyhand --toggle turn the pointer on or off
+ tinyhand --on / --off turn it on or off
+ tinyhand --clear clear the drawing
+ tinyhand --settings open the settings window
+ tinyhand --quit stop the tray app
+"""
+
+import os
+import sys
+
+from . import APP_NAME, DBUS_INTERFACE, DBUS_PATH, DBUS_SERVICE
+
+_PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
+VERSION_FILES = [
+ os.path.join(os.path.dirname(_PACKAGE_DIR), "VERSION"),
+ os.path.join(os.path.dirname(os.path.dirname(_PACKAGE_DIR)), "VERSION"),
+]
+
+COMMANDS = {
+ "--toggle": "Toggle",
+ "--on": "TurnOn",
+ "--off": "TurnOff",
+ "--clear": "Clear",
+ "--quit": "Quit",
+ "--reload": "Reload",
+}
+
+
+def version():
+ for path in VERSION_FILES:
+ try:
+ with open(path, encoding="utf-8") as handle:
+ return handle.read().strip()
+ except OSError:
+ continue
+ return "dev"
+
+
+def send(method):
+ """Call the running daemon. Returns True when a daemon answered."""
+ from PySide6.QtCore import QCoreApplication
+ from PySide6.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage
+
+ app = QCoreApplication.instance() or QCoreApplication(sys.argv)
+ bus = QDBusConnection.sessionBus()
+ iface = QDBusInterface(DBUS_SERVICE, DBUS_PATH, DBUS_INTERFACE, bus)
+ if not iface.isValid():
+ return False
+ reply = iface.call(method)
+ return reply.type() != QDBusMessage.ErrorMessage
+
+
+def main(argv=None):
+ argv = list(sys.argv if argv is None else argv)
+ args = argv[1:]
+ if "--help" in args or "-h" in args:
+ print(__doc__.strip())
+ return 0
+ if "--version" in args:
+ print(f"{APP_NAME} {version()}")
+ return 0
+ if "--settings" in args:
+ os.environ.pop("QT_WAYLAND_SHELL_INTEGRATION", None)
+ from .settings_ui import run_settings
+
+ return run_settings(argv)
+ for flag, method in COMMANDS.items():
+ if flag in args:
+ if send(method):
+ return 0
+ if flag in ("--toggle", "--on"):
+ # Not running yet: start the daemon with the pointer on.
+ os.environ["TINYHAND_START_ON"] = "1"
+ break
+ print(f"{APP_NAME} is not running.", file=sys.stderr)
+ return 1
+ from .app import run_daemon
+
+ return run_daemon([argv[0]])
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/linux/tinyhand/app.py b/linux/tinyhand/app.py
new file mode 100644
index 0000000..aa4029e
--- /dev/null
+++ b/linux/tinyhand/app.py
@@ -0,0 +1,389 @@
+"""The TinyHand daemon: tray icon, D-Bus control, global shortcuts, and the pointer overlays."""
+
+import configparser
+import os
+import shutil
+import subprocess
+import sys
+import time
+from pathlib import Path
+
+from PySide6.QtCore import QEvent, QObject, QTimer, Slot
+from PySide6.QtDBus import QDBusConnection
+from PySide6.QtGui import QAction, QIcon
+from PySide6.QtWidgets import QApplication, QMenu, QSystemTrayIcon, QWidget
+
+from . import APP_NAME, DBUS_INTERFACE, DBUS_PATH, DBUS_SERVICE
+from .overlay import Overlay
+from .paths import launcher_command
+from .prefs import prefs
+from .shortcuts import GlobalShortcuts
+from .sound import TapSound
+from .styles import HandAsset, PointerStyle
+
+TOGGLE_DEFAULT = "Ctrl+Alt+H"
+CLEAR_DEFAULT = "Ctrl+Alt+X"
+
+
+def panel_is_dark(app):
+ """Plasma panels follow the Plasma theme, which can differ from the application colours."""
+ name = ""
+ for exe in ("kreadconfig6", "kreadconfig5"):
+ path = shutil.which(exe)
+ if path is None:
+ continue
+ try:
+ result = subprocess.run([path, "--file", "plasmarc", "--group", "Theme", "--key", "name"],
+ capture_output=True, text=True, timeout=3)
+ name = result.stdout.strip()
+ except (OSError, subprocess.TimeoutExpired):
+ name = ""
+ break
+ if name and name != "default":
+ bases = [Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share")]
+ bases += [Path(d) for d in (os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share").split(":")]
+ for base in bases:
+ colors = base / "plasma" / "desktoptheme" / name / "colors"
+ if not colors.is_file():
+ continue
+ parser = configparser.ConfigParser(interpolation=None, strict=False)
+ try:
+ parser.read(colors, encoding="utf-8")
+ r, g, b = (int(v) for v in parser["Colors:Window"]["BackgroundNormal"].split(",")[:3])
+ return (0.299 * r + 0.587 * g + 0.114 * b) < 128
+ except (KeyError, ValueError, configparser.Error):
+ break
+ lowered = name.lower()
+ if "dark" in lowered:
+ return True
+ if "light" in lowered:
+ return False
+ return app.palette().window().color().lightness() < 128
+
+
+class PointerController(QObject):
+ """Owns one overlay per screen while the pointer is on."""
+
+ def __init__(self, app, sound, shortcuts):
+ super().__init__()
+ self.app = app
+ self.sound = sound
+ self.shortcuts = shortcuts
+ self.overlays = []
+ self.active = False
+ self.style = prefs().pointer_style
+ self.on_active_changed = None
+ self.on_sound_changed = None
+ self.step_aside_timer = QTimer(self)
+ self.step_aside_timer.setSingleShot(True)
+ self.step_aside_timer.timeout.connect(self._end_step_aside)
+ self.stepping_aside = False
+ app.screenAdded.connect(self._screens_changed)
+ app.screenRemoved.connect(self._screens_changed)
+ app.installEventFilter(self)
+ prefs().changed.connect(self._prefs_changed)
+
+ # -- on / off --------------------------------------------------------------
+
+ def toggle(self):
+ if self.active:
+ self.deactivate()
+ else:
+ self.activate()
+
+ def activate(self):
+ if self.active:
+ return
+ self.active = True
+ self.style = prefs().pointer_style
+ self.sound.enabled = prefs().sound_on_for(self.style)
+ self.sound.load(prefs().tap_sound)
+ self._build_overlays()
+ reveal_until = time.monotonic() + 3
+ first_run = not prefs().first_run_hint_shown
+ if first_run:
+ prefs().set("firstRunHintShown", True)
+ parts = [("Click", "to tap"), ("Drag", "to draw")]
+ button = prefs().step_aside_button
+ if button != "off":
+ parts.append((f"{button.capitalize()}-click", "to click through"))
+ parts.append(("Esc", "to exit"))
+ reveal_until = time.monotonic() + 10
+ for overlay in self.overlays:
+ overlay.show_hint(parts, 10)
+ for overlay in self.overlays:
+ overlay.reveal_for(reveal_until - time.monotonic())
+ self.shortcuts.set_active("exit", True)
+ if self.on_active_changed:
+ self.on_active_changed()
+
+ def deactivate(self):
+ if not self.active:
+ return
+ self.active = False
+ self.step_aside_timer.stop()
+ self.stepping_aside = False
+ self.shortcuts.set_active("exit", False)
+ self._destroy_overlays()
+ if self.on_active_changed:
+ self.on_active_changed()
+
+ def exit(self):
+ self.clear_ink()
+ self.deactivate()
+
+ def _build_overlays(self):
+ self._destroy_overlays()
+ for screen in self.app.screens():
+ overlay = Overlay(self, screen)
+ overlay.set_style(self.style)
+ overlay.show()
+ self.overlays.append(overlay)
+
+ def _destroy_overlays(self):
+ for overlay in self.overlays:
+ overlay.hide()
+ overlay.deleteLater()
+ self.overlays = []
+
+ def _screens_changed(self, screen):
+ if self.active:
+ QTimer.singleShot(300, self._rebuild_if_active)
+
+ def _rebuild_if_active(self):
+ if self.active:
+ self._build_overlays()
+
+ # -- events from overlays --------------------------------------------------
+
+ def pointer_entered(self, overlay):
+ for other in self.overlays:
+ if other is not overlay:
+ other.set_hand_hidden(True)
+
+ def tapped(self, overlay):
+ self.sound.play()
+
+ def pick(self, style):
+ self.style = style
+ prefs().pointer_style = style
+
+ def clear_ink(self):
+ for overlay in self.overlays:
+ overlay.clear_ink()
+
+ @property
+ def sound_on(self):
+ return prefs().sound_on_for(self.style)
+
+ def toggle_sound(self):
+ prefs().set_sound_on_for(self.style, not self.sound_on)
+
+ def step_aside(self):
+ """Hide the hand and let clicks through for a few seconds."""
+ if not self.active:
+ return
+ self.stepping_aside = True
+ for overlay in self.overlays:
+ overlay.set_pass_through(True)
+ self.step_aside_timer.start(int(prefs().step_aside_seconds * 1000))
+
+ def _end_step_aside(self):
+ self.stepping_aside = False
+ for overlay in self.overlays:
+ overlay.set_pass_through(False)
+
+ def _prefs_changed(self):
+ self.sound.load(prefs().tap_sound)
+ style = prefs().pointer_style
+ if style != self.style:
+ self.style = style
+ for overlay in self.overlays:
+ overlay.set_style(style)
+ self.sound.enabled = prefs().sound_on_for(self.style)
+ for overlay in self.overlays:
+ overlay.apply_prefs()
+ if self.on_sound_changed:
+ self.on_sound_changed()
+
+ def eventFilter(self, obj, event):
+ # Mouse moves over toolbar buttons still steer the hand.
+ if event.type() == QEvent.MouseMove and isinstance(obj, QWidget):
+ window = obj.window()
+ if isinstance(window, Overlay) and obj is not window:
+ window.aim(obj.mapTo(window, event.position().toPoint()))
+ return False
+
+
+class DBusService(QObject):
+ """app.tinyhand.TinyHand on the session bus. Slots are the public methods."""
+
+ def __init__(self, daemon):
+ super().__init__()
+ self.daemon = daemon
+
+ @Slot()
+ def Toggle(self):
+ self.daemon.pointer.toggle()
+
+ @Slot()
+ def TurnOn(self):
+ self.daemon.pointer.activate()
+
+ @Slot()
+ def TurnOff(self):
+ self.daemon.pointer.exit()
+
+ @Slot()
+ def Clear(self):
+ self.daemon.pointer.clear_ink()
+
+ @Slot()
+ def Settings(self):
+ self.daemon.open_settings()
+
+ @Slot()
+ def Reload(self):
+ prefs().reload()
+
+ @Slot()
+ def Quit(self):
+ self.daemon.quit()
+
+ @Slot(result=bool)
+ def IsActive(self):
+ return self.daemon.pointer.active
+
+
+class Daemon(QObject):
+ def __init__(self, app):
+ super().__init__()
+ self.app = app
+ app.setQuitOnLastWindowClosed(False)
+ app.setApplicationName("tinyhand")
+ app.setDesktopFileName("tinyhand")
+ self.sound = TapSound(prefs().tap_sound)
+ self.shortcuts = GlobalShortcuts(self)
+ self.pointer = PointerController(app, self.sound, self.shortcuts)
+ self.pointer.on_active_changed = self._refresh_tray
+ self.pointer.on_sound_changed = self._refresh_menu
+ self.settings_process = None
+
+ self.service = DBusService(self)
+ bus = QDBusConnection.sessionBus()
+ bus.registerObject(DBUS_PATH, DBUS_INTERFACE, self.service, QDBusConnection.ExportAllSlots)
+
+ self.shortcuts.register("toggle", "Turn pointer on or off", TOGGLE_DEFAULT, self.pointer.toggle)
+ self.shortcuts.register("clear", "Clear the drawing", CLEAR_DEFAULT, self.pointer.clear_ink)
+ self.shortcuts.register("exit", "Turn pointer off (while it is on)", "Esc", self.pointer.exit, active=False)
+
+ self._setup_tray()
+ app.installEventFilter(self)
+ QTimer.singleShot(0, self._warm)
+
+ def eventFilter(self, obj, event):
+ if event.type() == QEvent.ApplicationPaletteChange and obj is self.app:
+ self._refresh_tray()
+ return False
+
+ def _warm(self):
+ for style in prefs().pinned_styles:
+ if not style.is_laser:
+ HandAsset.thumbnail(style)
+
+ # -- tray ----------------------------------------------------------------
+
+ def _setup_tray(self):
+ self.tray = QSystemTrayIcon(self)
+ self.menu = QMenu()
+ self.toggle_action = QAction("", self.menu)
+ self.toggle_action.triggered.connect(self.pointer.toggle)
+ self.clear_action = QAction("", self.menu)
+ self.clear_action.triggered.connect(self.pointer.clear_ink)
+ self.sound_action = QAction("Tap Sound", self.menu)
+ self.sound_action.setCheckable(True)
+ self.sound_action.triggered.connect(self.pointer.toggle_sound)
+ self.menu.addAction(self.toggle_action)
+ self.menu.addAction(self.clear_action)
+ self.menu.addAction(self.sound_action)
+ self.menu.addSeparator()
+ settings_action = QAction("Settings…", self.menu)
+ settings_action.triggered.connect(self.open_settings)
+ self.menu.addAction(settings_action)
+ self.menu.addSeparator()
+ quit_action = QAction(f"Quit {APP_NAME}", self.menu)
+ quit_action.triggered.connect(self.quit)
+ self.menu.addAction(quit_action)
+ self.menu.aboutToShow.connect(self._refresh_menu)
+ self.tray.setContextMenu(self.menu)
+ self.tray.activated.connect(self._tray_activated)
+ self._refresh_tray()
+ self._refresh_menu()
+ self.tray.show()
+
+ def _tray_icon(self, on):
+ """Light or dark glyph to match the panel. Plasma shows the pixmap as is, so we pick the colour."""
+ name = "tinyhand-tray-on" if on else "tinyhand-tray"
+ variant = "light" if panel_is_dark(self.app) else "dark"
+ path = Path(__file__).resolve().parent.parent / "data" / "icons" / f"{name}-{variant}.svg"
+ if path.is_file():
+ return QIcon(str(path))
+ return QIcon.fromTheme("input-mouse")
+
+ def _refresh_tray(self):
+ on = self.pointer.active
+ self.tray.setIcon(self._tray_icon(on))
+ self.tray.setToolTip(f"{APP_NAME} is on. Click to turn off, right-click for the menu" if on
+ else f"{APP_NAME}. Click to turn on, right-click for the menu")
+ self._refresh_menu()
+
+ def _refresh_menu(self):
+ on = self.pointer.active
+ toggle_keys = self.shortcuts.current_keys("toggle") or TOGGLE_DEFAULT
+ clear_keys = self.shortcuts.current_keys("clear") or CLEAR_DEFAULT
+ self.toggle_action.setText(f"{'Turn Pointer Off' if on else 'Turn Pointer On'}\t{toggle_keys}")
+ self.clear_action.setText(f"Clear Drawing\t{clear_keys}")
+ self.sound_action.setChecked(self.pointer.sound_on)
+
+ def _tray_activated(self, reason):
+ if reason in (QSystemTrayIcon.Trigger, QSystemTrayIcon.DoubleClick):
+ self.pointer.toggle()
+ elif reason == QSystemTrayIcon.MiddleClick:
+ self.pointer.clear_ink()
+
+ # -- settings --------------------------------------------------------------
+
+ def open_settings(self):
+ """The settings window runs in its own process so it gets a normal window, not a layer surface."""
+ self.pointer.exit()
+ if self.settings_process is not None and self.settings_process.poll() is None:
+ return
+ env = dict(os.environ)
+ env.pop("QT_WAYLAND_SHELL_INTEGRATION", None)
+ command = launcher_command().split(" ", 1)
+ if len(command) == 2:
+ args = [command[0], command[1], "--settings"]
+ else:
+ args = [command[0], "--settings"]
+ try:
+ self.settings_process = subprocess.Popen(args, env=env)
+ except OSError as error:
+ print(f"could not open settings: {error}", file=sys.stderr)
+
+ def quit(self):
+ self.pointer.deactivate()
+ self.tray.hide()
+ self.app.quit()
+
+
+def run_daemon(argv):
+ os.environ.setdefault("QT_WAYLAND_SHELL_INTEGRATION", "layer-shell")
+ app = QApplication(argv)
+ bus = QDBusConnection.sessionBus()
+ if not bus.registerService(DBUS_SERVICE):
+ print(f"{APP_NAME} is already running.", file=sys.stderr)
+ return 0
+ daemon = Daemon(app)
+ if os.environ.pop("TINYHAND_START_ON", ""):
+ QTimer.singleShot(0, daemon.pointer.activate)
+ return app.exec()
diff --git a/linux/tinyhand/arm.py b/linux/tinyhand/arm.py
new file mode 100644
index 0000000..124b445
--- /dev/null
+++ b/linux/tinyhand/arm.py
@@ -0,0 +1,263 @@
+"""The hand on a stick: where it is, how it leans, and how it is drawn."""
+
+import math
+
+import numpy as np
+from PySide6.QtCore import QPointF, QRectF, Qt
+from PySide6.QtGui import QColor, QImage, QPainter, QRadialGradient, QTransform
+
+from .styles import RED, HandAsset, PointerStyle
+
+
+class ArmRenderer:
+ GRIP_LEAN = 0.30
+ GRIP_DEPTH = 0.12
+ GRIP_FOLLOW = 0.08
+ LIFT = 0.06
+ MIN_ROD = 0.05
+
+ def __init__(self):
+ self.style = PointerStyle("lavender")
+ self.asset = None
+ self.hand_len_frac = 0.13
+ self.show_shadow = True
+ self.hidden = False
+ self.held = False
+ self.press_amount = 0.0
+ self.grip_x = None
+ self.grip_target = None
+ self.cursor = None
+ self.width = 0
+ self.height = 0
+ self.dpr = 1.0
+ self.pose = None
+ self._display = None
+ self._shadow = None
+ self._display_height = 0
+
+ # -- state ---------------------------------------------------------------
+
+ @property
+ def is_settling(self):
+ if self.press_amount > 0.002:
+ return True
+ if self.grip_x is None or self.grip_target is None:
+ return False
+ return abs(self.grip_target - self.grip_x) > 0.5
+
+ def set_style(self, style):
+ self.style = style
+ self.asset = None if style.is_laser else HandAsset.load(style)
+ if not style.is_laser and self.asset is None:
+ self.style = PointerStyle("laser")
+ self._display = None
+ self._shadow = None
+ self._display_height = 0
+ self.pose = None
+
+ def set_size(self, width, height, dpr):
+ if (width, height, dpr) != (self.width, self.height, self.dpr):
+ self.width = width
+ self.height = height
+ self.dpr = dpr
+ self._display_height = 0
+ self.pose = None
+
+ def press(self):
+ self.press_amount = 1.0
+
+ def bounds(self):
+ """Screen rect that the current pose covers, for partial repaints."""
+ pose = self.pose
+ if pose is None:
+ return QRectF()
+ if pose["kind"] == "laser":
+ r = pose["glow"] + 2
+ c = pose["cursor"]
+ return QRectF(c.x() - r, c.y() - r, 2 * r, 2 * r)
+ width = pose["width"]
+ local = QRectF(-pose["rod_x"] * width, -(pose["rod_len"] + pose["hand"]), width, pose["rod_len"] + pose["hand"])
+ rect = pose["transform"].mapRect(local)
+ shadow = pose["shadow_transform"].mapRect(local)
+ margin = pose["lift"] * 0.5 + 12
+ return rect.united(shadow).adjusted(-margin, -margin, margin, margin)
+
+ # -- aiming --------------------------------------------------------------
+
+ def aim(self, cursor):
+ """Point at `cursor` (overlay coordinates, y down). Returns True when the pose changed."""
+ self.cursor = QPointF(cursor)
+ w = self.width
+ h = self.height
+ if w <= 0 or h <= 0:
+ return False
+ target_x = min(max(cursor.x() + self.GRIP_LEAN * w, w * 0.15), w * 1.05)
+ if self.grip_x is None:
+ gx = target_x
+ else:
+ gx = self.grip_x + (target_x - self.grip_x) * self.GRIP_FOLLOW
+ self.grip_x = gx
+ self.grip_target = target_x
+ if not self.held:
+ self.press_amount += (0 - self.press_amount) * 0.18
+ if self.press_amount < 0.001:
+ self.press_amount = 0.0
+
+ if self.style.is_laser or self.asset is None:
+ return self._aim_laser(cursor)
+
+ a = self.asset
+ self._update_display(a)
+ grip_up = QPointF(gx, -self.GRIP_DEPTH * h)
+ cursor_up = QPointF(cursor.x(), h - cursor.y())
+ dx = cursor_up.x() - grip_up.x()
+ dy = cursor_up.y() - grip_up.y()
+ reach = math.hypot(dx, dy)
+ aim_angle = math.atan2(dy, dx)
+
+ hand_pts = self.hand_len_frac * h * (1 - 0.06 * self.press_amount)
+ s = hand_pts / (a.wrist - a.hand_top)
+ width_pts = s * a.aspect
+ hand_tip_pts = (a.wrist - a.tip) * s
+ tip_off_x = (a.tip_x - a.rod_x) * width_pts
+
+ along = math.sqrt(max(0.0, reach * reach - tip_off_x * tip_off_x))
+ rod_len = max(self.MIN_ROD * h, along - hand_tip_pts)
+ tip_angle = math.atan2(rod_len + hand_tip_pts, tip_off_x)
+ rotation = aim_angle - tip_angle
+
+ lift = self.LIFT * h * (1 - 0.8 * self.press_amount)
+ shadow_offset = QPointF(lift * 0.6, lift)
+ grip = QPointF(gx, h + self.GRIP_DEPTH * h)
+ degrees = -math.degrees(rotation)
+
+ key = tuple(round(v * 100) for v in (rotation, rod_len, hand_pts, width_pts, gx, lift, self.press_amount,
+ 1.0 if self.show_shadow else 0.0))
+ if self.pose is not None and self.pose.get("key") == key:
+ return False
+ transform = QTransform().translate(grip.x(), grip.y()).rotate(degrees)
+ shadow_transform = QTransform().translate(grip.x() + shadow_offset.x(), grip.y() + shadow_offset.y()).rotate(degrees)
+ self.pose = {
+ "kind": "hand",
+ "key": key,
+ "transform": transform,
+ "shadow_transform": shadow_transform,
+ "rod_len": rod_len,
+ "hand": hand_pts,
+ "width": width_pts,
+ "rod_x": a.rod_x,
+ "lift": lift,
+ "press": self.press_amount,
+ }
+ return True
+
+ def _aim_laser(self, cursor):
+ press = self.press_amount
+ r = max(4.0, self.height * 0.0038 * self.hand_len_frac / 0.13) * (1 + 0.2 * press)
+ key = tuple(round(v * 100) for v in (r, cursor.x(), cursor.y()))
+ if self.pose is not None and self.pose.get("key") == key:
+ return False
+ self.pose = {
+ "kind": "laser",
+ "key": key,
+ "cursor": QPointF(cursor),
+ "r": r,
+ "core": r * 0.45,
+ "glow": r * (2.2 + press) * 1.6,
+ }
+ return True
+
+ # -- images --------------------------------------------------------------
+
+ def _update_display(self, a):
+ full = a.image
+ rest_pts = self.hand_len_frac * self.height / (a.wrist - a.hand_top)
+ step = 64
+ needed = (int(math.ceil(rest_pts * self.dpr)) + step - 1) // step * step
+ height = full.height() if needed >= full.height() * 9 // 10 else max(step, needed)
+ if height == self._display_height and self._display is not None:
+ return
+ self._display_height = height
+ if height == full.height():
+ image = full
+ else:
+ width = max(1, round(full.width() * height / full.height()))
+ image = full.scaled(width, height, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
+ self._display = image
+ self._shadow = self._make_shadow(image)
+
+ @staticmethod
+ def _make_shadow(image):
+ """A soft black silhouette of the hand image."""
+ img = image.convertToFormat(QImage.Format_ARGB32_Premultiplied).copy()
+ h = img.height()
+ w = img.width()
+ raw = np.frombuffer(img.bits(), dtype=np.uint8)
+ pixels = raw.reshape(h, img.bytesPerLine())[:, : w * 4].reshape(h, w, 4)
+ pixels[:, :, 0:3] = 0
+ small = img.scaled(max(1, w // 6), max(1, h // 6), Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
+ return small.scaled(w, h, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
+
+ # -- painting ------------------------------------------------------------
+
+ def paint(self, painter):
+ if self.hidden or self.pose is None:
+ return
+ if self.pose["kind"] == "laser":
+ self._paint_laser(painter)
+ return
+ a = self.asset
+ if a is None or self._display is None:
+ return
+ pose = self.pose
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ if self.show_shadow:
+ painter.save()
+ painter.setTransform(pose["shadow_transform"], True)
+ painter.setOpacity(0.26 + 0.02 * pose["press"])
+ self._paint_group(painter, self._shadow, a, pose)
+ painter.restore()
+ painter.setTransform(pose["transform"], True)
+ self._paint_group(painter, self._display, a, pose)
+ painter.restore()
+
+ @staticmethod
+ def _paint_group(painter, image, a, pose):
+ iw = image.width()
+ ih = image.height()
+ width = pose["width"]
+ rod_len = pose["rod_len"]
+ hand = pose["hand"]
+ left = -pose["rod_x"] * width
+ rod_source = QRectF(0, a.wrist * ih, iw, (a.rod_end - a.wrist) * ih)
+ hand_source = QRectF(0, a.hand_top * ih, iw, (a.wrist - a.hand_top) * ih)
+ painter.drawImage(QRectF(left, -rod_len, width, rod_len + 1), image, rod_source)
+ painter.drawImage(QRectF(left, -(rod_len + hand), width, hand), image, hand_source)
+
+ def _paint_laser(self, painter):
+ pose = self.pose
+ c = pose["cursor"]
+ r = pose["r"]
+ glow = pose["glow"]
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.NoPen)
+ gradient = QRadialGradient(c, glow)
+ red = QColor(RED)
+ red.setAlphaF(0.55)
+ gradient.setColorAt(0.0, red)
+ mid = QColor(RED)
+ mid.setAlphaF(0.18)
+ gradient.setColorAt(0.45, mid)
+ clear = QColor(RED)
+ clear.setAlphaF(0.0)
+ gradient.setColorAt(1.0, clear)
+ painter.setBrush(gradient)
+ painter.drawEllipse(c, glow, glow)
+ painter.setBrush(RED)
+ painter.drawEllipse(c, r, r)
+ painter.setBrush(QColor.fromRgbF(1, 0.86, 0.82, 0.95))
+ painter.drawEllipse(c, pose["core"], pose["core"])
+ painter.restore()
diff --git a/linux/tinyhand/ink.py b/linux/tinyhand/ink.py
new file mode 100644
index 0000000..269537c
--- /dev/null
+++ b/linux/tinyhand/ink.py
@@ -0,0 +1,133 @@
+"""Drawing strokes that fade away."""
+
+import time
+
+from PySide6.QtCore import QPointF, QRectF, Qt
+from PySide6.QtGui import QColor, QPainterPath, QPen
+
+FADE_DURATION = 0.6
+
+
+class Stroke:
+ __slots__ = ("points", "base", "color", "width", "ended_at", "bounds")
+
+ def __init__(self, point, color, width):
+ self.points = [QPointF(point)]
+ self.base = QPainterPath(QPointF(point))
+ self.color = QColor(color)
+ self.width = width
+ self.ended_at = None
+ pad = width / 2 + 6
+ self.bounds = QRectF(point.x() - pad, point.y() - pad, 2 * pad, 2 * pad)
+
+ def path(self):
+ path = QPainterPath(self.base)
+ if len(self.points) == 1:
+ path.lineTo(self.points[0])
+ else:
+ path.lineTo(self.points[-1])
+ return path
+
+ def extend(self, point):
+ prev = self.points[-1]
+ mid = QPointF((prev.x() + point.x()) / 2, (prev.y() + point.y()) / 2)
+ self.base.quadTo(prev, mid)
+ self.points.append(QPointF(point))
+ pad = self.width / 2 + 6
+ segment = QRectF(prev, point).normalized().adjusted(-pad, -pad, pad, pad)
+ self.bounds = self.bounds.united(segment)
+ return segment
+
+
+class Ink:
+ def __init__(self):
+ self.color = QColor(135, 84, 242)
+ self.fade_seconds = 3.0
+ self.strokes = []
+ self.current = None
+
+ @property
+ def keep(self):
+ return self.fade_seconds <= 0
+
+ @property
+ def fade_delay(self):
+ return max(0.5, self.fade_seconds)
+
+ @property
+ def is_drawing(self):
+ return self.current is not None
+
+ def begin(self, point, width):
+ stroke = Stroke(point, self.color, width)
+ self.strokes.append(stroke)
+ self.current = stroke
+ return stroke.bounds
+
+ def extend(self, point):
+ if self.current is None:
+ return None
+ last = self.current.points[-1]
+ if (point - last).manhattanLength() < 1.5:
+ return None
+ return self.current.extend(point)
+
+ def end(self):
+ if self.current is None:
+ return
+ self.current.ended_at = time.monotonic()
+ self.current = None
+
+ def clear(self):
+ rects = [s.bounds for s in self.strokes]
+ self.strokes = []
+ self.current = None
+ return rects
+
+ def opacity(self, stroke, now):
+ if stroke.ended_at is None or self.keep:
+ return 1.0
+ start = stroke.ended_at + self.fade_delay
+ if now < start:
+ return 1.0
+ return max(0.0, 1.0 - (now - start) / FADE_DURATION)
+
+ def is_fading(self, stroke, now):
+ return stroke.ended_at is not None and not self.keep and now >= stroke.ended_at + self.fade_delay
+
+ def has_pending_fade(self):
+ return not self.keep and any(s.ended_at is not None for s in self.strokes)
+
+ def prune(self, now):
+ """Drop strokes that finished fading. Returns the rects that changed."""
+ dirty = []
+ remaining = []
+ for stroke in self.strokes:
+ if self.is_fading(stroke, now):
+ dirty.append(stroke.bounds)
+ if self.opacity(stroke, now) <= 0:
+ continue
+ remaining.append(stroke)
+ self.strokes = remaining
+ return dirty
+
+ def paint(self, painter, clip):
+ now = time.monotonic()
+ painter.save()
+ painter.setRenderHint(painter.RenderHint.Antialiasing)
+ painter.setBrush(Qt.NoBrush)
+ for stroke in self.strokes:
+ if not stroke.bounds.intersects(clip):
+ continue
+ alpha = self.opacity(stroke, now)
+ if alpha <= 0:
+ continue
+ path = stroke.path()
+ painter.setOpacity(alpha)
+ shadow = QPen(QColor(0, 0, 0, 46), stroke.width + 1.5, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
+ painter.setPen(shadow)
+ painter.drawPath(path.translated(1.0, 1.5))
+ pen = QPen(stroke.color, stroke.width, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
+ painter.setPen(pen)
+ painter.drawPath(path)
+ painter.restore()
diff --git a/linux/tinyhand/layershell.py b/linux/tinyhand/layershell.py
new file mode 100644
index 0000000..d44e5d3
--- /dev/null
+++ b/linux/tinyhand/layershell.py
@@ -0,0 +1,88 @@
+"""Thin ctypes bridge to LayerShellQt, so the overlay can sit above every window and panel.
+
+LayerShellQt has no Python bindings. Its setters take plain integers, so calling the
+C++ symbols directly is safe on the x86_64 and aarch64 ABIs.
+"""
+
+import ctypes
+import ctypes.util
+
+ANCHOR_TOP = 1
+ANCHOR_BOTTOM = 2
+ANCHOR_LEFT = 4
+ANCHOR_RIGHT = 8
+ANCHOR_ALL = ANCHOR_TOP | ANCHOR_BOTTOM | ANCHOR_LEFT | ANCHOR_RIGHT
+
+LAYER_BACKGROUND = 0
+LAYER_BOTTOM = 1
+LAYER_TOP = 2
+LAYER_OVERLAY = 3
+
+KEYBOARD_NONE = 0
+KEYBOARD_EXCLUSIVE = 1
+KEYBOARD_ON_DEMAND = 2
+
+SCREEN_FROM_QWINDOW = 0
+SCREEN_FROM_COMPOSITOR = 1
+
+_lib = None
+_symbols = {}
+
+
+def _load():
+ global _lib
+ if _lib is not None:
+ return _lib
+ for name in ("libLayerShellQtInterface.so.6", ctypes.util.find_library("LayerShellQtInterface")):
+ if not name:
+ continue
+ try:
+ _lib = ctypes.CDLL(name)
+ break
+ except OSError:
+ continue
+ if _lib is None:
+ return None
+ table = {
+ "get": ("_ZN12LayerShellQt6Window3getEP7QWindow", ctypes.c_void_p, [ctypes.c_void_p]),
+ "set_layer": ("_ZN12LayerShellQt6Window8setLayerENS0_5LayerE", None, [ctypes.c_void_p, ctypes.c_uint]),
+ "set_anchors": ("_ZN12LayerShellQt6Window10setAnchorsE6QFlagsINS0_6AnchorEE", None,
+ [ctypes.c_void_p, ctypes.c_uint]),
+ "set_keyboard": ("_ZN12LayerShellQt6Window24setKeyboardInteractivityENS0_21KeyboardInteractivityE", None,
+ [ctypes.c_void_p, ctypes.c_uint]),
+ "set_exclusive_zone": ("_ZN12LayerShellQt6Window16setExclusiveZoneEi", None, [ctypes.c_void_p, ctypes.c_int]),
+ "set_screen_configuration": ("_ZN12LayerShellQt6Window22setScreenConfigurationENS0_19ScreenConfigurationE",
+ None, [ctypes.c_void_p, ctypes.c_uint]),
+ }
+ for key, (symbol, restype, argtypes) in table.items():
+ try:
+ fn = getattr(_lib, symbol)
+ except AttributeError:
+ _lib = None
+ return None
+ fn.restype = restype
+ fn.argtypes = argtypes
+ _symbols[key] = fn
+ return _lib
+
+
+def available():
+ return _load() is not None
+
+
+def configure(qwindow, layer=LAYER_OVERLAY, anchors=ANCHOR_ALL, keyboard=KEYBOARD_NONE):
+ """Turn a not-yet-shown QWindow into a layer surface with the given layer and anchors."""
+ if _load() is None:
+ return False
+ import shiboken6
+
+ ptr = shiboken6.getCppPointer(qwindow)[0]
+ window = _symbols["get"](ptr)
+ if not window:
+ return False
+ _symbols["set_layer"](window, layer)
+ _symbols["set_anchors"](window, anchors)
+ _symbols["set_keyboard"](window, keyboard)
+ _symbols["set_exclusive_zone"](window, -1)
+ _symbols["set_screen_configuration"](window, SCREEN_FROM_QWINDOW)
+ return True
diff --git a/linux/tinyhand/overlay.py b/linux/tinyhand/overlay.py
new file mode 100644
index 0000000..c963fea
--- /dev/null
+++ b/linux/tinyhand/overlay.py
@@ -0,0 +1,314 @@
+"""One transparent window per screen that draws the hand, takes the clicks, and hosts the toolbar."""
+
+import time
+
+from PySide6.QtCore import QPointF, QRect, QRectF, QTimer, Qt
+from PySide6.QtGui import QGuiApplication, QPainter, QRegion
+from PySide6.QtWidgets import QWidget
+
+from . import layershell
+from .arm import ArmRenderer
+from .ink import Ink
+from .prefs import ink_color_for, prefs
+from .toolbar import HintStrip, Toolbar
+
+NO_INPUT = QRegion(QRect(-10, -10, 1, 1))
+
+DEBUG = bool(__import__("os").environ.get("TINYHAND_DEBUG"))
+COUNTS = {"paint": 0, "tick": 0, "aim": 0, "move": 0}
+
+
+def _debug_report():
+ if DEBUG:
+ print("counts/s", dict(COUNTS), flush=True)
+ for key in COUNTS:
+ COUNTS[key] = 0
+
+
+_debug_timer = None
+
+
+def _start_debug():
+ global _debug_timer
+ if DEBUG and _debug_timer is None:
+ _debug_timer = QTimer()
+ _debug_timer.timeout.connect(_debug_report)
+ _debug_timer.start(1000)
+
+
+class Overlay(QWidget):
+ def __init__(self, controller, screen):
+ super().__init__(None)
+ _start_debug()
+ self.controller = controller
+ self.target_screen = screen
+ self.wayland = QGuiApplication.platformName().startswith("wayland")
+ if self.wayland:
+ self.setWindowFlags(Qt.FramelessWindowHint)
+ else:
+ self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.X11BypassWindowManagerHint
+ | Qt.Tool)
+ self.setAttribute(Qt.WA_TranslucentBackground)
+ self.setAttribute(Qt.WA_NoSystemBackground)
+ self.setMouseTracking(True)
+ self.setCursor(Qt.BlankCursor)
+
+ self.arm = ArmRenderer()
+ self.ink = Ink()
+ self.toolbar = Toolbar(self)
+ self.toolbar.pick.connect(controller.pick)
+ self.toolbar.toggle_sound.connect(controller.toggle_sound)
+ self.toolbar.exit_requested.connect(controller.exit)
+ self.hint = None
+ self.down_point = None
+ self.reveal_until = 0.0
+ self.pass_through = False
+ self.last_mouse = None
+
+ self.tick_timer = QTimer(self)
+ self.tick_timer.setInterval(8)
+ self.tick_timer.timeout.connect(self._tick)
+ self.fade_timer = QTimer(self)
+ self.fade_timer.setInterval(33)
+ self.fade_timer.timeout.connect(self._fade_tick)
+
+ self.winId()
+ handle = self.windowHandle()
+ handle.setScreen(screen)
+ if self.wayland:
+ layershell.configure(handle, layer=layershell.LAYER_OVERLAY, anchors=layershell.ANCHOR_ALL,
+ keyboard=layershell.KEYBOARD_NONE)
+ else:
+ self.setGeometry(screen.geometry())
+ self.apply_prefs()
+
+ # -- geometry ------------------------------------------------------------
+
+ @property
+ def ink_width(self):
+ return max(3.0, self.height() * 0.0055)
+
+ def toolbar_bottom(self):
+ """Where the toolbar's bottom edge goes: 14 px above the panel, like above the Dock on macOS."""
+ screen = self.target_screen
+ available = screen.availableGeometry()
+ full = screen.geometry()
+ bottom = available.bottom() - full.top() + 1
+ return min(self.height(), bottom) - 14
+
+ def resizeEvent(self, event):
+ super().resizeEvent(event)
+ self.arm.set_size(self.width(), self.height(), self.devicePixelRatioF())
+ self.toolbar.place(self.toolbar_bottom())
+ self._place_hint()
+ self.clear_ink()
+ if self.last_mouse is not None:
+ self.aim(self.last_mouse)
+
+ # -- preferences ---------------------------------------------------------
+
+ def apply_prefs(self):
+ p = prefs()
+ self.arm.hand_len_frac = p.pointer_size
+ self.arm.show_shadow = p.show_shadow
+ self.ink.fade_seconds = p.fade_seconds
+ self.ink.color = ink_color_for(p.ink_choice, self.arm.style)
+ self.arm.pose = None
+ self.toolbar.rebuild_styles()
+ self.toolbar.refresh_ink()
+ self.toolbar.set_sound_on(p.sound_on_for(self.arm.style))
+ if self.last_mouse is not None:
+ self.aim(self.last_mouse)
+ self.update()
+
+ def set_style(self, style):
+ old = self.arm.bounds()
+ self.arm.set_style(style)
+ self.ink.color = ink_color_for(prefs().ink_choice, self.arm.style)
+ self.toolbar.select(self.arm.style)
+ self.toolbar.set_sound_on(prefs().sound_on_for(self.arm.style))
+ self._update_rect(old)
+ if self.last_mouse is not None:
+ self.aim(self.last_mouse)
+
+ # -- pointer -------------------------------------------------------------
+
+ def set_hand_hidden(self, hidden):
+ if hidden == self.arm.hidden:
+ return
+ if hidden:
+ self.release()
+ self.arm.hidden = hidden
+ self._update_rect(self.arm.bounds())
+
+ def set_pass_through(self, value):
+ """Let clicks fall through to the windows beneath (used by step-aside)."""
+ self.pass_through = value
+ handle = self.windowHandle()
+ if handle is None:
+ return
+ if self.wayland:
+ handle.setMask(NO_INPUT if value else QRegion())
+ else:
+ self.setWindowFlag(Qt.WindowTransparentForInput, value)
+ self.show()
+ if value:
+ self.toolbar.hide_now()
+ self.set_hand_hidden(value)
+
+ def aim(self, pos):
+ COUNTS["aim"] += 1
+ self.last_mouse = QPointF(pos)
+ old = self.arm.bounds()
+ if self.arm.aim(pos):
+ self._update_rect(old.united(self.arm.bounds()))
+ self._update_zones(pos)
+ if (self.arm.is_settling or self.arm.held) and not self.tick_timer.isActive():
+ self.tick_timer.start()
+
+ def _tick(self):
+ COUNTS["tick"] += 1
+ if self.last_mouse is None:
+ self.tick_timer.stop()
+ return
+ old = self.arm.bounds()
+ if self.arm.aim(self.last_mouse):
+ self._update_rect(old.united(self.arm.bounds()))
+ if not self.arm.is_settling and not self.arm.held:
+ self.tick_timer.stop()
+
+ def _update_rect(self, rect):
+ if rect.isNull() or rect.isEmpty():
+ return
+ self.update(rect.toAlignedRect().adjusted(-2, -2, 2, 2))
+
+ def reveal_for(self, seconds):
+ """Show the toolbar for a while, then fall back to the hover rule."""
+ self.reveal_until = time.monotonic() + seconds
+ self.toolbar.set_revealed(True)
+ QTimer.singleShot(int(seconds * 1000) + 50, self._recheck_zones)
+
+ def _recheck_zones(self):
+ if self.last_mouse is not None:
+ self._update_zones(self.last_mouse)
+ elif time.monotonic() >= self.reveal_until:
+ self.toolbar.set_revealed(False)
+
+ def _update_zones(self, mouse):
+ frame = self.toolbar.frame()
+ near_rect = frame.adjusted(-80, -90 - 10, 80, 10)
+ near = near_rect.contains(mouse.toPoint())
+ drawing = self.ink.is_drawing
+ self.toolbar.set_revealed(not drawing and (near or time.monotonic() < self.reveal_until))
+
+ # -- mouse -----------------------------------------------------------------
+
+ def enterEvent(self, event):
+ self.controller.pointer_entered(self)
+ self.set_hand_hidden(self.pass_through)
+ self.aim(event.position())
+
+ def mouseMoveEvent(self, event):
+ COUNTS["move"] += 1
+ pos = event.position()
+ self.aim(pos)
+ if self.down_point is not None:
+ self.drag(pos)
+
+ def mousePressEvent(self, event):
+ button = event.button()
+ if button == Qt.LeftButton:
+ self.press(event.position())
+ return
+ name = "right" if button == Qt.RightButton else "middle" if button == Qt.MiddleButton else ""
+ if name and name == prefs().step_aside_button:
+ self.controller.step_aside()
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ self.release()
+
+ def press(self, pos):
+ self.arm.press()
+ self.arm.held = True
+ self.down_point = QPointF(pos)
+ self.hide_hint()
+ self.controller.tapped(self)
+ self.aim(pos)
+
+ def drag(self, pos):
+ start = self.down_point
+ if start is None:
+ return
+ if not self.ink.is_drawing:
+ if (pos - start).manhattanLength() < 4:
+ return
+ self._update_rect(self.ink.begin(start, self.ink_width))
+ self._update_zones(pos)
+ segment = self.ink.extend(pos)
+ if segment is not None:
+ self._update_rect(segment)
+
+ def release(self):
+ if self.down_point is None and not self.arm.held:
+ return
+ self.arm.held = False
+ self.down_point = None
+ self.ink.end()
+ if self.ink.has_pending_fade() and not self.fade_timer.isActive():
+ self.fade_timer.start()
+ if self.last_mouse is not None:
+ self.aim(self.last_mouse)
+
+ # -- ink -------------------------------------------------------------------
+
+ def clear_ink(self):
+ for rect in self.ink.clear():
+ self._update_rect(rect)
+ self.fade_timer.stop()
+
+ def _fade_tick(self):
+ now = time.monotonic()
+ for stroke in self.ink.strokes:
+ if self.ink.is_fading(stroke, now):
+ self._update_rect(stroke.bounds)
+ for rect in self.ink.prune(now):
+ self._update_rect(rect)
+ if not self.ink.has_pending_fade():
+ self.fade_timer.stop()
+
+ # -- hint ------------------------------------------------------------------
+
+ def show_hint(self, parts, seconds):
+ self.hide_hint()
+ self.hint = HintStrip(self, parts)
+ self._place_hint()
+ self.hint.show()
+ self.hint.raise_()
+ self.hint.fade(1.0, 200)
+ QTimer.singleShot(int(seconds * 1000), self.hide_hint)
+
+ def _place_hint(self):
+ if self.hint is None:
+ return
+ x = (self.width() - self.hint.width()) // 2
+ y = self.toolbar.y() - 12 - self.hint.height()
+ self.hint.move(x, y)
+
+ def hide_hint(self):
+ hint = self.hint
+ if hint is None:
+ return
+ self.hint = None
+ hint.fade(0.0, 300, hint.deleteLater)
+
+ # -- painting --------------------------------------------------------------
+
+ def paintEvent(self, event):
+ COUNTS["paint"] += 1
+ painter = QPainter(self)
+ clip = QRectF(event.rect())
+ self.ink.paint(painter, clip)
+ if self.arm.bounds().intersects(clip):
+ self.arm.paint(painter)
+ painter.end()
diff --git a/linux/tinyhand/paths.py b/linux/tinyhand/paths.py
new file mode 100644
index 0000000..f6a0350
--- /dev/null
+++ b/linux/tinyhand/paths.py
@@ -0,0 +1,62 @@
+"""Where TinyHand finds its images, sounds, and user data."""
+
+import os
+import sys
+from pathlib import Path
+
+
+def _package_root():
+ return Path(__file__).resolve().parent
+
+
+def asset_dirs():
+ """Directories that can hold the hand images and sounds, in search order."""
+ dirs = []
+ env = os.environ.get("TINYHAND_ASSETS")
+ if env:
+ dirs.append(Path(env))
+ root = _package_root()
+ dirs.append(root.parent / "assets")
+ dirs.append(root.parent.parent / "assets")
+ dirs.append(data_home() / "assets")
+ dirs.append(Path("/usr/local/share/tinyhand/assets"))
+ dirs.append(Path("/usr/share/tinyhand/assets"))
+ return dirs
+
+
+def find_asset(relative):
+ for base in asset_dirs():
+ candidate = base / relative
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def data_home():
+ base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
+ return Path(base) / "tinyhand"
+
+
+def cache_home():
+ base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
+ return Path(base) / "tinyhand"
+
+
+def config_home():
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
+ return Path(base)
+
+
+def custom_hands_dir():
+ return data_home() / "hands"
+
+
+def launcher_command():
+ """The command that starts TinyHand, for autostart and desktop files."""
+ exe = Path(sys.argv[0]).resolve()
+ if exe.name == "tinyhand":
+ return str(exe)
+ run_py = _package_root().parent / "run.py"
+ if run_py.is_file():
+ return f"{sys.executable} {run_py}"
+ return f"{sys.executable} -m tinyhand"
diff --git a/linux/tinyhand/prefs.py b/linux/tinyhand/prefs.py
new file mode 100644
index 0000000..298f5e8
--- /dev/null
+++ b/linux/tinyhand/prefs.py
@@ -0,0 +1,220 @@
+"""User preferences, stored with QSettings in ~/.config/tinyhand/tinyhand.conf."""
+
+from pathlib import Path
+
+from PySide6.QtCore import QObject, QSettings, Signal
+
+from .paths import config_home, launcher_command
+from .styles import DEFAULT_PINNED, RED, LASER, PointerStyle, hex_color
+
+INK_CHOICES = [
+ ("match", "Match the pointer", None),
+ ("red", "Red", RED),
+ ("orange", "Orange", hex_color(0xFF8A00)),
+ ("yellow", "Yellow", hex_color(0xFFCC00)),
+ ("green", "Green", hex_color(0x30C25A)),
+ ("blue", "Blue", hex_color(0x0A84FF)),
+ ("purple", "Purple", hex_color(0x8754F2)),
+ ("white", "White", hex_color(0xFFFFFF)),
+ ("black", "Black", hex_color(0x1D1D21)),
+]
+
+TAP_SOUNDS = [
+ ("thock", "Thock"),
+ ("click", "Click"),
+ ("pop", "Pop"),
+ ("wood", "Wood block"),
+ ("boop", "Boop"),
+ ("squeak", "Squeak"),
+]
+
+STEP_ASIDE_BUTTONS = [
+ ("right", "Right mouse button"),
+ ("middle", "Middle mouse button"),
+ ("off", "Off"),
+]
+
+POINTER_SIZE_MIN = 0.08
+POINTER_SIZE_MAX = 0.20
+POINTER_SIZE_DEFAULT = 0.13
+
+DEFAULTS = {
+ "pointerStyle": "lavender",
+ "pointerSize": POINTER_SIZE_DEFAULT,
+ "showShadow": True,
+ "fadeSeconds": 3.0,
+ "sound.hands": True,
+ "sound.laser": False,
+ "tapSound": "thock",
+ "inkChoice": "match",
+ "showInkButton": True,
+ "pinnedStyles": list(DEFAULT_PINNED),
+ "stepAsideButton": "right",
+ "stepAsideSeconds": 3.0,
+ "firstRunHintShown": False,
+}
+
+
+def ink_color_for(choice_id, style):
+ for cid, _, color in INK_CHOICES:
+ if cid == choice_id and color is not None:
+ return color
+ return style.ink_color
+
+
+class Prefs(QObject):
+ """Typed access to the settings. `changed` fires after every write and after reload()."""
+
+ changed = Signal()
+
+ def __init__(self):
+ super().__init__()
+ self._settings = QSettings(QSettings.IniFormat, QSettings.UserScope, "tinyhand", "tinyhand")
+
+ @property
+ def file_path(self):
+ return self._settings.fileName()
+
+ def reload(self):
+ self._settings.sync()
+ self.changed.emit()
+
+ def _get(self, key, kind):
+ value = self._settings.value(key, DEFAULTS[key])
+ if kind is bool:
+ if isinstance(value, str):
+ return value.lower() in ("true", "1", "yes")
+ return bool(value)
+ if kind is float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return float(DEFAULTS[key])
+ if kind is list:
+ if isinstance(value, str):
+ return [value] if value else []
+ return list(value) if value else []
+ return str(value)
+
+ def set(self, key, value):
+ self._settings.setValue(key, value)
+ self._settings.sync()
+ self.changed.emit()
+
+ def reset_all(self):
+ for key in DEFAULTS:
+ self._settings.remove(key)
+ self._settings.sync()
+ self.changed.emit()
+
+ @property
+ def pointer_style(self):
+ return PointerStyle.from_id(self._get("pointerStyle", str))
+
+ @pointer_style.setter
+ def pointer_style(self, style):
+ self.set("pointerStyle", style.id)
+
+ @property
+ def pointer_size(self):
+ return min(POINTER_SIZE_MAX, max(POINTER_SIZE_MIN, self._get("pointerSize", float)))
+
+ @property
+ def show_shadow(self):
+ return self._get("showShadow", bool)
+
+ @property
+ def fade_seconds(self):
+ return self._get("fadeSeconds", float)
+
+ @property
+ def hand_sound(self):
+ return self._get("sound.hands", bool)
+
+ @property
+ def laser_sound(self):
+ return self._get("sound.laser", bool)
+
+ @property
+ def tap_sound(self):
+ value = self._get("tapSound", str)
+ return value if value in {sid for sid, _ in TAP_SOUNDS} else "thock"
+
+ @property
+ def ink_choice(self):
+ value = self._get("inkChoice", str)
+ return value if value in {cid for cid, _, _ in INK_CHOICES} else "match"
+
+ @property
+ def show_ink_button(self):
+ return self._get("showInkButton", bool)
+
+ @property
+ def pinned_styles(self):
+ known = {s.id for s in PointerStyle.all_styles()}
+ seen = []
+ for raw in self._get("pinnedStyles", list):
+ if raw in known and raw not in seen:
+ seen.append(raw)
+ if not seen:
+ seen = list(DEFAULT_PINNED)
+ return [PointerStyle(s) for s in seen]
+
+ @property
+ def step_aside_button(self):
+ value = self._get("stepAsideButton", str)
+ return value if value in {bid for bid, _ in STEP_ASIDE_BUTTONS} else "right"
+
+ @property
+ def step_aside_seconds(self):
+ return max(0.5, self._get("stepAsideSeconds", float))
+
+ @property
+ def first_run_hint_shown(self):
+ return self._get("firstRunHintShown", bool)
+
+ def sound_on_for(self, style):
+ return self.laser_sound if style.is_laser else self.hand_sound
+
+ def set_sound_on_for(self, style, value):
+ self.set("sound.laser" if style.is_laser else "sound.hands", bool(value))
+
+ # Launch at login is a desktop file in ~/.config/autostart, not a setting.
+
+ @property
+ def autostart_file(self):
+ return config_home() / "autostart" / "tinyhand.desktop"
+
+ @property
+ def launch_at_login(self):
+ return self.autostart_file.is_file()
+
+ @launch_at_login.setter
+ def launch_at_login(self, value):
+ path = self.autostart_file
+ if value:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ "[Desktop Entry]\n"
+ "Type=Application\n"
+ "Name=TinyHand\n"
+ "Comment=A tiny hand on a stick that points at things on your screen\n"
+ f"Exec={launcher_command()}\n"
+ "Icon=tinyhand\n"
+ "Terminal=false\n"
+ "X-KDE-autostart-after=panel\n"
+ "OnlyShowIn=KDE;\n"
+ )
+ else:
+ path.unlink(missing_ok=True)
+ self.changed.emit()
+
+
+_instance = None
+
+
+def prefs():
+ global _instance
+ if _instance is None:
+ _instance = Prefs()
+ return _instance
diff --git a/linux/tinyhand/settings_ui.py b/linux/tinyhand/settings_ui.py
new file mode 100644
index 0000000..8725739
--- /dev/null
+++ b/linux/tinyhand/settings_ui.py
@@ -0,0 +1,322 @@
+"""The settings window. Runs as its own process so it gets a normal window."""
+
+import shutil
+import subprocess
+import sys
+
+from PySide6.QtCore import QSize, Qt
+from PySide6.QtGui import QIcon
+from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QFrame, QGridLayout,
+ QGroupBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QScrollArea, QSlider,
+ QToolButton, QVBoxLayout, QWidget)
+
+from . import APP_NAME, DBUS_INTERFACE, DBUS_PATH, DBUS_SERVICE
+from .paths import custom_hands_dir
+from .prefs import (INK_CHOICES, POINTER_SIZE_MAX, POINTER_SIZE_MIN,
+ STEP_ASIDE_BUTTONS, TAP_SOUNDS, prefs)
+from .shortcuts import read_current_keys
+from .sound import TapSound
+from .styles import MAX_PINNED, HandAsset, PointerStyle
+from .toolbar import laser_icon
+
+
+def notify_daemon(method="Reload"):
+ from PySide6.QtDBus import QDBusConnection, QDBusInterface
+
+ iface = QDBusInterface(DBUS_SERVICE, DBUS_PATH, DBUS_INTERFACE, QDBusConnection.sessionBus())
+ if iface.isValid():
+ iface.call(method)
+
+
+class HandPicker(QWidget):
+ """A grid of every hand. Up to eight can be pinned to the toolbar."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.buttons = {}
+ self.updating = False
+ self.grid = QGridLayout(self)
+ self.grid.setSpacing(4)
+ self.rebuild()
+
+ def rebuild(self):
+ while self.grid.count():
+ item = self.grid.takeAt(0)
+ if item.widget():
+ item.widget().deleteLater()
+ self.buttons = {}
+ styles = PointerStyle.all_styles()
+ columns = 8
+ for i, style in enumerate(styles):
+ button = QToolButton(self)
+ button.setCheckable(True)
+ button.setAutoRaise(True)
+ button.setIconSize(QSize(44, 44))
+ button.setFixedSize(56, 56)
+ button.setToolTip(style.title)
+ pixmap = laser_icon() if style.is_laser else HandAsset.thumbnail(style)
+ if pixmap is not None:
+ button.setIcon(QIcon(pixmap))
+ else:
+ button.setText(style.title[:2])
+ button.toggled.connect(lambda checked, s=style: self._toggled(s, checked))
+ self.buttons[style] = button
+ self.grid.addWidget(button, i // columns, i % columns)
+ self.refresh()
+
+ def refresh(self):
+ self.updating = True
+ pinned = prefs().pinned_styles
+ for style, button in self.buttons.items():
+ button.setChecked(style in pinned)
+ self.updating = False
+
+ def _toggled(self, style, checked):
+ if self.updating:
+ return
+ pinned = prefs().pinned_styles
+ if checked:
+ if style in pinned:
+ return
+ if len(pinned) >= MAX_PINNED:
+ self.updating = True
+ self.buttons[style].setChecked(False)
+ self.updating = False
+ QMessageBox.information(self, APP_NAME, f"The toolbar holds up to {MAX_PINNED} pointers. "
+ "Unpin one first.")
+ return
+ pinned.append(style)
+ else:
+ if len(pinned) <= 1:
+ self.updating = True
+ self.buttons[style].setChecked(True)
+ self.updating = False
+ return
+ pinned = [s for s in pinned if s != style]
+ prefs().set("pinnedStyles", [s.id for s in pinned])
+ notify_daemon()
+
+
+class SettingsWindow(QWidget):
+ def __init__(self):
+ super().__init__()
+ self.setWindowTitle(f"{APP_NAME} Settings")
+ self.setMinimumWidth(560)
+ self.sound = TapSound(prefs().tap_sound)
+ self.sound.enabled = True
+ self.loading = True
+
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(0, 0, 0, 0)
+ scroll = QScrollArea(self)
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.NoFrame)
+ outer.addWidget(scroll)
+ body = QWidget()
+ scroll.setWidget(body)
+ layout = QVBoxLayout(body)
+ layout.setSpacing(14)
+ layout.setContentsMargins(18, 18, 18, 18)
+
+ # Pointer
+ pointer = QGroupBox("Pointer")
+ form = QFormLayout(pointer)
+ self.size_slider = QSlider(Qt.Horizontal)
+ self.size_slider.setRange(int(POINTER_SIZE_MIN * 100), int(POINTER_SIZE_MAX * 100))
+ self.size_slider.valueChanged.connect(lambda v: self._set("pointerSize", v / 100))
+ form.addRow("Size", self.size_slider)
+ self.shadow_box = QCheckBox("Draw a shadow under the hand")
+ self.shadow_box.toggled.connect(lambda v: self._set("showShadow", v))
+ form.addRow("", self.shadow_box)
+ self.style_combo = QComboBox()
+ for style in PointerStyle.all_styles():
+ self.style_combo.addItem(style.title, style.id)
+ self.style_combo.currentIndexChanged.connect(
+ lambda i: self._set("pointerStyle", self.style_combo.itemData(i)))
+ form.addRow("Current pointer", self.style_combo)
+ layout.addWidget(pointer)
+
+ pins = QGroupBox(f"Pointers in the toolbar (up to {MAX_PINNED})")
+ pins_layout = QVBoxLayout(pins)
+ self.picker = HandPicker(pins)
+ pins_layout.addWidget(self.picker)
+ custom = QLabel(f'Your own hands: put a PNG with a transparent background, the hand on its stick and the '
+ f'fingertip at the top, into {custom_hands_dir()}. The built-in hands in '
+ f'the assets folder show the layout.')
+ custom.setWordWrap(True)
+ custom.setTextFormat(Qt.RichText)
+ pins_layout.addWidget(custom)
+ layout.addWidget(pins)
+
+ # Drawing
+ drawing = QGroupBox("Drawing")
+ form = QFormLayout(drawing)
+ self.ink_combo = QComboBox()
+ for choice_id, title, _ in INK_CHOICES:
+ self.ink_combo.addItem(title, choice_id)
+ self.ink_combo.currentIndexChanged.connect(lambda i: self._set("inkChoice", self.ink_combo.itemData(i)))
+ form.addRow("Ink colour", self.ink_combo)
+ self.fade_spin = QDoubleSpinBox()
+ self.fade_spin.setRange(0, 60)
+ self.fade_spin.setDecimals(1)
+ self.fade_spin.setSingleStep(0.5)
+ self.fade_spin.setSuffix(" s")
+ self.fade_spin.setSpecialValueText("Keep until cleared")
+ self.fade_spin.valueChanged.connect(lambda v: self._set("fadeSeconds", v))
+ form.addRow("Fade drawing after", self.fade_spin)
+ self.ink_button_box = QCheckBox("Show the ink colour button in the toolbar")
+ self.ink_button_box.toggled.connect(lambda v: self._set("showInkButton", v))
+ form.addRow("", self.ink_button_box)
+ layout.addWidget(drawing)
+
+ # Sound
+ sound = QGroupBox("Tap sound")
+ form = QFormLayout(sound)
+ row = QHBoxLayout()
+ self.sound_combo = QComboBox()
+ for sound_id, title in TAP_SOUNDS:
+ self.sound_combo.addItem(title, sound_id)
+ self.sound_combo.currentIndexChanged.connect(self._sound_changed)
+ row.addWidget(self.sound_combo, 1)
+ play = QPushButton("Play")
+ play.clicked.connect(lambda: self.sound.play(self.sound_combo.currentData()))
+ row.addWidget(play)
+ form.addRow("Sound", row)
+ self.hand_sound_box = QCheckBox("Play when a hand taps")
+ self.hand_sound_box.toggled.connect(lambda v: self._set("sound.hands", v))
+ form.addRow("", self.hand_sound_box)
+ self.laser_sound_box = QCheckBox("Play when the laser dot taps")
+ self.laser_sound_box.toggled.connect(lambda v: self._set("sound.laser", v))
+ form.addRow("", self.laser_sound_box)
+ layout.addWidget(sound)
+
+ # Clicking through
+ through = QGroupBox("Clicking through")
+ form = QFormLayout(through)
+ self.aside_combo = QComboBox()
+ for button_id, title in STEP_ASIDE_BUTTONS:
+ self.aside_combo.addItem(title, button_id)
+ self.aside_combo.currentIndexChanged.connect(
+ lambda i: self._set("stepAsideButton", self.aside_combo.itemData(i)))
+ form.addRow("Hand steps aside on", self.aside_combo)
+ self.aside_spin = QDoubleSpinBox()
+ self.aside_spin.setRange(0.5, 30)
+ self.aside_spin.setDecimals(1)
+ self.aside_spin.setSingleStep(0.5)
+ self.aside_spin.setSuffix(" s")
+ self.aside_spin.valueChanged.connect(lambda v: self._set("stepAsideSeconds", v))
+ form.addRow("Comes back after", self.aside_spin)
+ note = QLabel("While the pointer is on, your clicks tap instead of clicking. Press the step-aside button "
+ "to hide the hand and click the real thing underneath. Wayland does not let an app pass a "
+ "click on, so the hand steps aside for a moment instead.")
+ note.setWordWrap(True)
+ form.addRow(note)
+ layout.addWidget(through)
+
+ # Shortcuts
+ keys = QGroupBox("Keyboard shortcuts")
+ form = QFormLayout(keys)
+ self.toggle_label = QLabel()
+ self.clear_label = QLabel()
+ form.addRow("Turn pointer on or off", self.toggle_label)
+ form.addRow("Clear the drawing", self.clear_label)
+ form.addRow("Turn pointer off", QLabel("Esc (while it is on)"))
+ change = QPushButton("Change in System Settings…")
+ change.clicked.connect(self._open_shortcut_settings)
+ form.addRow("", change)
+ hint = QLabel(f"The shortcuts are listed under “{APP_NAME}” in System Settings > Keyboard > Shortcuts "
+ f"once {APP_NAME} has run.")
+ hint.setWordWrap(True)
+ form.addRow(hint)
+ layout.addWidget(keys)
+
+ # Startup
+ startup = QGroupBox("Startup")
+ form = QFormLayout(startup)
+ self.login_box = QCheckBox("Open at login")
+ self.login_box.toggled.connect(self._login_changed)
+ form.addRow("", self.login_box)
+ layout.addWidget(startup)
+
+ bottom = QHBoxLayout()
+ reset = QPushButton("Reset to defaults")
+ reset.clicked.connect(self._reset)
+ bottom.addWidget(reset)
+ bottom.addStretch(1)
+ where = QLabel(f"Settings file: {prefs().file_path}")
+ where.setTextInteractionFlags(Qt.TextSelectableByMouse)
+ bottom.addWidget(where)
+ layout.addLayout(bottom)
+
+ self.load()
+ self.resize(640, 820)
+
+ def load(self):
+ self.loading = True
+ p = prefs()
+ self.size_slider.setValue(int(round(p.pointer_size * 100)))
+ self.shadow_box.setChecked(p.show_shadow)
+ index = self.style_combo.findData(p.pointer_style.id)
+ self.style_combo.setCurrentIndex(max(0, index))
+ self.ink_combo.setCurrentIndex(max(0, self.ink_combo.findData(p.ink_choice)))
+ self.fade_spin.setValue(p.fade_seconds)
+ self.ink_button_box.setChecked(p.show_ink_button)
+ self.sound_combo.setCurrentIndex(max(0, self.sound_combo.findData(p.tap_sound)))
+ self.hand_sound_box.setChecked(p.hand_sound)
+ self.laser_sound_box.setChecked(p.laser_sound)
+ self.aside_combo.setCurrentIndex(max(0, self.aside_combo.findData(p.step_aside_button)))
+ self.aside_spin.setValue(p.step_aside_seconds)
+ self.login_box.setChecked(p.launch_at_login)
+ self.toggle_label.setText(read_current_keys("toggle", "Turn pointer on or off") or "Ctrl+Alt+H")
+ self.clear_label.setText(read_current_keys("clear", "Clear the drawing") or "Ctrl+Alt+X")
+ self.picker.refresh()
+ self.loading = False
+
+ def _set(self, key, value):
+ if self.loading:
+ return
+ prefs().set(key, value)
+ notify_daemon()
+
+ def _sound_changed(self, index):
+ if self.loading:
+ return
+ choice = self.sound_combo.itemData(index)
+ self._set("tapSound", choice)
+ self.sound.play(choice)
+
+ def _login_changed(self, value):
+ if self.loading:
+ return
+ prefs().launch_at_login = value
+
+ def _reset(self):
+ prefs().reset_all()
+ notify_daemon()
+ self.load()
+
+ def _open_shortcut_settings(self):
+ exe = shutil.which("systemsettings") or shutil.which("systemsettings5")
+ if exe is None:
+ QMessageBox.information(self, APP_NAME, "System Settings was not found. Open it from the launcher and "
+ "look for Keyboard > Shortcuts.")
+ return
+ subprocess.Popen([exe, "kcm_keys"])
+
+
+def run_settings(argv):
+ app = QApplication(argv)
+ app.setApplicationName("tinyhand")
+ app.setDesktopFileName("tinyhand")
+ icon = QIcon.fromTheme("tinyhand")
+ if icon.isNull():
+ from pathlib import Path
+
+ icon_path = Path(__file__).resolve().parent.parent / "data" / "icons" / "tinyhand.png"
+ if icon_path.is_file():
+ icon = QIcon(str(icon_path))
+ app.setWindowIcon(icon)
+ notify_daemon("TurnOff")
+ window = SettingsWindow()
+ window.show()
+ return app.exec()
diff --git a/linux/tinyhand/shortcuts.py b/linux/tinyhand/shortcuts.py
new file mode 100644
index 0000000..664a6d2
--- /dev/null
+++ b/linux/tinyhand/shortcuts.py
@@ -0,0 +1,136 @@
+"""Global shortcuts through KDE's KGlobalAccel daemon over D-Bus.
+
+The shortcuts show up in System Settings > Keyboard > Shortcuts under "TinyHand",
+where the user can change them. We use busctl for the calls whose arguments PySide6
+cannot marshal (integer arrays) and QtDBus for the signal.
+"""
+
+import shutil
+import subprocess
+
+from PySide6.QtCore import QObject, Slot, SLOT
+from PySide6.QtDBus import QDBusConnection, QDBusInterface, QDBusMessage
+from PySide6.QtGui import QKeySequence
+
+COMPONENT = "tinyhand"
+COMPONENT_FRIENDLY = "TinyHand"
+SERVICE = "org.kde.kglobalaccel"
+PATH = "/kglobalaccel"
+INTERFACE = "org.kde.KGlobalAccel"
+COMPONENT_INTERFACE = "org.kde.kglobalaccel.Component"
+
+SET_PRESENT = 2
+NO_AUTOLOADING = 4
+IS_DEFAULT = 8
+
+
+def _busctl(method, signature="", *args):
+ exe = shutil.which("busctl")
+ if exe is None:
+ return None
+ cmd = [exe, "--user", "--timeout=3", "call", SERVICE, PATH, INTERFACE, method]
+ if signature:
+ cmd.append(signature)
+ cmd.extend(str(a) for a in args)
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ if result.returncode != 0:
+ return None
+ return result.stdout.strip()
+
+
+def _parse_int_array(text):
+ """busctl prints `ai 2 123 456`; return the integers."""
+ if not text:
+ return []
+ parts = text.split()
+ if len(parts) < 2 or parts[0] != "ai":
+ return []
+ try:
+ count = int(parts[1])
+ return [int(p) for p in parts[2 : 2 + count]]
+ except ValueError:
+ return []
+
+
+class GlobalShortcuts(QObject):
+ """Registers named actions with default keys and calls back when one is pressed."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._bus = QDBusConnection.sessionBus()
+ self._callbacks = {}
+ self._friendly = {}
+ self._defaults = {}
+ self.available = self._bus.isConnected() and shutil.which("busctl") is not None
+ self._listening = False
+
+ def _action_id(self, action):
+ return [COMPONENT, action, COMPONENT_FRIENDLY, self._friendly.get(action, action)]
+
+ def register(self, action, friendly, default_keys, callback, active=True):
+ """`default_keys` is a key sequence string such as "Ctrl+Alt+H"."""
+ self._callbacks[action] = callback
+ self._friendly[action] = friendly
+ self._defaults[action] = default_keys
+ if not self.available:
+ return False
+ iface = QDBusInterface(SERVICE, PATH, INTERFACE, self._bus)
+ iface.call("doRegister", self._action_id(action))
+ key = QKeySequence(default_keys)[0].toCombined() if default_keys else 0
+ aid = self._action_id(action)
+ _busctl("setShortcut", "asaiu", 4, *aid, 1, key, SET_PRESENT | IS_DEFAULT)
+ if active:
+ _busctl("setShortcut", "asaiu", 4, *aid, 1, key, SET_PRESENT)
+ else:
+ _busctl("setInactive", "as", 4, *aid)
+ self._listen()
+ return True
+
+ def set_active(self, action, active):
+ if not self.available or action not in self._callbacks:
+ return
+ aid = self._action_id(action)
+ if active:
+ key = QKeySequence(self._defaults[action])[0].toCombined() if self._defaults[action] else 0
+ _busctl("setShortcut", "asaiu", 4, *aid, 1, key, SET_PRESENT)
+ else:
+ _busctl("setInactive", "as", 4, *aid)
+
+ def current_keys(self, action):
+ """The key sequence the user currently has for an action, as display text."""
+ if not self.available:
+ return self._defaults.get(action, "")
+ text = _busctl("shortcut", "as", 4, *self._action_id(action))
+ keys = [k for k in _parse_int_array(text) if k > 0]
+ if not keys:
+ return ""
+ return QKeySequence(keys[0]).toString(QKeySequence.NativeText)
+
+ def _listen(self):
+ if self._listening:
+ return
+ self._listening = self._bus.connect(
+ SERVICE, f"/component/{COMPONENT}", COMPONENT_INTERFACE, "globalShortcutPressed",
+ self, SLOT("_pressed(QDBusMessage)"),
+ )
+
+ @Slot(QDBusMessage)
+ def _pressed(self, message):
+ args = message.arguments()
+ if len(args) < 2 or args[0] != COMPONENT:
+ return
+ callback = self._callbacks.get(args[1])
+ if callback is not None:
+ callback()
+
+
+def read_current_keys(action, friendly_fallback=""):
+ """Read a shortcut without registering anything, for the settings window."""
+ text = _busctl("shortcut", "as", 4, COMPONENT, action, COMPONENT_FRIENDLY, friendly_fallback or action)
+ keys = [k for k in _parse_int_array(text) if k > 0]
+ if not keys:
+ return ""
+ return QKeySequence(keys[0]).toString(QKeySequence.NativeText)
diff --git a/linux/tinyhand/sound.py b/linux/tinyhand/sound.py
new file mode 100644
index 0000000..11e5b56
--- /dev/null
+++ b/linux/tinyhand/sound.py
@@ -0,0 +1,50 @@
+"""The tap sound."""
+
+import shutil
+import subprocess
+
+from PySide6.QtCore import QUrl
+
+from .paths import find_asset
+from .prefs import TAP_SOUNDS
+
+try:
+ from PySide6.QtMultimedia import QSoundEffect
+except ImportError: # QtMultimedia is optional; we fall back to a command line player.
+ QSoundEffect = None
+
+
+class TapSound:
+ def __init__(self, choice="thock"):
+ self.enabled = True
+ self.choice = choice
+ self._effects = {}
+ self._player = shutil.which("pw-play") or shutil.which("paplay") or shutil.which("aplay")
+ if QSoundEffect is not None:
+ for sound_id, _ in TAP_SOUNDS:
+ path = find_asset(f"sounds/tap_{sound_id}.wav")
+ if path is None:
+ continue
+ effect = QSoundEffect()
+ effect.setSource(QUrl.fromLocalFile(str(path)))
+ effect.setVolume(1.0)
+ self._effects[sound_id] = effect
+
+ def load(self, choice):
+ self.choice = choice
+
+ def play(self, choice=None):
+ if not self.enabled and choice is None:
+ return
+ sound_id = choice or self.choice
+ effect = self._effects.get(sound_id)
+ if effect is not None and effect.status() != QSoundEffect.Status.Error:
+ effect.play()
+ return
+ path = find_asset(f"sounds/tap_{sound_id}.wav")
+ if path is None or self._player is None:
+ return
+ try:
+ subprocess.Popen([self._player, str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ except OSError:
+ pass
diff --git a/linux/tinyhand/styles.py b/linux/tinyhand/styles.py
new file mode 100644
index 0000000..04147cf
--- /dev/null
+++ b/linux/tinyhand/styles.py
@@ -0,0 +1,332 @@
+"""Pointer styles (the 36 hands and the laser dot) and the hand image measurements."""
+
+import colorsys
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+import numpy as np
+from PySide6.QtCore import QRect, Qt
+from PySide6.QtGui import QColor, QImage, QPixmap
+
+from .paths import cache_home, custom_hands_dir, find_asset
+
+PURPLE = QColor.fromRgbF(0.53, 0.33, 0.95)
+RED = QColor.fromRgbF(1.0, 0.16, 0.12)
+
+
+def hex_color(value):
+ return QColor((value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF)
+
+
+BUILT_IN = [
+ ("lavender", "Lavender toy", PURPLE),
+ ("glove", "Cursor glove", PURPLE),
+ ("coral", "Coral vinyl", RED),
+ ("plush", "Plush", hex_color(0x2F86F0)),
+ ("robot", "Robot", hex_color(0x16A87A)),
+ ("pink", "Bubblegum", hex_color(0xEE4F98)),
+ ("blue", "Sky", hex_color(0x2F86F0)),
+ ("mint", "Mint", hex_color(0x16A87A)),
+ ("lemon", "Lemon", hex_color(0xF2B705)),
+ ("black", "Midnight", hex_color(0x1D1D21)),
+ ("chrome", "Chrome", hex_color(0x6B7380)),
+ ("holo", "Holo", PURPLE),
+ ("fuzzy", "Fuzzy", PURPLE),
+ ("wood", "Wood", hex_color(0x9A5E2C)),
+ ("marble", "Marble", hex_color(0x6B7380)),
+ ("terrazzo", "Terrazzo", hex_color(0xF0561D)),
+ ("glass", "Glass", hex_color(0x1597E5)),
+ ("jelly", "Jelly", hex_color(0xA33FE6)),
+ ("pastel", "Pastel", hex_color(0xEE4F98)),
+ ("pixel", "Pixel", hex_color(0x1D1D21)),
+ ("lowpoly", "Low poly", PURPLE),
+ ("knit", "Knit", PURPLE),
+ ("skeleton", "Skeleton", hex_color(0x9A5E2C)),
+ ("astro", "Astronaut", hex_color(0x2F86F0)),
+ ("space", "Space", RED),
+ ("monster", "Monster", hex_color(0x3E9E2E)),
+ ("water", "Water", hex_color(0x1597E5)),
+ ("lava", "Lava", hex_color(0xF0561D)),
+ ("grass", "Grass", hex_color(0x3E9E2E)),
+ ("cactus", "Cactus", hex_color(0x3E9E2E)),
+ ("choco", "Chocolate", hex_color(0x9A5E2C)),
+ ("cloud", "Cloud", hex_color(0x2F86F0)),
+ ("mech", "Mech", hex_color(0xF0561D)),
+ ("neon", "Neon", hex_color(0xE0309A)),
+ ("leaf", "Leaf", hex_color(0x3E9E2E)),
+ ("cookie", "Gingerbread", hex_color(0x9A5E2C)),
+]
+
+LASER = "laser"
+DEFAULT_PINNED = ["lavender", "glove", "coral", "plush", "robot", LASER]
+MAX_PINNED = 8
+
+
+class PointerStyle:
+ """One pointer look. Built-in hands are keyed by their short id, custom hands by file name."""
+
+ __slots__ = ("id",)
+
+ def __init__(self, style_id):
+ self.id = style_id
+
+ def __eq__(self, other):
+ return isinstance(other, PointerStyle) and other.id == self.id
+
+ def __hash__(self):
+ return hash(self.id)
+
+ def __repr__(self):
+ return f"PointerStyle({self.id!r})"
+
+ @property
+ def is_laser(self):
+ return self.id == LASER
+
+ @property
+ def is_custom(self):
+ return self.id.startswith("custom:")
+
+ @property
+ def title(self):
+ if self.is_laser:
+ return "Laser dot"
+ if self.is_custom:
+ name = self.id[len("custom:"):]
+ return name.replace("_", " ").replace("-", " ").strip().capitalize() or name
+ for style_id, title, _ in BUILT_IN:
+ if style_id == self.id:
+ return title
+ return self.id
+
+ @property
+ def image_path(self):
+ if self.is_laser:
+ return None
+ if self.is_custom:
+ path = custom_hands_dir() / (self.id[len("custom:"):] + ".png")
+ return path if path.is_file() else None
+ return find_asset(f"hand_{self.id}.png")
+
+ @property
+ def ink_color(self):
+ if self.is_laser:
+ return RED
+ if self.is_custom:
+ asset = HandAsset.load(self)
+ return asset.ink if asset is not None and asset.ink is not None else PURPLE
+ for style_id, _, color in BUILT_IN:
+ if style_id == self.id:
+ return color
+ return PURPLE
+
+ @staticmethod
+ def built_in_hands():
+ return [PointerStyle(style_id) for style_id, _, _ in BUILT_IN]
+
+ @staticmethod
+ def custom_hands():
+ folder = custom_hands_dir()
+ if not folder.is_dir():
+ return []
+ names = sorted(p.stem for p in folder.glob("*.png"))
+ return [PointerStyle("custom:" + name) for name in names]
+
+ @staticmethod
+ def all_styles():
+ return PointerStyle.built_in_hands() + PointerStyle.custom_hands() + [PointerStyle(LASER)]
+
+ @staticmethod
+ def from_id(style_id, fallback="lavender"):
+ if style_id == "dot":
+ style_id = LASER
+ if style_id == "sunny":
+ style_id = "coral"
+ style = PointerStyle(style_id or "")
+ if style.is_laser or style.id in {s.id for s in PointerStyle.all_styles()}:
+ return style
+ return PointerStyle(fallback)
+
+
+@dataclass
+class HandAsset:
+ """A hand image and where its parts sit, all as fractions of the image size."""
+
+ image: QImage
+ aspect: float
+ hand_top: float
+ tip: float
+ wrist: float
+ rod_end: float
+ tip_x: float
+ rod_x: float
+ hand_min_x: float
+ hand_max_x: float
+ ink: QColor | None
+
+ _cache = {}
+ _recent = []
+ _cache_limit = 3
+ _thumbnails = {}
+
+ @classmethod
+ def load(cls, style):
+ path = style.image_path
+ if path is None:
+ return None
+ key = str(path)
+ hit = cls._cache.get(key)
+ if hit is not None:
+ cls._touch(key)
+ return hit
+ image = QImage(str(path))
+ if image.isNull():
+ return None
+ image = image.convertToFormat(QImage.Format_ARGB32_Premultiplied)
+ asset = cls.measure(image, want_ink=style.is_custom)
+ if asset is None:
+ return None
+ cls._cache[key] = asset
+ cls._touch(key)
+ return asset
+
+ @classmethod
+ def _touch(cls, key):
+ if key in cls._recent:
+ cls._recent.remove(key)
+ cls._recent.append(key)
+ while len(cls._recent) > cls._cache_limit:
+ cls._cache.pop(cls._recent.pop(0), None)
+
+ @classmethod
+ def forget(cls, style):
+ path = style.image_path
+ if path is None:
+ return
+ cls._cache.pop(str(path), None)
+ cls._thumbnails.pop(style.id, None)
+
+ @staticmethod
+ def measure(image, want_ink=False):
+ """Find the fingertip, wrist, and stick in the alpha channel, like the macOS version."""
+ w = image.width()
+ h = image.height()
+ if w < 8 or h < 8:
+ return None
+ if image.format() != QImage.Format_ARGB32_Premultiplied:
+ image = image.convertToFormat(QImage.Format_ARGB32_Premultiplied)
+ raw = np.frombuffer(image.constBits(), dtype=np.uint8)
+ pixels = raw.reshape(h, image.bytesPerLine())[:, : w * 4].reshape(h, w, 4)
+ alpha = pixels[:, :, 3] > 128
+ counts = alpha.sum(axis=1)
+ rows = np.flatnonzero(counts)
+ if len(rows) == 0:
+ return None
+ top = int(rows[0])
+ last = int(rows[-1])
+ search_end = min(h, top + int(0.45 * h))
+ max_w = int(counts[top:search_end].max())
+ if max_w <= 0:
+ return None
+ fist_rows = np.flatnonzero(counts[top:] > 0.6 * max_w)
+ if len(fist_rows) == 0:
+ return None
+ fist = top + int(fist_rows[0])
+ wrist_rows = np.flatnonzero(counts[fist:] < 0.35 * max_w)
+ if len(wrist_rows) == 0:
+ return None
+ wrist = fist + int(wrist_rows[0])
+ rod_w = int(counts[min(h - 1, wrist + h // 50)])
+ start = min(h - 1, wrist + h // 33)
+ handle_rows = np.flatnonzero(counts[start:] > 1.8 * rod_w)
+ rod_end = start + int(handle_rows[0]) if len(handle_rows) else last
+
+ def center(y):
+ xs = np.flatnonzero(alpha[y])
+ if len(xs) == 0:
+ return 0.5
+ return (int(xs[0]) + int(xs[-1])) / 2 / w
+
+ columns = np.flatnonzero(alpha[top:wrist].any(axis=0))
+ min_x = int(columns[0]) if len(columns) else 0
+ max_x = int(columns[-1]) if len(columns) else w - 1
+
+ ink = None
+ if want_ink:
+ ink = HandAsset._dominant_color(pixels[top:wrist])
+
+ fh = float(h)
+ return HandAsset(
+ image=image,
+ aspect=w / fh,
+ hand_top=max(0.0, top - 0.004 * fh) / fh,
+ tip=top / fh,
+ wrist=wrist / fh,
+ rod_end=rod_end / fh,
+ tip_x=center(min(h - 1, top + h // 200)),
+ rod_x=center((wrist + rod_end) // 2),
+ hand_min_x=min_x / w,
+ hand_max_x=max_x / w,
+ ink=ink,
+ )
+
+ @staticmethod
+ def _dominant_color(pixels):
+ """A bright, saturated version of the hand's average colour, for custom hands' ink."""
+ solid = pixels[pixels[:, :, 3] > 200]
+ if len(solid) == 0:
+ return None
+ mean = solid[:, :3].astype(np.float64).mean(axis=0) / 255.0
+ b, g, r = mean
+ hue, sat, val = colorsys.rgb_to_hsv(r, g, b)
+ sat = max(sat, 0.6)
+ val = max(val, 0.75)
+ r, g, b = colorsys.hsv_to_rgb(hue, sat, val)
+ return QColor.fromRgbF(r, g, b)
+
+ @classmethod
+ def thumbnail(cls, style, size=64):
+ """A square crop of just the hand, cached in memory and on disk."""
+ key = (style.id, size)
+ hit = cls._thumbnails.get(key)
+ if hit is not None:
+ return hit
+ path = style.image_path
+ if path is None:
+ return None
+ cache_dir = cache_home() / "thumbs"
+ stamp = f"{path.stat().st_mtime_ns}-{path.stat().st_size}-{size}"
+ safe = style.id.replace(":", "_").replace("/", "_")
+ cache_file = cache_dir / f"{safe}-{stamp}.png"
+ if cache_file.is_file():
+ pix = QPixmap(str(cache_file))
+ if not pix.isNull():
+ cls._thumbnails[key] = pix
+ return pix
+ asset = cls.load(style)
+ if asset is None:
+ return None
+ image = asset.image
+ w = image.width()
+ h = image.height()
+ hand_w = (asset.hand_max_x - asset.hand_min_x) * w
+ hand_h = (asset.wrist - asset.hand_top) * h
+ side = max(hand_w, hand_h) * 1.06
+ cx = (asset.hand_min_x + asset.hand_max_x) / 2 * w
+ cy = (asset.hand_top + asset.wrist) / 2 * h
+ rect = QRect(int(cx - side / 2), int(cy - side / 2), int(side), int(side)).intersected(QRect(0, 0, w, h))
+ px = size * 2
+ small = image.copy(rect).scaled(px, px, Qt.KeepAspectRatio, Qt.SmoothTransformation)
+ small.setDevicePixelRatio(2.0)
+ pix = QPixmap.fromImage(small)
+ try:
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ for old in cache_dir.glob(f"{safe}-*.png"):
+ old.unlink(missing_ok=True)
+ small.save(str(cache_file))
+ except OSError:
+ pass
+ cls._thumbnails[key] = pix
+ return pix
diff --git a/linux/tinyhand/toolbar.py b/linux/tinyhand/toolbar.py
new file mode 100644
index 0000000..4e2d1e2
--- /dev/null
+++ b/linux/tinyhand/toolbar.py
@@ -0,0 +1,436 @@
+"""The small toolbar at the bottom of the screen, drawn inside the overlay window."""
+
+import math
+
+from PySide6.QtCore import QEasingCurve, QPointF, QPropertyAnimation, QRect, QRectF, QSize, Qt, Signal
+from PySide6.QtGui import QColor, QFont, QLinearGradient, QPainter, QPainterPath, QPen, QPixmap
+from PySide6.QtWidgets import QGraphicsOpacityEffect, QHBoxLayout, QLabel, QWidget
+
+from .prefs import INK_CHOICES, ink_color_for, prefs
+from .styles import RED, HandAsset
+
+BACKGROUND = QColor(28, 28, 32, 222)
+BORDER = QColor(255, 255, 255, 36)
+
+
+def rounded_panel(painter, rect, radius):
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ path = QPainterPath()
+ path.addRoundedRect(QRectF(rect), radius, radius)
+ painter.fillPath(path, BACKGROUND)
+ painter.setPen(QPen(BORDER, 1))
+ painter.setBrush(Qt.NoBrush)
+ painter.drawPath(path)
+
+
+def ink_dot(color, size=64):
+ pix = QPixmap(size, size)
+ pix.fill(Qt.transparent)
+ p = QPainter(pix)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ c = QPointF(size / 2, size / 2)
+ r = size * 13 / 64
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(0, 0, 0, 90))
+ p.drawEllipse(QRectF(c.x() - r - 1.5, c.y() - r - 0.5, 2 * r + 3, 2 * r + 3))
+ p.setBrush(color)
+ p.drawEllipse(c, r, r)
+ p.setBrush(QColor(255, 255, 255, 90))
+ p.drawEllipse(QRectF(c.x() - r * 0.55, c.y() - r * 0.65, r * 0.7, r * 0.5))
+ p.end()
+ return pix
+
+
+def rainbow_dot(size=64):
+ pix = QPixmap(size, size)
+ pix.fill(Qt.transparent)
+ p = QPainter(pix)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ c = QPointF(size / 2, size / 2)
+ r = size * 13 / 64
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(0, 0, 0, 90))
+ p.drawEllipse(QRectF(c.x() - r - 1.5, c.y() - r - 0.5, 2 * r + 3, 2 * r + 3))
+ colors = [QColor(255, 59, 48), QColor(255, 149, 0), QColor(255, 204, 0), QColor(52, 199, 89), QColor(0, 122, 255),
+ QColor(175, 82, 222)]
+ rect = QRectF(c.x() - r, c.y() - r, 2 * r, 2 * r)
+ for i, color in enumerate(colors):
+ p.setBrush(color)
+ p.drawPie(rect, int((90 - i * 60) * 16), int(-60 * 16))
+ p.end()
+ return pix
+
+
+def laser_icon(size=64):
+ pix = QPixmap(size, size)
+ pix.fill(Qt.transparent)
+ p = QPainter(pix)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ c = QPointF(size / 2, size / 2)
+ p.setPen(Qt.NoPen)
+ from PySide6.QtGui import QRadialGradient
+
+ glow = QRadialGradient(c, size * 0.3)
+ red = QColor(RED)
+ red.setAlphaF(0.55)
+ glow.setColorAt(0, red)
+ red2 = QColor(RED)
+ red2.setAlphaF(0)
+ glow.setColorAt(1, red2)
+ p.setBrush(glow)
+ p.drawEllipse(c, size * 0.3, size * 0.3)
+ p.setBrush(RED)
+ p.drawEllipse(c, 7, 7)
+ p.setBrush(QColor.fromRgbF(1, 0.86, 0.82, 0.95))
+ p.drawEllipse(c, 3, 3)
+ p.end()
+ return pix
+
+
+def symbol_icon(name, size=40, alpha=0.85):
+ """Simple vector icons: speaker, muted speaker, close."""
+ pix = QPixmap(size, size)
+ pix.fill(Qt.transparent)
+ p = QPainter(pix)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ color = QColor(255, 255, 255, int(alpha * 255))
+ pen = QPen(color, 2.2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
+ p.setPen(pen)
+ c = size / 2
+ if name == "close":
+ d = size * 0.17
+ p.drawLine(QPointF(c - d, c - d), QPointF(c + d, c + d))
+ p.drawLine(QPointF(c + d, c - d), QPointF(c - d, c + d))
+ elif name in ("sound", "muted"):
+ body = QPainterPath()
+ body.moveTo(c - 9, c - 4)
+ body.lineTo(c - 4, c - 4)
+ body.lineTo(c + 1, c - 9)
+ body.lineTo(c + 1, c + 9)
+ body.lineTo(c - 4, c + 4)
+ body.lineTo(c - 9, c + 4)
+ body.closeSubpath()
+ p.fillPath(body, color)
+ if name == "sound":
+ p.drawArc(QRectF(c - 2, c - 5, 10, 10), -50 * 16, 100 * 16)
+ p.drawArc(QRectF(c - 4, c - 9, 18, 18), -50 * 16, 100 * 16)
+ else:
+ p.drawLine(QPointF(c + 4, c - 4), QPointF(c + 11, c + 4))
+ p.drawLine(QPointF(c + 11, c - 4), QPointF(c + 4, c + 4))
+ p.end()
+ return pix
+
+
+class IconButton(QWidget):
+ clicked = Signal()
+
+ def __init__(self, parent, pixmap=None, side=40, icon_size=None, tip=""):
+ super().__init__(parent)
+ self.pixmap = pixmap
+ self.side = side
+ self.icon_size = icon_size or int(side * 0.85)
+ self.tip = tip
+ self.selected = False
+ self.hover = False
+ self.radius = 10
+ self.setFixedSize(side, side)
+ self.setMouseTracking(True)
+
+ def set_pixmap(self, pixmap):
+ self.pixmap = pixmap
+ self.update()
+
+ def set_selected(self, value):
+ if value != self.selected:
+ self.selected = value
+ self.update()
+
+ def enterEvent(self, event):
+ self.hover = True
+ self.update()
+
+ def leaveEvent(self, event):
+ self.hover = False
+ self.update()
+
+ def mousePressEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ event.accept()
+ else:
+ event.ignore()
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == Qt.LeftButton:
+ event.accept()
+ if self.rect().contains(event.position().toPoint()):
+ self.clicked.emit()
+ else:
+ event.ignore()
+
+ def paintEvent(self, event):
+ p = QPainter(self)
+ p.setRenderHint(QPainter.RenderHint.Antialiasing)
+ p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
+ if self.selected or self.hover:
+ p.setPen(Qt.NoPen)
+ p.setBrush(QColor(255, 255, 255, 46 if self.selected else 24))
+ p.drawRoundedRect(QRectF(self.rect()), self.radius, self.radius)
+ if self.pixmap is not None:
+ s = self.icon_size
+ target = QRectF((self.side - s) / 2, (self.side - s) / 2, s, s)
+ p.drawPixmap(target, self.pixmap, QRectF(self.pixmap.rect()))
+
+
+class Divider(QWidget):
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.setFixedSize(1, 24)
+
+ def paintEvent(self, event):
+ p = QPainter(self)
+ p.fillRect(self.rect(), QColor(255, 255, 255, 46))
+
+
+class InkTray(QWidget):
+ """Nine ink swatches that pop up above the ink button."""
+
+ picked = Signal(str)
+
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.setMouseTracking(True)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(6, 5, 6, 5)
+ layout.setSpacing(2)
+ self.swatches = {}
+ for choice_id, title, color in INK_CHOICES:
+ button = IconButton(self, pixmap=ink_dot(color) if color is not None else rainbow_dot(), side=30,
+ icon_size=28, tip=title)
+ button.radius = 15
+ button.clicked.connect(lambda cid=choice_id: self.picked.emit(cid))
+ self.swatches[choice_id] = button
+ layout.addWidget(button)
+ self.adjustSize()
+ self.hide()
+
+ def refresh(self, current):
+ for choice_id, button in self.swatches.items():
+ button.set_selected(choice_id == current)
+
+ def paintEvent(self, event):
+ p = QPainter(self)
+ rounded_panel(p, self.rect(), 14)
+
+
+class HintStrip(QWidget):
+ """The first-run tips shown above the toolbar."""
+
+ def __init__(self, parent, parts):
+ super().__init__(parent)
+ self.setAttribute(Qt.WA_TransparentForMouseEvents)
+ layout = QHBoxLayout(self)
+ layout.setContentsMargins(16, 9, 16, 9)
+ layout.setSpacing(14)
+ for key, text in parts:
+ label = QLabel(f'{key}'
+ f' {text}', self)
+ label.setTextFormat(Qt.RichText)
+ font = QFont()
+ font.setPointSizeF(10.5)
+ label.setFont(font)
+ layout.addWidget(label)
+ self.adjustSize()
+ self.effect = QGraphicsOpacityEffect(self)
+ self.effect.setOpacity(0.0)
+ self.setGraphicsEffect(self.effect)
+ self.animation = QPropertyAnimation(self.effect, b"opacity", self)
+ self.animation.finished.connect(self._finished)
+ self.on_done = None
+
+ def fade(self, to, duration, on_done=None):
+ self.animation.stop()
+ self.animation.setDuration(duration)
+ self.animation.setStartValue(self.effect.opacity())
+ self.animation.setEndValue(to)
+ self.on_done = on_done
+ self.animation.start()
+
+ def _finished(self):
+ done = self.on_done
+ self.on_done = None
+ if done is not None:
+ done()
+
+ def paintEvent(self, event):
+ p = QPainter(self)
+ rounded_panel(p, self.rect(), 12)
+
+
+class Toolbar(QWidget):
+ pick = Signal(object)
+ toggle_sound = Signal()
+ exit_requested = Signal()
+
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.setMouseTracking(True)
+ self.layout_ = QHBoxLayout(self)
+ self.layout_.setContentsMargins(10, 8, 10, 8)
+ self.layout_.setSpacing(6)
+ self.style_buttons = {}
+ self.shown_styles = []
+ self.current = None
+ self.revealed = False
+ self.ink_open = False
+
+ self.divider = Divider(self)
+ self.layout_.addWidget(self.divider)
+ self.ink_button = IconButton(self, tip="Ink colour")
+ self.ink_button.clicked.connect(self._toggle_ink)
+ self.layout_.addWidget(self.ink_button)
+ self.sound_button = IconButton(self, pixmap=symbol_icon("sound"), icon_size=40, tip="Tap sound")
+ self.sound_button.clicked.connect(self.toggle_sound.emit)
+ self.layout_.addWidget(self.sound_button)
+ self.exit_button = IconButton(self, pixmap=symbol_icon("close"), icon_size=40, tip="Exit (Esc)")
+ self.exit_button.clicked.connect(self.exit_requested.emit)
+ self.layout_.addWidget(self.exit_button)
+
+ self.tray = InkTray(parent)
+ self.tray.picked.connect(self._ink_picked)
+
+ self.effect = QGraphicsOpacityEffect(self)
+ self.effect.setOpacity(0.0)
+ self.setGraphicsEffect(self.effect)
+ self.animation = QPropertyAnimation(self.effect, b"opacity", self)
+ self.animation.setEasingCurve(QEasingCurve.OutCubic)
+ self.animation.finished.connect(self._animation_done)
+ self.hide()
+ self.rebuild_styles()
+ self.refresh_ink()
+
+ # -- layout ----------------------------------------------------------------
+
+ def frame(self):
+ rect = QRect(self.geometry())
+ if self.ink_open and self.tray.isVisible():
+ rect = rect.united(self.tray.geometry())
+ return rect
+
+ def place(self, bottom):
+ """Centre the toolbar horizontally with its bottom edge at `bottom` (overlay coordinates)."""
+ self.adjustSize()
+ parent = self.parentWidget()
+ x = (parent.width() - self.width()) // 2
+ y = bottom - self.height()
+ self.move(x, y)
+ self._place_tray()
+
+ def _place_tray(self):
+ self.tray.adjustSize()
+ centre = self.ink_button.mapTo(self.parentWidget(), self.ink_button.rect().center())
+ self.tray.move(centre.x() - self.tray.width() // 2, self.y() - 8 - self.tray.height())
+
+ def rebuild_styles(self):
+ styles = list(prefs().pinned_styles)
+ if self.current is not None and self.current not in styles:
+ styles.append(self.current)
+ if styles == self.shown_styles:
+ return
+ self.shown_styles = styles
+ for button in self.style_buttons.values():
+ self.layout_.removeWidget(button)
+ button.deleteLater()
+ self.style_buttons = {}
+ for i, style in enumerate(styles):
+ pixmap = laser_icon() if style.is_laser else HandAsset.thumbnail(style)
+ button = IconButton(self, pixmap=pixmap, icon_size=36, tip=style.title)
+ button.clicked.connect(lambda s=style: self.pick.emit(s))
+ self.style_buttons[style] = button
+ self.layout_.insertWidget(i, button)
+ self._highlight()
+ self._resize_keeping_centre()
+
+ def _resize_keeping_centre(self):
+ centre_x = self.x() + self.width() / 2
+ bottom = self.y() + self.height()
+ self.adjustSize()
+ self.move(int(centre_x - self.width() / 2), bottom - self.height())
+ self._place_tray()
+
+ def select(self, style):
+ self.current = style
+ self.rebuild_styles()
+ self._highlight()
+ self.refresh_ink()
+
+ def _highlight(self):
+ for style, button in self.style_buttons.items():
+ button.set_selected(style == self.current)
+
+ def set_sound_on(self, on):
+ self.sound_button.set_pixmap(symbol_icon("sound" if on else "muted", alpha=0.85 if on else 0.45))
+
+ # -- ink -------------------------------------------------------------------
+
+ def _toggle_ink(self):
+ self.set_ink_open(not self.ink_open)
+
+ def set_ink_open(self, open_):
+ if open_ == self.ink_open:
+ return
+ self.ink_open = open_
+ if open_:
+ self._place_tray()
+ self.tray.raise_()
+ self.tray.show()
+ else:
+ self.tray.hide()
+ self.refresh_ink()
+
+ def _ink_picked(self, choice_id):
+ prefs().set("inkChoice", choice_id)
+ self.set_ink_open(False)
+
+ def refresh_ink(self):
+ style = self.current or prefs().pointer_style
+ self.ink_button.set_pixmap(ink_dot(ink_color_for(prefs().ink_choice, style)))
+ self.ink_button.set_selected(self.ink_open)
+ hidden = not prefs().show_ink_button
+ if self.ink_button.isHidden() != hidden:
+ self.ink_button.setVisible(not hidden)
+ if hidden:
+ self.set_ink_open(False)
+ self._resize_keeping_centre()
+ self.tray.refresh(prefs().ink_choice)
+
+ # -- reveal ----------------------------------------------------------------
+
+ def set_revealed(self, value):
+ if value == self.revealed:
+ return
+ self.revealed = value
+ if not value:
+ self.set_ink_open(False)
+ self.animation.stop()
+ self.animation.setDuration(150 if value else 350)
+ self.animation.setStartValue(self.effect.opacity())
+ self.animation.setEndValue(1.0 if value else 0.0)
+ if value:
+ self.show()
+ self.raise_()
+ self.animation.start()
+
+ def _animation_done(self):
+ if not self.revealed:
+ self.hide()
+
+ def hide_now(self):
+ self.revealed = False
+ self.animation.stop()
+ self.effect.setOpacity(0.0)
+ self.set_ink_open(False)
+ self.hide()
+
+ def paintEvent(self, event):
+ p = QPainter(self)
+ rounded_panel(p, self.rect(), 16)