Source code for simvx.core.ui.overlay

"""The tree-owned overlay layer: an ordered registry of on-top UI overlays.

One :class:`OverlayLayer` per :class:`SceneTree` (``tree.overlays``) is the single
source of truth for "what is on top, in what order, and what it captures". It
replaces the wrong ``render-iff-self-is-on-the-modal-stack`` coupling: layering is
registry insertion order, input scope is derived from the topmost *capturing*
entry, and background inertness is derived from any *inert* entry.

This module lives in **core** and never imports ``simvx.graphics`` at module
level. The single collector :func:`iter_overlay_draws` is consumed identically by
both the immediate :meth:`SceneTree.render` path and the retained item pipeline,
so desktop == web and headless == live hold by construction.
"""

from __future__ import annotations

from collections.abc import Iterator
from dataclasses import dataclass
from typing import Any

__all__ = ["OverlayEntry", "OverlayLayer", "iter_overlay_draws", "DEFAULT_SCRIM_COLOUR"]

# Fallback scrim colour when the active theme defines no ``overlay_scrim`` token.
DEFAULT_SCRIM_COLOUR: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.5)

# Preset -> (capture_input, dim, dismiss_on_outside_click, inert). The ONLY place
# a modality preset becomes flags (design §0 / §3). ``dim`` / ``dismiss`` / ``inert``
# kwargs (None = preset default) override these per call.
_PRESETS: dict[str, tuple[bool, bool, bool, bool]] = {
    "none": (False, False, False, False),
    "light": (True, False, True, False),
    "blocking": (True, True, True, False),
}


