"""GestureRecognizer — Touch gesture detection node.
Consumes raw touch data from the Input singleton each frame and emits
signals for common gestures: tap, long press, swipe, pinch, rotate, pan.
"""
from __future__ import annotations
import logging
import math
from .descriptors import Property, Signal
from .input.state import Input
from .node import Node
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}"))
"""
# Signals
tap = Signal()
long_press = Signal()
swipe = Signal()
pinch = Signal()
rotate = Signal()
pan = Signal()
# Configurable thresholds
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")
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 process(self, dt: float) -> None:
just_pressed = Input.get_touches_just_pressed()
just_released = Input.get_touches_just_released()
active = Input.get_touches()
now = self._current_time()
# Handle new touches
for fid, (x, y, _p) 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, _p) 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(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(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(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(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)
# Tap: short duration, small movement, not already long-pressed
if elapsed <= self.tap_timeout and dist <= self.tap_max_distance and fid not in self._long_press_fired:
self.tap(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(direction)
del self._finger_state[fid]
self._long_press_fired.discard(fid)
# ------------------------------------------------------------------
# Time source — overridable for deterministic testing
# ------------------------------------------------------------------
_time_override: float | None = None
def _current_time(self) -> float:
"""Return the current time. Uses override if set (for testing)."""
if self._time_override is not None:
return self._time_override
import time
return time.monotonic()