Input

One input system covers keyboard, mouse, pen, tablet, controller and touch. Every input is a control of a device type (Key.SPACE, Mouse.LEFT, Controller.A, Tablet.RING), so a binding is a typed value rather than a raw string.

Input actions

An action is a named binding that decouples game logic from specific keys. It is typed, and its bindings compose into that type:

from simvx.core import Axis1D, Axis2D, Controller, Input, Key, Node

class Player(Node):
    input_context = {
        "jump": [Key.SPACE, Controller.A],                            # bool
        "throttle": [Axis1D(low=Key.LEFT_CONTROL, high=Key.SPACE),
                     Controller.RIGHT_TRIGGER],                         # float
        "move": [Axis2D.WASD, Controller.LEFT_STICK],                  # Vec2
    }

    def on_update(self, dt):
        if Input.just_pressed("jump"):
            self.jump()
        self.position += Input.value("move") * self.speed * dt

Bindings

Input.value() returns

ActionKind

Buttons only

bool

BUTTON

An Axis1D or a single controller axis

float

AXIS_1D

An Axis2D or a stick

Vec2

AXIS_2D

Axis2D.WASD and Axis2D.ARROWS are ready-made. When several devices drive one action, the largest magnitude wins, and a key, being full deflection, takes a tie. A plain dict on input_context is shorthand for an InputContext named after the node’s class; InputMap.add_action("shoot", [Mouse.LEFT]) is the imperative spelling, into the tree’s own context.

Action queries

Method

Returns

Description

Input.value(name)

bool, float or Vec2

The action’s typed value

Input.pressed(name)

bool

True while held; for a continuous action, past its deadzone

Input.just_pressed(name)

bool

True on the frame the action activates

Input.just_released(name)

bool

True on the frame the action deactivates

Input.get_action_strength(name)

float

The magnitude of value, 0.0 to 1.0

A name no context declares raises a KeyError naming the nearest action. An action that exists but is hidden or shadowed reads as at rest.

Inside a physics tick

A frame runs zero or more physics ticks. Each tick reads held state as it was when the tick started, and the frame’s edges arrive on its first tick only (or the next tick, if the frame ran none), so a press fires exactly once whatever the display and tick rates are.

def on_fixed_update(self, dt):
    if Input.just_pressed("jump") and self.is_on_floor():
        self.velocity.y = -450.0
    self.velocity.x = Input.value("move").x * 250.0

Input.mouse_delta and Input.scroll_delta follow the same rule. Input.tick is the snapshot (a TickInput) while a tick runs and None outside one; it can be recorded and replayed with tree.physics_tick(dt, tick_input=recorded).

Combining separate actions

Input.get_vector(neg_x, pos_x, neg_y, pos_y) and Input.get_axis(neg, pos) combine separate actions. For movement, declare one Axis2D or Axis1D action instead.

InputMap

InputMap is the flattened result of the active contexts, plus an override layer for the player’s rebinds. Every tree has its own. Its layers, bottom to top:

  1. the engine’s entries (named controls and the ui_* actions);

  2. the project’s, from [input] in simvx.toml (applied by ProjectSettings.apply_input(), shared by every tree and read live);

  3. the tree’s own context, "tree", which add_action writes and which lasts as long as the tree;

  4. node contexts, in activation order;

  5. the player’s rebinds.

The bare InputMap is the tree whose code is running, else SceneTree.current(); with no tree it raises RuntimeError, so an action cannot be registered from main() before run(). Declare it on a node’s input_context or in the project settings. tree.input_map names one tree’s map.

Input contexts

A game’s controls change with what it is doing: in a car move steers, and a pause menu stops the player’s keys reaching the player. An InputContext expresses that as something the tree owns:

from simvx.core import Axis2D, InputContext, Key, Node2D

