"""The clip + transform scope table (design §2.5).
Today, clip/transform nesting is implicit in walk recursion plus push/pop
stacks. Once items live in one flat sorted list the recursion is gone, so the
nesting must be *encoded*. This module is that encoding (the model, §2.5 -- not
yet wired to a real walk):
- Each ``push_clip``/``pop_clip`` (and the synthesised per-child Control wrap,
§2.5) opens/closes a :class:`ClipScope`. A scope's effective scissor is the
**intersection of its own rect with its parent's effective scissor**, computed
once at open time and stored with a parent pointer.
- Every Item records the :class:`ClipScopeId` active at emission. After sort,
the scissor is read straight off the item's scope -- no draw-time clip stack.
- A scope also carries a ``transform`` id so the Control walk's implicit
per-child translate (§2.5) is reproduced as a scope entry rather than baked.
The hard invariant (§2.5): clipping is a *filter*, never an ordering input. A
scope id travels with its item through the sort; banding never moves an item
across a clip boundary. So the table only needs to reproduce, for any item, the
exact effective scissor + transform its recursive push/pop would have produced.
"""
from __future__ import annotations
from typing import NamedTuple
__all__ = ["ROOT_CLIP_SCOPE", "ClipScope", "ClipScopeTable"]
# A 2D clip rect ``(x, y, w, h)`` in integer pixels, matching the legacy
# ``Op.clip`` tuple shape. ``None`` means "no clip" (unbounded scissor).
Rect = tuple[int, int, int, int]
ROOT_CLIP_SCOPE = 0 # the always-present unclipped root scope id
[docs]
class ClipScope(NamedTuple):
"""One entry in the :class:`ClipScopeTable` (design §2.5).
``scissor`` is the effective rect (already intersected up the parent chain),
so a batch sets the scissor straight from ``scissor`` with no walk. ``None``
means unbounded. ``transform`` is an index into the per-item transform
column (-1 = inherit parent / identity), reproducing the Control per-child
translate that the walk synthesises.
"""
parent: int # parent scope id (-1 for the root)
scissor: Rect | None # effective (intersected) scissor rect
transform: int # transform-column index, or -1
def _intersect(a: Rect | None, b: Rect | None) -> Rect | None:
"""Return the rectangle intersection, or ``None`` if either is unbounded.
Mirrors the legacy clip-stack semantics: a child clip is the intersection of
its rect with the active clip. A degenerate (empty) intersection is returned
as a zero-area rect so it still clips everything (nothing leaks).
"""
if a is None:
return b
if b is None:
return a
ax, ay, aw, ah = a
bx, by, bw, bh = b
x0 = max(ax, bx)
y0 = max(ay, by)
x1 = min(ax + aw, bx + bw)
y1 = min(ay + ah, by + bh)
return (x0, y0, max(0, x1 - x0), max(0, y1 - y0))
[docs]
class ClipScopeTable:
"""Builds + stores nested clip/transform scopes for a flat item list (§2.5).
Usage models the collection pass: maintain an open-scope cursor, and
:meth:`push` / :meth:`pop` around clipped subtrees (or call :meth:`open`
directly with an explicit parent). Items record :attr:`current` at emission.
After collection the table is a flat array indexed by ``ClipScopeId``; the
effective scissor + transform are read with O(1) :meth:`scissor` /
:meth:`get`.
The root scope (id :data:`ROOT_CLIP_SCOPE`) is always present, unclipped,
identity transform.
"""
__slots__ = ("_scopes", "_stack")
def __init__(self) -> None:
# Scope 0 is the unclipped, identity-transform root.
self._scopes: list[ClipScope] = [ClipScope(parent=-1, scissor=None, transform=-1)]
self._stack: list[int] = [ROOT_CLIP_SCOPE]
[docs]
def __len__(self) -> int:
return len(self._scopes)
[docs]
@property
def current(self) -> int:
"""The currently-open scope id (what an emitted item would record)."""
return self._stack[-1]
[docs]
def open(self, parent: int, clip: Rect | None = None, transform: int = -1) -> int:
"""Create a child scope under ``parent`` and return its id.
The new scope's effective scissor is the intersection of ``clip`` with
``parent``'s effective scissor (§2.5), so nesting is resolved once at
open time. Does not change :attr:`current` -- use :meth:`push` for the
stack-style collection cursor.
"""
if not 0 <= parent < len(self._scopes):
raise IndexError(f"clip-scope parent {parent} out of range")
effective = _intersect(self._scopes[parent].scissor, clip)
scope_id = len(self._scopes)
self._scopes.append(ClipScope(parent=parent, scissor=effective, transform=transform))
return scope_id
[docs]
def push(self, clip: Rect | None = None, transform: int = -1) -> int:
"""Open a child scope under :attr:`current`, make it current, return id."""
scope_id = self.open(self.current, clip, transform)
self._stack.append(scope_id)
return scope_id
[docs]
def pop(self) -> int:
"""Close the current scope, restoring its parent as current; returns the
scope that was just closed (it remains stored in the table)."""
if len(self._stack) == 1:
raise IndexError("cannot pop the root clip scope")
return self._stack.pop()
[docs]
def get(self, scope_id: int) -> ClipScope:
"""Return the :class:`ClipScope` record for ``scope_id``."""
if not 0 <= scope_id < len(self._scopes):
raise IndexError(f"clip-scope id {scope_id} out of range")
return self._scopes[scope_id]
[docs]
def scissor(self, scope_id: int) -> Rect | None:
"""Return the effective (intersected) scissor for ``scope_id`` (§2.5)."""
return self.get(scope_id).scissor
[docs]
def ancestry(self, scope_id: int) -> list[int]:
"""Return ``[scope_id, parent, ..., root]`` -- the nesting chain (§2.5).
Proves the flat table reproduces push/pop nesting: walking parent
pointers recovers exactly the recursive scope stack that was open when
the scope was created.
"""
chain: list[int] = []
cur = scope_id
while cur != -1:
chain.append(cur)
cur = self.get(cur).parent
return chain