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)