class Vehicle(Node2D):
    input_context = InputContext(
        {"move": [Axis2D.WASD], "honk": [Key.H]},
        name="vehicle",
    )
  • A context is active exactly while its node is in the tree. Assign node.input_context at runtime, or None to remove it.

  • Contexts stack in activation order and shadow by action name: the vehicle’s move steers while the walking controller’s jump still jumps.

  • hides=InputContext.ALL takes every name and handler below it, which a pause menu wants. The engine’s entries, ui_accept among them, come back as the engine declared them. A tuple of names hides exactly those.

  • The root’s context covers the tree while that scene is mounted; change_scene swaps it.

  • A change releases and never presses: a key held across a pause reads unpressed on resume.

class PauseMenu(Control):
    input_context = InputContext({"resume": [Key.ESCAPE]}, hides=InputContext.ALL)

One map: controls, device aliases and actions

Every name is an entry of one map, resolving to raw controls or to other entries’ names. The engine’s entries (Controller.A, Pointer.PRIMARY) are in every tree; a game adds device aliases and actions that name them:

input_context = {
    "throttle": [Controller.axis(2)],         # a flight stick's third axis
    "start": [Controller.button(5)],
    "engine_power": ["throttle"],             # a string is another entry's name
    "pause": ["start", Key.ESCAPE],
    "move": [Axis2D(up="forward", down=Key.S, left=Key.A, right=Key.D)],
    "forward": [Key.W],                       # names may be used before they are declared
}
  • A loop, an unknown name (with the near miss) or a type that does not fit is refused when the entry is made. A string is always a name, so a key combo is Key.SHIFT + Key.TAB.

  • Every name on a raw control fires: if Controller.A and Controller.B both resolve to pad button 1, pressing it fires both.

  • An entry names only entries in its own context or one beneath it.

  • InputMap.resolve(Controller.A) is the raw control an engine entry stands for now; InputMap.aliases(raw) lists every entry naming a raw control.

Rebinding

A player’s rebind writes the override layer, never an InputContext (which is frozen), so it survives a scene change, a pause or a vehicle being entered.

from simvx.core import BindingRole

InputMap.rebind("jump", Key.J)                                # a button action
InputMap.rebind("move", Key.T, slot=0, role=BindingRole.UP)   # one direction
InputMap.rebind("pause", "start")                             # an action onto a device alias
InputMap.rebind(Controller.A, Controller.button(1))           # an engine entry: everything using A follows
InputMap.swap(Controller.A, Controller.B)                     # a pad with the two the wrong way round
  • slot picks the source, in declaration order (in [Axis2D.WASD, Controller.LEFT_STICK] the keys are slot 0); role picks its part: WHOLE, LOW/HIGH, or UP/DOWN/LEFT/RIGHT. roles_of(source) lists the parts a rebind screen draws a row for.

  • binding=None leaves a part unbound, player= makes the rebind one player’s, and clear_rebind() / clear_rebinds() reset.

InputMap.export() returns JSON-able data and load() puts it back. A binding pinned to one device is filed under that device’s guid, so it follows the controller after a USB reorder; a device with no guid keeps its rebinds for the session only.

Listening for the next input

An InputListener is a node that hears the player’s next press before the UI and every handler, and claims it, so the press that picks a new jump key does not also jump. Its context hides every action while it listens.

from simvx.core import DeviceType, InputListener, InputMap, Key

def listen(self):
    listener = self.add_child(InputListener(devices=[DeviceType.KEYBOARD, DeviceType.MOUSE]))
    listener.heard.connect(self.captured)

def captured(self, heard):                      # a ControlInput
    if heard.control is not Key.ESCAPE:         # cancelling is the game's choice
        InputMap.rebind("jump", heard)

heard carries control, device_id, pressed, value, held and the event; heard.binding is the binding that hears it again. A modifier is heard alone only when released with nothing pressed under it, so Ctrl+S is heard as the chord. The knobs are devices, threshold (axis travel, 0.5), releases and once (True).

get_sources() and get_bindings() answer a hidden action too. examples/features/input/standard_controls.py is a complete rebind screen.

Bindings, modifiers and chords

InputBinding takes one control with options: a deadzone, one half of an axis, one pad.

from simvx.core import Controller, InputBinding, InputMap, Key

