Skip to content

Instantly share code, notes, and snippets.

@ergolyam
Last active October 3, 2025 13:35
Show Gist options
  • Select an option

  • Save ergolyam/bef08f0a9fa62baf450eae639490ee7e to your computer and use it in GitHub Desktop.

Select an option

Save ergolyam/bef08f0a9fa62baf450eae639490ee7e to your computer and use it in GitHub Desktop.
script that turns a touchscreen into a touchpad
#!/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