ai-station/.venv/lib/python3.12/site-packages/textual/layout.py

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