diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index a43510d7d5..1cd5371d1e 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -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") @@ -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)) @@ -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 @@ -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), @@ -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 ) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d0dfdb2b03..e9ca2b6a6e 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -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 @@ -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. @@ -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 @@ -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, @@ -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 ): diff --git a/src/textual/_extrema.py b/src/textual/_extrema.py index 2bb9c3ec95..6a28d10747 100644 --- a/src/textual/_extrema.py +++ b/src/textual/_extrema.py @@ -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.""" @@ -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.""" diff --git a/src/textual/_measurement.py b/src/textual/_measurement.py index 6667d23424..ee83d8c60f 100644 --- a/src/textual/_measurement.py +++ b/src/textual/_measurement.py @@ -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 @@ -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 @@ -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. @@ -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. @@ -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: @@ -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) diff --git a/src/textual/_paint_state.py b/src/textual/_paint_state.py index d87c7fc16a..f8baf2453a 100644 --- a/src/textual/_paint_state.py +++ b/src/textual/_paint_state.py @@ -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 @@ -21,25 +37,28 @@ 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, ) @@ -47,24 +66,3 @@ def same_paint(self, other: PaintState) -> bool: 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, - ) diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 4e53fdb8a3..d7b6e88127 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -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: @@ -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, @@ -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] = [ @@ -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") ] ) ) @@ -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") ] ) ) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index c00485c071..070320d7d0 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -33,17 +33,39 @@ RenderLineCallback: TypeAlias = Callable[[int], Strip] -@rich.repr.auto(angular=True) +class FramePaint: + """Inputs consumed by the existing line painter, not a second style store.""" + + paint_gutter: Spacing + border: tuple + outline: tuple + hatch: tuple | None + tint: Color + text_opacity: float + border_title_align: str + border_subtitle_align: str + + @property + def plain(self) -> bool: + return (not any(self.paint_gutter) and not any(edge for edge, _ in self.outline) + and self.hatch is None and not self.tint.a and self.text_opacity == 1.0) + + @property + def frame_key(self) -> tuple: + return (self.paint_gutter, self.border, self.outline, self.hatch, self.tint, + self.text_opacity, self.border_title_align, self.border_subtitle_align) + +@rich.repr.auto(angular=True) @dataclass(frozen=True, slots=True) -class PaintStyles: +class PaintStyles(FramePaint): """One render's reads of a widget's styles, shared by all of its lines. Every line of a render paints with the same styles; reading the CSS properties once per render rather than once per line. """ - gutter: Spacing + paint_gutter: Spacing border: tuple outline: tuple hatch: tuple | None @@ -51,18 +73,26 @@ class PaintStyles: text_opacity: float border_title_align: str border_subtitle_align: str - plain: bool - """No border, outline, padding, hatch, tint or text opacity: a line is its content line.""" @classmethod def of(cls, styles: StylesBase) -> PaintStyles: hatch = styles.hatch if styles.has_rule("hatch") and styles.hatch != "none" else None gutter, border, outline = styles.gutter, styles.border, styles.outline tint, text_opacity = styles.tint, styles.text_opacity - plain = (not any(gutter) and not any(edge for edge, _ in outline) and hatch is None - and not tint.a and text_opacity == 1.0) return cls(gutter, border, outline, hatch, tint, text_opacity, - styles.border_title_align, styles.border_subtitle_align, plain) + styles.border_title_align, styles.border_subtitle_align) + +def border_label( + label: Content | None, background: Color, auto_color: bool, color: Color, + label_background: Color, style: RichStyle, +) -> tuple[Content, Color, Color, Style] | None: + """A border title or subtitle with its paint, contrasting when its color is auto.""" + if label is None: + return None + if auto_color: + color = background.get_contrast_text(color.a) + return label, color, label_background, Style.from_rich_style(style) + class StylesCache: """Responsible for rendering CSS Styles and keeping a cache of rendered lines. @@ -93,6 +123,7 @@ def __init__(self) -> None: self._cache: dict[int, Strip] = {} self._dirty_lines: set[int] = set() self._size = Size(0, 0) + self._paint_key: tuple | None = None self._simple_strip: Strip | None = None """A simple strip consisting of left border + background + right border, which may be reused in a render.""" @@ -127,6 +158,16 @@ def clear(self) -> None: self._dirty_lines.clear() self._simple_strip = None + @staticmethod + def widget_paint_key(widget: Widget, paint: FramePaint) -> tuple: + return (paint.frame_key, *widget.background_colors, + widget.rich_style, widget.geometry.padding) + + def refresh_paint(self, widget: Widget) -> None: + """Dirty only a widget whose current paint differs from its cached lines.""" + if self.widget_paint_key(widget, widget.frame_paint) != self._paint_key: + widget.refresh() + def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: """Render the content for a widget. @@ -137,38 +178,30 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: Returns: Rendered lines. """ - border_title = widget._border_title - border_subtitle = widget._border_subtitle self._simple_strip = None base_background, background = widget.background_colors - styles = widget.styles - app = widget.app + app, styles = widget.app, widget.styles + paint = widget.frame_paint + key = self.widget_paint_key(widget, paint) + if key != self._paint_key: + widget._release_paint() + self._paint_key = key strips = self.render( - styles, + paint, widget.region.size, base_background, background, widget.render_line, widget.get_line_filters(), - ( - None - if border_title is None - else ( - border_title, - *widget._get_title_style_information(base_background), - ) - ), - ( - None - if border_subtitle is None - else ( - border_subtitle, - *widget._get_subtitle_style_information(base_background), - ) - ), + border_label(widget._border_title, base_background, styles.auto_border_title_color, + styles.border_title_color, styles.border_title_background, + styles.border_title_style), + border_label(widget._border_subtitle, base_background, + styles.auto_border_subtitle_color, styles.border_subtitle_color, + styles.border_subtitle_background, styles.border_subtitle_style), content_size=widget.content_region.size, - padding=styles.padding, + padding=widget.geometry.padding, crop=crop, opacity=widget.opacity, ansi_theme=app.ansi_theme, @@ -193,7 +226,7 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: def render( self, - styles: StylesBase, + painted: FramePaint, size: Size, base_background: Color, background: Color, @@ -201,8 +234,9 @@ def render( filters: Sequence[LineFilter], border_title: tuple[Content, Color, Color, Style] | None, border_subtitle: tuple[Content, Color, Color, Style] | None, + *, + padding: Spacing, content_size: Size | None = None, - padding: Spacing | None = None, crop: Region | None = None, opacity: float = 1.0, ansi_theme: TerminalTheme = DEFAULT_TERMINAL_THEME, @@ -211,7 +245,7 @@ def render( """Render a widget content plus CSS styles. Args: - styles: CSS Styles object. + painted: The frame paint inputs. size: Size of widget. base_background: Background color beneath widget. background: Background color of widget. @@ -220,7 +254,7 @@ def render( border_title: Optional tuple of (title, color, background, style). border_subtitle: Optional tuple of (subtitle, color, background, style). content_size: Size of content or None to assume full size. - padding: Override padding from Styles, or None to use styles.padding. + padding: Padding declared by the widget's geometry. crop: Region to crop to. filters: Additional post-processing for the segments. opacity: Widget opacity. @@ -232,8 +266,6 @@ def render( """ if content_size is None: content_size = size - if padding is None: - padding = styles.padding if crop is None: crop = size.region @@ -246,7 +278,6 @@ def render( is_dirty = self._dirty_lines.__contains__ render_line = self.render_line - painted = PaintStyles.of(styles) # A plain widget's line is its content line at the widget's width. # Returning that strip itself (rather than a copy of its segments) # keeps what it has already computed, such as its terminal sequence. @@ -313,7 +344,7 @@ def get_inner_outer( def render_line( self, - styles: PaintStyles, + styles: FramePaint, y: int, size: Size, content_size: Size, @@ -349,7 +380,7 @@ def render_line( A line of segments. """ - gutter = styles.gutter + gutter = styles.paint_gutter width, height = size content_width, content_height = content_size diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 4a36b78813..c50236e00b 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -1,144 +1,246 @@ from __future__ import annotations from fractions import Fraction -from typing import TYPE_CHECKING, NamedTuple +from typing import TYPE_CHECKING, NamedTuple, get_args -from textual.geometry import Size, Spacing -from textual.css.scalar import Scalar +from textual._extrema import Extrema, NO_EXTREMA +from textual.css.scalar import Scalar, ScalarOffset, Unit +from textual.css.types import AlignHorizontal, AlignVertical +from textual.geometry import NULL_OFFSET, NULL_SPACING, Offset, Size, Spacing if TYPE_CHECKING: + from textual.color import Color + from textual.css._style_properties import Edges + from textual.layout import Layout from textual.widget import Widget - from textual._extrema import Extrema + + +DEFAULT_LAYER = "default" +"""The layer a widget paints in when it declares none.""" class BoxModel(NamedTuple): """The result of `get_box_model`.""" - # Content + padding + border. Integral sizes are ints; a Fraction comes - # only from a scalar or fraction unit that resolved to one. width: Fraction | int height: Fraction | int - margin: Spacing # Additional margin - - @classmethod - def resolve( - cls, - node: Widget, - container: Size, - viewport: Size, - width_fraction: Fraction, - height_fraction: Fraction, - constrain_width: bool = False, - greedy: bool = True, + margin: Spacing + + +class BoxGeometry: + """Geometry inputs and their shared measurement/placement algorithms. + + CSS styles supply these inputs through their descriptors. A widget may + instead inherit this capability and declare its inputs directly. Neither + path translates into, or keeps a copy of, the other path's declarations. + """ + + width: Scalar | None + height: Scalar | None + min_width: Scalar | None + min_height: Scalar | None + max_width: Scalar | None + max_height: Scalar | None + padding: Spacing + margin: Spacing + border: Edges + box_sizing: str + width_policy: str + dock: str + split: str + overflow_x: str + overflow_y: str + scrollbar_policy: str + vertical_scrollbar_size: int + horizontal_scrollbar_size: int + scrollbar_visibility: str + align_horizontal: str + align_vertical: str + grid_rows: tuple[Scalar, ...] + grid_columns: tuple[Scalar, ...] + grid_size_columns: int + grid_size_rows: int + grid_gutter_horizontal: int + grid_gutter_vertical: int + row_span: int + column_span: int + keyline: tuple[str, Color] + position: str + overlay: str + constrain_x: str + constrain_y: str + + @property + def box_gutter(self) -> Spacing: + return self.padding + self.border.spacing + + @property + def child_layout(self) -> Layout | None: + """An explicitly supplied child layout, if any.""" + raise NotImplementedError + + @property + def placement_offset(self) -> ScalarOffset | None: + raise NotImplementedError + + def set_placement_offset(self, offset: ScalarOffset) -> None: + raise NotImplementedError + + @property + def layer_name(self) -> str: + raise NotImplementedError + + @property + def layer_order(self) -> tuple[str, ...] | None: + raise NotImplementedError + + @property + def flow_geometry(self) -> BoxGeometry: + """Inputs accepted by the stream layout's existing flow contract.""" + return self + + @property + def auto_dimensions(self) -> bool: + return self.is_auto_width or self.is_auto_height + + @property + def is_relative_width(self) -> bool: + width = self.width + return width is not None and width.unit in (Unit.FRACTION, Unit.PERCENT) + + @property + def is_relative_height(self) -> bool: + height = self.height + return height is not None and height.unit in (Unit.FRACTION, Unit.PERCENT) + + @property + def is_auto_width(self) -> bool: + width = self.width + return width is not None and width.is_auto + + @property + def is_auto_height(self) -> bool: + height = self.height + return height is not None and height.is_auto + + @property + def is_dynamic_height(self) -> bool: + height = self.height + return height is not None and height.unit in (Unit.AUTO, Unit.FRACTION, Unit.PERCENT) + + @property + def is_docked(self) -> bool: + return self.dock != "none" + + @property + def is_split(self) -> bool: + return self.split != "none" + + @property + def sizing_gutter(self) -> Spacing: + """The gutter that a declared size includes.""" + return self.box_gutter if self.box_sizing == "border-box" else NULL_SPACING + + @property + def stable_gutter(self) -> bool: + """Whether the scrollbar gutter is reserved while the scrollbar is hidden.""" + return self.scrollbar_policy == "stable" + + def _reserves_gutter(self, overflow: str) -> bool: + return overflow == "auto" and self.stable_gutter + + def _align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset: + # Each alignment Literal declares start, middle, end: its index halves the free space. + x = get_args(AlignHorizontal).index(self.align_horizontal) + y = get_args(AlignVertical).index(self.align_vertical) + return Offset((parent[0] - child[0]) * x // 2, (parent[1] - child[1]) * y // 2) + + def resolve_offset(self, size: Size, viewport: Size) -> Offset: + offset = self.placement_offset + return NULL_OFFSET if offset is None else offset.resolve(size, viewport) + + def resolve_extrema( + self, container: Size, viewport: Size, + width_fraction: Fraction, height_fraction: Fraction, + ) -> Extrema: + """Resolve constraints independently of the scene's measurement cache.""" + min_width_rule, max_width_rule = self.min_width, self.max_width + min_height_rule, max_height_rule = self.min_height, self.max_height + if all(rule is None for rule in (min_width_rule, max_width_rule, + min_height_rule, max_height_rule)): + return NO_EXTREMA + container -= self.margin.totals + gutter_width, gutter_height = self.sizing_gutter.totals + return Extrema( + None if min_width_rule is None else min_width_rule.resolve( + container, viewport, width_fraction) - gutter_width, + None if max_width_rule is None else max_width_rule.resolve( + container, viewport, width_fraction) - gutter_width, + None if min_height_rule is None else min_height_rule.resolve( + container, viewport, height_fraction) - gutter_height, + None if max_height_rule is None else max_height_rule.resolve( + container, viewport, height_fraction) - gutter_height, + ) + + def resolve_box( + self, node: Widget, container: Size, viewport: Size, + width_fraction: Fraction, height_fraction: Fraction, + constrain_width: bool = False, greedy: bool = True, ) -> tuple[BoxModel, Extrema]: - """Resolve native CSS dimensions from intrinsic measurements. - - Scene custody and measurement reuse belong to the caller. - """ - styles = node.styles - is_border_box = styles.box_sizing == "border-box" - gutter = styles.gutter # Padding plus border - margin = styles.margin - - styles_width = styles.width - if not greedy and styles_width is not None and styles_width.is_fraction: - styles_width = Scalar.parse("auto") - is_auto_width = styles_width and styles_width.is_auto - styles_height = styles.height - is_auto_height = styles_height and styles_height.is_auto - - # Container minus padding and border + """Resolve these dimensions using the widget's intrinsic measurements.""" + gutter, margin, sizing_gutter = self.box_gutter, self.margin, self.sizing_gutter + width, height = self.width, self.height + if not greedy and width is not None and width.is_fraction: + width = Scalar(1.0, Unit.AUTO, Unit.AUTO) + is_auto_width = width is not None and width.is_auto + is_auto_height = height is not None and height.is_auto content_container = container - gutter.totals - - extrema = node._extrema = node._resolve_extrema( + extrema = node._extrema = self.resolve_extrema( container, viewport, width_fraction, height_fraction ) min_width, max_width, min_height, max_height = extrema - if styles_width is None: - # No width specified, fill available space + if width is None: content_width = content_container.width - margin.width elif is_auto_width: - # When width is auto, we want enough space to always fit the content - content_width = node.get_content_width( - content_container - margin.totals, viewport - ) - if ( - styles.overflow_x == "auto" and styles.scrollbar_gutter == "stable" - ) or node.show_vertical_scrollbar: - content_width += styles.scrollbar_size_vertical - if ( - content_width < content_container.width - and node._has_relative_children_width - ): + content_width = node.get_content_width(content_container - margin.totals, viewport) + if self._reserves_gutter(self.overflow_x) or node.show_vertical_scrollbar: + content_width += self.vertical_scrollbar_size + if content_width < content_container.width and node._has_relative_children_width: content_width = content_container.width else: - # An explicit width - content_width = styles_width.resolve( - container - margin.totals, viewport, width_fraction - ) - if is_border_box: - content_width -= gutter.width - + content_width = width.resolve(container - margin.totals, viewport, width_fraction) + content_width -= sizing_gutter.width if min_width is not None: - # Restrict to minimum width, if set content_width = max(content_width, min_width, 0) - if max_width is not None and not ( - container.width == 0 - and not styles.max_width.is_cells - and node._parent is not None - and node._parent.styles.is_auto_width + container.width == 0 and not self.max_width.is_cells + and node._parent is not None and node._parent.geometry.is_auto_width ): - # Restrict to maximum width, if set content_width = min(content_width, max_width) - content_width = max(0, content_width) - if constrain_width: content_width = min(container.width - gutter.width, content_width) - if styles_height is None: - # No height specified, fill the available space + if height is None: content_height = content_container.height - margin.height elif is_auto_height: - # Calculate dimensions based on content content_height = node.get_content_height( - content_container - margin.totals, - viewport, - int(content_width), + content_container - margin.totals, viewport, int(content_width) ) - if ( - styles.overflow_y == "auto" and styles.scrollbar_gutter == "stable" - ) or node.show_horizontal_scrollbar: - content_height += styles.scrollbar_size_horizontal - if ( - content_height < content_container.height - and node._has_relative_children_height - ): + if self._reserves_gutter(self.overflow_y) or node.show_horizontal_scrollbar: + content_height += self.horizontal_scrollbar_size + if content_height < content_container.height and node._has_relative_children_height: content_height = content_container.height else: - # Explicit height set - content_height = styles_height.resolve( - container - margin.totals, viewport, height_fraction - ) - if is_border_box: - content_height -= gutter.height - + content_height = height.resolve(container - margin.totals, viewport, height_fraction) + content_height -= sizing_gutter.height if min_height is not None: - # Restrict to minimum height, if set content_height = max(content_height, min_height, 0) - if max_height is not None and not ( - container.height == 0 - and not styles.max_height.is_cells - and node._parent is not None - and node._parent.styles.is_auto_height + container.height == 0 and not self.max_height.is_cells + and node._parent is not None and node._parent.geometry.is_auto_height ): content_height = min(content_height, max_height) - content_height = max(0, content_height) - model = cls( - content_width + gutter.width, content_height + gutter.height, margin - ) - return model, extrema + return BoxModel(content_width + gutter.width, content_height + gutter.height, margin), extrema diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 6b0cfca789..6db22e9ee0 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -15,6 +15,8 @@ from textual._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from textual._types import AnimationLevel, CallbackType from textual.color import Color +from textual.box_model import DEFAULT_LAYER, BoxGeometry +from textual._paint_state import PaintLayer from textual.css._style_properties import ( AlignProperty, BooleanProperty, @@ -82,7 +84,7 @@ TextWrap, Visibility, ) -from textual.geometry import Offset, Spacing +from textual.geometry import Spacing if TYPE_CHECKING: from textual.css.types import CSSLocation @@ -225,7 +227,7 @@ class RulesMap(TypedDict, total=False): _rule_getter = attrgetter(*RULE_NAMES) -class StylesBase: +class StylesBase(BoxGeometry, PaintLayer): """A common base class for Styles and RenderStyles""" ANIMATABLE = { @@ -611,63 +613,58 @@ def items(self) -> Iterable[tuple[str, object]]: yield (key, getattr(self, key)) @property - def gutter(self) -> Spacing: - """Get space around widget. + def paint_foreground(self) -> Color | None: + return self.color if self.has_rule("color") else None - Returns: - Space around widget content. - """ - return self.padding + self.border.spacing + @property + def paint_background(self) -> Color | None: + return self.background if self.has_rule("background") else None @property - def auto_dimensions(self) -> bool: - """Check if width or height are set to 'auto'.""" - has_rule = self.has_rule - return (has_rule("width") and self.width.is_auto) or ( # type: ignore - has_rule("height") and self.height.is_auto # type: ignore - ) + def layer_opacity(self) -> float: + return self.opacity + + paint_cacheable = True @property - def is_relative_width(self, _relative_units={Unit.FRACTION, Unit.PERCENT}) -> bool: - """Does the node have a relative width?""" - width = self.width - return width is not None and width.unit in _relative_units + def gutter(self) -> Spacing: + """The public CSS gutter; geometry resolves the shared calculation.""" + return self.box_gutter @property - def is_relative_height(self, _relative_units={Unit.FRACTION, Unit.PERCENT}) -> bool: - """Does the node have a relative width?""" - height = self.height - return height is not None and height.unit in _relative_units + def width_policy(self) -> str: + return self.expand @property - def is_auto_width(self, _auto=Unit.AUTO) -> bool: - """Does the node have automatic width?""" - width = self.width - return width is not None and width.unit == _auto + def scrollbar_policy(self) -> str: + return self.scrollbar_gutter @property - def is_auto_height(self, _auto=Unit.AUTO) -> bool: - """Does the node have automatic height?""" - height = self.height - return height is not None and height.unit == _auto + def vertical_scrollbar_size(self) -> int: + return self.scrollbar_size_vertical @property - def is_dynamic_height( - self, _dynamic_units={Unit.AUTO, Unit.FRACTION, Unit.PERCENT} - ) -> bool: - """Does the node have a dynamic (not fixed) height?""" - height = self.height - return height is not None and height.unit in _dynamic_units + def horizontal_scrollbar_size(self) -> int: + return self.scrollbar_size_horizontal + + @property + def child_layout(self) -> Layout | None: + return self.layout + + @property + def placement_offset(self) -> ScalarOffset | None: + return self.offset if self.has_rule("offset") else None + + def set_placement_offset(self, offset: ScalarOffset) -> None: + self.offset = offset @property - def is_docked(self) -> bool: - """Is the node docked?""" - return self.dock != "none" + def layer_name(self) -> str: + return self.layer or DEFAULT_LAYER @property - def is_split(self) -> bool: - """Is the node split?""" - return self.split != "none" + def layer_order(self) -> tuple[str, ...] | None: + return self.layers if self.has_rule("layers") else None def has_rule(self, rule_name: str) -> bool: """Check if a rule is set on this Styles object. @@ -813,62 +810,6 @@ def _get_transition(self, key: str) -> Transition | None: else: return None - def _align_width(self, width: int, parent_width: int) -> int: - """Align the width dimension. - - Args: - width: Width of the content. - parent_width: Width of the parent container. - - Returns: - An offset to add to the X coordinate. - """ - offset_x = 0 - align_horizontal = self.align_horizontal - if align_horizontal != "left": - if align_horizontal == "center": - offset_x = (parent_width - width) // 2 - else: - offset_x = parent_width - width - - return offset_x - - def _align_height(self, height: int, parent_height: int) -> int: - """Align the height dimensions - - Args: - height: Height of the content. - parent_height: Height of the parent container. - - Returns: - An offset to add to the Y coordinate. - """ - offset_y = 0 - align_vertical = self.align_vertical - if align_vertical != "top": - if align_vertical == "middle": - offset_y = (parent_height - height) // 2 - else: - offset_y = parent_height - height - return offset_y - - def _align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset: - """Align a size according to alignment rules. - - Args: - child: The size of the child (width, height) - parent: The size of the parent (width, height) - - Returns: - Offset required to align the child. - """ - width, height = child - parent_width, parent_height = parent - return Offset( - self._align_width(width, parent_width), - self._align_height(height, parent_height), - ) - @property def partial_rich_style(self) -> Style: """Get the style properties associated with this node only (not including parents in the DOM). @@ -1475,6 +1416,10 @@ def __eq__(self, other: object) -> bool: ) return NotImplemented + @property + def paint_key(self) -> int: + return self._cache_key + @property def _cache_key(self) -> int: """A cache key, that changes when any style is changed. @@ -1489,6 +1434,11 @@ def node(self) -> DOMNode | None: """The DOM node the styles will be applied to, or `None` if it is not set.""" return self._node() + @property + def flow_geometry(self) -> BoxGeometry: + # Stream deliberately accepts CSS declarations, not inline overrides. + return self._base_styles + @property def base(self) -> Styles: """Quick access to base (css) style.""" @@ -1506,7 +1456,7 @@ def rich_style(self) -> Style: return self.node.rich_style @property - def gutter(self) -> Spacing: + def box_gutter(self) -> Spacing: """Get space around widget (padding + border) Returns: @@ -1517,7 +1467,7 @@ def gutter(self) -> Spacing: cache_key, gutter = self._gutter if cache_key == self._cache_key: return gutter - gutter = self.padding + self.border.spacing + gutter = super().box_gutter self._gutter = (self._cache_key, gutter) return gutter diff --git a/src/textual/dom.py b/src/textual/dom.py index 23b3f9cf11..8e6ca58777 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -37,7 +37,7 @@ from textual._context import active_message_pump from textual._compat import cached_property from textual._node_list import NodeList -from textual._paint_state import EMPTY_PAINT, PaintState, resolve_paint +from textual._paint_state import EMPTY_PAINT, PaintLayer, PaintState from textual._types import WatchCallbackType from textual.binding import Binding, BindingsMap, BindingType from textual.cache import LRUCache @@ -49,6 +49,7 @@ from textual.css.parse import is_id_selector, parse_declarations, parse_selectors from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType from textual.css.styles import RenderStyles, Styles +from textual.box_model import BoxGeometry from textual.geometry import Size from textual.css.tokenize import IDENTIFIER from textual.css.tokenizer import TokenError @@ -257,9 +258,9 @@ def _has_relative_children_width(self) -> bool: if not self.is_container: return False for child in self.children: - if child.styles.expand == "optimal": + if child.geometry.width_policy == "optimal": continue - styles = child.styles + styles = child.geometry if not child.display: continue width = styles.width @@ -285,7 +286,7 @@ def _scan_relative_children_height(self, native: bool) -> tuple[bool, bool]: else: result = False for child in self.children: - styles = child.styles + styles = child.geometry native = native and ( type(child).display is DOMNode.display and type(styles) is RenderStyles @@ -325,7 +326,7 @@ def _render_container(self): """Native container paint, shared independently of scene custody.""" from textual.renderables.blank import Blank - if self.styles.layout and self.styles.keyline[0] != "none": + if self.geometry.child_layout and self.geometry.keyline[0] != "none": return self.layout.render_keyline(self) return Blank(self.background_colors[1]) @@ -1401,26 +1402,20 @@ def _resolved_paint_state(self) -> PaintState: # style mutations, including when inherited by a native child. state = EMPTY_PAINT._replace(cacheable=False) for node in reversed(self.ancestors_with_self): - state = resolve_paint(state, node.styles) + state = PaintState.resolve(state, node.paint_layer) return state epoch = (Styles._revision, MessagePump._tree_revision) + layer = self.paint_layer state = self.__dict__.get("_paint_state") - if ( - state is not None - and state.cacheable - and self.__dict__.get("_paint_epoch") == epoch - ): + reusable = state is not None and layer.paint_cacheable + if reusable and state.cacheable and self.__dict__.get("_paint_epoch") == epoch: return state parent = self._parent inherited = ( parent._resolved_paint_state() if isinstance(parent, DOMNode) else EMPTY_PAINT ) - if ( - state is None - or state.parent is not inherited - or state.style_key != self.styles._cache_key - ): - state = self._paint_state = resolve_paint(inherited, self.styles) + if not (reusable and state.parent is inherited and state.style_key == layer.paint_key): + state = self._paint_state = PaintState.resolve(inherited, layer) self._paint_epoch = epoch return state @@ -1432,6 +1427,15 @@ def selection_style(self) -> Style: ) return style + @property + def paint_layer(self) -> PaintLayer: + return self.styles + + @property + def geometry(self) -> BoxGeometry: + """The original sizing/placement source; declared widgets override this.""" + return self.styles + @property def rich_style(self) -> Style: """Get a Rich Style object for this DOMNode. @@ -1460,45 +1464,41 @@ def get_visual_style( """ cache_key = (self._pseudo_classes_cache_key, component_classes, partial) - if (visual_style := self._visual_style_cache.get(cache_key, None)) is None: + cacheable = self._resolved_paint_state().cacheable + visual_style = self._visual_style_cache.get(cache_key) if cacheable else None + if visual_style is None: background = Color(0, 0, 0, 0) color = Color(255, 255, 255, 0) style = Style() opacity = 1.0 - def iter_styles() -> Iterable[StylesBase]: + def iter_styles() -> Iterable[PaintLayer]: """Iterate over the styles from the DOM and additional components styles.""" if partial: node = self else: for node in reversed(self.ancestors_with_self): - yield node.styles + yield node.paint_layer for name in component_classes: yield node.get_component_styles(name) for styles in iter_styles(): - has_rule = styles.has_rule - opacity *= styles.opacity - if has_rule("background"): - text_background = background + styles.background.tint( - styles.background_tint - ) + opacity *= styles.layer_opacity + local_background = styles.paint_background + if local_background is not None: + tinted = local_background.tint(styles.background_tint) + text_background = background + tinted if partial: - background_tint = styles.background.tint(styles.background_tint) - background = background.blend( - background_tint, 1 - background_tint.a - ).multiply_alpha(opacity) + background = background.blend(tinted, 1 - tinted.a).multiply_alpha(opacity) else: - background += ( - styles.background.tint(styles.background_tint) - ).multiply_alpha(opacity) + background += tinted.multiply_alpha(opacity) else: text_background = background - if has_rule("color"): - color = styles.color.multiply_alpha(styles.text_opacity) + if (foreground := styles.paint_foreground) is not None: + color = foreground.multiply_alpha(styles.text_opacity) style += styles.text_style - if has_rule("auto_color") and styles.auto_color: + if styles.auto_color: color = text_background.get_contrast_text(color.a) visual_style = replace( @@ -1506,7 +1506,8 @@ def iter_styles() -> Iterable[StylesBase]: background=background, foreground=color, ) - self._visual_style_cache[cache_key] = visual_style + if cacheable: + self._visual_style_cache[cache_key] = visual_style return visual_style @@ -1620,51 +1621,6 @@ def check_consume_key(self, key: str, character: str | None) -> bool: """ return False - def _get_title_style_information( - self, background: Color - ) -> tuple[Color, Color, VisualStyle]: - """Get a Visual Style object for titles. - - Args: - background: The background color. - - Returns: - A Rich style. - """ - - styles = self.styles - if styles.auto_border_title_color: - color = background.get_contrast_text(styles.border_title_color.a) - else: - color = styles.border_title_color - return ( - color, - styles.border_title_background, - VisualStyle.from_rich_style(styles.border_title_style), - ) - - def _get_subtitle_style_information( - self, background: Color - ) -> tuple[Color, Color, VisualStyle]: - """Get a Rich Style object for subtitles. - - Args: - background: The background color. - - Returns: - A Rich style. - """ - styles = self.styles - if styles.auto_border_subtitle_color: - color = background.get_contrast_text(styles.border_subtitle_color.a) - else: - color = styles.border_subtitle_color - return ( - color, - styles.border_subtitle_background, - VisualStyle.from_rich_style(styles.border_subtitle_style), - ) - @property def background_colors(self) -> tuple[Color, Color]: """Background colors adjusted for opacity. @@ -1685,15 +1641,12 @@ def colors(self) -> tuple[Color, Color, Color, Color]: base_background = background = WHITE base_color = color = BLACK for node in reversed(self.ancestors_with_self): - styles = node.styles + layer = node.paint_layer base_background = background - background += styles.background.tint(styles.background_tint) - if styles.has_rule("color"): + background += layer.background_color.tint(layer.background_tint) + if (foreground := layer.paint_foreground) is not None: base_color = color - if styles.auto_color: - color = background.get_contrast_text(color.a) - else: - color = styles.color + color = background.get_contrast_text(color.a) if layer.auto_color else foreground return (base_background, base_color, background, color) diff --git a/src/textual/layout.py b/src/textual/layout.py index 93e7c332b4..d9cc6bedc1 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -224,7 +224,7 @@ def process_offset( Processes placement, may be the same instance. """ widget = self.widget - styles = widget.styles + styles = widget.geometry if not widget.uses_screen_coordinates: # Bail early if there is nothing to do return self @@ -336,8 +336,8 @@ def get_content_height( Content height (in lines). """ if widget._nodes: - if not widget.styles.is_docked and all( - child.styles.is_dynamic_height for child in widget.displayed_children + if not widget.geometry.is_docked and all( + child.geometry.is_dynamic_height for child in widget.displayed_children ): # An exception for containers with all dynamic height widgets arrangement = widget.arrange(Size(width, container.height)) @@ -363,7 +363,7 @@ def render_keyline(self, container: Widget) -> StripRenderable: width, height = container.region.size canvas = Canvas(width, height) - line_style, keyline_color = container.styles.keyline + line_style, keyline_color = container.geometry.keyline if keyline_color: keyline_color = container.background_colors[0] + keyline_color diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index c4141e76d9..6a3794a844 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -6,7 +6,7 @@ from textual._resolve import resolve from textual._measurement import GRID_HEIGHT, height_dependency from textual.css.scalar import Scalar -from textual.geometry import NULL_OFFSET, Region, Size, Spacing +from textual.geometry import Region, Size, Spacing from textual.layout import ArrangeResult, Layout, WidgetPlacement from textual.visual import visualize @@ -51,10 +51,10 @@ def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True ) -> ArrangeResult: parent.pre_layout(self) - styles = parent.styles + styles = parent.geometry row_scalars = styles.grid_rows or ( [Scalar.parse("1fr")] - if (size.height and not parent.styles.is_auto_height) + if (size.height and not parent.geometry.is_auto_height) else [Scalar.parse("auto")] ) column_scalars = styles.grid_columns or [Scalar.parse("1fr")] @@ -159,7 +159,7 @@ def repeat_scalars(scalars: Iterable[Scalar], count: int) -> list[Scalar]: column = row = 0 for child in children: - child_styles = child.styles + child_styles = child.geometry column_span = child_styles.column_span or 1 row_span = child_styles.row_span or 1 # Find a slot where this cell fits @@ -197,7 +197,7 @@ def apply_width_limits(widget: Widget, width: int) -> int: Returns: New width. """ - styles = widget.styles + styles = widget.geometry if styles.min_width is not None: width = max( width, @@ -220,7 +220,7 @@ def apply_height_limits(widget: Widget, height: int) -> int: Returns: New height """ - styles = widget.styles + styles = widget.geometry if styles.min_height is not None: height = max( height, @@ -244,14 +244,14 @@ def apply_height_limits(widget: Widget, height: int) -> int: except KeyError: pass else: - if widget.styles.column_span != 1: + if widget.geometry.column_span != 1: continue width = max( width, apply_width_limits( widget, widget.get_content_width(size, viewport) - + widget.styles.gutter.width, + + widget.geometry.box_gutter.width, ), ) column_scalars[column] = Scalar.from_number(width) @@ -269,7 +269,7 @@ def apply_height_limits(widget: Widget, height: int) -> int: visualize(widget, widget.render()).get_minimal_width( widget.styles ) - + widget.styles.gutter.width, + + widget.geometry.box_gutter.width, column_minimums[column_index], ) @@ -295,10 +295,10 @@ def apply_height_limits(widget: Widget, height: int) -> int: except KeyError: pass else: - if widget.styles.row_span != 1: + if widget.geometry.row_span != 1: continue column_width = columns[column][1] - gutter_width, gutter_height = widget.styles.gutter.totals + gutter_width, gutter_height = widget.geometry.box_gutter.totals widget_height = apply_height_limits( widget, widget.get_content_height( @@ -351,16 +351,9 @@ def apply_height_limits(widget: Widget, height: int) -> int: .shrink(margin) ) + offset - widget_styles = widget.styles - placement_offset = ( - widget_styles.offset.resolve(cell_size, viewport) - if widget_styles.has_rule("offset") - else NULL_OFFSET - ) - - absolute = ( - widget_styles.has_rule("position") and styles.position == "absolute" - ) + geometry = widget.geometry + placement_offset = geometry.resolve_offset(cell_size, viewport) + absolute = geometry.position == "absolute" add_placement( _WidgetPlacement( region, diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index a6e51f8393..26c11c096b 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -4,7 +4,7 @@ from textual._resolve import resolve_box_models from textual._measurement import FLOW_HEIGHT, height_dependency -from textual.geometry import NULL_OFFSET, Region, Size +from textual.geometry import Region, Size from textual.layout import ArrangeResult, Layout, WidgetPlacement if TYPE_CHECKING: @@ -28,7 +28,7 @@ def arrange( add_placement = placements.append viewport = parent.layout_viewport - child_styles = [child.styles for child in children] + child_styles = [child.geometry for child in children] box_margins: list[Spacing] = [ styles.margin for styles in child_styles if styles.overlay != "screen" ] @@ -72,7 +72,7 @@ def arrange( ( box_model.margin.left for box_model, child in zip(box_models, children) - if child.styles.overlay != "screen" + if child.geometry.overlay != "screen" ), 0, ) @@ -83,15 +83,10 @@ def arrange( for widget, (content_width, content_height, box_margin), margin in zip( children, box_models, margins ): - styles = widget.styles + styles = widget.geometry overlay = styles.overlay == "screen" - offset = ( - styles.offset.resolve( - _Size(content_width.__floor__(), content_height.__floor__()), - viewport, - ) - if styles.has_rule("offset") - else NULL_OFFSET + offset = styles.resolve_offset( + _Size(content_width.__floor__(), content_height.__floor__()), viewport ) offset_y = box_margin.top next_x = x + content_width @@ -102,7 +97,7 @@ def arrange( (next_x - x.__floor__()).__floor__(), content_height.__floor__(), ) - absolute = styles.has_rule("position") and styles.position == "absolute" + absolute = styles.position == "absolute" add_placement( _WidgetPlacement( region, diff --git a/src/textual/layouts/stream.py b/src/textual/layouts/stream.py index e7f0a2c27b..e41fec927a 100644 --- a/src/textual/layouts/stream.py +++ b/src/textual/layouts/stream.py @@ -62,7 +62,7 @@ def arrange( placements: list[WidgetPlacement] = [] width = size.width - first_child_styles = children[0].styles + first_child_styles = children[0].geometry y = 0 previous_margin = first_child_styles.margin.top null_offset = NULL_OFFSET @@ -75,15 +75,15 @@ def arrange( else: placements.append(placement) y = placement.region.bottom - styles = widget.styles._base_styles + styles = widget.geometry.flow_geometry previous_margin = styles.margin.bottom continue if widget is None: break - styles = widget.styles._base_styles + styles = widget.geometry.flow_geometry margin = styles.margin - gutter_width, gutter_height = styles.gutter.totals + gutter_width, gutter_height = styles.box_gutter.totals top, right, bottom, left = margin child_width = width - (left + right) y += top if top > previous_margin else previous_margin diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 3a0bbccaff..b7a0b2e87b 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -4,7 +4,7 @@ from textual._resolve import resolve_box_models from textual._measurement import FLOW_HEIGHT, height_dependency -from textual.geometry import NULL_OFFSET, Region, Size +from textual.geometry import Region, Size from textual.layout import ArrangeResult, Layout, WidgetPlacement if TYPE_CHECKING: @@ -26,7 +26,7 @@ def arrange( add_placement = placements.append viewport = parent.layout_viewport - child_styles = [child.styles for child in children] + child_styles = [child.geometry for child in children] box_margins: list[Spacing] = [ styles.margin for styles in child_styles if styles.overlay != "screen" ] @@ -77,7 +77,7 @@ def arrange( ( box_model.margin.top for box_model, child in zip(box_models, children) - if child.styles.overlay != "screen" + if child.geometry.overlay != "screen" ), 0, ) @@ -88,16 +88,11 @@ def arrange( for widget, (content_width, content_height, box_margin), margin in zip( children, box_models, margins ): - styles = widget.styles + styles = widget.geometry overlay = styles.overlay == "screen" next_y = y + content_height - offset = ( - styles.offset.resolve( - _Size(content_width.__floor__(), content_height.__floor__()), - viewport, - ) - if styles.has_rule("offset") - else NULL_OFFSET + offset = styles.resolve_offset( + _Size(content_width.__floor__(), content_height.__floor__()), viewport ) region = _Region( @@ -106,7 +101,7 @@ def arrange( content_width.__floor__(), next_y.__floor__() - y.__floor__(), ) - absolute = styles.has_rule("position") and styles.position == "absolute" + absolute = styles.position == "absolute" add_placement( _WidgetPlacement( region, diff --git a/src/textual/screen.py b/src/textual/screen.py index 1d00547610..6c6508d6b3 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -386,7 +386,7 @@ def _screens_seen_through(self) -> list[Screen]: """ screens: list[Screen] = [] screen = self - while screen.styles.background.a < 1 and (screen := screen._screen_below) is not None: + while screen.paint_layer.background_color.a < 1 and (screen := screen._screen_below) is not None: screens.append(screen) return screens @@ -416,7 +416,7 @@ def layers(self) -> tuple[str, ...]: @property def size(self) -> Size: """The size of the screen.""" - return self.app.size - self.styles.gutter.totals + return self.app.size - self.geometry.box_gutter.totals def _watch_focused(self): self.refresh_bindings() @@ -665,7 +665,7 @@ def render(self) -> RenderableType: Returns: Background renderable. """ - background = self.styles.background + background = self.paint_layer.background_color if seen_through := self._screens_seen_through: # If background is translucent, render the screen below return BackgroundScreen(seen_through[0], background) @@ -1774,7 +1774,7 @@ async def _on_layout(self, message: messages.Layout) -> None: if widget not in self._layout_widgets: self._layout_widgets[ancestor].add(widget) layout_required = True - if not ancestor.styles.auto_dimensions: + if not ancestor.geometry.auto_dimensions: break widget = ancestor @@ -1794,7 +1794,7 @@ def _layout_boundaries(self) -> set[Widget] | None: # keys not sized by their content. boundaries = { ancestor for ancestor in self._layout_widgets - if not ancestor.styles.auto_dimensions + if not ancestor.geometry.auto_dimensions } if not boundaries or self in boundaries: return None @@ -1845,14 +1845,14 @@ def _get_inline_height(self, size: Size) -> int: Returns: Height for inline mode. """ - height_scalar = self.styles.height + height_scalar = self.geometry.height if height_scalar is None or height_scalar.is_auto: inline_height = self.get_content_height(size, size, size.width) else: inline_height = int(height_scalar.resolve(size, size)) - inline_height += self.styles.gutter.height - min_height = self.styles.min_height - max_height = self.styles.max_height + inline_height += self.geometry.box_gutter.height + min_height = self.geometry.min_height + max_height = self.geometry.max_height if min_height is not None: inline_height = max(inline_height, int(min_height.resolve(size, size))) if max_height is not None: @@ -1967,11 +1967,7 @@ async def _on_resize(self, event: events.Resize) -> None: self.dismiss_hints() self._screen_resized(event.size) - horizontal_breakpoints = ( - self.app.HORIZONTAL_BREAKPOINTS - if self.HORIZONTAL_BREAKPOINTS is None - else self.HORIZONTAL_BREAKPOINTS - ) or [] + horizontal_breakpoints = self.horizontal_breakpoints vertical_breakpoints = ( self.app.VERTICAL_BREAKPOINTS @@ -1986,34 +1982,37 @@ async def _on_resize(self, event: events.Resize) -> None: for _, breakpoint in (horizontal_breakpoints + vertical_breakpoints) } - for breakpoint in self._get_breakpoint_classes( - width, horizontal_breakpoints - ): - breakpoints[breakpoint] = True - - for breakpoint in self._get_breakpoint_classes( - height, vertical_breakpoints + for dimension, declarations in ( + (width, horizontal_breakpoints), (height, vertical_breakpoints) ): - breakpoints[breakpoint] = True + if (active := self._get_breakpoint(dimension, declarations)) is not None: + breakpoints[active[1]] = True self.update_classes(breakpoints, animate=False) - def _get_breakpoint_classes( - self, dimension: int, breakpoints: list[tuple[int, str]] - ) -> set[str]: - """Get breakpoint classes for given dimension. + @property + def horizontal_breakpoints(self) -> list[tuple[int, str]]: + """Horizontal sizing declarations, using the screen override if supplied.""" + return ( + self.app.HORIZONTAL_BREAKPOINTS + if self.HORIZONTAL_BREAKPOINTS is None + else self.HORIZONTAL_BREAKPOINTS + ) or [] - Args: - dimension: Size in cells. - breakpoints: Associated breakpoints. + @property + def horizontal_breakpoint(self) -> int | None: + """The active viewport-width minimum, independent of its CSS class.""" + active = self._get_breakpoint(self.app.size.width, self.horizontal_breakpoints) + return None if active is None else active[0] - Returns: - A set containing a breakpoint, or an empty set if none apply. - """ - for breakpoint, class_name in sorted(breakpoints, reverse=True): - if dimension >= breakpoint: - return {class_name} - return set() + def _get_breakpoint( + self, dimension: int, breakpoints: list[tuple[int, str]] + ) -> tuple[int, str] | None: + """Resolve a sizing declaration; CSS and native geometry share this rule.""" + for breakpoint in sorted(breakpoints, reverse=True): + if dimension >= breakpoint[0]: + return breakpoint + return None def _update_tooltip(self, widget: Widget) -> None: """Update the content of the tooltip.""" @@ -2560,7 +2559,7 @@ def can_view_entire(self, widget: Widget) -> bool: if widget not in self._compositor.visible_widgets: return False # If the widget is one that overlays the screen... - if widget.styles.overlay == "screen": + if widget.geometry.overlay == "screen": # ...simply check if it's within the screen's region. return widget.region in self.region # Failing that fall back to normal checking. @@ -2578,7 +2577,7 @@ def can_view_partial(self, widget: Widget) -> bool: if widget not in self._compositor.visible_widgets: return False # If the widget is one that overlays the screen... - if widget.styles.overlay == "screen": + if widget.geometry.overlay == "screen": # ...simply check if it's within the screen's region. return widget.region in self.region # Failing that fall back to normal checking. diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 94f352f68d..5db678dfe3 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -104,7 +104,7 @@ def _size_updated( # The line surface authors its virtual extent. Commit native bounds # without feeding layout measurements or reactive validation into it. return super()._size_updated( - size, self.virtual_size, size - self.styles.gutter.totals, layout=False + size, self.virtual_size, size - self.geometry.box_gutter.totals, layout=False ) def _scroll_update(self, virtual_size: Size) -> None: diff --git a/src/textual/selection.py b/src/textual/selection.py index 858a66ff5d..65705fdca4 100644 --- a/src/textual/selection.py +++ b/src/textual/selection.py @@ -3,6 +3,7 @@ from operator import attrgetter from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple +from textual.box_model import DEFAULT_LAYER from textual.geometry import NULL_REGION, Offset, Region, Shape if TYPE_CHECKING: @@ -436,7 +437,7 @@ def get_region(widget: Widget) -> Region: def get_content_region(widget: Widget) -> Region: if type(widget).content_region is not Widget.content_region: return widget.content_region - return get_region(widget).shrink(widget.styles.gutter) + return get_region(widget).shrink(widget.geometry.box_gutter) def get_selection_order(widget: Widget) -> tuple[int, int]: if type(widget)._selection_order is not Widget._selection_order: @@ -485,7 +486,7 @@ def branch(widget: Widget) -> Widget | None: # Only ordinary flow children can be bounded by their # DOM order. Positioned/layered children may be drawn # elsewhere and fall back to the general walker. - if all(child.styles.position == "relative" and not child.styles.layer + if all(child.geometry.position == "relative" and child.layer == DEFAULT_LAYER for child in siblings): low, high = sorted((first_index, last_index)) visible = set(visible_children(root, root_visible)) diff --git a/src/textual/widget.py b/src/textual/widget.py index ca337c54d7..563ad7d8e9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -43,7 +43,7 @@ from rich.text import Text from typing_extensions import Self -from textual.css.styles import RenderStyles, StylesBase +from textual.css.styles import RenderStyles if TYPE_CHECKING: from textual.app import RenderResult @@ -62,13 +62,14 @@ box_depends_on_available_height, height_dependency, ) from textual.message_pump import MessagePump -from textual._styles_cache import StylesCache -from textual._activation import PrimaryActivation +from textual._styles_cache import FramePaint, PaintStyles, StylesCache +from textual._activation import PrimaryActivation, PrimaryTarget, StyleTarget +from textual._event_broker import NoHandler, extract_handler_actions from textual._types import AnimationLevel from textual.actions import SkipAction from textual.await_complete import AwaitComplete, AwaitCompletion from textual.await_remove import AwaitRemove -from textual.box_model import BoxModel +from textual.box_model import DEFAULT_LAYER, BoxModel from textual.cache import FIFOCache, LRUCache from textual.color import Color from textual.compose import compose, compose_async @@ -348,7 +349,7 @@ def _invalidate_layout(self) -> None: if not isinstance(ancestor, Widget): break ancestor._measurement_updated() - if not ancestor.styles.auto_dimensions: + if not ancestor.geometry.auto_dimensions: break DEFAULT_CSS = """ @@ -451,6 +452,8 @@ def _invalidate_layout(self) -> None: """A title to show in the bottom border (if there is one).""" # Default sort order, incremented by constructor + DEFAULT_LAYOUT: ClassVar[type[Layout]] = VerticalLayout + _sort_order: ClassVar[int] = 0 _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[Widget], bool]]] = { @@ -504,7 +507,7 @@ def __init__( self._scroll_required = False self._recompose_required = False self._refresh_styles_required = False - self._default_layout = VerticalLayout() + self._default_layout = self.DEFAULT_LAYOUT() self._animate: BoundAnimator | None = None Widget._sort_order += 1 self.sort_order = Widget._sort_order @@ -668,11 +671,11 @@ def offset(self) -> Offset: Returns: Relative offset. """ - return self.styles.offset.resolve(self.size, self.screen.size) + return self.geometry.resolve_offset(self.size, self.screen.size) @offset.setter def offset(self, offset: tuple[int, int]) -> None: - self.styles.offset = ScalarOffset.from_offset(offset) + self.geometry.set_placement_offset(ScalarOffset.from_offset(offset)) @property def opacity(self) -> float: @@ -727,7 +730,7 @@ def is_in_maximized_view(self) -> bool: def uses_screen_coordinates(self) -> bool: """Whether the native placement must resolve against the screen.""" return (self.absolute_offset is not None - or self.styles.has_any_rules("constrain_x", "constrain_y")) + or self.geometry.constrain_x != "none" or self.geometry.constrain_y != "none") def subtree_geometry_resource(self): """Choose the native arrangement owner for this widget's scroll scope.""" @@ -1780,7 +1783,7 @@ def _get_box_model( if local_styles: # Fixed/fill native boxes never query child content. Their source # is the original local style owner, not descendant layout epochs. - styles = self.styles + styles = self.geometry revision = (id(styles), styles, styles._cache_key) cache_container = container cache_width_fraction, cache_height_fraction = width_fraction, height_fraction @@ -1792,8 +1795,8 @@ def _get_box_model( parent = self._parent revision += ( self._parent_revision, - parent is not None and parent.styles.is_auto_width, - parent is not None and parent.styles.is_auto_height, + parent is not None and parent.geometry.is_auto_width, + parent is not None and parent.geometry.is_auto_height, ) if independent_height: cache_container = container.with_height(0) @@ -1826,7 +1829,7 @@ def _get_box_model( model, self._extrema = cached_measurement return model - model, extrema = BoxModel.resolve( + model, extrema = self.geometry.resolve_box( self, container, viewport, width_fraction, height_fraction, constrain_width, greedy, ) @@ -2025,7 +2028,7 @@ def _refresh_scrollbars(self) -> None: if not self.is_scrollable or not self.container_size: return - styles = self.styles + styles = self.geometry overflow_x = styles.overflow_x overflow_y = styles.overflow_y @@ -2058,11 +2061,11 @@ def _refresh_scrollbars(self) -> None: # When a single scrollbar is shown, the other dimension changes, so we need to recalculate. if overflow_x == "auto" and show_vertical and not show_horizontal: show_horizontal = self.virtual_size.width > ( - width - styles.scrollbar_size_vertical + width - styles.vertical_scrollbar_size ) if overflow_y == "auto" and show_horizontal and not show_vertical: show_vertical = self.virtual_size.height > ( - height - styles.scrollbar_size_horizontal + height - styles.horizontal_scrollbar_size ) if ( @@ -2105,10 +2108,10 @@ def scrollbar_size_vertical(self) -> int: Returns: Number of columns in the vertical scrollbar. """ - styles = self.styles - if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto": - return styles.scrollbar_size_vertical - return styles.scrollbar_size_vertical if self.show_vertical_scrollbar else 0 + geometry = self.geometry + if geometry.stable_gutter and geometry.overflow_y == "auto": + return geometry.vertical_scrollbar_size + return geometry.vertical_scrollbar_size if self.show_vertical_scrollbar else 0 @property def scrollbar_size_horizontal(self) -> int: @@ -2117,8 +2120,7 @@ def scrollbar_size_horizontal(self) -> int: Returns: Number of rows in the horizontal scrollbar. """ - styles = self.styles - return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0 + return self.geometry.horizontal_scrollbar_size if self.show_horizontal_scrollbar else 0 @property def scrollbar_gutter(self) -> Spacing: @@ -2138,7 +2140,7 @@ def gutter(self) -> Spacing: Returns: Additional spacing around content area. """ - return self.styles.gutter + self.scrollbar_gutter + return self.geometry.box_gutter + self.scrollbar_gutter @property def size(self) -> Size: @@ -2151,7 +2153,7 @@ def size(self) -> Size: Content area size. """ width, height = self._size - gutter = self.styles.gutter + gutter = self.geometry.box_gutter return Size(max(0, width - gutter.width), max(0, height - gutter.height)) @property @@ -2197,7 +2199,7 @@ def content_region(self) -> Region: Returns: Screen region that contains a widget's content. """ - content_region = self.region.shrink(self.styles.gutter) + content_region = self.region.shrink(self.geometry.box_gutter) return content_region @property @@ -2207,7 +2209,7 @@ def scrollable_content_region(self) -> Region: Returns: Screen region that contains a widget's content. """ - content_region = self.region.shrink(self.styles.gutter).shrink( + content_region = self.region.shrink(self.geometry.box_gutter).shrink( self.scrollbar_gutter ) return content_region @@ -2312,7 +2314,7 @@ def virtual_region_with_margin(self) -> Region: Returns: The virtual region of the Widget, inclusive of its margin. """ - return self.virtual_region.grow(self.styles.margin) + return self.virtual_region.grow(self.geometry.margin) @property def _self_or_ancestors_disabled(self) -> bool: @@ -2339,7 +2341,7 @@ def focusable(self) -> bool: def _focus_sort_key(self) -> tuple[int, int]: """Key function to sort widgets into focus order.""" x, y, _, _ = self.virtual_region - top, _, _, left = self.styles.margin + top, _, _, left = self.geometry.margin return y - top, x - left @property @@ -2383,7 +2385,7 @@ def _relative_children_height(self) -> tuple[bool, bool]: native = ( not self._container_selection_dependency.depends(self) and type(self).children is DOMNode.children - and type(self.styles) is RenderStyles + and type(self.geometry) is RenderStyles ) answers = self._height_dependency_answers() if native and "relative_children" in answers: @@ -2406,29 +2408,6 @@ def is_on_screen(self) -> bool: except NoScreen: return False - def _resolve_extrema( - self, - container: Size, - viewport: Size, - width_fraction: Fraction, - height_fraction: Fraction, - ) -> Extrema: - """Resolve minimum and maximum values for width and height. - - Args: - container: Size of outer widget. - viewport: Viewport size. - width_fraction: Size of 1fr width. - height_fraction: Size of 1fr height. - - Returns: - Extrema object. - """ - - return Extrema.resolve( - self.styles, container, viewport, width_fraction, height_fraction, - ) - def animate( self, attribute: str, @@ -2489,18 +2468,18 @@ def layout(self) -> Layout: Returns: A layout object. """ - return self.styles.layout or self._default_layout + return self.geometry.child_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 + return bool(self._nodes) or self.geometry.child_layout is not None @property def is_scrollable(self) -> bool: """Can this widget be scrolled?""" - return self.styles.layout is not None or bool(self._nodes) + return self.geometry.child_layout is not None or bool(self._nodes) @property def is_scrolling(self) -> bool: @@ -2526,21 +2505,21 @@ def layer(self) -> str: Returns: Name of layer. """ - return self.styles.layer or "default" + return self.geometry.layer_name @property def layers(self) -> tuple[str, ...]: """Names from the outermost authored layer declaration, or the default.""" order = self._get_layer_order(self.walk_ancestors(with_self=True)) - return ("default",) if order is None else order + return (DEFAULT_LAYER,) if order is None else order def _get_layer_order(self, sources: Iterable[DOMNode]) -> tuple[str, ...] | None: order = None for node in sources: if not isinstance(node, Widget): break - if node.styles.has_rule("layers"): - order = node.styles.layers + if (declared := node.geometry.layer_order) is not None: + order = declared return order @property @@ -2578,7 +2557,7 @@ def _set_dirty(self, *regions: Region) -> None: # Invalidation must not measure a lazily invalidated compositor. # Mark the last committed local content bounds; the next geometry # commit dirties its new bounds independently. - self._styles_cache.set_dirty(self._size.region.shrink(self.styles.gutter).reset_offset) + self._styles_cache.set_dirty(self._size.region.shrink(self.geometry.box_gutter).reset_offset) outer_size = self.outer_size self._dirty_regions.add(outer_size.region) if outer_size: @@ -3453,7 +3432,7 @@ def scroll_to_widget( container = widget.parent - if widget.styles.dock != "none": + if widget.geometry.is_docked: scroll_offset = Offset(0, 0) else: scroll_offset = container.scroll_to_region( @@ -3478,15 +3457,15 @@ def scroll_to_widget( # its parent's virtual coordinate system. region = ( region.translate(-scroll_offset) - .translate(container.styles.margin.top_left) - .translate(container.styles.border.spacing.top_left) + .translate(container.geometry.margin.top_left) + .translate(container.geometry.border.spacing.top_left) ) # Translate and clip against the same acquired parent geometry, # after its scroll operation has completed. container_region = container.virtual_region_with_margin region = ( region.translate(container_region.offset) - .grow(container.styles.margin) + .grow(container.geometry.margin) .intersection(container_region) ) @@ -3784,7 +3763,6 @@ def __init_subclass__( ) 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.layout is Widget.layout @@ -3824,9 +3802,9 @@ def _get_scrollable_region(self, region: Region) -> Region: """ show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled - styles = self.styles - scrollbar_size_horizontal = styles.scrollbar_size_horizontal - scrollbar_size_vertical = styles.scrollbar_size_vertical + geometry = self.geometry + scrollbar_size_horizontal = geometry.horizontal_scrollbar_size + scrollbar_size_vertical = geometry.vertical_scrollbar_size show_vertical_scrollbar = bool( show_vertical_scrollbar and scrollbar_size_vertical @@ -3835,10 +3813,10 @@ def _get_scrollable_region(self, region: Region) -> Region: show_horizontal_scrollbar and scrollbar_size_horizontal ) - if styles.scrollbar_gutter == "stable": - # Let's _always_ reserve some space, whether the scrollbar is actually displayed or not: + if geometry.stable_gutter: + # Reserve the declared gutter even while the scrollbar is hidden. show_vertical_scrollbar = True - scrollbar_size_vertical = styles.scrollbar_size_vertical + scrollbar_size_vertical = geometry.vertical_scrollbar_size if show_horizontal_scrollbar and show_vertical_scrollbar: region, _, _, _ = region.split( @@ -3980,6 +3958,13 @@ def post_render( return renderable + @property + def frame_paint(self) -> FramePaint: + return PaintStyles.of(self.styles) + + def refresh_paint(self) -> None: + self._styles_cache.refresh_paint(self) + def watch_has_focus(self, _has_focus: bool) -> None: """Update from CSS if has focus state changes.""" try: @@ -4072,13 +4057,13 @@ def _scroll_update(self, virtual_size: Size) -> None: self._refresh_scrollbars() width, height = self.container_size - if self.show_vertical_scrollbar and self.styles.scrollbar_size_vertical: + if self.show_vertical_scrollbar and self.geometry.vertical_scrollbar_size: self.vertical_scrollbar.window_virtual_size = virtual_size.height self.vertical_scrollbar.window_size = ( height - self.scrollbar_size_horizontal ) self.vertical_scrollbar.refresh() - if self.show_horizontal_scrollbar and self.styles.scrollbar_size_horizontal: + if self.show_horizontal_scrollbar and self.geometry.horizontal_scrollbar_size: self.horizontal_scrollbar.window_virtual_size = virtual_size.width self.horizontal_scrollbar.window_size = width - self.scrollbar_size_vertical self.horizontal_scrollbar.refresh() @@ -4470,7 +4455,7 @@ def _check_refresh(self) -> None: self.call_later(self.update_node_styles) if self._scroll_required: self._scroll_required = False - if self.styles.keyline[0] != "none": + if self.geometry.keyline[0] != "none": # A keyline's background follows its content scroll. self._set_dirty() # The screen decides between a scoped and a full reflow. @@ -4672,9 +4657,9 @@ def _extend_compose(self, widgets: list[Widget]) -> None: """ def _on_mount(self, event: events.Mount) -> None: - if self.styles.overflow_y == "scroll": + if self.geometry.overflow_y == "scroll": self.show_vertical_scrollbar = True - if self.styles.overflow_x == "scroll": + if self.geometry.overflow_x == "scroll": self.show_horizontal_scrollbar = True def _on_leave(self, event: events.Leave) -> None: diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index ef61847c5e..a7785df264 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -548,7 +548,7 @@ def _watch_value(self, value: str) -> None: self._suggestion = "" if self.suggester and value: self.run_worker(self.suggester._get_suggestion(self, value)) - if self.styles.auto_dimensions: + if self.geometry.auto_dimensions: self.refresh(layout=True) validation_result = ( diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 44b4095c82..8c30f2d8e6 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -693,7 +693,7 @@ def __init__(self, headers: list[Content], rows: list[list[Content]]): def pre_layout(self, layout: Layout) -> None: assert isinstance(layout, GridLayout) layout.auto_minimum = True - layout.expand = not self.query_ancestor(MarkdownTable).styles.is_auto_width + layout.expand = not self.query_ancestor(MarkdownTable).geometry.is_auto_width layout.shrink = True layout.stretch_height = True diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 6a57821a00..0bac59069a 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -407,7 +407,7 @@ def add_options(self, new_options: Iterable[OptionListContent]) -> Self: self._id_to_option[option._id] = option add_option(option) if self.is_mounted: - self.refresh(layout=self.styles.auto_dimensions) + self.refresh(layout=self.geometry.auto_dimensions) self._update_lines() return self diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 568ecd062a..9e2d3b45a5 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic, Hashable, Iterable, TypeVar +from typing import TYPE_CHECKING, ClassVar, Generic, Hashable, Iterable, TypeVar import rich.repr from rich.console import RenderableType @@ -286,6 +286,9 @@ class Select(Generic[SelectType], Vertical, can_focus=True): When activated with ++enter++ the widget displays an overlay with a list of all possible options. """ + CURRENT: ClassVar[type[SelectCurrent]] = SelectCurrent + OVERLAY: ClassVar[type[SelectOverlay]] = SelectOverlay + NULL = NULL """Constant to flag that the widget has no selection.""" @@ -618,8 +621,8 @@ def _watch_value(self, value: SelectType | NoSelection) -> None: def compose(self) -> ComposeResult: """Compose Select with overlay and current value.""" - yield SelectCurrent(self.prompt) - yield SelectOverlay(type_to_search=self._type_to_search).data_bind( + yield self.CURRENT(self.prompt) + yield self.OVERLAY(type_to_search=self._type_to_search).data_bind( compact=Select.compact ) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 54d16edd18..f950d009a9 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -131,6 +131,6 @@ def update(self, content: VisualType = "", *, layout: bool | None = None) -> Non # unset) dimension; a fixed-size Static repaints without a layout. layout = not unchanged and any( scalar is None or scalar.is_auto - for scalar in (self.styles.width, self.styles.height) + for scalar in (self.geometry.width, self.geometry.height) ) self.refresh(layout=layout) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index fa6cb518ec..803c417923 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -658,7 +658,7 @@ def move_underline(animate: bool) -> None: pass else: tab_region = active_tab.virtual_region.shrink( - active_tab.styles.gutter + active_tab.geometry.box_gutter ) start, end = tab_region.column_span if animate: diff --git a/tests/test_height_independent_measurement.py b/tests/test_height_independent_measurement.py index c4f3af268c..cf8710d56a 100644 --- a/tests/test_height_independent_measurement.py +++ b/tests/test_height_independent_measurement.py @@ -168,22 +168,6 @@ async def test_noncell_width_limit_keeps_original_zero_width_parent_exception(): assert second.width == 12 -async def test_custom_extrema_keeps_available_parent_width(): - from textual._extrema import Extrema - - class WidthSizedExtrema(Widget): - def _resolve_extrema(self, container, viewport, width_fraction, height_fraction): - return Extrema(min_width=Fraction(container.width)) - - async with App().run_test(): - widget = WidthSizedExtrema() - widget.styles.width = 12 - widget.styles.height = 3 - first = widget._get_box_model(Size(30, 10), Size(120, 40), Fraction(30), Fraction(10)) - second = widget._get_box_model(Size(70, 10), Size(120, 40), Fraction(70), Fraction(10)) - assert first.width == 30 - assert second.width == 70 - async def test_auto_width_relative_child_keeps_parent_width_expansion(): async with App().run_test() as pilot: @@ -416,28 +400,6 @@ async def test_width_padding_margin_offset_and_viewport_parity(layout_name): assert [box(column, height, width=width, greedy=greedy) for width, height, greedy in inputs] == expected -async def test_unset_height_and_custom_extrema_keep_the_full_context(): - from textual._extrema import Extrema - - class CustomExtrema(VerticalGroup): - def _resolve_extrema(self, container, viewport, width_fraction, height_fraction): - return Extrema(min_height=Fraction(container.height)) - - app = App() - async with app.run_test() as pilot: - fill = VerticalGroup(Static("fill")) - custom = CustomExtrema(Static("extent")) - await app.mount(fill, custom) - await pilot.pause() - fill.styles.base.clear_rule("height") - fill.styles.inline.clear_rule("height") - assert fill.styles.height is None - assert fill._box_depends_on_available_height() - assert custom._box_depends_on_available_height() - for height in (10, 30): - assert box(fill, height).height == height - assert box(custom, height).height == height - async def test_custom_scalar_resolver_is_not_classified_by_its_unit_alone(): class ContextScalar(Scalar): diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py index 25ea1d7612..179ef771f1 100644 --- a/tests/test_styles_cache.py +++ b/tests/test_styles_cache.py @@ -4,10 +4,10 @@ from rich.segment import Segment -from textual._styles_cache import StylesCache +from textual._styles_cache import PaintStyles, StylesCache from textual.color import Color from textual.css.styles import Styles -from textual.geometry import Region, Size +from textual.geometry import Region, Size, Spacing from textual.strip import Strip @@ -21,7 +21,7 @@ def test_color_memo_does_not_retain_retired_paint_cache(): """A shared color calculation must not own an otherwise retired scene.""" cache = StylesCache() cache.render( - Styles(), + PaintStyles.of(Styles()), Size(3, 1), Color.parse("blue"), Color.parse("green"), @@ -29,6 +29,7 @@ def test_color_memo_does_not_retain_retired_paint_cache(): [], None, None, + padding=Spacing(0, 0, 0, 0), ) reference = weakref.ref(cache) del cache @@ -54,7 +55,7 @@ def test_clear_retires_simple_padding_strip(): def render(background): return cache.render( - styles, + PaintStyles.of(styles), Size(5, 3), Color.parse("black"), background, @@ -63,6 +64,7 @@ def render(background): None, None, content_size=Size(3, 1), + padding=styles.padding, ) blue = render(Color.parse("blue")) @@ -91,7 +93,7 @@ def test_border(): styles.border = ("heavy", "white") cache = StylesCache() lines = cache.render( - styles, + PaintStyles.of(styles), Size(5, 5), Color.parse("blue"), Color.parse("green"), @@ -100,6 +102,7 @@ def test_border(): None, None, content_size=Size(3, 3), + padding=styles.padding, ) text_content = _extract_content(lines) @@ -125,7 +128,7 @@ def test_padding(): styles.padding = 1 cache = StylesCache() lines = cache.render( - styles, + PaintStyles.of(styles), Size(5, 5), Color.parse("blue"), Color.parse("green"), @@ -134,6 +137,7 @@ def test_padding(): None, None, content_size=Size(3, 3), + padding=styles.padding, ) text_content = _extract_content(lines) @@ -160,7 +164,7 @@ def test_padding_border(): styles.border = ("heavy", "white") cache = StylesCache() lines = cache.render( - styles, + PaintStyles.of(styles), Size(7, 7), Color.parse("blue"), Color.parse("green"), @@ -169,6 +173,7 @@ def test_padding_border(): None, None, content_size=Size(3, 3), + padding=styles.padding, ) text_content = _extract_content(lines) @@ -196,7 +201,7 @@ def test_outline(): styles.outline = ("heavy", "white") cache = StylesCache() lines = cache.render( - styles, + PaintStyles.of(styles), Size(3, 3), Color.parse("blue"), Color.parse("green"), @@ -205,6 +210,7 @@ def test_outline(): None, None, content_size=Size(3, 3), + padding=styles.padding, ) text_content = _extract_content(lines) @@ -227,7 +233,7 @@ def test_crop(): styles.border = ("heavy", "white") cache = StylesCache() lines = cache.render( - styles, + PaintStyles.of(styles), Size(7, 7), Color.parse("blue"), Color.parse("green"), @@ -237,6 +243,7 @@ def test_crop(): None, content_size=Size(3, 3), crop=Region(2, 2, 3, 3), + padding=styles.padding, ) text_content = _extract_content(lines) expected_text = [ @@ -266,7 +273,7 @@ def get_content_line(y: int) -> Strip: styles.border = ("heavy", "white") cache = StylesCache() lines = cache.render( - styles, + PaintStyles.of(styles), Size(7, 7), Color.parse("blue"), Color.parse("green"), @@ -275,6 +282,7 @@ def get_content_line(y: int) -> Strip: None, None, content_size=Size(3, 3), + padding=styles.padding, ) assert rendered_lines == [0, 1, 2] del rendered_lines[:] @@ -294,7 +302,7 @@ def get_content_line(y: int) -> Strip: # Re-render styles, check that content was not requested lines = cache.render( - styles, + PaintStyles.of(styles), Size(7, 7), Color.parse("blue"), Color.parse("green"), @@ -303,6 +311,7 @@ def get_content_line(y: int) -> Strip: None, None, content_size=Size(3, 3), + padding=styles.padding, ) assert rendered_lines == [] del rendered_lines[:] @@ -313,7 +322,7 @@ def get_content_line(y: int) -> Strip: cache.set_dirty(Region(0, 2, 7, 2)) lines = cache.render( - styles, + PaintStyles.of(styles), Size(7, 7), Color.parse("blue"), Color.parse("green"), @@ -322,6 +331,7 @@ def get_content_line(y: int) -> Strip: None, None, content_size=Size(3, 3), + padding=styles.padding, ) assert rendered_lines == [0, 1] text_content = _extract_content(lines)