"""InputMap: input action registry. Instance-based with module-level default."""
import contextvars
import logging
from contextlib import contextmanager
from .enums import JoyButton, Key, MouseButton, name_to_keys
from .events import InputBinding
log = logging.getLogger(__name__)
class _InputMap:
"""Maps action names to physical inputs. Create new instances for per-tree isolation."""
def __init__(self):
self._actions: dict[str, list[InputBinding]] = {}
def add_action(
self,
name: str,
bindings: list[InputBinding | Key | MouseButton | JoyButton | str] | None = None,
*,
_quiet: bool = False,
):
"""Register a named action with optional initial bindings.
Convenience: passing bare Key/MouseButton/JoyButton values auto-wraps them.
The canonical registration path is the root node's
``input_actions = {...}`` class attribute, which the scene tree
consumes at mount and re-applies on every ``change_scene`` swap.
Use this method only for runtime additions (rebinding UI, plugin
actions, etc.). A warning fires when called after the active scene
has begun ticking because the web exporter instantiates the root
directly without running ``main()``; see ``docs/web/export.md``.
The ``_quiet`` flag is internal; it suppresses both the late-call
and overwrite warnings for the declarative re-registration path.
"""
if not _quiet:
# Late-call warning. We import lazily to avoid a top-level cycle
# (scene_tree imports from input via Input). ``_active_tree`` is
# None before any tree exists (registrations from
# ``App.__init__`` / module scope): that's fine, no warning.
from ..scene_tree import SceneTree
active = SceneTree._active_tree
if active is not None and active._tick_count > 0:
log.warning(
"InputMap.add_action(%r) called after the scene began ticking. "
"On desktop the binding will work; on web exports the runtime "
"instantiates the root class directly without invoking "
"``main()``, so any add_action calls executed after the first "
"tick (or inside ``main()``) are silently dropped. Move them "
"to the root's ``input_actions`` class attribute or its "
"on_ready(): see docs/web/export.md.",
name,
)
if name in self._actions:
log.warning("Input action %r overwritten (had %s bindings)", name, len(self._actions[name]))
self._actions[name] = []
log.debug("InputMap.add_action(%r, %s)", name, bindings)
if bindings:
self._actions[name].extend(self._to_binding(b) for b in bindings)
def remove_action(self, name: str):
"""Remove a named action and all its bindings."""
self._actions.pop(name, None)
def add_binding(self, name: str, binding: InputBinding | Key | MouseButton | JoyButton | str):
"""Add a binding to an existing action. Creates the action if it does not exist."""
if name not in self._actions:
self._actions[name] = []
self._actions[name].append(self._to_binding(binding))
def remove_binding(self, name: str, binding: InputBinding):
"""Remove a specific binding from an action."""
if name in self._actions:
try:
self._actions[name].remove(binding)
except ValueError:
pass
def get_bindings(self, name: str) -> list[InputBinding]:
"""Return bindings for an action (empty list if unknown)."""
return self._actions.get(name, [])
def has_action(self, name: str) -> bool:
"""Check if an action is registered."""
return name in self._actions
@property
def actions(self) -> list[str]:
"""All registered action names."""
return list(self._actions)
def clear(self):
"""Remove all actions and bindings."""
self._actions.clear()
def _to_binding(self, b: InputBinding | Key | MouseButton | JoyButton | str) -> InputBinding:
if isinstance(b, InputBinding):
return b
if isinstance(b, Key):
return InputBinding(key=b)
if isinstance(b, MouseButton):
return InputBinding(mouse_button=b)
if isinstance(b, JoyButton):
return InputBinding(joy_button=b)
if isinstance(b, str):
# Try name_to_keys lookup first (handles "space", "escape", etc.)
keys = name_to_keys(b)
if keys:
return InputBinding(key=keys[0])
# Try enum name lookup: Key, MouseButton, JoyButton
upper = b.upper()
for enum_cls, field in ((Key, "key"), (MouseButton, "mouse_button"), (JoyButton, "joy_button")):
try:
return InputBinding(**{field: enum_cls[upper]})
except KeyError:
continue
raise ValueError(f"Cannot resolve input binding from string {b!r}")
raise TypeError(f"Cannot create InputBinding from {type(b).__name__}")
_default_input_map = _InputMap()
_active_input_map: contextvars.ContextVar[_InputMap] = contextvars.ContextVar("_active_input_map", default=_default_input_map)
class _InputMapProxy:
"""Proxy that delegates all access to the active _InputMap for the current context."""
__slots__ = ()
def __getattr__(self, name: str):
return getattr(_active_input_map.get(), name)
def __setattr__(self, name: str, value):
setattr(_active_input_map.get(), name, value)
def __repr__(self) -> str:
return repr(_active_input_map.get())
InputMap = _InputMapProxy()