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"]