Source code for simvx.editor.panels.input_map_editor
"""Input Action Map Editor -- Visual editor for InputMap action bindings.
Displays all registered input actions and their key/mouse/gamepad bindings.
Users can add/remove actions, add/remove bindings per action, and configure
deadzone for gamepad axes. All changes are applied immediately to the
InputMap singleton.
Layout:
+------------------------------------------+
| [+ Add Action] [Clear All] |
+------------------------------------------+
| v jump |
| [SPACE] [x] |
| [JoyButton.A] [x] |
| [+ Add Binding] |
+------------------------------------------+
| v move_left |
| [A] [x] |
| [LEFT] [x] |
| [JoyAxis.LEFT_X -] [x] |
| [+ Add Binding] |
+------------------------------------------+
"""
from __future__ import annotations
import logging
from simvx.core import (
Button,
CallableCommand,
CheckBox,
Control,
DropDown,
HBoxContainer,
Label,
Signal,
SpinBox,
TextEdit,
VBoxContainer,
Vec2,
)
from simvx.core.input.enums import JoyAxis, JoyButton, Key, MouseButton
from simvx.core.input.events import InputBinding
from simvx.core.input.map import InputMap
from simvx.core.ui.theme import em, get_theme
log = logging.getLogger(__name__)
def _row_h() -> float:
return em(2.18)
def _font_size() -> float:
return get_theme().font_size
def _padding() -> float:
return em(0.55)
# ============================================================================
# Binding display helpers
# ============================================================================
def _binding_label(b: InputBinding) -> str:
"""Human-readable label for an InputBinding."""
if b.key is not None:
return b.key.name
if b.mouse_button is not None:
return f"Mouse.{b.mouse_button.name}"
if b.joy_button is not None:
return f"Joy.{b.joy_button.name}"
if b.joy_axis is not None:
sign = "+" if b.joy_axis_positive else "-"
return f"Axis.{b.joy_axis.name} {sign}"
return "?"
# ============================================================================
# InputMapEditorPanel
# ============================================================================
[docs]
class InputMapEditorPanel(Control):
"""Visual editor for the InputMap singleton.
Displays all actions and their bindings. Supports adding/removing
actions and bindings. Integrates with the editor undo stack when
an EditorState is available.
"""
def __init__(self, editor_state=None, **kwargs):
super().__init__(**kwargs)
self.state = editor_state
self.bg_colour = get_theme().panel_bg
self.size = Vec2(300, 600)
self._content: VBoxContainer | None = None
self._add_action_edit: TextEdit | None = None
def _rebuild(self):
"""Tear down and rebuild the full panel."""
for child in list(self.children):
self.remove_child(child)
content = VBoxContainer()
content.separation = 2.0
content.size = Vec2(self.size.x - _padding() * 2, self.size.y)
content.position = Vec2(_padding(), _padding())
self.add_child(content)
self._content = content
# Toolbar: new action name field + Add button
toolbar = HBoxContainer()
toolbar.separation = 4.0
toolbar.size = Vec2(self.size.x - _padding() * 2, _row_h())
name_edit = TextEdit(text="", placeholder="Action name...")
name_edit.font_size = 11.0
name_edit.size = Vec2(140, _row_h())
toolbar.add_child(name_edit)
self._add_action_edit = name_edit
add_btn = Button("+ Add Action")
add_btn.size = Vec2(90, _row_h())
add_btn.font_size = 11.0
add_btn.pressed.connect(self._on_add_action)
toolbar.add_child(add_btn)
content.add_child(toolbar)
# Separator
sep = Control()
sep.size = Vec2(self.size.x - _padding() * 2, 1)
content.add_child(sep)
# Action sections
for action_name in sorted(InputMap.get_actions()):
self._add_action_section(content, action_name)
def _add_action_section(self, parent: VBoxContainer, action_name: str):
"""Add a collapsible section for one action with its bindings."""
t = get_theme()
# Action header row
header = HBoxContainer()
header.separation = 4.0
header.size = Vec2(self.size.x - _padding() * 2, _row_h() + 2)
action_label = Label(action_name)
action_label.font_size = 13.0
action_label.text_colour = (0.4, 0.8, 1.0, 1.0)
action_label.size = Vec2(180, _row_h())
header.add_child(action_label)
remove_btn = Button("Remove")
remove_btn.size = Vec2(60, _row_h())
remove_btn.font_size = 10.0
remove_btn.pressed.connect(lambda n=action_name: self._on_remove_action(n))
header.add_child(remove_btn)
parent.add_child(header)
# Binding rows
bindings = InputMap.get_bindings(action_name)
for binding in bindings:
row = HBoxContainer()
row.separation = 4.0
row.size = Vec2(self.size.x - _padding() * 2, _row_h())
# Indent
indent = Control()
indent.size = Vec2(16, _row_h())
row.add_child(indent)
# Binding label
blabel = Label(_binding_label(binding))
blabel.font_size = 11.0
blabel.text_colour = t.text
blabel.size = Vec2(160, _row_h())
row.add_child(blabel)
# Remove binding button
del_btn = Button("x")
del_btn.size = Vec2(22, _row_h())
del_btn.font_size = 10.0
del_btn.pressed.connect(
lambda n=action_name, b=binding: self._on_remove_binding(n, b))
row.add_child(del_btn)
parent.add_child(row)
# Add binding row
add_row = HBoxContainer()
add_row.separation = 4.0
add_row.size = Vec2(self.size.x - _padding() * 2, _row_h())
indent2 = Control()
indent2.size = Vec2(16, _row_h())
add_row.add_child(indent2)
# Binding type dropdown
binding_types = ["Key", "Mouse", "JoyButton", "JoyAxis"]
type_dd = DropDown(items=binding_types, selected=0)
type_dd.font_size = 10.0
type_dd.size = Vec2(70, _row_h())
add_row.add_child(type_dd)
# Value dropdown (populated based on type)
key_names = [k.name for k in Key]
value_dd = DropDown(items=key_names, selected=0)
value_dd.font_size = 10.0
value_dd.size = Vec2(100, _row_h())
add_row.add_child(value_dd)
# Update value dropdown when type changes
def _update_values(idx, vdd=value_dd):
if idx == 0:
vdd.items = [k.name for k in Key]
elif idx == 1:
vdd.items = [m.name for m in MouseButton]
elif idx == 2:
vdd.items = [j.name for j in JoyButton]
elif idx == 3:
vdd.items = [a.name for a in JoyAxis]
vdd.selected = 0
type_dd.item_selected.connect(_update_values)
add_bind_btn = Button("+")
add_bind_btn.size = Vec2(22, _row_h())
add_bind_btn.font_size = 11.0
add_bind_btn.pressed.connect(
lambda n=action_name, tdd=type_dd, vdd=value_dd: self._on_add_binding(n, tdd, vdd))
add_row.add_child(add_bind_btn)
parent.add_child(add_row)
# ====================================================================
# Action operations
# ====================================================================
def _on_add_action(self):
"""Add a new action from the text field."""
if self._add_action_edit is None:
return
name = self._add_action_edit.text.strip()
if not name or InputMap.has_action(name):
return
def do_fn():
InputMap.add_action(name)
def undo_fn():
InputMap.remove_action(name)
self._push_command(do_fn, undo_fn, f"Add action '{name}'")
self._add_action_edit.text = ""
self._rebuild()
def _on_remove_action(self, name: str):
"""Remove an action and all its bindings."""
old_bindings = list(InputMap.get_bindings(name))
def do_fn():
InputMap.remove_action(name)
def undo_fn():
InputMap.add_action(name, old_bindings)
self._push_command(do_fn, undo_fn, f"Remove action '{name}'")
self._rebuild()
# ====================================================================
# Binding operations
# ====================================================================
def _on_add_binding(self, action_name: str, type_dd: DropDown, value_dd: DropDown):
"""Add a binding to an action based on dropdown selections."""
type_idx = type_dd.selected
value_idx = value_dd.selected
if type_idx == 0:
keys = list(Key)
binding = InputBinding(key=keys[value_idx])
elif type_idx == 1:
buttons = list(MouseButton)
binding = InputBinding(mouse_button=buttons[value_idx])
elif type_idx == 2:
joys = list(JoyButton)
binding = InputBinding(joy_button=joys[value_idx])
elif type_idx == 3:
axes = list(JoyAxis)
binding = InputBinding(joy_axis=axes[value_idx])
else:
return
def do_fn():
InputMap.add_binding(action_name, binding)
def undo_fn():
InputMap.remove_binding(action_name, binding)
self._push_command(do_fn, undo_fn, f"Add {_binding_label(binding)} to '{action_name}'")
self._rebuild()
def _on_remove_binding(self, action_name: str, binding: InputBinding):
"""Remove a specific binding from an action."""
def do_fn():
InputMap.remove_binding(action_name, binding)
def undo_fn():
InputMap.add_binding(action_name, binding)
self._push_command(do_fn, undo_fn, f"Remove {_binding_label(binding)} from '{action_name}'")
self._rebuild()
# ====================================================================
# Undo support
# ====================================================================
def _push_command(self, do_fn, undo_fn, description: str):
"""Push a command through the undo stack if available, else execute directly."""
if self.state is not None:
cmd = CallableCommand(do_fn, undo_fn, description=description)
self.state.undo_stack.push(cmd)
self.state.modified = True
else:
do_fn()
# ====================================================================
# Drawing
# ====================================================================
[docs]
def draw(self, renderer):
t = get_theme()
x, y, w, h = self.get_global_rect()
renderer.draw_filled_rect(x, y, w, h, t.panel_bg)
renderer.draw_filled_rect(x, y, 2, h, t.border)
def _draw_recursive(self, renderer):
if not self.visible:
return
self.draw(renderer)
x, y, w, h = self.get_global_rect()
renderer.push_clip(x, y, w, h)
for child in list(self.children):
child._draw_recursive(renderer)
renderer.pop_clip()