From bb84167728ec966cae06e8c836c05763810ab5b4 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 18:09:08 -0400 Subject: [PATCH 1/3] Exclude unused parent width from fixed native box measurements --- src/textual/_measurement.py | 45 ++++++++++++++++++++++++------------- src/textual/widget.py | 15 +++++++++---- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 29988af194..5512a71f22 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -231,14 +231,14 @@ def decorate(function: Function) -> Function: return decorate -def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool]: +def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool, bool]: """Resolve local scalar dependencies once per owning style generation.""" styles = widget.styles if type(styles) is not RenderStyles: - return True, False, False, False + return True, False, 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") + cached = widget.__dict__.get("_box_style_dependency_cache") if cached is not None and cached[0] == revision: return cached[1] width, height = styles.width, styles.height @@ -249,20 +249,14 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, 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, False) + result = (True, False, 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, False) + result = (True, False, False, False, False) break else: - result = ( - height is None or height.is_fraction - or (min_height is not None and min_height.is_fraction) - # Non-cell max heights have a zero-height auto-parent exception. - 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, + styles_only = ( all(scalar is None or type(scalar) is Scalar for scalar in ( width, height, min_width, max_width, min_height, max_height, )) @@ -272,9 +266,30 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool]: 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), + for edge in styles.border) + ) + independent_width = ( + styles_only + and width is not None + # Fill, percentages and fractions still read available width. + and all(scalar is None or ( + (scalar.percent_unit if scalar.unit is Unit.PERCENT else scalar.unit) + not in (Unit.WIDTH, Unit.FRACTION) + ) for scalar in (width, height, min_width, max_width, min_height, max_height)) + # Non-cell max widths have a zero-width auto-parent exception. + and (max_width is None or max_width.is_cells) + ) + result = ( + height is None or height.is_fraction + or (min_height is not None and min_height.is_fraction) + # Non-cell max heights have a zero-height auto-parent exception. + 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, + styles_only, + independent_width, ) - widget._height_style_dependency_cache = revision, result + widget._box_style_dependency_cache = revision, result return result @@ -282,7 +297,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 b54232da4f..c6be083afd 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1961,16 +1961,18 @@ def _get_box_model( revision: tuple[object, ...] = (self._layout_updates, nodes._updates if nodes is not None else 0) if self._native_box_measurement: - _, content_width, _, styles_only = _local_box_inputs(self) + _, content_width, _, styles_only, independent_width = _local_box_inputs(self) local_styles = styles_only and (greedy or not content_width) else: local_styles = False + independent_width = False 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 + cache_container = container + cache_width_fraction, cache_height_fraction = width_fraction, height_fraction 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. @@ -1985,6 +1987,11 @@ def _get_box_model( if independent_height: cache_container = container.with_height(0) cache_height_fraction = Fraction(0) + if independent_width and not constrain_width: + # The native local resolver cannot read these inputs. Keep the + # viewport and actual height inputs; only parent width is unused. + cache_container = cache_container.with_width(0) + cache_width_fraction = Fraction(0) if revision != self._box_model_revision: # Measurements from previous revisions can never be hit again. # Keep width/viewport variants within this revision, rather than @@ -1997,7 +2004,7 @@ def _get_box_model( cache_key = ( cache_container, viewport, - width_fraction, + cache_width_fraction, cache_height_fraction, constrain_width, greedy, @@ -4876,7 +4883,7 @@ def _release_presentation(self) -> None: self._box_model_cache.clear() self.__dict__.pop("_height_dependency_cache", None) self.__dict__.pop("_height_arrangement_cache", None) - self.__dict__.pop("_height_style_dependency_cache", None) + self.__dict__.pop("_box_style_dependency_cache", None) self._box_model_revision = None self._content_width_cache = (None, 0) self._content_height_cache = (None, 0) From 20dc34a4dfd44b6ae148ce9ceac01c6a45389a1d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 18:12:46 -0400 Subject: [PATCH 2/3] Verify fixed width reuse and preserve constrained and custom measurements --- .../README.md | 49 ++++++++++++ tests/test_height_independent_measurement.py | 77 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 evidence/parent-width-measurement-20261007/README.md diff --git a/evidence/parent-width-measurement-20261007/README.md b/evidence/parent-width-measurement-20261007/README.md new file mode 100644 index 0000000000..ee108f1617 --- /dev/null +++ b/evidence/parent-width-measurement-20261007/README.md @@ -0,0 +1,49 @@ +# Fixed boxes under width changes + +The native arrangement resolver visits child box measurements when a container +changes width. `_get_box_model` previously keyed every measurement by that +container width and its width fraction, including explicit fixed boxes whose +original scalar/extrema resolver cannot read either input. + +The existing `_local_box_inputs` declaration now supplies this answer alongside +its height/content/style answers, from the same original RenderStyles generation. +The existing box measurement resource omits parent width only for proved native +local boxes. No second measurement or geometry cache was added. Both consumers +and Widget teardown were migrated; the old height-only style-resource name is +deleted. Parent identity/auto sizing, viewport, actual height, styles, extrema +and constrained-width behavior remain inputs. Fill, auto content, percentages, +fractions, cross-axis width units, custom resolvers/styles and the non-cell +maximum-width zero-parent exception remain conservative. + +Source trace covered `_resolve_box_models`, native vertical/horizontal placement, +Widget arrangement/measurement/resize/refresh, DOM ancestor invalidation, +Compositor placement/held/intrinsic resources, Scalar resolution and extrema. +The existing Package AST reader parsed 250 native modules without omissions. +Source search found two `_local_box_inputs` consumers plus the original teardown +resource release; all were migrated together. + +Wrapping is a separate required answer. Auto-height content measures at its new +width; layout needs those heights to position following children and establish +scroll extents before painting. Native TextArea also rebuilds wrapping on resize. +Its public document can be independently mutated, so equal wrap width alone does +not certify unchanged text. No width-only shortcut or readiness bypass was added +to that family, and this change does not remove its synchronous work. + +## Results + +- **32 measurement checks passed, 1.76s**, including exact fixed-box resource + reuse and style replacement, width-relative values, constraints, viewport + units, custom extrema and the zero-width auto-parent exception. +- **Original raw-driver drag App exit 0, empty stderr** using this native source + and the released native-layout-lifetime author runtime. Final width 60, + capture released, final paint 852.16ms, 16 frames, 57 Layout messages, + 732 initial / 650 final widgets. No provider input or package change. +- Parent's installed baseline was 803.64ms with 802 initial / 662 final widgets. + Different work was present; **no reliable latency improvement is claimed**. + +Original unchanged driver: +`/home/ts/.cache/agent-scratch/sidebar-drag-hotpath-20261007/drag-settled.py`. +Results: `/home/ts/.cache/agent-scratch/native-parent-width-20261007/` +(`native.log`, `app.stdout.log`, `app.stderr.log`, `drag01/result.json`). +The original fixture owns private cleanup. Exact child birth records were not +collected. No installed or live qualification is implied by the source App. diff --git a/tests/test_height_independent_measurement.py b/tests/test_height_independent_measurement.py index 267345e3ac..c5bf55bbdc 100644 --- a/tests/test_height_independent_measurement.py +++ b/tests/test_height_independent_measurement.py @@ -15,6 +15,83 @@ from textual.widgets import Static +async def test_fixed_box_reuses_original_measurement_across_parent_widths(): + async with App().run_test(): + widget = Widget() + widget.styles.width = 12 + widget.styles.height = 3 + widget.styles.padding = (1, 2) + widget.styles.margin = (2, 1) + widget.styles.min_width = 8 + widget.styles.max_width = 20 + first = widget._get_box_model(Size(30, 10), Size(120, 40), Fraction(30), Fraction(10)) + second = widget._get_box_model(Size(70, 10), Size(120, 40), Fraction(70), Fraction(10)) + assert first is second + assert len(widget._box_model_cache) == 1 + widget.styles.width = 14 + changed = widget._get_box_model(Size(70, 10), Size(120, 40), Fraction(70), Fraction(10)) + assert changed.width != first.width + + +@pytest.mark.parametrize("width,height", [(None, 3), ("50%", 3), ("1fr", 3), (12, "50w")]) +async def test_available_width_inputs_keep_distinct_box_measurements(width, height): + async with App().run_test(): + widget = Widget() + widget.styles.width = width + widget.styles.height = height + first = widget._get_box_model(Size(30, 20), Size(120, 40), Fraction(30), Fraction(20)) + second = widget._get_box_model(Size(70, 20), Size(120, 40), Fraction(70), Fraction(20)) + assert first != second + + +async def test_fixed_box_constrain_width_and_viewport_remain_inputs(): + async with App().run_test(): + widget = Widget() + widget.styles.width = 50 + widget.styles.height = 3 + narrow = widget._get_box_model(Size(20, 10), Size(120, 40), Fraction(20), Fraction(10), constrain_width=True) + wide = widget._get_box_model(Size(70, 10), Size(120, 40), Fraction(70), Fraction(10), constrain_width=True) + assert narrow.width == 20 + assert wide.width == 50 + widget.styles.width = "10vw" + first = widget._get_box_model(Size(30, 10), Size(100, 40), Fraction(30), Fraction(10)) + second = widget._get_box_model(Size(70, 10), Size(200, 40), Fraction(70), Fraction(10)) + assert first.width == 10 + assert second.width == 20 + + +async def test_noncell_width_limit_keeps_original_zero_width_parent_exception(): + async with App().run_test() as pilot: + child = Widget() + child.styles.width = 30 + child.styles.height = 3 + child.styles.max_width = "10vw" + parent = VerticalGroup(child) + parent.styles.width = "auto" + await pilot.app.mount(parent) + first = child._get_box_model(Size(0, 10), Size(120, 40), Fraction(0), Fraction(10)) + second = child._get_box_model(Size(70, 10), Size(120, 40), Fraction(70), Fraction(10)) + assert first.width == 30 + assert second.width == 12 + + +async def test_custom_extrema_keeps_available_parent_width(): + from textual._extrema import Extrema + + class WidthSizedExtrema(Widget): + def _resolve_extrema(self, container, viewport, width_fraction, height_fraction): + return Extrema(min_width=Fraction(container.width)) + + async with App().run_test(): + widget = WidthSizedExtrema() + widget.styles.width = 12 + widget.styles.height = 3 + first = widget._get_box_model(Size(30, 10), Size(120, 40), Fraction(30), Fraction(10)) + second = widget._get_box_model(Size(70, 10), Size(120, 40), Fraction(70), Fraction(10)) + assert first.width == 30 + assert second.width == 70 + + def box(widget, height, *, width=40, greedy=True): return widget._get_box_model(Size(width, height), widget.app.size, Fraction(width), Fraction(height), greedy=greedy) From b93d1a0a2898406a574c30e964c36ba9046df76b Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 18:23:17 -0400 Subject: [PATCH 3/3] Demonstrate auto-content refusal in parent-width measurement relation --- .../README.md | 19 ++++++++ tests/test_height_independent_measurement.py | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/evidence/parent-width-measurement-20261007/README.md b/evidence/parent-width-measurement-20261007/README.md index ee108f1617..2d79d94e0b 100644 --- a/evidence/parent-width-measurement-20261007/README.md +++ b/evidence/parent-width-measurement-20261007/README.md @@ -47,3 +47,22 @@ Results: `/home/ts/.cache/agent-scratch/native-parent-width-20261007/` (`native.log`, `app.stdout.log`, `app.stderr.log`, `drag01/result.json`). The original fixture owns private cleanup. Exact child birth records were not collected. No installed or live qualification is implied by the source App. + +## Auto-content review + +The published predicate already requires `styles_only`, which excludes both +auto width and auto height. It also excludes fractional width. Therefore an +independent-width answer implies no width-content call and `local_styles=True` +for both greedy modes. Custom auto-height measurement cannot receive the +normalized width key. No production correction or extra guard was added. + +Two additional real native controls passed: auto width with a genuinely relative +`1fr` child expands from 30 to 50; a custom auto-height method reading parent +width returns 60 then 100. Both keep distinct measurement resources. +`auto-content-review.log` retains the custom-height pass and first invalid +auto-width setup; `auto-width-mounted-review.log` retains the explicit refusal +of that setup. Inline `50%` is normalized to Unit.WIDTH by ScalarProperty and +does not enter the original relative-child expansion predicate. The corrected +control uses the original fractional declaration and awaits child mount before +asserting that required relation; `auto-width-relative-declaration-review.log` +records its pass (0.41s). No accepted drag App was repeated. diff --git a/tests/test_height_independent_measurement.py b/tests/test_height_independent_measurement.py index c5bf55bbdc..835ca2291b 100644 --- a/tests/test_height_independent_measurement.py +++ b/tests/test_height_independent_measurement.py @@ -92,6 +92,51 @@ def _resolve_extrema(self, container, viewport, width_fraction, height_fraction) assert second.width == 70 +async def test_auto_width_relative_child_keeps_parent_width_expansion(): + async with App().run_test() as pilot: + child = Widget() + # Fraction is retained as a relative unit by the inline declaration. + # Inline percentages are normalized to WIDTH by ScalarProperty. + child.styles.width = "1fr" + child.styles.height = 1 + parent = VerticalGroup(child) + parent.styles.width = "auto" + parent.styles.height = 3 + await pilot.app.mount(parent) + await child._mounted_event.wait() + assert child in parent.children + assert parent._has_relative_children_width + first = parent._get_box_model( + Size(30, 10), Size(120, 40), Fraction(30), Fraction(10) + ) + second = parent._get_box_model( + Size(50, 10), Size(120, 40), Fraction(50), Fraction(10) + ) + assert first.width == 30 + assert second.width == 50 + assert first is not second + + +async def test_custom_auto_height_keeps_independent_parent_width_input(): + class WidthMeasuredHeight(Widget): + def get_content_height(self, container, viewport, width): + return container.width * 2 + + async with App().run_test(): + widget = WidthMeasuredHeight() + widget.styles.width = 12 + widget.styles.height = "auto" + first = widget._get_box_model( + Size(30, 10), Size(120, 40), Fraction(30), Fraction(10) + ) + second = widget._get_box_model( + Size(50, 10), Size(120, 40), Fraction(50), Fraction(10) + ) + assert first.height == 60 + assert second.height == 100 + assert first is not second + + def box(widget, height, *, width=40, greedy=True): return widget._get_box_model(Size(width, height), widget.app.size, Fraction(width), Fraction(height), greedy=greedy)