Source code for simvx.core.input.gestures

"""Gestures as controls: what fingers do together, recognised only while something names it.

``Touch.LONG_PRESS`` and ``Touch.TWO_FINGER_TAP`` are buttons, ``Touch.PINCH``
and ``Touch.ROTATE`` axes, ``Touch.PAN`` a stick and ``Mouse.WHEEL`` an axis,
so a gesture binds, rebinds and is heard by the one mechanism a key is. The
router feeds every finger here and publishes what this recognises as ordinary
events: a gesture button is a ``PointerEvent`` on the finger that made it, a
gesture axis an ``AxisEvent`` carrying the frame's motion.

Recognition is not free, so it runs only while a binding in the tree's map, a
handler, or ``Input.touch_secondary`` names one; a game that names none pays one
set test per finger event.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from time import monotonic
from typing import TYPE_CHECKING

from .controls import DeviceAxis, DeviceButton, DeviceControl, DeviceStick, DeviceType, Mouse, Touch
from .enums import PointerPhase, PointerType
from .events import AxisEvent, PointerEvent

if TYPE_CHECKING:
    from .router import InputRouter

__all__ = ["RECOGNISED", "recognised_in"]

#: Every control the engine recognises rather than reads off a device.
RECOGNISED: frozenset[DeviceControl] = frozenset(
    {Touch.LONG_PRESS, Touch.TWO_FINGER_TAP, Touch.PINCH, Touch.ROTATE, Touch.PAN_X, Touch.PAN_Y, Mouse.WHEEL}
)

#: The index form of each, in the engine's own numbering, so a raw binding names it too.
_BY_INDEX: dict[tuple[DeviceType, str, int], DeviceControl] = {
    (DeviceType.TOUCH, "button", 1): Touch.LONG_PRESS,
    (DeviceType.TOUCH, "button", 2): Touch.TWO_FINGER_TAP,
    (DeviceType.TOUCH, "axis", 0): Touch.PINCH,
    (DeviceType.TOUCH, "axis", 1): Touch.ROTATE,
    (DeviceType.TOUCH, "axis", 2): Touch.PAN_X,
    (DeviceType.TOUCH, "axis", 3): Touch.PAN_Y,
    (DeviceType.MOUSE, "axis", 0): Mouse.WHEEL,
}


[docs] def recognised_in(control: object) -> tuple[DeviceControl, ...]: """Which recognised controls *control* names: a gesture, its half or motion, a stick of two, or none.""" if not isinstance(control, DeviceControl) or control.device not in (DeviceType.TOUCH, DeviceType.MOUSE): return () if isinstance(control, DeviceStick): return recognised_in(control.x) + recognised_in(control.y) if isinstance(control, DeviceAxis): control = control.whole if control.unit is not None: control = control.on(None) if control.index is not None: kind = "axis" if isinstance(control, DeviceAxis) else "button" found = _BY_INDEX.get((control.device, kind, control.index)) return (found,) if found is not None else () return (control,) if control in RECOGNISED else ()
@dataclass(slots=True) class _Finger: """One finger on the glass, as the recogniser follows it.""" pointer_id: int device_id: int start: tuple[float, float] position: tuple[float, float] down_at: float #: Whether this finger has travelled past its press's drag threshold. moved: bool = False #: Whether a long press was recognised on it. long_pressed: bool = False #: Whether that long press was a right click, which is over at once, not held. clicked: bool = False
[docs] class GestureRecognition: """One router's recogniser: follows its fingers and publishes the gestures its tree names.""" def __init__(self, router: InputRouter) -> None: self._router = router # By the backend's key for the finger, so a finger whose contact a # recognised gesture cancelled is still followed to its lift. self._fingers: dict[tuple, _Finger] = {} #: The two fingers a pinch reads, their distance, angle and centre last frame. self._pair: tuple[tuple, tuple, float, float, tuple[float, float]] | None = None #: The fingers of a two-finger tap in the making, and when the first went down. self._tap: tuple[set[tuple], float] | None = None #: The fingers of the tap that have lifted, and where. self._tap_lifted: list[tuple[float, float]] = [] # -- Feeding -----------------------------------------------------------------
[docs] def touch( self, key: tuple, pointer_id: int | None, phase: PointerPhase, position: tuple[float, float], stamp: float ) -> None: """A finger went down, moved, lifted or was cancelled.""" named = self._router._recognising() if phase is PointerPhase.DOWN and pointer_id is not None: new = _Finger(pointer_id, key[1], position, position, stamp) self._fingers[key] = new self._pair = None if Touch.TWO_FINGER_TAP in named: self._tap_began(key, new, stamp) return finger = self._fingers.get(key) if finger is None: return finger.position = position if not finger.moved and _moved_past(position, finger.start, self._threshold(finger)): finger.moved = True self._tap = None if phase is PointerPhase.MOVE: return del self._fingers[key] self._pair = None if finger.long_pressed and not finger.clicked and phase is PointerPhase.UP: self._release_long_press(finger, position, stamp) if phase is PointerPhase.CANCEL: self._tap = None elif self._tap is not None and key in self._tap[0]: self._tap_lifted.append(position) if len(self._tap_lifted) == 2: self._tap_ended(finger, stamp)
[docs] def frame(self) -> None: """Once a frame, after the frame's motion: time the long presses and publish the pair's motion.""" if not self._fingers: return named = self._router._recognising() if not named: return if Touch.LONG_PRESS in named and len(self._fingers) == 1: self._check_long_press() if len(self._fingers) >= 2 and named & _PAIR_AXES: self._pair_motion(named)
# -- Long press -------------------------------------------------------------- def _check_long_press(self) -> None: router = self._router tree = router.tree if tree is None: return finger = next(iter(self._fingers.values())) if finger.long_pressed or finger.moved or monotonic() - finger.down_at < tree.long_press_time: return if tree._ui.grabbed(router.source_id, finger.pointer_id): return # a control holding the finger (a joystick, a slider) is using it finger.long_pressed = True if router._input.touch_secondary is Touch.LONG_PRESS: # A right click: the press that began it is cancelled, so it is no # click too, and the gesture is a click of its own. finger.clicked = True router._end_contact(finger.pointer_id, PointerPhase.CANCEL) self._click(finger, Touch.LONG_PRESS, finger.position) return router._gesture_button(finger.pointer_id, finger.device_id, Touch.LONG_PRESS, True, finger.position) def _release_long_press(self, finger: _Finger, position: tuple[float, float], stamp: float) -> None: self._router._gesture_button(finger.pointer_id, finger.device_id, Touch.LONG_PRESS, False, position) # -- Two-finger tap ---------------------------------------------------------- def _tap_began(self, key: tuple, finger: _Finger, stamp: float) -> None: tree = self._router.tree tap_time = tree.tap_time if tree is not None else 0.3 others = [k for k in self._fingers if k != key] if not others: self._tap = ({key}, stamp) self._tap_lifted = [] return if self._tap is None or len(others) != 1 or others[0] not in self._tap[0] or stamp - self._tap[1] > tap_time: self._tap = None return first = self._fingers[others[0]] if first.moved: self._tap = None return self._tap[0].add(key) if self._router._input.touch_secondary is Touch.TWO_FINGER_TAP: # The first finger's press would otherwise be a click as it lifts. self._router._end_contact(first.pointer_id, PointerPhase.CANCEL) def _tap_ended(self, last: _Finger, stamp: float) -> None: tap, self._tap = self._tap, None tree = self._router.tree if tap is None or tree is None or stamp - tap[1] > tree.tap_time: return (ax, ay), (bx, by) = self._tap_lifted centre = ((ax + bx) / 2, (ay + by) / 2) self._click(last, Touch.TWO_FINGER_TAP, centre) # -- Pinch, rotate, pan ------------------------------------------------------ def _pair_motion(self, named: frozenset[DeviceControl]) -> None: keys = list(self._fingers)[:2] a, b = self._fingers[keys[0]], self._fingers[keys[1]] dx, dy = b.position[0] - a.position[0], b.position[1] - a.position[1] distance = math.hypot(dx, dy) angle = math.atan2(dy, dx) centre = ((a.position[0] + b.position[0]) / 2, (a.position[1] + b.position[1]) / 2) pair = self._pair self._pair = (keys[0], keys[1], distance, angle, centre) if pair is None or pair[0] != keys[0] or pair[1] != keys[1]: return # the pair just formed: motion is measured from here _, _, was_distance, was_angle, was_centre = pair router = self._router device = a.device_id if Touch.PINCH in named and was_distance > 0.0 and distance != was_distance: router._gesture_axis(Touch.PINCH, distance / was_distance - 1.0, device) if Touch.ROTATE in named and angle != was_angle: turn = (angle - was_angle + math.pi) % math.tau - math.pi router._gesture_axis(Touch.ROTATE, turn, device) if centre != was_centre: if Touch.PAN_X in named and centre[0] != was_centre[0]: router._gesture_axis(Touch.PAN_X, centre[0] - was_centre[0], device) if Touch.PAN_Y in named and centre[1] != was_centre[1]: router._gesture_axis(Touch.PAN_Y, centre[1] - was_centre[1], device) # -- Helpers ------------------------------------------------------------------ def _click(self, finger: _Finger, button: DeviceButton, position: tuple[float, float]) -> None: router = self._router router._gesture_button(finger.pointer_id, finger.device_id, button, True, position) router._gesture_button(finger.pointer_id, finger.device_id, button, False, position) def _threshold(self, finger: _Finger) -> float: track = self._router._pointers.get(finger.pointer_id) if track is not None: return track.drag_threshold tree = self._router.tree return tree.drag_threshold[PointerType.TOUCH] if tree is not None else 12.0
_PAIR_AXES: frozenset[DeviceControl] = frozenset({Touch.PINCH, Touch.ROTATE, Touch.PAN_X, Touch.PAN_Y}) def _moved_past(a: tuple[float, float], b: tuple[float, float], limit: float) -> bool: return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 > limit * limit
[docs] def gesture_event( pointer_id: int, device_id: int, button: DeviceButton, pressed: bool, position: tuple[float, float], buttons: int ) -> PointerEvent: """The event a gesture button is: a press or release of it on the finger that made it, holding *buttons* after.""" return PointerEvent( phase=PointerPhase.DOWN if pressed else PointerPhase.UP, pointer_id=pointer_id, pointer_type=PointerType.TOUCH, is_primary=True, position=position, button=button, buttons=buttons, device_id=device_id, time=monotonic(), )
[docs] def gesture_axis_event(axis: DeviceAxis, motion: float, device_id: int) -> AxisEvent: """The event a gesture axis is: the frame's motion, with no position.""" return AxisEvent(axis=axis, value=None, delta=motion, device_id=device_id, time=monotonic())