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:

  1. Device state, always written, so a poll answers honestly whoever else claimed the event.

  2. Targeted, the delivery that belongs to one node: the UI’s focus and hit-test route.

  3. Resolve, the active bindings turned into action edges.

  4. Broadcast, every matching @on_input handler in reverse tree order, stopping at the first claim.

  5. 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

InputRouter

Routes normalised input events into engine state, UI, and @on_input.

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: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 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 from Input.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_delivered instead 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 as Key.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.ctrl off 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_size are 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 dy scrolls 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:ButtonEvent or an

Class:

AxisEvent carrying 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:publish like the mouse: a handler taking a PointerEvent hears 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 while Input.emulate_mouse is on it also drives mouse_position and the mouse’s primary, as state, so a game written for a mouse plays under a thumb.

A CANCEL is 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, UP or CANCEL. 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 ENTER and LEAVE are 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 MOVE while nothing is pressed is the pen in the air, with no contact and a pressure of 0.0, and ENTER and LEAVE bracket its time in the digitiser’s range. DOWN and UP with 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 under Input.emulate_mouse a 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 of tool while 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, CANCEL or LEAVE. x, y: Position in logical (screen) pixels, sub-pixel. button: Which button a DOWN or UP is 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, AIRBRUSH or PUCK. 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.devices holds 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.Button resolved 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.ABSOLUTE takes value and the delta is derived, the short way round one that wraps; a

Attr:

~.buttons.AxisReport.RELATIVE one 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 blur and visibilitychange for the same switch away.

focus_gained() → None[source]

The window has the operating system’s keyboard focus again.

Nothing is held on the way back in, so this only records it: a window without keyboard focus moves no focus by hover, and this one may again.

property window_focused: bool[source]

Whether this window has the operating system’s keyboard focus, as the platform last said.

simvx.core.input.router.__all__

[‘InputRouter’]