Last active
October 3, 2025 13:35
-
-
Save ergolyam/bef08f0a9fa62baf450eae639490ee7e to your computer and use it in GitHub Desktop.
script that turns a touchscreen into a touchpad
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import argparse | |
| import time | |
| import math | |
| import logging | |
| from dataclasses import dataclass | |
| from typing import Optional, Dict, Tuple, List | |
| from evdev import InputDevice, UInput, ecodes as E | |
| @dataclass | |
| class Config: | |
| device_path: str | |
| sensitivity_x: float = 0.7 | |
| sensitivity_y: float = 0.7 | |
| tap_max_time: float = 0.22 | |
| tap_max_move: float = 40.0 | |
| two_tap_time: float = 0.25 | |
| two_tap_move: float = 45.0 | |
| two_scroll_start_move: float = 25.0 | |
| scroll_sens: float = 0.006 | |
| scroll_invert: bool = False | |
| rotate: int = 0 | |
| flip_x: bool = False | |
| flip_y: bool = False | |
| two_to_one_settle: float = 0.12 | |
| debug: bool = False | |
| long_press_time: float = 0.28 | |
| long_press_move: float = 12.0 | |
| class TouchToMouse: | |
| def __init__(self, dev: InputDevice, cfg: Config, logger: logging.Logger): | |
| self.log = logger | |
| self.dev = dev | |
| self.cfg = cfg | |
| self.ui = UInput({ | |
| E.EV_KEY: [E.BTN_LEFT, E.BTN_RIGHT, E.BTN_MIDDLE], | |
| E.EV_REL: [E.REL_X, E.REL_Y, E.REL_WHEEL, E.REL_HWHEEL], | |
| }, name='Touchscreen-to-Mouse', bustype=0x03) | |
| caps = dev.capabilities(absinfo=True) | |
| abs_caps = set() | |
| if isinstance(caps.get(E.EV_ABS, {}), dict): | |
| abs_caps = set(caps.get(E.EV_ABS, {}).keys()) | |
| elif isinstance(caps.get(E.EV_ABS, []), list): | |
| abs_caps = {code for code, *_ in caps.get(E.EV_ABS, [])} | |
| self.has_mt = (E.ABS_MT_POSITION_X in abs_caps) or (E.ABS_MT_POSITION_Y in abs_caps) | |
| self.has_slots = (E.ABS_MT_SLOT in abs_caps) | |
| self.current_slot = 0 | |
| self.slot_pos: Dict[int, Dict[str, Optional[int]]] = {} | |
| self.abs_x: Optional[int] = None | |
| self.abs_y: Optional[int] = None | |
| self.one_contact_active = False | |
| self.primary_slot: Optional[int] = None | |
| self.touch_start_time = 0.0 | |
| self.touch_start_pos: Tuple[float, float] = (0.0, 0.0) | |
| self.last_pos: Tuple[Optional[float], Optional[float]] = (None, None) | |
| self.had_pos_after_down = False | |
| self.two_active = False | |
| self.two_start_time = 0.0 | |
| self.two_start_pos: Tuple[float, float] = (0.0, 0.0) | |
| self.two_last_pos: Tuple[Optional[float], Optional[float]] = (None, None) | |
| self.two_scrolling = False | |
| self.two_slot_start: Dict[int, Tuple[float, float]] = {} | |
| self.drag_active = False | |
| self.drag_last_pos: Tuple[Optional[float], Optional[float]] = (None, None) | |
| self.suppress_next_single_tap = False | |
| self.one_quiet_until: float = 0.0 | |
| self.accum_dx = 0.0 | |
| self.accum_dy = 0.0 | |
| self.scroll_accum_v = 0.0 | |
| self.scroll_accum_h = 0.0 | |
| self.rot = self.cfg.rotate % 360 | |
| if self.rot not in (0, 90, 180, 270): | |
| raise ValueError("--rotate must be 0/90/180/270") | |
| self.log.debug("Device: %s name='%s' phys='%s'", dev.path, dev.name, dev.phys) | |
| self.log.debug("Caps: MT=%s slots=%s", self.has_mt, self.has_slots) | |
| self.log.debug("Transform: rotate=%s flip_x=%s flip_y=%s", self.rot, self.cfg.flip_x, self.cfg.flip_y) | |
| self.log.debug("1F: tap_time=%.2f tap_move=%.1f long_press=%.2fs/%.1fpx", | |
| self.cfg.tap_max_time, self.cfg.tap_max_move, | |
| self.cfg.long_press_time, self.cfg.long_press_move) | |
| self.log.debug("2F: tap_time=%.2f tap_move=%.1f scroll_start=%.1f sens=%.4f invertV=%s", | |
| self.cfg.two_tap_time, self.cfg.two_tap_move, | |
| self.cfg.two_scroll_start_move, self.cfg.scroll_sens, | |
| self.cfg.scroll_invert) | |
| self.log.debug("Settle after 2F->1F: %.0f ms", self.cfg.two_to_one_settle*1000) | |
| try: | |
| self.dev.grab() | |
| self.log.debug("Device grabbed successfully.") | |
| except Exception as e: | |
| self.log.warning("Could not grab device: %s", e) | |
| def _active_slots(self) -> List[int]: | |
| act = [] | |
| for s, st in self.slot_pos.items(): | |
| tid = st.get('tid') | |
| if tid is not None and tid != -1 and st.get('x') is not None and st.get('y') is not None: | |
| act.append(s) | |
| return act | |
| def _active_count(self) -> int: | |
| if self.has_mt or self.has_slots: | |
| return len(self._active_slots()) | |
| return 1 if self.one_contact_active else 0 | |
| def _avg_pos_of_slots(self, slots: List[int]) -> Optional[Tuple[float, float]]: | |
| if not slots: return None | |
| sx = sy = 0.0; n = 0 | |
| for s in slots[:2]: | |
| st = self.slot_pos.get(s, {}) | |
| x, y = st.get('x'), st.get('y') | |
| if x is None or y is None: continue | |
| sx += float(x); sy += float(y); n += 1 | |
| if n == 0: return None | |
| return sx/n, sy/n | |
| def _transform(self, dx: float, dy: float): | |
| if self.rot == 90: dx, dy = dy, -dx | |
| elif self.rot == 180: dx, dy = -dx, -dy | |
| elif self.rot == 270: dx, dy = -dy, dx | |
| if self.cfg.flip_x: dx = -dx | |
| if self.cfg.flip_y: dy = -dy | |
| return dx, dy | |
| def _emit_rel(self, dx: float, dy: float): | |
| dx, dy = self._transform(dx, dy) | |
| self.accum_dx += dx * self.cfg.sensitivity_x | |
| self.accum_dy += dy * self.cfg.sensitivity_y | |
| sx = int(round(self.accum_dx)) | |
| sy = int(round(self.accum_dy)) | |
| if sx or sy: | |
| self.ui.write(E.EV_REL, E.REL_X, sx) | |
| self.ui.write(E.EV_REL, E.REL_Y, sy) | |
| self.ui.syn() | |
| self.accum_dx -= sx; self.accum_dy -= sy | |
| def _emit_scroll(self, dy: float, dx: float): | |
| dx, dy = self._transform(dx, dy) | |
| v = (-dy if self.cfg.scroll_invert else dy) * self.cfg.scroll_sens | |
| self.scroll_accum_v += v | |
| sv = int(round(self.scroll_accum_v)) | |
| if sv: | |
| self.ui.write(E.EV_REL, E.REL_WHEEL, sv) | |
| self.scroll_accum_v -= sv | |
| h = dx * self.cfg.scroll_sens | |
| self.scroll_accum_h += h | |
| sh = int(round(self.scroll_accum_h)) | |
| if sh: | |
| self.ui.write(E.EV_REL, E.REL_HWHEEL, sh) | |
| self.scroll_accum_h -= sh | |
| if sv or sh: | |
| self.ui.syn() | |
| def _left_click(self): | |
| self.ui.write(E.EV_KEY, E.BTN_LEFT, 1); self.ui.syn() | |
| self.ui.write(E.EV_KEY, E.BTN_LEFT, 0); self.ui.syn() | |
| def _left_down(self): | |
| self.ui.write(E.EV_KEY, E.BTN_LEFT, 1); self.ui.syn() | |
| def _left_up(self): | |
| self.ui.write(E.EV_KEY, E.BTN_LEFT, 0); self.ui.syn() | |
| def _right_click(self): | |
| self.ui.write(E.EV_KEY, E.BTN_RIGHT, 1); self.ui.syn() | |
| self.ui.write(E.EV_KEY, E.BTN_RIGHT, 0); self.ui.syn() | |
| def _get_primary_pos(self) -> Optional[Tuple[float, float]]: | |
| if self.primary_slot is not None: | |
| st = self.slot_pos.get(self.primary_slot, {}) | |
| x, y = st.get('x'), st.get('y') | |
| if x is not None and y is not None: return float(x), float(y) | |
| if self.abs_x is not None and self.abs_y is not None: | |
| return float(self.abs_x), float(self.abs_y) | |
| return None | |
| def run(self): | |
| self.log.debug("Entering read_loop()...") | |
| for ev in self.dev.read_loop(): | |
| if ev.type == E.EV_ABS: | |
| if ev.code == E.ABS_MT_SLOT: | |
| self.current_slot = ev.value | |
| self.slot_pos.setdefault(self.current_slot, {'x': None, 'y': None, 'tid': None}) | |
| elif ev.code == E.ABS_MT_TRACKING_ID: | |
| slot = self.slot_pos.setdefault(self.current_slot, {'x': None, 'y': None, 'tid': None}) | |
| slot['tid'] = ev.value | |
| if ev.value == -1: | |
| if self.primary_slot == self.current_slot and self.one_contact_active: | |
| self._on_touch_up_single() | |
| self.primary_slot = None | |
| self.one_contact_active = False | |
| slot['x'] = None; slot['y'] = None | |
| else: | |
| if self.primary_slot is None and not self.one_contact_active: | |
| self.primary_slot = self.current_slot | |
| self.one_contact_active = True | |
| self._on_touch_down_single() | |
| elif ev.code == E.ABS_MT_POSITION_X: | |
| self.slot_pos.setdefault(self.current_slot, {'x': None, 'y': None, 'tid': None})['x'] = ev.value | |
| elif ev.code == E.ABS_MT_POSITION_Y: | |
| self.slot_pos.setdefault(self.current_slot, {'x': None, 'y': None, 'tid': None})['y'] = ev.value | |
| if self.primary_slot is None and not self.has_slots and not self.one_contact_active: | |
| self.primary_slot = self.current_slot | |
| self.one_contact_active = True | |
| self._on_touch_down_single() | |
| if ev.code == E.ABS_X: | |
| self.abs_x = ev.value | |
| elif ev.code == E.ABS_Y: | |
| self.abs_y = ev.value | |
| elif ev.type == E.EV_KEY and ev.code == E.BTN_TOUCH: | |
| pressed = bool(ev.value) | |
| if pressed and not self.one_contact_active: | |
| self.one_contact_active = True | |
| self._on_touch_down_single() | |
| elif not pressed and self.one_contact_active: | |
| self._on_touch_up_single() | |
| self.one_contact_active = False | |
| self.abs_x = None; self.abs_y = None | |
| elif ev.type == E.EV_SYN and ev.code == E.SYN_REPORT: | |
| self._on_frame() | |
| def _on_touch_down_single(self): | |
| now = time.monotonic() | |
| self.touch_start_time = now | |
| self.had_pos_after_down = False | |
| self.touch_start_pos = (0.0, 0.0) | |
| self.last_pos = (None, None) | |
| self.accum_dx = 0.0; self.accum_dy = 0.0 | |
| self.log.debug("DOWN (1f) pos=pending") | |
| def _on_touch_up_single(self): | |
| if self.two_active: | |
| self.log.debug("UP (1f) during 2F gesture (ignored for click)") | |
| return | |
| if self.drag_active: | |
| self._drag_end_single() | |
| self.last_pos = (None, None) | |
| self.log.debug("UP (1f) end-drag") | |
| if self.primary_slot is not None: | |
| st = self.slot_pos.get(self.primary_slot) | |
| if st: | |
| st['x'] = None; st['y'] = None | |
| return | |
| if self.suppress_next_single_tap: | |
| self.suppress_next_single_tap = False | |
| self.last_pos = (None, None) | |
| self.log.debug("UP (1f) suppressed (after 2F tap)") | |
| if self.primary_slot is not None: | |
| st = self.slot_pos.get(self.primary_slot) | |
| if st: | |
| st['x'] = None; st['y'] = None | |
| return | |
| now = time.monotonic() | |
| duration = now - self.touch_start_time | |
| pos = self._get_primary_pos() | |
| move = math.dist(pos, self.touch_start_pos) if (self.had_pos_after_down and pos is not None) else 0.0 | |
| if self.had_pos_after_down and duration <= self.cfg.tap_max_time and move <= self.cfg.tap_max_move: | |
| self._left_click() | |
| self.log.debug("TAP -> left click") | |
| self.last_pos = (None, None) | |
| self.log.debug("UP (1f)") | |
| if self.primary_slot is not None: | |
| st = self.slot_pos.get(self.primary_slot) | |
| if st: | |
| st['x'] = None; st['y'] = None | |
| def _drag_begin_single(self, start_pos: Tuple[float, float]): | |
| if self.drag_active: | |
| return | |
| self.drag_active = True | |
| self.drag_last_pos = start_pos | |
| self._left_down() | |
| self.log.debug("DRAG BEGIN (1f) pos=%s", start_pos) | |
| def _drag_update_single(self, current_pos: Tuple[float, float]): | |
| if not self.drag_active: | |
| return | |
| lx, ly = self.drag_last_pos | |
| cx, cy = current_pos | |
| if lx is None or ly is None: | |
| self.drag_last_pos = (cx, cy) | |
| return | |
| dx, dy = cx - lx, cy - ly | |
| if dx or dy: | |
| self._emit_rel(dx, dy) | |
| self.drag_last_pos = (cx, cy) | |
| def _drag_end_single(self): | |
| if not self.drag_active: | |
| return | |
| self._left_up() | |
| self.log.debug("DRAG END (1f)") | |
| self.drag_active = False | |
| self.drag_last_pos = (None, None) | |
| def _two_start(self): | |
| self.two_active = True | |
| self.two_scrolling = False | |
| self.scroll_accum_v = 0.0 | |
| self.scroll_accum_h = 0.0 | |
| self.one_quiet_until = 0.0 | |
| slots = self._active_slots() | |
| pos = self._avg_pos_of_slots(slots) | |
| self.two_last_pos = pos | |
| self.two_start_pos = pos if pos else (0.0, 0.0) | |
| self.two_start_time = time.monotonic() | |
| self.two_slot_start = {} | |
| for s in slots[:2]: | |
| st = self.slot_pos.get(s, {}) | |
| x, y = st.get('x'), st.get('y') | |
| if x is not None and y is not None: | |
| self.two_slot_start[s] = (float(x), float(y)) | |
| if self.drag_active: | |
| self._drag_end_single() | |
| self.log.debug("2F START pos=%s slots=%s", pos, slots[:2]) | |
| def _two_end(self): | |
| now = time.monotonic() | |
| fired_right_click = False | |
| duration = now - self.two_start_time | |
| per_finger_ok = True | |
| for s, (sx, sy) in self.two_slot_start.items(): | |
| st = self.slot_pos.get(s, {}) | |
| x, y = st.get('x'), st.get('y') | |
| if x is None or y is None: | |
| continue | |
| if math.hypot(float(x)-sx, float(y)-sy) > self.cfg.two_tap_move: | |
| per_finger_ok = False | |
| break | |
| if not self.two_scrolling and duration <= self.cfg.two_tap_time and per_finger_ok: | |
| self._right_click() | |
| fired_right_click = True | |
| self.log.debug("2F TAP -> right click") | |
| self.log.debug("2F END dur=%.3f scrolled=%s", duration, self.two_scrolling) | |
| remain = self._active_slots() | |
| if len(remain) == 1: | |
| self.primary_slot = remain[0] | |
| self.one_contact_active = True | |
| self.had_pos_after_down = False | |
| self.last_pos = (None, None) | |
| self.accum_dx = 0.0; self.accum_dy = 0.0 | |
| self.one_quiet_until = now + self.cfg.two_to_one_settle | |
| if fired_right_click: | |
| self.suppress_next_single_tap = True | |
| self.log.debug("Suppress next 1F tap (post 2F right-click)") | |
| self.log.debug("2F->1F settle for %.0f ms; adopt slot %s", self.cfg.two_to_one_settle*1000, self.primary_slot) | |
| else: | |
| self.one_quiet_until = 0.0 | |
| self.two_active = False | |
| self.two_scrolling = False | |
| self.two_last_pos = (None, None) | |
| self.two_slot_start = {} | |
| self.log.debug("UP (2f)") | |
| def _on_frame(self): | |
| now = time.monotonic() | |
| active_n = self._active_count() | |
| if active_n >= 2: | |
| if not self.two_active: | |
| self._two_start() | |
| slots = self._active_slots() | |
| if not slots: | |
| return | |
| for s in slots[:2]: | |
| if s not in self.two_slot_start: | |
| st = self.slot_pos.get(s, {}) | |
| x, y = st.get('x'), st.get('y') | |
| if x is not None and y is not None: | |
| self.two_slot_start[s] = (float(x), float(y)) | |
| pos = self._avg_pos_of_slots(slots) | |
| if pos is None: | |
| return | |
| if self.two_last_pos in ((None, None), None): | |
| self.two_last_pos = pos; self.two_start_pos = pos; return | |
| cx, cy = pos | |
| lx, ly = self.two_last_pos | |
| dx, dy = cx - lx, cy - ly | |
| if not self.two_scrolling: | |
| if math.dist(pos, self.two_start_pos) >= self.cfg.two_scroll_start_move: | |
| self.two_scrolling = True | |
| self.log.debug("2F SCROLL start") | |
| if self.two_scrolling and (dx or dy): | |
| self._emit_scroll(dy=dy, dx=dx) | |
| self.two_last_pos = pos | |
| return | |
| else: | |
| if self.two_active: | |
| self._two_end() | |
| if active_n == 0: | |
| if self.drag_active: | |
| self._drag_end_single() | |
| self.suppress_next_single_tap = False | |
| return | |
| pos = self._get_primary_pos() | |
| if pos is None: | |
| return | |
| if now < self.one_quiet_until: | |
| if not self.had_pos_after_down: | |
| self.touch_start_pos = pos | |
| self.last_pos = pos | |
| self.had_pos_after_down = True | |
| self.log.debug("1F settle baseline pos=%s", pos) | |
| else: | |
| self.last_pos = pos | |
| return | |
| if not self.had_pos_after_down: | |
| self.touch_start_pos = pos | |
| self.last_pos = pos | |
| self.had_pos_after_down = True | |
| self.log.debug("FIRST_POS (1f) pos=%s", pos) | |
| return | |
| duration = now - self.touch_start_time | |
| baseline_move = math.dist(pos, self.touch_start_pos) | |
| if not self.drag_active: | |
| if duration >= self.cfg.long_press_time and baseline_move <= self.cfg.long_press_move: | |
| self._drag_begin_single(pos) | |
| self.drag_last_pos = pos | |
| lx, ly = self.last_pos | |
| cx, cy = pos | |
| dx, dy = cx - lx, cy - ly | |
| if dx != 0 or dy != 0: | |
| if self.drag_active: | |
| self._drag_update_single(pos) | |
| else: | |
| self._emit_rel(dx, dy) | |
| self.last_pos = pos | |
| def build_logger(debug: bool) -> logging.Logger: | |
| logger = logging.getLogger("touchpad") | |
| logger.setLevel(logging.DEBUG if debug else logging.WARNING) | |
| if logger.handlers: | |
| for h in list(logger.handlers): | |
| logger.removeHandler(h) | |
| h = logging.StreamHandler() | |
| fmt = logging.Formatter(fmt="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S") | |
| h.setFormatter(fmt) | |
| h.setLevel(logging.DEBUG if debug else logging.WARNING) | |
| logger.addHandler(h) | |
| return logger | |
| def main(): | |
| ap = argparse.ArgumentParser(description="Use a touchscreen like a touchpad (virtual relative mouse) via uinput.") | |
| ap.add_argument("-d", "--device", required=True, help="Path to touchscreen evdev node, e.g. /dev/input/event7") | |
| ap.add_argument("--sens", type=float, default=None, help="Overall cursor sensitivity (used if --sens-x/--sens-y not set)") | |
| ap.add_argument("--sens-x", type=float, default=None, help="Cursor sensitivity X") | |
| ap.add_argument("--sens-y", type=float, default=None, help="Cursor sensitivity Y") | |
| ap.add_argument("--tap-time", type=float, default=0.22, help="1-finger tap max seconds") | |
| ap.add_argument("--tap-move", type=float, default=40.0, help="1-finger tap max move") | |
| ap.add_argument("--two-tap-time", type=float, default=0.25, help="Two-finger tap max seconds (right click)") | |
| ap.add_argument("--two-tap-move", type=float, default=45.0, help="Two-finger tap max move") | |
| ap.add_argument("--two-scroll-start", type=float, default=25.0, help="Movement to switch two-finger tap -> scroll") | |
| ap.add_argument("--scroll-sens", type=float, default=0.006, help="Scroll sensitivity (vertical & horizontal)") | |
| ap.add_argument("--scroll-invert", action="store_true", help="Invert vertical scroll") | |
| ap.add_argument("--long-press-time", type=float, default=0.28, help="Seconds to hold before starting 1-finger drag") | |
| ap.add_argument("--long-press-move", type=float, default=12.0, help="Max move (px) allowed during long-press before drag") | |
| ap.add_argument("--rotate", type=int, default=0, help="Rotate clockwise: 0/90/180/270") | |
| ap.add_argument("--flip-x", action="store_true") | |
| ap.add_argument("--flip-y", action="store_true") | |
| ap.add_argument("--settle", type=float, default=0.12, help="Cooldown after two-finger end (seconds)") | |
| ap.add_argument("--debug", action="store_true", help="Enable debug logging") | |
| args = ap.parse_args() | |
| sens_default = 0.7 if args.sens is None else args.sens | |
| sens_x = sens_default if args.sens_x is None else args.sens_x | |
| sens_y = sens_default if args.sens_y is None else args.sens_y | |
| logger = build_logger(args.debug) | |
| cfg = Config( | |
| device_path=args.device, | |
| sensitivity_x=sens_x, | |
| sensitivity_y=sens_y, | |
| tap_max_time=args.tap_time, | |
| tap_max_move=args.tap_move, | |
| two_tap_time=args.two_tap_time, | |
| two_tap_move=args.two_tap_move, | |
| two_scroll_start_move=args.two_scroll_start, | |
| scroll_sens=args.scroll_sens, | |
| scroll_invert=args.scroll_invert, | |
| rotate=args.rotate, | |
| flip_x=args.flip_x, | |
| flip_y=args.flip_y, | |
| two_to_one_settle=args.settle, | |
| debug=args.debug, | |
| long_press_time=args.long_press_time, | |
| long_press_move=args.long_press_move, | |
| ) | |
| dev = InputDevice(cfg.device_path) | |
| ttm = TouchToMouse(dev, cfg, logger) | |
| try: | |
| ttm.run() | |
| except KeyboardInterrupt: | |
| pass | |
| finally: | |
| try: | |
| if ttm.drag_active: | |
| ttm._drag_end_single() | |
| dev.ungrab() | |
| logger.debug("Device ungrabbed.") | |
| except Exception as e: | |
| logger.debug("Ungrab error (ignored): %s", e) | |
| if __name__ == "__main__": | |
| main() | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment