"""Input bindings, and the typed event family every input flows through.
One class per device family, all of them subclasses of :class:`InputEvent`. A
handler's filter decides which one it receives, so a typed signature needs no
narrowing and ``match event:`` reads as ordinary Python::
match event:
case KeyEvent(key=Key.ESCAPE, pressed=True):
self.pause()
case PointerEvent(phase=PointerPhase.DOWN, position=pos):
self.aim_at(pos)
Every class is a ``@dataclass(slots=True, kw_only=True)``: no per-instance
``__dict__``, a free ``repr``, and keyword-only fields so a subclass can declare
a required one after the base's defaulted ones. ``kw_only`` also leaves
``__match_args__`` empty, which is why the patterns above are keyword patterns.
The base carries what every input has: when it happened, the modifier state at
that moment, and whether something has claimed it.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field, replace
from time import monotonic
from typing import TYPE_CHECKING
from ..math.types import Vec2, Vec3
from .controls import (
MODIFIER_OF,
MODIFIER_SIDES,
DeviceAxis,
DeviceButton,
DeviceControl,
DeviceType,
Key,
key_to_name,
name_to_keys,
)
from .enums import PointerPhase, PointerTool, PointerType
if TYPE_CHECKING:
from ..node import Node
from ..viewport import SubViewport
from .contexts import InputContext
#: The written names of the modifiers, as a combo spells them: ``"ctrl+s"``.
MODIFIER_NAMES = ("ctrl", "shift", "alt", "meta")
def _is_modifier(control: DeviceButton) -> bool:
bare = control.on(None)
return bare in MODIFIER_SIDES or bare in MODIFIER_OF
def _modifier_name(group: DeviceButton) -> str:
"""The written name of a modifier group: ``"ctrl"`` for ``Key.CTRL``."""
return str(group.name)
[docs]
def key_part(key: DeviceButton) -> str:
"""How one key of a combo is written: a modifier group by its name, any other key by its own."""
bare = key.on(None)
if bare in MODIFIER_SIDES:
return str(bare.name)
return key_to_name(bare) if bare not in MODIFIER_OF else str(bare.name)
[docs]
def combo_for_key(key: DeviceButton, *, held: tuple[DeviceButton, ...] = ()) -> str:
"""Spell a key and what it holds as a combo (``"ctrl+s"``): the one place the spelling is written.
For the interfaces spelled in text: a project file, a rebind screen, a
menu's shortcut column. Nothing on the input path compares combos.
"""
binding = InputBinding(key, held=held).key_combo
assert binding is not None
return binding
[docs]
def modifiers_held(event: InputEvent) -> tuple[DeviceButton, ...]:
"""The modifier groups *event* says were held, in combo order: ``(Key.CTRL, Key.SHIFT)``."""
return tuple(group for group in MODIFIER_SIDES if getattr(event, str(group.name), False))
[docs]
def key_named(name: str) -> DeviceButton | None:
"""The key a combo part names: a modifier group (``"ctrl"``), a key name, or a key's constant name."""
for group in MODIFIER_SIDES:
if group.name == name:
return group
keys = name_to_keys(name)
if keys:
return keys[0]
found = getattr(Key, name.upper(), None)
return found if isinstance(found, DeviceButton) else None
[docs]
def key_combo_to_binding(spec: str) -> InputBinding | None:
"""Parse a combo such as ``"ctrl+s"`` or ``"g+x"`` into a binding; None when *spec* is not one.
The inverse of :attr:`InputBinding.key_combo`: every part but the last is
held, the last is pressed, and ``"ctrl"``, ``"shift"``, ``"alt"`` and
``"meta"`` are either side. A bare key name is not a combo and returns
None, leaving it to the caller's own key lookup.
"""
parts = spec.lower().split("+")
if len(parts) < 2 or not all(parts):
return None
keys = [key_named(part) for part in parts]
if any(key is None for key in keys):
return None
*held, pressed = keys
return InputBinding(pressed, held=tuple(held)) # type: ignore[arg-type]
[docs]
def key_event_for_combo(spec: str, *, pressed: bool = True) -> KeyEvent:
"""The key event a written combo names: ``"escape"``, ``"ctrl+s"``.
For the drivers that are themselves written in text (a test harness, a
scripted demo). Raises ``ValueError`` naming the spec when no key answers
to it, or when the combo holds something a key event cannot carry.
"""
binding = key_combo_to_binding(spec)
if binding is None:
keys = name_to_keys(spec)
if not keys:
raise ValueError(f"no key is named {spec!r}; try a name such as 'escape' or a combo such as 'ctrl+s'")
binding = InputBinding(keys[0])
key = binding.control
assert isinstance(key, DeviceButton)
if len(binding.modifiers) != len(binding.held):
raise ValueError(f"{spec!r} holds a key that is not a modifier, which one key event cannot carry")
names = {_modifier_name(m) for m in binding.modifiers}
return KeyEvent(
key=key,
pressed=pressed,
label=key_to_name(key),
ctrl="ctrl" in names,
shift="shift" in names,
alt="alt" in names,
meta="meta" in names,
)
# ============================================================================
# The event family
# ============================================================================
[docs]
@dataclass(slots=True, kw_only=True)
class KeyEvent(InputEvent):
"""A key going down or coming up.
Attributes:
key: The **physical** key position, ``Key.W``: the named key where
the engine's table names the platform's code, ``Key.button(code)``
where it does not. A binding and a saved rebind file both name this,
so WASD is the same four positions on QWERTY and on AZERTY.
label: What that key prints under the player's layout, for a prompt or
a rebind screen. The router fills it from the engine's US table
when the backend cannot say.
pressed: True on press, False on release.
echo: True for an OS auto-repeat of a key already held.
cancelled: True on a release that did not happen: the control that
owned the press was hidden, disabled, paused or raised while the
key was down, and is told the press is over without it counting
as a keystroke, as a pointer's ``CANCEL`` phase tells a drag.
device_id: Which physical keyboard, or 0 for the aggregate device.
"""
key: DeviceButton
pressed: bool
label: str = ""
echo: bool = False
device_id: int = 0
cancelled: bool = False
[docs]
@dataclass(slots=True, kw_only=True)
class TextEvent(InputEvent):
"""Text the player typed, after the layout and the IME have had their say.
Attributes:
text: The characters produced.
composing: True while an IME composition is still in flight, so a text
field can underline the preedit rather than commit it.
"""
text: str
composing: bool = False
def _altitude(tilt: tuple[float, float]) -> float:
"""The angle between the barrel and the surface, from tilt: pi/2 upright, 0 flat.
Every platform reports tilt as x and y, so that is the lossless form stored;
a brush engine consumes the polar pair, so it is computed on read with the
W3C Pointer Events conversion.
"""
tx, ty = tilt
if tx == 0.0 and ty == 0.0:
return math.pi / 2
if tx == 0.0:
return math.pi / 2 - abs(ty)
if ty == 0.0:
return math.pi / 2 - abs(tx)
return math.atan(1.0 / math.hypot(math.tan(tx), math.tan(ty)))
def _azimuth(tilt: tuple[float, float]) -> float:
"""The direction the barrel leans, from tilt, in ``[0, 2pi)``; 0 at zero tilt."""
tx, ty = tilt
if tx == 0.0:
return math.pi / 2 if ty > 0.0 else (3 * math.pi / 2 if ty < 0.0 else 0.0)
if ty == 0.0:
return 0.0 if tx > 0.0 else math.pi
if abs(tx) == math.pi / 2 or abs(ty) == math.pi / 2:
return 0.0
angle = math.atan2(math.tan(ty), math.tan(tx))
return angle + 2 * math.pi if angle < 0.0 else angle
[docs]
@dataclass(slots=True, kw_only=True)
class PointerEvent(InputEvent):
"""A mouse, a finger or a pen doing something.
Every finger is a pointer of its own and so is a pen, each delivered
through the same door the mouse is. One of them at a time is *primary*:
the one that stands in for the mouse, so a control that takes one pointer
and, while ``Input.emulate_mouse`` is on, the mouse state and the
left-button bindings follow it.
**A button is one control.** :attr:`button` is what the device's layout
calls the button pressed, ``Pen.BARREL_1``, or its index form where the
layout has no name. A role is a group, asked with ``in``::
if event.phase is PointerPhase.DOWN and event.button in Pointer.PRIMARY:
self.press()
**Contact is not the phase.** :attr:`contact` is the primary role's bit:
a finger on the glass, a pen tip on the tablet, the left mouse button
held. A pen hovering over the tablet produces ``MOVE`` events with no
contact and a pressure of 0.0, and a pen pressing a barrel button while it
hovers is a ``DOWN`` with no contact, which is how a hover press is bound
at all.
Attributes:
pointer_id: 0 is the mouse. Every other pointer's id is opaque,
allocated by the router per device and backend id: a finger gets
a fresh one each time it touches down and a pen keeps one while it
is in range. :attr:`pointer_type` says what kind it is.
pointer_type: Which kind of pointer this is.
is_primary: True for the pointer that stands in for the mouse: the
mouse itself, the first finger down while no other pointer holds
primacy, and a pen likewise. Primacy moves on a press only.
phase: What the pointer did.
position: Where it is, in screen pixels, sub-pixel.
delta: How far it moved since the previous event for this pointer.
button: The button this event is about, as the layout in force names
it (``Mouse.LEFT``, ``Pen.BARREL_1``, ``Touch.CONTACT``), or its
index form (``Pen.button(5)``) where the layout names none; None for
a pure move. An event built by hand may name either, or a role;
publishing it resolves the button through the device's layout.
buttons: Bitmask of the buttons this pointer holds once the event has
happened, bit ``index`` each, in the device's own numbering. For
the mouse, the buttons the machine holds.
contact_mask: The bits of :attr:`buttons` that are contact: the
primary role's, from the device's layout.
click_count: 1 for a single click, 2 for a double, and so on, on the
press and on the release that closes it; 0 on a pure move and
wherever nothing has resolved it.
dragging: True once the pointer has travelled past its type's drag
threshold since the press, and on every event of that press after.
A release still carrying False is a click rather than a drag.
pressure: 0.0 whenever the pointer is not in contact. In contact, what
the device measured, or 1.0 on hardware that cannot measure it;
``Input.capabilities`` says which (``PenAxis.PRESSURE in
pen_axes`` for a pen, ``touch_pressure`` for a finger).
tilt: The barrel's tilt from upright as ``(x, y)``, radians in
``[-pi/2, pi/2]``; zero for a device without one.
twist: The barrel's rotation about its own axis, radians in
``[0, 2pi)`` clockwise; zero where not reported.
distance: How far a hovering pen is from the surface, 0 to 1, 0 at the
surface; zero where not reported.
tangential_pressure: An airbrush wheel or barrel squeeze, -1 to 1 with
0 neutral; zero where not reported.
tool: Which end of the pointer is working: the tip, or a pen's eraser.
tool_id: Which physical tool, for a per-tool preset; 0 where not
reported.
contact_size: The width and height of the contact in screen pixels, a
finger or a palm on the glass; ``(0, 0)`` where not reported.
locked: True while the pointer is locked and ``position`` is stale.
viewport: The space ``position`` is in, or None for the root.
device_id: The engine's id for the device, the key in ``Input.devices``,
allocated per type and backend id; 0 on an event built by hand, which
publishing resolves to the type's aggregate device.
"""
phase: PointerPhase
pointer_id: int = 0
pointer_type: PointerType = PointerType.MOUSE
is_primary: bool = True
position: tuple[float, float] = (0.0, 0.0)
delta: tuple[float, float] = (0.0, 0.0)
button: DeviceButton | None = None
buttons: int = 0
contact_mask: int = 1
click_count: int = 0
dragging: bool = False
pressure: float = 0.0
tilt: tuple[float, float] = (0.0, 0.0)
twist: float = 0.0
distance: float = 0.0
tangential_pressure: float = 0.0
tool: PointerTool = PointerTool.TIP
tool_id: int = 0
contact_size: tuple[float, float] = (0.0, 0.0)
locked: bool = False
viewport: SubViewport | None = None
device_id: int = 0
[docs]
def __post_init__(self) -> None:
button = self.button
if button is not None and button.device is DeviceType.POINTER:
# Built by hand with a role and not yet through the router: the
# role becomes this pointer type's member of it, so a widget test
# reads the control a published event carries.
from .controls import ROLE_MEMBER
self.button = ROLE_MEMBER.get((button, DeviceType(self.pointer_type.value)), button)
[docs]
@property
def altitude(self) -> float:
"""The angle between the barrel and the surface, radians: pi/2 upright, 0 flat."""
return _altitude(self.tilt)
[docs]
@property
def azimuth(self) -> float:
"""The direction the barrel leans, radians in ``[0, 2pi)``; 0 at zero tilt."""
return _azimuth(self.tilt)
[docs]
@dataclass(frozen=True, slots=True, kw_only=True)
class PointerState:
"""One live pointer as it stands this frame, as ``Input.pointers`` holds it.
The polled companion of :class:`PointerEvent`, with the same field names,
so a game can poll every finger and a pen the way it polls a key. A finger
is live from its press until the frame after it lifts; a pen from the
moment it is seen until the frame after it leaves the digitiser's range;
the mouse once it has done anything at all.
Attributes:
pointer_id: 0 is the mouse; every other id is opaque, as
:attr:`PointerEvent.pointer_id`.
pointer_type: Which kind of pointer this is.
position: Where it is, in screen pixels.
contact: Whether it is touching, as :attr:`PointerEvent.contact`.
is_primary: Whether it stands in for the mouse.
buttons: The buttons it holds, bit ``index`` each in the device's own
numbering.
pressure, tilt, twist, distance, tangential_pressure, tool, tool_id,
contact_size: As on :class:`PointerEvent`, from its latest event.
just_pressed: Contact began this frame.
just_released: Contact ended this frame, lifted or cancelled.
cancelled: The contact that ended was taken away by the system rather
than lifted, so it completed nothing: a gesture reading a tap off
a release checks this first.
device_id: The engine's id for the device, as :attr:`PointerEvent.device_id`.
"""
pointer_id: int
pointer_type: PointerType
position: tuple[float, float]
contact: bool = False
is_primary: bool = False
buttons: int = 0
pressure: float = 0.0
tilt: tuple[float, float] = (0.0, 0.0)
twist: float = 0.0
distance: float = 0.0
tangential_pressure: float = 0.0
tool: PointerTool = PointerTool.TIP
tool_id: int = 0
contact_size: tuple[float, float] = (0.0, 0.0)
just_pressed: bool = False
just_released: bool = False
cancelled: bool = False
device_id: int = 0
[docs]
@property
def altitude(self) -> float:
"""The angle between the barrel and the surface, radians: pi/2 upright, 0 flat."""
return _altitude(self.tilt)
[docs]
@property
def azimuth(self) -> float:
"""The direction the barrel leans, radians in ``[0, 2pi)``; 0 at zero tilt."""
return _azimuth(self.tilt)
[docs]
@dataclass(slots=True, kw_only=True)
class AxisEvent(InputEvent):
"""An axis with no position moving: a pad's stick or trigger, a tablet's ring or strip.
Both halves of what an axis can say, whichever its hardware reports: where
it is and how far it moved. A ring that reports only motion has no
position, and :attr:`value` says so with None rather than a number that
would mean nothing.
Attributes:
axis: What the layout in force calls the axis, ``Controller.LEFT_X`` or
``Tablet.RING``, or its index form, ``Tablet.axis(0)``.
value: Where it is now, or None for an axis that reports only motion.
delta: How far it moved in this report, the short way round a ring.
device_id: Which device, the engine's id for it.
"""
axis: DeviceAxis
value: float | None
delta: float = 0.0
device_id: int = 0
[docs]
@dataclass(slots=True, kw_only=True)
class ActionEvent(InputEvent):
"""An action edge, resolved from whatever device binding produced it.
What an action handler, ``@on_input("jump")``, receives. It asked about an
action, so it is given the action: which one, which way it went, what it
reads, whose it is, and the device event underneath it.
Attributes:
action: The action's name.
pressed: True as the action opens, False as it closes.
value: What the action reads at this edge, in the type its bindings
gave it: ``bool`` for a button action, ``float`` for one carrying
an ``Axis1D`` or a bare axis, ``Vec2`` for one carrying an
``Axis2D`` or a stick.
player: Whose action this is. 0 is the default player, which owns
every device nobody else was paired to, so a single-player game
sees 0 throughout.
source: The device event the binding resolved from, or None.
context: The input context that declared the action, or None.
"""
action: str
pressed: bool
value: bool | float | Vec2 = False
player: int = 0
source: InputEvent | None = None
context: InputContext | None = None
[docs]
@dataclass(slots=True, kw_only=True)
class PickEvent(InputEvent):
"""A ray through the pointer hitting a node, delivered to ``on_picked``.
Attributes:
source: The pointer event the ray was cast for, or None.
viewport: The space the pointer was in, or None for the root.
button: Which button was involved, as :attr:`PointerEvent.button`.
pressed: True on press, False on release.
origin: World-space ray origin.
direction: World-space ray direction, normalised.
distance: Distance along the ray to the hit.
node: The node that was hit, or None.
point: The world-space hit point, or None where it is not computed.
normal: The surface normal there, or None where it is not computed.
"""
origin: Vec3
direction: Vec3
source: InputEvent | None = None
viewport: SubViewport | None = None
button: DeviceButton | None = None
pressed: bool = True
distance: float = 0.0
node: Node | None = None
point: Vec3 | None = None
normal: Vec3 | None = None
[docs]
@dataclass(slots=True, kw_only=True)
class ControllerConnectionEvent(InputEvent):
"""A controller arriving or leaving.
Attributes:
device_id: Which pad, the key it has in ``Input.controllers``.
connected: True when it arrived, False when it left.
"""
device_id: int
connected: bool
#: Everything ``on_gui_input`` can be handed, which is the same object the
#: tree received: a key press or release is a :class:`KeyEvent`, a typed
#: character a :class:`TextEvent`, a pointer press, release or move a
#: :class:`PointerEvent`, and a wheel notch a :class:`ScrollEvent`.
#:
#: A handler tells them apart with ``isinstance``, and the guard belongs in
#: the branch that reads the field rather than above several of them: a guard
#: placed above is only correct while every branch under it returns, and the
#: next edit moves one.
GuiInputEvent = KeyEvent | TextEvent | PointerEvent | ScrollEvent