From b2804bf101caf84b247eeba72c875eebe3a1ddfc Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 10:16:28 -0400 Subject: [PATCH 1/4] Retain child geometry sources instead of flattening nested captures --- src/textual/_compositor.py | 138 +++++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 20 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index e49134edde..9b95b5c695 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -12,7 +12,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from abc import ABC, abstractmethod from bisect import bisect_left, bisect_right from collections import Counter @@ -198,13 +198,35 @@ def project(self, original: SubtreeGeometryKey, current: SubtreeGeometryKey, GeometryEntry = TypeVar("GeometryEntry", MapGeometry, SubtreeMapGeometry) +@dataclass(frozen=True) +class SubtreeGeometryPlacement: + """A borrowed child source at its original acquired parent placement. + + The source retains its own geometry and lifetime. This edge owns only the + placement, clip declaration and captured parent in the containing source. + """ + + source: SubtreeGeometry + key: SubtreeGeometryKey + clip: SceneClip + parent: Widget | None = None + clip_bounds: tuple[Region, ...] = () + + @property + def region(self) -> Region: + """All child bounds, including overflow outside the root rectangle.""" + return self.source._spatial_map.total_region + ( + self.key.region.offset - self.source.key.region.offset + ) + + @dataclass(frozen=True) class SubtreeGeometry(ABC, Generic[GeometryEntry]): """One immutable native arrangement resource in the compositor's cache.""" key: SubtreeGeometryKey - geometry: Mapping[Widget, tuple[int, GeometryEntry]] - """Original source ordinals and placements, indexed by their native widget.""" + geometry: Mapping[Widget, tuple[int, GeometryEntry | SubtreeGeometryPlacement]] + """Ordered local placements and borrowed child sources, without flattening.""" widgets: frozenset[Widget] invisible_widgets: frozenset[Widget] @@ -219,6 +241,29 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ for ordinal, (node, entry) in enumerate(geometry.items()) }), widgets, invisible_widgets) + @staticmethod + def _contains_geometry(geometry, node: Widget) -> bool: + if node is None: + return False + return node in geometry or any( + isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) + for entry in geometry.values() + ) + + def contains(self, node: Widget) -> bool: + """Membership in this complete source, including borrowed children.""" + return node in self.geometry or any( + isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) + for _, entry in self.geometry.values() + ) + + def members(self, *, invisible: bool = False) -> Iterator[Widget]: + """Logical membership stays with each original source.""" + yield from self.invisible_widgets if invisible else self.widgets + for _, entry in self.geometry.values(): + if isinstance(entry, SubtreeGeometryPlacement): + yield from entry.source.members(invisible=invisible) + @cached_property def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: """Derive spatial admission from this immutable arrangement, once.""" @@ -243,8 +288,8 @@ def restore_into( self.project_into(geometry, key, clip, clips, root, visible_only=visible_only, retained=retained, bounds=bounds, source_held=source_held) - widgets.update(self.widgets) - invisible_widgets.update(self.invisible_widgets) + widgets.update(self.members()) + invisible_widgets.update(self.members(invisible=True)) def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, @@ -262,11 +307,26 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, for node in (root, *retained): if (indexed := self.geometry.get(node)) is not None: candidates[indexed[0]] = node + elif node in retained: + # Usually retained_paths includes the child root already. + # A held source may have a different captured ancestry. + for child, (ordinal, entry) in self.geometry.items(): + if (isinstance(entry, SubtreeGeometryPlacement) + and entry.source.contains(node)): + candidates[ordinal] = child entries = ((node, self.geometry[node][1]) for _, node in sorted(candidates.items())) else: entries = ((node, entry) for node, (_, entry) in self.geometry.items()) for node, entry in self._paint_entries(entries, source_held=source_held): + if isinstance(entry, SubtreeGeometryPlacement): + child_key, child_clip = self._project_child(entry, key, clip) + entry.source.project_into( + geometry, child_key, child_clip, clips, node, + visible_only=visible_only, retained=retained, bounds=bounds, + source_held=source_held, + ) + continue 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 @@ -282,6 +342,11 @@ def _paint_entries(self, entries, *, source_held: bool): """Placed sources retain their original native participation.""" return entries + def _project_child(self, entry: SubtreeGeometryPlacement, key: SubtreeGeometryKey, + clip: SceneClip) -> tuple[SubtreeGeometryKey, SceneClip]: + """Opaque sources retain the original exact child placement.""" + return entry.key, entry.clip + @abstractmethod def _project_entry(self, entry: GeometryEntry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: @@ -290,7 +355,10 @@ def _project_entry(self, entry: GeometryEntry, key: SubtreeGeometryKey, def references_retired(self, owner: Widget, retired: set[Widget]) -> bool: return (owner in retired or not self.geometry.keys().isdisjoint(retired) or not self.widgets.isdisjoint(retired) - or not self.invisible_widgets.isdisjoint(retired)) + or not self.invisible_widgets.isdisjoint(retired) + or any(isinstance(entry, SubtreeGeometryPlacement) + and entry.source.references_retired(node, retired) + for node, (_, entry) in self.geometry.items())) @dataclass(frozen=True) @@ -310,7 +378,6 @@ def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: return entry, RootSceneClip(entry.clip) - @dataclass(frozen=True) class IntrinsicSubtreeGeometry(SubtreeGeometry[SubtreeMapGeometry]): """The same bounded resource, reusable under a new original outer clip.""" @@ -327,14 +394,23 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ intrinsic = {} origin = key.region.offset for node, entry in geometry.items(): - scope, bounds = clips[node].relative_bounds(clip, origin) + child = isinstance(entry, SubtreeGeometryPlacement) + scope, bounds = (entry.clip if child else clips[node]).relative_bounds(clip, origin) + if child and not isinstance(entry.source, IntrinsicSubtreeGeometry): + # A child whose source retains exact screen placement cannot + # authorize translation of its containing resource. + return PlacedSubtreeGeometry.capture( + key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) if scope is not clip: return PlacedSubtreeGeometry.capture( key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) parent = node.parent parent = parent._render_widget if isinstance(parent, Widget) else None - intrinsic[node] = SubtreeMapGeometry( - entry, bounds, parent if parent in geometry and parent is not node else None, + parent = (parent if parent is not node and cls._contains_geometry(geometry, parent) + else None) + intrinsic[node] = ( + replace(entry, parent=parent, clip_bounds=bounds) if child + else SubtreeMapGeometry(entry, bounds, parent) ) return super().capture(key, intrinsic, widgets, invisible_widgets, clips, clip, screen_coordinates) @@ -353,6 +429,17 @@ def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: return entry.project(self.key, key, clip, root=root) + def _project_child(self, entry, key, clip): + for bound in entry.clip_bounds: + clip = clip.intersect(bound + key.region.offset) + offset = key.region.offset - self.key.region.offset + return entry.key._replace( + region=entry.key.region + offset, + order=self.key.project_order(entry.key.order, key), + layer_order=entry.key.layer_order + key.layer_order - self.key.layer_order, + clip=clip.region, + ), clip + def _paint_entries(self, entries, *, source_held: bool): if not source_held: return entries @@ -928,7 +1015,8 @@ def _arrange_root( held[owner][node] = geometry for owner in mutation_paths - mutation_roots: self._subtree_geometry.pop(owner, None) - map: CompositorMap = {} + map: dict[Widget, MapGeometry | SubtreeGeometryPlacement] = {} + capturing_source = False # Transient declaration paths manufacture the one retained resource. # The published MapGeometry continues to own the actual clipped scene. clips: dict[Widget, SceneClip] = {} @@ -1187,7 +1275,7 @@ def arrange_widget( def add_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, complete, inherited_layers): - nonlocal map, widgets, invisible_widgets + nonlocal map, widgets, invisible_widgets, capturing_source if inherited_layers is None and (order_names := widget._get_layer_order((widget,))) is not None: inherited_layers = {name: index for index, name in enumerate(order_names)} if widget in held: @@ -1197,7 +1285,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible visible, dock_gutter, size, False, tuple(inherited_layers.items()) if inherited_layers is not None else (), ) - if resource is not None and widget._render_widget in resource.geometry and resource.matches( + if resource is not None and resource.contains(widget._render_widget) and resource.matches( key, require_complete=True, source_held=True, ): # Complete intrinsic geometry retains original descendants @@ -1212,8 +1300,9 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible # The complete resource has replaced these old viewport # entries, including nested holds. Do not merge them back # after projection and resurrect stale absolute placement. - for owner in held.keys() & resource.widgets: - held[owner].clear() + for owner in held: + if resource.contains(owner): + held[owner].clear() else: # Placed, resized or missing sources cannot authorize a # projection. Keep their exact committed hit/paint bounds. @@ -1247,8 +1336,11 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible # 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 + parent_map, parent_widgets, parent_invisible, parent_capture = ( + map, widgets, invisible_widgets, capturing_source + ) map, widgets, invisible_widgets = {}, set(), set() + capturing_source = True try: arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, complete, inherited_layers) # noqa: F821 -- closure cleared after traversal @@ -1256,15 +1348,21 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible added_widgets, added_invisible = frozenset(widgets), frozenset(invisible_widgets) finally: map, widgets, invisible_widgets = parent_map, parent_widgets, parent_invisible + capturing_source = parent_capture 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) + if capturing_source: + # Retain the child source and its placement. A containing + # capture does not consume a second flat copy of descendants. + map[widget._render_widget] = SubtreeGeometryPlacement(resource, key, clip) + else: + 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 previous_layout_geometry = self._layout_geometry @@ -1298,7 +1396,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible map.update(geometry) widgets.update(geometry) widgets -= invisible_widgets - return map, widgets + return cast(CompositorMap, map), widgets @property def layers(self) -> list[tuple[Widget, MapGeometry]]: From 58cce5c71b8e8b7fb12af0d574c81523ccbe31d1 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 10:28:05 -0400 Subject: [PATCH 2/4] Preserve required reader and held ancestry semantics across child sources --- src/textual/_compositor.py | 30 +++++--- tests/test_subtree_geometry_cache.py | 95 +++++++++++++++++++++++++ tests/test_viewport_geometry_targets.py | 12 ++-- 3 files changed, 121 insertions(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 9b95b5c695..4bd061f12e 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -242,21 +242,31 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ }), widgets, invisible_widgets) @staticmethod - def _contains_geometry(geometry, node: Widget) -> bool: + def _contains_geometry(geometry, entries, node: Widget) -> bool: if node is None: return False return node in geometry or any( isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) - for entry in geometry.values() + for entry in entries ) def contains(self, node: Widget) -> bool: """Membership in this complete source, including borrowed children.""" - return node in self.geometry or any( - isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node) - for _, entry in self.geometry.values() + return self._contains_geometry( + self.geometry, (entry for _, entry in self.geometry.values()), node ) + def captured_parent(self, node: Widget) -> Widget | None: + """The original parent edge, independent of the live child tree.""" + if (indexed := self.geometry.get(node)) is not None: + entry = indexed[1] + return (entry.parent if isinstance(entry, (SubtreeMapGeometry, SubtreeGeometryPlacement)) + else None) + for _, entry in self.geometry.values(): + if isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node): + return entry.source.captured_parent(node) + raise errors.NoWidget("Widget is not in captured subtree") + def members(self, *, invisible: bool = False) -> Iterator[Widget]: """Logical membership stays with each original source.""" yield from self.invisible_widgets if invisible else self.widgets @@ -294,7 +304,7 @@ def restore_into( def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, *, visible_only: bool, retained: set[Widget], bounds: Region, - source_held: bool = False) -> None: + source_held: bool = False, require_root: bool = True) -> None: """Publish the requested scene without retiring complete source geometry. A viewport needs exposed descendants and its explicit geometry targets. @@ -319,15 +329,15 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, else: entries = ((node, entry) for node, (_, entry) in self.geometry.items()) for node, entry in self._paint_entries(entries, source_held=source_held): + required = not visible_only or (node is root and require_root) or node in retained if isinstance(entry, SubtreeGeometryPlacement): child_key, child_clip = self._project_child(entry, key, clip) entry.source.project_into( geometry, child_key, child_clip, clips, node, visible_only=visible_only, retained=retained, bounds=bounds, - source_held=source_held, + source_held=source_held, require_root=required, ) continue - 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. @@ -406,7 +416,7 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) parent = node.parent parent = parent._render_widget if isinstance(parent, Widget) else None - parent = (parent if parent is not node and cls._contains_geometry(geometry, parent) + parent = (parent if parent is not node and cls._contains_geometry(geometry, geometry.values(), parent) else None) intrinsic[node] = ( replace(entry, parent=parent, clip_bounds=bounds) if child @@ -447,7 +457,7 @@ def _paint_entries(self, entries, *, source_held: bool): def participates(node: Widget) -> bool: if node not in participation: - parent = self.geometry[node][1].parent + parent = self.captured_parent(node) participation[node] = node._render_widget is node and ( parent is None or (parent.is_container and participates(parent)) ) diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index 603db7e9e4..fcd78feaae 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -52,6 +52,101 @@ async def test_unchanged_subtree_reuses_geometry_and_nested_changes_invalidate_i assert not compositor._subtree_geometry +async def test_changed_parent_borrows_original_children_without_flat_capture(): + from textual._compositor import SubtreeGeometryPlacement + from textual.containers import VerticalScroll + from textual.screen import Screen + + rows = [CachedGroup(Static(f"row {index}"), Static("wrapped " * 7)) + for index in range(20)] + history = CachedGroup(*rows) + + class SourceScreen(Screen): + def compose(self): + yield VerticalScroll(history) + + def _layout_mutation_roots(self): + return (history,) if history.lock.is_locked else () + + def _prepare_compositor_refresh(self): + return self._layout_mutation_roots() + + class SourceApp(App): + CSS = "VerticalScroll { height: 10; } Static { height: auto; }" + + def get_default_screen(self): + return SourceScreen() + + app = SourceApp() + async with app.run_test(size=(30, 12)) as pilot: + await pilot.pause() + compositor = app.screen._compositor + original = compositor._subtree_geometry[history] + stable = rows[5] + stable_source = original.geometry[stable][1] + assert isinstance(stable_source, SubtreeGeometryPlacement) + child = stable.children[1] + assert child not in original.geometry + assert original.contains(child) + assert original.captured_parent(child) is stable + + rows[0].children[0].update("changed\nheight\nthree") + inserted = CachedGroup(Static("new row"), Static("new second child")) + await history.mount(inserted, before=rows[1]) + await pilot.pause() + current = compositor._subtree_geometry[history] + assert current is not original + assert current.geometry[stable][1].source is stable_source.source + assert len(current.geometry) == len(history.children) + 1 + assert child not in current.geometry + assert current.contains(child) + scroll = app.query_one(VerticalScroll) + expected = {} + for position in (0, 18, 3, 55): + scroll.scroll_to(y=position, animate=False, immediate=True) + await pilot.pause() + compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + reference = Compositor(max_subtree_geometry_entries=0) + reference.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + demanded = set(child.walk_ancestors(with_self=True)) + # Cached complete sources may retain extra empty clipped boxes. + # Visible paint and every explicitly required reader must agree. + def required_scene(source): + return {node: geometry for node, geometry in source._published_map.items() + if geometry.visible_region or node in demanded} + assert required_scene(compositor) == required_scene(reference) + expected[position] = required_scene(reference) + # Complete source membership includes offscreen descendants; + # uncached visible traversal intentionally does not visit them. + assert compositor.widgets == reference._arrange_root( + app.screen, app.size, visible_only=False + )[1] + assert compositor.find_widget(child) == reference.find_widget(child) + held_source = compositor._subtree_geometry[history] + async with history.lock: + incomplete = CachedGroup(Static("NOT_COMMITTED")) + await history.mount(incomplete) + await pilot.pause() + for position in (3, 18, 55, 0): + scroll.scroll_to(y=position, animate=False, immediate=True) + await pilot.pause() + compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + assert compositor._subtree_geometry[history] is held_source + assert not held_source.contains(incomplete) + assert incomplete not in compositor._published_map + assert required_scene(compositor) == expected[position] + await incomplete.remove() + await pilot.pause() + compositor.reflow(app.screen, app.size) + reference.reflow(app.screen, app.size) + assert compositor._published_map == reference._published_map + await stable.remove() + await pilot.pause() + assert stable not in compositor._subtree_geometry + assert not any(source.contains(child) for source in compositor._subtree_geometry.values()) + assert child not in compositor._published_map + + @pytest.mark.parametrize("capacity", [-1, True, 1.5, None]) def test_geometry_budget_rejects_invalid_capacities(capacity): with pytest.raises(ValueError, match="non-negative integer"): diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index 8dceadfa67..94d6339222 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -106,7 +106,7 @@ def compose(self): compositor = screen._compositor resource = compositor._subtree_geometry[body] assert isinstance(resource, IntrinsicSubtreeGeometry) - assert all(row in resource.geometry for row in rows) + assert all(resource.contains(row) for row in rows) # Holding revisions does not permit resizing or changing the source's # own scroll scope. Those answers still require a fresh arrangement. assert not resource.matches( @@ -215,7 +215,7 @@ def _layout_mutation_roots(self): screen.query_one("#history").scroll_to(y=10, animate=False, immediate=True) await pilot.pause() for node, geometry in original.items(): - if node in resource.geometry: + if resource.contains(node): assert compositor._published_map[node] == geometry await retired.remove() await pilot.pause() @@ -275,8 +275,8 @@ def _layout_mutation_roots(self): await pilot.pause() compositor = screen._compositor resource = compositor._subtree_geometry[holder] - assert old in resource.geometry - assert resource.geometry[old][1].parent is body + assert resource.contains(old) + assert resource.captured_parent(old) is body async with holder.lock: body.update(Content("\n".join(["ACQUIRED_ROOT_PAINT"] * 18))) await body.mount(ChangingChild("UNCOMMITTED_DESCENDANT")) @@ -369,7 +369,7 @@ def compose(self): history = screen.query_one("#history", VerticalScroll) compositor = screen._compositor resource = compositor._subtree_geometry[body] - assert all(row in resource.geometry for row in rows) + assert all(resource.contains(row) for row in rows) for position in (0, 200, 0): history.scroll_to(y=position, immediate=True, animate=False) @@ -378,7 +378,7 @@ def compose(self): assert body in viewport and rows[235] in viewport omitted = rows[200] if position == 0 else rows[0] assert omitted not in viewport - assert all(row in resource.geometry for row in rows) + assert all(resource.contains(row) for row in rows) assert all(row in compositor.widgets for row in rows) _, placement = next(compositor.published_geometry((body,))) source_bounds = app.size.region - (placement.region.offset - resource.key.region.offset) From 547e42acc6ab91f640932c3c7efcb3fe69798301 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 10:51:06 -0400 Subject: [PATCH 3/4] Route reader demands and held source loans through child edges --- src/textual/_compositor.py | 57 ++++++++++++++++++++++------ tests/test_subtree_geometry_cache.py | 26 +++++++++++-- 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 4bd061f12e..943f8a404f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -211,6 +211,8 @@ class SubtreeGeometryPlacement: clip: SceneClip parent: Widget | None = None clip_bounds: tuple[Region, ...] = () + source_held: bool = False + """This edge borrows mutation-owned participation for one publication.""" @property def region(self) -> Region: @@ -274,6 +276,19 @@ def members(self, *, invisible: bool = False) -> Iterator[Widget]: if isinstance(entry, SubtreeGeometryPlacement): yield from entry.source.members(invisible=invisible) + @cached_property + def reusable(self) -> bool: + """Whether this immutable source can supply ordinary reuse. + + A captured loan needs fresh acquisition unless the containing mutation + owner explicitly lends that original whole-source snapshot. + """ + return all( + not entry.source_held and entry.source.reusable + for _, entry in self.geometry.values() + if isinstance(entry, SubtreeGeometryPlacement) + ) + @cached_property def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: """Derive spatial admission from this immutable arrangement, once.""" @@ -312,30 +327,39 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, entry. Both newly captured and reused resources publish through here. """ source_bounds = bounds - (key.region.offset - self.key.region.offset) + child_demands: dict[Widget, set[Widget]] = {} if visible_only: candidates = dict(self._spatial_map.get_values_in_region(source_bounds)) - for node in (root, *retained): + if (indexed_root := self.geometry.get(root)) is not None: + candidates[indexed_root[0]] = root + for node in retained: if (indexed := self.geometry.get(node)) is not None: candidates[indexed[0]] = node - elif node in retained: - # Usually retained_paths includes the child root already. - # A held source may have a different captured ancestry. + if isinstance(indexed[1], SubtreeGeometryPlacement): + child_demands.setdefault(node, set()).add(node) + else: + # Route once through captured membership. Children receive + # their own demands, never the unrelated global path set. + # More than one edge may contain an original widget; keep + # source order and its original last-assignment semantics. for child, (ordinal, entry) in self.geometry.items(): if (isinstance(entry, SubtreeGeometryPlacement) and entry.source.contains(node)): candidates[ordinal] = child + child_demands.setdefault(child, set()).add(node) entries = ((node, self.geometry[node][1]) for _, node in sorted(candidates.items())) else: entries = ((node, entry) for node, (_, entry) in self.geometry.items()) + no_demand: set[Widget] = set() for node, entry in self._paint_entries(entries, source_held=source_held): required = not visible_only or (node is root and require_root) or node in retained if isinstance(entry, SubtreeGeometryPlacement): child_key, child_clip = self._project_child(entry, key, clip) entry.source.project_into( geometry, child_key, child_clip, clips, node, - visible_only=visible_only, retained=retained, bounds=bounds, - source_held=source_held, require_root=required, + visible_only=visible_only, retained=child_demands.get(node, no_demand), bounds=bounds, + source_held=source_held or entry.source_held, require_root=required, ) continue # Clip intersection can only reduce these original rectangle bounds. @@ -377,6 +401,8 @@ class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]): def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: + if not self.reusable and not source_held: + return False if self.key.visible_only and require_complete: return False return self.key == key or ( @@ -427,6 +453,8 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: + if not self.reusable and not source_held: + return False if source_held: # The mutation owner lends the original source, not the changing # DOM. Internal scroll and all arrangement inputs still agree; @@ -1301,12 +1329,17 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible # Complete intrinsic geometry retains original descendants # and clip declarations. Ancestor scrolling changes only # their placement; no mutating DOM arrangement is read. - resource.restore_into( - map, widgets, invisible_widgets, key, clip, clips, - widget._render_widget, visible_only=visible_only and not complete, - retained=retained_paths, bounds=root_geometry.region, - source_held=True, - ) + if capturing_source: + map[widget._render_widget] = SubtreeGeometryPlacement( + resource, key, clip, source_held=True + ) + else: + resource.restore_into( + map, widgets, invisible_widgets, key, clip, clips, + widget._render_widget, visible_only=visible_only and not complete, + retained=retained_paths, bounds=root_geometry.region, + source_held=True, + ) # The complete resource has replaced these old viewport # entries, including nested holds. Do not merge them back # after projection and resurrect stale absolute placement. diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index fcd78feaae..4ae1809e49 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -53,6 +53,7 @@ async def test_unchanged_subtree_reuses_geometry_and_nested_changes_invalidate_i async def test_changed_parent_borrows_original_children_without_flat_capture(): + from dataclasses import replace from textual._compositor import SubtreeGeometryPlacement from textual.containers import VerticalScroll from textual.screen import Screen @@ -89,6 +90,22 @@ def get_default_screen(self): assert child not in original.geometry assert original.contains(child) assert original.captured_parent(child) is stable + # Capture the same acquired source with one original child loan. The + # immutable lifetime must be refused at the parent's reuse owner. + incoming_clip = stable_source.clip.source + local = {node: (entry if isinstance(entry, SubtreeGeometryPlacement) else entry.geometry) + for node, (_, entry) in original.geometry.items()} + local[stable] = replace(stable_source, source_held=True) + loan = type(original).capture( + original.key, local, original.widgets, original.invisible_widgets, + {history: incoming_clip}, incoming_clip, set(), + ) + assert loan.geometry[stable][1].source is stable_source.source + assert loan.geometry[stable][1].source_held + assert not loan.reusable + assert not loan.matches(loan.key) + assert loan.matches(loan.key, source_held=True) + assert original.reusable rows[0].children[0].update("changed\nheight\nthree") inserted = CachedGroup(Static("new row"), Static("new second child")) @@ -101,14 +118,15 @@ def get_default_screen(self): assert child not in current.geometry assert current.contains(child) scroll = app.query_one(VerticalScroll) + targets = (child, rows[12].children[0], rows[-1].children[-1]) expected = {} for position in (0, 18, 3, 55): scroll.scroll_to(y=position, animate=False, immediate=True) await pilot.pause() - compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + compositor.reflow_visible(app.screen, app.size, retain_geometry=targets) reference = Compositor(max_subtree_geometry_entries=0) - reference.reflow_visible(app.screen, app.size, retain_geometry=(child,)) - demanded = set(child.walk_ancestors(with_self=True)) + reference.reflow_visible(app.screen, app.size, retain_geometry=targets) + demanded = {node for target in targets for node in target.walk_ancestors(with_self=True)} # Cached complete sources may retain extra empty clipped boxes. # Visible paint and every explicitly required reader must agree. def required_scene(source): @@ -130,7 +148,7 @@ def required_scene(source): for position in (3, 18, 55, 0): scroll.scroll_to(y=position, animate=False, immediate=True) await pilot.pause() - compositor.reflow_visible(app.screen, app.size, retain_geometry=(child,)) + compositor.reflow_visible(app.screen, app.size, retain_geometry=targets) assert compositor._subtree_geometry[history] is held_source assert not held_source.contains(incomplete) assert incomplete not in compositor._published_map From d8ebcd9dd54bf9647e683758973cc416b96caa4b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 11:06:24 -0400 Subject: [PATCH 4/4] Preserve partial held acquisition scope through composed sources --- src/textual/_compositor.py | 36 ++++++++++++++++++++++++---- tests/test_subtree_geometry_cache.py | 18 +++++++++++++- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 943f8a404f..7ec4c98192 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -289,6 +289,14 @@ def reusable(self) -> bool: if isinstance(entry, SubtreeGeometryPlacement) ) + @cached_property + def complete(self) -> bool: + """Complete acquisition includes the scope of every borrowed source.""" + return not self.key.visible_only and all( + entry.source.complete for _, entry in self.geometry.values() + if isinstance(entry, SubtreeGeometryPlacement) + ) + @cached_property def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: """Derive spatial admission from this immutable arrangement, once.""" @@ -403,7 +411,7 @@ def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: if not self.reusable and not source_held: return False - if self.key.visible_only and require_complete: + if require_complete and not self.complete: return False return self.key == key or ( not self.key.visible_only and key.visible_only @@ -455,6 +463,8 @@ def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, source_held: bool = False) -> bool: if not self.reusable and not source_held: return False + if require_complete and not self.complete: + return False if source_held: # The mutation owner lends the original source, not the changing # DOM. Internal scroll and all arrangement inputs still agree; @@ -1349,9 +1359,27 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible else: # Placed, resized or missing sources cannot authorize a # projection. Keep their exact committed hit/paint bounds. - for node, geometry in held[widget].items(): - store_geometry(node, geometry, RootSceneClip(geometry.clip)) - widgets.update(held[widget]) + if capturing_source: + # The published fallback is a partial placed source, + # not an invented complete or translatable subtree. + # Keep that lending scope on the same child edge. + snapshot = held[widget] + partial_key = SubtreeGeometryKey.from_widget( + widget, virtual_region, region, order, layer_order, + clip.region, visible, dock_gutter, size, True, + tuple(inherited_layers.items()) if inherited_layers is not None else (), + ) + source = PlacedSubtreeGeometry.capture( + partial_key, snapshot, frozenset(snapshot), frozenset(), + clips, clip, screen_coordinates, + ) + map[widget._render_widget] = SubtreeGeometryPlacement( + source, partial_key, clip, source_held=True + ) + else: + for node, geometry in held[widget].items(): + store_geometry(node, geometry, RootSceneClip(geometry.clip)) + widgets.update(held[widget]) return if (widget in mutation_paths or not self.max_subtree_geometry_entries or not widget.CACHE_SUBTREE_GEOMETRY or not widget._is_mounted): diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index 4ae1809e49..03e7ec30ad 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -54,7 +54,7 @@ async def test_unchanged_subtree_reuses_geometry_and_nested_changes_invalidate_i async def test_changed_parent_borrows_original_children_without_flat_capture(): from dataclasses import replace - from textual._compositor import SubtreeGeometryPlacement + from textual._compositor import PlacedSubtreeGeometry, SubtreeGeometryPlacement from textual.containers import VerticalScroll from textual.screen import Screen @@ -106,6 +106,22 @@ def get_default_screen(self): assert not loan.matches(loan.key) assert loan.matches(loan.key, source_held=True) assert original.reusable + # A missing complete child source can lend only its known placed + # snapshot. A containing capture must not promote that scope. + partial = PlacedSubtreeGeometry.capture( + stable_source.key._replace(visible_only=True), + {stable: stable_source.source.geometry[stable][1].geometry}, + frozenset({stable}), frozenset(), {}, stable_source.clip, set(), + ) + local[stable] = replace(stable_source, source=partial, source_held=True) + partial_loan = type(original).capture( + original.key, local, original.widgets, original.invisible_widgets, + {history: incoming_clip}, incoming_clip, set(), + ) + assert partial_loan.contains(stable) + assert not partial_loan.contains(child) + assert not partial_loan.complete + assert not partial_loan.matches(partial_loan.key, require_complete=True, source_held=True) rows[0].children[0].update("changed\nheight\nthree") inserted = CachedGroup(Static("new row"), Static("new second child"))