Source code for simvx.core.gesture

"""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