Source code for simvx.core.input.contexts

"""Where a game's bindings are declared, and which of them are live right now.

A game rarely wants one flat set of bindings for its whole run. Driving a car
means ``move`` steers rather than walks; a pause menu means the player's keys
stop reaching the player. Both are the same mechanism: an
:class:`InputContext` on the node the mode belongs to.

    class Vehicle(Node2D):
        input_context = InputContext({"move": [Axis2D(up=Key.W, down=Key.S,
                                                      left=Key.A, right=Key.D)],
                                      "honk": [Key.H]}, name="vehicle")

A context is active exactly while its node can receive input, so nothing can
leak one: an exception, an early return or a scene swap carries the node out
and the context goes with it, and pausing the node stops it declaring and stops
it hiding. Active contexts stack in activation order, later on top, and are
flattened into one binding map when the stack changes rather than being walked
on every query. A deaf node keeps its place in the stack, so waking it puts the
context back where it was rather than on top of whatever arrived meanwhile.

**Shadowing is by action name.** A vehicle context defining ``move`` takes that
name for as long as it is active and leaves every other name alone, so the
walking controller's ``jump`` still works while the car's ``move`` steers.

**hides is the stronger form.** ``hides=InputContext.ALL`` takes every name
below it, which is what a pause menu, a modal dialog or a minigame that wants a
clean configuration needs: the game's actions, its device aliases and its own
redefinitions of engine entries all go, without the context having to know
what the game bound. The ``ui_`` actions never go dark, because a menu that hid
``ui_accept`` could not be dismissed, and neither do the engine's entries
(``Controller.A``, ``Pointer.PRIMARY`` and the rest), so a clean branch can still
bind them. A tuple of names hides exactly those.

**Entries are keyed by name or by control.** A string key is an action or a
device alias, and its sources may name other entries: ``{"throttle":
[Controller.axis(2)], "engine_power": ["throttle"]}``. A control key redefines an
engine entry for as long as the context is live, ``{Controller.A:
[Controller.button(1)]}``, with exactly one control as its source.

Any node may declare one, and a plain dict is a context of its own entries:
``input_context = {"jump": [Key.SPACE]}`` on the root covers the whole tree.
The base of the stack is the tree's own context, which ``InputMap.add_action``
writes: it has the tree's lifetime and never deactivates. Under it are the
project's entries from its settings, and under those the engine's.
"""

from __future__ import annotations

from collections.abc import Iterable, Mapping
from types import MappingProxyType

from .actions import Action, Source, SourceSpec, to_source
from .controls import DeviceAxis, DeviceButton, DeviceControl, DeviceStick, DeviceType

__all__ = ["HideEverything", "InputContext"]

#: The device types a role such as ``Pointer.PRIMARY`` can be redefined onto.
_POINTER_TYPES = frozenset({DeviceType.MOUSE, DeviceType.PEN, DeviceType.TOUCH})


