diff --git a/evidence/arrangement-input-mode-20261007/README.md b/evidence/arrangement-input-mode-20261007/README.md new file mode 100644 index 0000000000..7b2284760e --- /dev/null +++ b/evidence/arrangement-input-mode-20261007/README.md @@ -0,0 +1,53 @@ +# Actual placement mode owns measurement dependencies + +Ordinary fractional-width placement resolves its fraction without querying +content width. Optimal sizing turns that width into auto and queries it. The +previous height proof conflated these operations, preventing ordinary content +height measurement and placement from sharing their existing arrangement. + +HeightDependency now carries the actual greedy mode. Flow forwards it to child +boxes; Widget derives it from optimal for arrangement and passes greedy from +the box resolver. Existing dependency resources retain both answers under their +original structure/layout epoch. Actual result resources already keyed mode; +no new resource or result cache was added. + +Native content-height algorithms still use normal arrangement regardless of +outer box mode. Native content-width uses zero-height optimal arrangement. +Stream directly measures content; Grid keeps its conservative track proof. +Custom hooks, relative heights, docks, splits, overlays and alignment retain +their original dependencies. Width, viewport, style and child mutation remain +inputs. No placement or rendering algorithm changed. + +## Source and checks + +Existing refactor-audit Package parsed 250 native modules, zero omissions. +All HeightDependency implementations live in _measurement.py. Widget owns both +source resources and box/arrangement consumers; Layout declarations choose the +content algorithms. Current Toad declares native measurement policies but no +custom HeightDependency subclass. No Toad source changed. + +Four affected controls passed in 0.49s: mode-specific custom width, ordinary +arrangement reuse, width/style/child changes, nested flow and unknown overrides. +The first new control incorrectly treated indexed (ordinal, placement) tuples +as placements; its AttributeError was corrected at that original test reader. +No product correction was made in response to that fixture error. + +One existing raw Driver drag-settled App completed with source native and the +installed owned-sidebar/Content-worker frontend. Twelve source updates and the +original ready boundary preceded 36 pointer moves. Exit zero, empty stderr, +released capture, final width 60. Profiled final frame 955.09ms, 8 frames, +625 final widgets. This is not comparable to the supplied unprofiled 747ms +result; no user-visible speedup is demonstrated. + +Inclusive profile: 8 root arrangements 462.58ms; box resolver 902/321 calls +300.62ms; Layout.get_content_height 291/68 calls 258.45ms. These costs overlap +and are not summed. Different widget/frame counts prohibit reliable comparison +with the prior installed profile. Width-dependent content measurement and +placement remain expensive. This removes a false dependency, not that work. + +Raw logs and profile: +/home/ts/.cache/agent-scratch/native-arrangement-input-mode-20261007/ +(native.log, app.stdout.log, app.stderr.log, drag01/result.json, drag01/ui.pstats). + +No package, provider, public owner or installed pin changed. No standalone +build requested on the strength of this result. diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 5512a71f22..4c3fde84f5 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -15,11 +15,15 @@ class HeightDependency(ABC): @abstractmethod - def depends(self, widget: Widget) -> bool: - """Whether incoming container height can change the measured result.""" + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: + """Whether incoming height changes this operation in its actual box mode. - def box_depends(self, widget: Widget) -> bool: - return self.depends(widget) or widget._has_relative_children_height + Placement propagates greedy mode. Content measurements select the mode + used by their own algorithm, independent of the outer box operation. + """ + + def box_depends(self, widget: Widget, *, greedy: bool = True) -> bool: + return self.depends(widget, greedy=greedy) or widget._has_relative_children_height def styles_sensitive(self, widget: Widget) -> bool: """Unknown measurements may read any rule, not just native box inputs. @@ -31,12 +35,12 @@ def styles_sensitive(self, widget: Widget) -> bool: class ContextHeight(HeightDependency): - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: return True class IndependentHeight(HeightDependency): - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: return False @@ -47,7 +51,7 @@ class StoredVirtualSize(HeightDependency): or replaced descriptors retain arbitrary style/container dependencies. """ - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: from textual.reactive import _STORED_REACTIVE_ACCESS from textual.widget import Widget @@ -64,15 +68,17 @@ class NativeWidgetMeasurementHeight(HeightDependency): def layout_dependency(self, widget: Widget) -> HeightDependency: """The native layout owns the selected measurement's dependency.""" - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: if not widget.is_container: # Native leaf visuals receive rules and width, not container height. return False if not widget._native_measurement_layout_hooks: return True + # Native get_content_height / get_content_width choose their own + # arrangement inputs; outer optimal placement cannot change those. return self.layout_dependency(widget).depends(widget) - def box_depends(self, widget: Widget) -> bool: + def box_depends(self, widget: Widget, *, greedy: bool = True) -> bool: if not widget.is_container: return False if not widget._native_measurement_layout_hooks: @@ -98,15 +104,15 @@ def layout_dependency(self, widget: Widget) -> HeightDependency: class NativeLayoutHeight(HeightDependency): - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: # Conservatively require a declaration for the arranger too. Even the # fixed-zero branch need not opt unknown/custom layout hooks into reuse. return widget._arrangement_depends_on_available_height() - def box_depends(self, widget: Widget) -> bool: + def box_depends(self, widget: Widget, *, greedy: bool = True) -> bool: if _arrangement_wrapper_uses_height(widget): return True - return widget.layout._arrangement_height_dependency.box_depends(widget) + return widget.layout._arrangement_height_dependency.box_depends(widget, greedy=True) def styles_sensitive(self, widget: Widget) -> bool: if not widget._native_measurement_layout_hooks: @@ -136,7 +142,7 @@ def styles_sensitive(self, widget: Widget) -> bool: or type(widget).process_layout is not Widget.process_layout ) - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: styles = widget.styles if (not widget._native_measurement_layout_hooks or styles.align_horizontal != "left" or styles.align_vertical != "top"): @@ -149,7 +155,7 @@ def depends(self, widget: Widget) -> bool: # percentage heights as relative even for a raw width-axis scalar. if child_styles.is_relative_height: return True - if child._box_depends_on_available_height(): + if child._box_depends_on_available_height(greedy=greedy): return True return False @@ -164,7 +170,7 @@ class StreamHeight(FlowHeight): # general box operation. Reuse its one implementation, not Flow's box proof. box_depends = HeightDependency.box_depends - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: # Stream measures content directly, even when a child's CSS box height # is fixed. A box proof alone cannot certify this operation. return any(child._content_height_dependency.depends(child) @@ -180,7 +186,7 @@ def _scalar_uses_height(scalar: Scalar, *, height_fraction: bool = False) -> boo class GridHeight(FlowHeight): box_depends = HeightDependency.box_depends - def depends(self, widget: Widget) -> bool: + def depends(self, widget: Widget, *, greedy: bool = True) -> bool: styles = widget.styles rows, columns = styles.grid_rows or (), styles.grid_columns or () # Default rows become fractional at nonzero height for non-auto parents. @@ -293,14 +299,17 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool, bool]: return result -def box_depends_on_available_height(widget: Widget) -> bool: +def box_depends_on_available_height(widget: Widget, *, greedy: bool) -> 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) if depends: return True - if content_width and widget._content_width_dependency.depends(widget): + # Fractional width becomes auto only in optimal sizing. Normal placement + # resolves the supplied fraction without calling get_content_width. + if (content_width and (not greedy or widget.styles.width.is_auto) + and widget._content_width_dependency.depends(widget)): return True if content_height: return widget._content_height_dependency.box_depends(widget) diff --git a/src/textual/widget.py b/src/textual/widget.py index c6be083afd..2358a645c2 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1420,7 +1420,7 @@ def arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: Widget locations. """ viewport = self.screen.size - independent = not self._arrangement_depends_on_available_height() + independent = not self._arrangement_depends_on_available_height(optimal=optimal) cache_key: tuple[object, ...] = ( size.with_height(0) if independent else size, viewport, self._nodes._updates, self._layout_updates, optimal, @@ -1882,7 +1882,7 @@ def _post_register(self, app: App) -> None: if app.debug: app.call_next(self.preflight_checks) - def _arrangement_depends_on_available_height(self) -> bool: + def _arrangement_depends_on_available_height(self, *, optimal: bool = False) -> bool: """Own the same complete dependency for arrangement and measurement. A native auto-size query may ask before arranging. Both questions read @@ -1893,15 +1893,17 @@ def _arrangement_depends_on_available_height(self) -> bool: proof = self.__dict__.get("_height_arrangement_cache") if proof is None or proof[0] != epoch: self._arrangement_cache.clear() - dependent = ( + proof = epoch, {} + self._height_arrangement_cache = proof + answers = proof[1] + if optimal not in answers: + answers[optimal] = ( _arrangement_wrapper_uses_height(self) - or self.layout._arrangement_height_dependency.depends(self) + or self.layout._arrangement_height_dependency.depends(self, greedy=not optimal) ) - proof = epoch, dependent - self._height_arrangement_cache = proof - return proof[1] + return answers[optimal] - def _height_dependency_answers(self) -> dict[str, bool]: + def _height_dependency_answers(self) -> dict[str | tuple[str, bool], bool]: """Computed height answers from the current native measurement source. Structure includes descendant membership/display publication; styles @@ -1916,12 +1918,13 @@ def _height_dependency_answers(self) -> dict[str, bool]: self._height_dependency_cache = cached return cached[1] - def _box_depends_on_available_height(self) -> bool: + def _box_depends_on_available_height(self, *, greedy: bool = True) -> bool: answers = self._height_dependency_answers() - if "box" in answers: - return answers["box"] - dependent = box_depends_on_available_height(self) - answers["box"] = dependent + operation = ("box", greedy) + if operation in answers: + return answers[operation] + dependent = box_depends_on_available_height(self, greedy=greedy) + answers[operation] = dependent return dependent def _get_box_model( @@ -1973,7 +1976,7 @@ def _get_box_model( revision = (id(styles), styles, styles._cache_key) cache_container = container cache_width_fraction, cache_height_fraction = width_fraction, height_fraction - independent_height = not self._box_depends_on_available_height() + independent_height = not self._box_depends_on_available_height(greedy=greedy) 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, diff --git a/tests/test_height_independent_measurement.py b/tests/test_height_independent_measurement.py index 835ca2291b..45793fc753 100644 --- a/tests/test_height_independent_measurement.py +++ b/tests/test_height_independent_measurement.py @@ -15,6 +15,47 @@ from textual.widgets import Static +async def test_fractional_placement_and_optimal_measurement_keep_distinct_dependencies(): + class HeightReadingWidth(Widget): + def get_content_width(self, container, viewport): + return container.height + + async with App().run_test() as pilot: + child = HeightReadingWidth() + child.styles.width = "1fr" + child.styles.height = 3 + column = VerticalGroup(child) + await pilot.app.mount(column) + await pilot.pause() + assert not child._box_depends_on_available_height(greedy=True) + assert child._box_depends_on_available_height(greedy=False) + assert not column._arrangement_depends_on_available_height(optimal=False) + assert column._arrangement_depends_on_available_height(optimal=True) + + normal = column.arrange(Size(30, 10)) + assert column.arrange(Size(30, 20)) is normal + optimal = column.arrange(Size(30, 10), optimal=True) + taller = column.arrange(Size(30, 20), optimal=True) + assert optimal.placements[0][1].region.width == 10 + assert taller.placements[0][1].region.width == 20 + assert taller is not optimal + assert column.arrange(Size(30, 20)) is normal + wider = column.arrange(Size(50, 20)) + assert wider is not normal + assert wider.placements[0][1].region.width == 50 + + # The same original source publication retires both mode answers. + child.styles.width = "auto" + assert column._arrangement_depends_on_available_height() + changed = column.arrange(Size(30, 20)) + assert changed is not normal + assert changed.placements[0][1].region.width == 20 + await child.remove() + empty = column.arrange(Size(30, 20)) + assert not empty.placements + assert not column._arrangement_depends_on_available_height(optimal=True) + + async def test_fixed_box_reuses_original_measurement_across_parent_widths(): async with App().run_test(): widget = Widget()