#!/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()