From 1c273fc881bc4bf9acbf987258a7d5dbaaa02f61 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Tue, 3 Nov 2026 22:46:43 +0100 Subject: [PATCH] Initial commit Co-Authored-By: Claude Opus 5.5 --- .gitignore | 16 ++ 70-trackscreen.rules | 5 + README.md | 58 ++++ install.sh | 29 ++ trackscreen.py | 632 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 740 insertions(+) create mode 100644 .gitignore create mode 100644 70-trackscreen.rules create mode 100644 README.md create mode 100755 install.sh create mode 100755 trackscreen.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..03f9eb0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Tooling +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Editors / OS +.idea/ +.vscode/ +*.swp +.DS_Store diff --git a/70-trackscreen.rules b/70-trackscreen.rules new file mode 100644 index 0000000..a97c147 --- /dev/null +++ b/70-trackscreen.rules @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a2b305 --- /dev/null +++ b/README.md @@ -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. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..73a6709 --- /dev/null +++ b/install.sh @@ -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" < 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()