Input Remapping¶
rebind actions at runtime through InputMap.
▶ Run in browserTags: 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_actionsclass attribute.Enumerating the live map:
InputMap.actions+InputMap.get_bindings(name), withInputBinding.key_comboas 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_pressedproves 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())