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
53 changes: 53 additions & 0 deletions evidence/arrangement-input-mode-20261007/README.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 27 additions & 18 deletions src/textual/_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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


Expand All @@ -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

Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"):
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
31 changes: 17 additions & 14 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
41 changes: 41 additions & 0 deletions tests/test_height_independent_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading