diff --git a/sandbox/will/basic.css b/sandbox/will/basic.css index 76d2f689d..dedc7d161 100644 --- a/sandbox/will/basic.css +++ b/sandbox/will/basic.css @@ -15,7 +15,7 @@ App > Screen { layout: dock; docks: side=left/1; - background: $surfaceX; + background: $surface; color: $text-surface; } @@ -59,7 +59,7 @@ DataTable { } #sidebar .content { - background: $surface; + background: $panel-darken-2; color: $text-surface; border-right: wide $background; content-align: center middle; @@ -224,7 +224,7 @@ Success { height:auto; box-sizing: border-box; background: $success; - color: $text-success; + color: $text-success-fade-1; border-top: hkey $success-darken-2; border-bottom: hkey $success-darken-2; diff --git a/sandbox/will/dock.py b/sandbox/will/dock.py new file mode 100644 index 000000000..f9eaac67c --- /dev/null +++ b/sandbox/will/dock.py @@ -0,0 +1,51 @@ +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class DockApp(App): + def compose(self) -> ComposeResult: + + self.screen.styles.layers = "base sidebar" + + header = Static("Header", id="header") + header.styles.dock = "top" + header.styles.height = "3" + + header.styles.background = "blue" + header.styles.color = "white" + header.styles.margin = 0 + header.styles.align_horizontal = "center" + + # header.styles.layer = "base" + + header.styles.box_sizing = "border-box" + + yield header + + footer = Static("Footer") + footer.styles.dock = "bottom" + footer.styles.height = 1 + footer.styles.background = "green" + footer.styles.color = "white" + + yield footer + + sidebar = Static("Sidebar", id="sidebar") + sidebar.styles.dock = "right" + sidebar.styles.width = 20 + sidebar.styles.height = "100%" + sidebar.styles.background = "magenta" + # sidebar.styles.layer = "sidebar" + + yield sidebar + + for n, color in zip(range(5), ["red", "green", "blue", "yellow", "magenta"]): + thing = Static(f"Thing {n}", id=f"#thing{n}") + thing.styles.background = f"{color} 20%" + thing.styles.height = 5 + yield thing + + +app = DockApp() +if __name__ == "__main__": + app.run() diff --git a/sandbox/will/pred.py b/sandbox/will/pred.py new file mode 100644 index 000000000..95f8cd50f --- /dev/null +++ b/sandbox/will/pred.py @@ -0,0 +1,50 @@ +def partition_will(pred, values): + if not values: + return [], [] + if len(values) == 1: + return ([], values) if pred(values[0]) else (values, []) + values = sorted(values, key=pred) + lower = 0 + upper = len(values) - 1 + index = (lower + upper) // 2 + while True: + value = pred(values[index]) + if value and not pred(values[index - 1]): + return values[:index], values[index:] + if value: + upper = index + else: + lower = index + + index = (lower + upper) // 2 + + +def partition_more_iter(pred, iterable): + """ + Returns a 2-tuple of iterables derived from the input iterable. + The first yields the items that have ``pred(item) == False``. + The second yields the items that have ``pred(item) == True``. + + >>> is_odd = lambda x: x % 2 != 0 + >>> iterable = range(10) + >>> even_items, odd_items = partition(is_odd, iterable) + >>> list(even_items), list(odd_items) + ([0, 2, 4, 6, 8], [1, 3, 5, 7, 9]) + + If *pred* is None, :func:`bool` is used. + + >>> iterable = [0, 1, False, True, '', ' '] + >>> false_items, true_items = partition(None, iterable) + >>> list(false_items), list(true_items) + ([0, False, ''], [1, True, ' ']) + + """ + if pred is None: + pred = bool + + evaluations = ((pred(x), x) for x in iterable) + t1, t2 = tee(evaluations) + return ( + (x for (cond, x) in t1 if not cond), + (x for (cond, x) in t2 if cond), + ) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py new file mode 100644 index 000000000..537371912 --- /dev/null +++ b/src/textual/_arrange.py @@ -0,0 +1,126 @@ +from __future__ import annotations + + +from fractions import Fraction +from typing import TYPE_CHECKING + +from .geometry import Region, Size, Spacing +from ._layout import ArrangeResult, WidgetPlacement +from ._partition import partition + + +if TYPE_CHECKING: + from ._layout import ArrangeResult + from .widget import Widget + + +def arrange(widget: Widget, size: Size, viewport: Size) -> ArrangeResult: + display_children = [child for child in widget.children if child.display] + + arrange_widgets: set[Widget] = set() + + dock_layers: dict[str, list[Widget]] = {} + for child in display_children: + dock_layers.setdefault(child.styles.layer or "default", []).append(child) + + width, height = size + + placements: list[WidgetPlacement] = [] + add_placement = placements.append + region = size.region + + _WidgetPlacement = WidgetPlacement + + top_z = 2**32 - 1 + + scroll_spacing = Spacing() + + for widgets in dock_layers.values(): + + dock_widgets, layout_widgets = partition( + (lambda widget: not widget.styles.dock), widgets + ) + + arrange_widgets.update(dock_widgets) + top = right = bottom = left = 0 + + for dock_widget in dock_widgets: + edge = dock_widget.styles.dock + + ( + widget_width_fraction, + widget_height_fraction, + margin, + ) = dock_widget.get_box_model( + size, + viewport, + Fraction(size.height if edge in ("top", "bottom") else size.width), + ) + + widget_width = int(widget_width_fraction) + margin.width + widget_height = int(widget_height_fraction) + margin.height + + align_offset = dock_widget.styles.align_size( + (widget_width, widget_height), size + ) + + if edge == "bottom": + dock_region = Region( + 0, height - widget_height, widget_width, widget_height + ) + bottom = max(bottom, dock_region.height) + elif edge == "top": + dock_region = Region(0, 0, widget_width, widget_height) + top = max(top, dock_region.height) + elif edge == "left": + dock_region = Region(0, 0, widget_width, widget_height) + left = max(left, dock_region.width) + elif edge == "right": + dock_region = Region( + width - widget_width, 0, widget_width, widget_height + ) + right = max(right, dock_region.width) + + dock_region = dock_region.shrink(margin).translate(align_offset) + add_placement(_WidgetPlacement(dock_region, dock_widget, top_z, True)) + + dock_spacing = Spacing(top, right, bottom, left) + region = size.region.shrink(dock_spacing) + layout_placements, _layout_widgets, spacing = widget.layout.arrange( + widget, layout_widgets, region.size + ) + if _layout_widgets: + scroll_spacing = scroll_spacing.grow_maximum(dock_spacing) + arrange_widgets.update(_layout_widgets) + placement_offset = region.offset + if placement_offset: + layout_placements = [ + _WidgetPlacement(_region + placement_offset, widget, order, fixed) + for _region, widget, order, fixed in layout_placements + ] + + placements.extend(layout_placements) + + result = ArrangeResult(placements, arrange_widgets, scroll_spacing) + return result + + # dock_spacing = Spacing(top, right, bottom, left) + # region = region.shrink(dock_spacing) + + # placements, placement_widgets, spacing = widget.layout.arrange( + # widget, layout_widgets, region.size + # ) + # dock_spacing += spacing + + # placement_offset = region.offset + # if placement_offset: + # placements = [ + # _WidgetPlacement(_region + placement_offset, widget, order, fixed) + # for _region, widget, order, fixed in placements + # ] + + # return ArrangeResult( + # (dock_placements + placements), + # placement_widgets.union(layout_widgets), + # dock_spacing, + # ) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index b44d83190..72b309d89 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -26,7 +26,7 @@ from rich.segment import Segment from rich.style import Style from . import errors -from .geometry import Region, Offset, Size +from .geometry import Region, Offset, Size, Spacing from ._cells import cell_len from ._profile import timer @@ -314,8 +314,7 @@ class Compositor: root (Widget): Top level widget. Returns: - map[dict[Widget, RenderRegion], Size]: A mapping of widget on to render region - and the "virtual size" (scrollable region) + tuple[CompositorMap, set[Widget]]: Compositor map and set of widgets. """ ORIGIN = Offset(0, 0) @@ -364,27 +363,43 @@ class Compositor: if widget.is_container: # Arrange the layout - placements, arranged_widgets = widget._arrange(child_region.size) + placements, arranged_widgets, spacing = widget._arrange( + child_region.size + ) widgets.update(arranged_widgets) placements = sorted(placements, key=get_order) # An offset added to all placements - placement_offset = ( - container_region.offset + layout_offset - widget.scroll_offset - ) + placement_offset = container_region.offset + layout_offset + placement_scroll_offset = placement_offset - widget.scroll_offset + + _layers = widget.layers + layers_to_index = { + layer_name: index for index, layer_name in enumerate(_layers) + } + get_layer_index = layers_to_index.get # Add all the widgets - for sub_region, sub_widget, z in placements: + for sub_region, sub_widget, z, fixed in placements: # Combine regions with children to calculate the "virtual size" - total_region = total_region.union(sub_region) - if sub_widget is not None: - add_widget( - sub_widget, - sub_region, - sub_region + placement_offset, - order + (z,), - sub_clip, - ) + if fixed: + widget_region = sub_region + placement_offset + else: + total_region = total_region.union(sub_region.grow(spacing)) + widget_region = sub_region + placement_scroll_offset + + if sub_widget is None: + continue + + widget_order = order + (get_layer_index(sub_widget.layer, 0), z) + + add_widget( + sub_widget, + sub_region, + widget_region, + widget_order, + sub_clip, + ) # Add any scrollbars for chrome_widget, chrome_region in widget._arrange_scrollbars( diff --git a/src/textual/_layout.py b/src/textual/_layout.py index b4c98f0b3..b8e13d663 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -5,7 +5,7 @@ import sys from typing import ClassVar, NamedTuple, TYPE_CHECKING -from .geometry import Region, Size +from .geometry import Region, Size, Spacing if sys.version_info >= (3, 10): from typing import TypeAlias @@ -16,7 +16,13 @@ else: # pragma: no cover if TYPE_CHECKING: from .widget import Widget -ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" + +class ArrangeResult(NamedTuple): + """The result of an arrange operation.""" + + placements: list[WidgetPlacement] + widgets: set[Widget] + spacing: Spacing = Spacing() class WidgetPlacement(NamedTuple): @@ -25,6 +31,7 @@ class WidgetPlacement(NamedTuple): region: Region widget: Widget | None = None # A widget of None means empty space order: int = 0 + fixed: bool = False class Layout(ABC): @@ -36,7 +43,9 @@ class Layout(ABC): return f"<{self.name}>" @abstractmethod - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: """Generate a layout map that defines where on the screen the widgets will be drawn. Args: diff --git a/src/textual/_partition.py b/src/textual/_partition.py new file mode 100644 index 000000000..3469a3240 --- /dev/null +++ b/src/textual/_partition.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from typing import Callable, Iterable, TypeVar + + +T = TypeVar("T") + + +def partition( + pred: Callable[[T], bool], iterable: Iterable[T] +) -> tuple[list[T], list[T]]: + """Partition a sequence in to two list from a given predicate. The first list will contain + the values where the predicate is False, the second list will contain the remaining values. + + Args: + pred (Callable[[T], bool]): A callable that returns True or False for a given value. + iterable (Iterable[T]): In Iterable of values. + + Returns: + tuple[list[T], list[T]]: A list of values where the predicate is False, and a list + where the predicate is True. + """ + + result: tuple[list[T], list[T]] = ([], []) + appends = (result[0].append, result[1].append) + + for value in iterable: + appends[pred(value)](value) + return result + + +if __name__ == "__main__": + print(partition((lambda n: bool(n % 2)), list(range(20)))) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 0acbfe134..3d7a9a6cd 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -94,7 +94,7 @@ def get_box_model( else: # Explicit height set content_height = styles.height.resolve_dimension( - sizing_container, viewport, fraction_unit + sizing_container - styles.margin.totals, viewport, fraction_unit ) if is_border_box: content_height -= gutter.height diff --git a/src/textual/color.py b/src/textual/color.py index 2647ba80b..813d6bcc6 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -20,6 +20,8 @@ from typing import Callable, NamedTuple import rich.repr from rich.color import Color as RichColor +from rich.color import ColorType +from rich.color_triplet import ColorTriplet from rich.style import Style from rich.text import Text @@ -30,6 +32,9 @@ from ._color_constants import COLOR_NAME_TO_RGB from .geometry import clamp +_TRUECOLOR = ColorType.TRUECOLOR + + class HLS(NamedTuple): """A color in HLS format.""" @@ -132,11 +137,10 @@ class Color(NamedTuple): def __rich__(self) -> Text: """A Rich method to show the color.""" - r, g, b, _ = self return Text( f" {self!r} ", style=Style.from_color( - self.get_contrast_text().rich_color, RichColor.from_rgb(r, g, b) + self.get_contrast_text().rich_color, self.rich_color ), ) @@ -161,9 +165,10 @@ class Color(NamedTuple): @property def rich_color(self) -> RichColor: """This color encoded in Rich's Color class.""" - # TODO: This isn't cheap as I'd like - cache in a LRUCache ? r, g, b, _a = self - return RichColor.from_rgb(r, g, b) + return RichColor( + f"#{r:02X}{g:02X}{b:02X}", _TRUECOLOR, None, ColorTriplet(r, g, b) + ) @property def normalized(self) -> tuple[float, float, float]: @@ -374,7 +379,7 @@ class Color(NamedTuple): Returns: Color: New color. """ - return self.darken(-amount).clamped + return self.darken(-amount) @lru_cache(maxsize=1024) def get_contrast_text(self, alpha=0.95) -> Color: diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index 553b03231..220add7dc 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -503,31 +503,19 @@ def dock_property_help_text(property_name: str, context: StylingContext) -> Help return HelpText( summary=f"Invalid value for [i]{property_name}[/] property", bullets=[ - Bullet("The value must be one of the defined docks"), + Bullet("The value must be one of 'top', 'right', 'bottom' or 'left'"), *ContextSpecificBullets( inline=[ Bullet( - "Attach a widget to a dock declared on the parent", - examples=[ - Example( - f'widget.styles.dock = "left" [dim] # assumes parent widget has declared left dock[/]' - ) - ], + "The 'dock' rule aligns a widget relative to the screen.", + examples=[Example(f'header.styles.dock = "top"')], ) ], css=[ Bullet( - "Define a dock using the [i]docks[/] property", - examples=[ - Example("docks: [u]lhs[/]=left/2;"), - ], - ), - Bullet( - "Then attach a widget to a defined dock using the [i]dock[/] property", - examples=[ - Example("dock: [scope.key][u]lhs[/][/];"), - ], - ), + "The 'dock' rule aligns a widget relative to the screen.", + examples=[Example(f"dock: top")], + ) ], ).get_by_context(context), ], diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 101125bf8..2f285d52f 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -39,6 +39,7 @@ from .scalar import ( Scalar, ScalarOffset, ScalarParseError, + percentage_string_to_float, ) from .transition import Transition from ..geometry import Spacing, SpacingDimensions, clamp @@ -47,7 +48,7 @@ if TYPE_CHECKING: from .._layout import Layout from .styles import DockGroup, Styles, StylesBase -from .types import EdgeType, AlignHorizontal, AlignVertical +from .types import DockEdge, EdgeType, AlignHorizontal, AlignVertical BorderDefinition = ( "Sequence[tuple[EdgeType, str | Color] | None] | tuple[EdgeType, str | Color]" @@ -533,7 +534,9 @@ class DockProperty: the docks themselves, and where they are located on screen. """ - def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> str: + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> DockEdge: """Get the Dock property Args: @@ -543,7 +546,7 @@ class DockProperty: Returns: str: The dock name as a string, or "" if the rule is not set. """ - return obj.get_rule("dock", "_default") + return cast(DockEdge, obj.get_rule("dock", "")) def __set__(self, obj: Styles, dock_name: str | None): """Set the Dock property @@ -839,15 +842,25 @@ class ColorProperty: if obj.set_rule(self.name, color): obj.refresh(children=True) elif isinstance(color, str): - try: - parsed_color = Color.parse(color) - except ColorParseError as error: - raise StyleValueError( - f"Invalid color value '{color}'", - help_text=color_property_help_text( - self.name, context="inline", error=error - ), - ) + alpha = 1.0 + parsed_color = Color(255, 255, 255) + for token in color.split(): + if token.endswith("%"): + try: + alpha = percentage_string_to_float(token) + except ValueError: + raise StyleValueError(f"invalid percentage value '{token}'") + continue + try: + parsed_color = Color.parse(token) + except ColorParseError as error: + raise StyleValueError( + f"Invalid color value '{token}'", + help_text=color_property_help_text( + self.name, context="inline", error=error + ), + ) + parsed_color = parsed_color.with_alpha(alpha) if obj.set_rule(self.name, parsed_color): obj.refresh(children=True) else: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index b0d390573..ba4bb1e67 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -619,14 +619,18 @@ class StylesBuilder: self.styles.text_style = style_definition def process_dock(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return - if len(tokens) > 1: + if len(tokens) > 1 or tokens[0].value not in VALID_EDGE: self.error( name, - tokens[1], + tokens[0], dock_property_help_text(name, context="css"), ) - self.styles._rules["dock"] = tokens[0].value if tokens else "" + + dock = tokens[0].value + self.styles._rules["dock"] = dock def process_docks(self, name: str, tokens: list[Token]) -> None: def docks_error(name, token): diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 333f62d3c..7e21786bd 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -12,7 +12,7 @@ from rich.style import Style from .._animator import Animation, EasingFunction from ..color import Color -from ..geometry import Spacing +from ..geometry import Size, Offset, Spacing from ._style_properties import ( AlignProperty, BorderProperty, @@ -436,6 +436,14 @@ class StylesBase(ABC): offset_y = parent_height - height return offset_y + def align_size(self, child: tuple[int, int], parent: tuple[int, int]) -> Offset: + width, height = child + parent_width, parent_height = parent + return Offset( + self.align_width(width, parent_width), + self.align_height(height, parent_height), + ) + @rich.repr.auto @dataclass diff --git a/src/textual/css/types.py b/src/textual/css/types.py index 206705276..d21fb0cb9 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -12,6 +12,7 @@ else: Edge = Literal["top", "right", "bottom", "left"] +DockEdge = Literal["top", "right", "bottom", "left", ""] EdgeType = Literal[ "", "ascii", diff --git a/src/textual/dom.py b/src/textual/dom.py index 8c8fdadee..3bda0e690 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -347,9 +347,8 @@ class DOMNode(MessagePump): def text_style(self) -> Style: """Get the text style object. - A widget's style is influenced by its parent. For instance if a widgets background has an alpha, - then its parent's background color will show through. Additionally, widgets will inherit their - parent's text style (i.e. bold, italic etc). + A widget's style is influenced by its parent. for instance if a parent is bold, then + the child will also be bold. Returns: Style: Rich Style object. @@ -357,17 +356,18 @@ class DOMNode(MessagePump): # TODO: Feels like there may be opportunity for caching here. - style = Style() - for node in reversed(self.ancestors): - style += node.styles.text_style + style = sum( + [node.styles.text_style for node in reversed(self.ancestors)], start=Style() + ) return style @property def rich_style(self) -> Style: """Get a Rich Style object for this DOMNode.""" - (_, _), (background, color) = self.colors - style = Style.from_color(color.rich_color, background.rich_color) - style += self.text_style + _, (background, color) = self.colors + style = ( + Style.from_color(color.rich_color, background.rich_color) + self.text_style + ) return style @property @@ -386,7 +386,7 @@ class DOMNode(MessagePump): background += styles.background if styles.has_rule("color"): base_color = color - color += styles.color + color = styles.color return (base_background, base_color), (background, color) @property @@ -394,9 +394,9 @@ class DOMNode(MessagePump): """Get a list of Nodes by tracing ancestors all the way back to App.""" nodes: list[DOMNode] = [self] add_node = nodes.append - node = self + node: DOMNode = self while True: - node = node.parent + node = node._parent if node is None: break add_node(node) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 73498d8ee..620d2419b 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -883,5 +883,23 @@ class Spacing(NamedTuple): ) return NotImplemented + def grow_maximum(self, other: Spacing) -> Spacing: + """Grow spacing with a maximum. + + Args: + other (Spacing): Spacing object. + + Returns: + Spacing: New spacing were the values are maximum of the two values. + """ + top, right, bottom, left = self + other_top, other_right, other_bottom, other_left = other + return Spacing( + max(top, other_top), + max(right, other_right), + max(bottom, other_bottom), + max(left, other_left), + ) + NULL_OFFSET = Offset(0, 0) diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index e1ce2b7c2..5b39d477b 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -4,11 +4,11 @@ import sys from collections import defaultdict from dataclasses import dataclass from operator import attrgetter -from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence +from typing import TYPE_CHECKING, NamedTuple, Sequence from .._layout_resolve import layout_resolve from ..css.types import Edge -from ..geometry import Offset, Region, Size +from ..geometry import Region, Size from .._layout import ArrangeResult, Layout, WidgetPlacement from ..widget import Widget @@ -52,9 +52,9 @@ class DockLayout(Layout): def __repr__(self): return "" - def get_docks(self, parent: Widget) -> list[Dock]: + def get_docks(self, parent: Widget, children: list[Widget]) -> list[Dock]: groups: dict[str, list[Widget]] = defaultdict(list) - for child in parent.displayed_children: + for child in children: assert isinstance(child, Widget) groups[child.styles.dock].append(child) docks: list[Dock] = [] @@ -63,13 +63,15 @@ class DockLayout(Layout): append_dock(Dock(edge, groups[name], z)) return docks - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: width, height = size layout_region = Region(0, 0, width, height) layers: dict[int, Region] = defaultdict(lambda: layout_region) - docks = self.get_docks(parent) + docks = self.get_docks(parent, children) def make_dock_options(widget: Widget, edge: Edge) -> DockOptions: styles = widget.styles @@ -181,4 +183,4 @@ class DockLayout(Layout): layers[z] = region - return placements, arranged_widgets + return ArrangeResult(placements, arranged_widgets) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 251e93838..c2749b9ac 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -16,7 +16,9 @@ class HorizontalLayout(Layout): name = "horizontal" - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: placements: list[WidgetPlacement] = [] add_placement = placements.append @@ -24,7 +26,6 @@ class HorizontalLayout(Layout): x = max_width = max_height = Fraction(0) parent_size = parent.outer_size - children = list(parent.children) styles = [child.styles for child in children if child.styles.width is not None] total_fraction = sum( [int(style.width.value) for style in styles if style.width.is_fraction] @@ -33,7 +34,7 @@ class HorizontalLayout(Layout): box_models = [ widget.get_box_model(size, parent_size, fraction_unit) - for widget in cast("list[Widget]", parent.children) + for widget in cast("list[Widget]", children) ] margins = [ diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index f8ed4f152..93f14ff24 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -15,14 +15,15 @@ class VerticalLayout(Layout): name = "vertical" - def arrange(self, parent: Widget, size: Size) -> ArrangeResult: + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: placements: list[WidgetPlacement] = [] add_placement = placements.append parent_size = parent.outer_size - children = list(parent.children) styles = [child.styles for child in children if child.styles.height is not None] total_fraction = sum( [int(style.height.value) for style in styles if style.height.is_fraction] @@ -31,7 +32,7 @@ class VerticalLayout(Layout): box_models = [ widget.get_box_model(size, parent_size, fraction_unit) - for widget in parent.children + for widget in children ] margins = [ @@ -43,8 +44,7 @@ class VerticalLayout(Layout): y = Fraction(box_models[0].margin.top if box_models else 0) - displayed_children = cast("list[Widget]", parent.displayed_children) - for widget, box_model, margin in zip(displayed_children, box_models, margins): + for widget, box_model, margin in zip(children, box_models, margins): content_width, content_height, box_margin = box_model offset_x = ( widget.styles.align_width( @@ -60,4 +60,4 @@ class VerticalLayout(Layout): total_region = Region(0, 0, size.width, int(y)) add_placement(WidgetPlacement(total_region, None, 0)) - return placements, set(displayed_children) + return ArrangeResult(placements, set(children)) diff --git a/src/textual/widget.py b/src/textual/widget.py index 74ffa4884..e947b1171 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -10,6 +10,7 @@ from typing import ( Collection, Iterable, NamedTuple, + Tuple, ) import rich.repr @@ -24,7 +25,7 @@ from rich.text import Text from . import errors, events, messages from ._animator import BoundAnimator -from ._border import Border +from ._arrange import arrange from ._context import active_app from ._layout import ArrangeResult, Layout from ._segment_tools import line_crop @@ -36,8 +37,6 @@ from .geometry import Offset, Region, Size, Spacing, clamp from .layouts.vertical import VerticalLayout from .message import Message from .reactive import Reactive, watch -from .renderables.opacity import Opacity -from .renderables.tint import Tint if TYPE_CHECKING: from .app import App, ComposeResult @@ -116,7 +115,7 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement: ArrangeResult | None = None + self._arrangement: Tuple[ArrangeResult, Spacing] | None = None self._arrangement_cache_key: tuple[int, Size] = (-1, Size()) self._styles_cache = StylesCache() @@ -146,14 +145,17 @@ class Widget(DOMNode): Returns: ArrangeResult: Widget locations. """ + arrange_cache_key = (self.children._updates, size) if ( self._arrangement is not None and arrange_cache_key == self._arrangement_cache_key ): return self._arrangement - self._arrangement = self.layout.arrange(self, size) self._arrangement_cache_key = (self.children._updates, size) + + self._arrangement = arrange(self, size, self.screen.size) + return self._arrangement def _clear_arrangement_cache(self) -> None: @@ -542,6 +544,25 @@ class Widget(DOMNode): """ return self.is_container + @property + def layer(self) -> str: + """Get the name of this widgets layer.""" + return self.styles.layer or "default" + + @property + def layers(self) -> tuple[str, ...]: + """Layers of from parent. + + Returns: + tuple[str, ...]: Tuple of layer names. + """ + for node in self.ancestors: + if not isinstance(node, Widget): + break + if node.styles.has_rule("layers"): + return node.styles.layers + return ("default",) + def _set_dirty(self, *regions: Region) -> None: """Set the Widget as 'dirty' (requiring re-paint). diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index 0e4c9b76a..7d617e81f 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -7,6 +7,12 @@ from ..widget import Widget class Static(Widget): + CSS = """ + Static { + height: auto; + } + """ + def __init__( self, renderable: RenderableType = "",