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)