"""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