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,
    PointerFilter,
    ScrollContainer,
    ScrollEvent,
    Signal,
    SpinBox,
    TextEdit,
    VBoxContainer,
    Vec2,
)
from simvx.core.descriptors import Property
from simvx.core.input.actions import Axis1D, Axis2D
from simvx.core.input.controls import Controller, DeviceAxis, DeviceButton, DeviceType, Key, Mouse, Pen, Pointer
from simvx.core.input.events import InputBinding, KeyEvent, key_part
from simvx.core.input.spelling import source_to_data
from simvx.core.project import (
    find_project,
    save_project,
)

from .dialog_message import ERROR_COLOUR, OK_COLOUR, set_message
from .project import read_project_settings

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", "Button", "Controller button", "Controller axis"]

_KEY_CHOICES: list[tuple[str, DeviceButton]] = sorted(
    ((str(k.name).upper(), k) for k in Key if isinstance(k, DeviceButton)), key=lambda p: p[0]
)
#: Every named pointer button, roles first: a role binds the main press of any
#: pointer, an alias one kind of device's button. Listed by their written
#: spelling, which is what the project file stores.
_BUTTON_CHOICES: list[tuple[str, DeviceButton]] = [
    (str(b), b)
    for b in (
        Pointer.PRIMARY,
        Pointer.SECONDARY,
        Mouse.LEFT,
        Mouse.RIGHT,
        Mouse.MIDDLE,
        Mouse.BACK,
        Mouse.FORWARD,
        Pen.TIP,
        Pen.BARREL_1,
        Pen.BARREL_2,
        Pen.BARREL_3,
    )
]
_JOY_BUTTON_CHOICES: list[tuple[str, DeviceButton]] = [
    (str(b.name).upper(), b) for b in Controller if isinstance(b, DeviceButton)
]
_JOY_AXIS_CHOICES: list[tuple[str, DeviceAxis]] = [
    (str(a.name).upper(), a) for a in Controller if isinstance(a, DeviceAxis)
]


def _binding_label(b: InputBinding | Axis1D | Axis2D | str) -> str:
    """Human-readable label for an InputBinding, or for the entry a name refers to.

    What a binding holds is spelled out (``Key.Shift+TAB``): two rows that
    differ only by what they hold must not read the same, or the list shows
    what looks like a duplicate of a binding the user cannot find.
    """
    if isinstance(b, str):
        return f"-> {b}"  # another entry's name: this one resolves through it
    if isinstance(b, Axis1D | Axis2D):
        # A composite reads as the controls it is made of, in the file's own spelling.
        return str(source_to_data(b))
    control = b.control
    held = "".join(f"{key_part(h).capitalize() if h.device is DeviceType.KEYBOARD else repr(h)}+" for h in b.held)
    if control is not None and b.is_key:
        name = control.name.upper() if control.name is not None else f"#{control.index}"
        return f"Key.{held}{name}"
    if isinstance(control, DeviceAxis):
        # The half and the unit are part of the control's own spelling, so two
        # players' rows or the two halves of a stick never read alike.
        return f"{held}{control!r} (deadzone={b.deadzone:.2f})"
    if control is not None:
        return f"{held}{control!r}"
    return "?"


def _bindings_equal(a: InputBinding | Axis1D | Axis2D | str, b: InputBinding | Axis1D | Axis2D | str) -> bool:
    """Structural equality for duplicate detection."""
    if not isinstance(a, InputBinding) or not isinstance(b, InputBinding):
        return a == b
    return (a.control, a.held, a.exact) == (b.control, b.held, b.exact)


#: Height of the message strip: three 11 px lines on a 1.2 line pitch.
_MESSAGE_H = 46.0


[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. """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") DIALOG_W = 720.0 DIALOG_H = 560.0 visible = Property( False, coerce=bool, hint="The Input Map editor is up only between show() and its answer", on_change="_on_visible_changed", ) # Above the editor's panels, below the dialogs that pause the tree. z_index = Property(1600, range=(-4096, 4096), hint="Draw order (higher = on top)", on_change="_invalidate_z_cache") 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.closed = Signal() self.saved = Signal() # Editable local state self._actions: dict[str, list[InputBinding | Axis1D | Axis2D | str]] = {} self._saved_snapshot: dict[str, list[InputBinding | Axis1D | Axis2D | str]] = {} self._selected_action: str | None = None self._dirty: bool = False # Why the project file on disk could not be read, if it could not be. self._load_error: str | None = None # 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_index=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_index=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 = ERROR_COLOUR # Three lines: an error names the action, the binding, and what is # wrong with it, which is more than one line of this width holds. self._message_label.size = Vec2(self.DIALOG_W - 32, _MESSAGE_H) self._message_label.vertical_alignment = "top" 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 if self._load_error: self._set_error(self._load_error) else: self._clear_error() 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 _reload_from_disk(self): """Read [input] from disk, seed local state, rebuild UI.""" settings, self._load_error = read_project_settings(self._toml_path()) 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 isinstance(b, InputBinding) and isinstance(b.control, DeviceAxis): spin = SpinBox(min_value=0.0, max_value=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 == "Controller axis" 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 == "Button": return [n for n, _ in _BUTTON_CHOICES] if type_name == "Controller button": return [n for n, _ in _JOY_BUTTON_CHOICES] if type_name == "Controller axis": 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_CHOICES[idx][1]) elif type_name == "Button": binding = InputBinding(_BUTTON_CHOICES[idx][1]) elif type_name == "Controller button": binding = InputBinding(_JOY_BUTTON_CHOICES[idx][1]) elif type_name == "Controller axis": axis = _JOY_AXIS_CHOICES[idx][1] binding = InputBinding(axis.positive if self._binding_positive_cb.checked else axis.negative) 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, []) binding = bindings[index] if 0 <= index < len(bindings) else None if isinstance(binding, InputBinding) and isinstance(binding.control, DeviceAxis): bindings[index] = InputBinding(binding.control, deadzone=float(value)) self._dirty = True self._refresh_bindings_list() # ------------------------------------------------------------------ # Save / revert / close # ------------------------------------------------------------------ def _on_save(self): settings, error = read_project_settings(self._toml_path()) if error: # Writing now would replace a document that is merely unparseable # with the defaults this dialog fell back to. self._load_error = error self._set_error(error) return 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() self._saved_snapshot = {name: list(bindings) for name, bindings in self._actions.items()} self._dirty = False self._set_message("Saved.", OK_COLOUR) 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_message(self, msg: str, colour: tuple[float, float, float, float]): if self._message_label is not None: set_message(self._message_label, msg, colour) def _set_error(self, msg: str): self._set_message(msg, ERROR_COLOUR) def _clear_error(self): self._set_message("", ERROR_COLOUR) # ------------------------------------------------------------------ # Input # ------------------------------------------------------------------
[docs] def on_gui_input(self, event): if isinstance(event, KeyEvent) and event.key is Key.ESCAPE and event.pressed: if self._confirm_overlay.visible: self._on_confirm_cancel() else: self._on_close() return True # The dialog fills the screen, so it claims the wheel at its own root: # a turn over the header, the footer or the dimmed backdrop belongs to # this dialog and stops here rather than climbing to whatever the shell # parents it to. A turn over one of the lists inside is claimed there # while the list can move; a list at its limit hands the turn on, and # this is where it stops. if isinstance(event, ScrollEvent): return True return super().on_gui_input(event)