From 52333b71a1674aba9e8a2730bcb5a043848eddc0 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Wed, 7 Oct 2026 19:27:12 -0400 Subject: [PATCH] Declare container selection separately from native box resolution --- src/textual/_measurement.py | 25 +++++++++- src/textual/scroll_view.py | 3 +- src/textual/widget.py | 11 +++-- src/textual/widgets/_static.py | 1 + tests/test_height_independent_measurement.py | 52 ++++++++++++++++++++ 5 files changed, 87 insertions(+), 5 deletions(-) diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 4c3fde84f5..8d760e0d1f 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -44,6 +44,17 @@ def depends(self, widget: Widget, *, greedy: bool = True) -> bool: return False +class NativeContainerSelection(IndependentHeight): + """Native selection reads child membership / layout, not paint rules. + + User-declared independent getters retain IndependentHeight's conservative + style contract; height independence alone says nothing about paint inputs. + """ + + def styles_sensitive(self, widget: Widget) -> bool: + return False + + class StoredVirtualSize(HeightDependency): """The original line surface measures its authored reactive extent. @@ -69,6 +80,8 @@ def layout_dependency(self, widget: Widget) -> HeightDependency: """The native layout owns the selected measurement's dependency.""" def depends(self, widget: Widget, *, greedy: bool = True) -> bool: + if widget._container_selection_dependency.depends(widget): + return True if not widget.is_container: # Native leaf visuals receive rules and width, not container height. return False @@ -79,6 +92,8 @@ def depends(self, widget: Widget, *, greedy: bool = True) -> bool: return self.layout_dependency(widget).depends(widget) def box_depends(self, widget: Widget, *, greedy: bool = True) -> bool: + if widget._container_selection_dependency.depends(widget): + return True if not widget.is_container: return False if not widget._native_measurement_layout_hooks: @@ -86,6 +101,8 @@ def box_depends(self, widget: Widget, *, greedy: bool = True) -> bool: return self.layout_dependency(widget).box_depends(widget) def styles_sensitive(self, widget: Widget) -> bool: + if widget._container_selection_dependency.styles_sensitive(widget): + return True if not widget.is_container: return widget._render_styles_sensitive() if not widget._native_measurement_layout_hooks: @@ -213,6 +230,7 @@ def depends(self, widget: Widget, *, greedy: bool = True) -> bool: CONTEXT_HEIGHT = ContextHeight() INDEPENDENT_HEIGHT = IndependentHeight() +NATIVE_CONTAINER_SELECTION = NativeContainerSelection() STORED_VIRTUAL_SIZE = StoredVirtualSize() NATIVE_WIDGET_HEIGHT = NativeWidgetHeight() NATIVE_WIDGET_WIDTH = NativeWidgetWidth() @@ -230,6 +248,10 @@ def height_dependency(policy: HeightDependency) -> Callable[[Function], Function This does not cache a method's result or retain a widget. A declaration must describe the whole implementation, including additional work around super(). + A declared is_container getter must also publish changes to its selection + inputs through the original layout invalidation (refresh(layout=True), + layout reactives, or native child/style publication). Independence from + incoming height does not make independently changing selection immutable. """ def decorate(function: Function) -> Function: function._height_dependency = policy # type: ignore[attr-defined] @@ -301,7 +323,8 @@ def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool, 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: + if (not widget._native_box_measurement + or widget._container_selection_dependency.depends(widget)): return True depends, content_width, content_height, _, _ = _local_box_inputs(widget) if depends: diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 39f8f61952..94f352f68d 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -7,7 +7,7 @@ from rich.console import RenderableType from textual._animator import EasingFunction -from textual._measurement import STORED_VIRTUAL_SIZE, height_dependency +from textual._measurement import NATIVE_CONTAINER_SELECTION, STORED_VIRTUAL_SIZE, height_dependency from textual._types import AnimationLevel, CallbackType from textual.containers import ScrollableContainer from textual.geometry import Region, Size @@ -39,6 +39,7 @@ def is_scrollable(self) -> bool: return True @property + @height_dependency(NATIVE_CONTAINER_SELECTION) def is_container(self) -> bool: """Since a ScrollView should be a line-api widget, it won't have children, and therefore isn't a container.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 2358a645c2..18a0ff0879 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -56,7 +56,8 @@ from textual._easing import DEFAULT_SCROLL_EASING from textual._extrema import Extrema from textual._measurement import ( - CONTEXT_HEIGHT, INDEPENDENT_HEIGHT, NATIVE_WIDGET_HEIGHT, NATIVE_WIDGET_WIDTH, + CONTEXT_HEIGHT, INDEPENDENT_HEIGHT, NATIVE_CONTAINER_SELECTION, + NATIVE_WIDGET_HEIGHT, NATIVE_WIDGET_WIDTH, HeightDependency, _arrangement_wrapper_uses_height, _local_box_inputs, box_depends_on_available_height, height_dependency, ) @@ -313,6 +314,7 @@ class Widget(DOMNode): _content_height_dependency: ClassVar[HeightDependency] = NATIVE_WIDGET_HEIGHT _content_width_dependency: ClassVar[HeightDependency] = NATIVE_WIDGET_WIDTH + _container_selection_dependency: ClassVar[HeightDependency] = NATIVE_CONTAINER_SELECTION _native_box_measurement: ClassVar[bool] = True _native_measurement_layout_hooks: ClassVar[bool] = True @@ -2693,7 +2695,7 @@ def _relative_children_height(self) -> tuple[bool, bool]: guess this separate box-stretch answer. """ native = ( - type(self).is_container is Widget.is_container + not self._container_selection_dependency.depends(self) and type(self).children is DOMNode.children and type(self.styles) is RenderStyles ) @@ -2865,6 +2867,7 @@ def layout(self) -> Layout: return self.styles.layout or self._default_layout @property + @height_dependency(NATIVE_CONTAINER_SELECTION) def is_container(self) -> bool: """Is this widget a container (contains other widgets)?""" return bool(self._nodes) or self.styles.layout is not None @@ -4193,12 +4196,14 @@ def __init_subclass__( # on every measurement. Undeclared overrides select the safe full context. cls._content_height_dependency = getattr(cls.get_content_height, "_height_dependency", CONTEXT_HEIGHT) cls._content_width_dependency = getattr(cls.get_content_width, "_height_dependency", CONTEXT_HEIGHT) + cls._container_selection_dependency = getattr( + getattr(cls.is_container, "fget", None), "_height_dependency", CONTEXT_HEIGHT + ) cls._native_box_measurement = ( cls._get_box_model is Widget._get_box_model and cls._resolve_extrema is Widget._resolve_extrema and cls._has_relative_children_height is Widget._has_relative_children_height and cls._has_relative_children_width is Widget._has_relative_children_width - and cls.is_container is Widget.is_container and cls.layout is Widget.layout ) cls._native_measurement_layout_hooks = ( diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index f2b0a2b644..e33db92cde 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -103,6 +103,7 @@ def _has_native_content_measurement(self) -> bool: and type(self).get_content_height is Widget.get_content_height and type(self.__visual) is Content and self._native_box_measurement + and not self._container_selection_dependency.styles_sensitive(self) and self._native_measurement_layout_hooks and not self.is_container ) diff --git a/tests/test_height_independent_measurement.py b/tests/test_height_independent_measurement.py index 45793fc753..c4f3af268c 100644 --- a/tests/test_height_independent_measurement.py +++ b/tests/test_height_independent_measurement.py @@ -15,6 +15,58 @@ from textual.widgets import Static +async def test_declared_container_selection_publishes_original_measurement_lifetime(): + from textual._measurement import INDEPENDENT_HEIGHT, NATIVE_WIDGET_HEIGHT, height_dependency + from textual.reactive import reactive + + class SelectedContent(Static): + show_children = reactive(True, layout=True) + + @property + @height_dependency(INDEPENDENT_HEIGHT) + def is_container(self): + return self.show_children and super().is_container + + class UndeclaredSelection(SelectedContent): + @property + def is_container(self): + return super().is_container + + async with App().run_test() as pilot: + selected = SelectedContent("leaf") + selected.styles.width = 20 + selected.styles.height = "auto" + child = Static("child") + child.styles.height = 7 + await pilot.app.mount(selected) + await selected.mount(child) + await pilot.pause() + assert selected._native_box_measurement + first = box(selected, 10) + assert first.height == 7 + assert box(selected, 30) is first + selected.show_children = False + leaf = box(selected, 10) + assert leaf.height == 1 + assert leaf is not first + assert box(selected, 30) is leaf + selected.show_children = True + child.styles.height = "1fr" + await pilot.pause() + assert selected._box_depends_on_available_height() + assert selected._has_relative_children_height + selected.show_children = False + await pilot.pause() + assert not selected._has_relative_children_height + assert not selected._box_depends_on_available_height() + + unknown = UndeclaredSelection("unknown") + assert unknown._native_box_measurement + assert unknown._box_depends_on_available_height() + assert NATIVE_WIDGET_HEIGHT.depends(unknown) + assert NATIVE_WIDGET_HEIGHT.styles_sensitive(selected) + + async def test_fractional_placement_and_optimal_measurement_keep_distinct_dependencies(): class HeightReadingWidth(Widget): def get_content_width(self, container, viewport):