Source code for simvx.core.input.router
"""Platform-neutral input routing.
Every backend -- desktop windowing (GLFW/SDL3) and the browser runtime -- feeds
its events through :class:`InputRouter`. The router is the single place that
knows what an input event *means* to the engine:
1. update the :class:`Input` singleton (typed and string-keyed state),
2. route the event to the UI system (``tree.ui_input`` / ``tree.touch_input``),
3. propagate a :class:`TreeInputEvent` for ``@on_input`` handler dispatch.
Backends translate their own wire format into the normalised arguments here --
engine :class:`Key` codes, :class:`MouseButton` values, ``pressed`` booleans --
and do nothing else. Keeping steps 1-3 in one place is what stops the backends
drifting apart: a handler kind that works on the desktop works in the browser
because both reach it through this module.
"""
import logging
from typing import TYPE_CHECKING
from ..events import TreeInputEvent
from .enums import Key, MouseButton, _name_for_code
from .state import Input as _ambient_input
from .state import _Input
if TYPE_CHECKING:
from ..scene_tree import SceneTree
log = logging.getLogger(__name__)
# Touch actions, as delivered by every backend's touch callback.
TOUCH_DOWN = 0
TOUCH_UP = 1
TOUCH_MOVE = 2
def _resolve_modifiers(
state: _Input, ctrl: bool | None, shift: bool | None, alt: bool | None, meta: bool | None
) -> tuple[bool, bool, bool, bool]:
"""The four modifier flags an event carries, filling in what it did not state.
A window backend reports what it saw at the moment of the event, and those
flags pass through unchanged. Anything left unstated -- a wheel notch, a
pure move, a touch, an event a test synthesised -- falls back to ``state``'s
held keys, which is the same fact arriving a different way. That fallback is
what lets every event kind carry modifiers, not only the ones a backend
reports them for.
The Super / Command key is held under the name ``"super"`` and reported on
an event as ``meta``: one key, in the spelling each layer already uses.
"""
held = state._keys
return (
held.get("ctrl", False) if ctrl is None else bool(ctrl),
held.get("shift", False) if shift is None else bool(shift),
held.get("alt", False) if alt is None else bool(alt),
held.get("super", False) if meta is None else bool(meta),
)
[docs]
class InputRouter:
"""Routes normalised input events into engine state, UI, and ``@on_input``.
Args:
tree: The :class:`SceneTree` receiving UI and ``@on_input`` dispatch.
``None`` routes to input state only, which is what a headless
backend or a state-only test wants.
input_state: Input state override, for a caller that owns an instance
no tree holds.
A router that was given a tree writes that tree's own :class:`_Input`, not
the ambient proxy. The two are the same object for an ordinary tree, and a
different one for a tree built with ``isolated_input=True``: the state write
happens before the tree opens its input span, so resolving the ambient proxy
here would land every key in the process-wide default and the isolated tree
would read ``False`` for a key that is genuinely held.
"""
__slots__ = ("tree", "_input", "_primary_finger", "_last_motion_pos")
def __init__(self, tree: SceneTree | None = None, *, input_state: _Input | None = None):
self.tree = tree
if input_state is not None:
self._input = input_state
elif tree is not None:
self._input = tree.input
else:
self._input = _ambient_input
# First finger down owns the emulated mouse pointer until it lifts.
self._primary_finger: int | None = None
# Motion is deduplicated before reaching the UI: backends can deliver
# repeated positions, and a no-op move invalidates hover state for free.
self._last_motion_pos: tuple[float, float] | None = None
[docs]
def reset(self) -> None:
"""Drop per-session pointer state (call on scene change or app teardown)."""
self._primary_finger = None
self._last_motion_pos = None
[docs]
def is_key_down(self, code: int) -> bool:
"""Whether *code* is currently held, for backends that must infer key repeat."""
return code in self._input._keys_pressed
# -- Keyboard ---------------------------------------------------------
[docs]
def key(
self,
code: int,
pressed: bool,
*,
echo: bool = False,
ctrl: bool | None = None,
shift: bool | None = None,
alt: bool | None = None,
meta: bool | None = None,
) -> None:
"""Route a key event.
Args:
code: Engine :class:`Key` code.
pressed: True on press, False on release.
echo: True for an auto-repeat press. Repeats reach the UI, so held
keys still repeat in text fields, but they are not propagated
as ``@on_input`` events: a game action fires once per physical
press, so a tree event is never a repeat.
ctrl, shift, alt, meta: Modifier state at event time. A backend
that does not report them leaves them unset and the held-key
state answers instead.
"""
ctrl, shift, alt, meta = _resolve_modifiers(self._input, ctrl, shift, alt, meta)
name = _name_for_code(code)
if not echo:
# _on_key keeps the typed and string-keyed state in lock-step,
# just_pressed included, so this is the whole state update.
self._input._on_key(code, pressed)
tree = self.tree
if tree is None:
return
if not echo:
try:
key_enum = Key(code)
except ValueError:
key_enum = None
if key_enum is not None:
tree.propagate_input(
TreeInputEvent(
"key",
key=key_enum,
pressed=pressed,
ctrl=ctrl,
shift=shift,
alt=alt,
meta=meta,
)
)
tree.ui_input(key=name, pressed=pressed, ctrl=ctrl, shift=shift, alt=alt, meta=meta)
[docs]
def char(self, text: str) -> None:
"""Route a typed character (text entry only; never a game action)."""
if self.tree is not None:
self.tree.ui_input(char=text)
# -- Mouse ------------------------------------------------------------
[docs]
def mouse_button(
self,
button: MouseButton,
pressed: bool,
*,
ctrl: bool | None = None,
shift: bool | None = None,
alt: bool | None = None,
meta: bool | None = None,
) -> None:
"""Route a mouse button event.
Args:
button: Engine :class:`MouseButton`. Backends whose native ordering
differs (the DOM numbers the middle and right buttons the other
way round) remap before calling.
pressed: True on press, False on release.
ctrl, shift, alt, meta: Modifier state at event time, carried to
the UI as well as to ``@on_input``, so a widget can implement a
Shift-click. A caller that does not report them -- the emulated
pointer behind a touch, for one -- leaves them unset and the
held-key state answers instead.
"""
ctrl, shift, alt, meta = _resolve_modifiers(self._input, ctrl, shift, alt, meta)
inp = self._input
name = f"mouse_{int(button) + 1}"
if pressed:
if not inp._keys.get(name):
inp._keys_just_pressed[name] = True
inp._keys[name] = True
else:
inp._keys[name] = False
inp._keys_just_released[name] = True
inp._on_mouse_button(int(button), pressed)
tree = self.tree
if tree is None:
return
tree.propagate_input(
TreeInputEvent(
"mouse_button",
mouse_button=button,
pressed=pressed,
position=inp._mouse_pos,
ctrl=ctrl,
shift=shift,
alt=alt,
meta=meta,
)
)
tree.ui_input(
mouse_pos=inp._mouse_pos, button=button, pressed=pressed, ctrl=ctrl, shift=shift, alt=alt, meta=meta
)
[docs]
def mouse_motion(self, x: float, y: float) -> None:
"""Route absolute pointer motion, in logical (screen) pixels.
HiDPI needs no scaling here: cursor coordinates and ``tree.screen_size``
are both logical units even when the framebuffer is larger.
"""
inp = self._input
old = inp._mouse_pos
inp._mouse_pos = (x, y)
if inp._discard_next_mouse_delta:
# Discontinuity (capture toggle / warp): re-baseline, no delta.
inp._discard_next_mouse_delta = False
delta = (0.0, 0.0)
else:
# Accumulate within the frame: several motion events can arrive per
# frame (especially under pointer lock, where the virtual cursor
# moves in large steps). Input._end_frame() clears the accumulator,
# so summing the segments yields the frame's true total motion
# instead of keeping only the last one.
delta = (x - old[0], y - old[1])
inp._mouse_delta = (inp._mouse_delta[0] + delta[0], inp._mouse_delta[1] + delta[1])
tree = self.tree
if tree is None:
return
# Straight to the UI, before propagation: drag responsiveness depends on
# controls seeing motion the moment it arrives.
if (x, y) != self._last_motion_pos:
self._last_motion_pos = (x, y)
tree.ui_input(mouse_pos=(x, y), button=None, pressed=False)
tree.propagate_input(TreeInputEvent("mouse_motion", position=(x, y), delta=delta))
[docs]
def mouse_motion_relative(self, dx: float, dy: float) -> None:
"""Route relative pointer motion under pointer lock.
A locked pointer has no meaningful screen position, so this contributes
delta only: the UI is not notified and the stored position stands.
"""
inp = self._input
if inp._discard_next_mouse_delta:
inp._discard_next_mouse_delta = False
return
inp._mouse_delta = (inp._mouse_delta[0] + dx, inp._mouse_delta[1] + dy)
if self.tree is not None:
self.tree.propagate_input(TreeInputEvent("mouse_motion", position=inp._mouse_pos, delta=(dx, dy)))
[docs]
def scroll(self, dx: float, dy: float) -> None:
"""Route a scroll event. Positive ``dy`` scrolls up."""
inp = self._input
inp._scroll_delta = (inp._scroll_delta[0] + dx, inp._scroll_delta[1] + dy)
tree = self.tree
if tree is None:
return
# The UI consumes scroll as key events, which is what its focus and
# hover machinery already understands.
if dy > 0:
tree.ui_input(key="scroll_up", pressed=True)
elif dy < 0:
tree.ui_input(key="scroll_down", pressed=True)
tree.propagate_input(TreeInputEvent("scroll", position=inp._mouse_pos, delta=(dx, dy)))
# -- Touch ------------------------------------------------------------
[docs]
def touch(self, finger_id: int, action: int, x: float, y: float, pressure: float = 1.0) -> None:
"""Route a touch event.
Raw multi-touch always reaches ``Input._touches`` for game code and
gesture recognition. The primary finger (the first one down) is also
emulated as mouse input so every UI widget works under touch untouched;
when ``Input.set_mouse_from_touch_emulation`` is on, it additionally fires
:attr:`MouseButton.LEFT`, so actions bound to the left button work on
touch devices without the game doing anything.
Args:
finger_id: Unique finger identifier.
action: :data:`TOUCH_DOWN`, :data:`TOUCH_UP`, or :data:`TOUCH_MOVE`.
x, y: Position in logical (screen) pixels.
pressure: Touch pressure, 0.0-1.0.
"""
inp = self._input
inp._update_touch(finger_id, action, x, y, pressure)
tree = self.tree
if tree is not None:
# Controls with touch_mode="multi" want every finger, not just the
# emulated pointer.
tree.touch_input(finger_id, action, x, y)
if action == TOUCH_DOWN:
if self._primary_finger is not None:
return
self._primary_finger = finger_id
inp._mouse_pos = (x, y)
inp._mouse_delta = (0.0, 0.0)
self._notify_touch_motion(x, y)
if inp._emulate_mouse_from_touch:
self.mouse_button(MouseButton.LEFT, True)
elif tree is not None:
tree.ui_input(mouse_pos=(x, y), button=MouseButton.LEFT, pressed=True)
elif action == TOUCH_UP:
if finger_id != self._primary_finger:
return
self._primary_finger = None
inp._mouse_pos = (x, y)
self._notify_touch_motion(x, y)
if inp._emulate_mouse_from_touch:
self.mouse_button(MouseButton.LEFT, False)
elif tree is not None:
tree.ui_input(mouse_pos=(x, y), button=MouseButton.LEFT, pressed=False)
elif action == TOUCH_MOVE:
if finger_id != self._primary_finger:
return
old = inp._mouse_pos
inp._mouse_pos = (x, y)
inp._mouse_delta = (inp._mouse_delta[0] + (x - old[0]), inp._mouse_delta[1] + (y - old[1]))
self._notify_touch_motion(x, y)
def _notify_touch_motion(self, x: float, y: float) -> None:
"""Move the emulated pointer without re-running absolute-motion bookkeeping."""
if self.tree is None:
return
if (x, y) != self._last_motion_pos:
self._last_motion_pos = (x, y)
self.tree.ui_input(mouse_pos=(x, y), button=None, pressed=False)
__all__ = ["InputRouter", "TOUCH_DOWN", "TOUCH_UP", "TOUCH_MOVE"]