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
68 changes: 68 additions & 0 deletions evidence/parent-width-measurement-20261007/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# 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.

## 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.
45 changes: 30 additions & 15 deletions src/textual/_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
))
Expand All @@ -272,17 +266,38 @@ 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


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):
Expand Down
15 changes: 11 additions & 4 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -1997,7 +2004,7 @@ def _get_box_model(
cache_key = (
cache_container,
viewport,
width_fraction,
cache_width_fraction,
cache_height_fraction,
constrain_width,
greedy,
Expand Down Expand Up @@ -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)
Expand Down
122 changes: 122 additions & 0 deletions tests/test_height_independent_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,128 @@
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


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)
Expand Down
Loading