InputMap.add_action("move_left", [
    Key.A,
    Key.LEFT,
    InputBinding(Controller.LEFT_X.negative, deadzone=0.2),   # the stick pushed left
])

An axis’s halves are .positive and .negative (a stick’s are .up, .right and so on), and Controller.A.on(pad) hears one pad only, for local co-op.

A modifier is a held control, written with +, the last control being the one pressed:

InputMap.add_action("save", [Key.CTRL + Key.S, Key.META + Key.S])   # Ctrl+S, and Cmd+S on a Mac
InputMap.add_action("quick_save", [Key.RIGHT_CONTROL + Key.Q])       # the right Control key only
InputMap.add_action("extrude_x", [Key.G + Key.X])                    # hold G, press X
InputMap.add_action("super_jump", [Controller.LEFT_BUMPER + Controller.A])

Key.CTRL, Key.SHIFT, Key.ALT and Key.META are either side. A binding holding a modifier is exact (Ctrl+S does not fire on Ctrl+Shift+S); one holding none ignores modifiers (Space still jumps while Shift runs). exact= overrides either.

InputBinding(Key.F, held=(Key.CTRL,), exact=False)   # Ctrl+F, and Ctrl+Alt+F too
InputBinding(Key.D, exact=True)                      # D with no modifier down

An @on_input target is read by the same rule: @on_input(Key.CTRL + Key.Z) does not fire on Ctrl+Shift+Z, and @on_input(Key.S, exact=True) is S alone.

Bindings in simvx.toml

simvx.toml and the rebind file spell sources the same way. In a file a string is always a control (a key name, a combo, or "type:control"); another entry is {name = "..."}:

[input]
jump = ["space", "controller:a"]
dedent = ["shift+tab"]
redo = [{key = "y", held = ["ctrl"]}]
super_jump = [{control = "controller:a", held = ["controller:left_bumper"]}]
move = ["controller:left_stick", {up = "w", down = "s", left = "a", right = "d"}]
aim = [{control = "controller:right_stick", deadzone = 0.3}]
pause = [{name = "start"}, "escape"]

Units: scale and per-second rate

Sources in different units share one action when each converts into the action’s unit: * scales, and .per_second(rate) turns a held value into motion over the frame or tick:

input_context = {
    "zoom": [Touch.PINCH,                                   # the unit: the frame's change of scale
             Mouse.WHEEL * 0.1,                             # a notch is a tenth
             Axis1D(low=Controller.LEFT_TRIGGER,
                    high=Controller.RIGHT_TRIGGER).per_second(1.0)],  # held fully, 1.0 a second
}

The deadzone is measured before scaling. In a file: {control = "mouse:wheel", scale = 0.1}.

Actions belong to a player

Routing decides which tree an input reaches; ownership decides which player it belongs to.

two = Input.add_player()
two.pair(pad)                   # a ControllerDevice from Input.controllers leaves the unpaired pool
node.input_player = two         # Input queries in this subtree answer for player two

ActionEvent.player carries the player. Player 0 owns every unpaired device, so a single-player game never sees any of this. Device state is never per player: Input.is_key_pressed is a fact about the keyboard.

UI navigation actions

What moves UI focus is an ordinary engine action, so it rebinds like any other and a controller drives all of it:

Action

Keyboard

Controller

What the UI does

ui_focus_next

tab, exact

Right bumper

Focus the next control in tab order

ui_focus_prev

shift+tab

Left bumper

Focus the previous one

ui_accept

enter

Bottom face button

Activate the focus owner

ui_cancel

escape

Right face button

Dismiss the overlay that holds the keyboard

ui_up / ui_down / ui_left / ui_right

none

D-pad and left stick

Move focus in that direction

A context declaring one of these names redefines it, as in {"ui_up": [Key.UP, Controller.DPAD_UP]}, and a player moves one with InputMap.rebind. The four directions have no keyboard default, so they never take the arrow keys from a game that steers with them. Directional focus moves by geometry; focus_up, focus_down, focus_left and focus_right on a control override it. See UI system for the focus model.

Direct key and mouse queries

For input that is not an action (debug tools, editor code):

Input.is_key_pressed(Key.ESCAPE)
Input.is_key_just_pressed(Key.F3)
Input.is_mouse_button_just_pressed(Mouse.LEFT)
pos = Input.mouse_position        # Vec2
delta = Input.mouse_delta         # Vec2, this frame's movement
scroll_x, scroll_y = Input.scroll_delta
Input.set_mouse_capture_mode(MouseCaptureMode.CAPTURED)  # hidden and locked: a first-person camera

MouseCaptureMode is VISIBLE, HIDDEN (moves freely), CAPTURED or CONFINED (visible, kept inside the window).

Controllers

Most games never touch a device: bind an action to Controller.A and it answers for whichever pad the player picked up. Input.controllers is for which pad; it maps a device id to a ControllerDevice:

from simvx.core import Controller, Input

for device_id, pad in Input.controllers.items():
    print(device_id, pad.name, pad.family, pad.source)

pad = next(iter(Input.controllers.values()))
if pad.just_pressed(Controller.A):
    self.jump()
stick = pad.stick(Controller.LEFT_STICK)          # deadzoned Vec2
trigger = pad.axis(Controller.RIGHT_TRIGGER)      # 0.0 released, 1.0 fully pulled
pad.rumble(strong=0.8, duration=0.3)              # False if this pad cannot

Stick axes run from -1.0 to 1.0, +y down the screen; triggers from 0.0 to 1.0, bound by their positive half (Controller.RIGHT_TRIGGER.positive).

Unrecognised controllers

A controller the database does not know (pad.recognised False, such as a flight stick) has only Controller.button(i) and Controller.axis(i) until the player maps names onto it:

InputMap.rebind(Controller.A, Controller.button(3).on(stick))

The mapping is saved with the other rebinds. Until then, asking that device for Controller.A raises, and an action bound to the name reads at rest on it.

Connection

Input.controller_connected and Input.controller_disconnected fire with the ControllerDevice; an unplugged pad’s held buttons release with a real edge and its connected is False. The Qt window backend reports no controllers.

The on-screen controller

TouchController draws a stick and buttons and publishes them as a pad, so a game that binds a controller is playable with two thumbs:

from simvx.core.ui import TouchController

def on_ready(self):
    self.add_child(TouchController())

While on screen it is in Input.controllers with source ControllerSource.VIRTUAL, which a lobby can filter out. It is a CanvasLayer, so it is pinned to the screen.

Knob

Values

layout

TouchControllerLayout.STICK_AND_BUTTONS (default), TWIN_STICK, DPAD_AND_BUTTONS

buttons

any of A, B, X, Y, the shoulders and Back; (A, B) by default

auto_hide

leave the screen while the player uses a real controller

pointer_types

the pointers it answers, {PointerType.TOUCH} by default; None for every pointer

None takes a coordinate: the geometry follows the window. It answers fingers only and never takes focus; add PointerType.MOUSE to pointer_types to test with a mouse. VirtualJoystick and VirtualButton are its pieces.

What the player is using

Input.input_method is KEYBOARD_MOUSE, CONTROLLER or TOUCH: the kind of device the player last acted with, which is what a prompt should be drawn for.

if Input.input_method is InputMethod.CONTROLLER:
    self.hint.text = "Press A to start"

Mouse motion switches it only past Input.mouse_method_threshold, so a nudged desk does not count. Input.input_method_changed fires with the new method. Input.capabilities is what the machine can do (touch, pen, coarse_pointer, rumble, soft_keyboard and more).

Prompts

Input.prompt(action) is the label for an action on whatever the player holds:

self.hint.text = f"Press {Input.prompt('jump')} to jump"

That reads “Press Space” at a keyboard, “Press A” on an Xbox pad and “Press Cross” on a PlayStation one: Controller.A is the bottom face button everywhere and only the label changes. device_id= prompts for one pad, and Input.label(Key.CTRL + Key.S) is “Ctrl+S”.

Typing on a phone

Focusing a control whose wants_text_input is True, such as TextEdit, opens the soft keyboard; Input.begin_text_input(rect) opens it for what focus cannot know about. node.viewport_insets is how much of each edge the keyboard, a notch or a home indicator covers: keep anything the player must touch out of it, and read it every frame.

Controls and roles

Key, Mouse, Pen, Touch, Tablet and Controller hold each device type’s named controls. A control is interned, so event.button == Mouse.LEFT works and a control is a dict key; a control nothing names arrives in its raw form, such as Tablet.button(11).

A role groups controls across device types and is asked with in, since a group is not one of its members (Mouse.LEFT != Pointer.PRIMARY):

Role

Members

Pointer.PRIMARY

Mouse.LEFT, Pen.TIP, Touch.CONTACT

Pointer.SECONDARY

Mouse.RIGHT, Pen.BARREL_1

if event.phase is PointerPhase.DOWN and event.button in Pointer.PRIMARY:
    self.press()

The full list of controls is in simvx.core.input.

Touch gestures

Gestures are controls. They bind, rebind and are heard like a key, and each is recognised only while something names it, so a game that names none pays nothing:

Control

Kind

Value

Touch.LONG_PRESS

button

a finger resting unmoved for tree.long_press_time (0.5 s), held until it lifts

Touch.TWO_FINGER_TAP

button

two fingers touching and lifting within tree.tap_time (0.3 s)

Touch.PINCH

axis

the frame’s change of scale, 0.05 for five percent wider

Touch.ROTATE

axis

radians, clockwise

Touch.PAN

stick

the two fingers’ shared translation, over PAN_X and PAN_Y

input_context = {
    "zoom": [Touch.PINCH, Mouse.WHEEL],
    "pan": [Touch.PAN],
    "inspect": [Touch.LONG_PRESS],
}

@on_input(Touch.ROTATE)
def _twist(self, event):             # an AxisEvent; event.delta is this frame's turn
    self.rotation += event.delta

A long press never fires on a finger a control captured (a joystick), and a scroll a control took moves no binding on Mouse.WHEEL.

A finger never right-clicks unless the game asks. Input.touch_secondary = Touch.LONG_PRESS (or Touch.TWO_FINGER_TAP) puts that gesture in Pointer.SECONDARY, and recognising it cancels the press that began it.

GestureRecognizer is a node with one signal per gesture (tap, long_press, swipe, pinch, rotate, pan). Its timings follow the scene clock, so a paused scene never completes a long press; a recogniser in a pause menu uses GestureRecognizer(clock="wall"). See examples/features/input/touch_gestures.py.

Input events

One event class per device family; a widget receives the same object a handler does:

Event

From

KeyEvent, TextEvent

the keyboard

PointerEvent, ScrollEvent

the mouse, pens and fingers

ButtonEvent, AxisEvent

a pad’s or a tablet’s buttons and axes

ActionEvent

an action

PickEvent

a pickable 3D shape under a press

Every event carries time, source_id and the modifier flags; a device event carries one control (event.key, event.button, event.axis) and the device_id it came from:

match event:
    case KeyEvent(key=Key.ESCAPE, pressed=True): ...
    case ButtonEvent(button=Controller.START): ...

Handlers

@on_input filters on these:

Axis

Form

event family

a positional event class, or a tuple of them: @on_input(KeyEvent), @on_input((KeyEvent, ScrollEvent)), @on_input(PointerEvent, phase=PointerPhase.MOVE). Bare @on_input is every event

target

positional, typed: an action name ("jump"), a button of any device (Key.S, Pointer.PRIMARY, Controller.A), or a whole axis or stick (Controller.LEFT_STICK, Touch.PINCH). A tuple is any-of within one kind

edge

edge= on an action or a button: "press" (the default), "release" or "both"

repeat

repeat=True on a key target also hears the OS auto-repeats of the held key, each with event.echo set; the default hears one press per physical press

held controls

a button target holds others with +: Key.CTRL + Key.S, Controller.LEFT_BUMPER + Controller.A; exact= overrides whether it is exact about modifiers

