diff --git a/evidence/viewport-subtree-projection-20261004/SANITY.json b/evidence/viewport-subtree-projection-20261004/SANITY.json new file mode 100644 index 0000000000..01b5296e97 --- /dev/null +++ b/evidence/viewport-subtree-projection-20261004/SANITY.json @@ -0,0 +1,14 @@ +{ + "qualification": "native source App and geometry controls only", + "production_head": "9a686b19", + "command": "PYTHONPATH=src python -m pytest -q tests/test_subtree_geometry_cache.py tests/test_viewport_geometry_targets.py tests/test_compositor.py", + "exit_code": 0, + "passed": 26, + "elapsed_seconds": 4.25, + "observed_stdout": ".......................... [100%]\n26 passed in 4.25s\n", + "new_environment": false, + "installed_package_writes": false, + "physical_recording": false, + "provider_inputs": 0, + "remaining": "Affected installed Toad/native qualification under Heisenberg named purpose; no measured CPU/frame-time gain." +} diff --git a/evidence/viewport-subtree-projection-20261004/SCOPE.md b/evidence/viewport-subtree-projection-20261004/SCOPE.md new file mode 100644 index 0000000000..ce9c844618 --- /dev/null +++ b/evidence/viewport-subtree-projection-20261004/SCOPE.md @@ -0,0 +1,38 @@ +# Complete resources and viewport geometry + +The existing `SubtreeGeometry` resource owns complete reusable arrangements. +Its viewport consumer currently publishes every cached descendant before the +compositor sorts and clips that scene. A long live body therefore projects its +offscreen descendants on each scroll even when only a few rows are exposed. + +This change makes the original geometry family own viewport projection for +both restored and newly captured resources. Complete capture, ordinary lazy +full-map queries, explicit reader and interaction targets, overlays, covers and +scrollbars retain their original contracts. No second geometry map, cache or +state owner is introduced. Heisenberg confirmed the Toad contract: body roots +and explicit anchor paths need publication; body capture separately acquires +complete geometry. He owns the affected installed qualification. + +`SubtreeGeometry.project_into` now owns both cheap source-bounds rejection before +clip/rank/placement transformation and exact clipped publication. Placed and +intrinsic resources implement only their original coordinate transforms. The +cache-hit and fresh-capture publication algorithms are replaced by one call; +enclosing complete captures keep complete descendant resources. Generic +`find_widget`/`full_map` and full subtree captures remain complete. + +The one final native batch passed 26 controls in 4.25 seconds using existing +system dependencies. The added actual native App case exercises forward and +reverse scrolling, complete body capture, an offscreen reader target, lazy full +geometry and authored display invalidation. Existing controls cover viewport +targets, scene ordering, clipping, cache retirement and source mutation. See +`SANITY.json` and `owner-consumers.json`; both native source snapshots parse all +249 production modules without omissions, and the Toad context parses 288. + +Complete cached membership is still retained and every resource entry is still +visited. The change removes unnecessary offscreen transforms and downstream +scene publication/sorting; it does not establish CPU dominance or frame-time +gain. The affected installed application remains unqualified. This draft has +no build, installed-package, provider or recording purpose. + +The separate callback arity source defect remains independent; it does not hold +this geometry change or imply a frame-time attribution. diff --git a/evidence/viewport-subtree-projection-20261004/owner-consumers.json b/evidence/viewport-subtree-projection-20261004/owner-consumers.json new file mode 100644 index 0000000000..cdb5543170 --- /dev/null +++ b/evidence/viewport-subtree-projection-20261004/owner-consumers.json @@ -0,0 +1,180 @@ +{ + "scope": "Source declarations and syntactic consumers, not dynamic dispatch proof", + "snapshots": [ + { + "head": "777232c8b960272cb3ddc445df04c9133e6dd6fa", + "root": "src/textual", + "parsed": 249, + "omissions": [], + "declarations": [ + { + "path": "src/textual/_compositor.py", + "class": "SubtreeMapGeometry", + "member": "project", + "line": 172 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "complete_arrangement", + "line": 201 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "capture", + "line": 205 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "restore_into", + "line": 212 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "project_into", + "line": 222 + }, + { + "path": "src/textual/_compositor.py", + "class": "PlacedSubtreeGeometry", + "member": "project_into", + "line": 237 + }, + { + "path": "src/textual/_compositor.py", + "class": "IntrinsicSubtreeGeometry", + "member": "complete_arrangement", + "line": 248 + }, + { + "path": "src/textual/_compositor.py", + "class": "IntrinsicSubtreeGeometry", + "member": "capture", + "line": 252 + }, + { + "path": "src/textual/_compositor.py", + "class": "IntrinsicSubtreeGeometry", + "member": "project_into", + "line": 269 + } + ], + "consumers": [ + { + "path": "src/textual/_compositor.py", + "line": 217, + "callee": "self.project_into" + }, + { + "path": "src/textual/_compositor.py", + "line": 1069, + "callee": "cached.restore_into" + } + ] + }, + { + "head": "9a686b19", + "root": "src/textual", + "parsed": 249, + "omissions": [], + "declarations": [ + { + "path": "src/textual/_compositor.py", + "class": "SubtreeMapGeometry", + "member": "region", + "line": 173 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeMapGeometry", + "member": "project", + "line": 177 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "complete_arrangement", + "line": 206 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "capture", + "line": 210 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "restore_into", + "line": 217 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "project_into", + "line": 227 + }, + { + "path": "src/textual/_compositor.py", + "class": "SubtreeGeometry", + "member": "_project_entry", + "line": 250 + }, + { + "path": "src/textual/_compositor.py", + "class": "PlacedSubtreeGeometry", + "member": "_project_entry", + "line": 265 + }, + { + "path": "src/textual/_compositor.py", + "class": "IntrinsicSubtreeGeometry", + "member": "complete_arrangement", + "line": 275 + }, + { + "path": "src/textual/_compositor.py", + "class": "IntrinsicSubtreeGeometry", + "member": "capture", + "line": 279 + }, + { + "path": "src/textual/_compositor.py", + "class": "IntrinsicSubtreeGeometry", + "member": "_project_entry", + "line": 296 + } + ], + "consumers": [ + { + "path": "src/textual/_compositor.py", + "line": 222, + "callee": "self.project_into" + }, + { + "path": "src/textual/_compositor.py", + "line": 244, + "callee": "self._project_entry" + }, + { + "path": "src/textual/_compositor.py", + "line": 1114, + "callee": "resource.restore_into" + } + ] + } + ], + "ambiguities": [ + "External dynamically installed SubtreeGeometry subclasses/hooks are outside the declared repository roots; no compatibility path added.", + "Syntactic attribute-call matching does not resolve arbitrary dynamic aliases." + ], + "toad_context": { + "parsed": 288, + "omissions": [], + "head": "c9ae0e5f5ac0f7055680674f1dc9bdd597d96f29", + "contract": "Heisenberg confirmed body-root and explicit anchor targets; complete capture separately borrows published root and render_subtree_strips. No Toad source edits." + } +} diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index daabfb4a23..039ffd722d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -169,6 +169,11 @@ class SubtreeMapGeometry(NamedTuple): geometry: MapGeometry clip_bounds: tuple[Region, ...] + @property + def region(self) -> Region: + """The original placement bounds, before any destination projection.""" + return self.geometry.region + def project(self, original: SubtreeGeometryKey, current: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: for bound in self.clip_bounds: @@ -185,7 +190,7 @@ def project(self, original: SubtreeGeometryKey, current: SubtreeGeometryKey, ), clip -GeometryEntry = TypeVar("GeometryEntry") +GeometryEntry = TypeVar("GeometryEntry", MapGeometry, SubtreeMapGeometry) @dataclass(frozen=True) @@ -212,15 +217,38 @@ def matches(self, key: SubtreeGeometryKey) -> bool: def restore_into( self, geometry: CompositorMap, widgets: set[Widget], invisible_widgets: set[Widget], key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], - root: Widget, + root: Widget, *, visible_only: bool, retained: set[Widget], bounds: Region, ) -> None: - self.project_into(geometry, key, clip, clips, root) + self.project_into(geometry, key, clip, clips, root, + visible_only=visible_only, retained=retained, bounds=bounds) widgets.update(self.widgets) invisible_widgets.update(self.invisible_widgets) - @abstractmethod def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, - clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget) -> None: + clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, + *, visible_only: bool, retained: set[Widget], bounds: Region) -> None: + """Publish the requested scene without retiring complete source geometry. + + A viewport needs exposed descendants and its explicit geometry targets. + Complete capture and ordinary full-map acquisition still consume every + entry. Both newly captured and reused resources publish through here. + """ + source_bounds = bounds - (key.region.offset - self.key.region.offset) + for node, entry in self.geometry.items(): + required = not visible_only or node is root or node in retained + # Clip intersection can only reduce these original rectangle bounds. + # Reject an offscreen, unrequired entry before resolving its clip + # chain, descendant rank and destination MapGeometry allocation. + if not required and not entry.region.overlaps(source_bounds): + continue + placement, node_clip = self._project_entry(entry, key, clip, root=node is root) + if required or placement.visible_region.overlaps(bounds): + geometry[node] = placement + clips[node] = node_clip + + @abstractmethod + def _project_entry(self, entry: GeometryEntry, key: SubtreeGeometryKey, + clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: ... def references_retired(self, owner: Widget, retired: set[Widget]) -> bool: @@ -234,10 +262,9 @@ class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]): def matches(self, key: SubtreeGeometryKey) -> bool: return self.key == key - def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, - clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget) -> None: - geometry.update(self.geometry) - clips.update((node, RootSceneClip(entry.clip)) for node, entry in self.geometry.items()) + def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, + clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: + return entry, RootSceneClip(entry.clip) @dataclass(frozen=True) @@ -266,10 +293,9 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ def matches(self, key: SubtreeGeometryKey) -> bool: return self.key.intrinsic() == key.intrinsic() - def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, - clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget) -> None: - for node, entry in self.geometry.items(): - geometry[node], clips[node] = entry.project(self.key, key, clip, root=node is root) + def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey, + clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: + return entry.project(self.key, key, clip, root=root) class CompositorUpdate: @@ -1053,6 +1079,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible complete, inherited_layers) # noqa: F821 -- closure cleared after traversal return resource_type = widget.subtree_geometry_resource() + enclosing_complete = complete complete = resource_type.complete_arrangement(complete) # Retention requires an uncropped path only for partial resources. # Complete native arrangements already own every descendant box. @@ -1064,34 +1091,29 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible virtual_region, region, order, layer_order, clip.region, visible, dock_gutter, size, visible_only and not complete, tuple(inherited_layers.items()) if inherited_layers is not None else ()) - cached = self._subtree_geometry.get(widget) - if cached is not None and cached.matches(key): - cached.restore_into(map, widgets, invisible_widgets, key, clip, clips, widget._render_widget) - return - # A body needs its complete native arrangement to reveal new rows. - # A scroll-owning viewport changes its own arrangement inputs and - # must continue culling; it cannot demand the entire prepared buffer - # merely to retain an exact-coordinate scene. - # Construct only this native subtree, then publish it into its - # enclosing scene. Copying and subtracting the already-built whole - # scene for each body makes admission depend on unrelated bodies. - parent_map, parent_widgets, parent_invisible = map, widgets, invisible_widgets - map, widgets, invisible_widgets = {}, set(), set() - try: - arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, - complete, inherited_layers) # noqa: F821 -- closure cleared after traversal - geometry = map - added_widgets, added_invisible = frozenset(widgets), frozenset(invisible_widgets) - finally: - map, widgets, invisible_widgets = parent_map, parent_widgets, parent_invisible - map.update(geometry) - widgets.update(added_widgets) - invisible_widgets.update(added_invisible) - if (widget not in self._subtree_geometry - and len(self._subtree_geometry) >= self.max_subtree_geometry_entries): - self._subtree_geometry.pop(next(iter(self._subtree_geometry))) - self._subtree_geometry[widget] = resource_type.capture( - key, geometry, added_widgets, added_invisible, clips, clip, screen_coordinates) + resource = self._subtree_geometry.get(widget) + if resource is None or not resource.matches(key): + # Capture complete source geometry before its viewport publication. + # Enclosing complete captures borrow all child entries; ordinary + # viewport publication retains only exposed and required boxes. + parent_map, parent_widgets, parent_invisible = map, widgets, invisible_widgets + map, widgets, invisible_widgets = {}, set(), set() + try: + arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, + complete, inherited_layers) # noqa: F821 -- closure cleared after traversal + geometry = map + added_widgets, added_invisible = frozenset(widgets), frozenset(invisible_widgets) + finally: + map, widgets, invisible_widgets = parent_map, parent_widgets, parent_invisible + if (widget not in self._subtree_geometry + and len(self._subtree_geometry) >= self.max_subtree_geometry_entries): + self._subtree_geometry.pop(next(iter(self._subtree_geometry))) + resource = resource_type.capture( + key, geometry, added_widgets, added_invisible, clips, clip, screen_coordinates) + self._subtree_geometry[widget] = resource + resource.restore_into(map, widgets, invisible_widgets, key, clip, clips, widget._render_widget, + visible_only=visible_only and not enclosing_complete, + retained=retained_paths, bounds=root_geometry.region) # Add top level (root) widget self._arranging = True diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index a06ef598bc..e4fcf33364 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -25,6 +25,56 @@ def compose(self): yield Static("second line " * 5) +async def test_complete_cached_body_keeps_capture_and_explicit_reader_geometry(): + class CachedBody(VerticalGroup): + CACHE_SUBTREE_GEOMETRY = True + + rows = [Static(f"Original row {index}", id=f"cached-row-{index}") + for index in range(40)] + body = CachedBody(*rows, id="cached-body") + + class CachedScreen(TargetedScreen): + def compose(self): + with VerticalScroll(id="history"): + yield body + + app = App() + async with app.run_test(size=(40, 10)) as pilot: + screen = CachedScreen() + screen.targets = (body, rows[35]) + await app.push_screen(screen) + await pilot.pause() + history = screen.query_one("#history", VerticalScroll) + compositor = screen._compositor + resource = compositor._subtree_geometry[body] + assert all(row in resource.geometry for row in rows) + + for position in (0, 20, 0): + history.scroll_to(y=position, immediate=True, animate=False) + screen._refresh_layout(app.size, scroll=True) + viewport = compositor._visible_map + assert body in viewport and rows[35] in viewport + omitted = rows[20] if position == 0 else rows[0] + assert omitted not in viewport + assert all(row in resource.geometry for row in rows) + # Capture remains complete without replacing the published viewport. + _, placement = next(compositor.published_geometry((body,))) + size, strips = compositor.render_subtree_strips(body, placement) + assert size.height == 40 + assert all(f"Original row {index}" in strip.text + for index, strip in enumerate(strips)) + assert compositor._visible_map is viewport + + # Ordinary position queries still acquire their complete original scene. + assert compositor.find_widget(rows[20]).region.height == 1 + assert all(row in compositor._full_map for row in rows) + rows[5].display = False + await pilot.pause() + screen._refresh_layout(app.size, scroll=True) + assert rows[5] not in compositor._subtree_geometry[body].geometry + assert rows[35] in compositor._visible_map + + async def test_offscreen_targets_match_full_geometry_without_full_tree_traversal(): app = App() async with app.run_test(size=(80, 25)) as pilot: