from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, Iterable, NamedTuple from textual._spatial_map import SpatialMap from textual.canvas import Canvas, Rectangle from textual.geometry import Offset, Region, Size, Spacing from textual.strip import StripRenderable if TYPE_CHECKING: from typing_extensions import TypeAlias from textual.widget import Widget ArrangeResult: TypeAlias = "list[WidgetPlacement]" @dataclass class DockArrangeResult: """Result of [Layout.arrange][textual.layout.Layout.arrange].""" placements: list[WidgetPlacement] """A `WidgetPlacement` for every widget to describe its location on screen.""" widgets: set[Widget] """A set of widgets in the arrangement.""" scroll_spacing: Spacing """Spacing to reduce scrollable area.""" _spatial_map: SpatialMap[WidgetPlacement] | None = None """A Spatial map to query widget placements.""" @property def spatial_map(self) -> SpatialMap[WidgetPlacement]: """A lazy-calculated spatial map.""" if self._spatial_map is None: self._spatial_map = SpatialMap() self._spatial_map.insert( ( placement.region.grow(placement.margin), placement.offset, placement.fixed, placement.overlay, placement, ) for placement in self.placements ) return self._spatial_map @property def total_region(self) -> Region: """The total area occupied by the arrangement. Returns: A Region. """ _top, right, bottom, _left = self.scroll_spacing return self.spatial_map.total_region.grow((0, right, bottom, 0)) def get_visible_placements(self, region: Region) -> list[WidgetPlacement]: """Get the placements visible within the given region. Args: region: A region. Returns: Set of placements. """ if self.total_region in region: # Short circuit for when we want all the placements return self.placements visible_placements = self.spatial_map.get_values_in_region(region) overlaps = region.overlaps culled_placements = [ placement for placement in visible_placements if placement.fixed or overlaps(placement.region + placement.offset) ] return culled_placements class WidgetPlacement(NamedTuple): """The position, size, and relative order of a widget within its parent.""" region: Region offset: Offset margin: Spacing widget: Widget order: int = 0 fixed: bool = False overlay: bool = False absolute: bool = False @property def reset_origin(self) -> WidgetPlacement: """Reset the origin in the placement (moves it to (0, 0)).""" return self._replace(region=self.region.reset_offset) @classmethod def translate( cls, placements: list[WidgetPlacement], translate_offset: Offset ) -> list[WidgetPlacement]: """Move all non-absolute placements by a given offset. Args: placements: List of placements. offset: Offset to add to placements. Returns: Placements with adjusted region, or same instance if offset is null. """ if translate_offset: return [ cls( ( region + translate_offset if layout_widget.absolute_offset is None else region ), offset, margin, layout_widget, order, fixed, overlay, absolute, ) for region, offset, margin, layout_widget, order, fixed, overlay, absolute in placements ] return placements @classmethod def apply_absolute(cls, placements: list[WidgetPlacement]) -> None: """Applies absolute offsets (in place). Args: placements: A list of placements. """ for index, placement in enumerate(placements): if placement.absolute: placements[index] = placement.reset_origin @classmethod def get_bounds(cls, placements: Iterable[WidgetPlacement]) -> Region: """Get a bounding region around all placements. Args: placements: A number of placements. Returns: An optimal binding box around all placements. """ bounding_region = Region.from_union( [placement.region.grow(placement.margin) for placement in placements] ) return bounding_region def process_offset( self, constrain_region: Region, absolute_offset: Offset ) -> WidgetPlacement: """Apply any absolute offset or constrain rules to the placement. Args: constrain_region: The container region when applying constrain rules. absolute_offset: Default absolute offset that moves widget into screen coordinates. Returns: Processes placement, may be the same instance. """ widget = self.widget styles = widget.styles if not widget.absolute_offset and not styles.has_any_rules( "constrain_x", "constrain_y" ): # Bail early if there is nothing to do return self region = self.region margin = self.margin if widget.absolute_offset is not None: region = region.at_offset( widget.absolute_offset + margin.top_left - absolute_offset ) region = region.translate(self.offset).constrain( styles.constrain_x, styles.constrain_y, self.margin, constrain_region - absolute_offset, ) offset = region.offset - self.region.offset if offset != self.offset: region, _offset, margin, widget, order, fixed, overlay, absolute = self placement = WidgetPlacement( region, offset, margin, widget, order, fixed, overlay, absolute ) return placement return self class Layout(ABC): """Base class of the object responsible for arranging Widgets within a container.""" name: ClassVar[str] = "" def __repr__(self) -> str: return f"<{self.name}>" @abstractmethod def arrange( self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True, ) -> ArrangeResult: """Generate a layout map that defines where on the screen the widgets will be drawn. Args: parent: Parent widget. size: Size of container. Returns: An iterable of widget location """ def get_content_width(self, widget: Widget, container: Size, viewport: Size) -> int: """Get the optimal content width by arranging children. Args: widget: The container widget. container: The container size. viewport: The viewport size. Returns: Width of the content. """ if not widget._nodes: width = 0 else: arrangement = widget.arrange( Size(0 if widget.shrink else container.width, 0), optimal=True, ) width = arrangement.total_region.right return width def get_content_height( self, widget: Widget, container: Size, viewport: Size, width: int ) -> int: """Get the content height. Args: widget: The container widget. container: The container size. viewport: The viewport. width: The content width. Returns: 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 ): # An exception for containers with all dynamic height widgets arrangement = widget.arrange(Size(width, container.height)) else: arrangement = widget.arrange(Size(width, 0)) height = arrangement.total_region.height else: height = 0 return height def render_keyline(self, container: Widget) -> StripRenderable: """Render keylines around all widgets. Args: container: The container widget. Returns: A renderable to draw the keylines. """ width, height = container.outer_size canvas = Canvas(width, height) line_style, keyline_color = container.styles.keyline if keyline_color: keyline_color = container.background_colors[0] + keyline_color container_offset = container.content_region.offset def get_rectangle(region: Region) -> Rectangle: """Get a canvas Rectangle that wraps a region. Args: region: Widget region. Returns: A Rectangle that encloses the widget. """ offset = region.offset - container_offset - (1, 1) width, height = region.size return Rectangle(offset, width + 2, height + 2, keyline_color, line_style) primitives = [ get_rectangle(widget.region) for widget in container.children if widget.visible ] canvas_renderable = canvas.render(primitives, container.rich_style) return canvas_renderable