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

376 lines
13 KiB
Python

from __future__ import annotations
from fractions import Fraction
from typing import TYPE_CHECKING, Iterable
from textual._resolve import resolve
from textual.css.scalar import Scalar
from textual.geometry import NULL_OFFSET, Region, Size, Spacing
from textual.layout import ArrangeResult, Layout, WidgetPlacement
from textual.visual import visualize
if TYPE_CHECKING:
from textual.widget import Widget
class GridLayout(Layout):
"""Used to layout Widgets into a grid."""
name = "grid"
def __init__(self) -> None:
self.min_column_width: int | None = None
"""Maintain a minimum column width, or `None` for no minimum."""
self.max_column_width: int | None = None
"""Maintain a maximum column width, or `None` for no maximum."""
self.stretch_height: bool = False
"""Stretch the height of cells to be equal in each row."""
self.regular: bool = False
"""Grid should be regular (no remainder in last row)."""
self.expand: bool = False
"""Expand the grid to fit the container if it is smaller."""
self.shrink: bool = False
"""Shrink the grid to fit the container if it is larger."""
self.auto_minimum: bool = False
"""If self.shrink is `True`, auto-detect and limit the width."""
self._grid_size: tuple[int, int] | None = None
"""Grid size after last arrange call."""
@property
def grid_size(self) -> tuple[int, int] | None:
"""The grid size after the last arrange call.
Returns:
A tuple of (WIDTH, HEIGHT) or `None` prior to the first `arrange`.
"""
return self._grid_size
def arrange(
self, parent: Widget, children: list[Widget], size: Size, greedy: bool = True
) -> ArrangeResult:
parent.pre_layout(self)
styles = parent.styles
row_scalars = styles.grid_rows or (
[Scalar.parse("1fr")]
if (size.height and not parent.styles.is_auto_height)
else [Scalar.parse("auto")]
)
column_scalars = styles.grid_columns or [Scalar.parse("1fr")]
gutter_horizontal = styles.grid_gutter_horizontal
gutter_vertical = styles.grid_gutter_vertical
table_size_columns = max(1, styles.grid_size_columns)
min_column_width = self.min_column_width
max_column_width = self.max_column_width
container_width = size.width
if max_column_width is not None:
container_width = (
max(1, min(len(children), (container_width // max_column_width)))
* max_column_width
)
size = Size(container_width, size.height)
if min_column_width is not None:
table_size_columns = max(
1,
(container_width + gutter_horizontal)
// (min_column_width + gutter_horizontal),
)
table_size_columns = min(table_size_columns, len(children))
if self.regular:
while len(children) % table_size_columns and table_size_columns > 1:
table_size_columns -= 1
table_size_rows = styles.grid_size_rows
viewport = parent.app.viewport_size
keyline_style, _keyline_color = styles.keyline
offset = (0, 0)
gutter_spacing: Spacing | None
if keyline_style == "none":
gutter_spacing = None
else:
size -= (2, 2)
offset = (1, 1)
gutter_spacing = Spacing(
gutter_vertical,
gutter_horizontal,
gutter_vertical,
gutter_horizontal,
)
def cell_coords(column_count: int) -> Iterable[tuple[int, int]]:
"""Iterate over table coordinates ad infinitum.
Args:
column_count: Number of columns
"""
row = 0
while True:
for column in range(column_count):
yield (column, row)
row += 1
def widget_coords(
column_start: int, row_start: int, columns: int, rows: int
) -> set[tuple[int, int]]:
"""Get coords occupied by a cell.
Args:
column_start: Start column.
row_start: Start_row.
columns: Number of columns.
rows: Number of rows.
Returns:
Set of coords.
"""
return {
(column, row)
for column in range(column_start, column_start + columns)
for row in range(row_start, row_start + rows)
}
def repeat_scalars(scalars: Iterable[Scalar], count: int) -> list[Scalar]:
"""Repeat an iterable of scalars as many times as required to return
a list of `count` values.
Args:
scalars: Iterable of values.
count: Number of values to return.
Returns:
A list of values.
"""
limited_values = list(scalars)[:]
while len(limited_values) < count:
limited_values.extend(scalars)
return limited_values[:count]
cell_map: dict[tuple[int, int], tuple[Widget, bool]] = {}
cell_size_map: dict[Widget, tuple[int, int, int, int]] = {}
next_coord = iter(cell_coords(table_size_columns)).__next__
cell_coord = (0, 0)
column = row = 0
for child in children:
child_styles = child.styles
column_span = child_styles.column_span or 1
row_span = child_styles.row_span or 1
# Find a slot where this cell fits
# A cell on a previous row may have a row span
while True:
column, row = cell_coord
coords = widget_coords(column, row, column_span, row_span)
if cell_map.keys().isdisjoint(coords):
for coord in coords:
cell_map[coord] = (child, coord == cell_coord)
cell_size_map[child] = (
column,
row,
column_span - 1,
row_span - 1,
)
break
else:
cell_coord = next_coord()
continue
cell_coord = next_coord()
column_scalars = repeat_scalars(column_scalars, table_size_columns)
table_size_rows = table_size_rows if table_size_rows else row + 1
row_scalars = repeat_scalars(row_scalars, table_size_rows)
self._grid_size = (table_size_columns, table_size_rows)
def apply_width_limits(widget: Widget, width: int) -> int:
"""Apply min and max widths to dimension.
Args:
widget: A Widget.
width: A width.
Returns:
New width.
"""
styles = widget.styles
if styles.min_width is not None:
width = max(
width,
int(styles.min_width.resolve(size, viewport, Fraction(width))),
)
if styles.max_width is not None:
width = min(
width,
int(styles.max_width.resolve(size, viewport, Fraction(width))),
)
return width
def apply_height_limits(widget: Widget, height: int) -> int:
"""Apply min and max height to a dimension.
Args:
widget: A widget.
height: A height.
Returns:
New height
"""
styles = widget.styles
if styles.min_height is not None:
height = max(
height,
int(styles.min_height.resolve(size, viewport, Fraction(height))),
)
if styles.max_height is not None:
height = min(
height,
int(styles.max_height.resolve(size, viewport, Fraction(height))),
)
return height
# Handle any auto columns
for column, scalar in enumerate(column_scalars):
if scalar.is_auto:
width = 0.0
for row in range(len(row_scalars)):
coord = (column, row)
try:
widget, _ = cell_map[coord]
except KeyError:
pass
else:
if widget.styles.column_span != 1:
continue
width = max(
width,
apply_width_limits(
widget,
widget.get_content_width(size, viewport)
+ widget.styles.gutter.width,
),
)
column_scalars[column] = Scalar.from_number(width)
column_minimums: list[int] | None = None
if self.auto_minimum and self.shrink:
column_minimums = [1] * table_size_columns
for column_index in range(table_size_columns):
for row_index in range(len(row_scalars)):
if (
cell_info := cell_map.get((column_index, row_index))
) is not None:
widget = cell_info[0]
column_minimums[column_index] = max(
visualize(widget, widget.render()).get_minimal_width(
widget.styles
)
+ widget.styles.gutter.width,
column_minimums[column_index],
)
columns = resolve(
column_scalars,
size.width,
gutter_vertical,
size,
viewport,
expand=self.expand,
shrink=self.shrink,
minimums=column_minimums,
)
# Handle any auto rows
for row, scalar in enumerate(row_scalars):
if scalar.is_auto:
height = 0.0
for column in range(len(column_scalars)):
coord = (column, row)
try:
widget, _ = cell_map[coord]
except KeyError:
pass
else:
if widget.styles.row_span != 1:
continue
column_width = columns[column][1]
gutter_width, gutter_height = widget.styles.gutter.totals
widget_height = apply_height_limits(
widget,
widget.get_content_height(
size,
viewport,
column_width - gutter_width,
)
+ gutter_height,
)
height = max(height, widget_height)
row_scalars[row] = Scalar.from_number(height)
rows = resolve(row_scalars, size.height, gutter_horizontal, size, viewport)
placements: list[WidgetPlacement] = []
_WidgetPlacement = WidgetPlacement
add_placement = placements.append
max_column = len(columns) - 1
max_row = len(rows) - 1
for widget, (column, row, column_span, row_span) in cell_size_map.items():
x = columns[column][0]
if row > max_row:
break
y = rows[row][0]
x2, cell_width = columns[min(max_column, column + column_span)]
y2, cell_height = rows[min(max_row, row + row_span)]
cell_size = Size(cell_width + x2 - x, cell_height + y2 - y)
box_width, box_height, margin = widget._get_box_model(
cell_size,
viewport,
Fraction(cell_size.width),
Fraction(cell_size.height),
constrain_width=True,
greedy=greedy,
)
if self.stretch_height and len(children) > 1:
if box_height <= cell_size.height:
box_height = Fraction(cell_size.height)
region = (
Region(
x, y, int(box_width + margin.width), int(box_height + margin.height)
)
.crop_size(cell_size)
.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"
)
add_placement(
_WidgetPlacement(
region,
placement_offset,
(
margin
if gutter_spacing is None
else margin.grow_maximum(gutter_spacing)
),
widget,
absolute,
)
)
return placements