Skip to content
20 changes: 6 additions & 14 deletions src/textual/_arrange.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ def _build_layers(widgets: Iterable[Widget]) -> Mapping[str, Sequence[Widget]]:
return layers


_get_dock = attrgetter("styles.is_docked")
_get_split = attrgetter("styles.is_split")
_get_dock = attrgetter("geometry.is_docked")
_get_split = attrgetter("geometry.is_split")
_get_display = attrgetter("display")


Expand All @@ -54,7 +54,7 @@ def arrange(
"""
placements: list[WidgetPlacement] = []
scroll_spacing = NULL_SPACING
styles = widget.styles
styles = widget.geometry

# Widgets which will be displayed
display_widgets = list(filter(_get_display, children))
Expand Down Expand Up @@ -149,7 +149,7 @@ def _arrange_dock_widgets(
append_placement = placements.append

for dock_widget in dock_widgets:
edge = dock_widget.styles.dock
edge = dock_widget.geometry.dock

box_model = dock_widget._get_box_model(
size, viewport, size.width, size.height, greedy=greedy
Expand All @@ -175,15 +175,7 @@ def _arrange_dock_widgets(
raise AssertionError("invalid value for dock edge") # pragma: no-cover

dock_region = dock_region.shrink(margin)
styles = dock_widget.styles
offset = (
styles.offset.resolve(
size,
viewport,
)
if styles.has_rule("offset")
else NULL_OFFSET
)
offset = dock_widget.geometry.resolve_offset(size, viewport)
append_placement(
_WidgetPlacement(
dock_region.translate(region_offset),
Expand Down Expand Up @@ -223,7 +215,7 @@ def _arrange_split_widgets(
null_offset = NULL_OFFSET

for split_widget in split_widgets:
split = split_widget.styles.split
split = split_widget.geometry.split
box_model = split_widget._get_box_model(
size, viewport, size.width, size.height
)
Expand Down
9 changes: 5 additions & 4 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
from textual._activation import Activation
from textual._loop import loop_last
from textual._spatial_map import SpatialMap
from textual.box_model import DEFAULT_LAYER
from textual.geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing
from textual.map_geometry import MapGeometry
from textual.layout import WidgetPlacement
Expand Down Expand Up @@ -1288,7 +1289,7 @@ def store_geometry(node: Widget, geometry: MapGeometry, clip: SceneClip) -> None
None if root_layers is None
else {name: index for index, name in enumerate(root_layers)}
)
default_layers = {"default": 0}
default_layers = {DEFAULT_LAYER: 0}
retained_paths: set[Widget] = set()
if visible_only:
# Requested targets need their outer placement even when offscreen.
Expand Down Expand Up @@ -1347,7 +1348,7 @@ def arrange_widget(
invisible_widgets.add(widget)

# Container region is minus border
container_region = region.shrink(styles.gutter)
container_region = region.shrink(widget.geometry.box_gutter)
container_size = container_region.size

# Widgets with scrollbars (containers or scroll view) require additional processing
Expand Down Expand Up @@ -1416,7 +1417,7 @@ def arrange_widget(
if widget._cover_widget is not None:
store_geometry(widget._cover_widget, _MapGeometry.from_widget(
widget._cover_widget,
region.shrink(widget.styles.gutter),
region.shrink(widget.geometry.box_gutter),
order,
clip.region,
region.size,
Expand Down Expand Up @@ -1461,7 +1462,7 @@ def arrange_widget(
if (
widget.show_vertical_scrollbar
or widget.show_horizontal_scrollbar
) and styles.scrollbar_visibility == "visible":
) and widget.geometry.scrollbar_visibility == "visible":
for chrome_widget, chrome_region in widget._arrange_scrollbars(
container_region
):
Expand Down
57 changes: 1 addition & 56 deletions src/textual/_extrema.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
from __future__ import annotations

from fractions import Fraction
from typing import TYPE_CHECKING, NamedTuple
from typing import NamedTuple

from textual.geometry import Size

if TYPE_CHECKING:
from textual.css.styles import StylesBase


class Extrema(NamedTuple):
"""Specifies minimum and maximum dimensions."""

Expand Down Expand Up @@ -66,56 +62,5 @@ def apply_dimensions(self, width: int, height: int) -> Size:
int(self.apply_height(height)),
)

@classmethod
def resolve(
cls, styles: StylesBase, container: Size, viewport: Size,
width_fraction: Fraction, height_fraction: Fraction,
) -> Extrema:
"""Resolve native min/max rules independently of scene custody."""
min_width_rule = styles.min_width
max_width_rule = styles.max_width
min_height_rule = styles.min_height
max_height_rule = styles.max_height
if (min_width_rule is None and max_width_rule is None
and min_height_rule is None and max_height_rule is None):
return NO_EXTREMA

min_width: Fraction | int | None = None
max_width: Fraction | int | None = None
min_height: Fraction | int | None = None
max_height: Fraction | int | None = None

container -= styles.margin.totals
if styles.box_sizing == "border-box":
gutter_width, gutter_height = styles.gutter.totals
else:
gutter_width = gutter_height = 0

if min_width_rule is not None:
min_width = (
min_width_rule.resolve(container, viewport, width_fraction)
- gutter_width
)

if max_width_rule is not None:
max_width = (
max_width_rule.resolve(container, viewport, width_fraction)
- gutter_width
)
if min_height_rule is not None:
min_height = (
min_height_rule.resolve(container, viewport, height_fraction)
- gutter_height
)

if max_height_rule is not None:
max_height = (
max_height_rule.resolve(container, viewport, height_fraction)
- gutter_height
)

return cls(min_width, max_width, min_height, max_height)


NO_EXTREMA = Extrema()
"""Extrema of a node with no min or max rules."""
18 changes: 9 additions & 9 deletions src/textual/_measurement.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,12 +160,12 @@ def styles_sensitive(self, widget: Widget) -> bool:
)

def depends(self, widget: Widget, *, greedy: bool = True) -> bool:
styles = widget.styles
styles = widget.geometry
if (not widget._native_measurement_layout_hooks
or styles.align_horizontal != "left" or styles.align_vertical != "top"):
return True
for child in widget.displayed_children:
child_styles = child.styles
child_styles = child.geometry
if child_styles.is_docked or child_styles.is_split or child_styles.overlay == "screen":
return True
# Match native auto-parent stretch semantics, which treat all
Expand Down Expand Up @@ -204,7 +204,7 @@ class GridHeight(FlowHeight):
box_depends = HeightDependency.box_depends

def depends(self, widget: Widget, *, greedy: bool = True) -> bool:
styles = widget.styles
styles = widget.geometry
rows, columns = styles.grid_rows or (), styles.grid_columns or ()
# Default rows become fractional at nonzero height for non-auto parents.
if (not rows and not styles.is_auto_height
Expand All @@ -214,7 +214,7 @@ def depends(self, widget: Widget, *, greedy: bool = True) -> bool:
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
child_styles = child.geometry
# 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.
Expand Down Expand Up @@ -273,7 +273,7 @@ def decorate(function: Function) -> Function:

def _local_box_inputs(widget: Widget) -> tuple[bool, bool, bool, bool, bool]:
"""Resolve local scalar dependencies once per owning style generation."""
styles = widget.styles
styles = widget.geometry
if type(styles) is not RenderStyles:
return True, False, False, False, False
# Retain the actual source so replacement cannot reuse an unrelated epoch.
Expand Down Expand Up @@ -343,7 +343,7 @@ def box_depends_on_available_height(widget: Widget, *, greedy: bool) -> bool:
return True
# 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)
if (content_width and (not greedy or widget.geometry.width.is_auto)
and widget._content_width_dependency.depends(widget)):
return True
if content_height:
Expand All @@ -352,9 +352,9 @@ def box_depends_on_available_height(widget: Widget, *, greedy: bool) -> bool:


def _arrangement_wrapper_uses_height(widget: Widget) -> bool:
styles = widget.styles
styles = widget.geometry
if (not widget._native_measurement_layout_hooks
or styles.align_horizontal != "left" or styles.align_vertical != "top"):
return True
return any(child.styles.is_docked or child.styles.is_split
or child.styles.overlay == "screen" for child in widget.displayed_children)
return any(child.geometry.is_docked or child.geometry.is_split
or child.geometry.overlay == "screen" for child in widget.displayed_children)
86 changes: 42 additions & 44 deletions src/textual/_paint_state.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
"""Immutable inherited paint values, resolved once per style/topology revision."""

"""Inherited paint and the declaration inputs that produce it."""
from __future__ import annotations

from typing import NamedTuple

from rich.style import NULL_STYLE, Style
from textual.color import Color, TRANSPARENT


class PaintLayer:
"""One local colour layer, independent of how its inputs are declared."""

from textual.color import Color
paint_foreground: Color | None
paint_background: Color | None
background_tint: Color
text_style: Style
text_opacity: float
layer_opacity: float
auto_color: bool
paint_key: int | None
paint_cacheable: bool

@property
def background_color(self) -> Color:
color = self.paint_background
return TRANSPARENT if color is None else color


class PaintState(NamedTuple):
parent: PaintState | None
style_key: int
style_key: int | None
background: Color
foreground: Color
text_style: Style
Expand All @@ -21,50 +37,32 @@ class PaintState(NamedTuple):
cacheable: bool = True

def same_paint(self, other: PaintState) -> bool:
"""Compare pixels, independently of the resolver's dependency lineage.
return self[2:8] == other[2:8]

Ancestor visibility or layout edits require fresh dependency resolution,
but retained paint remains valid when its resulting values are unchanged.
"""
return (
self.background,
self.foreground,
self.text_style,
self.opacity,
self.base_background,
self.layered_background,
) == (
other.background,
other.foreground,
other.text_style,
other.opacity,
other.base_background,
other.layered_background,
@classmethod
def resolve(cls, parent: PaintState, layer: PaintLayer) -> PaintState:
"""Apply one declaration layer, preserving native alpha order."""
opacity = parent.opacity * layer.layer_opacity
local_background = layer.paint_background
tinted = layer.background_color.tint(layer.background_tint)
background = text_background = parent.background
if local_background is not None:
text_background = background + tinted
background += tinted.multiply_alpha(opacity)
local_foreground = layer.paint_foreground
foreground = parent.foreground if local_foreground is None else local_foreground
if layer.auto_color:
foreground = text_background.get_contrast_text(foreground.a)
return cls(
parent, layer.paint_key, background, foreground,
parent.text_style + layer.text_style, opacity,
parent.layered_background,
parent.layered_background + tinted.multiply_alpha(opacity),
parent.cacheable and layer.paint_cacheable,
)


EMPTY_PAINT = PaintState(
None, -1, Color(0, 0, 0, 0), Color(255, 255, 255, 0),
NULL_STYLE, 1.0, Color(0, 0, 0, 0), Color(0, 0, 0, 0),
)


def resolve_paint(parent: PaintState, styles) -> PaintState:
"""Apply one declaration layer, preserving Textual's native alpha order."""
opacity = parent.opacity * styles.opacity
tinted = styles.background.tint(styles.background_tint)
background = parent.background
text_background = background
if styles.has_rule("background"):
text_background = background + tinted
background += tinted.multiply_alpha(opacity)
foreground = styles.color if styles.has_rule("color") else parent.foreground
if styles.has_rule("auto_color") and styles.auto_color:
foreground = text_background.get_contrast_text(foreground.a)
return PaintState(
parent, styles._cache_key, background, foreground,
parent.text_style + styles.text_style, opacity,
parent.layered_background,
parent.layered_background + tinted.multiply_alpha(opacity),
parent.cacheable,
)
12 changes: 6 additions & 6 deletions src/textual/_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from textual.box_model import BoxModel
from textual.css.scalar import Scalar
from textual.css.styles import RenderStyles
from textual.box_model import BoxGeometry
from textual.geometry import Size

if TYPE_CHECKING:
Expand Down Expand Up @@ -119,7 +119,7 @@ def resolve(


def resolve_fraction_unit(
widget_styles: Iterable[RenderStyles],
widget_styles: Iterable[BoxGeometry],
size: Size,
viewport_size: Size,
remaining_space: Fraction,
Expand Down Expand Up @@ -243,7 +243,7 @@ def resolve_box_models(
fraction_height = size.height
margin_size = size - margin

margins = [widget.styles.margin.totals for widget in widgets]
margins = [widget.geometry.margin.totals for widget in widgets]

# Fixed box models
box_models: list[BoxModel | None] = [
Expand All @@ -268,14 +268,14 @@ def resolve_box_models(
return cast("list[BoxModel]", box_models)

# If all box models have been calculated
widget_styles = [widget.styles for widget in widgets]
widget_styles = [widget.geometry for widget in widgets]
if resolve_dimension == "width":
total_remaining = int(
sum(
[
box_model.width
for widget, box_model in zip(widgets, box_models)
if (box_model is not None and widget.styles.overlay != "screen")
if (box_model is not None and widget.geometry.overlay != "screen")
]
)
)
Expand All @@ -302,7 +302,7 @@ def resolve_box_models(
[
box_model.height
for widget, box_model in zip(widgets, box_models)
if (box_model is not None and widget.styles.overlay != "screen")
if (box_model is not None and widget.geometry.overlay != "screen")
]
)
)
Expand Down
Loading