[docs] class HideEverything: """The ``hides=InputContext.ALL`` sentinel, spelled as the set it stands for.""" __slots__ = ()
[docs] def __repr__(self) -> str: return "InputContext.ALL"
[docs] def __contains__(self, name: object) -> bool: return True
[docs] def __bool__(self) -> bool: return True
#: Every action name any context in this process has declared, in a tree or #: not yet: a node class's ``input_context`` exists from import, so a rebind #: screen at the title can rebind an action whose context arrives with a level. _DECLARED_NAMES: set[str] = set()
[docs] class InputContext: """One named set of action bindings, live while its node can receive input. Args: bindings: What this context declares, ``{"jump": [Key.SPACE, Controller.A]}``. Each value is one action's sources in the same spelling a node's ``input_context`` dict takes, and a bare source is wrapped. name: What to call it in ``Input.context_stack`` and in a diagnostic. Defaults to nothing, which reads as the position in the stack. hides: :attr:`ALL` to take every name below this context, or the names to take. ``ui_`` actions are never hidden either way. A context is frozen once built. A player rebinding a key writes the override layer (:meth:`InputMap.rebind`), which survives every rebuild of the stack and is reapplied to each flatten; it does not edit the context the game wrote. """ #: Hide every action below this context, bar the ``ui_`` set. ALL = HideEverything() __slots__ = ("_actions", "_remaps", "_name", "_hides", "_hides_all", "_frozen") def __init__( self, bindings: Mapping[str | DeviceControl, Iterable[SourceSpec] | SourceSpec] | None = None, *, name: str | None = None, hides: Iterable[str] | HideEverything = (), ) -> None: actions: dict[str, Action] = {} remaps: dict[DeviceControl, Action] = {} for action_name, sources in (bindings or {}).items(): if isinstance(sources, (str, bytes)) or not isinstance(sources, Iterable): sources = [sources] if isinstance(action_name, DeviceControl): remaps[action_name] = remap_entry(action_name, list(sources)) continue if not isinstance(action_name, str) or not action_name: raise TypeError(f"an input entry is keyed by a name or by a control, got {action_name!r}") action = Action(action_name) action.sources.extend(to_source(spec) for spec in sources) if action_name in action.names: raise ValueError(f"input entry {action_name!r} names itself: {action_name!r} -> {action_name!r}") action.refresh() actions[action_name] = action if isinstance(hides, str): # A bare string reads as one name and iterates as its letters, # which would hide five single-letter actions instead of one. raise TypeError( f"hides={hides!r} is one name written as a string, which iterates as its " f"letters. Pass a tuple: hides=({hides!r},), or InputContext.ALL." ) self._actions = actions self._remaps = remaps _DECLARED_NAMES.update(actions) self._name = name self._hides_all = hides is InputContext.ALL self._hides: frozenset[str] = frozenset() if self._hides_all else frozenset(hides) # type: ignore[arg-type] self._frozen = True
[docs] def __setattr__(self, name: str, value: object) -> None: if getattr(self, "_frozen", False): raise AttributeError( f"an InputContext is frozen and {name!r} cannot be set on it. A player's rebind " "goes to the override layer, InputMap.rebind(...), which survives every rebuild " "of the context stack; a different set of bindings is a different context." ) object.__setattr__(self, name, value)
[docs] def __repr__(self) -> str: what = f"{self._name!r}, " if self._name else "" if self._hides_all: hidden = ", hides=ALL" else: hidden = f", hides={tuple(sorted(self._hides))!r}" if self._hides else "" return f"InputContext({what}{sorted(self._actions)!r}{hidden})"
[docs] @property def name(self) -> str | None: """What this context is called, or None where it was not named.""" return self._name
[docs] @property def actions(self) -> tuple[str, ...]: """The names this context declares: its actions and its device aliases.""" return tuple(self._actions)
[docs] @property def remaps(self) -> tuple[DeviceControl, ...]: """The engine entries this context redefines while it is live.""" return tuple(self._remaps)
[docs] @property def hides(self) -> frozenset[str] | HideEverything: """What this context takes from below it: names, or :attr:`ALL`.""" return InputContext.ALL if self._hides_all else self._hides
[docs] @property def hides_all(self) -> bool: """Whether this context takes every name below it.""" return self._hides_all
[docs] def sources(self, action: str) -> tuple[Source, ...]: """What this context binds *action* to, empty where it does not declare it.""" entry = self._actions.get(action) return tuple(entry.sources) if entry is not None else ()
[docs] @property def bindings(self) -> Mapping[str, Action]: """The compiled actions, read-only. ``InputMap`` flattens these.""" return MappingProxyType(self._actions)
class _RootContext(InputContext): """The base of every stack: what ``InputMap.add_action`` declares, and never hidden below itself. The one context that is not frozen, because ``InputMap.add_action`` is its imperative spelling and a game is allowed to register an action at any point in a scene's life. Everything a game writes by hand is an ordinary :class:`InputContext`. """ __slots__ = () def __init__(self) -> None: super().__init__(name="tree") def _set(self, action: Action) -> None: self._actions[str(action.name)] = action _DECLARED_NAMES.add(str(action.name)) def _drop(self, name: str) -> None: self._actions.pop(name, None) def _clear(self) -> None: self._actions.clear()
[docs] def remap_entry(control: DeviceControl, sources: list[SourceSpec]) -> Action: """A redefinition of the engine entry *control*: validated, with its one control as its source. An engine entry names a device control, so what it is redefined to is one control of the same kind: a button for a button, a whole axis for a whole axis, a raw index (``Controller.button(1)``) or another engine entry (``Controller.B``). A key is raw and has no entry, an index form is raw, and a unit, a half or a motion belongs on the binding that uses the entry. """ from .events import InputBinding if control.device is DeviceType.KEYBOARD: raise TypeError( f"{control!r} is a key, which is a raw control and not an entry: rebind the action that uses it" ) if control.name is None: raise TypeError( f"{control!r} is a raw control and not an entry: rebind the entry that names it, such as the " f"{control.device.value}'s named control, or an action" ) if isinstance(control, DeviceStick): raise TypeError(f"{control!r} is a stick, which is its two axes: redefine {control.x!r} and {control.y!r}") if control.unit is not None or _half_or_motion(control): raise TypeError( f"{control!r}: an engine entry is redefined whole and for every unit; put a unit, a half or " "a motion on the binding that uses it" ) if len(sources) != 1: raise TypeError(f"{control!r} is an engine entry and resolves to exactly one control, got {len(sources)}") (spec,) = sources target = spec.control if isinstance(spec, InputBinding) else spec if isinstance(target, str): raise TypeError( f"{control!r} is an engine entry and resolves to a device control, not to the entry {target!r}: " f"bind {target!r} to {control!r} instead" ) if not isinstance(target, DeviceControl) or type(target) is not type(control): raise TypeError(f"{control!r} resolves to one control of its own kind, got {target!r}") # A role belongs to every pointer type; any other entry stays on its own device type. allowed = _POINTER_TYPES if control.device is DeviceType.POINTER else {control.device} if target.device not in allowed: raise TypeError( f"{control!r} is a {control.device.value} entry, and {target!r} is not a {control.device.value}'s" ) if not isinstance(target, DeviceButton | DeviceAxis) or target.unit is not None or _half_or_motion(target): raise TypeError(f"{control!r} resolves to a whole control on every unit, got {target!r}") return Action(control, sources=[InputBinding(target)])
def _half_or_motion(control: DeviceControl) -> bool: """Whether *control* is one half of an axis or its motion rather than the whole of it.""" return isinstance(control, DeviceAxis) and (control.half != 0 or control.is_delta)