Source code for simvx.core.input.map

"""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, key_combo_to_binding

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, as do key names (``"space"``) and modifier combos (``"shift+tab"``).

        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.

        Registering from a node callback lands in the tree's own map, because
        every tree entry point that runs node code opens the tree's input span:
        a tick, for ``on_update`` and ``on_fixed_update``; a mount, for
        ``on_enter_tree`` and ``on_ready``, whether the node arrives through
        ``set_root``, ``change_scene``, ``add_singleton`` or ``add_child``; a
        teardown, for ``on_exit_tree``; event dispatch, for ``@on_input``,
        ``on_unhandled_input`` and UI handlers such as ``_on_gui_input``, a
        focus or hover signal, or a button's ``pressed`` signal; and drawing,
        for ``on_draw``.

        A warning fires for calls made after the scene began ticking from
        outside the tree altogether: code after ``App.run()`` returns,
        background threads, or tool code driving another tree.

        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.
            # ``_input_span_depth`` exempts everything the engine itself is
            # running: a tick, a physics step, a mount (which is why a scene
            # swapped in after a boot splash does not warn), a teardown, a
            # layout flush, drawing, a group broadcast, and input, UI, touch
            # and pick dispatch.
            from ..scene_tree import SceneTree

            active = SceneTree.current()
            if active is not None and active._tick_count > 0 and not active._input_span_depth:
                log.warning(
                    "InputMap.add_action(%r) called after the scene began ticking, from "
                    "outside the scene tree's own work. That is the case for code after "
                    "App.run() returns, a background thread, or tool code driving another "
                    "tree. The binding lands in whichever InputMap is active for the "
                    "caller, which need not be the running tree's, so the action may never "
                    "resolve; and code reached only from main() or the __main__ block does "
                    "not run in a web export at all. Declare the action on the root's "
                    "``input_actions`` class attribute, or register it from on_update. "
                    "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 is not None and not isinstance(bindings, (list, tuple)):
            bindings = [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()
            try:
                return InputBinding(key=Key[upper])
            except KeyError:
                pass
            try:
                return InputBinding(mouse_button=MouseButton[upper])
            except KeyError:
                pass
            try:
                return InputBinding(joy_button=JoyButton[upper])
            except KeyError:
                pass
            # Last: a modifier combo, e.g. "shift+tab" or "ctrl+alt+delete", so a
            # key whose own name contains "+" still resolves to itself first.
            combo = key_combo_to_binding(b)
            if combo is not None:
                return combo
            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__ = ()

    #: The class this proxy stands in for, for ``tools/api_surface.py``: the
    #: package root publishes this object, and the guard freezes the target's
    #: public methods under the exported name rather than one opaque line.
    _api_surface_class = _InputMap

    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())


[docs] @contextmanager def set_active_input_map(instance: _InputMap): """Context manager to set the active InputMap for the current context.""" token = _active_input_map.set(instance) try: yield finally: _active_input_map.reset(token)
InputMap = _InputMapProxy()