From 9181a0f82d1a7d4c45faa8d8cb7687e9cb94bcc3 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 12:48:05 -0400 Subject: [PATCH 1/7] Admit subtree capture against its original complete paint cohort --- src/textual/_compositor.py | 17 ++++-- tests/test_compositor_frame_damage.py | 4 +- tests/test_viewport_geometry_targets.py | 81 +++++++++++++++++++++++-- 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 7ec4c98192..559b5f8252 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -2091,8 +2091,9 @@ def published_geometry( yield root, geometry def render_subtree_strips( - self, root: Widget, root_geometry: MapGeometry, - ) -> tuple[Size, list[Strip]]: + self, root: Widget, root_geometry: MapGeometry, *, + admit: Callable[[Mapping[Widget, tuple[Region, Region]]], bool], + ) -> tuple[Size, list[Strip]] | None: """Paint a body using its borrowed original published placement. The caller acquires the placement from published_geometry and consumes @@ -2100,14 +2101,22 @@ def render_subtree_strips( or manufactures a scene to decide whether a body can be retired. The same original arrangement/line/chop algorithm supplies complete rows without replacing any published map or constructing another compositor. + + The synchronous admission callback receives the exact complete paint + mapping, including offscreen participants and excluding hidden or clipped + children. It may refuse capture when those participants' resources are + unavailable. Admission and painting borrow the same geometry; no second + DOM walk or arrangement chooses another capture cohort. """ bounds = root_geometry.region geometry, _ = self._arrange_root( root, self.size, visible_only=False, root_geometry=root_geometry, ) - widgets = self._paint_regions(self._ordered_geometry(geometry), bounds) - cuts = self._cuts_for_regions(bounds, widgets) + widgets = MappingProxyType(self._paint_regions(self._ordered_geometry(geometry), bounds)) with self._using_geometry(root, geometry): + if not admit(widgets): + return None + cuts = self._cuts_for_regions(bounds, widgets) chops = self._render_chops(bounds, self._regions_to_spans((bounds,)), widgets=widgets, cuts=cuts, bounds=bounds) return bounds.size, [Strip.join(chop.values()) for chop in chops] diff --git a/tests/test_compositor_frame_damage.py b/tests/test_compositor_frame_damage.py index dba0f4b48f..94ed620496 100644 --- a/tests/test_compositor_frame_damage.py +++ b/tests/test_compositor_frame_damage.py @@ -212,7 +212,7 @@ def compose(self): body = app.query_one("#body") [(original, placement)] = list(compositor.published_geometry([body])) assert placement.region.y == 4 - size, strips = compositor.render_subtree_strips(original, placement) + size, strips = compositor.render_subtree_strips(original, placement, admit=lambda participants: True) assert size == Size(12, 3) assert [strip.text.rstrip() for strip in strips] == ["ABC界DEF", "SECOND_ROW", "THIRD_ROW"] @@ -232,7 +232,7 @@ def compose(self): paint = compositor._paint_regions(compositor._ordered_geometry(geometry), placement.region) assert paint[body] == (placement.region, placement.region) assert compositor._cuts_for_regions(placement.region, paint) == [[-2, 10]] * 3 - size, strips = compositor.render_subtree_strips(original, placement) + size, strips = compositor.render_subtree_strips(original, placement, admit=lambda participants: True) assert size == Size(12, 3) assert [strip.text.rstrip() for strip in strips] == ["ABC界DEF", "SECOND_ROW", "THIRD_ROW"] diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index 94d6339222..cd7b19e1aa 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -8,6 +8,77 @@ from textual.widgets import Static +async def test_capture_admits_original_complete_paint_cohort_and_refuses_hidden_children(): + from textual.widgets import Collapsible + + shown = Static("\n".join(f"PAINT_{row}" for row in range(24))) + hidden = Static("HIDDEN_RESOURCE") + expanded = Collapsible(shown, title="Expanded", collapsed=False) + collapsed = Collapsible(hidden, title="Collapsed") + footer = Static("Session details") + + class Body(VerticalGroup): + CACHE_SUBTREE_GEOMETRY = True + + body = Body(expanded, collapsed, footer) + + class CaptureScreen(Screen): + def _use_viewport_layout(self): + return True + + def compose(self): + with VerticalScroll(): + yield body + + app = App() + async with app.run_test(size=(60, 8)) as pilot: + await app.push_screen(CaptureScreen()) + await pilot.pause() + compositor = app.screen._compositor + _, placement = next(compositor.published_geometry((body,))) + published = compositor._published_map + seen = [] + + def refuse(participants): + seen.append(tuple(participants)) + assert shown in participants + assert hidden not in participants + assert collapsed.query_one(Collapsible.Contents) not in participants + assert footer in participants + # Complete capture admits the actual offscreen paragraph, without + # arranging a second scene when its geometry is read by admission. + assert participants[shown][1].height == 24 + assert compositor.find_widget(footer).region.y >= app.size.height + return False + + assert compositor.render_subtree_strips(body, placement, admit=refuse) is None + assert len(seen) == 1 + assert compositor._published_map is published + assert compositor._render_geometry is None + + expanded.collapsed = True + await pilot.pause() + _, current = next(compositor.published_geometry((body,))) + assert current.region.height < placement.region.height + + def admit(participants): + assert shown not in participants + assert hidden not in participants + assert expanded._title in participants + assert collapsed._title in participants + assert footer in participants + return True + + result = compositor.render_subtree_strips(body, current, admit=admit) + assert result is not None + size, strips = result + assert size == current.region.size + assert len(strips) == size.height + assert not any("PAINT_" in row.text or "HIDDEN_RESOURCE" in row.text for row in strips) + assert any("Session details" in row.text for row in strips) + assert compositor._render_geometry is None + + async def test_complete_markdown_capture_keeps_offscreen_table_keylines(): from textual.widgets import Markdown from textual.widgets._markdown import MarkdownTableContent @@ -38,7 +109,7 @@ def compose(self): assert tables[-1] not in compositor._published_map _, placement = next(compositor.published_geometry((body,))) published = compositor._full_map, compositor._visible_map - _, strips = compositor.render_subtree_strips(body, placement) + _, strips = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) rows = [strip.text for strip in strips] for index in range(12): row = next(row for row in rows if f"TABLE_{index:02}" in row) @@ -389,7 +460,7 @@ def compose(self): assert rows[235] not in {node for _, node in candidates} assert len(candidates) < len(resource.geometry) # Capture remains complete without replacing the published viewport. - size, strips = compositor.render_subtree_strips(body, placement) + size, strips = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) assert size.height == 240 assert all(f"Original row {index}" in strip.text for index, strip in enumerate(strips)) @@ -512,7 +583,7 @@ def render_row(crop): return render_lines(crop) with patch.object(compositor, "_arrange_root", side_effect=arrange_body), patch.object(row, "render_lines", side_effect=render_row): - size, strips = compositor.render_subtree_strips(body, placement) + size, strips = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) assert size == bounds.size assert len(strips) == size.height assert all(strip.cell_length == size.width for strip in strips) @@ -543,7 +614,7 @@ def render_with_hidden_query(crop): return render_lines(crop) with patch.object(compositor, "_arrange_root", side_effect=arrange_body), patch.object(row, "render_lines", side_effect=render_with_hidden_query): - compositor.render_subtree_strips(body, placement) + compositor.render_subtree_strips(body, placement, admit=lambda participants: True) assert scoped_misses assert compositor._render_geometry is None assert compositor._full_map is published[0] @@ -553,7 +624,7 @@ def render_with_hidden_query(crop): # ordinary screen queries with their original publication. with patch.object(row, "render_lines", side_effect=ValueError("render failed")): try: - compositor.render_subtree_strips(body, placement) + compositor.render_subtree_strips(body, placement, admit=lambda participants: True) except ValueError as error: assert str(error) == "render failed" else: From 57523e1b98c44daee4645ad6380bcddd134cb98a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 16:22:18 -0400 Subject: [PATCH 2/7] Render admitted subtree capture in viewport-height bands --- src/textual/_compositor.py | 31 +++++++-- tests/test_compositor_frame_damage.py | 6 +- tests/test_viewport_geometry_targets.py | 83 +++++++++++++++++++++++-- 3 files changed, 107 insertions(+), 13 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 74e72c5505..19610137bd 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -2102,7 +2102,7 @@ def published_geometry( def render_subtree_strips( self, root: Widget, root_geometry: MapGeometry, *, admit: Callable[[Mapping[Widget, tuple[Region, Region]]], bool], - ) -> tuple[Size, list[Strip]] | None: + ) -> tuple[Size, Iterator[list[Strip]]] | None: """Paint a body using its borrowed original published placement. The caller acquires the placement from published_geometry and consumes @@ -2116,6 +2116,13 @@ def render_subtree_strips( children. It may refuse capture when those participants' resources are unavailable. Admission and painting borrow the same geometry; no second DOM walk or arrangement chooses another capture cohort. + + The returned iterator paints viewport-height bands. A preceding-source + writer may drain it synchronously; a retirement task may yield between + bands. The caller retains and validates its original source witness + before advancing and before committing the complete result. Geometry + lending ends before each band is returned, so ordinary input and frame + publication never borrow a suspended capture's private arrangement. """ bounds = root_geometry.region geometry, _ = self._arrange_root( @@ -2125,10 +2132,24 @@ def render_subtree_strips( with self._using_geometry(root, geometry): if not admit(widgets): return None - cuts = self._cuts_for_regions(bounds, widgets) - chops = self._render_chops(bounds, self._regions_to_spans((bounds,)), - widgets=widgets, cuts=cuts, bounds=bounds) - return bounds.size, [Strip.join(chop.values()) for chop in chops] + band_height = max(1, self.size.height) + + def render_bands() -> Iterator[list[Strip]]: + for y in range(bounds.y, bounds.bottom, band_height): + band = Region(bounds.x, y, bounds.width, min(band_height, bounds.bottom - y)) + participants = { + widget: (region, paint) + for widget, (region, original_paint) in widgets.items() + if (paint := original_paint.intersection(band)) + } + with self._using_geometry(root, geometry): + cuts = self._cuts_for_regions(band, participants) + chops = self._render_chops(band, self._regions_to_spans((band,)), + widgets=participants, cuts=cuts, bounds=band) + strips = [Strip.join(chop.values()) for chop in chops] + yield strips + + return bounds.size, render_bands() def _render_chops( self, diff --git a/tests/test_compositor_frame_damage.py b/tests/test_compositor_frame_damage.py index 94ed620496..af5d2ad837 100644 --- a/tests/test_compositor_frame_damage.py +++ b/tests/test_compositor_frame_damage.py @@ -212,7 +212,8 @@ def compose(self): body = app.query_one("#body") [(original, placement)] = list(compositor.published_geometry([body])) assert placement.region.y == 4 - size, strips = compositor.render_subtree_strips(original, placement, admit=lambda participants: True) + size, bands = compositor.render_subtree_strips(original, placement, admit=lambda participants: True) + strips = [strip for band in bands for strip in band] assert size == Size(12, 3) assert [strip.text.rstrip() for strip in strips] == ["ABC界DEF", "SECOND_ROW", "THIRD_ROW"] @@ -232,7 +233,8 @@ def compose(self): paint = compositor._paint_regions(compositor._ordered_geometry(geometry), placement.region) assert paint[body] == (placement.region, placement.region) assert compositor._cuts_for_regions(placement.region, paint) == [[-2, 10]] * 3 - size, strips = compositor.render_subtree_strips(original, placement, admit=lambda participants: True) + size, bands = compositor.render_subtree_strips(original, placement, admit=lambda participants: True) + strips = [strip for band in bands for strip in band] assert size == Size(12, 3) assert [strip.text.rstrip() for strip in strips] == ["ABC界DEF", "SECOND_ROW", "THIRD_ROW"] diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index 9112d078f2..b32ed203be 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -71,7 +71,8 @@ def admit(participants): result = compositor.render_subtree_strips(body, current, admit=admit) assert result is not None - size, strips = result + size, bands = result + strips = [strip for band in bands for strip in band] assert size == current.region.size assert len(strips) == size.height assert not any("PAINT_" in row.text or "HIDDEN_RESOURCE" in row.text for row in strips) @@ -79,6 +80,71 @@ def admit(participants): assert compositor._render_geometry is None +async def test_banded_capture_releases_geometry_for_input_and_unrelated_paint(): + from textual.geometry import Region + + class PaintedRows(Static): + def __init__(self): + super().__init__("\n".join(f"ROW_{row:02} 界" for row in range(37))) + self.crops = [] + + def render_lines(self, crop): + self.crops.append(crop) + return super().render_lines(crop) + + body = PaintedRows() + status = Static("INPUT_0", id="status") + + class CaptureScreen(Screen): + CSS = "#status { height: 1; } VerticalScroll { height: 1fr; }" + + def _use_viewport_layout(self): + return True + + def compose(self): + yield status + with VerticalScroll(): + yield body + + class CaptureApp(App): + received = 0 + + def on_key(self, event): + if event.key == "a": + self.received += 1 + status.update(f"INPUT_{self.received}") + + app = CaptureApp() + async with app.run_test(size=(40, 8)) as pilot: + await app.push_screen(CaptureScreen()) + await pilot.pause() + compositor = app.screen._compositor + _, placement = next(compositor.published_geometry((body,))) + published = compositor._published_map + body.crops.clear() + size, bands = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + assert body.crops == [] # Acquisition does not paint the complete body. + assert compositor._published_map is published + assert compositor._render_geometry is None + + rows = [] + for band in bands: + assert 0 < len(band) <= app.size.height + assert compositor._render_geometry is None + rows.extend(band) + if len(rows) < size.height: + # Real key delivery and visible paint may complete while the + # original offscreen capture still owns unfinished rows. + await pilot.press("a") + assert f"INPUT_{app.received}" in compositor.render_strips()[0].text + assert compositor.find_widget(status).region == Region(0, 0, 40, 1) + assert app.received == 4 + assert len(rows) == size.height == 37 + assert [row.text.rstrip() for row in rows] == [f"ROW_{row:02} 界" for row in range(37)] + assert all(crop.height <= app.size.height for crop in body.crops) + assert compositor._render_geometry is None + + def test_borrowed_geometry_routes_preserve_membership_ancestry_and_assignment_order(): from types import MappingProxyType @@ -161,7 +227,8 @@ def compose(self): assert tables[-1] not in compositor._published_map _, placement = next(compositor.published_geometry((body,))) published = compositor._full_map, compositor._visible_map - _, strips = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + _, bands = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + strips = [strip for band in bands for strip in band] rows = [strip.text for strip in strips] for index in range(12): row = next(row for row in rows if f"TABLE_{index:02}" in row) @@ -512,7 +579,8 @@ def compose(self): assert rows[235] not in {node for _, node in candidates} assert len(candidates) < len(resource.geometry) # Capture remains complete without replacing the published viewport. - size, strips = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + size, bands = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + strips = [strip for band in bands for strip in band] assert size.height == 240 assert all(f"Original row {index}" in strip.text for index, strip in enumerate(strips)) @@ -635,7 +703,8 @@ def render_row(crop): return render_lines(crop) with patch.object(compositor, "_arrange_root", side_effect=arrange_body), patch.object(row, "render_lines", side_effect=render_row): - size, strips = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + size, bands = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + strips = [strip for band in bands for strip in band] assert size == bounds.size assert len(strips) == size.height assert all(strip.cell_length == size.width for strip in strips) @@ -666,7 +735,8 @@ def render_with_hidden_query(crop): return render_lines(crop) with patch.object(compositor, "_arrange_root", side_effect=arrange_body), patch.object(row, "render_lines", side_effect=render_with_hidden_query): - compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + _, bands = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + tuple(bands) assert scoped_misses assert compositor._render_geometry is None assert compositor._full_map is published[0] @@ -676,7 +746,8 @@ def render_with_hidden_query(crop): # ordinary screen queries with their original publication. with patch.object(row, "render_lines", side_effect=ValueError("render failed")): try: - compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + _, bands = compositor.render_subtree_strips(body, placement, admit=lambda participants: True) + tuple(bands) except ValueError as error: assert str(error) == "render failed" else: From 01ccf09dde79d7f3e192fdf684999435cb53c3f7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 17:10:49 -0400 Subject: [PATCH 3/7] Acquire complete geometry at mutation entry, keep visible arrangement partial --- src/textual/_compositor.py | 131 +++++++++++++++++++++---------------- src/textual/screen.py | 32 ++++----- 2 files changed, 92 insertions(+), 71 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 19610137bd..6d466dd258 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -232,10 +232,6 @@ class SubtreeGeometry(ABC, Generic[GeometryEntry]): widgets: frozenset[Widget] invisible_widgets: frozenset[Widget] - @classmethod - def complete_arrangement(cls, enclosing_complete: bool) -> bool: - return enclosing_complete - @classmethod def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates): return cls(key, MappingProxyType({ @@ -316,10 +312,23 @@ def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: ) return spatial_map - @abstractmethod 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: + # Original source custody owns revisions during the write. Size, + # internal scrolling, layers and destination placement still agree. + key = key._replace(geometry_revision=self.key.geometry_revision, + nodes_revision=self.key.nodes_revision) + if self.complete and key.visible_only: + key = key._replace(visible_only=False) + return self._matches_placement(key) + + @abstractmethod + def _matches_placement(self, key: SubtreeGeometryKey) -> bool: ... def restore_into( self, geometry: CompositorMap, widgets: set[Widget], invisible_widgets: set[Widget], @@ -330,8 +339,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.members()) - invisible_widgets.update(self.members(invisible=True)) + widgets.update(node for node in self.members() if not node._pruning) + invisible_widgets.update(node for node in self.members(invisible=True) if not node._pruning) def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, @@ -369,6 +378,10 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, 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): + # The source can outlive its cache entry. Original prune admission + # still retires native participation before pump teardown. + if node._pruning: + continue 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) @@ -415,16 +428,8 @@ def references_retired(self, owner: Widget, retired: set[Widget]) -> bool: class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]): """A culled or screen-dependent arrangement keeps its exact placement.""" - 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 - return self.key == key or ( - not self.key.visible_only and key.visible_only - and self.key._replace(visible_only=True) == key - ) + def _matches_placement(self, key: SubtreeGeometryKey) -> bool: + return self.key == key def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: @@ -434,13 +439,9 @@ def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, class IntrinsicSubtreeGeometry(SubtreeGeometry[SubtreeMapGeometry]): """The same bounded resource, reusable under a new original outer clip.""" - @classmethod - def complete_arrangement(cls, enclosing_complete: bool) -> bool: - return True - @classmethod def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates): - if not (widgets | invisible_widgets).isdisjoint(screen_coordinates): + if key.visible_only or not (widgets | invisible_widgets).isdisjoint(screen_coordinates): return PlacedSubtreeGeometry.capture( key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) intrinsic = {} @@ -468,18 +469,7 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ return super().capture(key, intrinsic, widgets, invisible_widgets, clips, clip, screen_coordinates) - 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; - # only source revisions belong to the ongoing mutation. - key = key._replace(geometry_revision=self.key.geometry_revision, - nodes_revision=self.key.nodes_revision) + def _matches_placement(self, key: SubtreeGeometryKey) -> bool: return self.key.intrinsic() == key.intrinsic() def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey, @@ -904,7 +894,7 @@ def reflow( old_map = previous_map old_widgets = old_map.keys() - map, widgets = self._arrange_root( + map, widgets, _ = self._arrange_root( parent, size, visible_only=visible_only, retain_geometry=retain_geometry, ) @@ -954,7 +944,7 @@ def reflow_visible( # Keep a copy of the old map because we're going to compare it with the update old_map = self._visible_map or {} - map, widgets = self._arrange_root( + map, widgets, _ = self._arrange_root( parent, size, visible_only=True, retain_geometry=retain_geometry, ) @@ -1004,7 +994,7 @@ def full_map(self) -> CompositorMap: if self.root is None: return {} if self._full_map_invalidated and not self._arranging: - map, _widgets = self._arrange_root(self.root, self.size, visible_only=False) + map, _widgets, _ = self._arrange_root(self.root, self.size, visible_only=False) # A geometry query also publishes this scene. Retain the damage # from its original visible coordinates before replacing the map; # a later reflow can no longer recover that previous geometry. @@ -1047,20 +1037,23 @@ def _arrange_root( self, root: Widget, size: Size, visible_only: bool = True, retain_geometry: Iterable[Widget] = (), *, root_geometry: MapGeometry | None = None, - ) -> tuple[CompositorMap, set[Widget]]: + root_clip: SceneClip | None = None, + ) -> tuple[CompositorMap, set[Widget], SubtreeGeometryPlacement | None]: """Arrange a widget's children based on its layout attribute. Args: root: Top level widget. size: Size of visible area (screen). visible_only: Only update visible widgets (used in scrolling). - root_geometry: Original placement when arranging a complete body. + root_geometry: Original placement for explicit subtree acquisition. + root_clip: Original outer clip, or complete body bounds for painting. Returns: - Compositor map and set of widgets. + Compositor map, logical members, and the explicitly acquired source. """ - mutation_roots = set(root.screen._layout_mutation_roots()) + mutation_sources = root.screen._layout_mutation_roots() + mutation_roots = set(mutation_sources) previous = self._published_map held: dict[Widget, CompositorMap] = {owner: {} for owner in mutation_roots} mutation_paths: set[Widget] = set() @@ -1074,6 +1067,8 @@ def _arrange_root( self._subtree_geometry.pop(owner, None) map: dict[Widget, MapGeometry | SubtreeGeometryPlacement] = {} capturing_source = False + capture_root = root_geometry is not None + acquired: SubtreeGeometryPlacement | None = None # Transient declaration paths manufacture the one retained resource. # The published MapGeometry continues to own the actual clipped scene. clips: dict[Widget, SceneClip] = {} @@ -1091,7 +1086,7 @@ def store_geometry(node: Widget, geometry: MapGeometry, clip: SceneClip) -> None size, size, size.region, NULL_SPACING, ) layer_order = root_geometry.order[-1][2] - no_clip = RootSceneClip(root_geometry.region) + no_clip = root_clip if root_clip is not None else RootSceneClip(root_geometry.region) # Widget owns layer inheritance. Acquire external ancestry only at the # root, then carry that original declaration through this traversal. root_layers = root._get_layer_order(root.walk_ancestors(with_self=True)) @@ -1332,11 +1327,12 @@ 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, capturing_source + nonlocal map, widgets, invisible_widgets, capturing_source, acquired 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: - resource = self._subtree_geometry.get(widget) + placement = mutation_sources[widget] + resource = placement.source if placement is not None else None key = SubtreeGeometryKey.from_widget( widget, virtual_region, region, order, layer_order, clip.region, visible, dock_gutter, size, False, @@ -1345,6 +1341,8 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible if resource is not None and resource.contains(widget._render_widget) and resource.matches( key, require_complete=True, source_held=True, ): + if capture_root and widget is root: + acquired = SubtreeGeometryPlacement(resource, key, clip, source_held=True) # Complete intrinsic geometry retains original descendants # and clip declarations. Ancestor scrolling changes only # their placement; no mutating DOM arrangement is read. @@ -1390,25 +1388,25 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible 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): + explicit_root = capture_root and widget is root + cache_source = bool(self.max_subtree_geometry_entries and widget.CACHE_SUBTREE_GEOMETRY) + if not explicit_root and (widget in mutation_paths or not cache_source or not widget._is_mounted): arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, 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) key = SubtreeGeometryKey.from_widget(widget, 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 ()) resource = self._subtree_geometry.get(widget) - require_complete = widget in retained_paths and not complete + require_complete = (explicit_root and not visible_only) or (widget in retained_paths and not complete) matches = resource is not None and resource.matches(key, require_complete=require_complete) # A complete captured source already owns every requested path. # A partial or changed source still acquires the original path; # do not retain a partial capture whose key omits that request. - if require_complete and not matches: + if require_complete and not matches and not explicit_root: arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, complete, inherited_layers) # noqa: F821 -- closure cleared after traversal return @@ -1429,12 +1427,15 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible 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 + if cache_source: + 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 + if explicit_root: + acquired = SubtreeGeometryPlacement(resource, key, clip) if capturing_source: # Retain the child source and its placement. A containing # capture does not consume a second flat copy of descendants. @@ -1476,7 +1477,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible map.update(geometry) widgets.update(geometry) widgets -= invisible_widgets - return cast(CompositorMap, map), widgets + return cast(CompositorMap, map), widgets, acquired @property def layers(self) -> list[tuple[Widget, MapGeometry]]: @@ -1693,6 +1694,24 @@ def find_widget(self, widget: Widget) -> MapGeometry: raise errors.NoWidget("Widget is not in layout") return geometry + def acquire_subtree_geometry(self, root: Widget) -> SubtreeGeometryPlacement | None: + """Acquire the complete original source before a mutation begins. + + The transaction retains this placement, independently of cache eviction. + Nested acquisition lends existing held sources while arranging only the + unmodified remainder. It never publishes or paints a replacement scene. + An absent root or incomplete held source supplies no complete resource. + """ + geometry = self._get_published_geometry(root) + if geometry is None or self._arranging: + return None + _, _, placement = self._arrange_root( + root, self.size, visible_only=False, root_geometry=geometry, + root_clip=RootSceneClip(geometry.clip), + ) + return placement if (placement is not None and placement.source.complete + and placement.source.contains(root._render_widget)) else None + def acquire_geometry(self, widgets: Iterable[Widget]) -> bool: """Acquire missing reader paths in one original scene publication. @@ -2125,7 +2144,7 @@ def render_subtree_strips( publication never borrow a suspended capture's private arrangement. """ bounds = root_geometry.region - geometry, _ = self._arrange_root( + geometry, _, _ = self._arrange_root( root, self.size, visible_only=False, root_geometry=root_geometry, ) widgets = MappingProxyType(self._paint_regions(self._ordered_geometry(geometry), bounds)) diff --git a/src/textual/screen.py b/src/textual/screen.py index b8095fa4d5..4ebc86f78d 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -22,6 +22,7 @@ Iterable, Iterator, Literal, + Mapping, NamedTuple, Optional, TypeVar, @@ -36,7 +37,7 @@ from textual._arrange import arrange from textual._auto_scroll import get_auto_scroll_regions from textual._callback import invoke -from textual._compositor import Compositor, MapGeometry +from textual._compositor import Compositor, MapGeometry, SubtreeGeometryPlacement from textual._context import active_message_pump, visible_screen_stack from textual._path import ( CSSPathType, @@ -1236,6 +1237,13 @@ async def _on_idle(self, event: events.Idle) -> None: # Borrow only within this synchronous admission. Paint reacquires its # own cohort after queued source/layout work has had a chance to run. roots: tuple[Widget, ...] | None = None + # A mutation release wakes this owner with check_idle(). Its original + # retained members now become actionable; no new source invalidation + # or window-sized layout request is needed to resume them. + if not self._layout_required and self._has_actionable_layout_requests( + self._held_layout_requests() + ): + self._layout_required = True if not self.app._batch_count and self.is_current: if self._refresh_requested or self._dirty_widgets: self._update_timer.resume() @@ -1255,9 +1263,13 @@ def _prepare_compositor_refresh(self) -> tuple[Widget, ...]: """Prepare the frame and return original subtrees whose paint is held.""" return () - def _layout_mutation_roots(self) -> tuple[Widget, ...]: - """Subtrees borrowing their committed geometry during source mutation.""" - return () + def _layout_mutation_roots(self) -> Mapping[Widget, SubtreeGeometryPlacement | None]: + """Mutation owners lending sources acquired before their writes. + + None holds only the exact placed publication; it cannot authorize a + complete or translated source. Release wakes this screen with check_idle. + """ + return {} def _on_frame_published(self, deferred_roots: tuple[Widget, ...]) -> None: """Observe an actual displayed update with its acquired admission roots.""" @@ -1593,7 +1605,6 @@ async def _on_layout(self, message: messages.Layout) -> None: message.stop() message.prevent_default() - layout_required = False widget: DOMNode = message.widget if widget._pruning or widget._closed: return @@ -1604,20 +1615,11 @@ async def _on_layout(self, message: messages.Layout) -> None: self._layout_widgets[ancestor] = set() if widget not in self._layout_widgets: self._layout_widgets[ancestor].add(widget) - layout_required = True if not ancestor.styles.auto_dimensions: break widget = ancestor - if not self._layout_required: - # A prior frame keeps held requests but consumes its timer intent. - # Membership is not a new-source notification: after release the - # SAME pending request becomes actionable under current roots. - if layout_required or self._has_actionable_layout_requests( - self._held_layout_requests() - ): - self._layout_required = True - self.check_idle() + self.check_idle() async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: message.stop() From 2f2902f9cdb3af815834fb48449c6c60003b7e77 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 17:23:49 -0400 Subject: [PATCH 4/7] Preserve mutation clip and captured ancestry across explicit geometry loans --- .../README.md | 31 +++++ .../changed-controls.txt | 2 + src/textual/_compositor.py | 89 +++++++-------- tests/test_batch_paint_admission.py | 35 ++++-- tests/test_compositor.py | 2 +- tests/test_compositor_frame_damage.py | 6 +- tests/test_native_scene_size_publication.py | 16 +-- tests/test_subtree_geometry_cache.py | 106 ++++++++++++++++-- tests/test_viewport_geometry_targets.py | 45 ++++---- 9 files changed, 226 insertions(+), 106 deletions(-) create mode 100644 evidence/subtree-mutation-source-20261008/README.md create mode 100644 evidence/subtree-mutation-source-20261008/changed-controls.txt diff --git a/evidence/subtree-mutation-source-20261008/README.md b/evidence/subtree-mutation-source-20261008/README.md new file mode 100644 index 0000000000..b7d5df8239 --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/README.md @@ -0,0 +1,31 @@ +# Explicit mutation geometry + +Visible arrangement no longer forces complete offscreen acquisition merely to +fill an intrinsic cache entry. A partial source retains exact placed geometry. +`Compositor.acquire_subtree_geometry(root)` supplies the existing complete +`SubtreeGeometryPlacement` before writes; no paint or publication occurs. The +transaction owns that source independently of cache eviction. Missing placement +or an incomplete source returns None. Nested acquisition borrows held children. + +Screen's mutation hook now returns original roots mapped to these placements +(or None for the exact published fallback). Both resource kinds capture native +parent edges once. Shared source matching retains distinct exact/translated +placement rules; held projection filters original prune admission and native +self-paint participation along captured ancestry. Screen overlays retain their +screen clip, distinct from the acquired body's incoming/expanded paint clip. + +Mutation release calls `Screen.check_idle()`. Screen resumes its retained layout +members; the caller does not invalidate a window or manufacture a layout request. +Native scroll/source epochs remain unchanged, including capture currentness. + +First affected batch at 01ccf09d: six passes, two failures. One control incorrectly +asserted that a held RLock already meant a source mutation; it now checks the +actual published mutation scope. The other exposed the real overlay clip defect: +complete explicit acquisition incorrectly returned IntrinsicSubtreeGeometry for +an overlay. The corrected clip and release checks passed (3 / 0.81s); that result +predates the shared captured-parent representation change. Initial tool output is +retained in the original conversation; neither result is overall acceptance. + +Parent owns matching history transaction/reader lifetime and saved-history +application verification. No installed change or latency gain is claimed here. +Historical evidence scripts remain at their original source/API scope in Git. diff --git a/evidence/subtree-mutation-source-20261008/changed-controls.txt b/evidence/subtree-mutation-source-20261008/changed-controls.txt new file mode 100644 index 0000000000..70851caaba --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/changed-controls.txt @@ -0,0 +1,2 @@ +... [100%] +3 passed in 0.81s diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6d466dd258..626fcb7409 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -23,13 +23,11 @@ from typing import ( TYPE_CHECKING, Callable, - Generic, Iterable, Iterator, Mapping, NamedTuple, Sequence, - TypeVar, cast, ) @@ -195,9 +193,6 @@ def project(self, original: SubtreeGeometryKey, current: SubtreeGeometryKey, ), clip -GeometryEntry = TypeVar("GeometryEntry", MapGeometry, SubtreeMapGeometry) - - @dataclass(frozen=True) class SubtreeGeometryPlacement: """A borrowed child source at its original acquired parent placement. @@ -223,21 +218,30 @@ def region(self) -> Region: @dataclass(frozen=True) -class SubtreeGeometry(ABC, Generic[GeometryEntry]): +class SubtreeGeometry(ABC): """One immutable native arrangement resource in the compositor's cache.""" key: SubtreeGeometryKey - geometry: Mapping[Widget, tuple[int, GeometryEntry | SubtreeGeometryPlacement]] + geometry: Mapping[Widget, tuple[int, SubtreeMapGeometry | SubtreeGeometryPlacement]] """Ordered local placements and borrowed child sources, without flattening.""" widgets: frozenset[Widget] invisible_widgets: frozenset[Widget] @classmethod def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates): - return cls(key, MappingProxyType({ - node: (ordinal, entry) - for ordinal, (node, entry) in enumerate(geometry.items()) - }), widgets, invisible_widgets) + routes = cls._route_geometry(geometry) + captured = {} + for ordinal, (node, entry) in enumerate(geometry.items()): + parent = node.parent + parent = parent._render_widget if isinstance(parent, Widget) else None + parent = parent if parent is not node and parent in routes else None + if isinstance(entry, MapGeometry): + entry = SubtreeMapGeometry(entry, (), parent) + else: + entry = (replace(entry, parent=parent) if isinstance(entry, SubtreeGeometryPlacement) + else entry._replace(parent=parent)) + captured[node] = ordinal, entry + return cls(key, MappingProxyType(captured), widgets, invisible_widgets) @staticmethod def _route_geometry(geometry) -> Mapping[Widget, tuple[Widget, ...]]: @@ -402,8 +406,20 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, clips[node] = node_clip def _paint_entries(self, entries, *, source_held: bool): - """Placed sources retain their original native participation.""" - return entries + """Retained self-paint follows the source's original captured ancestry.""" + if not source_held: + return entries + participation: dict[Widget, bool] = {} + + def participates(node: Widget) -> bool: + if node not in participation: + parent = self.captured_parent(node) + participation[node] = node._render_widget is node and ( + parent is None or (parent.is_container and participates(parent)) + ) + return participation[node] + + return ((node, entry) for node, entry in entries if participates(node)) def _project_child(self, entry: SubtreeGeometryPlacement, key: SubtreeGeometryKey, clip: SceneClip) -> tuple[SubtreeGeometryKey, SceneClip]: @@ -411,7 +427,7 @@ def _project_child(self, entry: SubtreeGeometryPlacement, key: SubtreeGeometryKe return entry.key, entry.clip @abstractmethod - def _project_entry(self, entry: GeometryEntry, key: SubtreeGeometryKey, + def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: ... @@ -425,18 +441,18 @@ def references_retired(self, owner: Widget, retired: set[Widget]) -> bool: @dataclass(frozen=True) -class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]): +class PlacedSubtreeGeometry(SubtreeGeometry): """A culled or screen-dependent arrangement keeps its exact placement.""" def _matches_placement(self, key: SubtreeGeometryKey) -> bool: return self.key == key - def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, + def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: - return entry, RootSceneClip(entry.clip) + return entry.geometry, RootSceneClip(entry.geometry.clip) @dataclass(frozen=True) -class IntrinsicSubtreeGeometry(SubtreeGeometry[SubtreeMapGeometry]): +class IntrinsicSubtreeGeometry(SubtreeGeometry): """The same bounded resource, reusable under a new original outer clip.""" @classmethod @@ -446,7 +462,6 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ key, geometry, widgets, invisible_widgets, clips, clip, screen_coordinates) intrinsic = {} origin = key.region.offset - geometry_routes = cls._route_geometry(geometry) for node, entry in geometry.items(): child = isinstance(entry, SubtreeGeometryPlacement) scope, bounds = (entry.clip if child else clips[node]).relative_bounds(clip, origin) @@ -458,13 +473,9 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_ 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 - parent = (parent if parent is not node and parent in geometry_routes - else None) intrinsic[node] = ( - replace(entry, parent=parent, clip_bounds=bounds) if child - else SubtreeMapGeometry(entry, bounds, parent) + replace(entry, clip_bounds=bounds) if child + else SubtreeMapGeometry(entry, bounds, None) ) return super().capture(key, intrinsic, widgets, invisible_widgets, clips, clip, screen_coordinates) @@ -487,26 +498,6 @@ def _project_child(self, entry, key, clip): clip=clip.region, ), clip - def _paint_entries(self, entries, *, source_held: bool): - if not source_held: - return entries - participation: dict[Widget, bool] = {} - - def participates(node: Widget) -> bool: - if node not in participation: - parent = self.captured_parent(node) - participation[node] = node._render_widget is node and ( - parent is None or (parent.is_container and participates(parent)) - ) - return participation[node] - - # A retained-paint owner can now render itself rather than its old - # native children. Consult that declaration along captured ancestry, - # never walk or arrange the changing child tree. Each parent answers - # once within this projection; the resource remains immutable. - return ((node, entry) for node, entry in entries if participates(node)) - - class CompositorUpdate: """An update generated by the compositor, which also doubles as console renderables.""" @@ -1086,7 +1077,11 @@ def store_geometry(node: Widget, geometry: MapGeometry, clip: SceneClip) -> None size, size, size.region, NULL_SPACING, ) layer_order = root_geometry.order[-1][2] - no_clip = root_clip if root_clip is not None else RootSceneClip(root_geometry.region) + # Screen overlays retain their original screen clip, even when a body + # is acquired with expanded paint bounds or its own incoming clip. + no_clip = RootSceneClip(size.region) + incoming_clip = (root_clip if root_clip is not None else + RootSceneClip(root_geometry.region) if capture_root else no_clip) # Widget owns layer inheritance. Acquire external ancestry only at the # root, then carry that original declaration through this traversal. root_layers = root._get_layer_order(root.walk_ancestors(with_self=True)) @@ -1459,7 +1454,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible root_geometry.region, root_geometry.order, layer_order, - no_clip, + incoming_clip, True, root_geometry.dock_gutter, False, diff --git a/tests/test_batch_paint_admission.py b/tests/test_batch_paint_admission.py index 138c3b3941..b5afed16d9 100644 --- a/tests/test_batch_paint_admission.py +++ b/tests/test_batch_paint_admission.py @@ -1,7 +1,7 @@ """Real framework lifecycle checks, not installed terminal acceptance.""" import asyncio -from contextlib import nullcontext +from contextlib import asynccontextmanager, nullcontext import pytest @@ -138,7 +138,7 @@ class HeldVertical(Vertical): """A real changing subtree refuses layout and paint until its owner releases.""" def arrange(self, size, optimal=False): - assert not self.lock.is_locked, "Layout descended into a mutation" + assert self not in self.screen._layout_mutation_roots(), "Layout descended into a mutation" return super().arrange(size, optimal=optimal) def render_lines(self, crop): @@ -150,12 +150,25 @@ class IndependentScreen(ObservedScreen): def __init__(self): super().__init__() self.publications = [] + self.held_geometry = {} + + @asynccontextmanager + async def preserve_layout(self, root): + async with root.lock: + previous = self.held_geometry + placement = self._compositor.acquire_subtree_geometry(root) + self.held_geometry = {**previous, root: placement} + try: + yield placement + finally: + self.held_geometry = previous + self.check_idle() def _layout_mutation_roots(self): - return tuple(root for root in self.query(HeldVertical) if root.lock.is_locked) + return self.held_geometry def _prepare_compositor_refresh(self): - return self._layout_mutation_roots() + return tuple(self._layout_mutation_roots()) def _on_frame_published(self, deferred_roots): self.publications.append(deferred_roots) @@ -186,7 +199,7 @@ async def test_released_pending_layout_resumes_on_its_existing_owner_request(): holder = app.query_one(HeldVertical) original = app.query_one("#original") committed = screen._compositor.find_widget(original) - async with holder.lock: + async with holder.screen.preserve_layout(holder): new = Label("RELEASED_SOURCE", id="released") await holder.mount(new) # A child source change makes the holder itself a pending owner, @@ -199,9 +212,9 @@ async def test_released_pending_layout_resumes_on_its_existing_owner_request(): assert new not in screen._compositor._published_map # Subscribe only after the held frame, then exercise the SAME owner's - # refresh. No sidebar update, geometry query or manual timer wakeup. + # idle wakeup. No new source invalidation, geometry query or timer. screen.screen_layout_refresh_signal.subscribe(app, lambda _: completed.set()) - holder.refresh(layout=True) + screen.check_idle() await asyncio.wait_for(completed.wait(), 2) assert new in screen._compositor._published_map assert not screen._layout_widgets @@ -248,7 +261,7 @@ def get_default_screen(self): assert len(screen.acquisitions) > before await pilot.pause() - async with (holder.lock if held else nullcontext()): + async with (screen.preserve_layout(holder) if held else nullcontext()): assert not screen._refresh_pending compositor._dirty_regions.add((holder if held else sidebar).region) screen._invoke_later(completed.set, app) @@ -294,7 +307,7 @@ async def test_partial_publication_keeps_geometry_and_owner_callbacks(): original_hit = compositor.get_widget_at(15, 1)[0] app.frames.clear() screen.publications.clear() - async with holder.lock: + async with holder.screen.preserve_layout(holder): await holder.mount(Label("NEW_HELD", id="new")) sidebar.update("SIDEBAR_NEW") original.call_after_refresh(held_done.set) @@ -380,7 +393,7 @@ async def test_inline_partial_cells_preserve_held_content_and_clear_after_releas await pilot.pause() compositor = app.screen._compositor holder = app.query_one(HeldVertical) - async with holder.lock: + async with holder.screen.preserve_layout(holder): update = compositor.render_inline(app.size, clear=True, excluded_regions=compositor.deferred_regions((holder,))) assert isinstance(update, InlineUpdate) @@ -406,7 +419,7 @@ async def test_partial_translucent_publication_never_reads_held_backdrop(): await app.push_screen(ModalScreen()) await pilot.pause() foreground = app.screen - async with holder.lock: + async with holder.screen.preserve_layout(holder): compositor = foreground._compositor compositor._dirty_regions.add(compositor.size.region) regions = source._compositor.deferred_regions((holder,)) diff --git a/tests/test_compositor.py b/tests/test_compositor.py index 202ca38f85..ab7568de97 100644 --- a/tests/test_compositor.py +++ b/tests/test_compositor.py @@ -248,7 +248,7 @@ def compose(self): await pilot.pause() compositor = app.screen._compositor geometry = compositor.full_map - captured, _ = compositor._arrange_root( + captured, _, _ = compositor._arrange_root( inner, app.screen.size, visible_only=False, root_geometry=geometry[inner], ) diff --git a/tests/test_compositor_frame_damage.py b/tests/test_compositor_frame_damage.py index af5d2ad837..e4dd83edec 100644 --- a/tests/test_compositor_frame_damage.py +++ b/tests/test_compositor_frame_damage.py @@ -227,7 +227,7 @@ def compose(self): assert compositor.visible_widgets[body] == ( placement.region, Region(0, 0, 10, 2), ) - geometry, _ = compositor._arrange_root( + geometry, _, _ = compositor._arrange_root( original, compositor.size, visible_only=False, root_geometry=placement, ) paint = compositor._paint_regions(compositor._ordered_geometry(geometry), placement.region) @@ -315,7 +315,7 @@ def compose(self): publication = compositor._visible_map assert far not in publication [(original, placement)] = compositor.published_geometry((body,)) - capture, _ = compositor._arrange_root( + capture, _, _ = compositor._arrange_root( original, compositor.size, visible_only=False, root_geometry=placement, ) assert far in capture and omitted not in capture @@ -332,7 +332,7 @@ def compose(self): assert compositor._full_map_invalidated # A failed nested paint must restore the enclosing original resource. - nested, _ = compositor._arrange_root( + nested, _, _ = compositor._arrange_root( far, compositor.size, visible_only=False, root_geometry=capture[far], ) with pytest.raises(RuntimeError, match="native renderer failed"): diff --git a/tests/test_native_scene_size_publication.py b/tests/test_native_scene_size_publication.py index f656070379..cff07396cc 100644 --- a/tests/test_native_scene_size_publication.py +++ b/tests/test_native_scene_size_publication.py @@ -1,6 +1,7 @@ """Exercise scene transitions and size receipts through original native Apps.""" from textual import events +from tests.test_batch_paint_admission import IndependentScreen from textual.app import App from textual.containers import VerticalScroll from textual.geometry import Size @@ -9,12 +10,10 @@ async def test_scroll_intent_survives_layout_and_held_sources(): - from textual.screen import Screen history = VerticalScroll(Static("\n".join(str(n) for n in range(50)))) - class IntentScreen(Screen): - held = False + class IntentScreen(IndependentScreen): def __init__(self): super().__init__() @@ -23,12 +22,6 @@ def __init__(self): def compose(self): yield history - def _layout_mutation_roots(self): - return (history,) if self.held else () - - def _prepare_compositor_refresh(self): - return self._layout_mutation_roots() - def _refresh_layout(self, size=None, scroll=False): self.admissions.append(( scroll, self._has_actionable_layout_requests(self._held_layout_requests()) @@ -46,7 +39,7 @@ def get_default_screen(self): await pilot.pause() screen = app.screen original = screen._compositor.find_widget(history.children[0]) - screen.held = True + screen.held_geometry = {history: screen._compositor.acquire_subtree_geometry(history)} screen.admissions.clear() history.refresh(layout=True) history.scroll_to(y=10, animate=False, immediate=True) @@ -55,7 +48,8 @@ def get_default_screen(self): assert (True, False) in screen.admissions assert screen._compositor.find_widget(history.children[0]) == original assert screen._layout_widgets - screen.held = False + screen.held_geometry = {} + screen.check_idle() screen.admissions.clear() history.refresh(layout=True) history.scroll_to(y=15, animate=False, immediate=True) diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index 03e7ec30ad..a71e9fa458 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -1,8 +1,10 @@ +import asyncio from unittest.mock import patch import pytest from textual._compositor import Compositor +from tests.test_batch_paint_admission import IndependentScreen from textual.app import App from textual.containers import VerticalGroup from textual.widgets import Static @@ -56,22 +58,15 @@ async def test_changed_parent_borrows_original_children_without_flat_capture(): from dataclasses import replace from textual._compositor import PlacedSubtreeGeometry, 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): + class SourceScreen(IndependentScreen): 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; }" @@ -157,7 +152,7 @@ def required_scene(source): )[1] assert compositor.find_widget(child) == reference.find_widget(child) held_source = compositor._subtree_geometry[history] - async with history.lock: + async with app.screen.preserve_layout(history): incomplete = CachedGroup(Static("NOT_COMMITTED")) await history.mount(incomplete) await pilot.pause() @@ -185,3 +180,96 @@ def required_scene(source): def test_geometry_budget_rejects_invalid_capacities(capacity): with pytest.raises(ValueError, match="non-negative integer"): Compositor(max_subtree_geometry_entries=capacity) + + +async def test_visible_source_stays_partial_and_mutation_keeps_explicit_complete_loan(): + from textual._compositor import PlacedSubtreeGeometry, IntrinsicSubtreeGeometry + from textual.containers import VerticalScroll + + rows = [CachedGroup(Static(f"ROW_{index:03}")) for index in range(120)] + history = CachedGroup(*rows, id="history-source") + + class ViewportScreen(IndependentScreen): + CSS = "VerticalScroll { height: 8; } Static { height: 1; }" + + def compose(self): + yield VerticalScroll(history, id="scroller") + yield Static("UNRELATED_PAINT", id="sidebar") + + def _use_viewport_layout(self): + return True + + app = App() + async with app.run_test(size=(40, 12)) as pilot: + screen = ViewportScreen() + await app.push_screen(screen) + await pilot.pause() + compositor = screen._compositor + assert compositor.acquire_subtree_geometry(Static("UNPLACED")) is None + partial = compositor._subtree_geometry[history] + assert isinstance(partial, PlacedSubtreeGeometry) + assert not partial.complete and not partial.contains(rows[-1]) + original_scene = dict(compositor._published_map) + compositor.max_subtree_geometry_entries = 0 + completed = asyncio.Event() + async with screen.preserve_layout(history) as placement: + assert placement.source.complete + assert isinstance(placement.source, IntrinsicSubtreeGeometry) + assert placement.source.contains(rows[-1].children[0]) + assert not compositor._subtree_geometry + assert compositor._published_map == original_scene + added = CachedGroup(Static("UNCOMMITTED")) + await history.mount(added) + history.call_after_refresh(completed.set) + screen.query_one("#sidebar").update("UNRELATED_CHANGED") + scroller = screen.query_one("#scroller", VerticalScroll) + for position in (70, 5, 115, 0): + scroller.scroll_to(y=position, animate=False, immediate=True) + await pilot.pause() + assert added not in compositor._published_map + rendered = '\n'.join(strip.text for strip in compositor.render_strips()) + assert "UNCOMMITTED" not in rendered + assert "UNRELATED_CHANGED" in rendered + assert not completed.is_set() + await asyncio.wait_for(completed.wait(), 2) + assert added in compositor.widgets + assert not screen._layout_widgets and not app._exception + + +async def test_nested_source_expansion_borrows_original_held_child_before_sibling_arrangement(): + from textual.containers import VerticalScroll + + original = Static("ORIGINAL_CHILD") + child = CachedGroup(original) + sibling = CachedGroup(Static("UNMODIFIED_SIBLING")) + history = CachedGroup(child, sibling) + + class SourceScreen(IndependentScreen): + def compose(self): + yield VerticalScroll(history) + + app = App() + async with app.run_test(size=(30, 8)) as pilot: + screen = SourceScreen() + await app.push_screen(screen) + await pilot.pause() + compositor = screen._compositor + async with screen.preserve_layout(child) as first: + incoming = Static("NEW_UNCOMMITTED_CHILD") + await child.mount(incoming) + await pilot.pause() + expanded = compositor.acquire_subtree_geometry(history) + assert expanded is not None and expanded.source.complete + assert expanded.source.contains(original) + assert expanded.source.contains(sibling.children[0]) + assert not expanded.source.contains(incoming) + loan = expanded.source.geometry[child][1] + assert loan.source is first.source and loan.source_held + previous = screen.held_geometry + screen.held_geometry = {history: expanded} + screen.query_one(VerticalScroll).scroll_to(y=1, animate=False, immediate=True) + await pilot.pause() + assert incoming not in compositor._published_map + screen.held_geometry = previous + await pilot.pause() + assert incoming in compositor.widgets and not app._exception diff --git a/tests/test_viewport_geometry_targets.py b/tests/test_viewport_geometry_targets.py index b32ed203be..86c1ad3e22 100644 --- a/tests/test_viewport_geometry_targets.py +++ b/tests/test_viewport_geometry_targets.py @@ -1,7 +1,10 @@ import asyncio from unittest.mock import patch +import pytest + from textual import errors +from tests.test_batch_paint_admission import IndependentScreen from textual.app import App from textual.containers import VerticalGroup, VerticalScroll from textual.screen import Screen @@ -180,7 +183,7 @@ def source(owner, y): key, MappingProxyType({ first: (0, SubtreeGeometryPlacement(first_source, key, clip, root)), second: (1, SubtreeGeometryPlacement(second_source, key, clip, root)), - root: (2, placed(9)), + root: (2, SubtreeMapGeometry(placed(9), (), None)), }), frozenset((root,)), frozenset(), ) assert resource.contains(shared) @@ -238,7 +241,7 @@ def compose(self): assert tables[-1].outer_size.height == 0 -class TargetedScreen(Screen): +class TargetedScreen(IndependentScreen): CSS = "VerticalScroll { width: 1fr; } VerticalGroup, Static { height: auto; }" targets = () @@ -264,7 +267,7 @@ class HeldBody(VerticalGroup): CACHE_SUBTREE_GEOMETRY = True def arrange(self, size, optimal=False): - assert not self.lock.is_locked, "Read changing body layout" + assert self not in self.screen._layout_mutation_roots(), "Read changing body layout" return super().arrange(size, optimal=optimal) rows = [Static(f"COMMITTED_ROW_{index:02}", id=f"held-row-{index}") @@ -278,9 +281,6 @@ class HeldScreen(TargetedScreen): #outside-body { height: 30; } """ - def _layout_mutation_roots(self): - return (body,) if body.lock.is_locked else () - def compose(self): with VerticalScroll(id="history"): yield body @@ -294,7 +294,7 @@ def compose(self): await pilot.pause() history = screen.query_one("#history", VerticalScroll) compositor = screen._compositor - resource = compositor._subtree_geometry[body] + resource = compositor.acquire_subtree_geometry(body).source assert isinstance(resource, IntrinsicSubtreeGeometry) assert all(resource.contains(row) for row in rows) # Holding revisions does not permit resizing or changing the source's @@ -326,7 +326,7 @@ def compose(self): completed = asyncio.Event() unrelated = asyncio.Event() - async with body.lock: + async with app.screen.preserve_layout(body): incomplete = Static("UNCOMMITTED_CHILD", id="incomplete") await body.mount(incomplete) body.call_after_refresh(completed.set) @@ -373,7 +373,7 @@ class HeldBody(VerticalGroup): CACHE_SUBTREE_GEOMETRY = True def arrange(self, size, optimal=False): - assert not self.lock.is_locked, "Read changing overlay layout" + assert self not in self.screen._layout_mutation_roots(), "Read changing overlay layout" return super().arrange(size, optimal=optimal) overlay = Static("SCREEN_OVERLAY", id="overlay") @@ -387,9 +387,6 @@ def compose(self): with VerticalScroll(id="history"): yield body - def _layout_mutation_roots(self): - return (body,) if body.lock.is_locked else () - app = App() async with app.run_test(size=(40, 10)) as pilot: screen = HeldScreen() @@ -397,10 +394,10 @@ def _layout_mutation_roots(self): await app.push_screen(screen) await pilot.pause() compositor = screen._compositor - resource = compositor._subtree_geometry[body] + resource = compositor.acquire_subtree_geometry(body).source assert isinstance(resource, PlacedSubtreeGeometry) original = dict(compositor._published_map) - async with body.lock: + async with app.screen.preserve_layout(body): await body.mount(Static("INCOMPLETE")) screen.query_one("#history").scroll_to(y=10, animate=False, immediate=True) await pilot.pause() @@ -418,14 +415,15 @@ def _layout_mutation_roots(self): assert not app._exception -async def test_held_original_ancestry_honors_a_nested_self_painting_owner(): +@pytest.mark.parametrize("screen_overlay", [False, True]) +async def test_held_original_ancestry_honors_a_nested_self_painting_owner(screen_overlay): from textual.content import Content class HeldGroup(VerticalGroup): CACHE_SUBTREE_GEOMETRY = True def arrange(self, size, optimal=False): - assert not self.lock.is_locked, "Read changing child topology" + assert self not in self.screen._layout_mutation_roots(), "Read changing child topology" return super().arrange(size, optimal=optimal) class ContentBody(Static): @@ -444,6 +442,8 @@ def render_lines(self, crop): body = ContentBody("INITIAL_NATIVE_PARENT", id="content-body") old = ChangingChild("OLD_NATIVE_CHILD") + if screen_overlay: + old.styles.overlay = "screen" class ContentScreen(TargetedScreen): CSS = "#content-body { height: 18; } Static { height: 2; }" @@ -452,9 +452,6 @@ def compose(self): with VerticalScroll(id="history"): yield holder - def _layout_mutation_roots(self): - return (holder,) if holder.lock.is_locked else () - app = App() async with app.run_test(size=(40, 10)) as pilot: screen = ContentScreen() @@ -464,10 +461,10 @@ def _layout_mutation_roots(self): await body.mount(old) await pilot.pause() compositor = screen._compositor - resource = compositor._subtree_geometry[holder] + resource = compositor.acquire_subtree_geometry(holder).source assert resource.contains(old) assert resource.captured_parent(old) is body - async with holder.lock: + async with app.screen.preserve_layout(holder): body.update(Content("\n".join(["ACQUIRED_ROOT_PAINT"] * 18))) await body.mount(ChangingChild("UNCOMMITTED_DESCENDANT")) screen.query_one("#history").scroll_to(y=4, animate=False, immediate=True) @@ -520,8 +517,8 @@ def compose(self): screen._refresh_layout(app.size, scroll=True) # The overlay's clip reaches the outer screen, so this resource # keeps placed geometry rather than manufacturing an intrinsic clip. - assert isinstance(compositor._subtree_geometry[body], PlacedSubtreeGeometry) - expected, _ = Compositor(max_subtree_geometry_entries=0)._arrange_root( + assert isinstance(compositor.acquire_subtree_geometry(body).source, PlacedSubtreeGeometry) + expected, _, _ = Compositor(max_subtree_geometry_entries=0)._arrange_root( screen, app.size, visible_only=False, ) paint = compositor._paint_regions(compositor._ordered_geometry(expected), app.size.region) @@ -558,7 +555,7 @@ def compose(self): await pilot.pause() history = screen.query_one("#history", VerticalScroll) compositor = screen._compositor - resource = compositor._subtree_geometry[body] + resource = compositor.acquire_subtree_geometry(body).source assert all(resource.contains(row) for row in rows) for position in (0, 200, 0): From 3c7d5ec036056a94c57d554104b31c8a0689417a Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 17:49:06 -0400 Subject: [PATCH 5/7] Fence released layout callbacks and retain original opaque source membership --- .../README.md | 47 +- .../callback-release-controls.txt | 2 + .../callback-release-initial.txt | 230 + .../callback-release-ordering-negative.txt | 241 ++ .../fallback-controls.txt | 2 + .../final-controls.txt | 69 + .../nested-scope-control.txt | 2 + .../owner-consumers.json | 3730 +++++++++++++++++ src/textual/_compositor.py | 30 +- src/textual/screen.py | 27 +- tests/test_batch_paint_admission.py | 61 + tests/test_subtree_geometry_cache.py | 1 + 12 files changed, 4431 insertions(+), 11 deletions(-) create mode 100644 evidence/subtree-mutation-source-20261008/callback-release-controls.txt create mode 100644 evidence/subtree-mutation-source-20261008/callback-release-initial.txt create mode 100644 evidence/subtree-mutation-source-20261008/callback-release-ordering-negative.txt create mode 100644 evidence/subtree-mutation-source-20261008/fallback-controls.txt create mode 100644 evidence/subtree-mutation-source-20261008/final-controls.txt create mode 100644 evidence/subtree-mutation-source-20261008/nested-scope-control.txt create mode 100644 evidence/subtree-mutation-source-20261008/owner-consumers.json diff --git a/evidence/subtree-mutation-source-20261008/README.md b/evidence/subtree-mutation-source-20261008/README.md index b7d5df8239..3980ff0ac0 100644 --- a/evidence/subtree-mutation-source-20261008/README.md +++ b/evidence/subtree-mutation-source-20261008/README.md @@ -1,11 +1,21 @@ # Explicit mutation geometry +`Compositor.render_subtree_strips` keeps mandatory `admit`: it acquires the +complete original paint cohort synchronously, then returns `Size` and an +iterator of strip bands. Each band uses the original cuts and renderer, is at +most the acquired viewport height, and closes borrowed geometry before yielding. +Preceding-source callers drain before writing; retirement callers can cooperate +between bands while their original witnesses and custody remain current. The +six band controls at `57523e1b` passed (1.86s) and were not repeated here. + Visible arrangement no longer forces complete offscreen acquisition merely to fill an intrinsic cache entry. A partial source retains exact placed geometry. `Compositor.acquire_subtree_geometry(root)` supplies the existing complete `SubtreeGeometryPlacement` before writes; no paint or publication occurs. The transaction owns that source independently of cache eviction. Missing placement -or an incomplete source returns None. Nested acquisition borrows held children. +or an incomplete source returns None. Expansion to an unmodified ancestor +borrows held children; a new descendant scope inside a held complete source is +unavailable because that source does not own every descendant arrangement input. Screen's mutation hook now returns original roots mapped to these placements (or None for the exact published fallback). Both resource kinds capture native @@ -17,6 +27,16 @@ screen clip, distinct from the acquired body's incoming/expanded paint clip. Mutation release calls `Screen.check_idle()`. Screen resumes its retained layout members; the caller does not invalidate a window or manufacture a layout request. Native scroll/source epochs remain unchanged, including capture currentness. +Layout messages record actionable intent before the message pump's queued next +callbacks run. Callback admission also derives that intent from the original +pending members after release; it can precede Idle. It uses the same acquired +mutation mapping and the existing layout-intent owner. Geometry wakeups do not +create new requests for members already consumed by that publication. + +An opaque source whose destination changes retains exact original rectangles. +It still owns captured membership and self-paint ancestry: children now covered +by their original parent's retained paint cannot return through the final held +snapshot merge. An incomplete fallback remains placed and partial. First affected batch at 01ccf09d: six passes, two failures. One control incorrectly asserted that a held RLock already meant a source mutation; it now checks the @@ -26,6 +46,29 @@ an overlay. The corrected clip and release checks passed (3 / 0.81s); that resul predates the shared captured-parent representation change. Initial tool output is retained in the original conversation; neither result is overall acceptance. +The shared source/ancestry batch then passed twelve checks and exposed one real +opaque-source regression: after scrolling, fallback restored children covered +by retained self-paint. The corrected fallback, original overlay retirement and +held-scroll checks passed (3 / 0.85s). The unchanged ancestor expansion plus +explicit descendant refusal passed (1 / 0.46s). + +Ordinary pending-member release passed in the first callback batch. Two queue +control negatives are retained: they incorrectly required layout intent still +to be false when another original admission could already have recorded it. +The final real message-pump control releases on the same pump that acquired the +transaction, queues admission within that callback delivery, and verifies the +sender stays queued until actual layout completes (1 / 0.39s). No held paint +damage hides the pending layout requirement. + +Raw logs and the existing Package AST census are retained beside this file. +The census covers production and current tests with zero parse omissions; +historical evidence scripts retain their original private API shape. + Parent owns matching history transaction/reader lifetime and saved-history -application verification. No installed change or latency gain is claimed here. +application verification. Parent reports four matching source application paths +passed, but source was changing during those runs; the final committed pair still +needs its immutable saved-history measurement. No installed change or latency +gain is claimed here. Explicit complete geometry acquisition remains synchronous. +One uncached custom visual can still render a whole widget within a band; no +renderer deadline or readiness exemption is introduced. Historical evidence scripts remain at their original source/API scope in Git. diff --git a/evidence/subtree-mutation-source-20261008/callback-release-controls.txt b/evidence/subtree-mutation-source-20261008/callback-release-controls.txt new file mode 100644 index 0000000000..95c9b0cc89 --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/callback-release-controls.txt @@ -0,0 +1,2 @@ +. [100%] +1 passed in 0.39s diff --git a/evidence/subtree-mutation-source-20261008/callback-release-initial.txt b/evidence/subtree-mutation-source-20261008/callback-release-initial.txt new file mode 100644 index 0000000000..c8207609fb --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/callback-release-initial.txt @@ -0,0 +1,230 @@ +F. [100%] +=================================== FAILURES =================================== +___________ test_released_layout_fences_queued_callbacks_before_idle ___________ + +fut = , timeout = 2 + + async def wait_for(fut, timeout): + """Wait for the single Future or coroutine to complete, with timeout. + + Coroutine will be wrapped in Task. + + Returns result of the Future or coroutine. When a timeout occurs, + it cancels the task and raises TimeoutError. To avoid the task + cancellation, wrap it in shield(). + + If the wait is cancelled, the task is also cancelled. + + If the task suppresses the cancellation and returns a value instead, + that value is returned. + + This function is a coroutine. + """ + # The special case for timeout <= 0 is for the following case: + # + # async def test_waitfor(): + # func_started = False + # + # async def func(): + # nonlocal func_started + # func_started = True + # + # try: + # await asyncio.wait_for(func(), 0) + # except asyncio.TimeoutError: + # assert not func_started + # else: + # assert False + # + # asyncio.run(test_waitfor()) + + + if timeout is not None and timeout <= 0: + fut = ensure_future(fut) + + if fut.done(): + return fut.result() + + await _cancel_and_wait(fut) + try: + return fut.result() + except exceptions.CancelledError as exc: + raise TimeoutError from exc + + async with timeouts.timeout(timeout): +> return await fut + ^^^^^^^^^ + +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/tasks.py:488: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = + + async def wait(self): + """Block until the internal flag is true. + + If the internal flag is true on entry, return True + immediately. Otherwise, block until another task calls + set() to set the flag to true, then return True. + """ + if self._value: + return True + + fut = self._get_loop().create_future() + self._waiters.append(fut) + try: +> await fut +E asyncio.exceptions.CancelledError + +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/locks.py:213: CancelledError + +The above exception was the direct cause of the following exception: + + async def test_released_layout_fences_queued_callbacks_before_idle(): + app = IndependentApp() + completed = asyncio.Event() + admitted = asyncio.Event() + order: list[str] = [] + async with app.run_test(size=(40, 8)) as pilot: + await pilot.pause() + screen = app.screen + original = app.query_one("#original") + holder = app.query_one(HeldVertical) + + def complete() -> None: + assert asyncio.current_task() is original.task + order.append("completion") + completed.set() + + async with screen.preserve_layout(holder): + # A genuine geometry-only refresh leaves no paint damage to mask + # the pending layout fence. The sender still awaits that request. + original.refresh(repaint=False, layout=True) + original.call_after_refresh(complete) + await pilot.pause() + assert screen._layout_widgets + assert not screen._refresh_pending + assert any(sender is original for _, sender in screen._callbacks) + + def queued_admission() -> None: + assert asyncio.current_task() is screen.task + assert not screen._layout_required, "This admission precedes Idle" + assert screen._layout_widgets and not screen._layout_mutation_roots() + screen._invoke_and_clear_callbacks() + assert screen._layout_required + assert any(sender is original for _, sender in screen._callbacks) + order.append("queued admission") + admitted.set() + + screen.screen_layout_refresh_signal.subscribe( + app, lambda _: order.append("layout") + ) + # Dispatch an ordinary callback message; its original next-callback + # phase runs before the pump's Idle admission, without a direct call + # to Idle or publication from this test task. + screen.call_later(lambda: order.append("message")) + screen.call_next(queued_admission) +> await asyncio.wait_for(admitted.wait(), 2) + +tests/test_batch_paint_admission.py:268: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/tasks.py:487: in wait_for + async with timeouts.timeout(timeout): + ^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +exc_type = +exc_val = CancelledError(), exc_tb = + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + assert self._state in (_State.ENTERED, _State.EXPIRING) + + if self._timeout_handler is not None: + self._timeout_handler.cancel() + self._timeout_handler = None + + if self._state is _State.EXPIRING: + self._state = _State.EXPIRED + + if self._task.uncancel() <= self._cancelling and exc_type is not None: + # Since there are no new cancel requests, we're + # handling this. + if issubclass(exc_type, exceptions.CancelledError): +> raise TimeoutError from exc_val +E TimeoutError + +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/timeouts.py:114: TimeoutError + +During handling of the above exception, another exception occurred: + + async def test_released_layout_fences_queued_callbacks_before_idle(): + app = IndependentApp() + completed = asyncio.Event() + admitted = asyncio.Event() + order: list[str] = [] +> async with app.run_test(size=(40, 8)) as pilot: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests/test_batch_paint_admission.py:229: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/contextlib.py:235: in __aexit__ + await self.gen.athrow(value) +src/textual/app.py:2228: in run_test + raise self._exception +src/textual/message_pump.py:833: in _flush_next_callbacks + await invoke(callback.callback) +src/textual/_callback.py:97: in invoke + return await _invoke(callback, *params) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +src/textual/_callback.py:57: in _invoke + result = callback(*params[:parameter_count]) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def queued_admission() -> None: + assert asyncio.current_task() is screen.task +> assert not screen._layout_required, "This admission precedes Idle" +E AssertionError: This admission precedes Idle +E assert not True +E + where True = IndependentScreen()._layout_required + +tests/test_batch_paint_admission.py:252: AssertionError +----------------------------- Captured stderr call ----------------------------- +╭───────────────────── Traceback (most recent call last) ──────────────────────╮ +│ /home/ts/wt/textual-frame-publication-20261006/tests/test_batch_paint_admiss │ +│ ion.py:252 in queued_admission │ +│ │ +│ 249 │ │ │ +│ 250 │ │ def queued_admission() -> None: │ +│ 251 │ │ │ assert asyncio.current_task() is screen.task │ +│ ❱ 252 │ │ │ assert not screen._layout_required, "This admission preced │ +│ 253 │ │ │ assert screen._layout_widgets and not screen._layout_mutat │ +│ 254 │ │ │ screen._invoke_and_clear_callbacks() │ +│ 255 │ │ │ assert screen._layout_required │ +│ │ +│ ╭───────────────────────────────── locals ─────────────────────────────────╮ │ +│ │ @py_assert1 = True │ │ +│ │ @py_assert3 = False │ │ +│ │ @py_assert5 = None │ │ +│ │ @py_assert7 = None │ │ +│ │ @py_format4 = 'This admission precedes Idle\n>assert not True\n{True = │ │ +│ │ IndependentScreen()._layou'+12 │ │ +│ │ admitted = │ │ +│ │ order = ['message'] │ │ +│ │ original = Label(id='original') │ │ +│ │ screen = IndependentScreen() │ │ +│ ╰──────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +AssertionError: This admission precedes Idle +assert not True + + where True = IndependentScreen()._layout_required +=========================== short test summary info ============================ +FAILED tests/test_batch_paint_admission.py::test_released_layout_fences_queued_callbacks_before_idle +1 failed, 1 passed in 2.71s diff --git a/evidence/subtree-mutation-source-20261008/callback-release-ordering-negative.txt b/evidence/subtree-mutation-source-20261008/callback-release-ordering-negative.txt new file mode 100644 index 0000000000..7093266d84 --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/callback-release-ordering-negative.txt @@ -0,0 +1,241 @@ +F [100%] +=================================== FAILURES =================================== +___________ test_released_layout_fences_queued_callbacks_before_idle ___________ + +fut = , timeout = 2 + + async def wait_for(fut, timeout): + """Wait for the single Future or coroutine to complete, with timeout. + + Coroutine will be wrapped in Task. + + Returns result of the Future or coroutine. When a timeout occurs, + it cancels the task and raises TimeoutError. To avoid the task + cancellation, wrap it in shield(). + + If the wait is cancelled, the task is also cancelled. + + If the task suppresses the cancellation and returns a value instead, + that value is returned. + + This function is a coroutine. + """ + # The special case for timeout <= 0 is for the following case: + # + # async def test_waitfor(): + # func_started = False + # + # async def func(): + # nonlocal func_started + # func_started = True + # + # try: + # await asyncio.wait_for(func(), 0) + # except asyncio.TimeoutError: + # assert not func_started + # else: + # assert False + # + # asyncio.run(test_waitfor()) + + + if timeout is not None and timeout <= 0: + fut = ensure_future(fut) + + if fut.done(): + return fut.result() + + await _cancel_and_wait(fut) + try: + return fut.result() + except exceptions.CancelledError as exc: + raise TimeoutError from exc + + async with timeouts.timeout(timeout): +> return await fut + ^^^^^^^^^ + +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/tasks.py:488: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = + + async def wait(self): + """Block until the internal flag is true. + + If the internal flag is true on entry, return True + immediately. Otherwise, block until another task calls + set() to set the flag to true, then return True. + """ + if self._value: + return True + + fut = self._get_loop().create_future() + self._waiters.append(fut) + try: +> await fut +E asyncio.exceptions.CancelledError + +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/locks.py:213: CancelledError + +The above exception was the direct cause of the following exception: + + async def test_released_layout_fences_queued_callbacks_before_idle(): + app = IndependentApp() + completed = asyncio.Event() + admitted = asyncio.Event() + order: list[str] = [] + async with app.run_test(size=(40, 8)) as pilot: + await pilot.pause() + screen = app.screen + original = app.query_one("#original") + holder = app.query_one(HeldVertical) + transaction = screen.preserve_layout(holder) + entered = asyncio.Event() + + async def enter() -> None: + await transaction.__aenter__() + entered.set() + + def complete() -> None: + assert asyncio.current_task() is original.task + order.append("completion") + completed.set() + + screen.call_later(enter) + await asyncio.wait_for(entered.wait(), 2) + # A genuine geometry-only refresh leaves no paint damage to mask + # the pending layout fence. The sender still awaits that request. + original.refresh(repaint=False, layout=True) + original.call_after_refresh(complete) + await pilot.pause() + assert screen._layout_widgets + assert not screen._refresh_pending + assert any(sender is original for _, sender in screen._callbacks) + + def queued_admission() -> None: + assert asyncio.current_task() is screen.task + assert not screen._layout_required, "This admission precedes Idle" + assert screen._layout_widgets and not screen._layout_mutation_roots() + screen._invoke_and_clear_callbacks() + assert screen._layout_required + assert any(sender is original for _, sender in screen._callbacks) + order.append("queued admission") + admitted.set() + + screen.screen_layout_refresh_signal.subscribe( + app, lambda _: order.append("layout") + ) + + async def release() -> None: + # Enter and release on the same native pump. Its ordinary callback + # message owns this release and queues admission before Idle. + await transaction.__aexit__(None, None, None) + order.append("message") + screen.call_next(queued_admission) + + screen.call_later(release) +> await asyncio.wait_for(admitted.wait(), 2) + +tests/test_batch_paint_admission.py:279: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/tasks.py:487: in wait_for + async with timeouts.timeout(timeout): + ^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + +self = +exc_type = +exc_val = CancelledError(), exc_tb = + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> bool | None: + assert self._state in (_State.ENTERED, _State.EXPIRING) + + if self._timeout_handler is not None: + self._timeout_handler.cancel() + self._timeout_handler = None + + if self._state is _State.EXPIRING: + self._state = _State.EXPIRED + + if self._task.uncancel() <= self._cancelling and exc_type is not None: + # Since there are no new cancel requests, we're + # handling this. + if issubclass(exc_type, exceptions.CancelledError): +> raise TimeoutError from exc_val +E TimeoutError + +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/asyncio/timeouts.py:114: TimeoutError + +During handling of the above exception, another exception occurred: + + async def test_released_layout_fences_queued_callbacks_before_idle(): + app = IndependentApp() + completed = asyncio.Event() + admitted = asyncio.Event() + order: list[str] = [] +> async with app.run_test(size=(40, 8)) as pilot: + ^^^^^^^^^^^^^^^^^^^^^^^^^^ + +tests/test_batch_paint_admission.py:229: +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ +../../.local/share/uv/python/cpython-3.14.2-linux-x86_64-gnu/lib/python3.14/contextlib.py:235: in __aexit__ + await self.gen.athrow(value) +src/textual/app.py:2228: in run_test + raise self._exception +src/textual/message_pump.py:833: in _flush_next_callbacks + await invoke(callback.callback) +src/textual/_callback.py:97: in invoke + return await _invoke(callback, *params) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +src/textual/_callback.py:57: in _invoke + result = callback(*params[:parameter_count]) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ + + def queued_admission() -> None: + assert asyncio.current_task() is screen.task +> assert not screen._layout_required, "This admission precedes Idle" +E AssertionError: This admission precedes Idle +E assert not True +E + where True = IndependentScreen()._layout_required + +tests/test_batch_paint_admission.py:259: AssertionError +----------------------------- Captured stderr call ----------------------------- +╭───────────────────── Traceback (most recent call last) ──────────────────────╮ +│ /home/ts/wt/textual-frame-publication-20261006/tests/test_batch_paint_admiss │ +│ ion.py:259 in queued_admission │ +│ │ +│ 256 │ │ │ +│ 257 │ │ def queued_admission() -> None: │ +│ 258 │ │ │ assert asyncio.current_task() is screen.task │ +│ ❱ 259 │ │ │ assert not screen._layout_required, "This admission preced │ +│ 260 │ │ │ assert screen._layout_widgets and not screen._layout_mutat │ +│ 261 │ │ │ screen._invoke_and_clear_callbacks() │ +│ 262 │ │ │ assert screen._layout_required │ +│ │ +│ ╭───────────────────────────────── locals ─────────────────────────────────╮ │ +│ │ @py_assert1 = True │ │ +│ │ @py_assert3 = False │ │ +│ │ @py_assert5 = None │ │ +│ │ @py_assert7 = None │ │ +│ │ @py_format4 = 'This admission precedes Idle\n>assert not True\n{True = │ │ +│ │ IndependentScreen()._layou'+12 │ │ +│ │ admitted = │ │ +│ │ order = ['message'] │ │ +│ │ original = Label(id='original') │ │ +│ │ screen = IndependentScreen() │ │ +│ ╰──────────────────────────────────────────────────────────────────────────╯ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +AssertionError: This admission precedes Idle +assert not True + + where True = IndependentScreen()._layout_required +=========================== short test summary info ============================ +FAILED tests/test_batch_paint_admission.py::test_released_layout_fences_queued_callbacks_before_idle +1 failed in 2.51s diff --git a/evidence/subtree-mutation-source-20261008/fallback-controls.txt b/evidence/subtree-mutation-source-20261008/fallback-controls.txt new file mode 100644 index 0000000000..fd2d3e58e6 --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/fallback-controls.txt @@ -0,0 +1,2 @@ +... [100%] +3 passed in 0.85s diff --git a/evidence/subtree-mutation-source-20261008/final-controls.txt b/evidence/subtree-mutation-source-20261008/final-controls.txt new file mode 100644 index 0000000000..3e701bb028 --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/final-controls.txt @@ -0,0 +1,69 @@ +......F...... [100%] +=================================== FAILURES =================================== +____ test_held_original_ancestry_honors_a_nested_self_painting_owner[True] _____ + +screen_overlay = True + + @pytest.mark.parametrize("screen_overlay", [False, True]) + async def test_held_original_ancestry_honors_a_nested_self_painting_owner(screen_overlay): + from textual.content import Content + + class HeldGroup(VerticalGroup): + CACHE_SUBTREE_GEOMETRY = True + + def arrange(self, size, optimal=False): + assert self not in self.screen._layout_mutation_roots(), "Read changing child topology" + return super().arrange(size, optimal=optimal) + + class ContentBody(Static): + # This application declares a native child view for its initial text + # and a self-painted view when it acquires its actual Content resource. + @property + def is_container(self): + return not isinstance(self.content, Content) + + holder = HeldGroup(id="holder") + + class ChangingChild(Static): + def render_lines(self, crop): + assert not holder.lock.is_locked, "Read changing descendant paint" + return super().render_lines(crop) + + body = ContentBody("INITIAL_NATIVE_PARENT", id="content-body") + old = ChangingChild("OLD_NATIVE_CHILD") + if screen_overlay: + old.styles.overlay = "screen" + + class ContentScreen(TargetedScreen): + CSS = "#content-body { height: 18; } Static { height: 2; }" + + def compose(self): + with VerticalScroll(id="history"): + yield holder + + app = App() + async with app.run_test(size=(40, 10)) as pilot: + screen = ContentScreen() + screen.targets = (holder,) + await app.push_screen(screen) + await holder.mount(body) + await body.mount(old) + await pilot.pause() + compositor = screen._compositor + resource = compositor.acquire_subtree_geometry(holder).source + assert resource.contains(old) + assert resource.captured_parent(old) is body + async with app.screen.preserve_layout(holder): + body.update(Content("\n".join(["ACQUIRED_ROOT_PAINT"] * 18))) + await body.mount(ChangingChild("UNCOMMITTED_DESCENDANT")) + screen.query_one("#history").scroll_to(y=4, animate=False, immediate=True) + await pilot.pause() + assert compositor._subtree_geometry[holder] is resource +> assert old not in compositor._published_map +E AssertionError: assert ChangingChild() not in {ChangingChild(): MapGeometry(region=Region(x=0, y=0, width=38, height=2), order=((1, 0, 0),), clip=Region(x=0, y=0, w...10), virtual_region=Region(x=38, y=0, width=2, height=10), dock_gutter=Spacing(top=0, right=0, bottom=0, left=0)), ...} +E + where {ChangingChild(): MapGeometry(region=Region(x=0, y=0, width=38, height=2), order=((1, 0, 0),), clip=Region(x=0, y=0, w...10), virtual_region=Region(x=38, y=0, width=2, height=10), dock_gutter=Spacing(top=0, right=0, bottom=0, left=0)), ...} = ._published_map + +tests/test_viewport_geometry_targets.py:473: AssertionError +=========================== short test summary info ============================ +FAILED tests/test_viewport_geometry_targets.py::test_held_original_ancestry_honors_a_nested_self_painting_owner[True] +1 failed, 12 passed in 3.38s diff --git a/evidence/subtree-mutation-source-20261008/nested-scope-control.txt b/evidence/subtree-mutation-source-20261008/nested-scope-control.txt new file mode 100644 index 0000000000..babe1cf510 --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/nested-scope-control.txt @@ -0,0 +1,2 @@ +. [100%] +1 passed in 0.46s diff --git a/evidence/subtree-mutation-source-20261008/owner-consumers.json b/evidence/subtree-mutation-source-20261008/owner-consumers.json new file mode 100644 index 0000000000..bb7012c6ed --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/owner-consumers.json @@ -0,0 +1,3730 @@ +{ + "before_revision": "57523e1b", + "after_source_tree": "f32f1472e901293eaab0516d09ed2e578a7f6800", + "scope": "Original Package parsed Git source trees; dynamic external subclasses and historical evidence scripts are not claimed migrated.", + "revisions": { + "57523e1b": { + "src": { + "modules": 250, + "omissions": [], + "classes": [ + { + "path": "src/textual/_compositor.py", + "line": 69, + "class": "SubtreeGeometryKey", + "bases": [ + "NamedTuple" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 113, + "class": "SceneClip", + "bases": [ + "ABC" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 137, + "class": "RootSceneClip", + "bases": [ + "SceneClip" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 151, + "class": "NestedSceneClip", + "bases": [ + "SceneClip" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 169, + "class": "SubtreeMapGeometry", + "bases": [ + "NamedTuple" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 202, + "class": "SubtreeGeometryPlacement", + "bases": [] + }, + { + "path": "src/textual/_compositor.py", + "line": 226, + "class": "SubtreeGeometry", + "bases": [ + "ABC", + "Generic[GeometryEntry]" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 415, + "class": "PlacedSubtreeGeometry", + "bases": [ + "SubtreeGeometry[MapGeometry]" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 434, + "class": "IntrinsicSubtreeGeometry", + "bases": [ + "SubtreeGeometry[SubtreeMapGeometry]" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 734, + "class": "Compositor", + "bases": [] + }, + { + "path": "src/textual/screen.py", + "line": 144, + "class": "Screen", + "bases": [ + "Generic[ScreenResultType]", + "Widget" + ] + } + ], + "imports": [ + [ + "src/textual/_compositor.py", + 55, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/_context.py", + 10, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/app.py", + 120, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/await_remove.py", + 19, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/command.py", + 49, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/demo/game.py", + 27, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/demo/page.py", + 11, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/dom.py", + 70, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/dom.py", + 872, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/message_pump.py", + 56, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/renderables/background_screen.py", + 12, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/screen.py", + 39, + "textual._compositor", + "Compositor", + null + ], + [ + "src/textual/selection.py", + 231, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/selection.py", + 328, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widget.py", + 735, + "textual._compositor", + "IntrinsicSubtreeGeometry", + null + ], + [ + "src/textual/widget.py", + 735, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "src/textual/widget.py", + 1755, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widget.py", + 804, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_footer.py", + 19, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_input.py", + 16, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_key_panel.py", + 18, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_text_area.py", + 40, + "textual.screen", + "Screen", + null + ] + ], + "sites": [ + [ + "src/textual/_compositor.py", + 236, + "declaration", + "complete_arrangement" + ], + [ + "src/textual/_compositor.py", + 240, + "declaration", + "capture" + ], + [ + "src/textual/_compositor.py", + 320, + "declaration", + "matches" + ], + [ + "src/textual/_compositor.py", + 391, + "declaration", + "_paint_entries" + ], + [ + "src/textual/_compositor.py", + 418, + "declaration", + "matches" + ], + [ + "src/textual/_compositor.py", + 438, + "declaration", + "complete_arrangement" + ], + [ + "src/textual/_compositor.py", + 442, + "declaration", + "capture" + ], + [ + "src/textual/_compositor.py", + 471, + "declaration", + "matches" + ], + [ + "src/textual/_compositor.py", + 500, + "declaration", + "_paint_entries" + ], + [ + "src/textual/_compositor.py", + 1046, + "declaration", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 90, + "Load", + "_geometry_revision" + ], + [ + "src/textual/_compositor.py", + 371, + "Load", + "_paint_entries" + ], + [ + "src/textual/_compositor.py", + 468, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 907, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 957, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 2128, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 444, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 1007, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 1063, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 1400, + "Load", + "complete_arrangement" + ], + [ + "src/textual/_compositor.py", + 455, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 458, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 1407, + "Load", + "matches" + ], + [ + "src/textual/_compositor.py", + 1435, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 1563, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 296, + "Load", + "source_held" + ], + [ + "src/textual/_compositor.py", + 1345, + "Load", + "matches" + ], + [ + "src/textual/_compositor.py", + 378, + "Load", + "source_held" + ], + [ + "src/textual/_compositor.py", + 1381, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 1546, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/app.py", + 3811, + "declaration", + "_request_layout" + ], + [ + "src/textual/app.py", + 4410, + "declaration", + "_on_layout" + ], + [ + "src/textual/app.py", + 5052, + "declaration", + "_on_idle" + ], + [ + "src/textual/app.py", + 3813, + "Load", + "_request_layout" + ], + [ + "src/textual/app.py", + 3850, + "Load", + "_refresh_layout" + ], + [ + "src/textual/css/styles.py", + 1067, + "Load", + "_request_layout" + ], + [ + "src/textual/css/styles.py", + 1075, + "Load", + "_request_layout" + ], + [ + "src/textual/dom.py", + 705, + "declaration", + "_request_layout" + ], + [ + "src/textual/dom.py", + 150, + "Store", + "_geometry_revision" + ], + [ + "src/textual/dom.py", + 154, + "Store", + "_geometry_revision" + ], + [ + "src/textual/dom.py", + 1419, + "Load", + "_request_layout" + ], + [ + "src/textual/layouts/stream.py", + 58, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1233, + "declaration", + "_on_idle" + ], + [ + "src/textual/screen.py", + 1258, + "declaration", + "_layout_mutation_roots" + ], + [ + "src/textual/screen.py", + 1390, + "declaration", + "_invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1469, + "declaration", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1482, + "declaration", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1491, + "declaration", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1592, + "declaration", + "_on_layout" + ], + [ + "src/textual/screen.py", + 326, + "Store", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1252, + "Load", + "_invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1471, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1473, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/screen.py", + 1370, + "Load", + "_invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1450, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1541, + "Store", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1603, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1605, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1685, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1766, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1356, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1504, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1541, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1604, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1616, + "Load", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1651, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1704, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1358, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1508, + "Load", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1617, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1653, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1475, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1488, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1606, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1406, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/widget.py", + 4619, + "declaration", + "_refresh_scroll" + ], + [ + "src/textual/widget.py", + 4874, + "declaration", + "_on_idle" + ], + [ + "src/textual/widget.py", + 5037, + "declaration", + "_request_layout" + ], + [ + "src/textual/widget.py", + 2192, + "Load", + "_refresh_scroll" + ], + [ + "src/textual/widget.py", + 2200, + "Load", + "_refresh_scroll" + ], + [ + "src/textual/widget.py", + 4657, + "Load", + "_request_layout" + ], + [ + "src/textual/widgets/_data_table.py", + 1870, + "declaration", + "_on_idle" + ], + [ + "src/textual/widgets/_tree.py", + 1248, + "declaration", + "_on_idle" + ] + ] + }, + "tests": { + "modules": 481, + "omissions": [], + "classes": [ + { + "path": "tests/test_batch_paint_admission.py", + "line": 149, + "class": "IndependentScreen", + "bases": [ + "ObservedScreen" + ] + } + ], + "imports": [ + [ + "tests/command_palette/test_command_source_environment.py", + 5, + "textual.screen", + "Screen", + null + ], + [ + "tests/command_palette/test_declare_sources.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/css/test_css_reloading.py", + 5, + "textual.screen", + "Screen", + null + ], + [ + "tests/css/test_screen_css.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/layouts/test_common_layout_features.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/notifications/test_all_levels_notifications.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/shutdown_mode_installed_pilot.py", + 4, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/auto-table.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/bindings_screen_overrides_show.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py", + 5, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/layer_order_independence.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/notification_through_modes.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/notification_through_screens.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/recompose_on_mount.py", + 4, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/screen_switch.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/text_area_alternate_screen.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/test_snapshots.py", + 33, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_app.py", + 11, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_batch_paint_admission.py", + 10, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_binding.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_binding_inheritance.py", + 18, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_closed_presentation_lifetime.py", + 5, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_closed_presentation_lifetime.py", + 9, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_command.py", + 7, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_complete_placed_geometry.py", + 1, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_complete_placed_geometry.py", + 1, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_compositor_frame_damage.py", + 61, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_compositor_regions_to_spans.py", + 1, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_focus.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_freeze.py", + 4, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_geometry_reader_acquisition.py", + 4, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_geometry_reader_acquisition.py", + 7, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_header.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_links.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_native_scene_size_publication.py", + 12, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_pilot.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_primary_selection_gesture.py", + 7, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_refresh_callback_owner.py", + 8, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_removed_scene_ownership.py", + 6, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_reparent.py", + 9, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screen_modes.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screen_resolution_lifetime.py", + 6, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screens.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screens.py", + 548, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 5, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 57, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 57, + "textual._compositor", + "SubtreeGeometryPlacement", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 59, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_unmount.py", + 6, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 7, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 151, + "textual._compositor", + "IntrinsicSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 151, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 151, + "textual._compositor", + "RootSceneClip", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 151, + "textual._compositor", + "SubtreeGeometryKey", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 151, + "textual._compositor", + "SubtreeGeometryPlacement", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 151, + "textual._compositor", + "SubtreeMapGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 261, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 261, + "textual._compositor", + "IntrinsicSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 370, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 488, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 488, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_viewport_layout.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_widget.py", + 329, + "textual.screen", + "Screen", + null + ] + ], + "sites": [ + [ + "tests/css/test_programmatic_style_changes.py", + 55, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 66, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 24, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 29, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 39, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 58, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 69, + "Load", + "_geometry_revision" + ], + [ + "tests/participant_content_update_pilot.py", + 46, + "Load", + "_geometry_revision" + ], + [ + "tests/participant_content_update_pilot.py", + 57, + "Load", + "_geometry_revision" + ], + [ + "tests/test_batch_paint_admission.py", + 154, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 158, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 207, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 54, + "Load", + "_refresh_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 196, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 244, + "Load", + "_on_idle" + ], + [ + "tests/test_batch_paint_admission.py", + 331, + "Load", + "_refresh_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 333, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 257, + "Load", + "_on_idle" + ], + [ + "tests/test_batch_paint_admission.py", + 318, + "Load", + "_refresh_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 321, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 330, + "Load", + "_on_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 314, + "Load", + "_layout_widgets" + ], + [ + "tests/test_closed_presentation_lifetime.py", + 57, + "Load", + "_layout_widgets" + ], + [ + "tests/test_compositor.py", + 251, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor.py", + 106, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor.py", + 162, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor.py", + 194, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 230, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 318, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 335, + "Load", + "_arrange_root" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 18, + "declaration", + "_arrange_root" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 20, + "Load", + "_arrange_root" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 72, + "Load", + "_refresh_layout" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 120, + "Load", + "_refresh_layout" + ], + [ + "tests/test_height_independent_arrangement.py", + 32, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 35, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 48, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 51, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 58, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 62, + "Load", + "_geometry_revision" + ], + [ + "tests/test_native_scene_size_publication.py", + 26, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_native_scene_size_publication.py", + 32, + "declaration", + "_refresh_layout" + ], + [ + "tests/test_native_scene_size_publication.py", + 57, + "Load", + "_layout_widgets" + ], + [ + "tests/test_native_scene_size_publication.py", + 65, + "Load", + "_layout_widgets" + ], + [ + "tests/test_native_scene_size_publication.py", + 30, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_native_scene_size_publication.py", + 36, + "Load", + "_refresh_layout" + ], + [ + "tests/test_native_scene_size_publication.py", + 34, + "Load", + "_has_actionable_layout_requests" + ], + [ + "tests/test_native_scene_size_publication.py", + 34, + "Load", + "_held_layout_requests" + ], + [ + "tests/test_static.py", + 103, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 38, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 58, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 63, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 98, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 105, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 122, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 45, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 56, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 60, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 65, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 100, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 124, + "Load", + "_geometry_revision" + ], + [ + "tests/test_subtree_geometry_cache.py", + 69, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_subtree_geometry_cache.py", + 104, + "Load", + "source_held" + ], + [ + "tests/test_subtree_geometry_cache.py", + 26, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 40, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 49, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 99, + "Load", + "capture" + ], + [ + "tests/test_subtree_geometry_cache.py", + 107, + "Load", + "matches" + ], + [ + "tests/test_subtree_geometry_cache.py", + 111, + "Load", + "capture" + ], + [ + "tests/test_subtree_geometry_cache.py", + 117, + "Load", + "capture" + ], + [ + "tests/test_subtree_geometry_cache.py", + 24, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 42, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 47, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 73, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_subtree_geometry_cache.py", + 106, + "Load", + "matches" + ], + [ + "tests/test_subtree_geometry_cache.py", + 124, + "Load", + "matches" + ], + [ + "tests/test_subtree_geometry_cache.py", + 155, + "Load", + "_arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 281, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_viewport_geometry_targets.py", + 390, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_viewport_geometry_targets.py", + 455, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_viewport_geometry_targets.py", + 693, + "Load", + "_arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 597, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 647, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 648, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 661, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 686, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 723, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 302, + "Load", + "matches" + ], + [ + "tests/test_viewport_geometry_targets.py", + 306, + "Load", + "matches" + ], + [ + "tests/test_viewport_geometry_targets.py", + 520, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 524, + "Load", + "_arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 566, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 617, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 622, + "Load", + "_refresh_layout" + ], + [ + "tests/utilities/render.py", + 20, + "Load", + "capture" + ] + ] + } + }, + "f32f1472e901293eaab0516d09ed2e578a7f6800": { + "src": { + "modules": 250, + "omissions": [], + "classes": [ + { + "path": "src/textual/_compositor.py", + "line": 67, + "class": "SubtreeGeometryKey", + "bases": [ + "NamedTuple" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 111, + "class": "SceneClip", + "bases": [ + "ABC" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 135, + "class": "RootSceneClip", + "bases": [ + "SceneClip" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 149, + "class": "NestedSceneClip", + "bases": [ + "SceneClip" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 167, + "class": "SubtreeMapGeometry", + "bases": [ + "NamedTuple" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 197, + "class": "SubtreeGeometryPlacement", + "bases": [] + }, + { + "path": "src/textual/_compositor.py", + "line": 221, + "class": "SubtreeGeometry", + "bases": [ + "ABC" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 444, + "class": "PlacedSubtreeGeometry", + "bases": [ + "SubtreeGeometry" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 455, + "class": "IntrinsicSubtreeGeometry", + "bases": [ + "SubtreeGeometry" + ] + }, + { + "path": "src/textual/_compositor.py", + "line": 715, + "class": "Compositor", + "bases": [] + }, + { + "path": "src/textual/screen.py", + "line": 145, + "class": "Screen", + "bases": [ + "Generic[ScreenResultType]", + "Widget" + ] + } + ], + "imports": [ + [ + "src/textual/_compositor.py", + 53, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/_context.py", + 10, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/app.py", + 120, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/await_remove.py", + 19, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/command.py", + 49, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/demo/game.py", + 27, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/demo/page.py", + 11, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/dom.py", + 70, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/dom.py", + 872, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/message_pump.py", + 56, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/renderables/background_screen.py", + 12, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/screen.py", + 40, + "textual._compositor", + "Compositor", + null + ], + [ + "src/textual/screen.py", + 40, + "textual._compositor", + "SubtreeGeometryPlacement", + null + ], + [ + "src/textual/selection.py", + 231, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/selection.py", + 328, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widget.py", + 735, + "textual._compositor", + "IntrinsicSubtreeGeometry", + null + ], + [ + "src/textual/widget.py", + 735, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "src/textual/widget.py", + 1755, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widget.py", + 804, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_footer.py", + 19, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_input.py", + 16, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_key_panel.py", + 18, + "textual.screen", + "Screen", + null + ], + [ + "src/textual/widgets/_text_area.py", + 40, + "textual.screen", + "Screen", + null + ] + ], + "sites": [ + [ + "src/textual/_compositor.py", + 231, + "declaration", + "capture" + ], + [ + "src/textual/_compositor.py", + 319, + "declaration", + "matches" + ], + [ + "src/textual/_compositor.py", + 335, + "declaration", + "_matches_placement" + ], + [ + "src/textual/_compositor.py", + 408, + "declaration", + "_paint_entries" + ], + [ + "src/textual/_compositor.py", + 447, + "declaration", + "_matches_placement" + ], + [ + "src/textual/_compositor.py", + 459, + "declaration", + "capture" + ], + [ + "src/textual/_compositor.py", + 483, + "declaration", + "_matches_placement" + ], + [ + "src/textual/_compositor.py", + 1027, + "declaration", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 1707, + "declaration", + "acquire_subtree_geometry" + ], + [ + "src/textual/_compositor.py", + 88, + "Load", + "_geometry_revision" + ], + [ + "src/textual/_compositor.py", + 332, + "Load", + "_matches_placement" + ], + [ + "src/textual/_compositor.py", + 384, + "Load", + "_paint_entries" + ], + [ + "src/textual/_compositor.py", + 480, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 888, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 938, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 1046, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 1727, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 2166, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 461, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 988, + "Load", + "_arrange_root" + ], + [ + "src/textual/_compositor.py", + 471, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 474, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 1415, + "Load", + "matches" + ], + [ + "src/textual/_compositor.py", + 1440, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 1574, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 296, + "Load", + "source_held" + ], + [ + "src/textual/_compositor.py", + 1336, + "Load", + "matches" + ], + [ + "src/textual/_compositor.py", + 395, + "Load", + "source_held" + ], + [ + "src/textual/_compositor.py", + 1386, + "Load", + "capture" + ], + [ + "src/textual/_compositor.py", + 1557, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/_compositor.py", + 1371, + "Load", + "_paint_entries" + ], + [ + "src/textual/_compositor.py", + 1724, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/app.py", + 3811, + "declaration", + "_request_layout" + ], + [ + "src/textual/app.py", + 4410, + "declaration", + "_on_layout" + ], + [ + "src/textual/app.py", + 5052, + "declaration", + "_on_idle" + ], + [ + "src/textual/app.py", + 3813, + "Load", + "_request_layout" + ], + [ + "src/textual/app.py", + 3850, + "Load", + "_refresh_layout" + ], + [ + "src/textual/css/styles.py", + 1067, + "Load", + "_request_layout" + ], + [ + "src/textual/css/styles.py", + 1075, + "Load", + "_request_layout" + ], + [ + "src/textual/dom.py", + 705, + "declaration", + "_request_layout" + ], + [ + "src/textual/dom.py", + 150, + "Store", + "_geometry_revision" + ], + [ + "src/textual/dom.py", + 154, + "Store", + "_geometry_revision" + ], + [ + "src/textual/dom.py", + 1419, + "Load", + "_request_layout" + ], + [ + "src/textual/layouts/stream.py", + 58, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1234, + "declaration", + "_on_idle" + ], + [ + "src/textual/screen.py", + 1266, + "declaration", + "_layout_mutation_roots" + ], + [ + "src/textual/screen.py", + 1402, + "declaration", + "_invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1488, + "declaration", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1504, + "declaration", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1513, + "declaration", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1614, + "declaration", + "_on_layout" + ], + [ + "src/textual/screen.py", + 327, + "Store", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1260, + "Load", + "_invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1492, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1634, + "Load", + "_request_layout" + ], + [ + "src/textual/screen.py", + 1243, + "Load", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1246, + "Load", + "_request_layout" + ], + [ + "src/textual/screen.py", + 1382, + "Load", + "_invoke_and_clear_callbacks" + ], + [ + "src/textual/screen.py", + 1469, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1495, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/screen.py", + 1563, + "Store", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1624, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1626, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1635, + "Load", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1702, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1783, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1244, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1368, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1417, + "Load", + "_layout_mutation_roots" + ], + [ + "src/textual/screen.py", + 1421, + "Load", + "_request_layout" + ], + [ + "src/textual/screen.py", + 1526, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1563, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1625, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1635, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1668, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1721, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1370, + "Load", + "_refresh_layout" + ], + [ + "src/textual/screen.py", + 1422, + "Load", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1530, + "Load", + "_has_actionable_layout_requests" + ], + [ + "src/textual/screen.py", + 1670, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1423, + "Load", + "_held_layout_requests" + ], + [ + "src/textual/screen.py", + 1497, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1510, + "Load", + "_layout_widgets" + ], + [ + "src/textual/screen.py", + 1627, + "Load", + "_layout_widgets" + ], + [ + "src/textual/widget.py", + 4619, + "declaration", + "_refresh_scroll" + ], + [ + "src/textual/widget.py", + 4874, + "declaration", + "_on_idle" + ], + [ + "src/textual/widget.py", + 5037, + "declaration", + "_request_layout" + ], + [ + "src/textual/widget.py", + 2192, + "Load", + "_refresh_scroll" + ], + [ + "src/textual/widget.py", + 2200, + "Load", + "_refresh_scroll" + ], + [ + "src/textual/widget.py", + 4657, + "Load", + "_request_layout" + ], + [ + "src/textual/widgets/_data_table.py", + 1870, + "declaration", + "_on_idle" + ], + [ + "src/textual/widgets/_tree.py", + 1248, + "declaration", + "_on_idle" + ] + ] + }, + "tests": { + "modules": 481, + "omissions": [], + "classes": [ + { + "path": "tests/test_batch_paint_admission.py", + "line": 149, + "class": "IndependentScreen", + "bases": [ + "ObservedScreen" + ] + } + ], + "imports": [ + [ + "tests/command_palette/test_command_source_environment.py", + 5, + "textual.screen", + "Screen", + null + ], + [ + "tests/command_palette/test_declare_sources.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/css/test_css_reloading.py", + 5, + "textual.screen", + "Screen", + null + ], + [ + "tests/css/test_screen_css.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/layouts/test_common_layout_features.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/notifications/test_all_levels_notifications.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/shutdown_mode_installed_pilot.py", + 4, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/auto-table.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/bindings_screen_overrides_show.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/hot_reloading_app_with_screen_css.py", + 5, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/layer_order_independence.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/notification_through_modes.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/notification_through_screens.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/recompose_on_mount.py", + 4, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/screen_switch.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/snapshot_apps/text_area_alternate_screen.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/snapshot_tests/test_snapshots.py", + 33, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_app.py", + 11, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_batch_paint_admission.py", + 10, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_binding.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_binding_inheritance.py", + 18, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_closed_presentation_lifetime.py", + 5, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_closed_presentation_lifetime.py", + 9, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_command.py", + 7, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_complete_placed_geometry.py", + 1, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_complete_placed_geometry.py", + 1, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_compositor_frame_damage.py", + 61, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_compositor_regions_to_spans.py", + 1, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_focus.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_freeze.py", + 4, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_geometry_reader_acquisition.py", + 4, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_geometry_reader_acquisition.py", + 7, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_header.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_links.py", + 2, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_native_scene_size_publication.py", + 4, + "tests.test_batch_paint_admission", + "IndependentScreen", + null + ], + [ + "tests/test_pilot.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_primary_selection_gesture.py", + 7, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_refresh_callback_owner.py", + 8, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_removed_scene_ownership.py", + 6, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_reparent.py", + 9, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screen_modes.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screen_resolution_lifetime.py", + 6, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screens.py", + 13, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_screens.py", + 548, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 6, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 7, + "tests.test_batch_paint_admission", + "IndependentScreen", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 59, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 59, + "textual._compositor", + "SubtreeGeometryPlacement", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 186, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_subtree_geometry_cache.py", + 186, + "textual._compositor", + "IntrinsicSubtreeGeometry", + null + ], + [ + "tests/test_unmount.py", + 6, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 7, + "tests.test_batch_paint_admission", + "IndependentScreen", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 10, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 154, + "textual._compositor", + "IntrinsicSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 154, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 154, + "textual._compositor", + "RootSceneClip", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 154, + "textual._compositor", + "SubtreeGeometryKey", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 154, + "textual._compositor", + "SubtreeGeometryPlacement", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 154, + "textual._compositor", + "SubtreeMapGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 264, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 264, + "textual._compositor", + "IntrinsicSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 370, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 485, + "textual._compositor", + "Compositor", + null + ], + [ + "tests/test_viewport_geometry_targets.py", + 485, + "textual._compositor", + "PlacedSubtreeGeometry", + null + ], + [ + "tests/test_viewport_layout.py", + 3, + "textual.screen", + "Screen", + null + ], + [ + "tests/test_widget.py", + 329, + "textual.screen", + "Screen", + null + ] + ], + "sites": [ + [ + "tests/css/test_programmatic_style_changes.py", + 55, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 66, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 24, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 29, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 39, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 58, + "Load", + "_geometry_revision" + ], + [ + "tests/css/test_programmatic_style_changes.py", + 69, + "Load", + "_geometry_revision" + ], + [ + "tests/participant_content_update_pilot.py", + 46, + "Load", + "_geometry_revision" + ], + [ + "tests/participant_content_update_pilot.py", + 57, + "Load", + "_geometry_revision" + ], + [ + "tests/test_batch_paint_admission.py", + 167, + "declaration", + "_layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 253, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 220, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 54, + "Load", + "_refresh_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 141, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 159, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_batch_paint_admission.py", + 171, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 209, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 259, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 260, + "Load", + "_invoke_and_clear_callbacks" + ], + [ + "tests/test_batch_paint_admission.py", + 282, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 318, + "Load", + "_on_idle" + ], + [ + "tests/test_batch_paint_admission.py", + 405, + "Load", + "_refresh_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 407, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 331, + "Load", + "_on_idle" + ], + [ + "tests/test_batch_paint_admission.py", + 392, + "Load", + "_refresh_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 395, + "Load", + "_layout_widgets" + ], + [ + "tests/test_batch_paint_admission.py", + 404, + "Load", + "_on_layout" + ], + [ + "tests/test_batch_paint_admission.py", + 259, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_batch_paint_admission.py", + 388, + "Load", + "_layout_widgets" + ], + [ + "tests/test_closed_presentation_lifetime.py", + 57, + "Load", + "_layout_widgets" + ], + [ + "tests/test_compositor.py", + 251, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor.py", + 106, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor.py", + 162, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor.py", + 194, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 230, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 318, + "Load", + "_arrange_root" + ], + [ + "tests/test_compositor_frame_damage.py", + 335, + "Load", + "_arrange_root" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 18, + "declaration", + "_arrange_root" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 20, + "Load", + "_arrange_root" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 72, + "Load", + "_refresh_layout" + ], + [ + "tests/test_geometry_reader_acquisition.py", + 120, + "Load", + "_refresh_layout" + ], + [ + "tests/test_height_independent_arrangement.py", + 32, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 35, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 48, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 51, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 58, + "Load", + "_geometry_revision" + ], + [ + "tests/test_height_independent_arrangement.py", + 62, + "Load", + "_geometry_revision" + ], + [ + "tests/test_native_scene_size_publication.py", + 25, + "declaration", + "_refresh_layout" + ], + [ + "tests/test_native_scene_size_publication.py", + 50, + "Load", + "_layout_widgets" + ], + [ + "tests/test_native_scene_size_publication.py", + 59, + "Load", + "_layout_widgets" + ], + [ + "tests/test_native_scene_size_publication.py", + 29, + "Load", + "_refresh_layout" + ], + [ + "tests/test_native_scene_size_publication.py", + 42, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_native_scene_size_publication.py", + 27, + "Load", + "_has_actionable_layout_requests" + ], + [ + "tests/test_native_scene_size_publication.py", + 27, + "Load", + "_held_layout_requests" + ], + [ + "tests/test_static.py", + 103, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 38, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 58, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 63, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 98, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 105, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 122, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 45, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 56, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 60, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 65, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 100, + "Load", + "_geometry_revision" + ], + [ + "tests/test_static.py", + 124, + "Load", + "_geometry_revision" + ], + [ + "tests/test_subtree_geometry_cache.py", + 99, + "Load", + "source_held" + ], + [ + "tests/test_subtree_geometry_cache.py", + 28, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 42, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 51, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 94, + "Load", + "capture" + ], + [ + "tests/test_subtree_geometry_cache.py", + 102, + "Load", + "matches" + ], + [ + "tests/test_subtree_geometry_cache.py", + 106, + "Load", + "capture" + ], + [ + "tests/test_subtree_geometry_cache.py", + 112, + "Load", + "capture" + ], + [ + "tests/test_subtree_geometry_cache.py", + 26, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 44, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 49, + "Load", + "_arrange_root" + ], + [ + "tests/test_subtree_geometry_cache.py", + 101, + "Load", + "matches" + ], + [ + "tests/test_subtree_geometry_cache.py", + 119, + "Load", + "matches" + ], + [ + "tests/test_subtree_geometry_cache.py", + 208, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_subtree_geometry_cache.py", + 236, + "Load", + "_layout_widgets" + ], + [ + "tests/test_subtree_geometry_cache.py", + 262, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_subtree_geometry_cache.py", + 268, + "Load", + "source_held" + ], + [ + "tests/test_subtree_geometry_cache.py", + 261, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_subtree_geometry_cache.py", + 150, + "Load", + "_arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 690, + "Load", + "_arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 594, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 644, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 645, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 658, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 683, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 720, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 297, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_viewport_geometry_targets.py", + 302, + "Load", + "matches" + ], + [ + "tests/test_viewport_geometry_targets.py", + 306, + "Load", + "matches" + ], + [ + "tests/test_viewport_geometry_targets.py", + 397, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_viewport_geometry_targets.py", + 464, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_viewport_geometry_targets.py", + 517, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 521, + "Load", + "_arrange_root" + ], + [ + "tests/test_viewport_geometry_targets.py", + 558, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/test_viewport_geometry_targets.py", + 563, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 614, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 270, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_viewport_geometry_targets.py", + 376, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_viewport_geometry_targets.py", + 426, + "Load", + "_layout_mutation_roots" + ], + [ + "tests/test_viewport_geometry_targets.py", + 619, + "Load", + "_refresh_layout" + ], + [ + "tests/test_viewport_geometry_targets.py", + 520, + "Load", + "acquire_subtree_geometry" + ], + [ + "tests/utilities/render.py", + 20, + "Load", + "capture" + ] + ] + } + } + } +} diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 626fcb7409..1f719a5f0a 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -1361,11 +1361,23 @@ 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. + snapshot = held[widget] + if resource is not None and resource.complete: + # A changed destination refuses translation, not the + # original source's membership and self-paint lifetime. + # Filter the committed rectangles along captured parent + # edges; never infer ancestry from the changing DOM. + snapshot = dict( + resource._paint_entries( + ((node, geometry) for node, geometry in snapshot.items() + if resource.contains(node)), + source_held=True, + ) + ) 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, @@ -1379,9 +1391,12 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible source, partial_key, clip, source_held=True ) else: - for node, geometry in held[widget].items(): + for node, geometry in snapshot.items(): store_geometry(node, geometry, RootSceneClip(geometry.clip)) - widgets.update(held[widget]) + widgets.update(snapshot) + # The filtered original membership replaces this held + # fallback; the outer merge must not resurrect old children. + held[widget].clear() return explicit_root = capture_root and widget is root cache_source = bool(self.max_subtree_geometry_entries and widget.CACHE_SUBTREE_GEOMETRY) @@ -1700,6 +1715,15 @@ def acquire_subtree_geometry(self, root: Widget) -> SubtreeGeometryPlacement | N geometry = self._get_published_geometry(root) if geometry is None or self._arranging: return None + # An enclosing held source owns this original member. Acquiring a new + # descendant scope would read its changing DOM; the containing source + # does not retain every descendant's independent arrangement inputs. + # Expansion to an unmodified ancestor remains a complete acquisition. + if any( + owner is not root and placement is not None and placement.source.contains(root) + for owner, placement in root.screen._layout_mutation_roots().items() + ): + return None _, _, placement = self._arrange_root( root, self.size, visible_only=False, root_geometry=geometry, root_clip=RootSceneClip(geometry.clip), diff --git a/src/textual/screen.py b/src/textual/screen.py index 4ebc86f78d..472115386d 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1243,7 +1243,7 @@ async def _on_idle(self, event: events.Idle) -> None: if not self._layout_required and self._has_actionable_layout_requests( self._held_layout_requests() ): - self._layout_required = True + self._request_layout() if not self.app._batch_count and self.is_current: if self._refresh_requested or self._dirty_widgets: self._update_timer.resume() @@ -1413,9 +1413,16 @@ def _invoke_and_clear_callbacks(self, roots: tuple[Widget, ...] | None = None) - # That publication does not complete the mutating sender's work. # Acquire source custody once for this synchronous admission, # separately from the original paint-readiness roots. - roots = (*roots, *(root - for screen in (*self.app._background_screens, self) - for root in screen._layout_mutation_roots())) + for screen in (*self.app._background_screens, self): + mutation_sources = screen._layout_mutation_roots() + roots = (*roots, *mutation_sources) + # A queued next callback may run before Idle following source + # release. Retained requests must fence that admission too. + screen._request_layout( + screen._has_actionable_layout_requests( + screen._held_layout_requests(mutation_sources) + ) + ) index = 0 for _ in range(len(self._callbacks)): if self.app._batch_count or index >= len(self._callbacks): @@ -1478,11 +1485,14 @@ def _layout_geometry_targets(self) -> tuple[Widget, ...]: """Additional native geometry required by a viewport-layout transaction.""" return () - def _held_layout_requests(self) -> dict[DOMNode, set[Widget]]: + def _held_layout_requests( + self, mutation_roots: Iterable[Widget] | None = None, + ) -> dict[DOMNode, set[Widget]]: """Borrow pending requests whose subtree still owns committed geometry.""" if not self._layout_widgets: return {} - mutation_roots = self._layout_mutation_roots() + if mutation_roots is None: + mutation_roots = self._layout_mutation_roots() return { owner: held for owner, members in self._layout_widgets.items() if (held := { @@ -1619,6 +1629,11 @@ async def _on_layout(self, message: messages.Layout) -> None: break widget = ancestor + # Next callbacks can follow this message before Idle; record the + # original pending layout intent before their sender admission. + self._request_layout( + self._has_actionable_layout_requests(self._held_layout_requests()) + ) self.check_idle() async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: diff --git a/tests/test_batch_paint_admission.py b/tests/test_batch_paint_admission.py index b5afed16d9..fb649ef2e5 100644 --- a/tests/test_batch_paint_admission.py +++ b/tests/test_batch_paint_admission.py @@ -221,6 +221,67 @@ async def test_released_pending_layout_resumes_on_its_existing_owner_request(): assert not app._exception +async def test_released_layout_fences_queued_callbacks_before_idle(): + app = IndependentApp() + completed = asyncio.Event() + admitted = asyncio.Event() + order: list[str] = [] + async with app.run_test(size=(40, 8)) as pilot: + await pilot.pause() + screen = app.screen + original = app.query_one("#original") + holder = app.query_one(HeldVertical) + transaction = screen.preserve_layout(holder) + entered = asyncio.Event() + + async def enter() -> None: + await transaction.__aenter__() + entered.set() + + def complete() -> None: + assert asyncio.current_task() is original.task + order.append("completion") + completed.set() + + screen.call_later(enter) + await asyncio.wait_for(entered.wait(), 2) + # A genuine geometry-only refresh leaves no paint damage to mask + # the pending layout fence. The sender still awaits that request. + original.refresh(repaint=False, layout=True) + original.call_after_refresh(complete) + await pilot.pause() + assert screen._layout_widgets + assert not screen._refresh_pending + assert any(sender is original for _, sender in screen._callbacks) + + def queued_admission() -> None: + assert asyncio.current_task() is screen.task + assert screen._layout_widgets and not screen._layout_mutation_roots() + screen._invoke_and_clear_callbacks() + assert screen._layout_required + assert any(sender is original for _, sender in screen._callbacks) + order.append("queued admission") + admitted.set() + + screen.screen_layout_refresh_signal.subscribe( + app, lambda _: order.append("layout") + ) + + async def release() -> None: + # Enter and release on the same native pump. Its ordinary callback + # message owns this release and queues admission before Idle. + await transaction.__aexit__(None, None, None) + order.append("message") + screen.call_next(queued_admission) + + screen.call_later(release) + await asyncio.wait_for(admitted.wait(), 2) + await asyncio.wait_for(completed.wait(), 2) + assert order[:2] == ["message", "queued admission"] + assert order.index("layout") < order.index("completion") + assert not screen._layout_widgets and not app._exception + + @pytest.mark.parametrize("held", [False, True]) async def test_idle_shares_acquired_roots_and_paint_reacquires(held): from textual import events diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index a71e9fa458..34199a403b 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -258,6 +258,7 @@ def compose(self): incoming = Static("NEW_UNCOMMITTED_CHILD") await child.mount(incoming) await pilot.pause() + assert compositor.acquire_subtree_geometry(original) is None expanded = compositor.acquire_subtree_geometry(history) assert expanded is not None and expanded.source.complete assert expanded.source.contains(original) From 1daec79b0af978a292a37bc9f00f793dc8dd0eee Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 18:09:30 -0400 Subject: [PATCH 6/7] Reuse partial geometry for acquired position demands --- .../README.md | 43 +++- .../owner-consumers.json | 223 ++++++++++++++++++ .../retained-position-control.txt | 2 + src/textual/_compositor.py | 64 ++--- tests/test_subtree_geometry_cache.py | 90 +++++++ 5 files changed, 387 insertions(+), 35 deletions(-) create mode 100644 evidence/subtree-mutation-source-20261008/retained-position-control.txt diff --git a/evidence/subtree-mutation-source-20261008/README.md b/evidence/subtree-mutation-source-20261008/README.md index 3980ff0ac0..e4c78dc601 100644 --- a/evidence/subtree-mutation-source-20261008/README.md +++ b/evidence/subtree-mutation-source-20261008/README.md @@ -65,10 +65,45 @@ The census covers production and current tests with zero parse omissions; historical evidence scripts retain their original private API shape. Parent owns matching history transaction/reader lifetime and saved-history -application verification. Parent reports four matching source application paths -passed, but source was changing during those runs; the final committed pair still -needs its immutable saved-history measurement. No installed change or latency -gain is claimed here. Explicit complete geometry acquisition remains synchronous. +application verification. Four matching source application paths passed. The +immutable 3c7d5ec0/de83aa39 saved-history run then completed without application +error: median 25.1ms, p95 210.7ms, maximum 369.6ms versus published 32.8/191.3/228.2ms. +The tail regressed; this pair is not published and no overall gain is claimed. +The subsequent diagnostic main-thread sample found 142 samples through ordinary +arrangement and six through explicit acquisition. Those samples neither identify +acquisition as the cause nor supply call counts or CPU durations. +Explicit complete geometry acquisition remains synchronous. One uncached custom visual can still render a whole widget within a band; no renderer deadline or readiness exemption is introduced. Historical evidence scripts remain at their original source/API scope in Git. + +## Requested positions in partial geometry + +Ordinary visible arrangement previously required a complete source for every +retained position path. A matching partial source therefore could not supply a +position it already owned: the compositor bypassed the capture/reuse path and +descended again. The same original acquired path relation now carries the actual +requested positions to each ancestor. `SubtreeGeometry.matches` checks placement +first, then requires those positions in its captured geometry. Complete sources +also own absent answers; partial logical/invisible membership does not establish +a position. Missing positions use the original acquisition path, and the new +partial source can supply them on the next unchanged frame. Full capture still +requires complete scope; held-source, revision, internal scroll, clip and layer +checks remain intact. + +Source projection intersects those requests with its original immutable geometry +membership before routing them through borrowed children. It preserves original +ordinals, captured ancestry and last-assignment ordering, without scanning every +unrelated global request in every child source. The synchronous ancestor relation +still walks each requested target's path; this change does not remove that work +or make partial placed geometry translatable. + +One affected real native App check passed (0.64s): 100 rows, acquired and missing +offscreen targets, changing target membership, scroll/reversal and a child height +write. Every phase compares actual published geometry and rendered strips with +the original uncached compositor. An already covered partial source is reused; +a missing target is acquired before reuse, and that partial source still refuses +complete capture. This is geometry/paint confirmation, not saved-history latency +acceptance. The next affected saved-history run belongs to Parent; no installed +or public change is made here. Raw control output and the original Package +before/after consumer census are retained beside this file. diff --git a/evidence/subtree-mutation-source-20261008/owner-consumers.json b/evidence/subtree-mutation-source-20261008/owner-consumers.json index bb7012c6ed..5f236d3945 100644 --- a/evidence/subtree-mutation-source-20261008/owner-consumers.json +++ b/evidence/subtree-mutation-source-20261008/owner-consumers.json @@ -3726,5 +3726,228 @@ ] } } + }, + "retained_position_followup": { + "before_revision": "3c7d5ec036056a94c57d554104b31c8a0689417a", + "after_source_tree": "5d286c94eb501080c46043bf949c58ce6920478b", + "relation": "Original partial geometry may supply requested positions it owns; full capture still requires complete scope. Projection routes retained paths through immutable captured geometry membership. Placement, revisions, internal scrolling and held-source checks are unchanged.", + "scope": "Original Package src/tests census. Lexical sites are read against the actual source owner; external dynamic subclasses and private callers are not proven by this census.", + "revisions": { + "3c7d5ec036056a94c57d554104b31c8a0689417a": { + "src": { + "modules": 250, + "omissions": [], + "declarations": [ + "src/textual/_compositor.py:266 contains(self, node: Widget)", + "src/textual/_compositor.py:319 matches(self, key: SubtreeGeometryKey, *, require_complete: bool=False, source_held: bool=False)", + "src/textual/_compositor.py:335 _matches_placement(self, key: SubtreeGeometryKey)", + "src/textual/_compositor.py:337 restore_into(self, geometry: CompositorMap, widgets: set[Widget], invisible_widgets: set[Widget], key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, *, visible_only: bool, retained: set[Widget], bounds: Region, source_held: bool=False)", + "src/textual/_compositor.py:349 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, require_root: bool=True)", + "src/textual/_compositor.py:447 _matches_placement(self, key: SubtreeGeometryKey)", + "src/textual/_compositor.py:483 _matches_placement(self, key: SubtreeGeometryKey)", + "src/textual/geometry.py:270 contains(self, x: int, y: int)", + "src/textual/geometry.py:682 contains(self, x: int, y: int)", + "src/textual/layout.py:83 get_visible_placements(self, region: Region, *, retain: Iterable[Widget]=())", + "src/textual/screen.py:1484 _layout_geometry_targets(self)" + ], + "sites": [ + "src/textual/_compositor.py:204 Load SubtreeGeometry", + "src/textual/_compositor.py:332 Load _matches_placement", + "src/textual/_compositor.py:343 Load project_into", + "src/textual/_compositor.py:392 Load project_into", + "src/textual/_compositor.py:444 Load SubtreeGeometry", + "src/textual/_compositor.py:455 Load SubtreeGeometry", + "src/textual/_compositor.py:461 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:468 Load IntrinsicSubtreeGeometry", + "src/textual/_compositor.py:471 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:474 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:755 Load SubtreeGeometry", + "src/textual/_compositor.py:889 Load retain_geometry", + "src/textual/_compositor.py:939 Load retain_geometry", + "src/textual/_compositor.py:1093 Store retained_paths", + "src/textual/_compositor.py:1098 Load retain_geometry", + "src/textual/_compositor.py:1106 Load retained_paths", + "src/textual/_compositor.py:1110 Load retained_paths", + "src/textual/_compositor.py:1111 Load retained_paths", + "src/textual/_compositor.py:1193 Load get_visible_placements", + "src/textual/_compositor.py:1195 Load retained_paths", + "src/textual/_compositor.py:1336 Load contains", + "src/textual/_compositor.py:1336 Load matches", + "src/textual/_compositor.py:1349 Load restore_into", + "src/textual/_compositor.py:1352 Load retained_paths", + "src/textual/_compositor.py:1359 Load contains", + "src/textual/_compositor.py:1373 Load contains", + "src/textual/_compositor.py:1386 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:1414 Load retained_paths", + "src/textual/_compositor.py:1415 Load matches", + "src/textual/_compositor.py:1454 Load restore_into", + "src/textual/_compositor.py:1456 Load retained_paths", + "src/textual/_compositor.py:1555 Load contains", + "src/textual/_compositor.py:1558 Load contains", + "src/textual/_compositor.py:1575 Load contains", + "src/textual/_compositor.py:1723 Load contains", + "src/textual/_compositor.py:1732 Load contains", + "src/textual/screen.py:1525 Load _layout_geometry_targets", + "src/textual/widget.py:737 Load IntrinsicSubtreeGeometry", + "src/textual/widget.py:737 Load PlacedSubtreeGeometry" + ] + }, + "tests": { + "modules": 481, + "omissions": [], + "declarations": [ + "tests/test_viewport_geometry_targets.py:251 _layout_geometry_targets(self)" + ], + "sites": [ + "tests/test_complete_placed_geometry.py:34 Load PlacedSubtreeGeometry", + "tests/test_geometry_reader_acquisition.py:19 Load retain_geometry", + "tests/test_subtree_geometry_cache.py:86 Load contains", + "tests/test_subtree_geometry_cache.py:101 Load matches", + "tests/test_subtree_geometry_cache.py:102 Load matches", + "tests/test_subtree_geometry_cache.py:106 Load PlacedSubtreeGeometry", + "tests/test_subtree_geometry_cache.py:116 Load contains", + "tests/test_subtree_geometry_cache.py:117 Load contains", + "tests/test_subtree_geometry_cache.py:119 Load matches", + "tests/test_subtree_geometry_cache.py:130 Load contains", + "tests/test_subtree_geometry_cache.py:164 Load contains", + "tests/test_subtree_geometry_cache.py:175 Load contains", + "tests/test_subtree_geometry_cache.py:210 Load PlacedSubtreeGeometry", + "tests/test_subtree_geometry_cache.py:211 Load contains", + "tests/test_subtree_geometry_cache.py:217 Load IntrinsicSubtreeGeometry", + "tests/test_subtree_geometry_cache.py:218 Load contains", + "tests/test_subtree_geometry_cache.py:264 Load contains", + "tests/test_subtree_geometry_cache.py:265 Load contains", + "tests/test_subtree_geometry_cache.py:266 Load contains", + "tests/test_viewport_geometry_targets.py:174 Load IntrinsicSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:182 Load PlacedSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:189 Load contains", + "tests/test_viewport_geometry_targets.py:190 Load contains", + "tests/test_viewport_geometry_targets.py:191 Load contains", + "tests/test_viewport_geometry_targets.py:192 Load contains", + "tests/test_viewport_geometry_targets.py:197 Load project_into", + "tests/test_viewport_geometry_targets.py:298 Load IntrinsicSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:299 Load contains", + "tests/test_viewport_geometry_targets.py:302 Load matches", + "tests/test_viewport_geometry_targets.py:306 Load matches", + "tests/test_viewport_geometry_targets.py:398 Load PlacedSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:405 Load contains", + "tests/test_viewport_geometry_targets.py:465 Load contains", + "tests/test_viewport_geometry_targets.py:520 Load PlacedSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:559 Load contains", + "tests/test_viewport_geometry_targets.py:568 Load contains" + ] + } + }, + "5d286c94eb501080c46043bf949c58ce6920478b": { + "src": { + "modules": 250, + "omissions": [], + "declarations": [ + "src/textual/_compositor.py:267 contains(self, node: Widget)", + "src/textual/_compositor.py:320 matches(self, key: SubtreeGeometryKey, *, require_complete: bool=False, source_held: bool=False, required_geometry: Iterable[Widget]=())", + "src/textual/_compositor.py:342 _matches_placement(self, key: SubtreeGeometryKey)", + "src/textual/_compositor.py:344 restore_into(self, geometry: CompositorMap, widgets: set[Widget], invisible_widgets: set[Widget], key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, *, visible_only: bool, retained: AbstractSet[Widget], bounds: Region, source_held: bool=False)", + "src/textual/_compositor.py:356 project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, clip: SceneClip, clips: dict[Widget, SceneClip], root: Widget, *, visible_only: bool, retained: AbstractSet[Widget], bounds: Region, source_held: bool=False, require_root: bool=True)", + "src/textual/_compositor.py:456 _matches_placement(self, key: SubtreeGeometryKey)", + "src/textual/_compositor.py:492 _matches_placement(self, key: SubtreeGeometryKey)", + "src/textual/geometry.py:270 contains(self, x: int, y: int)", + "src/textual/geometry.py:682 contains(self, x: int, y: int)", + "src/textual/layout.py:83 get_visible_placements(self, region: Region, *, retain: Iterable[Widget]=())", + "src/textual/screen.py:1484 _layout_geometry_targets(self)" + ], + "sites": [ + "src/textual/_compositor.py:205 Load SubtreeGeometry", + "src/textual/_compositor.py:334 Load _matches_placement", + "src/textual/_compositor.py:339 Load contains", + "src/textual/_compositor.py:339 Load required_geometry", + "src/textual/_compositor.py:350 Load project_into", + "src/textual/_compositor.py:401 Load project_into", + "src/textual/_compositor.py:453 Load SubtreeGeometry", + "src/textual/_compositor.py:464 Load SubtreeGeometry", + "src/textual/_compositor.py:470 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:477 Load IntrinsicSubtreeGeometry", + "src/textual/_compositor.py:480 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:483 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:764 Load SubtreeGeometry", + "src/textual/_compositor.py:898 Load retain_geometry", + "src/textual/_compositor.py:948 Load retain_geometry", + "src/textual/_compositor.py:1102 Store retained_paths", + "src/textual/_compositor.py:1107 Load retain_geometry", + "src/textual/_compositor.py:1118 Load retained_paths", + "src/textual/_compositor.py:1200 Load get_visible_placements", + "src/textual/_compositor.py:1202 Load retained_paths", + "src/textual/_compositor.py:1343 Load contains", + "src/textual/_compositor.py:1343 Load matches", + "src/textual/_compositor.py:1356 Load restore_into", + "src/textual/_compositor.py:1359 Load retained_paths", + "src/textual/_compositor.py:1366 Load contains", + "src/textual/_compositor.py:1380 Load contains", + "src/textual/_compositor.py:1393 Load PlacedSubtreeGeometry", + "src/textual/_compositor.py:1421 Load matches", + "src/textual/_compositor.py:1423 Load retained_paths", + "src/textual/_compositor.py:1456 Load restore_into", + "src/textual/_compositor.py:1458 Load retained_paths", + "src/textual/_compositor.py:1557 Load contains", + "src/textual/_compositor.py:1560 Load contains", + "src/textual/_compositor.py:1577 Load contains", + "src/textual/_compositor.py:1725 Load contains", + "src/textual/_compositor.py:1734 Load contains", + "src/textual/screen.py:1525 Load _layout_geometry_targets", + "src/textual/widget.py:737 Load IntrinsicSubtreeGeometry", + "src/textual/widget.py:737 Load PlacedSubtreeGeometry" + ] + }, + "tests": { + "modules": 481, + "omissions": [], + "declarations": [ + "tests/test_subtree_geometry_cache.py:44 _layout_geometry_targets(self)", + "tests/test_viewport_geometry_targets.py:251 _layout_geometry_targets(self)" + ], + "sites": [ + "tests/test_complete_placed_geometry.py:34 Load PlacedSubtreeGeometry", + "tests/test_geometry_reader_acquisition.py:19 Load retain_geometry", + "tests/test_subtree_geometry_cache.py:54 Load contains", + "tests/test_subtree_geometry_cache.py:55 Load contains", + "tests/test_subtree_geometry_cache.py:70 Load matches", + "tests/test_subtree_geometry_cache.py:82 Load contains", + "tests/test_subtree_geometry_cache.py:176 Load contains", + "tests/test_subtree_geometry_cache.py:191 Load matches", + "tests/test_subtree_geometry_cache.py:192 Load matches", + "tests/test_subtree_geometry_cache.py:196 Load PlacedSubtreeGeometry", + "tests/test_subtree_geometry_cache.py:206 Load contains", + "tests/test_subtree_geometry_cache.py:207 Load contains", + "tests/test_subtree_geometry_cache.py:209 Load matches", + "tests/test_subtree_geometry_cache.py:220 Load contains", + "tests/test_subtree_geometry_cache.py:254 Load contains", + "tests/test_subtree_geometry_cache.py:265 Load contains", + "tests/test_subtree_geometry_cache.py:300 Load PlacedSubtreeGeometry", + "tests/test_subtree_geometry_cache.py:301 Load contains", + "tests/test_subtree_geometry_cache.py:307 Load IntrinsicSubtreeGeometry", + "tests/test_subtree_geometry_cache.py:308 Load contains", + "tests/test_subtree_geometry_cache.py:354 Load contains", + "tests/test_subtree_geometry_cache.py:355 Load contains", + "tests/test_subtree_geometry_cache.py:356 Load contains", + "tests/test_viewport_geometry_targets.py:174 Load IntrinsicSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:182 Load PlacedSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:189 Load contains", + "tests/test_viewport_geometry_targets.py:190 Load contains", + "tests/test_viewport_geometry_targets.py:191 Load contains", + "tests/test_viewport_geometry_targets.py:192 Load contains", + "tests/test_viewport_geometry_targets.py:197 Load project_into", + "tests/test_viewport_geometry_targets.py:298 Load IntrinsicSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:299 Load contains", + "tests/test_viewport_geometry_targets.py:302 Load matches", + "tests/test_viewport_geometry_targets.py:306 Load matches", + "tests/test_viewport_geometry_targets.py:398 Load PlacedSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:405 Load contains", + "tests/test_viewport_geometry_targets.py:465 Load contains", + "tests/test_viewport_geometry_targets.py:520 Load PlacedSubtreeGeometry", + "tests/test_viewport_geometry_targets.py:559 Load contains", + "tests/test_viewport_geometry_targets.py:568 Load contains" + ] + } + } + } } } diff --git a/evidence/subtree-mutation-source-20261008/retained-position-control.txt b/evidence/subtree-mutation-source-20261008/retained-position-control.txt new file mode 100644 index 0000000000..dea674fa44 --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/retained-position-control.txt @@ -0,0 +1,2 @@ +. [100%] +1 passed in 0.64s diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 1f719a5f0a..0f3005f8e7 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -22,6 +22,7 @@ from types import MappingProxyType from typing import ( TYPE_CHECKING, + AbstractSet, Callable, Iterable, Iterator, @@ -264,7 +265,7 @@ def _geometry_routes(self) -> Mapping[Widget, tuple[Widget, ...]]: return self._route_geometry({node: entry for node, (_, entry) in self.geometry.items()}) def contains(self, node: Widget) -> bool: - """Membership in this complete source, including borrowed children.""" + """Membership in this acquired geometry, including borrowed children.""" return node in self._geometry_routes def captured_parent(self, node: Widget) -> Widget | None: @@ -317,7 +318,8 @@ def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: return spatial_map def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, - source_held: bool = False) -> bool: + source_held: bool = False, + required_geometry: Iterable[Widget] = ()) -> bool: if not self.reusable and not source_held: return False if require_complete and not self.complete: @@ -329,7 +331,12 @@ def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False, nodes_revision=self.key.nodes_revision) if self.complete and key.visible_only: key = key._replace(visible_only=False) - return self._matches_placement(key) + if not self._matches_placement(key): + return False + # A position demand needs its original placed answer, not every + # offscreen descendant. A complete source also owns absent answers; + # an incomplete source must contain each explicitly requested target. + return self.complete or all(self.contains(node) for node in required_geometry) @abstractmethod def _matches_placement(self, key: SubtreeGeometryKey) -> bool: ... @@ -337,7 +344,7 @@ def _matches_placement(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, *, visible_only: bool, retained: set[Widget], bounds: Region, + root: Widget, *, visible_only: bool, retained: AbstractSet[Widget], bounds: Region, source_held: bool = False, ) -> None: self.project_into(geometry, key, clip, clips, root, @@ -348,7 +355,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, + *, visible_only: bool, retained: AbstractSet[Widget], bounds: Region, source_held: bool = False, require_root: bool = True) -> None: """Publish the requested scene without retiring complete source geometry. @@ -362,7 +369,9 @@ def project_into(self, geometry: CompositorMap, key: SubtreeGeometryKey, candidates = dict(self._spatial_map.get_values_in_region(source_bounds)) if (indexed_root := self.geometry.get(root)) is not None: candidates[indexed_root[0]] = root - for node in retained: + # Captured membership scopes this source's demands. Unrelated + # retained paths must not be rediscovered in every borrowed child. + for node in (retained & self._geometry_routes.keys() if retained else ()): if (indexed := self.geometry.get(node)) is not None: candidates[indexed[0]] = node if isinstance(indexed[1], SubtreeGeometryPlacement): @@ -1090,7 +1099,7 @@ def store_geometry(node: Widget, geometry: MapGeometry, clip: SceneClip) -> None else {name: index for index, name in enumerate(root_layers)} ) default_layers = {"default": 0} - retained_paths: set[Widget] = set() + retained_paths: dict[Widget, set[Widget]] = {} if visible_only: # A held root still needs its new outer placement when scrolling # takes it offscreen. Its resource owns descendants; this acquires @@ -1098,17 +1107,15 @@ def store_geometry(node: Widget, geometry: MapGeometry, clip: SceneClip) -> None for target in (*retain_geometry, *mutation_roots): path: list[Widget] = [] node = target - # Existing members already own a path to this same root. - # Shared ancestors need admission once, not per target. - while ( - isinstance(node, Widget) - and node is not root - and node not in retained_paths - ): + while isinstance(node, Widget) and node is not root: path.append(node) node = node.parent - if node is root or node in retained_paths: - retained_paths.update(path) + if node is root: + # The same acquired relation supplies path admission and + # the targets required within each source. Keys are routing + # ancestors, values are original requested positions. + for member in path: + retained_paths.setdefault(member, set()).add(target) def arrange_widget( widget: Widget, @@ -1192,7 +1199,7 @@ def arrange_widget( if visible_only and not complete: placements = arrange_result.get_visible_placements( sub_clip.region - child_region.offset + widget.scroll_offset, - retain=retained_paths, + retain=retained_paths.keys(), ) else: placements = arrange_result.placements @@ -1349,7 +1356,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible 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, + retained=retained_paths.keys(), bounds=root_geometry.region, source_held=True, ) # The complete resource has replaced these old viewport @@ -1411,19 +1418,14 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible size, visible_only and not complete, tuple(inherited_layers.items()) if inherited_layers is not None else ()) resource = self._subtree_geometry.get(widget) - require_complete = (explicit_root and not visible_only) or (widget in retained_paths and not complete) - matches = resource is not None and resource.matches(key, require_complete=require_complete) - # A complete captured source already owns every requested path. - # A partial or changed source still acquires the original path; - # do not retain a partial capture whose key omits that request. - if require_complete and not matches and not explicit_root: - arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, - complete, inherited_layers) # noqa: F821 -- closure cleared after traversal - return + matches = resource is not None and resource.matches( + key, require_complete=explicit_root and not visible_only, + required_geometry=retained_paths.get(widget, ()), + ) if not matches: - # Capture complete source geometry before its viewport publication. - # Enclosing complete captures borrow all child entries; ordinary - # viewport publication retains only exposed and required boxes. + # Capture this acquisition before its viewport publication. + # Enclosing captures borrow the original child sources; + # viewport acquisition includes exposed and requested boxes. parent_map, parent_widgets, parent_invisible, parent_capture = ( map, widgets, invisible_widgets, capturing_source ) @@ -1453,7 +1455,7 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible 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) + retained=retained_paths.keys(), bounds=root_geometry.region) # Add top level (root) widget previous_layout_geometry = self._layout_geometry diff --git a/tests/test_subtree_geometry_cache.py b/tests/test_subtree_geometry_cache.py index 34199a403b..8c6564b928 100644 --- a/tests/test_subtree_geometry_cache.py +++ b/tests/test_subtree_geometry_cache.py @@ -14,6 +14,96 @@ class CachedGroup(VerticalGroup): CACHE_SUBTREE_GEOMETRY = True +async def test_retained_positions_reuse_covered_partial_source_and_acquire_missing_paths(): + from textual.containers import VerticalScroll + + class ObservedGroup(CachedGroup): + arrangements = 0 + + def arrange(self, size, optimal=False): + self.arrangements += 1 + return super().arrange(size, optimal=optimal) + + rows = [Static(f"ORIGINAL_ROW_{index:03}") for index in range(100)] + source = ObservedGroup(*rows, id="source") + + class PositionScreen(IndependentScreen): + CSS = """ + VerticalScroll { height: 8; } + #source { height: 100; width: 1fr; } + Static { height: 1; } + """ + targets = () + + def compose(self): + yield VerticalScroll(source, id="scroller") + + def _use_viewport_layout(self): + return True + + def _layout_geometry_targets(self): + return self.targets + + app = App() + async with app.run_test(size=(40, 12)) as pilot: + screen = PositionScreen() + await app.push_screen(screen) + await pilot.pause() + compositor = screen._compositor + partial = compositor._subtree_geometry[source] + assert not partial.complete and partial.contains(rows[0]) + assert not partial.contains(rows[90]) + + def compare_original_geometry(): + reference = Compositor(max_subtree_geometry_entries=0) + reference.reflow_visible(screen, app.size, retain_geometry=screen.targets) + assert compositor._published_map == reference._published_map + assert tuple(compositor.render_strips()) == tuple(reference.render_strips()) + + # The partial source owns this existing position. Naming it does not + # turn the request into a complete offscreen descendant acquisition. + screen.targets = (rows[0],) + source.arrangements = 0 + screen._refresh_layout(scroll=True) + assert source.arrangements == 0 + assert compositor._subtree_geometry[source] is partial + assert not partial.matches(partial.key, require_complete=True, + required_geometry=screen.targets) + compare_original_geometry() + + # A newly requested missing path must be acquired. The resulting + # partial source owns both explicit positions on the following frame. + for targets in ((rows[0], rows[90]), (rows[90], rows[40])): + screen.targets = targets + source.arrangements = 0 + screen._refresh_layout(scroll=True) + assert source.arrangements > 0 + acquired = compositor._subtree_geometry[source] + assert not acquired.complete and all(acquired.contains(row) for row in targets) + compare_original_geometry() + source.arrangements = 0 + screen._refresh_layout(scroll=True) + assert source.arrangements == 0 + assert compositor._subtree_geometry[source] is acquired + compare_original_geometry() + + # Placement and child mutation still retire exact partial answers. + scroller = screen.query_one("#scroller", VerticalScroll) + for position in (15, 0): + scroller.scroll_to(y=position, animate=False, immediate=True) + await pilot.pause() + screen._refresh_layout(scroll=True) + compare_original_geometry() + before = compositor._subtree_geometry[source] + rows[40].styles.height = 3 + await pilot.pause() + screen._refresh_layout(scroll=True) + assert compositor._subtree_geometry[source] is not before + assert compositor._published_map[rows[40]].region.height == 3 + compare_original_geometry() + assert not app._exception + + @pytest.mark.parametrize("capacity", [0, 1, 2, 5]) async def test_geometry_budget_bounds_retention_without_changing_the_scene(capacity): app = App() From 73dc4ecf70ecc684f74379d2fb4bb0424fd96f4e Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 18:19:11 -0400 Subject: [PATCH 7/7] Observe actual pending pointer receiver tasks without changing dispatch --- .../pending_pointer_tasks.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 evidence/subtree-mutation-source-20261008/pending_pointer_tasks.py diff --git a/evidence/subtree-mutation-source-20261008/pending_pointer_tasks.py b/evidence/subtree-mutation-source-20261008/pending_pointer_tasks.py new file mode 100644 index 0000000000..9ad0e5157b --- /dev/null +++ b/evidence/subtree-mutation-source-20261008/pending_pointer_tasks.py @@ -0,0 +1,118 @@ +"""Read original pending pointer receipts on the application's event loop. + +Used by the existing saved-history observer during a slow delivery. No handler, +queue, task, future or timer is replaced; nothing is awaited or scheduled here. +Only identities, declaration locations and native custody are returned, never +event text, widget content or arbitrary frame locals. +""" + +import asyncio +import gc +import inspect +from functools import partial + +from textual.message import Message +from textual.message_pump import MessagePump +from textual.await_complete import AwaitCompletion + + +def capture_pointer_wait(app): + """Capture current App forwarding and the FIFO which owns its receipt.""" + tasks = asyncio.all_tasks() + frames_by_task = {} + for task in tasks: + operation = task.get_coro() + frames, visited = [], set() + while operation is not None and id(operation) not in visited: + visited.add(id(operation)) + if inspect.iscoroutine(operation): + frame, operation = operation.cr_frame, operation.cr_await + elif inspect.isgenerator(operation): + frame, operation = operation.gi_frame, operation.gi_yieldfrom + else: + # Coroutine wrappers expose the original coroutine to GC, + # whereas Future iterators do not own another Python frame. + operation = next((item for item in gc.get_referents(operation) + if inspect.iscoroutine(item) or inspect.isgenerator(item)), None) + continue + if frame is not None: + frames.append(frame) + frames_by_task[task] = frames + + def identity(value): + return None if value is None else f"{type(value).__qualname__}@{id(value):x}" + + def future_state(future): + if future is None: + return None + state = {"identity": identity(future), "done": future.done(), + "cancelled": future.cancelled()} + if isinstance(future, asyncio.Task): + state["name"] = future.get_name() + if children := getattr(future, "_children", ()): + state["children"] = [future_state(child) for child in children] + return state + + def task_state(task): + if task is None: + return None + chain = [] + for frame in frames_by_task.get(task, ()): + entry = {"file": frame.f_code.co_filename, + "function": frame.f_code.co_qualname, "line": frame.f_lineno} + owner = frame.f_locals.get("self") + if isinstance(owner, MessagePump): + entry["pump"] = identity(owner) + queue = owner.__dict__.get("_message_queue") + entry["queued"] = 0 if queue is None else queue.qsize() + message = frame.f_locals.get("message", frame.f_locals.get("event", frame.f_locals.get("callback"))) + if isinstance(message, Message): + entry["message"] = identity(message) + callback = getattr(message, "callback", None) + else: + callback = frame.f_locals.get("callback") + while isinstance(callback, partial): + callback = callback.func + if callback is not None: + entry["callback"] = getattr(callback, "__qualname__", type(callback).__qualname__) + if isinstance(owner, AwaitCompletion): + # Observe an already acquired receipt without calling _start: + # removal may otherwise begin its optional completion here. + values = vars(owner) + entry["receipt"] = identity(owner) + entry["receipt_future"] = future_state(values.get("_future", values.get("_completion"))) + chain.append(entry) + state = future_state(task) + state["await_chain"] = chain + # This is the actual Task waiter, including a gather's original children. + # An Event / executor waiter does not prove which external operation + # will release it; the Python await chain remains the evidence there. + state["waiter"] = future_state(getattr(task, "_fut_waiter", None)) + return state + + receipts = [] + for task, frames in frames_by_task.items(): + for frame in frames: + if frame.f_code.co_qualname != "MessagePump._post_message_and_wait..wait_for_dispatch": + continue + message = frame.f_locals["message"] + custody = message._dispatch_completion + if custody is None: + continue + receiver, caller, completion = custody + if caller is not app._task or completion.done(): + continue + queue = receiver.__dict__.get("_message_queue") + receipts.append({ + "message": identity(message), "receiver": identity(receiver), + "queued_on_receiver": 0 if queue is None else queue.qsize(), + "receiver_task": task_state(receiver._task), + "receipt_wait_task": task_state(task), + "completion": future_state(completion), + }) + return { + "app_task": task_state(app._task), "forwarded_receipts": receipts, + # Preserve original concurrent await chains for mount / worker joins; + # no inferred dependency edges are manufactured from similar names. + "tasks": [task_state(task) for task in tasks], + }