diff --git a/evidence/container-height-membership-20261007/README.md b/evidence/container-height-membership-20261007/README.md new file mode 100644 index 0000000000..081743a600 --- /dev/null +++ b/evidence/container-height-membership-20261007/README.md @@ -0,0 +1,38 @@ +# Native container membership during height measurement + +Native `is_container` is true for either committed child membership or an +explicit layout. Child membership now answers first, avoiding RenderStyles +layout resolution on every populated step of a relative-height traversal. +Empty native child lists still consult the current layout declaration. + +This changes only the original Widget property. Subclass property overrides, +relative percentage/fraction admission, hidden children, auto-size propagation, +layout policies, measurement generations and reparenting remain unchanged. +NodeList owns real child membership; RenderStyles owns the independently +changing layout declaration. Neither answer is stored or inferred from bounds. + +The recursive relative-height query remains necessary: even independent +Stream content measurements can have auto boxes stretched by relative-height +descendants. Removing that query or treating content independence as box +independence would change geometry. This patch removes a predicate cost; it +does not claim to remove descendant traversal or solve burst frame latency. + +source.json records existing Package AST declarations and consumers across +249 native and 288 Toad modules, with zero parse omissions. Runtime replacement +of the entire is_container property continues to dispatch normally. Private +replacement of native NodeList/RenderStyles with side-effectful nonconforming +objects is not a native measurement contract. + +64 affected measurement/arrangement/structural-generation checks passed in +4.33s. The existing loaded source Toad App completed with 568 widgets/10 tabs, +equal relative-height answers for every widget, unchanged geometry and empty +stderr. It compares old/current short-circuit order on the same acquired tree. +Five alternating unprofiled trials of 20 query sweeps had medians 49.73ms and +47.92ms; trial variation overlaps, so this is not a reliable latency improvement. +The cProfile files retain instrumentation output; summed recursive cumulative +values are not an aggregate wall-time result and are not used as speed evidence. + +No original session, submitted input, provider, package or public App is used. +The full recursive descendant query remains the outstanding cost; this patch +does not justify bypassing it, storing another readiness/dependency answer or +claiming the original 2.869s writer gap has been solved. diff --git a/evidence/container-height-membership-20261007/app.stderr.log b/evidence/container-height-membership-20261007/app.stderr.log new file mode 100644 index 0000000000..e69de29bb2 diff --git a/evidence/container-height-membership-20261007/app.stdout.log b/evidence/container-height-membership-20261007/app.stdout.log new file mode 100644 index 0000000000..79c3240da5 --- /dev/null +++ b/evidence/container-height-membership-20261007/app.stdout.log @@ -0,0 +1,40 @@ +{ + "scope": "source Toad App recursive height queries, not burst/frame latency", + "widgets": 568, + "tabs": 10, + "sweeps_per_trial": 20, + "seconds": { + "original": [ + 0.05167253699619323, + 0.04497357609216124, + 0.043833934934809804, + 0.0542515879496932, + 0.04973278904799372 + ], + "membership_first": [ + 0.0633440789533779, + 0.04001189791597426, + 0.04056658991612494, + 0.048753642942756414, + 0.04791633493732661 + ] + }, + "median_seconds": { + "original": 0.04973278904799372, + "membership_first": 0.04791633493732661 + }, + "profiles": { + "original": { + "is_container_seconds": 0.012723577, + "relative_height_seconds": 0.15487751200000002 + }, + "membership_first": { + "is_container_seconds": 0.013725005, + "relative_height_seconds": 0.006692936 + } + }, + "all_relative_answers_equal": true, + "geometry_unchanged": true, + "inputs": 0, + "providers": 0 +} diff --git a/evidence/container-height-membership-20261007/controls.log b/evidence/container-height-membership-20261007/controls.log new file mode 100644 index 0000000000..e3e6b294c7 --- /dev/null +++ b/evidence/container-height-membership-20261007/controls.log @@ -0,0 +1,2 @@ +................................................................ [100%] +64 passed in 4.33s diff --git a/evidence/container-height-membership-20261007/source.json b/evidence/container-height-membership-20261007/source.json new file mode 100644 index 0000000000..68862ec80f --- /dev/null +++ b/evidence/container-height-membership-20261007/source.json @@ -0,0 +1,344 @@ +{ + "native": { + "modules": 249, + "omissions": [], + "sites": [ + [ + "src/textual/_compositor.py", + 982, + "consumer", + "widget.is_container" + ], + [ + "src/textual/_measurement.py", + 19, + "declaration", + "box_depends" + ], + [ + "src/textual/_measurement.py", + 54, + "declaration", + "box_depends" + ], + [ + "src/textual/_measurement.py", + 95, + "declaration", + "box_depends" + ], + [ + "src/textual/_measurement.py", + 147, + "consumer", + "HeightDependency.box_depends" + ], + [ + "src/textual/_measurement.py", + 163, + "consumer", + "HeightDependency.box_depends" + ], + [ + "src/textual/_measurement.py", + 20, + "consumer", + "widget._has_relative_children_height" + ], + [ + "src/textual/_measurement.py", + 47, + "consumer", + "widget.is_container" + ], + [ + "src/textual/_measurement.py", + 55, + "consumer", + "widget.is_container" + ], + [ + "src/textual/_measurement.py", + 59, + "consumer", + "self.layout_dependency(widget).box_depends" + ], + [ + "src/textual/_measurement.py", + 62, + "consumer", + "widget.is_container" + ], + [ + "src/textual/_measurement.py", + 79, + "consumer", + "widget.is_container" + ], + [ + "src/textual/_measurement.py", + 98, + "consumer", + "widget.layout._arrangement_height_dependency.box_depends" + ], + [ + "src/textual/_measurement.py", + 258, + "consumer", + "widget._content_height_dependency.box_depends" + ], + [ + "src/textual/_measurement.py", + 134, + "consumer", + "child._box_depends_on_available_height" + ], + [ + "src/textual/await_remove.py", + 68, + "consumer", + "node._invalidate_layout" + ], + [ + "src/textual/dom.py", + 152, + "declaration", + "_style_geometry_updated" + ], + [ + "src/textual/dom.py", + 170, + "consumer", + "self._style_geometry_updated" + ], + [ + "src/textual/dom.py", + 184, + "consumer", + "node._style_geometry_updated" + ], + [ + "src/textual/screen.py", + 823, + "consumer", + "node.is_container" + ], + [ + "src/textual/scroll_view.py", + 41, + "declaration", + "is_container" + ], + [ + "src/textual/selection.py", + 600, + "consumer", + "child.is_container" + ], + [ + "src/textual/selection.py", + 570, + "consumer", + "descendant.is_container" + ], + [ + "src/textual/selection.py", + 369, + "consumer", + "widget.is_container" + ], + [ + "src/textual/visual.py", + 240, + "consumer", + "widget.is_container" + ], + [ + "src/textual/walk.py", + 220, + "consumer", + "node.is_container" + ], + [ + "src/textual/widget.py", + 323, + "declaration", + "_style_geometry_updated" + ], + [ + "src/textual/widget.py", + 332, + "declaration", + "_measurement_updated" + ], + [ + "src/textual/widget.py", + 339, + "declaration", + "_invalidate_layout" + ], + [ + "src/textual/widget.py", + 1904, + "declaration", + "_box_depends_on_available_height" + ], + [ + "src/textual/widget.py", + 2642, + "declaration", + "_has_relative_children_height" + ], + [ + "src/textual/widget.py", + 2789, + "declaration", + "is_container" + ], + [ + "src/textual/widget.py", + 2102, + "consumer", + "self.is_container" + ], + [ + "src/textual/widget.py", + 2136, + "consumer", + "self.is_container" + ], + [ + "src/textual/widget.py", + 4692, + "consumer", + "self.is_container" + ], + [ + "src/textual/widget.py", + 324, + "consumer", + "super()._style_geometry_updated" + ], + [ + "src/textual/widget.py", + 345, + "consumer", + "self._measurement_updated" + ], + [ + "src/textual/widget.py", + 1448, + "consumer", + "self._invalidate_layout" + ], + [ + "src/textual/widget.py", + 2624, + "consumer", + "self.is_container" + ], + [ + "src/textual/widget.py", + 2645, + "consumer", + "self.is_container" + ], + [ + "src/textual/widget.py", + 329, + "consumer", + "self._measurement_updated" + ], + [ + "src/textual/widget.py", + 349, + "consumer", + "ancestor._measurement_updated" + ], + [ + "src/textual/widget.py", + 1943, + "consumer", + "self._box_depends_on_available_height" + ], + [ + "src/textual/widget.py", + 4117, + "consumer", + "cls._has_relative_children_height" + ], + [ + "src/textual/widget.py", + 4117, + "consumer", + "Widget._has_relative_children_height" + ], + [ + "src/textual/widget.py", + 4119, + "consumer", + "cls.is_container" + ], + [ + "src/textual/widget.py", + 4119, + "consumer", + "Widget.is_container" + ], + [ + "src/textual/widget.py", + 4355, + "consumer", + "self.is_container" + ], + [ + "src/textual/widget.py", + 4594, + "consumer", + "self._invalidate_layout" + ], + [ + "src/textual/widget.py", + 2059, + "consumer", + "self._has_relative_children_height" + ], + [ + "src/textual/widget.py", + 2655, + "consumer", + "child._has_relative_children_height" + ], + [ + "src/textual/widgets/_static.py", + 107, + "consumer", + "self.is_container" + ] + ] + }, + "toad": { + "modules": 288, + "omissions": [], + "sites": [ + [ + "src/toad/widgets/patch_diff.py", + 326, + "consumer", + "code.is_container" + ], + [ + "src/toad/widgets/viewport_body.py", + 506, + "declaration", + "is_container" + ], + [ + "src/toad/widgets/viewport_body.py", + 510, + "consumer", + "super().is_container" + ] + ] + } +} diff --git a/src/textual/widget.py b/src/textual/widget.py index 7c212f56da..9fe65c19a4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2788,7 +2788,7 @@ def layout(self) -> Layout: @property def is_container(self) -> bool: """Is this widget a container (contains other widgets)?""" - return self.styles.layout is not None or bool(self._nodes) + return bool(self._nodes) or self.styles.layout is not None @property def is_scrollable(self) -> bool: diff --git a/tests/container_height_membership_pilot.py b/tests/container_height_membership_pilot.py new file mode 100644 index 0000000000..0549a46a53 --- /dev/null +++ b/tests/container_height_membership_pilot.py @@ -0,0 +1,89 @@ +"""Measure native relative-height queries on the existing loaded Toad App.""" + +import asyncio +import cProfile +import json +import os +from pathlib import Path +import pstats +import statistics +import tempfile +from time import perf_counter + +from agent_comms.comms import wire +from agent_comms.threads import Thread +from sidebar_collapse_latency_pilot import FrameApp +from textual.widget import Widget +from toad.widgets.agent_response import AgentResponse + + +async def main(output: Path): + output.mkdir(parents=True, exist_ok=True) + current = Widget.is_container + # The original property differs only in short-circuit order. Both versions + # use the same actual native NodeList and RenderStyles on this acquired App. + original = property(lambda self: self.styles.layout is not None or bool(self._nodes)) + with tempfile.TemporaryDirectory(prefix="height-membership-", dir=output) as folder: + root = Path(folder) + os.environ.update(XDG_CONFIG_HOME=str(root / "config"), + XDG_STATE_HOME=str(root / "state"), + XDG_DATA_HOME=str(root / "data"), AGENT_COMMS_ROOT=str(root / "wire")) + comms = wire(root / "wire") + comms.registry.declare(Thread(root.name, frozenset(), str(root))) + for index in range(24): + comms.registry.declare(Thread(f"worker-{index:02}", frozenset(), str(root))) + app = FrameApp(project_dir=str(root)) + try: + async with app.run_test(size=(120, 42)) as pilot: + for _ in range(9): + await app.session_navigation.new(app.session_navigation.default_source) + await app.selected_session.conversation.contents.mount(*[ + AgentResponse(f"## Response {index}\n\n" + "paragraph\n\n" * 10) + for index in range(12) + ]) + await pilot.pause() + widgets = tuple(app.screen.walk_children()) + expected = tuple(widget._has_relative_children_height for widget in widgets) + geometry = tuple(widget.region for widget in widgets) + timings = {"original": [], "membership_first": []} + profiles = {} + for trial in range(6): + for name, declaration in (("original", original), ("membership_first", current))[::1 if trial % 2 == 0 else -1]: + Widget.is_container = declaration + profile = cProfile.Profile() if trial == 0 else None + if profile: + profile.enable() + start = perf_counter() + for _ in range(20): + assert tuple(widget._has_relative_children_height for widget in widgets) == expected + elapsed = perf_counter() - start + if profile: + profile.disable() + profile.dump_stats(str(output / f"{name}.pstats")) + profiles[name] = { + "is_container_seconds": sum(value[2] for (_, _, function), value in pstats.Stats(profile).stats.items() + if function in {"is_container", ""}), + "relative_height_seconds": sum(value[3] for (_, _, function), value in pstats.Stats(profile).stats.items() + if function == "_has_relative_children_height"), + } + else: + timings[name].append(elapsed) + Widget.is_container = current + await pilot.pause() + assert tuple(widget.region for widget in widgets) == geometry + assert app._exception is None + result = {"scope": "source Toad App recursive height queries, not burst/frame latency", + "widgets": len(widgets), "tabs": 10, "sweeps_per_trial": 20, + "seconds": timings, + "median_seconds": {name: statistics.median(values) for name, values in timings.items()}, + "profiles": profiles, "all_relative_answers_equal": True, + "geometry_unchanged": True, "inputs": 0, "providers": 0} + (output / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print(json.dumps(result, indent=2), flush=True) + finally: + Widget.is_container = current + + +if __name__ == "__main__": + import sys + asyncio.run(main(Path(sys.argv[1])))