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
25 changes: 24 additions & 1 deletion src/textual/_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand All @@ -79,13 +92,17 @@ 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:
return True
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:
Expand Down Expand Up @@ -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()
Expand All @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/textual/scroll_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
11 changes: 8 additions & 3 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
Expand Down
1 change: 1 addition & 0 deletions src/textual/widgets/_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
52 changes: 52 additions & 0 deletions tests/test_height_independent_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading