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
[docs] def ready(self): self._rebuild()
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()