"""GestureRecognizer: Touch gesture detection node.
Reads the live pointers from ``Input.pointers`` each frame and emits signals
for common gestures: tap, long press, swipe, pinch, rotate, pan.
"""
import logging
import math
import time
from .descriptors import Property
from .input.enums import PointerType
from .input.state import Input
from .node import Node
from .signals import Signal
log = logging.getLogger(__name__)
[docs]
class GestureRecognizer(Node):
"""Recognises touch gestures and emits corresponding signals.
Add as a child node and connect to the gesture signals you care about::
gesture = GestureRecognizer()
root.add_child(gesture)
gesture.tap.connect(lambda x, y: print(f"Tapped at {x}, {y}"))
gesture.swipe.connect(lambda d: print(f"Swiped {d}"))
Timings are measured against the scene clock (``tree.now``), so slow motion
stretches a long press, a paused scene never completes one, and a gesture
replayed through the same fixed-step input script is recognised identically
every run. A recogniser that must keep working while the game is paused (the
tap-and-hold on a pause menu) opts out with ``clock="wall"`` and needs
``update_mode = UpdateMode.ALWAYS`` so it still ticks.
Only fingers are read by default, because a gesture is something a hand
does on the glass and a pen resting on it is not half of a pinch. A scene
that wants the mouse to pan and tap too says so with ``pointer_types``.
"""
# Signals
tap = Signal()
long_press = Signal()
swipe = Signal()
pinch = Signal()
rotate = Signal()
pan = Signal()
# Configurable thresholds
clock = Property(
"tree",
enum=["tree", "wall"],
hint="Clock the timings are measured against: the scene clock, or wall time that ignores pause",
)
tap_timeout = Property(0.3, hint="Max seconds for a tap")
long_press_timeout = Property(0.5, hint="Hold duration for long press")
swipe_min_velocity = Property(500.0, hint="Min pixels/sec for swipe")
tap_max_distance = Property(20.0, hint="Max pixel movement for tap")
pointer_types = Property(
(PointerType.TOUCH,),
hint="Which kinds of pointer take part: fingers by default, the mouse or a pen on request",
)
def __init__(self, name: str = "", **kwargs):
super().__init__(name=name, **kwargs)
# Per-finger tracking: finger_id -> (start_x, start_y, start_time, last_x, last_y)
self._finger_state: dict[int, tuple[float, float, float, float, float]] = {}
self._long_press_fired: set[int] = set()
# Two-finger state
self._prev_pinch_dist: float | None = None
self._prev_angle: float | None = None
[docs]
def on_update(self, dt: float) -> None:
kinds = self.pointer_types
taking_part = {
pid: p for pid, p in Input.pointers.items() if p.pointer_type in kinds and (p.contact or p.just_released)
}
just_pressed = {pid: p.position for pid, p in taking_part.items() if p.just_pressed}
just_released = [pid for pid, p in taking_part.items() if p.just_released]
cancelled = {pid for pid, p in taking_part.items() if p.cancelled}
active = {pid: p.position for pid, p in taking_part.items() if p.contact}
now = self._current_time()
# A finger that vanished without lifting (a scene change, the window
# losing focus) completed nothing and is forgotten.
for fid in [fid for fid in self._finger_state if fid not in taking_part]:
del self._finger_state[fid]
self._long_press_fired.discard(fid)
# Handle new touches
for fid, (x, y) in just_pressed.items():
self._finger_state[fid] = (x, y, now, x, y)
# Snapshot previous positions before updating
prev_pos: dict[int, tuple[float, float]] = {}
for fid, (_sx, _sy, _st, lx, ly) in self._finger_state.items():
prev_pos[fid] = (lx, ly)
# Update positions for active touches
for fid, (x, y) in active.items():
if fid in self._finger_state:
sx, sy, st, _lx, _ly = self._finger_state[fid]
self._finger_state[fid] = (sx, sy, st, x, y)
# Single-finger gestures
active_ids = set(active)
if len(active_ids) == 1:
fid = next(iter(active_ids))
if fid in self._finger_state:
sx, sy, st, cx, cy = self._finger_state[fid]
dx, dy = cx - sx, cy - sy
dist = math.hypot(dx, dy)
# Pan (emits delta every frame the finger moves)
if fid not in just_pressed and fid in prev_pos:
plx, ply = prev_pos[fid]
fdx, fdy = cx - plx, cy - ply
if fdx != 0.0 or fdy != 0.0:
self.pan.emit(fdx, fdy)
# Long press detection
elapsed = now - st
if (
elapsed >= self.long_press_timeout
and dist <= self.tap_max_distance
and fid not in self._long_press_fired
):
self._long_press_fired.add(fid)
self.long_press.emit(cx, cy)
# Two-finger gestures (pinch / rotate)
if len(active_ids) == 2:
ids = sorted(active_ids)
f0, f1 = ids
if f0 in active and f1 in active:
x0, y0 = active[f0]
x1, y1 = active[f1]
dist = math.hypot(x1 - x0, y1 - y0)
angle = math.atan2(y1 - y0, x1 - x0)
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
if self._prev_pinch_dist is not None and self._prev_pinch_dist > 0:
scale = dist / self._prev_pinch_dist
if scale != 1.0:
self.pinch.emit(scale, cx, cy)
if self._prev_angle is not None:
angle_delta = angle - self._prev_angle
# Normalise to [-pi, pi]
while angle_delta > math.pi:
angle_delta -= 2 * math.pi
while angle_delta < -math.pi:
angle_delta += 2 * math.pi
if angle_delta != 0.0:
self.rotate.emit(angle_delta, cx, cy)
self._prev_pinch_dist = dist
self._prev_angle = angle
else:
self._prev_pinch_dist = None
self._prev_angle = None
# Handle released touches
for fid in just_released:
if fid not in self._finger_state:
continue
sx, sy, st, lx, ly = self._finger_state[fid]
elapsed = now - st
dx, dy = lx - sx, ly - sy
dist = math.hypot(dx, dy)
if fid in cancelled:
# The system took the finger away: nothing it did completes.
pass
# Tap: short duration, small movement, not already long-pressed
elif elapsed <= self.tap_timeout and dist <= self.tap_max_distance and fid not in self._long_press_fired:
self.tap.emit(lx, ly)
# Swipe: fast directional movement
elif elapsed <= 0.4 and elapsed > 0 and dist > self.tap_max_distance:
velocity = dist / elapsed
if velocity >= self.swipe_min_velocity:
if abs(dx) >= abs(dy):
direction = "right" if dx > 0 else "left"
else:
direction = "down" if dy > 0 else "up"
self.swipe.emit(direction)
del self._finger_state[fid]
self._long_press_fired.discard(fid)
# ------------------------------------------------------------------
# Time source
# ------------------------------------------------------------------
def _current_time(self) -> float:
"""Seconds on whichever clock :attr:`clock` selects."""
if self.clock == "wall":
return time.monotonic()
return self.tree.now