simvx.core.input.router¶
Platform-neutral input routing.
Every backend – desktop windowing (GLFW/SDL3) and the browser runtime – feeds
its events through :class:InputRouter. :meth:InputRouter.publish is the one
door input comes through, and the five phases it runs are the whole of what an
input means to the engine:
Device state, always written, so a poll answers honestly whoever else claimed the event.
Targeted, the delivery that belongs to one node: the UI’s focus and hit-test route.
Resolve, the active bindings turned into action edges.
Broadcast, every matching
@on_inputhandler in reverse tree order, stopping at the first claim.Notify:
tree.input_delivered.emit(event, claimed_by), for everything that watches.
Backends translate their own wire format into the normalised arguments of the
entry points below – engine :class:Key codes, a device’s own button and axis
indices, pressed booleans – and do nothing else; each entry point builds
one typed event and publishes it. A button is reported by index, and the
device’s layout (:mod:~simvx.core.input.layouts) names it: a backend whose
platform numbers a device its own way declares that layout
(:meth:InputRouter.declare_layout) rather than renumbering. Keeping the phases 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.
It is also what lets a thin backend be a correct one. A backend states the
event’s identity and its payload; the router fills in what it left out, so the
event a handler receives carries a time, a key label and the modifier state
whether or not the platform reported them. What the platform did supply is
declared once, through :mod:simvx.core.input.capabilities, so a game can ask
what its input source can do rather than inspecting events.
Module Contents¶
Classes¶
Routes normalised input events into engine state, UI, and |
Data¶
API¶
- simvx.core.input.router.log¶
‘getLogger(…)’
- simvx.core.input.router.CLICK_INTERVAL¶
0.4
- simvx.core.input.router.CLICK_SLOP¶
None
- simvx.core.input.router.AXIS_METHOD_DEADZONE¶
0.5
- class simvx.core.input.router.InputRouter(tree: simvx.core.scene_tree.SceneTree | None = None, *, input_state: simvx.core.input.state._Input | None = None)[source]¶
Routes normalised input events into engine state, UI, and
@on_input.Args: tree: The :class:
SceneTreereceiving UI and@on_inputdispatch.Noneroutes 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 state write happens before the tree opens its input span, so resolving the ambient proxy here would land the delivered record in whichever pair was active for the caller and the tree the event was meant for would resolve none of its actions. The device record underneath is one for the process and takes the same write whoever makes it.Initialization
- __slots__¶
(‘tree’, ‘source_id’, ‘_input’, ‘_primary’, ‘_contacts’, ‘_claimed_contacts’, ‘_release_picked’, ‘_d…
- reset() None[source]¶
Drop the pointer state this router was tracking.
Called by :meth:
SceneTree.change_scene <simvx.core.scene_tree.SceneTree.change_scene>: the fingers and pen in flight were on the scene that just left, and a new scene that inherited them would answer a lift it never saw pressed. Their records go fromInput.pointers, and each one still touching is kept as departed: its lift reaches nobody, and lets go of the mouse’s primary it was standing in for, exactly as a mouse button held across the change is released by its own lift. A claim a press made is kept too, because its release inherits it whichever scene is running, as a mouse button’s claim does on the input state.
- flush_motion() None[source]¶
Deliver the frame’s motion as one event, and forget it.
A motion event carries the frame’s accumulated delta, which is what a camera or a drag wants: a 1000Hz mouse and a 125Hz one move the view by the same amount in the same frame, and a handler runs once rather than sixteen times. The samples are not lost, they are summed, because the delta a frame’s motion means is the total.
Called at the top of the frame’s input phase, and by anything that needs the position to be true before it acts: a button press carries where the pointer is, so the motion that got it there is spent first.
Every finger and pen is its own pointer with its own pending move, so two fingers dragging in one frame are two events. A handler that needs every sample as it arrives, a drawing or handwriting canvas under a pen, declares
@on_input(PointerEvent, samples=True): it was handed each one with its own position, pressure and time as it came in, and hears no merged move; the rest of the tree hears only this.
- is_key_down(code: int) bool[source]¶
Whether code is currently held, for backends that must infer key repeat.
The device record: a repeat is a fact about the key under the player’s finger, not about which tree the last press reached.
- publish(event: simvx.core.input.events.InputEvent) simvx.core.node.Node | None[source]¶
Deliver one input event. The only way input enters the engine.
Backends, the simulator, AI agents, on-screen controls and replays all arrive here, and the entry points below are translation layers that build an event and call this. Input is pumped once per frame, before the physics tick, and each event dispatches synchronously inside that pump.
A publish made during dispatch is queued and drained in order after the current event completes. An on-screen controller publishing a button from inside a pointer’s targeted phase, and a recogniser publishing a gesture from motion, are the cases; queueing is what keeps the frame’s events in arrival order rather than nesting one inside another. The queue belongs to the tree, because several routers can feed one tree and a second queue would let a nested publish overtake.
A key repeat runs the targeted phase only. An echo reaches the focused widget, so a held key still repeats in a text field and a held direction still walks a menu, and it never resolves to an action, so a game action fires once per physical press. The broadcast offers it only to the key handlers that asked for repeats,
repeat=True.Returns the node that claimed the event, or None when nobody did or when it was queued behind the event being dispatched. Everything that watches every event, a recorder or a debugger, connects to the tree’s
input_deliveredinstead of asking the event.
- cancel_pointers() None[source]¶
Abandon every press in flight, telling whoever holds one.
A press that is interrupted rather than completed is a cancel, not a release: the button does not fire, the slider does not commit and no release edge is published, but the node holding the pointer is told that the gesture is over so it can stop dragging. Silence is what left a splitter believing it was still being dragged after the window lost focus, and the next pass of the cursor over it moved the divider.
- key(code: int | simvx.core.input.controls.DeviceButton, pressed: bool, *, echo: bool = False, ctrl: bool | None = None, shift: bool | None = None, alt: bool | None = None, meta: bool | None = None, time: float | None = None, label: str | None = None, device_id: int = 0) None[source]¶
Route a key event.
Args: code: The platform code of a physical key position, in the engine’s table (
Key), or the key itself. A backend whose native codes are layout keysyms converts first. A code the table does not name arrives asKey.button(code). pressed: True on press, False on release. echo: True for an auto-repeat press. Repeats run the targeted phase only, so a held key still repeats in a text field while a game action fires once per physical press. 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. time: The platform’s event time on the monotonic clock. Left unset, the event is stamped at receipt. label: What this key prints under the player’s layout. Left unset, it falls back to the engine’s US table, so a rebind screen shows the physical name rather than nothing. device_id: The backend’s id for the keyboard; 0 is the aggregate device.
- char(text: str, *, time: float | None = None) None[source]¶
Route a typed character (text entry only; never a game action).
The modifier flags every event carries are filled from the held-key state, as they are on a pointer or a wheel notch that reports none of its own. No backend reports them on a character, and a handler that reads
event.ctrloff one is owed the truth rather than a standing False.Args: text: The characters produced. time: The platform’s event time on the monotonic clock. Left unset, the event is timed at receipt.
- mouse_button(button: int | simvx.core.input.controls.DeviceButton, pressed: bool, *, ctrl: bool | None = None, shift: bool | None = None, alt: bool | None = None, meta: bool | None = None, time: float | None = None, device_id: int = 0) None[source]¶
Route a mouse button event.
A pen is not a mouse and does not come through here: it is a pointer of its own (:meth:
pen), which stands in for the mouse while it is primary.Args: button: The mouse’s own index, as the backend reports it, or a control such as
Mouse.RIGHT, which resolves through the mouse’s layout. A backend whose platform numbers the mouse its own way (the DOM puts middle before right) declares that layout rather than renumbering. 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 leaves them unset and the held-key state answers instead. time: The platform’s event time on the monotonic clock. Left unset, the event is stamped at receipt. device_id: Which physical mouse; 0 is the aggregate one.
- mouse_motion(x: float, y: float, *, time: float | None = None) None[source]¶
Route absolute mouse motion, in logical (screen) pixels.
HiDPI needs no scaling here: cursor coordinates and
tree.screen_sizeare both logical units even when the framebuffer is larger.Coalesced: the sample is remembered and the frame delivers one motion event carrying the total, which is what a camera or a drag wants and what keeps a 1000Hz mouse from running every handler sixteen times a frame. Only where the pointer got to is kept, because the delta is measured from where the last delivery left the recorded position, so the sum falls out. A handler declared
@on_input(PointerEvent, samples=True)is handed each sample as it arrives instead.Args: x, y: Position in logical (screen) pixels. time: The platform’s event time on the monotonic clock. Left unset, the event is stamped at receipt.
- mouse_motion_relative(dx: float, dy: float, *, time: float | None = None) None[source]¶
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. Coalesced like absolute motion, and here the steps really are summed, because there is no position to read the total off.
Args: dx, dy: Motion since the previous event, in logical pixels. time: The platform’s event time on the monotonic clock. Left unset, the event is stamped at receipt.
- scroll(dx: float, dy: float, *, time: float | None = None) None[source]¶
Route a scroll event. Positive
dyscrolls up.Args: dx, dy: The scroll offset. time: The platform’s event time on the monotonic clock. Left unset, the event is stamped at receipt.
- flush_joy_events() None[source]¶
Publish the pad changes recorded since the last flush.
Every other device reaches the engine from the callback that carried it. A pad has no callback: a backend polls it and publishes one snapshot per frame, so the state layer records what changed and this turns those changes into events, a :class:
ButtonEventor an- Class:
AxisEventcarrying the control the pad’s layout names. A backend that polls pads calls this once per poll, after the transaction closes.
Without it a pad drives polled state only, and neither a handler filtered on a pad control nor one on an action with a pad binding fires.
- touch(finger_id: int, phase: simvx.core.input.enums.PointerPhase | str, x: float, y: float, pressure: float | None = None, *, contact_size: tuple[float, float] | None = None, time: float | None = None, device_id: int = 0) None[source]¶
Route one finger’s press, move, lift or cancel.
Every finger is a pointer of its own, with an id the router allocates each time it touches down, published through :meth:
publishlike the mouse: a handler taking aPointerEventhears each finger once, as a finger, with the pressure the screen reported. The first finger down while no pointer holds primacy is primary until it lifts, and whileInput.emulate_mouseis on it also drivesmouse_positionand the mouse’s primary, as state, so a game written for a mouse plays under a thumb.A
CANCELis the system taking the finger away mid-gesture: a phone edge swipe, a notification, the browser claiming the gesture. The finger is gone exactly as a lift is, but the gesture did not complete: whoever held it hears a cancel rather than a release, and the left button the primary finger held goes up with no release edge, so a charge shot is dropped rather than fired.Args: finger_id: The backend’s id for the finger, unique while it is down. phase:
DOWN,MOVE,UPorCANCEL. x, y: Position in logical (screen) pixels. pressure: What the screen measured, 0.0 to 1.0, or None where it cannot measure, which reads as 1.0 in contact. contact_size: The contact’s width and height in logical pixels, where the platform reports it. time: The platform’s event time on the monotonic clock. Left unset, the event is stamped at receipt. device_id: The backend’s id for the touchscreen; 0 is the aggregate device. Two screens that both call a finger 0 are two pointers.Raises: ValueError: for a phase a finger cannot have. A finger has no hover, so
ENTERandLEAVEare refused.
- pen(pen_id: int, phase: simvx.core.input.enums.PointerPhase | str, x: float, y: float, *, button: int | simvx.core.input.controls.DeviceButton = Pointer.PRIMARY, pressure: float | None = None, tilt: tuple[float, float] | None = None, twist: float | None = None, distance: float | None = None, tangential_pressure: float | None = None, tool: simvx.core.input.enums.PointerTool | str = PointerTool.TIP, tool_id: int = 0, time: float | None = None, device_id: int = 0, ctrl: bool | None = None, shift: bool | None = None, alt: bool | None = None, meta: bool | None = None) None[source]¶
Route one pen sample: hovering, touching, a barrel button, or leaving.
A pen is a pointer of its own, with an id the router allocates when it is first seen and keeps while it is in range. It hovers: a
MOVEwhile nothing is pressed is the pen in the air, with no contact and a pressure of 0.0, andENTERandLEAVEbracket its time in the digitiser’s range.DOWNandUPwith the primary button are the tip touching and lifting; with another button they are a barrel button pressed while the pen is wherever it is. A hovering pen nobody holds primacy against is primary, and a pen that touches down while nobody holds it takes primacy, so underInput.emulate_mousea pen drives the cursor as it always has.A value left as None is one the platform did not measure: it reads as the neutral value on the event, and the one that arrives declares the axis in
Input.capabilities.pen_axes. Turning the pen over ends the stroke: a change oftoolwhile touching lifts the old tool and presses the new one, so a handler never sees the tool change inside a contact.Args: pen_id: The backend’s id for the stylus, unique while it is in range. phase:
ENTER,MOVE,DOWN,UP,CANCELorLEAVE. x, y: Position in logical (screen) pixels, sub-pixel. button: Which button aDOWNorUPis about: the pen’s own index as the backend reports it, or a control resolved through the pen’s layout. The default is the tip. pressure: 0.0 to 1.0, or None where the pen cannot measure it. tilt: The barrel’s tilt from upright as(x, y)in radians. twist: The barrel’s rotation about its axis in radians,[0, 2pi). distance: Height above the surface while hovering, 0 to 1. tangential_pressure: The airbrush wheel or barrel squeeze, -1 to 1. tool: Which end is working:TIP,ERASER,AIRBRUSHorPUCK. tool_id: The physical tool’s serial, where the platform reports one. time: The platform’s event time on the monotonic clock. device_id: The backend’s id for the tablet; 0 is the aggregate device. ctrl, shift, alt, meta: Modifier state at event time, where the platform reports it.
- pointer_id(kind: simvx.core.input.controls.DeviceType | str, backend_id: int = 0, *, device_id: int = 0) int | None[source]¶
The pointer id this router gave the backend’s finger or pen backend_id, while it is live.
None for one it is not tracking: a finger that has lifted, a pen out of range. The mouse is always 0 and is not asked for here.
- declare_layout(layout: simvx.core.input.layouts.DeviceLayout) None[source]¶
Name every device of layout’s type this backend reports, by this layout.
For a backend whose platform numbers a kind of device its own way: the browser reports the mouse in DOM order, middle before right, and a pen in the W3C numbering, where the barrel is 2. The backend passes its own indices and declares what they are called; the engine’s own table for the type answers otherwise.
- connect_device(device_type: simvx.core.input.controls.DeviceType | str, *, device_id: int = 0, name: str = '', guid: str = '', layout: simvx.core.input.layouts.DeviceLayout | None = None, buttons: int | None = None, axes: collections.abc.Sequence[simvx.core.input.devices.AxisReport | str] = (), wraps: collections.abc.Collection[int] = ()) simvx.core.input.devices.InputDevice[source]¶
Announce a device the backend can enumerate: a pen, a tablet pad, a second touchscreen.
A pointer that arrives unannounced is registered under its kind’s layout, so announcing is for what an event cannot say: a name and a guid for a rebind screen and a saved layout, how many buttons there are, and what each axis reports.
Args: device_type: What type of device. device_id: The backend’s id for it. name: What the driver calls it. guid: Its model’s stable id, or
""where the backend has none; a pinned rebind persists by it. layout: The names the backend knows for its indices, or None for the type’s. buttons: How many buttons it has, or None to count what the layout names. axes: What each axis reports, in index order. wraps: The indices of the axes whose position wraps at 1.0, as a ring’s does.Returns: The device, as
Input.devicesholds it.
- disconnect_device(device_type: simvx.core.input.controls.DeviceType | str, *, device_id: int = 0) None[source]¶
The device went: every button it held goes up with no release edge.
Its record answers as a device with nothing held until the frame boundary, and then leaves
Input.devices.
- device_button(device_type: simvx.core.input.controls.DeviceType | str, button: int | simvx.core.input.controls.DeviceButton, pressed: bool, *, device_id: int = 0, time: float | None = None) None[source]¶
Route a button with no position: a tablet’s express key.
Args: device_type: What type of device. button: The device’s own index, or a :class:
~.buttons.Buttonresolved through its layout. pressed: True on press, False on release. device_id: The backend’s id for the device. time: The platform’s event time on the monotonic clock.
- device_axis(device_type: simvx.core.input.controls.DeviceType | str, axis: int, *, value: float | None = None, delta: float | None = None, device_id: int = 0, time: float | None = None) None[source]¶
Route an axis with no position: a tablet’s ring or strip.
Pass what the hardware reported and nothing else. An axis announced as
- Attr:
~.buttons.AxisReport.ABSOLUTEtakes value and the delta is derived, the short way round one that wraps; a- Attr:
~.buttons.AxisReport.RELATIVEone takes delta and has no position, which reads None rather than a number nobody measured; one reporting both takes both. An axis the backend never announced is taken as absolute.
Raises: ValueError: when the report does not match what the axis was announced to report, which is a backend defect worth hearing about rather than a value to guess at.
- focus_lost() None[source]¶
Release everything the window was holding when it lost focus.
The release of a key or button held at the moment focus moves away is delivered to whatever took the focus, so without this the engine goes on believing it is held: the player alt-tabs mid-sprint and comes back to a character running into a wall.
The two device kinds part company here, for the same reason a cancelled touch does. A key goes back up through the ordinary key path, so its release edge, its tree event and its UI handling all fire exactly as a real release would: the key genuinely is not held any more, and a text field repeating a character has to stop. A pointer does not: its press was interrupted rather than completed, so the held state simply goes away with no release edge, and whoever holds the pointer is sent a cancel instead of a release: the click does not happen, and the drag stops.
Idempotent, which matters because a browser fires
blurandvisibilitychangefor the same switch away.
- simvx.core.input.router.__all__¶
[‘InputRouter’]