diff --git a/evidence/committed-screen-membership-20261007/README.md b/evidence/committed-screen-membership-20261007/README.md new file mode 100644 index 0000000000..fc6c40906f --- /dev/null +++ b/evidence/committed-screen-membership-20261007/README.md @@ -0,0 +1,24 @@ +# Committed screen membership + +Widget.is_on_screen declares last-screen-update membership. It previously used +Compositor.find_widget, which acquires missing offscreen paths and can arrange +again every time a hidden widget asks. It now consumes the original committed +map via _get_published_geometry. No membership store or negative cache is added. +Region, virtual_region and focus ordering retain their original acquisition. +The selected capture reader remains unchanged; screen membership describes the +committed screen, not a temporary body-render scope. + +Native automatic_refresh and Toad project/path/recovery status consumers are +covered by the existing property. Custom overrides remain independent. The Toad +throbber already explicitly selects clipped visible membership; that stricter +paint eligibility remains untouched. The source census is in source.json. + +One actual headless App control passed in 0.37 seconds: visible, offscreen, +hidden and detached cases, 120 reads with zero _arrange_root calls, followed by +successful explicit offscreen acquisition. Original native layout and widget +owners were used. No provider, package, public runtime or profile rerun. + +This removes status-query arrangement. It does not fix repeated unsatisfied +focus geometry requests, prove the recorded mount delay solved, or establish +an installed latency improvement. Child cardinality is already constant-time; +style invalidation retains custom renderer dependencies. diff --git a/evidence/committed-screen-membership-20261007/check.log b/evidence/committed-screen-membership-20261007/check.log new file mode 100644 index 0000000000..c91be48cb8 --- /dev/null +++ b/evidence/committed-screen-membership-20261007/check.log @@ -0,0 +1,2 @@ +. [100%] +1 passed in 0.37s diff --git a/evidence/committed-screen-membership-20261007/source.json b/evidence/committed-screen-membership-20261007/source.json new file mode 100644 index 0000000000..a7b933f609 --- /dev/null +++ b/evidence/committed-screen-membership-20261007/source.json @@ -0,0 +1,144 @@ +{ + "native": { + "head": "6e950babe0808e9955d29d9f655d82dbc0be383c", + "modules": 723, + "omissions": [], + "sites": [ + [ + "src/textual/_compositor.py", + 1489, + "declaration", + "_get_published_geometry" + ], + [ + "src/textual/_compositor.py", + 1487, + "self._get_published_geometry" + ], + [ + "src/textual/_compositor.py", + 1818, + "self._get_published_geometry" + ], + [ + "src/textual/dom.py", + 622, + "declaration", + "is_on_screen" + ], + [ + "src/textual/dom.py", + 626, + "declaration", + "automatic_refresh" + ], + [ + "src/textual/dom.py", + 634, + "self.is_on_screen" + ], + [ + "src/textual/widget.py", + 2708, + "declaration", + "is_on_screen" + ], + [ + "tests/test_auto_refresh.py", + 17, + "declaration", + "automatic_refresh" + ] + ] + }, + "toad": { + "head": "aef46ff5d3069ae5afeefb9139d3943ffbcbe74c", + "modules": 691, + "omissions": [], + "sites": [ + [ + "src/toad/project_path_owner.py", + 19, + "widget.is_on_screen" + ], + [ + "src/toad/widgets/conversation.py", + 171, + "declaration", + "automatic_refresh" + ], + [ + "src/toad/widgets/goal_bar.py", + 47, + "declaration", + "automatic_refresh" + ], + [ + "src/toad/widgets/path_search.py", + 288, + "self.is_on_screen" + ], + [ + "src/toad/widgets/project_directory_tree.py", + 111, + "self.is_on_screen" + ], + [ + "src/toad/widgets/project_directory_tree.py", + 116, + "self.is_on_screen" + ], + [ + "src/toad/widgets/recovery_view.py", + 56, + "self.is_on_screen" + ], + [ + "src/toad/widgets/throbber.py", + 118, + "declaration", + "automatic_refresh" + ], + [ + "tests/declared_target_actions_installed_pilot.py", + 284, + "endpoint.is_on_screen" + ], + [ + "tests/declared_target_actions_installed_pilot.py", + 284, + "anchor.is_on_screen" + ], + [ + "tests/declared_target_actions_installed_pilot.py", + 293, + "anchor.is_on_screen" + ], + [ + "tests/declared_target_actions_installed_pilot.py", + 294, + "endpoint.is_on_screen" + ], + [ + "tests/declared_target_actions_installed_pilot.py", + 208, + "row.is_on_screen" + ], + [ + "tests/declared_target_actions_installed_pilot.py", + 157, + "row.is_on_screen" + ], + [ + "tests/sidebar_selected_targets_pilot.py", + 142, + "endpoint.is_on_screen" + ], + [ + "tests/sidebar_selected_targets_pilot.py", + 142, + "anchor.is_on_screen" + ] + ] + } +} diff --git a/src/textual/widget.py b/src/textual/widget.py index 20679de80f..23ba08956a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2706,12 +2706,15 @@ def _relative_children_height(self) -> tuple[bool, bool]: @property def is_on_screen(self) -> bool: - """Check if the node was displayed in the last screen update.""" + """Check membership in the last committed screen geometry. + + A status read must not acquire omitted/offscreen layout paths. Explicit + position readers still request those paths through ``find_widget``. + """ try: - self.screen.find_widget(self) - except (NoScreen, errors.NoWidget): + return self.screen._compositor._get_published_geometry(self) is not None + except NoScreen: return False - return True def _resolve_extrema( self, diff --git a/tests/test_compositor.py b/tests/test_compositor.py index 7b91dfa450..202ca38f85 100644 --- a/tests/test_compositor.py +++ b/tests/test_compositor.py @@ -391,3 +391,46 @@ def compose(self): with patch.object(compositor, "_get_renders", lambda crop=None, render_regions=None, *, widgets: original(crop, widgets=widgets)): expected = compositor.render_strips() assert actual == expected + + +async def test_screen_membership_reads_do_not_acquire_geometry(): + """Status is the committed scene; explicit position reads may widen it.""" + import cProfile + from textual.containers import VerticalScroll + + class MembershipApp(App): + CSS = "VerticalScroll { height: 3; } Static { height: 1; } #hidden { display: none; }" + + def compose(self): + yield VerticalScroll(*(Static(str(index), id=f"row-{index}") + for index in range(30))) + yield Static("hidden", id="hidden") + + app = MembershipApp() + detached = Static("detached") + assert not detached.is_on_screen + async with app.run_test(size=(30, 10)) as pilot: + await pilot.pause() + compositor = app.screen._compositor + compositor.reflow_visible(app.screen, app.screen.size, retain_geometry=()) + assert compositor._full_map_invalidated + hidden = app.query_one("#hidden") + last = app.query_one("#row-29") + first = app.query_one("#row-0") + scene = compositor._published_map + assert first in scene and last not in scene and hidden not in scene + profile = cProfile.Profile() + with profile: + for _ in range(40): + assert first.is_on_screen + assert not last.is_on_screen + assert not hidden.is_on_screen + assert compositor._published_map is scene + assert compositor._full_map_invalidated + assert not any(entry.code.co_name == "_arrange_root" + for entry in profile.getstats() + if not isinstance(entry.code, str)) + # A genuine geometry demand retains the offscreen path in this scene. + assert compositor.find_widget(last).virtual_region.y > 0 + assert last.is_on_screen + assert not hidden.is_on_screen