Input Remapping

rebind actions at runtime through InputMap.

▶ Run in browser

Tags: input remapping ui actions

A settings-style panel lists three actions (jump, fire, pause) with their current keyboard bindings, enumerated from InputMap.actions and InputMap.get_bindings and displayed via InputBinding.key_combo. Each row’s Rebind button arms capture mode: the next key press arrives through on_unhandled_input and replaces the action’s bindings with a single new one (InputMap.remove_binding + add_binding), modifiers included (“shift+f”). Esc cancels an armed capture; Reset restores the declared defaults.

What it demonstrates

  • Declaring default actions on the root’s input_actions class attribute.

  • Enumerating the live map: InputMap.actions + InputMap.get_bindings(name), with InputBinding.key_combo as the display spelling.

  • Key capture via on_unhandled_input: modifiers ride along, pure modifier presses are held back, and Esc cancels.

  • Replacing bindings with InputMap.remove_binding + InputMap.add_binding.

  • A ticker polling Input.is_action_just_pressed proves rebinds take effect.

Controls: Mouse - click Rebind to arm capture for that row, Reset for defaults Any key - fires its action (see the ticker); while capturing, becomes the new binding ESC - cancel an armed capture

Run: uv run python examples/features/input/remapping.py Headless self-check: uv run python examples/features/input/remapping.py –test

Source

  1"""Input Remapping: rebind actions at runtime through InputMap.
  2
  3A settings-style panel lists three actions (jump, fire, pause) with their
  4current keyboard bindings, enumerated from InputMap.actions and
  5InputMap.get_bindings and displayed via InputBinding.key_combo. Each row's
  6Rebind button arms capture mode: the next key press arrives through
  7on_unhandled_input and replaces the action's bindings with a single new one
  8(InputMap.remove_binding + add_binding), modifiers included ("shift+f").
  9Esc cancels an armed capture; Reset restores the declared defaults.
 10
 11# /// simvx
 12# tags = ["input", "remapping", "ui", "actions"]
 13# web = { root = "RemappingDemo", width = 960, height = 540, responsive = true }
 14# ///
 15
 16## What it demonstrates
 17- Declaring default actions on the root's `input_actions` class attribute.
 18- Enumerating the live map: `InputMap.actions` + `InputMap.get_bindings(name)`,
 19  with `InputBinding.key_combo` as the display spelling.
 20- Key capture via `on_unhandled_input`: modifiers ride along, pure modifier
 21  presses are held back, and Esc cancels.
 22- Replacing bindings with `InputMap.remove_binding` + `InputMap.add_binding`.
 23- A ticker polling `Input.is_action_just_pressed` proves rebinds take effect.
 24
 25Controls:
 26  Mouse    - click Rebind to arm capture for that row, Reset for defaults
 27  Any key  - fires its action (see the ticker); while capturing, becomes the new binding
 28  ESC      - cancel an armed capture
 29
 30Run: uv run python examples/features/input/remapping.py
 31Headless self-check: uv run python examples/features/input/remapping.py --test
 32"""
 33
 34from simvx.core import AnchorPreset, Colour, Input, InputBinding, InputMap, Key, Label, Node, Panel, Vec2
 35from simvx.core.ui import Button, FormLayout, HBoxContainer, VBoxContainer
 36from simvx.graphics import App
 37
 38WIDTH, HEIGHT = 960, 540
 39
 40#: Pure modifier presses are held back during capture so a combo such as
 41#: "shift+f" can be formed: the modifier rides along on the next real key.
 42MODIFIER_KEYS = frozenset(
 43    {
 44        Key.LEFT_SHIFT,
 45        Key.RIGHT_SHIFT,
 46        Key.LEFT_CONTROL,
 47        Key.RIGHT_CONTROL,
 48        Key.LEFT_ALT,
 49        Key.RIGHT_ALT,
 50        Key.LEFT_SUPER,
 51        Key.RIGHT_SUPER,
 52    }
 53)
 54
 55
 56class RemappingDemo(Node):
 57    """A rebinding panel over three actions, driven entirely by InputMap."""
 58
 59    # Canonical registration: the scene tree consumes this dict at mount (and
 60    # re-applies it on change_scene), so these are also what Reset restores.
 61    input_actions = {
 62        "jump": [Key.SPACE, Key.UP],
 63        "fire": [Key.F],
 64        "pause": [Key.P],
 65    }
 66
 67    def on_ready(self):
 68        self._capture: str | None = None  # action name while capture is armed
 69        self._fired: tuple[str, int] | None = None  # (last action, count)
 70        # Set for exactly one poll when a capture lands: the captured keystroke
 71        # is already a just-pressed of its new action by the time on_update
 72        # polls, and the ticker must not report the binding stroke as a play
 73        # press. Only that action is skipped, and only once.
 74        self._suppress_action: str | None = None
 75        self._rows: dict[str, tuple[Label, Button]] = {}
 76
 77        panel = Panel(name="RemapPanel")
 78        panel.set_anchor_preset(AnchorPreset.CENTER)
 79        panel.margin_left = -280
 80        panel.margin_right = 280
 81        panel.margin_top = -180
 82        panel.margin_bottom = 180
 83        panel.bg_colour = Colour.hex("#1A1A2E")
 84        self.add_child(panel)
 85
 86        column = VBoxContainer(name="Column")
 87        column.separation = 12.0
 88        column.set_anchor_preset(AnchorPreset.FULL_RECT)
 89        column.margin_left = 24
 90        column.margin_right = 24
 91        column.margin_top = 18
 92        column.margin_bottom = 18
 93        panel.add_child(column)
 94
 95        title = Label("Input Remapping")
 96        title.font_size = 22.0
 97        title.alignment = "center"
 98        column.add_child(title)
 99
