Source code for simvx.editor.input_map_dialog

"""Input Map dialog — modal UI for editing InputMap actions and bindings.

Launched from Edit > Input Map... Reads and writes the ``[input]`` section of
``simvx.toml``; changes are only committed to disk (and re-applied to
:class:`InputMap`) when the user clicks Save.

Follows the :mod:`simvx.editor.export_dialog` pattern: a modal :class:`Panel`
overlay with a centred inner panel containing widget-composed content.
"""

import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import (
    Button,
    CheckBox,
    DropDown,
    HBoxContainer,
    Label,
    Panel,
    ScrollContainer,
    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.project import (
    ProjectSettings,
    find_project,
    load_project,
    save_project,
)

if TYPE_CHECKING:
    from .state import State

log = logging.getLogger(__name__)

__all__ = ["InputMapDialog", "_binding_label"]

_ACTION_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")

_BINDING_TYPES = ["Key", "Mouse", "JoyButton", "JoyAxis"]

_KEY_CHOICES: list[tuple[str, Key]] = sorted(
    ((k.name, k) for k in Key), key=lambda p: p[0]
)
_MOUSE_CHOICES: list[tuple[str, MouseButton]] = [(b.name, b) for b in MouseButton]
_JOY_BUTTON_CHOICES: list[tuple[str, JoyButton]] = [(b.name, b) for b in JoyButton]
_JOY_AXIS_CHOICES: list[tuple[str, JoyAxis]] = [(a.name, a) for a in JoyAxis]

def _binding_label(b: InputBinding) -> str:
    """Human-readable label for an InputBinding."""
    if b.key is not None:
        return f"Key.{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} (deadzone={b.deadzone:.2f})"
    return "?"

def _bindings_equal(a: InputBinding, b: InputBinding) -> bool:
    """Structural equality for duplicate detection."""
    return (
        a.key == b.key
        and a.mouse_button == b.mouse_button
        and a.joy_button == b.joy_button
        and a.joy_axis == b.joy_axis
        and a.joy_axis_positive == b.joy_axis_positive
    )

[docs] class InputMapDialog(Panel): """Modal Input Map editor dialog. Operates on a local ``dict[str, list[InputBinding]]`` copy; the live :class:`InputMap` singleton and ``simvx.toml`` are only mutated on Save. """ DIALOG_W = 720.0 DIALOG_H = 560.0 def __init__(self, state: State | None = None, **kwargs): super().__init__(**kwargs) self.state = state self.bg_colour = (0.0, 0.0, 0.0, 0.6) self.border_width = 0 self.visible = False self.z_index = 1600 self.closed = Signal() self.saved = Signal() # Editable local state self._actions: dict[str, list[InputBinding]] = {} self._saved_snapshot: dict[str, list[InputBinding]] = {} self._selected_action: str | None = None self._dirty: bool = False # UI refs (populated in _build) self._inner: Panel | None = None self._project_label: Label | None = None self._action_list: VBoxContainer | None = None self._bindings_title: Label | None = None self._bindings_list: VBoxContainer | None = None self._message_label: Label | None = None self._new_action_edit: TextEdit | None = None self._binding_type_dd: DropDown | None = None self._binding_value_dd: DropDown | None = None self._binding_positive_cb: CheckBox | None = None self._binding_deadzone_spin: SpinBox | None = None self._confirm_overlay: Panel | None = None self._build() # ------------------------------------------------------------------ # Build UI # ------------------------------------------------------------------ def _build(self): inner = Panel(name="InputMapDialogInner") inner.bg_colour = (0.16, 0.16, 0.18, 1.0) inner.border_colour = (0.35, 0.35, 0.40, 1.0) inner.border_width = 1.0 inner.size = Vec2(self.DIALOG_W, self.DIALOG_H) self._inner = inner vbox = VBoxContainer(name="InputMapVBox") vbox.separation = 8 vbox.position = Vec2(16, 16) vbox.size = Vec2(self.DIALOG_W - 32, self.DIALOG_H - 32) inner.add_child(vbox) title = Label("Input Map", name="Title") title.font_size = 16.0 title.text_colour = (0.95, 0.95, 0.97, 1.0) title.size = Vec2(self.DIALOG_W - 32, 24) vbox.add_child(title) self._project_label = Label("(no project)", name="ProjectLabel") self._project_label.font_size = 11.0 self._project_label.text_colour = (0.55, 0.55, 0.58, 1.0) self._project_label.size = Vec2(self.DIALOG_W - 32, 18) vbox.add_child(self._project_label) # Two-column area (actions | bindings) main = HBoxContainer(name="InputMapHBox") main.separation = 12 main.size = Vec2(self.DIALOG_W - 32, 360) vbox.add_child(main) # Left column: actions left = VBoxContainer(name="ActionsColumn") left.separation = 4 left.size = Vec2(300, 360) main.add_child(left) actions_header = Label("Actions", name="ActionsHeader") actions_header.font_size = 13.0 actions_header.text_colour = (0.8, 0.8, 0.85, 1.0) actions_header.size = Vec2(300, 20) left.add_child(actions_header) action_scroll = ScrollContainer(name="ActionScroll") action_scroll.size = Vec2(300, 280) left.add_child(action_scroll) self._action_list = VBoxContainer(name="ActionList") self._action_list.separation = 2 self._action_list.size = Vec2(288, 280) action_scroll.add_child(self._action_list) add_action_row = HBoxContainer(name="AddActionRow") add_action_row.separation = 6 add_action_row.size = Vec2(300, 32) self._new_action_edit = TextEdit(name="NewActionEdit") self._new_action_edit.size = Vec2(190, 28) add_action_row.add_child(self._new_action_edit) add_action_btn = Button("+ Add", name="AddActionBtn") add_action_btn.size = Vec2(90, 28) add_action_btn.pressed.connect(self._on_add_action) add_action_row.add_child(add_action_btn) left.add_child(add_action_row) # Right column: bindings right = VBoxContainer(name="BindingsColumn") right.separation = 4 right.size = Vec2(376, 360) main.add_child(right) self._bindings_title = Label("Bindings — (select an action)", name="BindingsHeader") self._bindings_title.font_size = 13.0 self._bindings_title.text_colour = (0.8, 0.8, 0.85, 1.0) self._bindings_title.size = Vec2(376, 20) right.add_child(self._bindings_title) binding_scroll = ScrollContainer(name="BindingScroll") binding_scroll.size = Vec2(376, 240) right.add_child(binding_scroll) self._bindings_list = VBoxContainer(name="BindingsList") self._bindings_list.separation = 2 self._bindings_list.size = Vec2(364, 240) binding_scroll.add_child(self._bindings_list) # Add-binding row add_binding_row = HBoxContainer(name="AddBindingRow") add_binding_row.separation = 6 add_binding_row.size = Vec2(376, 32) self._binding_type_dd = DropDown(items=list(_BINDING_TYPES), selected=0, name="BindingTypeDD") self._binding_type_dd.size = Vec2(90, 28) self._binding_type_dd.item_selected.connect(lambda _i: self._refresh_binding_value_dd()) add_binding_row.add_child(self._binding_type_dd) self._binding_value_dd = DropDown(items=[n for n, _ in _KEY_CHOICES], selected=0, name="BindingValueDD") self._binding_value_dd.size = Vec2(130, 28) add_binding_row.add_child(self._binding_value_dd) self._binding_positive_cb = CheckBox("+axis", checked=True, name="BindingPositiveCB") self._binding_positive_cb.size = Vec2(60, 28) self._binding_positive_cb.visible = False add_binding_row.add_child(self._binding_positive_cb) add_binding_btn = Button("+ Add", name="AddBindingBtn") add_binding_btn.size = Vec2(70, 28) add_binding_btn.pressed.connect(self._on_add_binding) add_binding_row.add_child(add_binding_btn) right.add_child(add_binding_row) # Message area self._message_label = Label("", name="MessageArea") self._message_label.font_size = 11.0 self._message_label.text_colour = (0.9, 0.4, 0.4, 1.0) self._message_label.size = Vec2(self.DIALOG_W - 32, 20) vbox.add_child(self._message_label) # Button row btns = HBoxContainer(name="ButtonRow") btns.separation = 8 btns.size = Vec2(self.DIALOG_W - 32, 36) save_btn = Button("Save", name="SaveBtn") save_btn.size = Vec2(100, 32) save_btn.bg_colour = (0.20, 0.57, 0.92, 1.0) save_btn.pressed.connect(self._on_save) btns.add_child(save_btn) revert_btn = Button("Revert", name="RevertBtn") revert_btn.size = Vec2(100, 32) revert_btn.pressed.connect(self._on_revert) btns.add_child(revert_btn) close_btn = Button("Close", name="CloseBtn") close_btn.size = Vec2(100, 32) close_btn.bg_colour = (0.30, 0.30, 0.33, 1.0) close_btn.pressed.connect(self._on_close) btns.add_child(close_btn) vbox.add_child(btns) # Confirm-discard sub-overlay (hidden until needed) self._confirm_overlay = self._build_confirm_overlay() inner.add_child(self._confirm_overlay) self.add_child(inner) def _build_confirm_overlay(self) -> Panel: overlay = Panel(name="ConfirmOverlay") overlay.bg_colour = (0.0, 0.0, 0.0, 0.6) overlay.border_width = 0 overlay.size = Vec2(self.DIALOG_W, self.DIALOG_H) overlay.visible = False overlay.z_index = 1 inner = Panel(name="ConfirmInner") inner.bg_colour = (0.18, 0.18, 0.20, 1.0) inner.border_colour = (0.35, 0.35, 0.40, 1.0) inner.border_width = 1.0 inner.size = Vec2(320, 140) inner.position = Vec2((self.DIALOG_W - 320) / 2, (self.DIALOG_H - 140) / 2) overlay.add_child(inner) vbox = VBoxContainer() vbox.separation = 12 vbox.position = Vec2(16, 16) vbox.size = Vec2(288, 108) inner.add_child(vbox) msg = Label("Discard unsaved changes?", name="ConfirmMsg") msg.font_size = 13.0 msg.text_colour = (0.92, 0.92, 0.94, 1.0) msg.size = Vec2(288, 22) vbox.add_child(msg) btns = HBoxContainer() btns.separation = 8 btns.size = Vec2(288, 34) discard_btn = Button("Discard", name="ConfirmDiscardBtn") discard_btn.size = Vec2(130, 32) discard_btn.pressed.connect(self._on_confirm_discard) btns.add_child(discard_btn) cancel_btn = Button("Cancel", name="ConfirmCancelBtn") cancel_btn.size = Vec2(130, 32) cancel_btn.pressed.connect(self._on_confirm_cancel) btns.add_child(cancel_btn) vbox.add_child(btns) return overlay # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def show_dialog(self, parent_size: Vec2 | None = None): """Show and centre the dialog, populating from simvx.toml.""" self.visible = True if parent_size: self.size = parent_size if self._inner: pw = self.size.x if self.size.x > 0 else 1280 ph = self.size.y if self.size.y > 0 else 720 self._inner.position = Vec2((pw - self.DIALOG_W) / 2, (ph - self.DIALOG_H) / 2) self._reload_from_disk() self._confirm_overlay.visible = False self._message_label.text = "" self.queue_redraw()
[docs] def hide_dialog(self): self.visible = False self._confirm_overlay.visible = False self.closed.emit()
# ------------------------------------------------------------------ # Load / save # ------------------------------------------------------------------ def _project_dir(self) -> Path: if self.state is not None and getattr(self.state, "project_path", None): return Path(self.state.project_path) return Path.cwd() def _toml_path(self) -> Path: project = self._project_dir() toml = project / "simvx.toml" if toml.is_file(): return toml found = find_project(project) return found if found is not None else toml def _load_settings(self) -> ProjectSettings: toml = self._toml_path() if toml.is_file(): try: return load_project(toml) except Exception: log.exception("Failed to load %s", toml) s = ProjectSettings() s.project_path = str(toml) return s def _reload_from_disk(self): """Read [input] from disk, seed local state, rebuild UI.""" settings = self._load_settings() self._project_label.text = f"Project: {settings.name} ({self._project_dir()})" self._actions = {name: list(bindings) for name, bindings in settings.input.items()} self._saved_snapshot = {name: list(bindings) for name, bindings in self._actions.items()} self._dirty = False names = sorted(self._actions) self._selected_action = names[0] if names else None self._refresh_action_list() self._refresh_bindings_list() # ------------------------------------------------------------------ # Action list # ------------------------------------------------------------------ def _refresh_action_list(self): for child in list(self._action_list.children): self._action_list.remove_child(child) for name in sorted(self._actions): row = HBoxContainer(name=f"ActionRow_{name}") row.separation = 4 row.size = Vec2(288, 28) active = (name == self._selected_action) sel_btn = Button(name, name=f"SelectAction_{name}") sel_btn.size = Vec2(210, 28) sel_btn.bg_colour = (0.20, 0.45, 0.80, 1.0) if active else (0.22, 0.22, 0.25, 1.0) sel_btn.pressed.connect(lambda n=name: self._on_select_action(n)) row.add_child(sel_btn) del_btn = Button("x", name=f"RemoveAction_{name}") del_btn.size = Vec2(30, 28) del_btn.bg_colour = (0.45, 0.20, 0.20, 1.0) del_btn.pressed.connect(lambda n=name: self._on_remove_action(n)) row.add_child(del_btn) self._action_list.add_child(row) def _on_select_action(self, name: str): self._selected_action = name self._refresh_action_list() self._refresh_bindings_list() def _on_add_action(self): name = self._new_action_edit.text.strip() if not name: self._set_error("Action name must not be empty.") return if not _ACTION_NAME_RE.match(name): self._set_error(f"Invalid action name {name!r}: must match [a-zA-Z_][a-zA-Z0-9_]*") return if name in self._actions: self._set_error(f"Action {name!r} already exists.") return self._actions[name] = [] self._selected_action = name self._dirty = True self._new_action_edit.text = "" self._clear_error() self._refresh_action_list() self._refresh_bindings_list() def _on_remove_action(self, name: str): if name not in self._actions: return del self._actions[name] self._dirty = True if self._selected_action == name: remaining = sorted(self._actions) self._selected_action = remaining[0] if remaining else None self._clear_error() self._refresh_action_list() self._refresh_bindings_list() # ------------------------------------------------------------------ # Bindings list # ------------------------------------------------------------------ def _refresh_bindings_list(self): for child in list(self._bindings_list.children): self._bindings_list.remove_child(child) if self._selected_action is None: self._bindings_title.text = "Bindings — (select an action)" self._refresh_binding_value_dd() return self._bindings_title.text = f"Bindings — {self._selected_action}" bindings = self._actions.get(self._selected_action, []) for idx, b in enumerate(bindings): row = HBoxContainer(name=f"BindingRow_{idx}") row.separation = 4 row.size = Vec2(364, 28) lbl = Label(_binding_label(b), name=f"BindingLabel_{idx}") lbl.font_size = 12.0 lbl.text_colour = (0.85, 0.85, 0.88, 1.0) lbl.size = Vec2(240, 28) row.add_child(lbl) if b.joy_axis is not None: spin = SpinBox(min_val=0.0, max_val=1.0, value=b.deadzone, step=0.05, name=f"BindingDeadzone_{idx}") spin.size = Vec2(80, 28) def _on_dz(v, _i=idx): self._on_set_deadzone(_i, float(v)) spin.value_changed.connect(_on_dz) row.add_child(spin) del_btn = Button("x", name=f"RemoveBinding_{idx}") del_btn.size = Vec2(30, 28) del_btn.bg_colour = (0.45, 0.20, 0.20, 1.0) del_btn.pressed.connect(lambda i=idx: self._on_remove_binding(i)) row.add_child(del_btn) self._bindings_list.add_child(row) self._refresh_binding_value_dd() def _refresh_binding_value_dd(self): """Update the Value dropdown choices + axis-only widgets based on the Type selection.""" type_name = _BINDING_TYPES[self._binding_type_dd.selected_index] if self._binding_type_dd.items else "Key" is_axis = type_name == "JoyAxis" self._binding_positive_cb.visible = is_axis names = self._choices_for_type(type_name) self._binding_value_dd.items = names if names and self._binding_value_dd.selected_index >= len(names): self._binding_value_dd.selected_index = 0 def _choices_for_type(self, type_name: str) -> list[str]: if type_name == "Key": return [n for n, _ in _KEY_CHOICES] if type_name == "Mouse": return [n for n, _ in _MOUSE_CHOICES] if type_name == "JoyButton": return [n for n, _ in _JOY_BUTTON_CHOICES] if type_name == "JoyAxis": return [n for n, _ in _JOY_AXIS_CHOICES] return [] def _on_add_binding(self): if self._selected_action is None: self._set_error("Select an action first.") return type_name = _BINDING_TYPES[self._binding_type_dd.selected_index] idx = self._binding_value_dd.selected_index try: if type_name == "Key": binding = InputBinding(key=_KEY_CHOICES[idx][1]) elif type_name == "Mouse": binding = InputBinding(mouse_button=_MOUSE_CHOICES[idx][1]) elif type_name == "JoyButton": binding = InputBinding(joy_button=_JOY_BUTTON_CHOICES[idx][1]) elif type_name == "JoyAxis": binding = InputBinding( joy_axis=_JOY_AXIS_CHOICES[idx][1], joy_axis_positive=bool(self._binding_positive_cb.checked), ) else: # unreachable return except IndexError: self._set_error("Invalid binding selection.") return existing = self._actions[self._selected_action] if any(_bindings_equal(binding, e) for e in existing): self._set_error(f"Binding {_binding_label(binding)} already exists on this action.") return existing.append(binding) self._dirty = True self._clear_error() self._refresh_bindings_list() def _on_remove_binding(self, index: int): if self._selected_action is None: return bindings = self._actions.get(self._selected_action, []) if 0 <= index < len(bindings): bindings.pop(index) self._dirty = True self._clear_error() self._refresh_bindings_list() def _on_set_deadzone(self, index: int, value: float): if self._selected_action is None: return bindings = self._actions.get(self._selected_action, []) if 0 <= index < len(bindings) and bindings[index].joy_axis is not None: bindings[index] = InputBinding( joy_axis=bindings[index].joy_axis, joy_axis_positive=bindings[index].joy_axis_positive, deadzone=float(value), ) self._dirty = True self._refresh_bindings_list() # ------------------------------------------------------------------ # Save / revert / close # ------------------------------------------------------------------ def _on_save(self): settings = self._load_settings() settings.input = {name: list(bindings) for name, bindings in self._actions.items()} try: settings.validate() except Exception as exc: self._set_error(str(exc)) return try: save_project(settings, self._toml_path()) except Exception as exc: log.exception("Failed to save input map") self._set_error(f"Save failed: {exc}") return # Re-apply to live InputMap settings.apply_input_actions() self._saved_snapshot = {name: list(bindings) for name, bindings in self._actions.items()} self._dirty = False self._message_label.text_colour = (0.45, 0.85, 0.45, 1.0) self._message_label.text = "Saved." self.saved.emit() def _on_revert(self): self._actions = {name: list(bindings) for name, bindings in self._saved_snapshot.items()} self._dirty = False names = sorted(self._actions) if self._selected_action not in self._actions: self._selected_action = names[0] if names else None self._clear_error() self._refresh_action_list() self._refresh_bindings_list() def _on_close(self): if self._dirty: self._confirm_overlay.visible = True return self.hide_dialog() def _on_confirm_discard(self): self._confirm_overlay.visible = False self._dirty = False self.hide_dialog() def _on_confirm_cancel(self): self._confirm_overlay.visible = False # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _set_error(self, msg: str): self._message_label.text_colour = (0.9, 0.4, 0.4, 1.0) self._message_label.text = msg def _clear_error(self): self._message_label.text = "" # ------------------------------------------------------------------ # Input # ------------------------------------------------------------------ def _on_gui_input(self, event): if hasattr(event, "key") and event.key == "escape" and event.pressed: if self._confirm_overlay.visible: self._on_confirm_cancel() else: self._on_close() return super()._on_gui_input(event)