315 lines
9.7 KiB
Python
315 lines
9.7 KiB
Python
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
|