From f3f09dd0fdb0b989dbf6f1fca6e7613f0f363467 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 16:44:43 -0400 Subject: [PATCH 1/3] Bind fixed native box reuse to its actual local style source --- src/textual/_measurement.py | 19 ++++++++++++++----- src/textual/widget.py | 15 +++++++++++---- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 583690b50c..1e2577e3c8 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Callable, TypeVar from textual.css.scalar import Scalar, Unit +from textual.css.styles import RenderStyles if TYPE_CHECKING: from textual.widget import Widget @@ -229,10 +230,13 @@ def decorate(function: Function) -> Function: return decorate -def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool]: +def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool]: """Resolve local scalar dependencies once per owning style generation.""" styles = widget.styles - revision = styles._cache_key + if type(styles) is not RenderStyles: + return True, False, False, False + # Retain the actual source so replacement cannot reuse an unrelated epoch. + revision = id(styles), styles, styles._cache_key cached = widget.__dict__.get("_height_style_dependency_cache") if cached is not None and cached[0] == revision: return cached[1] @@ -244,11 +248,11 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool]: # Compile this check at the style-plan boundary, not each cache hit. # A custom scalar resolver need not obey its nominal unit's inputs. if type(scalar).resolve is not Scalar.resolve: - result = (True, False, False) + result = (True, False, False, False) break unit = scalar.percent_unit if scalar.unit is Unit.PERCENT else scalar.unit if unit is Unit.HEIGHT: - result = (True, False, False) + result = (True, False, False, False) break else: result = ( @@ -258,6 +262,11 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool]: or (max_height is not None and not max_height.is_cells), width is not None and (width.is_auto or width.is_fraction), height is not None and height.is_auto, + all(scalar is None or type(scalar) is Scalar for scalar in ( + width, height, min_width, max_width, min_height, max_height, + )) + and (width is None or not (width.is_auto or width.is_fraction)) + and (height is None or not height.is_auto), ) widget._height_style_dependency_cache = revision, result return result @@ -267,7 +276,7 @@ def box_depends_on_available_height(widget: Widget) -> bool: """Conservative proof for the native box resolver, including its extrema.""" if not widget._native_box_measurement: return True - depends, content_width, content_height = _local_box_inputs(widget) + depends, content_width, content_height, _ = _local_box_inputs(widget) if depends: return True if content_width and widget._content_width_dependency.depends(widget): diff --git a/src/textual/widget.py b/src/textual/widget.py index 2ae417d471..98913fe2c9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -57,7 +57,7 @@ from textual._extrema import Extrema from textual._measurement import ( CONTEXT_HEIGHT, INDEPENDENT_HEIGHT, NATIVE_WIDGET_HEIGHT, NATIVE_WIDGET_WIDTH, - HeightDependency, _arrangement_wrapper_uses_height, + HeightDependency, _arrangement_wrapper_uses_height, _local_box_inputs, box_depends_on_available_height, height_dependency, ) from textual.message_pump import MessagePump @@ -531,7 +531,7 @@ def __init__( self._repaint_regions: set[Region] = set() self._box_model_cache: LRUCache[object, tuple[BoxModel, Extrema]] = LRUCache(16) - self._box_model_revision: tuple[int | None, ...] | None = None + self._box_model_revision: tuple[object, ...] | None = None # Cache the auto content dimensions self._content_width_cache: tuple[object, int] = (None, 0) @@ -1960,8 +1960,15 @@ def _get_box_model( nodes = self.__dict__.get("_nodes") revision = (self._layout_updates, nodes._updates if nodes is not None else 0) + local_styles = self._native_box_measurement and _local_box_inputs(self)[3] + if local_styles: + # Fixed/fill native boxes never query child content. Their source + # is the original local style owner, not descendant layout epochs. + styles = self.styles + revision = (id(styles), styles, styles._cache_key) cache_container, cache_height_fraction = container, height_fraction - if not self._box_depends_on_available_height(): + independent_height = not self._box_depends_on_available_height() + if local_styles or independent_height: # The class/method/layout declarations proved these two inputs unused. # Include the immediate parent's actual auto-size inputs before idle, # and retire prior epochs rather than accumulating stale aliases. @@ -1971,6 +1978,7 @@ def _get_box_model( parent is not None and parent.styles.is_auto_width, parent is not None and parent.styles.is_auto_height, ) + if independent_height: cache_container = container.with_height(0) cache_height_fraction = Fraction(0) if revision != self._box_model_revision: @@ -1989,7 +1997,6 @@ def _get_box_model( cache_height_fraction, constrain_width, greedy, - *revision, ) if cached_measurement := self._box_model_cache.get(cache_key): # Alignment consumes the same original constraints that produced From 623e07dd777b82eb1f351162ccccbd50b091d334 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 16:50:42 -0400 Subject: [PATCH 2/3] Preserve actual style-source lifetime and verify fixed versus intrinsic boxes --- src/textual/_measurement.py | 2 +- src/textual/widget.py | 5 +- tests/test_box_model_cache_lifetime.py | 101 +++++++++++++++++++++++-- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 1e2577e3c8..3167fa42af 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -265,7 +265,7 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool]: all(scalar is None or type(scalar) is Scalar for scalar in ( width, height, min_width, max_width, min_height, max_height, )) - and (width is None or not (width.is_auto or width.is_fraction)) + and (width is None or not width.is_auto) and (height is None or not height.is_auto), ) widget._height_style_dependency_cache = revision, result diff --git a/src/textual/widget.py b/src/textual/widget.py index 98913fe2c9..8640f59027 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1958,9 +1958,10 @@ def _get_box_model( # never descend through an actively changing child to size siblings. return box nodes = self.__dict__.get("_nodes") - revision = (self._layout_updates, + revision: tuple[object, ...] = (self._layout_updates, nodes._updates if nodes is not None else 0) - local_styles = self._native_box_measurement and _local_box_inputs(self)[3] + inputs = _local_box_inputs(self) if self._native_box_measurement else None + local_styles = inputs is not None and inputs[3] and (greedy or not inputs[1]) if local_styles: # Fixed/fill native boxes never query child content. Their source # is the original local style owner, not descendant layout epochs. diff --git a/tests/test_box_model_cache_lifetime.py b/tests/test_box_model_cache_lifetime.py index 7ca52924a9..5b300a5b8b 100644 --- a/tests/test_box_model_cache_lifetime.py +++ b/tests/test_box_model_cache_lifetime.py @@ -18,6 +18,7 @@ async def test_obsolete_measurement_revisions_are_released_without_losing_width_ def measured(width): return widget._get_box_model(Size(width, 20), app.size, fraction, fraction) + previous_models = () for index in range(12): widget.update("changed paragraph " * (index + 1)) widget.styles.padding = (0, index % 3) @@ -25,10 +26,12 @@ def measured(width): models = {width: measured(width) for width in widths} for width, model in models.items(): assert measured(width) is model, "Same-revision width result should remain reusable" - expected = (widget._layout_updates, widget._geometry_revision, widget._nodes._updates) - assert all(key[-3:] == expected for key in widget._box_model_cache.keys()), ( - "Unreachable previous-generation box models retained", list(widget._box_model_cache.keys()) - ) + assert not any( + widget._box_model_cache.get(key)[0] is previous + for key in tuple(widget._box_model_cache.keys()) + for previous in previous_models + ), "Unreachable previous-generation box models retained" + previous_models = tuple(models.values()) async def test_ancestor_measurements_remain_correct_after_child_layout_changes(): @@ -41,12 +44,18 @@ async def test_ancestor_measurements_remain_correct_after_child_layout_changes() await pilot.pause() before = container._layout_updates old_height = container.size.height + fraction = Fraction(1) + old_box = container._get_box_model(Size(80, 20), app.size, fraction, fraction) leaf.update("line\n" * 12) await pilot.pause() assert container._layout_updates > before assert container.size.height > old_height - key = (container._layout_updates, container._geometry_revision, container._nodes._updates) - assert all(entry[-3:] == key for entry in container._box_model_cache.keys()) + new_box = container._get_box_model(Size(80, 20), app.size, fraction, fraction) + assert new_box.height > old_box.height + assert not any( + container._box_model_cache.get(key)[0] is old_box + for key in tuple(container._box_model_cache.keys()) + ) async def test_cached_box_return_restores_its_original_alignment_constraints(): @@ -78,3 +87,83 @@ async def test_cached_box_return_restores_its_original_alignment_constraints(): arrangement = parent.arrange(Size(40, 20)) placement = next(entry for _, entry in arrangement.placements if entry.widget is child) assert placement.region.x == 18 + + +async def test_fixed_box_retains_local_source_while_children_change(): + app = App() + async with app.run_test() as pilot: + leaf = Static('first') + container = Widget(leaf) + container.styles.width = 20 + container.styles.height = 4 + await app.mount(container) + await pilot.pause() + fraction = Fraction(1) + def measured(): + return container._get_box_model(Size(80, 20), app.size, fraction, fraction) + first = measured() + revision = container._layout_updates + leaf.styles.height = 12 + assert container._layout_updates > revision + assert measured() is first + added = Static('added') + await container.mount(added) + assert measured() is first + await added.remove() + assert measured() is first + container.styles.padding = 1 + assert measured() is not first + assert measured().width == 20 + container.styles.width = 30 + assert measured().width == 30 + assert container._get_box_model(Size(80, 20), app.size, fraction, fraction, + constrain_width=True).width == 30 + + +async def test_replaced_native_style_source_does_not_reuse_equal_epoch(): + app = App() + async with app.run_test() as pilot: + first, replacement = Widget(), Widget() + first.styles.width = 20 + first.styles.height = 4 + replacement.styles.width = 30 + replacement.styles.height = 4 + await app.mount(first, replacement) + await pilot.pause() + assert first.styles._cache_key == replacement.styles._cache_key + fraction = Fraction(1) + def measured(): + return first._get_box_model(Size(80, 20), app.size, fraction, fraction) + assert measured().width == 20 + original = first.styles + first.styles = replacement.styles + try: + assert measured().width == 30 + finally: + first.styles = original + assert measured().width == 20 + + +async def test_fractional_width_is_local_only_for_greedy_native_boxes(): + app = App() + async with app.run_test() as pilot: + widget = Static('first') + widget.styles.width = '1fr' + widget.styles.height = 4 + await app.mount(widget) + await pilot.pause() + fraction = Fraction(30) + def measured(greedy): + return widget._get_box_model(Size(80, 20), app.size, fraction, fraction, + greedy=greedy) + greedy = measured(True) + intrinsic = measured(False) + widget.update('a substantially longer original line') + changed = measured(False) + assert changed.width > intrinsic.width + assert measured(True) == greedy + # After switching measurement sources, acquire the fixed/fractional + # source again; subsequent content mutations need not retire it. + greedy = measured(True) + widget.update('another changed line') + assert measured(True) is greedy From 82eacab01004fae53feaa1496a2b610e6cba62d4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 17:05:16 -0400 Subject: [PATCH 3/3] Preserve custom box inputs and qualify fixed style source reuse --- evidence/fixed-box-source-20261007/README.md | 45 ++++ .../fixed-box-source-20261007/app-result.json | 212 ++++++++++++++++++ src/textual/_measurement.py | 8 +- src/textual/widget.py | 7 +- tests/test_box_model_cache_lifetime.py | 54 +++++ 5 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 evidence/fixed-box-source-20261007/README.md create mode 100644 evidence/fixed-box-source-20261007/app-result.json diff --git a/evidence/fixed-box-source-20261007/README.md b/evidence/fixed-box-source-20261007/README.md new file mode 100644 index 0000000000..a5f19a6787 --- /dev/null +++ b/evidence/fixed-box-source-20261007/README.md @@ -0,0 +1,45 @@ +# Fixed native box source + +The native fixed/fill box resolver reads its own styles and supplied size, +viewport and fractions. It does not measure descendants. Previously its cache +generation still included descendant layout changes. The existing local style +dependency owner now supplies this distinction; the existing box cache binds +these results to the actual RenderStyles object and generation. It retains that +object so replacement and object lifetime cannot alias an unrelated source. + +Intrinsic sizing, fractional width with greedy=False, custom style getters and +custom scalar resolvers retain full layout invalidation. Native spacing and +border inputs are required for the narrower source. Parent auto-size and parent +lifetime remain inputs. Held mutation-box admission remains first. Width, +viewport and fraction variants remain separate. Original extrema are restored +on cache hits. Repeated generation fields were removed from individual keys: +the owning cache already retires its previous generation. + +The complete consumers are Widget._get_box_model and +box_depends_on_available_height; presentation release already retires the style +dependency and box sources. Related tests now check stale results and actual +cache lifetime instead of duplicating internal key shape. Existing Package +analysis parsed 250 production modules without omissions. + +Final related controls: 34 passed in 2.63s, using this native source. Coverage +includes descendant changes, style-source replacement, intrinsic versus fixed +sizing, fractional greedy modes, custom Scalar/RenderStyles behavior, extrema +restoration and held geometry. An attempted Spacing-subclass control failed at +declaration because the actual accelerated Spacing is not subclassable; that +invalid control was removed. Its original failure log is preserved. + +One original raw-driver resize App used source 623e07dd with the existing +installed Toad/Core runtime, without changing packages. It reached width 60, +released capture, exited zero and produced empty stderr. Final width appeared +at 993.66ms; matching earlier installed observation was 1057.70ms. Layout counts +were 69 versus 61. Different work and profiling overhead mean this does not +establish a reliable latency gain. Width frames still take roughly 100ms. +The subsequent source changes only decline non-native spacing/border inputs and +name the already-used tuple fields; the App's native-input path is unchanged. + +Full App output and profiles remain at +/home/ts/.cache/agent-scratch/native-box-source-20261007. The original fixture +owns joined teardown; exact individual child births were not recorded. This is +source/App acceptance, not an installed or physical speed claim. There were no +provider calls, public inputs or package writes. Parent owns integration and +durable delivery. Required width-dependent rewrapping and arrangement remain. diff --git a/evidence/fixed-box-source-20261007/app-result.json b/evidence/fixed-box-source-20261007/app-result.json new file mode 100644 index 0000000000..42563c6ac0 --- /dev/null +++ b/evidence/fixed-box-source-20261007/app-result.json @@ -0,0 +1,212 @@ +{ + "posted_ms": 706.3528039725497, + "frames": [ + { + "ms": 111.03438097052276, + "width": 49, + "latest_requested_delta": 7 + }, + { + "ms": 230.6039429968223, + "width": 55, + "latest_requested_delta": 10 + }, + { + "ms": 345.3719209646806, + "width": 56, + "latest_requested_delta": 3 + }, + { + "ms": 463.6130550643429, + "width": 57, + "latest_requested_delta": 4 + }, + { + "ms": 578.8633780321106, + "width": 50, + "latest_requested_delta": 11 + }, + { + "ms": 689.6544770570472, + "width": 52, + "latest_requested_delta": 12 + }, + { + "ms": 877.9696580022573, + "width": 58, + "latest_requested_delta": 12 + }, + { + "ms": 993.6596649931744, + "width": 60, + "latest_requested_delta": 12 + } + ], + "requests": [ + [ + 0.2652329858392477, + 1 + ], + [ + 18.897947040386498, + 2 + ], + [ + 37.19545598141849, + 3 + ], + [ + 53.384628030471504, + 4 + ], + [ + 66.79921899922192, + 5 + ], + [ + 85.08271095342934, + 6 + ], + [ + 103.35164598654956, + 7 + ], + [ + 121.61416804883629, + 8 + ], + [ + 136.9925830513239, + 9 + ], + [ + 150.25750105269253, + 10 + ], + [ + 168.59511495567858, + 11 + ], + [ + 186.9223100366071, + 12 + ], + [ + 205.22478804923594, + 11 + ], + [ + 223.5255630221218, + 10 + ], + [ + 242.8538060048595, + 9 + ], + [ + 252.307447954081, + 8 + ], + [ + 262.4928279547021, + 7 + ], + [ + 278.1075610546395, + 6 + ], + [ + 296.5192859992385, + 5 + ], + [ + 314.88417205400765, + 4 + ], + [ + 333.2842959789559, + 3 + ], + [ + 351.663860026747, + 2 + ], + [ + 369.79632498696446, + 1 + ], + [ + 383.2449109759182, + 0 + ], + [ + 401.6143069602549, + 1 + ], + [ + 420.00607401132584, + 2 + ], + [ + 438.38870397303253, + 3 + ], + [ + 456.77275699563324, + 4 + ], + [ + 475.2445960184559, + 5 + ], + [ + 490.31689995899796, + 6 + ], + [ + 503.6086189793423, + 7 + ], + [ + 522.0020289998502, + 8 + ], + [ + 540.3764940565452, + 9 + ], + [ + 558.7385550606996, + 10 + ], + [ + 577.0936830667779, + 11 + ], + [ + 598.4561620280147, + 12 + ] + ], + "messages": { + "MouseMove": 16, + "CoreEventMessage": 40, + "Update": 465, + "Layout": 69, + "Resize": 80, + "InvokeLater": 42, + "Callback": 96, + "MouseUp": 2, + "TextSelected": 2, + "MouseRelease": 1, + "Click": 5, + "Compose": 153, + "Mount": 140, + "StateChanged": 66, + "TableOfContentsUpdated": 53 + }, + "widgets": 778, + "error": null, + "final_width": 60, + "boundary": "completed headless display; raw Driver ingress; not terminal pixels" +} \ No newline at end of file diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 3167fa42af..29988af194 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -7,6 +7,7 @@ from textual.css.scalar import Scalar, Unit from textual.css.styles import RenderStyles +from textual.geometry import Spacing if TYPE_CHECKING: from textual.widget import Widget @@ -266,7 +267,12 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool]: width, height, min_width, max_width, min_height, max_height, )) and (width is None or not width.is_auto) - and (height is None or not height.is_auto), + and (height is None or not height.is_auto) + and all(type(spacing) is Spacing and all( + type(cell) in (int, bool) for cell in spacing + ) for spacing in (styles.margin, styles.padding)) + and all(type(edge) is tuple and type(edge[0]) is str + for edge in styles.border), ) widget._height_style_dependency_cache = revision, result return result diff --git a/src/textual/widget.py b/src/textual/widget.py index 8640f59027..b54232da4f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1960,8 +1960,11 @@ def _get_box_model( nodes = self.__dict__.get("_nodes") revision: tuple[object, ...] = (self._layout_updates, nodes._updates if nodes is not None else 0) - inputs = _local_box_inputs(self) if self._native_box_measurement else None - local_styles = inputs is not None and inputs[3] and (greedy or not inputs[1]) + if self._native_box_measurement: + _, content_width, _, styles_only = _local_box_inputs(self) + local_styles = styles_only and (greedy or not content_width) + else: + local_styles = False if local_styles: # Fixed/fill native boxes never query child content. Their source # is the original local style owner, not descendant layout epochs. diff --git a/tests/test_box_model_cache_lifetime.py b/tests/test_box_model_cache_lifetime.py index 5b300a5b8b..ad3c24d184 100644 --- a/tests/test_box_model_cache_lifetime.py +++ b/tests/test_box_model_cache_lifetime.py @@ -167,3 +167,57 @@ def measured(greedy): greedy = measured(True) widget.update('another changed line') assert measured(True) is greedy + + +async def test_custom_scalar_keeps_descendant_measurement_source(): + from textual.css.scalar import Scalar, Unit + leaf = Static('source') + leaf.styles.height = 2 + + class ChildWidth(Scalar): + def resolve(self, size, viewport, fraction_unit=Fraction(1)): + return Fraction(leaf.styles.height.value) + + app = App() + async with app.run_test() as pilot: + container = Widget(leaf) + container.styles.width = ChildWidth(1.0, Unit.CELLS, Unit.WIDTH) + container.styles.height = 4 + await app.mount(container) + await pilot.pause() + fraction = Fraction(1) + def measured(): + return container._get_box_model(Size(80, 20), app.size, fraction, fraction) + assert measured().width == 2 + leaf.styles.height = 8 + assert measured().width == 8 + + +async def test_custom_style_getter_keeps_descendant_measurement_source(): + from textual.css.scalar import Scalar + from textual.css.styles import RenderStyles + + class ChildStyles(RenderStyles): + @property + def width(self): + return Scalar.from_number(self.node.children[0].styles.height.value) + + app = App() + async with app.run_test() as pilot: + leaf = Static('source') + leaf.styles.height = 2 + container = Widget(leaf) + container.styles.height = 4 + await app.mount(container) + await pilot.pause() + original = container.styles + container.styles = ChildStyles(container, original.base, original.inline) + fraction = Fraction(1) + def measured(): + return container._get_box_model(Size(80, 20), app.size, fraction, fraction) + try: + assert measured().width == 2 + leaf.styles.height = 8 + assert measured().width == 8 + finally: + container.styles = original