claim

consume=True claims the event whenever the handler runs

A target holding a modifier is exact, so Ctrl+S reaches the Ctrl+S handler and not the Ctrl+Shift+S one; a target holding none ignores modifiers. An unknown keyword raises naming the nearest valid one.

A held key’s auto-repeats are opt-in, and only a key target can opt in. A repeat never opens an action, so @on_input("move_right") fires once per press however long the key is held, and in the ordinary broadcast it reaches only a key handler declared with repeat=True, such as a cursor that walks a grid while an arrow is held. Anything else that asks for repeats is refused when the class is defined. In the capture phase an event family hears every repeat, as it always has.

A key’s repeats belong to the node that claimed its press, in every phase. A press claimed in the capture phase sends its repeats and its release to that node’s own capture handlers and to no other, claimed for it whether or not it asked to hear them, so a held Ctrl+Z never leaks into another captor or the focused text field. A press a control claimed sends its repeats to that control, and one it declines never falls through to focus traversal or the game. A press an ordinary handler claimed sends its repeats only to that node’s repeat=True handlers. A press nobody claimed leaves its repeats to every repeat=True handler, and each physical press starts the question afresh.

A handler returns True to claim the event, False to decline it, or None to defer to consume=; anything else raises. A claim stops every later handler and changes no device state. Bare @on_input on the root runs last, and only if nobody claimed.

The targeted phase has its own two decorators. @on_gui_input hears what Node.on_gui_input hears: keys and text at the focus owner, a pointer at its captor or the controls under it, the wheel along that chain. @on_picked hears what Node.on_picked hears: the pick a pointer press casts on a pickable 3D shape. Both take the filters above, routed the same way, less what cannot reach them:

class Map(Control):
    zoom = Property(1.0)

    @on_gui_input(ScrollEvent)                      # the wheel
    def zoomed(self, event):
        self.zoom *= 1.1 ** event.scroll[1]
        return True

    @on_gui_input(Pointer.PRIMARY, edge="release")  # any pointer's main button coming up
    def drop(self, event):
        ...

    @on_gui_input(Key.CTRL + Key.C)                 # while focused
    def copy(self, event):
        ...

class Crate(Node3D):
    @on_picked(Pointer.PRIMARY)
    def select(self, event):
        self.selected = True

    @on_picked(Mouse.RIGHT)
    def open_menu(self, event):
        ...

Bare @on_gui_input hears all four classes the phase delivers (PointerEvent, ScrollEvent, KeyEvent, TextEvent), and bare @on_picked every pick. Neither takes an action name, because actions resolve after the targeted phase from what no control claimed, nor capture= or samples=, which are the broadcast’s. @on_gui_input refuses a pad’s buttons and axes, which never reach a control; @on_picked takes a pointer button alone, and no phase=. Within one node the decorated handlers run in declaration order, base classes first, until one claims, and the on_gui_input or on_picked override runs after them only if none did. The pointer bubble (press, release, wheel) skips a node whose class declares neither; keys and text reach the focus owner, and motion and a captor’s events reach their target, whatever its class declares.

A key target on @on_gui_input hears one press per physical press, as on @on_input, and repeat=True adds the auto-repeats, which is what a key that walks a list wants. An event family, a bare @on_gui_input and the on_gui_input override hear every event the phase delivers, repeats included, which is the feed a text field reads. A key’s repeats and its release go to the control that claimed its press: a repeat that control declines is still its own and never falls through to focus traversal or the game, and an edge="release" handler hears its key only after a press handler on the same node claimed (consume=True, or edge="both" returning True on the press).

A pick can take a release. @on_picked(Pointer.PRIMARY, edge="release") hears the pick cast where the button comes up, over this node, and edge="both" hears both, told apart by event.pressed. A release casts a ray only while some node in the tree declares such a handler, so a scene that never asks pays nothing for it, and only those handlers hear it: a bare @on_picked and the on_picked override hear presses alone. A release belongs to whoever claimed its press: when a node claimed the press, a release pick’s claim stops only that node’s later handlers. When nobody claimed the press, a release pick that claims stops every later handler, as any claim does, so no broadcast or action handler hears the release. The action the press opened still closes, so Input.just_released and the physics tick see it end.

