Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ dist/
*.dmg
*.zip
assets/unused/
__pycache__/
*.pyc
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).
116 changes: 116 additions & 0 deletions linux/README.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions linux/data/icons/tinyhand-tray-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions linux/data/icons/tinyhand-tray-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions linux/data/icons/tinyhand-tray-on-dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions linux/data/icons/tinyhand-tray-on-light.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added linux/data/icons/tinyhand.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions linux/data/tinyhand.desktop
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions linux/install.sh
Original file line number Diff line number Diff line change
@@ -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" <<LAUNCHER
#!/bin/sh
exec python3 "$APP_DIR/run.py" "\$@"
LAUNCHER
chmod +x "$BIN"

sed "s|^Exec=tinyhand|Exec=$BIN|" "$HERE/data/tinyhand.desktop" > "$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."
12 changes: 12 additions & 0 deletions linux/run.py
Original file line number Diff line number Diff line change
@@ -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())
7 changes: 7 additions & 0 deletions linux/tinyhand/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
86 changes: 86 additions & 0 deletions linux/tinyhand/__main__.py
Original file line number Diff line number Diff line change
@@ -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())
Loading