Source code for simvx.core.gesture

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