diff --git a/evidence/grid-track-inputs-20261007/README.md b/evidence/grid-track-inputs-20261007/README.md new file mode 100644 index 0000000000..871bf71464 --- /dev/null +++ b/evidence/grid-track-inputs-20261007/README.md @@ -0,0 +1,45 @@ +# Grid measurement inputs + +`GridLayout.arrange` queries content width and outer-size width extrema only +for auto columns without column spans. It queries content height and outer-size +height extrema only for auto rows without row spans. Final boxes and offsets +receive the resolved cell size. `GridHeight` previously treated all four +operations as incoming-height inputs even when the tracks never called them. + +The existing dependency declaration now follows those actual query branches. +The original Widget arrangement resource can reuse measurement placements at +another available height when the grid does not read that height. Width, +viewport, child membership, layout/style publication and optimal mode remain +in the original resource key. Fractional/height-relative rows, height-relative +columns, actual auto-track measurements/extrema, custom layout hooks and +dock/split/overlay placement retain their original dependencies. No compositor, +held-root, clipping or hit-map decision changes. + +Source census: 250 production modules parsed, zero omissions, using the existing +refactor-audit Package. The supply/consumer family is GridLayout.arrange, +Layout.__init_subclass__, GridHeight, NativeLayoutHeight and +Widget._arrangement_depends_on_available_height/arrange. There is one changed +production owner, GridHeight; no new retained resource or event cache. + +Fourteen affected grid controls passed in 1.07 seconds. New real App controls +compare reused placements against fresh placement and retain custom incoming- +height behavior when the corresponding track changes to auto. The first +height-control assertion incorrectly expected a fixed-height child to fill +the larger auto track; it was corrected to make the child auto-height when +testing that dynamic measurement. Production was unchanged by that correction. + +One existing raw-Driver sidebar App check passed with the existing installed +WorkerStatic declaration cohort and native source override. It completed 36 +packets, 625 final widgets, width 60, no reported App error. Raw result and +profile remain at: + +`/home/ts/.cache/agent-scratch/sidebar-drag-hotpath-20261007/grid-track-input-source-drag/` + +Compared with the supplied installed profile, arrangement calls were 760 to +661 and box calls 721 to 643, but root reflows were also 11 to 9. Inclusive root +arrangement was 412 to 337 ms and box time 262 to 244 ms; these overlap. Final +headless paint was 1121 to 937 ms, while posting took 712 to 805 ms. This is +not a reliable overall latency improvement or terminal-pixel acceptance. +The new declaration is source-qualified and the affected App path works; +installation belongs to Parent's integration. Transcript remeasurement at +genuinely changed widths remains required. diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 8d760e0d1f..6667d23424 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -211,20 +211,32 @@ def depends(self, widget: Widget, *, greedy: bool = True) -> bool: or any(_scalar_uses_height(row, height_fraction=True) for row in rows) or any(_scalar_uses_height(column) for column in columns)): return True + auto_rows = not rows or any(row.is_auto for row in rows) + auto_columns = any(column.is_auto for column in columns) for child in widget.displayed_children: child_styles = child.styles # Auto cells pass the outer size directly to content measurement # and extrema. Final boxes/offsets receive the already-resolved cell # size, so fractional child boxes are safe once tracks are proven. - if child._content_height_dependency.depends(child): - return True - if child._content_width_dependency.depends(child): - return True - if any(scalar is not None and _scalar_uses_height(scalar) for scalar in ( - child_styles.min_width, child_styles.max_width, - child_styles.min_height, child_styles.max_height, - )): - return True + # Only auto tracks ask for content or resolve extrema against the + # incoming outer size. Fixed/fractional columns and fixed rows + # resolve their cell sizes first; final child boxes read those cell + # sizes, not the outer height. An unused custom measurement must + # not make measurement and placement arrange the same grid twice. + if auto_rows and child_styles.row_span == 1: + if child._content_height_dependency.depends(child): + return True + if any(scalar is not None and _scalar_uses_height(scalar) for scalar in ( + child_styles.min_height, child_styles.max_height, + )): + return True + if auto_columns and child_styles.column_span == 1: + if child._content_width_dependency.depends(child): + return True + if any(scalar is not None and _scalar_uses_height(scalar) for scalar in ( + child_styles.min_width, child_styles.max_width, + )): + return True return False diff --git a/tests/test_height_independent_arrangement.py b/tests/test_height_independent_arrangement.py index 90e907777b..0f0c18d816 100644 --- a/tests/test_height_independent_arrangement.py +++ b/tests/test_height_independent_arrangement.py @@ -132,6 +132,50 @@ async def test_grid_auto_track_extrema_keep_outer_height_dependency(field, value assert parent.arrange(Size(40, 100)) is not first +@pytest.mark.parametrize("unused_axis", ["width", "height"]) +async def test_grid_reuses_only_when_outer_measurement_is_not_called(unused_axis): + class WidthFromHeight(Static): + def get_content_width(self, container, viewport): + return container.height + + class HeightFromHeight(Static): + def get_content_height(self, container, viewport, width): + return container.height + + app = App() + async with app.run_test(size=(80, 24)) as pilot: + child = (WidthFromHeight if unused_axis == "width" else HeightFromHeight)( + "wrapping text " * 10 + ) + child.styles.width = "1fr" + child.styles.height = "auto" if unused_axis == "width" else 2 + parent = VerticalGroup(child) + parent.styles.layout = "grid" + parent.styles.height = "auto" + parent.styles.grid_columns = "1fr" + parent.styles.grid_rows = "auto" if unused_axis == "width" else "2" + await app.mount(parent) + await pilot.pause() + + first = parent.arrange(Size(40, 10)) + assert parent.arrange(Size(40, 100)) is first + # Reuse must agree with a fresh original placement, not just its key. + parent._clear_arrangement_cache() + assert parent.arrange(Size(40, 100)).placements == first.placements + assert parent.arrange(Size(30, 100)) is not first + + # The exact same custom method becomes a real outer-size input as + # soon as the corresponding auto track actually calls it. + if unused_axis == "width": + parent.styles.grid_columns = "auto" + else: + parent.styles.grid_rows = "auto" + child.styles.height = "auto" + dependent = parent.arrange(Size(40, 10)) + assert parent.arrange(Size(40, 100)) is not dependent + assert parent.arrange(Size(40, 100)).placements != dependent.placements + + async def test_local_style_projection_covers_all_raw_mutation_boundaries(): app = App() async with app.run_test() as pilot: