From c09d5b902a55992367746885523e141ebff81a40 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 6 Oct 2026 20:20:49 -0400 Subject: [PATCH 1/3] Keep held layout requests pending without forcing full scroll reflow --- .../held-layout-scroll-20261006/before.json | 355 ++++++++++++++++++ src/textual/screen.py | 29 +- 2 files changed, 375 insertions(+), 9 deletions(-) create mode 100644 evidence/held-layout-scroll-20261006/before.json diff --git a/evidence/held-layout-scroll-20261006/before.json b/evidence/held-layout-scroll-20261006/before.json new file mode 100644 index 0000000000..3391bca4f4 --- /dev/null +++ b/evidence/held-layout-scroll-20261006/before.json @@ -0,0 +1,355 @@ +{ + "native": { + "modules": 249, + "omissions": [], + "sites": [ + [ + "src/textual/_compositor.py", + 690, + "reflow", + "FunctionDef" + ], + [ + "src/textual/_compositor.py", + 744, + "reflow_visible", + "FunctionDef" + ], + [ + "src/textual/_compositor.py", + 856, + "_arrange_root", + "FunctionDef" + ], + [ + "src/textual/_compositor.py", + 717, + "_arrange_root", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 767, + "_arrange_root", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 1813, + "_arrange_root", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 817, + "_arrange_root", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 873, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 1309, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 1436, + "reflow_visible", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 1292, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/app.py", + 3850, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/layouts/stream.py", + 58, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1220, + "_layout_mutation_roots", + "FunctionDef" + ], + [ + "src/textual/screen.py", + 1419, + "_refresh_layout", + "FunctionDef" + ], + [ + "src/textual/screen.py", + 1547, + "_on_update_scroll", + "AsyncFunctionDef" + ], + [ + "src/textual/screen.py", + 326, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1400, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1466, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1534, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1536, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1610, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1691, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1312, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1432, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1433, + "reflow_visible", + "Attribute" + ], + [ + "src/textual/screen.py", + 1465, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/screen.py", + 1535, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1576, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1629, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1315, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1460, + "reflow", + "Attribute" + ], + [ + "src/textual/screen.py", + 1464, + "reflow", + "Attribute" + ], + [ + "src/textual/screen.py", + 1489, + "_size_updated", + "Attribute" + ], + [ + "src/textual/screen.py", + 1578, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1537, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1448, + "_size_updated", + "Attribute" + ], + [ + "src/textual/screen.py", + 1467, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/scroll_view.py", + 86, + "_size_updated", + "FunctionDef" + ], + [ + "src/textual/scroll_view.py", + 102, + "_size_updated", + "Attribute" + ], + [ + "src/textual/widget.py", + 4357, + "_size_updated", + "FunctionDef" + ] + ] + }, + "toad": { + "modules": 288, + "omissions": [], + "sites": [ + [ + "src/toad/screens/workspace.py", + 157, + "_layout_mutation_roots", + "FunctionDef" + ], + [ + "src/toad/screens/workspace.py", + 179, + "_refresh_layout", + "FunctionDef" + ], + [ + "src/toad/screens/workspace.py", + 265, + "_refresh_layout", + "Attribute" + ], + [ + "src/toad/screens/workspace.py", + 52, + "_layout_widgets", + "Attribute" + ], + [ + "src/toad/screens/workspace.py", + 219, + "_refresh_layout", + "Attribute" + ], + [ + "src/toad/screens/workspace.py", + 267, + "_refresh_layout", + "Attribute" + ], + [ + "src/toad/screens/workspace.py", + 197, + "_refresh_layout", + "Attribute" + ], + [ + "src/toad/screens/workspace.py", + 204, + "_refresh_layout", + "Attribute" + ], + [ + "src/toad/screens/workspace.py", + 263, + "_refresh_layout", + "Attribute" + ], + [ + "src/toad/screens/workspace.py", + 209, + "_refresh_layout", + "Attribute" + ], + [ + "src/toad/widgets/conversation.py", + 2121, + "reflow", + "Attribute" + ], + [ + "src/toad/widgets/history_anchor.py", + 221, + "_size_updated", + "FunctionDef" + ], + [ + "src/toad/widgets/history_anchor.py", + 225, + "_size_updated", + "Attribute" + ], + [ + "src/toad/widgets/viewport_body.py", + 554, + "_size_updated", + "FunctionDef" + ], + [ + "src/toad/widgets/viewport_body.py", + 555, + "_size_updated", + "Attribute" + ] + ] + }, + "core": { + "modules": 324, + "omissions": [], + "sites": [] + } +} diff --git a/src/textual/screen.py b/src/textual/screen.py index fdfbe86b4f..c5f0ed9eaf 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1416,6 +1416,17 @@ 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]]: + """Borrow pending requests whose subtree still owns committed geometry.""" + mutation_roots = self._layout_mutation_roots() + return { + owner: held for owner, members in self._layout_widgets.items() + if (held := { + member for member in members + if set(member.walk_ancestors(with_self=True)).intersection(mutation_roots) + }) + } if mutation_roots else {} + def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> None: """Refresh the layout (can change size and positions of widgets).""" size = self.outer_size if size is None else size @@ -1429,7 +1440,14 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non try: geometry_targets = self._layout_geometry_targets() - if scroll and not self._layout_widgets: + held_layout = self._held_layout_requests() if scroll else {} + # Held requests remain pending for release. They cannot alter the + # committed subtree during scrolling; only actionable requests + # require layout beyond the original visible-scroll traversal. + if scroll and all( + members <= held_layout.get(owner, set()) + for owner, members in self._layout_widgets.items() + ): exposed_widgets = self._compositor.reflow_visible( self, size, retain_geometry=geometry_targets, ) @@ -1462,14 +1480,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non ) else: hidden, shown = self._compositor.reflow(self, size) - mutation_roots = self._layout_mutation_roots() - self._layout_widgets = { - owner: held for owner, members in self._layout_widgets.items() - if (held := { - member for member in members - if set(member.walk_ancestors(with_self=True)).intersection(mutation_roots) - }) - } if mutation_roots else {} + self._layout_widgets = self._held_layout_requests() Hide = events.Hide Show = events.Show From 21e5620866fbf960ac7d39fd7f659bfa56c89fdd Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 6 Oct 2026 20:22:22 -0400 Subject: [PATCH 2/3] Exercise held scroll geometry, outside layout and release through the real Screen --- src/textual/screen.py | 2 ++ tests/test_batch_paint_admission.py | 25 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/textual/screen.py b/src/textual/screen.py index c5f0ed9eaf..7486150cf2 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1418,6 +1418,8 @@ def _layout_geometry_targets(self) -> tuple[Widget, ...]: def _held_layout_requests(self) -> 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() return { owner: held for owner, members in self._layout_widgets.items() diff --git a/tests/test_batch_paint_admission.py b/tests/test_batch_paint_admission.py index be4df66c94..61d704a7f6 100644 --- a/tests/test_batch_paint_admission.py +++ b/tests/test_batch_paint_admission.py @@ -277,6 +277,31 @@ async def test_partial_publication_keeps_geometry_and_owner_callbacks(): assert compositor.get_widget_at(1, 1)[0] is original assert screen.publications and all(roots == (holder,) for roots in screen.publications) assert compositor._dirty_regions + # The mount remains a real pending layout request until release. + # Scrolling must keep its exact held hit/paint geometry without + # turning that deferred request into a full-scene layout. + pending_layout = {owner: set(members) for owner, members in screen._layout_widgets.items()} + assert any(pending_layout.values()) + full_map = compositor._full_map + for _ in range(2): + screen._refresh_layout(scroll=True) + assert compositor._visible_map is not None + assert compositor._full_map is full_map + assert screen._layout_widgets == pending_layout + assert compositor.find_widget(holder) == before + assert compositor.find_widget(original) == original_geometry + assert compositor.get_widget_at(1, 1)[0] is original + assert not held_done.is_set() and not parent_done.is_set() and not scene_done.is_set() + + # An independent layout request is actionable even in a scroll + # transaction. It must not be hidden by the held request. + from textual import messages + await screen._on_layout(messages.Layout(sidebar)) + screen._refresh_layout(scroll=True) + assert compositor._visible_map is None + assert screen._layout_widgets == pending_layout + assert compositor.find_widget(holder) == before + assert compositor.find_widget(original) == original_geometry compositor._dirty_regions.add(compositor.size.region) full_damage = set(compositor._dirty_regions) # Reflow still commits geometry while full-screen damage already From 40c4b7c829e26c258fdd0e7a32142c14aad54715 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Tue, 6 Oct 2026 20:23:06 -0400 Subject: [PATCH 3/3] Qualify held scroll admission and ordinary source App layout --- .../held-layout-scroll-20261006/README.md | 48 +++++ .../held-layout-scroll-20261006/RESULT.json | 22 +++ .../held-layout-scroll-20261006/after.json | 174 ++++++++++++++++++ .../held-layout-scroll-20261006/controls.log | 2 + 4 files changed, 246 insertions(+) create mode 100644 evidence/held-layout-scroll-20261006/README.md create mode 100644 evidence/held-layout-scroll-20261006/RESULT.json create mode 100644 evidence/held-layout-scroll-20261006/after.json create mode 100644 evidence/held-layout-scroll-20261006/controls.log diff --git a/evidence/held-layout-scroll-20261006/README.md b/evidence/held-layout-scroll-20261006/README.md new file mode 100644 index 0000000000..8ba06b5fd9 --- /dev/null +++ b/evidence/held-layout-scroll-20261006/README.md @@ -0,0 +1,48 @@ +# Held layout requests during scrolling + +Screen previously chose full layout whenever `_layout_widgets` was nonempty. +After a layout during a mutation, that same mapping intentionally retains +requests below the held roots. Compositor already preserves those roots' exact +committed geometry in both full and visible arrangement. Retained requests +therefore forced repeated full layout without being allowed to change their +subtree. + +Screen now owns the existing hold classification in `_held_layout_requests`. +Scroll takes the original visible traversal when every pending member is held. +Requests outside the hold, including promoted auto-size ancestors, still force +full layout. The full-layout branch uses that same classification to retain +requests after arrangement. Release makes those requests actionable again. +An empty request mapping needs no mutation-root acquisition. + +No placement, mutation-root, damage, callback, resize or renderer owner was +replaced. The original visible traversal still acquires retained reader paths; +Compositor still preserves exact held hit/paint geometry. No persistent state, +cache, timer, Toad change or publication exemption was added. + +## Confirmation + +The existing real App control now performs two scroll layouts while a mounted +child is held, verifies exact geometry/hits and pending requests, then admits an +outside request and verifies full layout. Original release applies the mount. +The original cut-cell, callback and overlap assertions remain. That control and +the inline/translucent controls passed: **3 passed in 0.57s**. The final empty +request shortcut was subsequently exercised by the source App below. + +One original Toad sidebar source App completed with 508 widgets / 10 tabs, +exit zero and empty stderr. First-display medians were 35.1ms / 40.1ms, with a +112.1ms left maximum. This ordinary layout check does not measure the changed +held-scroll path or establish a reliable speed improvement. + +Source: `before.json` parses native 249, Toad 288 and Core 324 modules with zero +omissions; `after.json` contains the native decision/consumer sites. Arbitrary +external dynamic mutation of private layout state remains outside this audit. + +Raw App output and profiles remain under +`/home/ts/.cache/agent-scratch/held-layout-scroll-20261006/`. +The supplied live scroll profile identifies full/visible arrangement activity; +its transition groups do not establish call counts, CPU durations or which +pending layout requests caused any particular pass. + +This is a source/native-framework qualification. Installed saved scrolling, +writer-gap reduction and overall smoothness remain unqualified. No provider, +package, artifact, public-owner or recorder operation occurred. diff --git a/evidence/held-layout-scroll-20261006/RESULT.json b/evidence/held-layout-scroll-20261006/RESULT.json new file mode 100644 index 0000000000..91ecafca0c --- /dev/null +++ b/evidence/held-layout-scroll-20261006/RESULT.json @@ -0,0 +1,22 @@ +{ + "source_head": "21e5620866fbf960ac7d39fd7f659bfa56c89fdd", + "controls": "3 passed in 0.57s", + "app": { + "exit": 0, + "stderr_bytes": 0, + "widgets": 508, + "tabs": 10, + "boundary": "source headless App _display, not terminal pixels or held-scroll latency" + }, + "keepers": { + "src/textual/screen.py": "66ca0546457c22de6bdd9165a0e4c0a4b6a1294ad09ac5136f188a521729f6b2", + "tests/test_batch_paint_admission.py": "b002872170751bfea8cc99847574ac0df019c538191fe71c4f1fac71db745a0d", + "evidence/held-layout-scroll-20261006/before.json": "b17233b81c922f4a0f7652e603ee188d393f4a53421562a770cdac8f29ddeffa", + "evidence/held-layout-scroll-20261006/after.json": "9937a6bcc505cd48a2e5c0e1cbbe0c17272e9fd05d44bf438609202323e63466", + "evidence/held-layout-scroll-20261006/controls.log": "a2781fcf8f9c65bdb27d0edf589ab8df478ce510960a3dcee3c456a717c0f5ed", + "/home/ts/.cache/agent-scratch/held-layout-scroll-20261006/app.stdout.log": "518cc08803afbfecc92913c0e6953f19c9638d7c5ea57446ca58f3430fac1b8e", + "/home/ts/.cache/agent-scratch/held-layout-scroll-20261006/app.stderr.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "installed": false, + "reliable_latency_improvement": false +} diff --git a/evidence/held-layout-scroll-20261006/after.json b/evidence/held-layout-scroll-20261006/after.json new file mode 100644 index 0000000000..bd28790312 --- /dev/null +++ b/evidence/held-layout-scroll-20261006/after.json @@ -0,0 +1,174 @@ +{ + "modules": 249, + "omissions": [], + "sites": [ + [ + "src/textual/_compositor.py", + 873, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 1309, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/_compositor.py", + 1292, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/app.py", + 3850, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/layouts/stream.py", + 58, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1220, + "_layout_mutation_roots", + "FunctionDef" + ], + [ + "src/textual/screen.py", + 1419, + "_held_layout_requests", + "FunctionDef" + ], + [ + "src/textual/screen.py", + 1432, + "_refresh_layout", + "FunctionDef" + ], + [ + "src/textual/screen.py", + 326, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1421, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1423, + "_layout_mutation_roots", + "Attribute" + ], + [ + "src/textual/screen.py", + 1400, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1485, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1547, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1549, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1623, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1704, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1312, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1445, + "_held_layout_requests", + "Attribute" + ], + [ + "src/textual/screen.py", + 1485, + "_held_layout_requests", + "Attribute" + ], + [ + "src/textual/screen.py", + 1548, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1589, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1642, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1315, + "_refresh_layout", + "Attribute" + ], + [ + "src/textual/screen.py", + 1591, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1425, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1550, + "_layout_widgets", + "Attribute" + ], + [ + "src/textual/screen.py", + 1451, + "_layout_widgets", + "Attribute" + ] + ] +} diff --git a/evidence/held-layout-scroll-20261006/controls.log b/evidence/held-layout-scroll-20261006/controls.log new file mode 100644 index 0000000000..7658b2f0ae --- /dev/null +++ b/evidence/held-layout-scroll-20261006/controls.log @@ -0,0 +1,2 @@ +... [100%] +3 passed in 0.57s