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