Declare decorated handlers above the override. A class body fills top to bottom, so below def on_gui_input(...) the name on_gui_input is that method, and an @on_gui_input(...) written there calls it instead of the decorator; the class fails with a missing-argument error that names neither. Below on_ready, on_enter_tree or on_exit_tree, which take no argument, nothing fails: the override runs at class definition and the decorated handler silently becomes None. Put the decorated handlers first, or qualify the decorator (@decorators.on_gui_input). tools/decorator_shadowing.py finds the mistake for every lifecycle and input decorator.

Pointers

Pointer 0 is the mouse; every other pointer has an id allocated each time it touches down, and pointer_type says what it is. While Input.emulate_mouse is on (the default) the primary finger also presses Mouse.LEFT, so a game that handles a click handles a tap. A PointerEvent carries its phase, position, delta, button, pressure (1.0 without a pen), tilt and more.

@on_input(PointerEvent)
def _draw(self, event):
    if event.phase is PointerPhase.MOVE and event.pointer_type is PointerType.PEN:
        self.stroke(event.position, width=event.pressure * 8.0)

A motion event carries the frame’s accumulated delta, one per frame. A drawing canvas that needs every sample declares @on_input(PointerEvent, samples=True) and receives each with its own position, pressure and timestamp.

Capture, picking and debugging

The capture phase runs first. capture=True puts a handler there: it hears the event root-first, ahead of the focused control, the actions and every other handler, and a claim there takes the whole event, its release included. Global shortcuts live there, so a focused text field cannot take them. ShortcutManager matches bindings by the same rule, and a chord is two of them, (Key.CTRL + Key.K, Key.CTRL + Key.C):

class EditorRoot(Node):
    def on_ready(self):
        self.shortcuts = ShortcutManager()
        self.shortcuts.register("save", Key.CTRL + Key.S, self.save)

    @on_input(KeyEvent, capture=True)
    def _shortcut(self, event):
        return self.shortcuts.handle(event) or None

Picking. A press the UI does not take casts a ray, and the nearest CollisionShape3D with pickable=True delivers a PickEvent (node, world point, normal, distance) to its owner. A release casts one too while a node in the tree declares @on_picked(..., edge="release"), and reaches only such handlers. tree.input_cast(position, button, pressed=False) casts a release pick by hand.

Debugging. A tree’s input_delivered signal fires after each delivery with (event, claimed_by). InputMap.explain("jump") says why an action reads what it reads: its bindings, the declaring context, any context hiding it and the rebinds that apply.

Hosting a tree inside another

A tree can run inside another: an arcade cabinet in a game, a game in an editor’s viewport. A SceneHost is the control in the host tree that runs it and forwards input to it: a pointer while it is over the host’s rect or held by it, and keys, text and controllers while it holds focus.

from simvx.core import Anchor, Control, FocusMode, Key, SceneHost, on_input

class Cabinet(Control):
    def on_ready(self):
        self.host = self.add_child(
            SceneHost(ArcadeGame(), focus_mode=FocusMode.HOVER, anchor=Anchor.FULL_RECT))

    @on_input(Key.ESCAPE, capture=True)        # heard before the game
    def _leave(self, event):
        self.host.forwarding = False           # hands everything over
        return True
  • A press stays with the host that forwarded it, so a drag leaving the rect still reaches the guest.

  • When input stops reaching the guest it is handed over: held actions close with a real edge, pointers cancel, hover leaves and the text session ends; nothing presses on arrival. Turning forwarding off, hiding or removing the host hands over everything; losing focus hands over keys only. tree.input_handover(pointers=..., keys=...) does it by hand.

  • host.to_guest(point) and host.to_host(point) convert between the spaces.

  • A guest starts from the engine and project entries alone; SceneTree(input_base=host_tree) builds it on the host’s entries instead.

API reference

See simvx.core.input for the complete input API.