Source code for simvx.core.decorators

"""Lifecycle hook decorators for Node subclasses.

A node's per-frame, lifecycle, and input behaviour is defined by methods
named ``on_ready``, ``on_process``, ``on_physics_process``, ``on_input``,
``on_unhandled_input``, ``on_enter_tree``, ``on_exit_tree``, ``on_draw``,
and ``on_picked``. Subclasses override these directly to define one
"primary" handler per hook.

The decorators in this module mark *additional* methods (with arbitrary
names) as extra handlers for the same hooks, allowing concerns to be
split across methods without forcing one monolithic override::

    class Player(Node):
        def on_process(self, dt):           # primary override (always fires first)
            self.position += self.velocity * dt

        @on_process
        def update_animation(self, dt):     # additional handler (fires after override)
            self._frame += dt * self.fps

        @on_input(action="jump")            # filtered input handler
        def jump(self, event):
            self.velocity.y = -300
            return True                     # truthy return consumes the event

        @on_input(key=Key.S, ctrl=True)     # raw key with modifiers
        def save(self, event):
            self.tree.save_game()

The decorators are bare-or-called: ``@on_process`` and ``@on_process()``
are both valid. ``@on_input(...)`` requires the call form when filters
are passed; bare ``@on_input`` is the all-events catch-all.

All hook collection happens once in :meth:`Node.__init_subclass__`:
runtime dispatch is a tuple iteration, never a name lookup.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from typing import Any, Protocol, cast

from .input.enums import JoyAxis, JoyButton, Key, MouseButton

_InputFilter = dict[str, Any]


class _HookedCallable(Protocol):
    """A callable stamped with a lifecycle-hook marker by the ``@on_<hook>`` decorators."""

    _simvx_hook: str

    def __call__(self, *args: Any, **kwargs: Any) -> Any: ...


class _InputHookedCallable(_HookedCallable, Protocol):
    """A callable stamped as an input handler, carrying its accumulated filter list."""

    _simvx_input_filters: list[_InputFilter]

_LIFECYCLE_HOOK_NAMES = frozenset({
    "ready", "process", "physics_process",
    "enter_tree", "exit_tree", "draw", "picked",
    "unhandled_input",
})

# (kind, target_or_None, released, ctrl, shift, alt, meta)
# - released: True/False; ignored for motion/scroll
# - ctrl/shift/alt/meta: True (must be pressed), False (must not), None (don't care)


def _make_simple(hook_name: str) -> Callable[..., Any]:
    """Build a `@on_<hook>` decorator supporting bare and called forms."""
    def decorator(*args: Any, **kwargs: Any) -> Any:
        if kwargs:
            raise TypeError(f"on_{hook_name} takes no keyword arguments")
        if len(args) == 1 and callable(args[0]):
            cast(_HookedCallable, args[0])._simvx_hook = hook_name
            return args[0]
        if len(args) == 0:
            def wrap(fn: Callable[..., Any]) -> Callable[..., Any]:
                cast(_HookedCallable, fn)._simvx_hook = hook_name
                return fn
            return wrap
        raise TypeError(
            f"on_{hook_name} must decorate a function (use @on_{hook_name} or @on_{hook_name}())"
        )
    decorator.__name__ = f"on_{hook_name}"
    decorator.__qualname__ = f"on_{hook_name}"
    return decorator


on_ready = _make_simple("ready")
on_process = _make_simple("process")
on_physics_process = _make_simple("physics_process")
on_enter_tree = _make_simple("enter_tree")
on_exit_tree = _make_simple("exit_tree")
on_draw = _make_simple("draw")
on_picked = _make_simple("picked")
on_unhandled_input = _make_simple("unhandled_input")


# ---------------------------------------------------------------------------
# @on_input: unified filter decorator
# ---------------------------------------------------------------------------

_VALID_INPUT_KWARGS = frozenset({
    "action", "key", "button", "motion", "scroll", "joy_button", "joy_axis",
    "released", "ctrl", "shift", "alt", "meta",
})

_FILTER_KIND_KWARGS = ("action", "key", "button", "motion", "scroll", "joy_button", "joy_axis")


def _normalise_target(kind: str, value: Any) -> Any:
    """Validate and normalise the target of an input filter."""
    if kind == "action":
        if not isinstance(value, str) or not value:
            raise TypeError(f"@on_input(action=...) requires a non-empty string, got {value!r}")
        return value
    if kind == "key":
        if isinstance(value, Key):
            return (value,)
        if isinstance(value, tuple | list):
            keys = tuple(value)
            if not keys or not all(isinstance(k, Key) for k in keys):
                raise TypeError(
                    "@on_input(key=...) tuple must contain at least one Key enum value"
                )
            return keys
        raise TypeError(f"@on_input(key=...) requires a Key or tuple of Keys, got {value!r}")
    if kind == "button":
        if not isinstance(value, MouseButton):
            raise TypeError(f"@on_input(button=...) requires a MouseButton enum, got {value!r}")
        return value
    if kind == "joy_button":
        if not isinstance(value, JoyButton):
            raise TypeError(f"@on_input(joy_button=...) requires a JoyButton enum, got {value!r}")
        return value
    if kind == "joy_axis":
        if not isinstance(value, JoyAxis):
            raise TypeError(f"@on_input(joy_axis=...) requires a JoyAxis enum, got {value!r}")
        return value
    return value


def _classify_input_filter(kwargs: dict[str, Any]) -> dict[str, Any]:
    """Validate kwargs and return a normalised filter dict.

    Filter kinds are mutually exclusive: only one of ``action``, ``key``,
    ``button``, ``motion``, ``scroll``, ``joy_button``, ``joy_axis`` may be
    set per decoration.
    """
    unknown = set(kwargs) - _VALID_INPUT_KWARGS
    if unknown:
        raise TypeError(f"@on_input got unexpected kwargs: {sorted(unknown)}")

    kinds_set = [k for k in _FILTER_KIND_KWARGS if k in kwargs]
    if len(kinds_set) > 1:
        raise TypeError(
            f"@on_input filters are mutually exclusive; got {kinds_set}. "
            f"Stack multiple decorators for multiple bindings."
        )

    if not kinds_set:
        # Bare-equivalent: catch-all
        kind = "catch_all"
        target: Any = None
    else:
        kind = kinds_set[0]
        target = _normalise_target(kind, kwargs[kind])

    if kind == "motion":
        if kwargs.get("motion") is not True:
            raise TypeError("@on_input(motion=True) is the only valid motion form")
        target = None
    elif kind == "scroll":
        if kwargs.get("scroll") is not True:
            raise TypeError("@on_input(scroll=True) is the only valid scroll form")
        target = None

    released = bool(kwargs.get("released", False))
    ctrl = kwargs.get("ctrl", False)
    shift = kwargs.get("shift", False)
    alt = kwargs.get("alt", False)
    meta = kwargs.get("meta", False)

    # Validate modifier types: bool or None.
    for name, val in (("ctrl", ctrl), ("shift", shift), ("alt", alt), ("meta", meta)):
        if val is not None and not isinstance(val, bool):
            raise TypeError(f"@on_input({name}=...) must be True, False, or None; got {val!r}")

    if kind in ("motion", "scroll", "joy_axis") and any(
        kwargs.get(m) is True for m in ("ctrl", "shift", "alt", "meta")
    ):
        raise TypeError(
            f"@on_input({kind}=...) does not support modifier filters"
        )

    return {
        "kind": kind,
        "target": target,
        "released": released,
        "mods": (ctrl, shift, alt, meta),
    }


[docs] def on_input(*args: Any, **kwargs: Any) -> Any: """Mark a method as an input event handler with optional filters. Filters (mutually exclusive: pass at most one): action: action name (str). Fires when the input event matches the action's bindings on the active InputMap. key: a :class:`Key` enum value, or a tuple of Keys (any-of match). button: a :class:`MouseButton` enum value (mouse button events). motion: ``True`` to receive mouse-motion events. scroll: ``True`` to receive scroll events. joy_button: a :class:`JoyButton` enum value (gamepad button events). joy_axis: a :class:`JoyAxis` enum value (gamepad axis events). State filter: released: ``True`` matches release events; default ``False`` matches press events. Ignored for motion/scroll/joy_axis. Modifier filters (key/button/joy_button only): ctrl, shift, alt, meta: ``True`` requires the modifier pressed, ``False`` (default) requires it not pressed, ``None`` ignores it. Tuple-of-keys is *any-of*; modifiers AND across the match. Bare ``@on_input`` is the catch-all: fires for every input event, regardless of filter. Use sparingly; prefer specific filters so the SceneTree dispatch tables can route directly. Truthy return value marks the event consumed: subsequent ``on_unhandled_input`` handlers do not fire for this event. """ # Bare: @on_input if len(args) == 1 and callable(args[0]) and not kwargs: return _stamp_input(args[0], _classify_input_filter({})) if args: raise TypeError("@on_input does not take positional arguments; use kwargs") filt = _classify_input_filter(kwargs) def wrap(fn: Callable[..., Any]) -> Callable[..., Any]: return _stamp_input(fn, filt) return wrap
def _stamp_input(fn: Callable[..., Any], filt: _InputFilter) -> Callable[..., Any]: """Attach an input-handler marker to *fn*, supporting stacked decorators.""" stamped = cast(_InputHookedCallable, fn) stamped._simvx_hook = "input" existing: list[_InputFilter] | None = getattr(fn, "_simvx_input_filters", None) if existing is None: stamped._simvx_input_filters = [filt] else: existing.append(filt) return fn
[docs] def collect_hooks( cls: type, primary_methods: Iterable[str] ) -> tuple[dict[str, tuple[str, ...]], tuple[tuple[str, dict[str, Any]], ...]]: """Collect lifecycle and input handlers declared on *cls* and its bases. Walks the MRO most-derived-last (matching ``Property.__set_name__``): - Decorated methods (``@on_process`` etc.) are gathered in declaration order across the MRO. - Same-named primary overrides (``def on_process(self, dt):``) are treated as implicit primary handlers and pinned to index 0 of their bucket: users do not need to apply ``@on_process`` to their own ``on_process`` override for it to fire. Returns ``(hooks, input_handlers)``: hooks: dict mapping hook name (e.g. ``"process"``) to an ordered tuple of method names. The primary override, if present, is always first. input_handlers: tuple of ``(method_name, filter_dict)`` pairs in declaration order across the MRO. """ hooks: dict[str, list[str]] = {} input_handlers: list[tuple[str, dict[str, Any]]] = [] seen_input_methods: set[str] = set() seen_hook_methods: dict[str, set[str]] = {} primary_set = frozenset(primary_methods) for base in reversed(cls.__mro__): if base is object: continue for name, attr in base.__dict__.items(): hook = getattr(attr, "_simvx_hook", None) # Treat same-named primary methods as implicit lifecycle handlers, # even without an explicit decorator. e.g. `def on_process(self, dt)`. if hook is None and name in primary_set and callable(attr): hook = name[3:] # strip the "on_" prefix to get the bucket name if hook is None: continue if hook == "input": if name in seen_input_methods: continue seen_input_methods.add(name) filters: Iterable[_InputFilter] = getattr(attr, "_simvx_input_filters", ()) for filt in filters: input_handlers.append((name, filt)) else: bucket = hooks.setdefault(hook, []) seen = seen_hook_methods.setdefault(hook, set()) if name in seen: continue seen.add(name) bucket.append(name) # Pin primary same-named overrides (on_process, on_ready, ...) to index 0. for primary in primary_methods: if not primary.startswith("on_"): continue hook_name = primary[3:] ordered = hooks.get(hook_name) if not ordered or primary not in ordered: continue if ordered[0] != primary: ordered.remove(primary) ordered.insert(0, primary) frozen_hooks = {k: tuple(v) for k, v in hooks.items()} return frozen_hooks, tuple(input_handlers)
__all__ = [ "on_ready", "on_process", "on_physics_process", "on_input", "on_unhandled_input", "on_enter_tree", "on_exit_tree", "on_draw", "on_picked", "collect_hooks", ]