Source code for simvx.core.ui.marks

"""One canonical answer to: which visible controls are interactive, and where are they on screen.

A single pure-data pass over a scene tree that returns a :class:`ControlMark` per visible
:class:`~simvx.core.ui.core.Control`, carrying its screen-space rect (from ``get_global_rect``)
and the interaction flags derived from the very predicates the engine uses at runtime:

- ``clickable``  -> ``mouse_filter`` truthy and the rect is non-degenerate (matches
  ``UIInputManager._find_control_at_point``: a control receives clicks iff it is visible,
  ``mouse_filter`` is truthy, and the point lands inside its rect).
- ``focusable``  -> ``focus_mode != FocusMode.NONE`` (matches the focus/tab predicates).

No new member is added to ``Control``: every flag is derived from existing state so there is
exactly one source of truth. This module is backend-agnostic (no renderer, no Vulkan, no
``simvx.ai``): the two render adapters that consume it are the in-game ``DebugOverlay`` (Draw2D)
and the AI set-of-marks pixel overlay.

The tree must be laid out for the current frame before calling this: ``get_global_rect`` is
per-frame cached keyed on ``Control._current_frame`` (bumped by ``SceneTree.on_update``), so a
pre-layout call returns stale or zero rects.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from ..node import Node
    from .core import Control

__all__ = ["ControlMark", "enumerate_interactive_controls"]


[docs] @dataclass(frozen=True, slots=True) class ControlMark: """A visible control plus its screen rect and runtime-derived interaction flags.""" control: Control rect: tuple[float, float, float, float] # screen-space (x, y, w, h) from get_global_rect() path: str type_name: str text: str clickable: bool # mouse_filter truthy AND rect non-degenerate (matches the hit-test) focusable: bool # focus_mode != FocusMode.NONE (matches the focus/tab predicate) disabled: bool focused: bool mouse_over: bool
[docs] @property def interactive(self) -> bool: """True when this control can receive mouse clicks or keyboard focus.""" return self.clickable or self.focusable
[docs] def enumerate_interactive_controls(root: Node, *, interactive_only: bool = False) -> list[ControlMark]: """Enumerate visible Controls under ``root`` as screen-space :class:`ControlMark` records. Single DFS pass over ``root.walk(include_self=True)``. A node is included when it is a ``Control`` that is visible in the hierarchy and has a non-degenerate rect; degenerate rects (``w <= 0`` or ``h <= 0``) are skipped silently because that is normal for a 0-sized or anchor/margin-collapsed decorative node (``get_rect`` already warns only on *negative* size). With ``interactive_only=True``, records that are neither ``clickable`` nor ``focusable`` are dropped, leaving only what the mouse and an agent can act on. Note a control is ``clickable`` whenever ``mouse_filter`` is truthy (the engine default), so decorative containers drop out only when the author opts them out of hit-testing (``mouse_filter = False``), exactly matching what the runtime hit-test would route a click to. """ from .core import Control from .enums import FocusMode marks: list[ControlMark] = [] for node in root.walk(include_self=True): if not isinstance(node, Control): continue if not node._visible_in_hierarchy: continue x, y, w, h = node.get_global_rect() if w <= 0 or h <= 0: continue clickable = bool(node.mouse_filter) focusable = node.focus_mode != FocusMode.NONE if interactive_only and not (clickable or focusable): continue marks.append( ControlMark( control=node, rect=(float(x), float(y), float(w), float(h)), path=node.path, type_name=type(node).__name__, text=(getattr(node, "text", "") or "")[:40], clickable=clickable, focusable=focusable, disabled=node.disabled, focused=node.focused, mouse_over=node.mouse_over, ) ) return marks