diff --git a/evidence/complete-placed-geometry-20261007/README.md b/evidence/complete-placed-geometry-20261007/README.md new file mode 100644 index 0000000000..37dab0ebe3 --- /dev/null +++ b/evidence/complete-placed-geometry-20261007/README.md @@ -0,0 +1,48 @@ +# Complete placed geometry supplies narrower requests + +Native PlacedSubtreeGeometry previously required the capture visible_only flag +to equal the request flag. It therefore discarded an unchanged complete source +when the next transaction requested only visible geometry. The compositor also +bypassed any partial-mode resource when acquiring a retained path, even if its +original capture was complete and already owned that path. + +The existing resource matches method now accepts require_complete. Placed +geometry permits complete-to-visible requests only when every other key field +matches. Partial-to-full remains a miss. Retained requests require the original +complete capture, while IntrinsicSubtreeGeometry retains its existing complete +source and original intrinsic matching. No new state, cache, capture format or +projection behavior. The compositor delegates to this one answer; an incomplete +or changed retained source still uses the original uncached path acquisition. + +Facts independently forcing placement remain geometry/child revision, width and +height, viewport size, region, rank, clip, visibility, dock gutter, inherited +layers and scroll offset. Authored mutations and held-root invalidation remain +unchanged. Full acquisition/viewport projection are different requests against +one complete source, not different source identities. Partial captures cannot +invent offscreen geometry. Existing spatial projection handles actual entries. + +Source audit with original Package:250 production modules, zero omissions; +SubtreeGeometry plus both concrete matches implementations migrated, one caller +in Compositor.add_widget. No external dynamic resource override is inferred from +lexical source. Public custom arrangement and coordinate behavior remain bound +to the original CACHE_SUBTREE_GEOMETRY opt-in and native key contract. + +Three real native App checks passed1.19s. Complete capture to visible plus an +actual offscreen retained row performs zero subtree arrangements; cache-disabled +native reference agrees on visible geometry and retained coordinates. Changed +scroll, partial-to-retained/full and changed child arrange. Existing fixed, +viewport/overlay, resize, full-map and rendering controls remain correct. + +One original history_check.py App run used readonly installed selected-memberships +delivery runtime Toad/Core, replacing only Textual source2d85b8e9. Imports/command +stdout retained under /home/ts/.cache/agent-scratch/native99-channel-history-20261007. +HISTORY_PROFILE=0;160 authored initial private wire messages,20 incoming and30 +PageUp. No provider/public inputs, package writes, native build or pin change. +Terminal0, empty stderr, no App exception. Result:53 mounted rows/oldest113, +p95 loop16.20ms, maximum126.83ms,6 intervals above50ms. Actual runtime/Toad changes +and different final workload invalidate comparison against the earlier120-row +run. The driver asserts App health, not complete pagination or exact reader +position; no full paging or overall speedup claim. Significant pauses remain. +The original runtime_fixture joins teardown; controller joined and no driver +process remained. Exact child births were not recorded. Parent owns Toad +consumer followthrough and durable delivery; no extra App rerun performed. diff --git a/evidence/complete-placed-geometry-20261007/app-result.json b/evidence/complete-placed-geometry-20261007/app-result.json new file mode 100644 index 0000000000..239cbabac2 --- /dev/null +++ b/evidence/complete-placed-geometry-20261007/app-result.json @@ -0,0 +1,11 @@ +{ + "main_loop_samples": 2621, + "median_ms": 5.766002926975489, + "p95_ms": 16.204836894758046, + "max_ms": 126.82982999831438, + "gaps_above_50ms": 6, + "mounted_rows": 53, + "oldest_sequence": 113, + "app_error": null, + "profiling": false +} diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index c25c191c8d..9b8dbbd6df 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -228,7 +228,7 @@ def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]: return spatial_map @abstractmethod - def matches(self, key: SubtreeGeometryKey) -> bool: + def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool: ... def restore_into( @@ -287,8 +287,13 @@ 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) -> bool: - return self.key == key + def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool: + if self.key.visible_only and require_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 _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey, clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]: @@ -319,7 +324,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) -> bool: + def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool: return self.key.intrinsic() == key.intrinsic() def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey, @@ -1156,18 +1161,21 @@ def add_widget(widget, virtual_region, region, order, layer_order, clip, visible resource_type = widget.subtree_geometry_resource() enclosing_complete = complete complete = resource_type.complete_arrangement(complete) - # Retention requires an uncropped path only for partial resources. - # Complete native arrangements already own every descendant box. - if widget in retained_paths and not complete: - arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, - complete, inherited_layers) # noqa: F821 -- closure cleared after traversal - return 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) - if resource is None or not resource.matches(key): + require_complete = 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: + arrange_widget(widget, virtual_region, region, order, layer_order, clip, visible, dock_gutter, + complete, inherited_layers) # noqa: F821 -- closure cleared after traversal + return + 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. diff --git a/tests/test_complete_placed_geometry.py b/tests/test_complete_placed_geometry.py new file mode 100644 index 0000000000..e0558fa8d9 --- /dev/null +++ b/tests/test_complete_placed_geometry.py @@ -0,0 +1,71 @@ +from textual._compositor import Compositor, PlacedSubtreeGeometry +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Static + + +class CapturedScroll(VerticalScroll): + CACHE_SUBTREE_GEOMETRY = True + arrangement_calls = 0 + + def arrange(self, size, optimal=False): + self.arrangement_calls += 1 + return super().arrange(size, optimal=optimal) + + +class CompleteSceneApp(App): + CSS = "CapturedScroll { height: 8; } Static { height: 2; }" + + def compose(self) -> ComposeResult: + with CapturedScroll(): + for index in range(25): + yield Static(f"Original row {index}", id=f"row{index}") + + +async def test_complete_placed_source_serves_viewport_and_retained_paths(): + app = CompleteSceneApp() + async with app.run_test(size=(40, 12)) as pilot: + await pilot.pause() + history = app.query_one(CapturedScroll) + target = app.query_one("#row23") + compositor = app.screen._compositor + compositor.reflow(app.screen, app.size) + complete = compositor._subtree_geometry[history] + assert isinstance(complete, PlacedSubtreeGeometry) + assert not complete.key.visible_only and target in complete.geometry + + history.arrangement_calls = 0 + compositor.reflow_visible(app.screen, app.size, retain_geometry=(target,)) + assert history.arrangement_calls == 0 + assert compositor._subtree_geometry[history] is complete + target_geometry = compositor._visible_map[target] + visible = dict(compositor.visible_widgets) + reference = Compositor(max_subtree_geometry_entries=0) + reference.reflow_visible(app.screen, app.size, retain_geometry=(target,)) + assert reference.visible_widgets == visible + assert reference._visible_map[target] == target_geometry + + # A changed scroll scope still needs fresh placement and clipping. + history.scroll_to(y=6, animate=False, immediate=True) + history.arrangement_calls = 0 + compositor.reflow_visible(app.screen, app.size, retain_geometry=()) + assert history.arrangement_calls > 0 + partial = compositor._subtree_geometry[history] + assert partial.key.visible_only + + # This partial source cannot grant a previously unacquired path. + history.arrangement_calls = 0 + compositor.reflow_visible(app.screen, app.size, retain_geometry=(target,)) + assert history.arrangement_calls > 0 + reference.reflow_visible(app.screen, app.size, retain_geometry=(target,)) + assert reference.visible_widgets == compositor.visible_widgets + assert reference._visible_map[target] == compositor._visible_map[target] + + # Neither full acquisition nor a changed child can reuse partial data. + history.arrangement_calls = 0 + compositor.reflow(app.screen, app.size) + assert history.arrangement_calls > 0 + app.query_one("#row0", Static).styles.height = 4 + history.arrangement_calls = 0 + compositor.reflow_visible(app.screen, app.size, retain_geometry=()) + assert history.arrangement_calls > 0