+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Tooling
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Editors / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# trackscreen: let the user logged in at the seat read touchscreens and create
|
||||||
|
# virtual input devices (no 'input' group membership needed).
|
||||||
|
# Must sort before 73-seat-late.rules, which applies the "uaccess" tag.
|
||||||
|
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_TOUCHSCREEN}=="1", TAG+="uaccess"
|
||||||
|
KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# trackscreen
|
||||||
|
|
||||||
|
Use a laptop touchscreen as a real multitouch **touchpad**. Toggle between
|
||||||
|
modes with a **five-finger tap**.
|
||||||
|
|
||||||
|
* **Touchscreen mode** (the default): nothing changes. trackscreen only reads
|
||||||
|
the touchscreen passively, so the desktop keeps native multitouch.
|
||||||
|
* **Trackpad mode**: trackscreen grabs the touchscreen and re-emits its touches
|
||||||
|
through a virtual touchpad ("Trackscreen Virtual Touchpad"). libinput handles
|
||||||
|
it like any other touchpad, so you get relative pointer motion with
|
||||||
|
acceleration, tap-to-click (1/2/3 fingers = left/right/middle), tap-and-drag,
|
||||||
|
two-finger scrolling, pinch zoom, and 3/4-finger swipes. Configure these in
|
||||||
|
your desktop's normal touchpad settings (KDE: System Settings -> Touchpad).
|
||||||
|
|
||||||
|
The grab exists only in trackpad mode. The kernel releases it if trackscreen
|
||||||
|
exits or crashes, so the touchscreen can't get stuck. A pen or stylus is a
|
||||||
|
separate device and is never affected.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Linux with libinput (Wayland or X11), Python 3 and `python-evdev`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
./install.sh # udev rule (sudo) + systemd user service, starts it now
|
||||||
|
|
||||||
|
The udev rule gives the logged-in user access to touchscreens and `/dev/uinput`.
|
||||||
|
You can join the `input` group instead, but that also exposes every keyboard.
|
||||||
|
|
||||||
|
To run it by hand instead: `./trackscreen.py --debug`
|
||||||
|
|
||||||
|
## Toggling
|
||||||
|
|
||||||
|
* Five-finger tap: fingers down and up within 0.6 s, without moving.
|
||||||
|
Tuning: `--toggle-fingers N` (0 disables it), `--tap-time`, `--tap-move`.
|
||||||
|
* `trackscreen.py --toggle`, or `pkill -USR1 -f trackscreen.py`. Bind either
|
||||||
|
to a keyboard shortcut if you like.
|
||||||
|
|
||||||
|
A desktop notification shows the new mode (disable with `--no-notify`).
|
||||||
|
|
||||||
|
## Other laptops / tuning
|
||||||
|
|
||||||
|
* `--list-devices` shows the auto-detected touchscreen; `--device` picks one by
|
||||||
|
path or name.
|
||||||
|
* Physical size comes from the kernel or from udev (`ID_INPUT_WIDTH_MM`), so
|
||||||
|
pointer speed is consistent across machines. Override with `--size 307x187`.
|
||||||
|
* If the built-in display is rotated, use `--rotate 90|180|270`. A
|
||||||
|
`LIBINPUT_CALIBRATION_MATRIX` set in udev is applied automatically
|
||||||
|
(`--matrix` overrides it).
|
||||||
|
* `--speed 1.5` scales pointer and gesture speed on top of the desktop settings.
|
||||||
|
* `--start-mode trackpad` starts in trackpad mode.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
* In touchscreen mode the five-finger tap also reaches the desktop as five
|
||||||
|
touches. It is usually harmless, but it can tap whatever is under your fingers.
|
||||||
|
* If "disable touchpad when a mouse is connected" is on, it also applies to
|
||||||
|
the virtual touchpad.
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Installs the udev rule (needs sudo) and a systemd user service that starts
|
||||||
|
# trackscreen with your graphical session. Extra arguments are passed to
|
||||||
|
# trackscreen.py, e.g. ./install.sh --speed 1.3 --rotate 90
|
||||||
|
set -e
|
||||||
|
DIR=$(cd "$(dirname "$0")" && pwd)
|
||||||
|
|
||||||
|
sudo install -m 644 "$DIR/70-trackscreen.rules" /etc/udev/rules.d/70-trackscreen.rules
|
||||||
|
sudo udevadm control --reload
|
||||||
|
sudo udevadm trigger --subsystem-match=input --subsystem-match=misc --action=change
|
||||||
|
|
||||||
|
mkdir -p "$HOME/.config/systemd/user"
|
||||||
|
cat > "$HOME/.config/systemd/user/trackscreen.service" <<UNIT
|
||||||
|
[Unit]
|
||||||
|
Description=Trackscreen - touchscreen as touchpad (five-finger tap to toggle)
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
After=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/usr/bin/python3 $DIR/trackscreen.py $*
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
|
UNIT
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now trackscreen.service
|
||||||
|
systemctl --user --no-pager status trackscreen.service | head -n 12
|
||||||
Executable
+632
@@ -0,0 +1,632 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
trackscreen - use a laptop touchscreen as a multitouch touchpad.
|
||||||
|
|
||||||
|
How it works
|
||||||
|
------------
|
||||||
|
* Touchscreen mode (default): the touchscreen is only *read* passively; the
|
||||||
|
compositor keeps receiving every touch exactly as before (full multitouch).
|
||||||
|
* Trackpad mode: the touchscreen is grabbed (EVIOCGRAB) so the compositor stops
|
||||||
|
seeing it, and its touches are re-emitted through a virtual multitouch
|
||||||
|
*touchpad* (uinput). libinput then treats it like any other touchpad, so you
|
||||||
|
get relative pointer motion, acceleration, tap-to-click, two-finger
|
||||||
|
scrolling, pinch-zoom, 3/4-finger swipes, ... all configurable in your
|
||||||
|
desktop's normal touchpad settings. The grab is released automatically by
|
||||||
|
the kernel if this program exits or crashes.
|
||||||
|
|
||||||
|
Toggle between the modes with a five-finger tap (configurable), by sending
|
||||||
|
SIGUSR1 to the running instance, or by running `trackscreen.py --toggle`
|
||||||
|
(handy for a keyboard shortcut).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import array
|
||||||
|
import fcntl
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
import evdev
|
||||||
|
from evdev import AbsInfo, UInput, ecodes as e
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
sys.exit("python-evdev is required (pip install evdev / pacman -S python-evdev)")
|
||||||
|
|
||||||
|
VIRTUAL_NAME = "Trackscreen Virtual Touchpad"
|
||||||
|
UNITS_PER_MM = 100 # resolution of the virtual touchpad's coordinate space
|
||||||
|
TOUCHSCREEN, TRACKPAD = "touchscreen", "trackpad"
|
||||||
|
TOOL_BUTTONS = [e.BTN_TOOL_FINGER, e.BTN_TOOL_DOUBLETAP, e.BTN_TOOL_TRIPLETAP,
|
||||||
|
e.BTN_TOOL_QUADTAP, e.BTN_TOOL_QUINTTAP]
|
||||||
|
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
|
||||||
|
def log(*a):
|
||||||
|
print("trackscreen:", *a, file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def dbg(*a):
|
||||||
|
if DEBUG:
|
||||||
|
log(*a)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Device discovery
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def udev_props(path):
|
||||||
|
"""Read udev properties of a device node straight from the udev database."""
|
||||||
|
props = {}
|
||||||
|
try:
|
||||||
|
rdev = os.stat(path).st_rdev
|
||||||
|
with open(f"/run/udev/data/c{os.major(rdev)}:{os.minor(rdev)}") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("E:"):
|
||||||
|
k, _, v = line[2:].strip().partition("=")
|
||||||
|
props[k] = v
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
def is_touchscreen(dev):
|
||||||
|
caps = dev.capabilities(absinfo=False)
|
||||||
|
absc = set(caps.get(e.EV_ABS, []))
|
||||||
|
keys = set(caps.get(e.EV_KEY, []))
|
||||||
|
if not {e.ABS_MT_SLOT, e.ABS_MT_POSITION_X, e.ABS_MT_POSITION_Y,
|
||||||
|
e.ABS_MT_TRACKING_ID} <= absc:
|
||||||
|
return False # we need multitouch protocol B
|
||||||
|
if keys & {e.BTN_TOOL_PEN, e.BTN_STYLUS, e.BTN_TOOL_RUBBER}:
|
||||||
|
return False # a pen/stylus interface
|
||||||
|
return e.INPUT_PROP_DIRECT in dev.input_props()
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_devices():
|
||||||
|
"""Yield (path, InputDevice|None, udev_props); device is None if not readable."""
|
||||||
|
# (evdev.list_devices() silently skips unreadable nodes; we want to report those)
|
||||||
|
paths = [os.path.join("/dev/input", n) for n in os.listdir("/dev/input")
|
||||||
|
if n.startswith("event") and n[5:].isdigit()]
|
||||||
|
for path in sorted(paths, key=lambda p: int(p.rsplit("event", 1)[-1])):
|
||||||
|
props = udev_props(path)
|
||||||
|
try:
|
||||||
|
dev = evdev.InputDevice(path)
|
||||||
|
except PermissionError:
|
||||||
|
yield path, None, props
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
yield path, dev, props
|
||||||
|
|
||||||
|
|
||||||
|
def find_touchscreen(spec=None):
|
||||||
|
"""Return an InputDevice for the touchscreen, or raise SystemExit with advice."""
|
||||||
|
denied = []
|
||||||
|
for path, dev, props in candidate_devices():
|
||||||
|
if dev is None:
|
||||||
|
if props.get("ID_INPUT_TOUCHSCREEN") == "1":
|
||||||
|
denied.append(path)
|
||||||
|
continue
|
||||||
|
if dev.name == VIRTUAL_NAME:
|
||||||
|
dev.close()
|
||||||
|
continue
|
||||||
|
if spec:
|
||||||
|
ok = (os.path.realpath(spec) == path or spec.lower() in dev.name.lower())
|
||||||
|
else:
|
||||||
|
ok = is_touchscreen(dev)
|
||||||
|
if ok:
|
||||||
|
return dev
|
||||||
|
dev.close()
|
||||||
|
if denied:
|
||||||
|
raise LookupError(
|
||||||
|
f"touchscreen {', '.join(denied)} exists but is not readable by this user.\n"
|
||||||
|
"Install the udev rule shipped with trackscreen (70-trackscreen.rules), "
|
||||||
|
"or add yourself to the 'input' group, then log out and in again.")
|
||||||
|
raise LookupError("no multitouch touchscreen found" + (f" matching {spec!r}" if spec else ""))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Geometry: touchscreen coordinates -> virtual touchpad coordinates
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Geometry:
|
||||||
|
def __init__(self, dev, size_mm=None, rotate=0, matrix=None, speed=1.0):
|
||||||
|
self.ax = dev.absinfo(e.ABS_MT_POSITION_X)
|
||||||
|
self.ay = dev.absinfo(e.ABS_MT_POSITION_Y)
|
||||||
|
rx = max(1, self.ax.max - self.ax.min)
|
||||||
|
ry = max(1, self.ay.max - self.ay.min)
|
||||||
|
props = udev_props(dev.path)
|
||||||
|
|
||||||
|
# Physical size of the panel in its native orientation.
|
||||||
|
if size_mm:
|
||||||
|
w_mm, h_mm, src = size_mm[0], size_mm[1], "--size"
|
||||||
|
elif self.ax.resolution > 0 and self.ay.resolution > 0:
|
||||||
|
w_mm, h_mm, src = rx / self.ax.resolution, ry / self.ay.resolution, "kernel"
|
||||||
|
elif props.get("ID_INPUT_WIDTH_MM") and props.get("ID_INPUT_HEIGHT_MM"):
|
||||||
|
w_mm, h_mm, src = float(props["ID_INPUT_WIDTH_MM"]), float(props["ID_INPUT_HEIGHT_MM"]), "udev"
|
||||||
|
else:
|
||||||
|
w_mm, h_mm, src = 300.0, 300.0 * ry / rx, "guess"
|
||||||
|
log("could not determine the touchscreen's physical size; guessing. "
|
||||||
|
"Use --size WxH (in mm) for correct pointer speed.")
|
||||||
|
self.mm_per_unit_x = w_mm / rx
|
||||||
|
self.mm_per_unit_y = h_mm / ry
|
||||||
|
|
||||||
|
# Optional libinput calibration matrix (a b c d e f), as used for
|
||||||
|
# rotated panels; taken from udev unless given explicitly.
|
||||||
|
if matrix is None and props.get("LIBINPUT_CALIBRATION_MATRIX"):
|
||||||
|
try:
|
||||||
|
matrix = [float(v) for v in props["LIBINPUT_CALIBRATION_MATRIX"].split()]
|
||||||
|
except ValueError:
|
||||||
|
matrix = None
|
||||||
|
if matrix is not None and len(matrix) != 6:
|
||||||
|
raise SystemExit("calibration matrix needs 6 numbers")
|
||||||
|
self.m = matrix or [1, 0, 0, 0, 1, 0]
|
||||||
|
self.rot = rotate % 360
|
||||||
|
if self.rot not in (0, 90, 180, 270):
|
||||||
|
raise SystemExit("--rotate must be 0, 90, 180 or 270")
|
||||||
|
|
||||||
|
swap = abs(self.m[0]) < 0.5 and abs(self.m[4]) < 0.5 # matrix rotates by 90/270
|
||||||
|
if self.rot in (90, 270):
|
||||||
|
swap = not swap
|
||||||
|
self.w_mm, self.h_mm = (h_mm, w_mm) if swap else (w_mm, h_mm)
|
||||||
|
self.max_x = max(1, round(self.w_mm * UNITS_PER_MM))
|
||||||
|
self.max_y = max(1, round(self.h_mm * UNITS_PER_MM))
|
||||||
|
# libinput derives physical distances from the resolution; lying about
|
||||||
|
# it scales pointer/gesture speed.
|
||||||
|
self.res = max(1, round(UNITS_PER_MM / speed))
|
||||||
|
log(f"panel {w_mm:.0f}x{h_mm:.0f} mm ({src}), touchpad {self.w_mm:.0f}x{self.h_mm:.0f} mm, "
|
||||||
|
f"rotate {self.rot}, speed x{speed:g}")
|
||||||
|
|
||||||
|
def map(self, x, y):
|
||||||
|
u = (x - self.ax.min) / (self.ax.max - self.ax.min or 1)
|
||||||
|
v = (y - self.ay.min) / (self.ay.max - self.ay.min or 1)
|
||||||
|
m = self.m
|
||||||
|
u, v = m[0] * u + m[1] * v + m[2], m[3] * u + m[4] * v + m[5]
|
||||||
|
if self.rot == 90:
|
||||||
|
u, v = 1 - v, u
|
||||||
|
elif self.rot == 180:
|
||||||
|
u, v = 1 - u, 1 - v
|
||||||
|
elif self.rot == 270:
|
||||||
|
u, v = v, 1 - u
|
||||||
|
u = min(1.0, max(0.0, u))
|
||||||
|
v = min(1.0, max(0.0, v))
|
||||||
|
return round(u * self.max_x), round(v * self.max_y)
|
||||||
|
|
||||||
|
def dist_mm(self, x0, y0, x1, y1):
|
||||||
|
return math.hypot((x1 - x0) * self.mm_per_unit_x, (y1 - y0) * self.mm_per_unit_y)
|
||||||
|
|
||||||
|
|
||||||
|
def make_virtual_touchpad(geo, nslots):
|
||||||
|
ax = AbsInfo(value=0, min=0, max=geo.max_x, fuzz=0, flat=0, resolution=geo.res)
|
||||||
|
ay = AbsInfo(value=0, min=0, max=geo.max_y, fuzz=0, flat=0, resolution=geo.res)
|
||||||
|
caps = {
|
||||||
|
# No BTN_LEFT on purpose: libinput then enables tap-to-click by default.
|
||||||
|
e.EV_KEY: [e.BTN_TOUCH] + TOOL_BUTTONS,
|
||||||
|
e.EV_ABS: [
|
||||||
|
(e.ABS_X, ax), (e.ABS_Y, ay),
|
||||||
|
(e.ABS_MT_SLOT, AbsInfo(0, 0, nslots - 1, 0, 0, 0)),
|
||||||
|
(e.ABS_MT_POSITION_X, ax), (e.ABS_MT_POSITION_Y, ay),
|
||||||
|
(e.ABS_MT_TRACKING_ID, AbsInfo(0, 0, 65535, 0, 0, 0)),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return UInput(caps, name=VIRTUAL_NAME, vendor=0x7473, product=0x0001, version=1,
|
||||||
|
bustype=e.BUS_VIRTUAL, input_props=[e.INPUT_PROP_POINTER])
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Multitouch state
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _eviocgmtslots(size):
|
||||||
|
return (2 << 30) | (size << 16) | (ord("E") << 8) | 0x0A # _IOC(_IOC_READ,'E',0x0a,size)
|
||||||
|
|
||||||
|
|
||||||
|
def read_mt_slots(fd, code, nslots):
|
||||||
|
buf = array.array("i", [code] + [0] * nslots)
|
||||||
|
fcntl.ioctl(fd, _eviocgmtslots(buf.itemsize * len(buf)), buf, True)
|
||||||
|
return list(buf[1:])
|
||||||
|
|
||||||
|
|
||||||
|
class Slot:
|
||||||
|
__slots__ = ("tid", "x", "y")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.tid, self.x, self.y = -1, 0, 0
|
||||||
|
|
||||||
|
|
||||||
|
class TapDetector:
|
||||||
|
"""Recognises an N-finger tap: N+ fingers, short, nearly motionless."""
|
||||||
|
|
||||||
|
def __init__(self, geo, fingers, max_time, max_move_mm):
|
||||||
|
self.geo, self.fingers, self.max_time, self.max_move = geo, fingers, max_time, max_move_mm
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.start, self.max_n, self.moved, self.origin = None, 0, False, {}
|
||||||
|
|
||||||
|
def update(self, now, slots):
|
||||||
|
n = 0
|
||||||
|
for s in slots:
|
||||||
|
if s.tid < 0:
|
||||||
|
continue
|
||||||
|
n += 1
|
||||||
|
o = self.origin.setdefault(s.tid, (s.x, s.y))
|
||||||
|
if not self.moved and self.geo.dist_mm(o[0], o[1], s.x, s.y) > self.max_move:
|
||||||
|
self.moved = True
|
||||||
|
if n and self.start is None:
|
||||||
|
self.start = now
|
||||||
|
self.max_n = max(self.max_n, n)
|
||||||
|
|
||||||
|
def finish(self, now):
|
||||||
|
ok = (self.start is not None and self.max_n >= self.fingers and not self.moved
|
||||||
|
and now - self.start <= self.max_time)
|
||||||
|
dbg(f"touch sequence ended: fingers={self.max_n} moved={self.moved} "
|
||||||
|
f"time={now - (self.start or now):.2f}s -> {'TOGGLE' if ok else 'no toggle'}")
|
||||||
|
self.reset()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Main engine
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Trackscreen:
|
||||||
|
def __init__(self, args):
|
||||||
|
self.args = args
|
||||||
|
self.mode = args.start_mode
|
||||||
|
self.pending_toggle = False
|
||||||
|
self.dev = self.ui = None
|
||||||
|
|
||||||
|
# -- device lifecycle ---------------------------------------------------
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
a = self.args
|
||||||
|
self.dev = find_touchscreen(a.device)
|
||||||
|
self.nslots = self.dev.absinfo(e.ABS_MT_SLOT).max + 1
|
||||||
|
self.geo = Geometry(self.dev, a.size, a.rotate, a.matrix, a.speed)
|
||||||
|
self.ui = make_virtual_touchpad(self.geo, self.nslots)
|
||||||
|
self.slots = [Slot() for _ in range(self.nslots)]
|
||||||
|
self.vtid = [-1] * self.nslots # tracking ids as last sent to the virtual pad
|
||||||
|
self.vtool = -1
|
||||||
|
self.dirty = set()
|
||||||
|
self.dropping = False
|
||||||
|
fingers = min(a.toggle_fingers, self.nslots)
|
||||||
|
if fingers != a.toggle_fingers:
|
||||||
|
log(f"touchscreen only tracks {self.nslots} fingers; toggle tap uses {fingers}")
|
||||||
|
if a.toggle_fingers and fingers < 4:
|
||||||
|
log("warning: toggle tap with <4 fingers will also produce clicks/gestures")
|
||||||
|
self.tap = TapDetector(self.geo, fingers, a.tap_time, a.tap_move) if a.toggle_fingers else None
|
||||||
|
self.resync()
|
||||||
|
log(f"using {self.dev.path}: {self.dev.name!r} ({self.nslots} slots), "
|
||||||
|
f"virtual touchpad {self.ui.device.path if self.ui.device else VIRTUAL_NAME!r}")
|
||||||
|
self.apply_mode(notify=False)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.ui:
|
||||||
|
try:
|
||||||
|
self.lift_all_virtual()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.ui.close()
|
||||||
|
self.ui = None
|
||||||
|
if self.dev:
|
||||||
|
try:
|
||||||
|
self.dev.ungrab()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self.dev.close()
|
||||||
|
self.dev = None
|
||||||
|
|
||||||
|
def resync(self):
|
||||||
|
"""Re-read the full multitouch state from the kernel (start-up, SYN_DROPPED)."""
|
||||||
|
fd = self.dev.fd
|
||||||
|
tids = read_mt_slots(fd, e.ABS_MT_TRACKING_ID, self.nslots)
|
||||||
|
xs = read_mt_slots(fd, e.ABS_MT_POSITION_X, self.nslots)
|
||||||
|
ys = read_mt_slots(fd, e.ABS_MT_POSITION_Y, self.nslots)
|
||||||
|
for i, s in enumerate(self.slots):
|
||||||
|
if (s.tid, s.x, s.y) != (tids[i], xs[i], ys[i]):
|
||||||
|
s.tid, s.x, s.y = tids[i], xs[i], ys[i]
|
||||||
|
self.dirty.add(i)
|
||||||
|
self.cur = min(max(self.dev.absinfo(e.ABS_MT_SLOT).value, 0), self.nslots - 1)
|
||||||
|
|
||||||
|
# -- modes --------------------------------------------------------------
|
||||||
|
|
||||||
|
def apply_mode(self, notify=True):
|
||||||
|
if self.mode == TRACKPAD:
|
||||||
|
try:
|
||||||
|
self.dev.grab()
|
||||||
|
except OSError as ex:
|
||||||
|
log(f"cannot grab touchscreen ({ex}); staying in touchscreen mode")
|
||||||
|
self.mode = TOUCHSCREEN
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.dev.ungrab()
|
||||||
|
except OSError:
|
||||||
|
pass # was not grabbed
|
||||||
|
log(f"mode: {self.mode}")
|
||||||
|
if notify:
|
||||||
|
self.notify()
|
||||||
|
|
||||||
|
def toggle(self):
|
||||||
|
self.mode = TRACKPAD if self.mode == TOUCHSCREEN else TOUCHSCREEN
|
||||||
|
self.apply_mode()
|
||||||
|
|
||||||
|
def notify(self):
|
||||||
|
if self.args.no_notify or not shutil.which("notify-send"):
|
||||||
|
return
|
||||||
|
trackpad = self.mode == TRACKPAD
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["notify-send", "-a", "Trackscreen", "-t", "1500", "-e",
|
||||||
|
"-i", "input-touchpad" if trackpad else "input-touchscreen",
|
||||||
|
"-h", "string:x-canonical-private-synchronous:trackscreen",
|
||||||
|
"Trackpad mode" if trackpad else "Touchscreen mode"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -- event handling -----------------------------------------------------
|
||||||
|
|
||||||
|
def handle(self, ev):
|
||||||
|
if ev.type == e.EV_SYN:
|
||||||
|
if ev.code == e.SYN_DROPPED:
|
||||||
|
self.dropping = True
|
||||||
|
elif ev.code == e.SYN_REPORT:
|
||||||
|
if self.dropping:
|
||||||
|
self.dropping = False
|
||||||
|
self.resync()
|
||||||
|
self.frame()
|
||||||
|
return
|
||||||
|
if self.dropping or ev.type != e.EV_ABS:
|
||||||
|
return
|
||||||
|
if ev.code == e.ABS_MT_SLOT:
|
||||||
|
self.cur = ev.value if 0 <= ev.value < self.nslots else self.cur
|
||||||
|
return
|
||||||
|
s = self.slots[self.cur]
|
||||||
|
if ev.code == e.ABS_MT_TRACKING_ID:
|
||||||
|
s.tid = ev.value
|
||||||
|
elif ev.code == e.ABS_MT_POSITION_X:
|
||||||
|
s.x = ev.value
|
||||||
|
elif ev.code == e.ABS_MT_POSITION_Y:
|
||||||
|
s.y = ev.value
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
self.dirty.add(self.cur)
|
||||||
|
|
||||||
|
def frame(self):
|
||||||
|
now = time.monotonic()
|
||||||
|
if self.tap:
|
||||||
|
self.tap.update(now, self.slots)
|
||||||
|
if self.mode == TRACKPAD:
|
||||||
|
self.forward()
|
||||||
|
self.dirty.clear()
|
||||||
|
if not any(s.tid >= 0 for s in self.slots): # all fingers lifted
|
||||||
|
tapped = self.tap.finish(now) if self.tap else False
|
||||||
|
if tapped != self.pending_toggle: # both cancel each other out
|
||||||
|
self.toggle()
|
||||||
|
self.pending_toggle = False
|
||||||
|
|
||||||
|
def forward(self):
|
||||||
|
"""Re-emit the changed touch slots on the virtual touchpad."""
|
||||||
|
ui, geo = self.ui, self.geo
|
||||||
|
for i in sorted(self.dirty):
|
||||||
|
s = self.slots[i]
|
||||||
|
ui.write(e.EV_ABS, e.ABS_MT_SLOT, i)
|
||||||
|
tid = s.tid & 0xFFFF if s.tid >= 0 else -1
|
||||||
|
if tid != self.vtid[i]:
|
||||||
|
ui.write(e.EV_ABS, e.ABS_MT_TRACKING_ID, tid)
|
||||||
|
self.vtid[i] = tid
|
||||||
|
if tid >= 0:
|
||||||
|
x, y = geo.map(s.x, s.y)
|
||||||
|
ui.write(e.EV_ABS, e.ABS_MT_POSITION_X, x)
|
||||||
|
ui.write(e.EV_ABS, e.ABS_MT_POSITION_Y, y)
|
||||||
|
active = [s for s in self.slots if s.tid >= 0]
|
||||||
|
self.emit_common(active)
|
||||||
|
|
||||||
|
def emit_common(self, active):
|
||||||
|
ui, n = self.ui, len(active)
|
||||||
|
tool = min(n, len(TOOL_BUTTONS)) - 1 # -1: no finger
|
||||||
|
if tool != self.vtool:
|
||||||
|
if self.vtool >= 0:
|
||||||
|
ui.write(e.EV_KEY, TOOL_BUTTONS[self.vtool], 0)
|
||||||
|
if tool >= 0:
|
||||||
|
ui.write(e.EV_KEY, TOOL_BUTTONS[tool], 1)
|
||||||
|
ui.write(e.EV_KEY, e.BTN_TOUCH, 1 if n else 0)
|
||||||
|
self.vtool = tool
|
||||||
|
if active:
|
||||||
|
x, y = self.geo.map(active[0].x, active[0].y)
|
||||||
|
ui.write(e.EV_ABS, e.ABS_X, x)
|
||||||
|
ui.write(e.EV_ABS, e.ABS_Y, y)
|
||||||
|
ui.syn()
|
||||||
|
|
||||||
|
def lift_all_virtual(self):
|
||||||
|
for i, tid in enumerate(self.vtid):
|
||||||
|
if tid >= 0:
|
||||||
|
self.ui.write(e.EV_ABS, e.ABS_MT_SLOT, i)
|
||||||
|
self.ui.write(e.EV_ABS, e.ABS_MT_TRACKING_ID, -1)
|
||||||
|
self.vtid[i] = -1
|
||||||
|
self.emit_common([])
|
||||||
|
|
||||||
|
# -- signals ------------------------------------------------------------
|
||||||
|
|
||||||
|
def request_toggle(self):
|
||||||
|
if any(s.tid >= 0 for s in self.slots):
|
||||||
|
self.pending_toggle = not self.pending_toggle # applied once fingers lift
|
||||||
|
else:
|
||||||
|
self.toggle()
|
||||||
|
|
||||||
|
# -- main loop ----------------------------------------------------------
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
rsock, wsock = socket.socketpair()
|
||||||
|
rsock.setblocking(False)
|
||||||
|
wsock.setblocking(False)
|
||||||
|
signal.set_wakeup_fd(wsock.fileno())
|
||||||
|
flags = {"toggle": 0, "stop": False}
|
||||||
|
|
||||||
|
def on_toggle(*_):
|
||||||
|
flags["toggle"] += 1
|
||||||
|
|
||||||
|
def on_stop(*_):
|
||||||
|
flags["stop"] = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGUSR1, on_toggle)
|
||||||
|
signal.signal(signal.SIGTERM, on_stop)
|
||||||
|
signal.signal(signal.SIGINT, on_stop)
|
||||||
|
signal.signal(signal.SIGHUP, on_stop)
|
||||||
|
|
||||||
|
waiting_logged = False
|
||||||
|
try:
|
||||||
|
while not flags["stop"]:
|
||||||
|
if self.dev is None:
|
||||||
|
try:
|
||||||
|
self.open()
|
||||||
|
waiting_logged = False
|
||||||
|
except LookupError as ex:
|
||||||
|
if not self.args.wait:
|
||||||
|
raise SystemExit(f"trackscreen: {ex}")
|
||||||
|
if not waiting_logged:
|
||||||
|
log(f"{ex}; waiting for it to appear...")
|
||||||
|
waiting_logged = True
|
||||||
|
select.select([rsock], [], [], 2.0)
|
||||||
|
self._drain(rsock)
|
||||||
|
continue
|
||||||
|
|
||||||
|
r, _, _ = select.select([self.dev.fd, rsock], [], [])
|
||||||
|
if rsock in r:
|
||||||
|
self._drain(rsock)
|
||||||
|
while flags["toggle"]:
|
||||||
|
flags["toggle"] -= 1
|
||||||
|
self.request_toggle()
|
||||||
|
if self.dev.fd in r:
|
||||||
|
try:
|
||||||
|
for ev in self.dev.read():
|
||||||
|
self.handle(ev)
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
except OSError as ex: # device vanished (suspend, driver reload, ...)
|
||||||
|
log(f"lost touchscreen ({ex}); will reconnect")
|
||||||
|
self.close()
|
||||||
|
if not self.args.wait:
|
||||||
|
self.args.wait = True
|
||||||
|
finally:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _drain(sock):
|
||||||
|
try:
|
||||||
|
while sock.recv(4096):
|
||||||
|
pass
|
||||||
|
except BlockingIOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def pidfile_path():
|
||||||
|
return os.path.join(os.environ.get("XDG_RUNTIME_DIR") or f"/tmp/trackscreen-{os.getuid()}",
|
||||||
|
"trackscreen.pid")
|
||||||
|
|
||||||
|
|
||||||
|
def running_pid():
|
||||||
|
try:
|
||||||
|
with open(pidfile_path()) as f:
|
||||||
|
pid = int(f.read().strip())
|
||||||
|
with open(f"/proc/{pid}/cmdline", "rb") as f:
|
||||||
|
if b"trackscreen" not in f.read():
|
||||||
|
return None
|
||||||
|
return pid
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices():
|
||||||
|
for path, dev, props in candidate_devices():
|
||||||
|
ts = props.get("ID_INPUT_TOUCHSCREEN") == "1"
|
||||||
|
if dev is None:
|
||||||
|
if ts:
|
||||||
|
print(f"{path} (no permission) udev says: touchscreen")
|
||||||
|
continue
|
||||||
|
if is_touchscreen(dev) or ts:
|
||||||
|
print(f"{path} {dev.name!r} {'<- auto-detected' if is_touchscreen(dev) else ''}")
|
||||||
|
dev.close()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size(s):
|
||||||
|
try:
|
||||||
|
w, h = s.lower().split("x")
|
||||||
|
return float(w), float(h)
|
||||||
|
except ValueError:
|
||||||
|
raise argparse.ArgumentTypeError("expected WxH in millimetres, e.g. 307x187")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
description="Use the touchscreen as a multitouch touchpad; toggle with a five-finger tap.")
|
||||||
|
p.add_argument("--toggle", action="store_true",
|
||||||
|
help="toggle the mode of the running instance and exit")
|
||||||
|
p.add_argument("--list-devices", action="store_true", help="list touchscreens and exit")
|
||||||
|
p.add_argument("-d", "--device", help="touchscreen device path or (part of) its name "
|
||||||
|
"(default: auto-detect)")
|
||||||
|
p.add_argument("--start-mode", choices=[TOUCHSCREEN, TRACKPAD], default=TOUCHSCREEN)
|
||||||
|
p.add_argument("--toggle-fingers", type=int, default=5,
|
||||||
|
help="number of fingers for the toggle tap; 0 disables it (default 5)")
|
||||||
|
p.add_argument("--tap-time", type=float, default=0.6,
|
||||||
|
help="max duration of the toggle tap in seconds (default 0.6)")
|
||||||
|
p.add_argument("--tap-move", type=float, default=12.0,
|
||||||
|
help="max finger movement during the toggle tap in mm (default 12)")
|
||||||
|
p.add_argument("--speed", type=float, default=1.0,
|
||||||
|
help="pointer/gesture speed multiplier on top of the desktop's touchpad "
|
||||||
|
"settings (default 1.0)")
|
||||||
|
p.add_argument("--rotate", type=int, default=0,
|
||||||
|
help="rotate touch coordinates clockwise by 0/90/180/270 degrees, e.g. if "
|
||||||
|
"your built-in display is rotated")
|
||||||
|
p.add_argument("--matrix", type=float, nargs=6, metavar="N",
|
||||||
|
help="calibration matrix 'a b c d e f' (default: LIBINPUT_CALIBRATION_MATRIX "
|
||||||
|
"from udev, if set)")
|
||||||
|
p.add_argument("--size", type=parse_size, metavar="WxH",
|
||||||
|
help="physical touchscreen size in mm (default: from kernel/udev)")
|
||||||
|
p.add_argument("--no-wait", dest="wait", action="store_false",
|
||||||
|
help="exit instead of waiting if no touchscreen is available")
|
||||||
|
p.add_argument("--no-notify", action="store_true", help="no desktop notification on toggle")
|
||||||
|
p.add_argument("--debug", action="store_true")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
global DEBUG
|
||||||
|
DEBUG = args.debug
|
||||||
|
|
||||||
|
if args.list_devices:
|
||||||
|
list_devices()
|
||||||
|
return
|
||||||
|
if args.toggle:
|
||||||
|
pid = running_pid()
|
||||||
|
if not pid:
|
||||||
|
sys.exit("trackscreen is not running")
|
||||||
|
os.kill(pid, signal.SIGUSR1)
|
||||||
|
return
|
||||||
|
if args.speed <= 0:
|
||||||
|
p.error("--speed must be positive")
|
||||||
|
|
||||||
|
pid = running_pid()
|
||||||
|
if pid and pid != os.getpid():
|
||||||
|
sys.exit(f"trackscreen is already running (pid {pid}); use --toggle")
|
||||||
|
pf = pidfile_path()
|
||||||
|
os.makedirs(os.path.dirname(pf), exist_ok=True)
|
||||||
|
with open(pf, "w") as f:
|
||||||
|
f.write(str(os.getpid()))
|
||||||
|
try:
|
||||||
|
Trackscreen(args).run()
|
||||||
|
finally:
|
||||||
|
if running_pid() == os.getpid():
|
||||||
|
os.unlink(pf)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user