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. [![Download for macOS](https://img.shields.io/badge/Download_for_macOS-000000?style=for-the-badge&logo=apple&logoColor=white)](https://github.com/nilbuild/tinyhand/releases/latest/download/TinyHand.dmg) [![Visit website](https://img.shields.io/badge/Visit_website-ffffff?style=for-the-badge&logo=safari&logoColor=black)](https://tinyhand.app) -macOS 13 or later, Apple Silicon and Intel. +macOS 13 or later, Apple Silicon and Intel. 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)