100        # One form row per registered action. InputMap.actions is the
101        # enumeration API: the rows are built from the live map, not from a
102        # parallel list the UI would have to keep in sync.
103        form = FormLayout(name="Bindings")
104        form.separation = 10.0
105        for action in InputMap.actions:
106            row = HBoxContainer(name=f"Row_{action}")
107            row.separation = 12.0
108            bindings = Label(self._binding_text(action))
109            bindings.size = Vec2(220, 28)
110            bindings.text_colour = Colour.hex("#9FE2BF")
111            row.add_child(bindings)
112            button = Button("Rebind", on_press=lambda a=action: self._arm_capture(a))
113            button.size = Vec2(150, 28)
114            row.add_child(button)
115            form.add_field(f"{action}:", row)
116            self._rows[action] = (bindings, button)
117        column.add_child(form)
118
119        self._status = Label("Click Rebind, then press the new key.")
120        self._status.font_size = 13.0
121        self._status.text_colour = Colour.LIGHT_GRAY
122        column.add_child(self._status)
123
124        self._ticker = Label("No action fired yet. Try Space, Up, F or P.")
125        self._ticker.font_size = 13.0
126        self._ticker.text_colour = Colour.hex("#F5C97B")
127        column.add_child(self._ticker)
128
129        # In its own row: a direct VBoxContainer child is stretched to the
130        # column width, and the button should stay its declared size.
131        reset_row = HBoxContainer(name="ResetRow")
132        reset = Button("Reset defaults", on_press=self._reset_defaults)
133        reset.size = Vec2(150, 30)
134        reset_row.add_child(reset)
135        column.add_child(reset_row)
136
137    # -- Display ----------------------------------------------------------
138
139    def _binding_text(self, action: str) -> str:
140        """The combo spelling of every binding on *action* ("space, up")."""
141        combos = [b.key_combo or "(non-key)" for b in InputMap.get_bindings(action)]
142        return ", ".join(combos) if combos else "unbound"
143
144    def _refresh_row(self, action: str):
145        label, button = self._rows[action]
146        label.text = self._binding_text(action)
147        button.text = "press a key..." if self._capture == action else "Rebind"
148
149    # -- Capture flow ------------------------------------------------------
150
151    def _arm_capture(self, action: str):
152        self._capture = action
153        # Drop keyboard focus from the clicked button so Space or Enter can be
154        # captured instead of re-activating it.
155        self._rows[action][1].release_focus()
156        for name in self._rows:
157            self._refresh_row(name)
158        self._status.text = f"Press the new key for '{action}' (Esc cancels; modifiers combine)."
159
160    def _finish_capture(self, message: str):
161        action, self._capture = self._capture, None
162        self._suppress_action = action
163        self._refresh_row(action)
164        self._status.text = message
165
166    def on_unhandled_input(self, event):
167        if self._capture is None or event.type != "key" or not event.pressed:
168            return
169        if event.key == Key.ESCAPE:
170            self._finish_capture(f"Capture cancelled; '{self._capture}' keeps its bindings.")
171            return
172        if event.key in MODIFIER_KEYS:
173            return  # wait for the key the modifier belongs to
174        action = self._capture
175        new = InputBinding(key=event.key, ctrl=event.ctrl, shift=event.shift, alt=event.alt)
176        for old in list(InputMap.get_bindings(action)):
177            InputMap.remove_binding(action, old)
178        InputMap.add_binding(action, new)
179        self._finish_capture(f"'{action}' is now bound to {new.key_combo}.")
180
181    def _reset_defaults(self):
182        for action, defaults in self.input_actions.items():
183            for old in list(InputMap.get_bindings(action)):
184                InputMap.remove_binding(action, old)
185            for binding in defaults:
186                InputMap.add_binding(action, binding)
187            self._refresh_row(action)
188        self._status.text = "All actions reset to their declared defaults."
189
190    # -- Proof that rebinds take effect ------------------------------------
191
192    def on_update(self, dt: float):
193        if self._capture is not None:
194            return  # the armed key is a binding, not a press
195        suppressed, self._suppress_action = self._suppress_action, None
196        for action in self._rows:
197            if action == suppressed:
198                continue
199            if Input.is_action_just_pressed(action):
200                last, count = self._fired or (action, 0)
201                count = count + 1 if last == action else 1
202                self._fired = (action, count)
203                self._ticker.text = f"Action fired: {action} (x{count})"
204
205
206def _selftest() -> bool:
207    """Headless: drive the whole rebind flow through the real dispatch paths.
208
209    The rebind buttons are clicked by pointer, the captured keys arrive as
210    tree input events (the same route on_unhandled_input receives at runtime),
211    and the final check fires the NEW binding through the input simulator to
212    prove the action follows the map, not the other way round.
213    """
214    from simvx.core import TreeInputEvent
215    from simvx.core.testing import InputSimulator
216    from simvx.core.ui.testing import UITestHarness
217
218    harness = UITestHarness(RemappingDemo(name="RemappingDemo"), screen_size=(WIDTH, HEIGHT))
219    scene = harness.tree.root
220    imap = harness.tree.input_map
221    harness.tick()
222    ok = True
223
224    def check(label: str, passed: bool, detail: str) -> None:
225        nonlocal ok
226        ok = ok and passed
227        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
228
229    def press(key: Key, **mods) -> None:
230        harness.tree.propagate_input(TreeInputEvent("key", key=key, pressed=True, **mods))
231        harness.tree.propagate_input(TreeInputEvent("key", key=key, pressed=False, **mods))
232
233    # Enumeration: every declared action is in the map, and each row shows the
234    # key_combo spelling of that action's live bindings.
235    declared = set(RemappingDemo.input_actions)
236    check(
237        "InputMap.actions enumerates the declared actions",
238        declared <= set(imap.actions),
239        f"{sorted(declared)} within {sorted(imap.actions)}",
240    )
241    mismatches = [
242        a
243        for a in declared
244        if scene._rows[a][0].text != ", ".join(b.key_combo for b in imap.get_bindings(a))
245    ]
246    check("each row shows its bindings via key_combo", not mismatches, f"mismatched rows: {mismatches}")
247    check(
248        "jump starts with both of its declared bindings",
249        scene._rows["jump"][0].text == "space, up",
250        scene._rows["jump"][0].text,
251    )
252
253    # Rebind jump to J: click arms capture, the key press lands it.
254    harness.click(scene._rows["jump"][1])
255    harness.tick()
256    check(
257        "clicking Rebind arms capture for that row",
258        scene._capture == "jump" and scene._rows["jump"][1].text == "press a key...",
259        f"capture={scene._capture!r}, button text {scene._rows['jump'][1].text!r}",
260    )
261    press(Key.J)
262    harness.tick()
263    check(
264        "the captured key replaces every old binding",
265        imap.get_bindings("jump") == [InputBinding(key=Key.J)] and scene._rows["jump"][0].text == "j",
266        f"bindings {[b.key_combo for b in imap.get_bindings('jump')]}",
267    )
268
269    # Modifiers ride along: shift+F becomes one combo binding on fire.
270    harness.click(scene._rows["fire"][1])
271    harness.tick()
272    press(Key.F, shift=True)
273    check(
274        "a held modifier is captured into the combo",
275        imap.get_bindings("fire") == [InputBinding(key=Key.F, shift=True)]
276        and scene._rows["fire"][0].text == "shift+f",
277        scene._rows["fire"][0].text,
278    )
279
280    # A pure modifier press does not bind, and Esc cancels the capture.
281    harness.click(scene._rows["pause"][1])
282    harness.tick()
283    press(Key.LEFT_SHIFT)
284    still_armed = scene._capture == "pause"
285    press(Key.ESCAPE)
286    check(
287        "a bare modifier waits, and Esc cancels the capture",
288        still_armed and scene._capture is None and imap.get_bindings("pause") == [InputBinding(key=Key.P)],
289        f"armed after shift: {still_armed}, pause still {scene._rows['pause'][0].text!r}",
290    )
291
292    # The rebind is live: J fires jump through the real polling path, and the
293    # old Space binding no longer does.
294    sim = InputSimulator(tree=harness.tree)
295    sim.press_key(Key.J)
296    harness.tick()
297    sim.release_key(Key.J)
298    harness.tick()
299    check("the new binding fires the action", scene._fired == ("jump", 1), f"fired={scene._fired}")
300    sim.press_key(Key.SPACE)
301    harness.tick()
302    sim.release_key(Key.SPACE)
303    harness.tick()
304    check("the removed binding no longer fires it", scene._fired == ("jump", 1), f"fired={scene._fired}")
305
306    # Reset restores the declared defaults, rows included.
307    harness.click(harness.find_by_text("Reset defaults"))
308    harness.tick()
309    check(
310        "Reset restores the declared defaults",
311        imap.get_bindings("jump") == [InputBinding(key=Key.SPACE), InputBinding(key=Key.UP)]
312        and scene._rows["jump"][0].text == "space, up",
313        scene._rows["jump"][0].text,
314    )
315
316    harness.teardown()
317    print("SELFTEST:", "PASS" if ok else "FAIL")
318    return ok
319
320
321if __name__ == "__main__":
322    import sys
323
324    if "--test" in sys.argv:
325        sys.exit(0 if _selftest() else 1)
326    App(title="Input Remapping", width=WIDTH, height=HEIGHT).run(RemappingDemo())