[docs] @dataclass(frozen=True, slots=True) class OverlayEntry: """One registered overlay (design §3). Frozen: the registry holds entries by value. ``modality`` is the preset string; the derived per-entry booleans are ``capture_input`` / ``dim`` / ``inert`` / ``dismiss_on_outside_click``. ``owner`` is the logical chain owner (the ``MenuBar`` for a dropdown/submenu chain, else the control itself) and drives whole-chain dismissal. ``viewport`` is the owning render target (``None`` = the main viewport). ``dim_colour`` is resolved from the theme at :meth:`OverlayLayer.open` so the collector needs no theme lookup. ``prev_focus`` is the focus snapshot restored on close. Draw and input order are the registry's list (insertion) order, so there is no seq field. """ control: Any modality: str capture_input: bool dim: bool inert: bool dismiss_on_outside_click: bool dim_colour: tuple[float, float, float, float] owner: Any viewport: Any prev_focus: Any
[docs] @staticmethod def expand_preset( modality: str, *, dim: bool | None = None, dismiss: bool | None = None, inert: bool | None = None, ) -> tuple[bool, bool, bool, bool]: """Resolve ``modality`` to ``(capture_input, dim, dismiss, inert)``. ``dim`` / ``dismiss`` / ``inert`` (``None`` = take the preset default) override individual flags. ``capture_input`` is preset-only. """ try: cap, d_dim, d_dismiss, d_inert = _PRESETS[modality] except KeyError: raise ValueError(f"unknown overlay modality {modality!r}: expected one of {sorted(_PRESETS)}") from None return ( cap, d_dim if dim is None else bool(dim), d_dismiss if dismiss is None else bool(dismiss), d_inert if inert is None else bool(inert), )
[docs] class OverlayLayer: """An ordered registry of open overlays owned by one :class:`SceneTree`. Insertion order == draw order == open-chain order. Empty registry is an O(1) truthiness check (the zero-cost gate on every hot path). """ __slots__ = ("_tree", "_entries") def __init__(self, tree: Any) -> None: self._tree = tree self._entries: list[OverlayEntry] = [] # -- zero-cost emptiness -------------------------------------------------
[docs] def __bool__(self) -> bool: return bool(self._entries)
[docs] def __len__(self) -> int: return len(self._entries)
# -- mutation ------------------------------------------------------------
[docs] def open(self, control: Any, entry: OverlayEntry) -> None: """Register ``entry`` for ``control`` on top of the stack. Snapshots prior focus and focuses the first focusable descendant when the entry is capturing. Bumps ``tree._structure_version`` (a registry mutation is a structural change) and emits ``tree.overlay_opened``. """ self._entries.append(entry) tree = self._tree if entry.capture_input and tree is not None: ui = tree._ui # Non-capturing overlays must NOT clear the world grab; capturing ones do. grab = ui._mouse_grab if grab is not None and not _is_descendant_of(grab, entry.owner): ui._mouse_grab = None first = _first_focusable_descendant(control) if first is not None: tree._set_focused_control(first) if tree is not None: tree._structure_version += 1 tree.overlay_opened(control)
[docs] def close(self, control: Any) -> None: """Pop ``control`` and every entry above it sharing its owner (the chain). ``control`` is ALWAYS popped; entries above it that share its owner are popped too (whole-chain dismissal), while interleaved entries of OTHER owners (e.g. a ``none`` tooltip sitting between a dropdown and its submenu) are left in place. Restores ``prev_focus`` per popped entry (LIFO, top-down) iff the prior-focus control is still in this tree, else clears focus. Bumps ``tree._structure_version`` and emits ``tree.overlay_closed`` per popped control. Idempotent. """ idx = self._index_of(control) if idx is None: return owner = self._entries[idx].owner # Partition: pop ``control`` (idx) plus every higher entry sharing its owner; # keep everything else (lower entries, and interleaved other-owner entries). # A plain index walk handles the non-contiguous case the old contiguous-run # loop stranded: it never breaks early on an other-owner interloper. popped: list[OverlayEntry] = [] keep: list[OverlayEntry] = [] for i, entry in enumerate(self._entries): if i == idx or (i > idx and entry.owner is owner): popped.append(entry) else: keep.append(entry) self._entries = keep tree = self._tree for entry in reversed(popped): # LIFO: highest index (topmost) first # The registry is the source of truth: clear the flag here so a direct # close (router dismissal, _exit_tree) stays consistent with the # Control.close_overlay path. entry.control._is_overlay = False self._restore_focus(entry) if tree is not None: tree.overlay_closed(entry.control) if tree is not None: tree._structure_version += 1
[docs] def chain_base(self, owner: Any) -> Any | None: """Return the lowest (earliest-opened) overlay control whose owner is ``owner``. Closing the base pops the whole owner chain (LIFO), so this is the handle for whole-chain dismissal (an outside click / Escape on a menu chain closes the bar + popup + submenu in one action). """ for entry in self._entries: if entry.owner is owner: return entry.control return None
[docs] def close_all(self) -> None: """Pop every open overlay (used on scene change / teardown).""" while self._entries: self.close(self._entries[-1].control)
# -- queries -------------------------------------------------------------
[docs] def draw_set(self) -> tuple[Any, ...]: """Return every open overlay control in insertion order.""" return tuple(e.control for e in self._entries)
[docs] def topmost_capturing(self) -> Any | None: """Return the topmost capturing overlay control, skipping ``capture=none``.""" for entry in reversed(self._entries): if entry.capture_input: return entry.control return None
[docs] def any_inert(self) -> bool: """True if any open overlay is inert (drives tree pause). Short-circuits empty.""" for entry in self._entries: if entry.inert: return True return False
[docs] def entry_of(self, control: Any) -> OverlayEntry | None: """Return the open entry for ``control``, or ``None``.""" idx = self._index_of(control) return self._entries[idx] if idx is not None else None
[docs] def scope_root_of(self, control: Any) -> Any: """Return the chain owner for ``control`` (chain-aware input scope).""" entry = self.entry_of(control) return entry.owner if entry is not None else control
# -- internals ----------------------------------------------------------- def _index_of(self, control: Any) -> int | None: for i, entry in enumerate(self._entries): if entry.control is control: return i return None def _restore_focus(self, entry: OverlayEntry) -> None: tree = self._tree if tree is None or not entry.capture_input: return prev = entry.prev_focus if prev is not None and getattr(prev, "_tree", None) is tree: tree._set_focused_control(prev) else: tree._set_focused_control(None)
[docs] def iter_overlay_draws(tree: Any, *, viewport: Any = None) -> Iterator[tuple[str, Any, OverlayEntry]]: """Yield ``(kind, control, entry)`` overlay draws for ``viewport`` in open order. ``kind`` is ``"dim"`` (a full-screen scrim drawn behind the overlay) then ``"overlay"`` (the overlay control's subtree). Filtered to entries whose ``entry.viewport is viewport`` (``None`` = the main viewport). The generator is only *entered* inside an ``if tree.overlays`` guard, so it is allocation-free and O(1) when unused. """ for entry in tree.overlays._entries: if entry.viewport is not viewport: continue if entry.dim: yield ("dim", entry.control, entry) yield ("overlay", entry.control, entry)
# --------------------------------------------------------------------------- # Duck-typed helpers (no graphics import; mirror Control internals lightly) # --------------------------------------------------------------------------- def _is_descendant_of(node: Any, ancestor: Any) -> bool: cur = node while cur is not None: if cur is ancestor: return True cur = getattr(cur, "parent", None) return False def _first_focusable_descendant(control: Any) -> Any | None: """First ``FocusMode.ALL`` Control in ``control``'s subtree (descendants preferred).""" from .core import Control result: list[Any] = [] Control._walk_focusable(control, result) for c in result: if c is not control: return c return result[0] if result else None