"""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),
}
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",
]