Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions evidence/committed-screen-membership-20261007/README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions evidence/committed-screen-membership-20261007/check.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
. [100%]
1 passed in 0.37s
144 changes: 144 additions & 0 deletions evidence/committed-screen-membership-20261007/source.json
Original file line number Diff line number Diff line change
@@ -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"
]
]
}
}
11 changes: 7 additions & 4 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions tests/test_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading