Merge pull request #712 from Textualize/table-layout

table layout
This commit is contained in:
Will McGugan
2022-08-31 08:48:57 +01:00
committed by GitHub
28 changed files with 709 additions and 530 deletions

View File

@@ -0,0 +1,35 @@
Screen {
overflow: auto;
}
#calculator {
layout: table;
table-size: 4;
table-gutter: 1 2;
table-columns: 1fr;
table-rows: 2fr 1fr 1fr 1fr 1fr 1fr;
margin: 1 2;
min-height:26;
min-width: 50;
}
Button {
width: 100%;
height: 100%;
}
.display {
column-span: 4;
content-align: right middle;
padding: 0 1;
height: 100%;
background: $panel-darken-2;
}
.special {
tint: $text-panel 20%;
}
.zero {
column-span: 2;
}

View File

@@ -0,0 +1,36 @@
from textual.app import App
from textual.layout import Container
from textual.widgets import Button, Static
class CalculatorApp(App):
def compose(self):
yield Container(
Static("0", classes="display"),
Button("AC", classes="special"),
Button("+/-", classes="special"),
Button("%", classes="special"),
Button("÷", variant="warning"),
Button("7"),
Button("8"),
Button("9"),
Button("×", variant="warning"),
Button("4"),
Button("5"),
Button("6"),
Button("-", variant="warning"),
Button("1"),
Button("2"),
Button("3"),
Button("+", variant="warning"),
Button("0", classes="operator zero"),
Button("."),
Button("=", variant="warning"),
id="calculator",
)
app = CalculatorApp(css_path="calculator.css")
if __name__ == "__main__":
app.run()

12
sandbox/will/spacing.css Normal file
View File

@@ -0,0 +1,12 @@
Screen {
overflow: auto;
}
Static {
background: blue 20%;
height: 100%;
margin: 2 4;
min-width: 30;
}

10
sandbox/will/spacing.py Normal file
View File

@@ -0,0 +1,10 @@
from textual.app import App
from textual.widgets import Static
class SpacingApp(App):
def compose(self):
yield Static()
app = SpacingApp(css_path="spacing.css")

View File

@@ -0,0 +1,23 @@
Screen {
layout: table;
table-columns: 2fr 1fr 1fr;
table-rows: 1fr 1fr;
table-gutter: 1 2;
}
Static {
border: solid white;
background: blue 20%;
height: 100%;
width: 100%;
}
#foo {
row-span: 2;
}
#last {
column-span: 3;
margin: 1;
}

View File

@@ -0,0 +1,19 @@
from textual.app import App
from textual.widgets import Static
class TableLayoutApp(App):
def compose(self):
yield Static("foo", id="foo")
yield Static("bar")
yield Static("baz")
yield Static("foo")
yield Static("bar")
yield Static("baz", id="last")
app = TableLayoutApp(css_path="table_layout.css")
if __name__ == "__main__":
app.run()

View File

@@ -48,7 +48,7 @@ def arrange(
_WidgetPlacement = WidgetPlacement
top_z = TOP_Z
scroll_spacing = Spacing()
null_spacing = Spacing()
get_dock = attrgetter("styles.dock")
for widgets in dock_layers.values():
@@ -94,7 +94,9 @@ def arrange(
(widget_width, widget_height), size
)
dock_region = dock_region.shrink(margin).translate(align_offset)
add_placement(_WidgetPlacement(dock_region, dock_widget, top_z, True))
add_placement(
_WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True)
)
dock_spacing = Spacing(top, right, bottom, left)
region = size.region.shrink(dock_spacing)
@@ -109,9 +111,9 @@ def arrange(
if placement_offset:
layout_placements = [
_WidgetPlacement(
_region + placement_offset, layout_widget, order, fixed
_region + placement_offset, margin, layout_widget, order, fixed
)
for _region, layout_widget, order, fixed in layout_placements
for _region, margin, layout_widget, order, fixed in layout_placements
]
placements.extend(layout_placements)

View File

@@ -400,17 +400,16 @@ class Compositor:
get_layer_index = layers_to_index.get
# Add all the widgets
for sub_region, sub_widget, z, fixed in placements:
for sub_region, margin, sub_widget, z, fixed in placements:
# Combine regions with children to calculate the "virtual size"
if fixed:
widget_region = sub_region + placement_offset
else:
total_region = total_region.union(sub_region.grow(spacing))
total_region = total_region.union(
sub_region.grow(spacing + margin)
)
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(

View File

@@ -25,7 +25,8 @@ class WidgetPlacement(NamedTuple):
"""The position, size, and relative order of a widget within its parent."""
region: Region
widget: Widget | None = None # A widget of None means empty space
margin: Spacing
widget: Widget
order: int = 0
fixed: bool = False

73
src/textual/_resolve.py Normal file
View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from fractions import Fraction
from itertools import accumulate
from typing import cast, Sequence
from .css.scalar import Scalar
from .geometry import Size
def resolve(
dimensions: Sequence[Scalar],
total: int,
gutter: int,
size: Size,
viewport: Size,
) -> list[tuple[int, int]]:
"""Resolve a list of dimensions.
Args:
dimensions (Sequence[Scalar]): Scalars for column / row sizes.
total (int): Total space to divide.
gutter (int): Gutter between rows / columns.
size (Size): Size of container.
viewport (Size): Size of viewport.
Returns:
list[tuple[int, int]]: List of (<OFFSET>, <LENGTH>)
"""
resolved: list[tuple[Scalar, Fraction | None]] = [
(
(scalar, None)
if scalar.is_fraction
else (scalar, scalar.resolve_dimension(size, viewport))
)
for scalar in dimensions
]
from_float = Fraction.from_float
total_fraction = from_float(
sum(scalar.value for scalar, fraction in resolved if fraction is None)
)
if total_fraction:
total_gutter = gutter * (len(dimensions) - 1)
consumed = sum(fraction for _, fraction in resolved if fraction is not None)
remaining = max(Fraction(0), Fraction(total - total_gutter) - consumed)
fraction_unit = Fraction(remaining, total_fraction)
resolved_fractions = [
from_float(scalar.value) * fraction_unit if fraction is None else fraction
for scalar, fraction in resolved
]
else:
resolved_fractions = cast(
"list[Fraction]", [fraction for _, fraction in resolved]
)
fraction_gutter = Fraction(gutter)
offsets = [0] + [
int(fraction)
for fraction in accumulate(
value
for fraction in resolved_fractions
for value in (fraction, fraction_gutter)
)
]
results = [
(offset1, offset2 - offset1)
for offset1, offset2 in zip(offsets[::2], offsets[1::2])
]
return results

View File

@@ -608,7 +608,29 @@ def scrollbar_size_single_axis_help_text(property_name: str) -> HelpText:
summary=f"Invalid value for [i]{property_name}[/]",
bullets=[
Bullet(
markup=f"The [i]{property_name}[/] property can only be set to a positive integer, greather than zero",
markup=f"The [i]{property_name}[/] property can only be set to a positive integer, greater than zero",
examples=[
Example(f"{property_name}: 2;"),
],
),
],
)
def integer_help_text(property_name: str) -> HelpText:
"""Help text to show when the user supplies an invalid integer value.
Args:
property_name (str): The name of the property
Returns:
HelpText: Renderable for displaying the help text for this property
"""
return HelpText(
summary=f"Invalid value for [i]{property_name}[/]",
bullets=[
Bullet(
markup=f"An integer value is expected here",
examples=[
Example(f"{property_name}: 2;"),
],
@@ -732,3 +754,12 @@ def style_flags_property_help_text(
).get_by_context(context),
],
)
def table_rows_or_columns_help_text(
property_name: str, value: str, context: StylingContext
):
property_name = _contextualize_property_name(property_name, context)
return HelpText(
summary=f"Invalid value '{value}' in [i]{property_name}[/] property"
)

View File

@@ -194,6 +194,43 @@ class ScalarProperty:
obj.refresh(layout=True)
class ScalarListProperty:
def __set_name__(self, owner: Styles, name: str) -> None:
self.name = name
def __get__(
self, obj: StylesBase, objtype: type[StylesBase] | None = None
) -> tuple[Scalar, ...] | None:
value = obj.get_rule(self.name)
return value
def __set__(
self, obj: StylesBase, value: str | Iterable[str | float] | None
) -> None:
if value is None:
obj.clear_rule(self.name)
obj.refresh(layout=True)
return
parse_values: Iterable[str | float]
if isinstance(value, str):
parse_values = value.split()
else:
parse_values = value
scalars = []
for parse_value in parse_values:
if isinstance(parse_value, (int, float)):
scalars.append(Scalar.from_number(parse_value))
else:
scalars.append(
Scalar.parse(parse_value)
if isinstance(parse_value, str)
else parse_value
)
if obj.set_rule(self.name, tuple(scalars)):
obj.refresh(layout=True)
class BoxProperty:
"""Descriptor for getting and setting outlines and borders along a single edge.
For example "border-right", "outline-bottom", etc.

View File

@@ -8,22 +8,24 @@ import rich.repr
from ._error_tools import friendly_list
from ._help_renderables import HelpText
from ._help_text import (
spacing_invalid_value_help_text,
spacing_wrong_number_of_values_help_text,
scalar_help_text,
color_property_help_text,
string_enum_help_text,
align_help_text,
border_property_help_text,
layout_property_help_text,
color_property_help_text,
dock_property_help_text,
fractional_property_help_text,
align_help_text,
integer_help_text,
layout_property_help_text,
offset_property_help_text,
offset_single_axis_help_text,
style_flags_property_help_text,
property_invalid_value_help_text,
scalar_help_text,
scrollbar_size_property_help_text,
scrollbar_size_single_axis_help_text,
spacing_invalid_value_help_text,
spacing_wrong_number_of_values_help_text,
string_enum_help_text,
style_flags_property_help_text,
table_rows_or_columns_help_text,
text_align_help_text,
)
from .constants import (
@@ -841,6 +843,102 @@ class StylesBuilder:
self.error(name, token, scrollbar_size_single_axis_help_text(name))
self.styles._rules["scrollbar_size_horizontal"] = value
def _process_table_rows_or_columns(self, name: str, tokens: list[Token]) -> None:
scalars: list[Scalar] = []
for token in tokens:
if token.name == "number":
scalars.append(Scalar.from_number(float(token.value)))
elif token.name == "scalar":
scalars.append(
Scalar.parse(
token.value,
percent_unit=Unit.WIDTH if name == "rows" else Unit.HEIGHT,
)
)
else:
self.error(
name,
token,
table_rows_or_columns_help_text(name, token.value, context="css"),
)
self.styles._rules[name.replace("-", "_")] = scalars
process_table_rows = _process_table_rows_or_columns
process_table_columns = _process_table_rows_or_columns
def _process_integer(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) != 1:
self.error(name, tokens[0], integer_help_text(name))
else:
token = tokens[0]
if token.name != "number" or not token.value.isdigit():
self.error(name, token, integer_help_text(name))
value = int(token.value)
if value == 0:
self.error(name, token, integer_help_text(name))
self.styles._rules[name.replace("-", "_")] = value
process_table_gutter_horizontal = _process_integer
process_table_gutter_vertical = _process_integer
process_column_span = _process_integer
process_row_span = _process_integer
process_table_size_columns = _process_integer
process_table_size_rows = _process_integer
def process_table_gutter(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) == 1:
token = tokens[0]
if token.name != "number":
self.error(name, token, integer_help_text(name))
value = max(0, int(token.value))
self.styles._rules["table_gutter_horizontal"] = value
self.styles._rules["table_gutter_vertical"] = value
elif len(tokens) == 2:
token = tokens[0]
if token.name != "number":
self.error(name, token, integer_help_text(name))
value = max(0, int(token.value))
self.styles._rules["table_gutter_horizontal"] = value
token = tokens[1]
if token.name != "number":
self.error(name, token, integer_help_text(name))
value = max(0, int(token.value))
self.styles._rules["table_gutter_vertical"] = value
else:
self.error(name, tokens[0], "expected two integers here")
def process_table_size(self, name: str, tokens: list[Token]) -> None:
if not tokens:
return
if len(tokens) == 1:
token = tokens[0]
if token.name != "number":
self.error(name, token, integer_help_text(name))
value = max(0, int(token.value))
self.styles._rules["table_size_columns"] = value
self.styles._rules["table_size_rows"] = 0
elif len(tokens) == 2:
token = tokens[0]
if token.name != "number":
self.error(name, token, integer_help_text(name))
value = max(0, int(token.value))
self.styles._rules["table_size_columns"] = value
token = tokens[1]
if token.name != "number":
self.error(name, token, integer_help_text(name))
value = max(0, int(token.value))
self.styles._rules["table_size_rows"] = value
else:
self.error(name, tokens[0], "expected two integers here")
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
"""
Returns a valid CSS property "Python" name, or None if no close matches could be found.

View File

@@ -32,7 +32,7 @@ VALID_BORDER: Final[set[EdgeType]] = {
"wide",
}
VALID_EDGE: Final = {"top", "right", "bottom", "left"}
VALID_LAYOUT: Final = {"vertical", "horizontal", "center"}
VALID_LAYOUT: Final = {"vertical", "horizontal", "center", "table"}
VALID_BOX_SIZING: Final = {"border-box", "content-box"}
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}

View File

@@ -27,6 +27,7 @@ from ._style_properties import (
NameListProperty,
NameProperty,
OffsetProperty,
ScalarListProperty,
ScalarProperty,
SpacingProperty,
StringEnumProperty,
@@ -144,6 +145,16 @@ class RulesMap(TypedDict, total=False):
content_align_horizontal: AlignHorizontal
content_align_vertical: AlignVertical
table_size_rows: int
table_size_columns: int
table_gutter_horizontal: int
table_gutter_vertical: int
table_rows: tuple[Scalar, ...]
table_columns: tuple[Scalar, ...]
row_span: int
column_span: int
text_align: TextAlign
@@ -253,6 +264,17 @@ class StylesBase(ABC):
content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
content_align = AlignProperty()
table_rows = ScalarListProperty()
table_columns = ScalarListProperty()
table_size_columns = IntegerProperty(default=1, layout=True)
table_size_rows = IntegerProperty(default=0, layout=True)
table_gutter_horizontal = IntegerProperty(default=0, layout=True)
table_gutter_vertical = IntegerProperty(default=0, layout=True)
row_span = IntegerProperty(default=1, layout=True)
column_span = IntegerProperty(default=1, layout=True)
text_align = StringEnumProperty(VALID_TEXT_ALIGN, "start")
def __eq__(self, styles: object) -> bool:
@@ -780,6 +802,30 @@ class Styles(StylesBase):
)
elif has_rule("content_align_vertical"):
append_declaration("content-align-vertical", self.content_align_vertical)
elif has_rule("table_columns"):
append_declaration(
"table-columns",
" ".join(str(scalar) for scalar in self.table_columns or ()),
)
elif has_rule("table_rows"):
append_declaration(
"table-rows",
" ".join(str(scalar) for scalar in self.table_rows or ()),
)
elif has_rule("table_size_columns"):
append_declaration("table-size-columns", str(self.table_size_columns))
elif has_rule("table_size_rows"):
append_declaration("table-size-rows", str(self.table_size_rows))
elif has_rule("table_gutter_horizontal"):
append_declaration(
"table-gutter-horizontal", str(self.table_gutter_horizontal)
)
elif has_rule("table_gutter_vertical"):
append_declaration("table-gutter-vertical", str(self.table_gutter_vertical))
elif has_rule("row_span"):
append_declaration("row-span", str(self.row_span))
elif has_rule("column_span"):
append_declaration("column-span", str(self.column_span))
lines.sort()
return lines

View File

@@ -551,6 +551,19 @@ class Region(NamedTuple):
height + expand_height * 2,
)
def clip_size(self, size: tuple[int, int]) -> Region:
"""Clip the size to fit within minimum values.
Args:
size (tuple[int, int]): Maximum width and height.
Returns:
Region: No region, not bigger than size.
"""
x, y, width, height = self
max_width, max_height = size
return Region(x, y, min(width, max_width), min(height, max_height))
@lru_cache(maxsize=1024)
def overlaps(self, other: Region) -> bool:
"""Check if another region overlaps this region.

View File

@@ -17,7 +17,6 @@ class CenterLayout(Layout):
) -> ArrangeResult:
placements: list[WidgetPlacement] = []
total_regions: list[Region] = []
parent_size = parent.outer_size
container_width, container_height = size
@@ -32,8 +31,6 @@ class CenterLayout(Layout):
x = margin.left + max(0, (container_width - margin_width) // 2)
y = margin.top + max(0, (container_height - margin_height) // 2)
region = Region(x, y, int(width), int(height))
total_regions.append(region.grow(margin))
placements.append(WidgetPlacement(region, widget, 0))
placements.append(WidgetPlacement(region, margin, widget, 0))
placements.append(WidgetPlacement(Region.from_union(total_regions), None, 0))
return placements, set(children)

View File

@@ -1,15 +1,16 @@
from __future__ import annotations
from .._layout import Layout
from .horizontal import HorizontalLayout
from .vertical import VerticalLayout
from .center import CenterLayout
from .horizontal import HorizontalLayout
from .table import TableLayout
from .vertical import VerticalLayout
LAYOUT_MAP: dict[str, type[Layout]] = {
"vertical": VerticalLayout,
"horizontal": HorizontalLayout,
"center": CenterLayout,
"horizontal": HorizontalLayout,
"table": TableLayout,
"vertical": VerticalLayout,
}

View File

@@ -1,443 +0,0 @@
# TODO: This is deprecated (and probably doesn't work any more)
from __future__ import annotations
import sys
from collections import defaultdict
from dataclasses import dataclass
from itertools import cycle, product
from logging import getLogger
from operator import itemgetter
from typing import Iterable, NamedTuple, TYPE_CHECKING
from .._layout_resolve import layout_resolve
from ..geometry import Size, Offset, Region
from .._layout import Layout, WidgetPlacement
if TYPE_CHECKING:
from ..widget import Widget
from ..screen import Screen
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
log = getLogger("rich")
GridAlign = Literal["start", "end", "center", "stretch"]
@dataclass
class GridOptions:
name: str
size: int | None = None
fraction: int = 1
min_size: int = 1
max_size: int | None = None
class GridArea(NamedTuple):
col_start: str
col_end: str
row_start: str
row_end: str
class GridLayout(Layout):
def __init__(
self,
gap: tuple[int, int] | int | None = None,
gutter: tuple[int, int] | int | None = None,
align: tuple[GridAlign, GridAlign] | None = None,
) -> None:
self.columns: list[GridOptions] = []
self.rows: list[GridOptions] = []
self.areas: dict[str, GridArea] = {}
self.widgets: dict[Widget, str | None] = {}
self.column_gap = 0
self.row_gap = 0
self.column_repeat = False
self.row_repeat = False
self.column_align: GridAlign = "start"
self.row_align: GridAlign = "start"
self.column_gutter: int = 0
self.row_gutter: int = 0
self.hidden_columns: set[str] = set()
self.hidden_rows: set[str] = set()
if gap is not None:
if isinstance(gap, tuple):
self.set_gap(*gap)
else:
self.set_gap(gap)
if gutter is not None:
if isinstance(gutter, tuple):
self.set_gutter(*gutter)
else:
self.set_gutter(gutter)
if align is not None:
self.set_align(*align)
super().__init__()
def is_row_visible(self, row_name: str) -> bool:
return row_name not in self.hidden_rows
def is_column_visible(self, column_name: str) -> bool:
return column_name not in self.hidden_columns
def show_row(self, row_name: str, visible: bool = True) -> bool:
changed = (row_name in self.hidden_rows) == visible
if visible:
self.hidden_rows.discard(row_name)
else:
self.hidden_rows.add(row_name)
if changed:
self.require_update()
return True
return False
def show_column(self, column_name: str, visible: bool = True) -> bool:
changed = (column_name in self.hidden_columns) == visible
if visible:
self.hidden_columns.discard(column_name)
else:
self.hidden_columns.add(column_name)
if changed:
self.require_update()
return True
return False
def add_column(
self,
name: str,
*,
size: int | None = None,
fraction: int = 1,
min_size: int = 1,
max_size: int | None = None,
repeat: int = 1,
) -> None:
names = (
[name]
if repeat == 1
else [f"{name}{count}" for count in range(1, repeat + 1)]
)
append = self.columns.append
for name in names:
append(
GridOptions(
name,
size=size,
fraction=fraction,
min_size=min_size,
max_size=max_size,
)
)
self.require_update()
def add_row(
self,
name: str,
*,
size: int | None = None,
fraction: int = 1,
min_size: int = 1,
max_size: int | None = None,
repeat: int = 1,
) -> None:
names = (
[name]
if repeat == 1
else [f"{name}{count}" for count in range(1, repeat + 1)]
)
append = self.rows.append
for name in names:
append(
GridOptions(
name,
size=size,
fraction=fraction,
min_size=min_size,
max_size=max_size,
)
)
self.require_update()
def _add_area(
self, name: str, columns: str | tuple[str, str], rows: str | tuple[str, str]
) -> None:
if isinstance(columns, str):
column_start = f"{columns}-start"
column_end = f"{columns}-end"
else:
column_start, column_end = columns
if isinstance(rows, str):
row_start = f"{rows}-start"
row_end = f"{rows}-end"
else:
row_start, row_end = rows
self.areas[name] = GridArea(column_start, column_end, row_start, row_end)
def add_areas(self, **areas: str) -> None:
for name, area in areas.items():
area = area.replace(" ", "")
column, _, row = area.partition(",")
column_start, column_sep, column_end = column.partition("|")
row_start, row_sep, row_end = row.partition("|")
self._add_area(
name,
(column_start, column_end) if column_sep else column,
(row_start, row_end) if row_sep else row,
)
self.require_update()
def set_gap(self, column: int, row: int | None = None) -> None:
self.column_gap = column
self.row_gap = column if row is None else row
self.require_update()
def set_gutter(self, column: int, row: int | None = None) -> None:
self.column_gutter = column
self.row_gutter = column if row is None else row
self.require_update()
def add_widget(self, widget: Widget, area: str | None = None) -> Widget:
self.widgets[widget] = area
self.require_update()
return widget
def place(self, *auto_widgets: Widget, **area_widgets: Widget) -> None:
widgets = self.widgets
for area, widget in area_widgets.items():
widgets[widget] = area
for widget in auto_widgets:
widgets[widget] = None
self.require_update()
def set_repeat(self, column: bool | None = None, row: bool | None = None) -> None:
if column is not None:
self.column_repeat = column
if row is not None:
self.row_repeat = row
self.require_update()
def set_align(self, column: GridAlign | None = None, row: GridAlign | None = None):
if column is not None:
self.column_align = column
if row is not None:
self.row_align = row
self.require_update()
@classmethod
def _align(
cls,
region: Region,
grid_size: Size,
container: Size,
col_align: GridAlign,
row_align: GridAlign,
) -> Region:
def align(size: int, container: int, align: GridAlign) -> int:
offset = 0
if align == "end":
offset = container - size
elif align == "center":
offset = (container - size) // 2
return offset
offset = Offset(
align(grid_size.width, container.width, col_align),
align(grid_size.height, container.height, row_align),
)
region = region.translate(offset)
return region
def get_widgets(self) -> Iterable[Widget]:
return self.widgets.keys()
def arrange(
self, view: Screen, size: Size, scroll: Offset
) -> Iterable[WidgetPlacement]:
"""Generate a map that associates widgets with their location on screen.
Args:
width (int): [description]
height (int): [description]
offset (Point, optional): [description]. Defaults to Point(0, 0).
Returns:
dict[Widget, OrderedRegion]: [description]
"""
width, height = size
def resolve(
size: int, edges: list[GridOptions], gap: int, repeat: bool
) -> Iterable[tuple[int, int]]:
total_gap = gap * (len(edges) - 1)
tracks: Iterable[int]
tracks = [
track if edge.max_size is None else min(edge.max_size, track)
for track, edge in zip(layout_resolve(size - total_gap, edges), edges)
]
if repeat:
tracks = cycle(tracks)
total = 0
edge_count = len(edges)
for index, track in enumerate(tracks):
if total + track >= size and index >= edge_count:
break
yield total, total + track
total += track + gap
def resolve_tracks(
grid: list[GridOptions], size: int, gap: int, repeat: bool
) -> tuple[list[str], dict[str, tuple[int, int]], int, int]:
spans = [
(options.name, span)
for options, span in zip(cycle(grid), resolve(size, grid, gap, repeat))
]
max_size = 0
tracks: dict[str, tuple[int, int]] = {}
counts: dict[str, int] = defaultdict(int)
if repeat:
names = []
for index, (name, (start, end)) in enumerate(spans):
max_size = max(max_size, end)
counts[name] += 1
count = counts[name]
names.append(f"{name}-{count}")
tracks[f"{name}-{count}-start"] = (index, start)
tracks[f"{name}-{count}-end"] = (index, end)
else:
names = [name for name, _span in spans]
for index, (name, (start, end)) in enumerate(spans):
max_size = max(max_size, end)
tracks[f"{name}-start"] = (index, start)
tracks[f"{name}-end"] = (index, end)
return names, tracks, len(spans), max_size
container = Size(width - self.column_gutter * 2, height - self.row_gutter * 2)
column_names, column_tracks, column_count, column_size = resolve_tracks(
[
options
for options in self.columns
if options.name not in self.hidden_columns
],
container.width,
self.column_gap,
self.column_repeat,
)
row_names, row_tracks, row_count, row_size = resolve_tracks(
[options for options in self.rows if options.name not in self.hidden_rows],
container.height,
self.row_gap,
self.row_repeat,
)
grid_size = Size(column_size, row_size)
widget_areas = (
(widget, area)
for widget, area in self.widgets.items()
if area and widget.display
)
free_slots = {
(col, row) for col, row in product(range(column_count), range(row_count))
}
order = 1
from_corners = Region.from_corners
gutter = Offset(self.column_gutter, self.row_gutter)
for widget, area in widget_areas:
column_start, column_end, row_start, row_end = self.areas[area]
try:
col1, x1 = column_tracks[column_start]
col2, x2 = column_tracks[column_end]
row1, y1 = row_tracks[row_start]
row2, y2 = row_tracks[row_end]
except (KeyError, IndexError):
continue
free_slots.difference_update(
product(range(col1, col2 + 1), range(row1, row2 + 1))
)
region = self._align(
from_corners(x1, y1, x2, y2),
grid_size,
container,
self.column_align,
self.row_align,
)
yield WidgetPlacement(region + gutter, widget, (0, order))
order += 1
# Widgets with no area assigned.
auto_widgets = (widget for widget, area in self.widgets.items() if area is None)
grid_slots = sorted(
(
slot
for slot in product(range(column_count), range(row_count))
if slot in free_slots
),
key=itemgetter(1, 0), # TODO: other orders
)
for widget, (col, row) in zip(auto_widgets, grid_slots):
col_name = column_names[col]
row_name = row_names[row]
_col1, x1 = column_tracks[f"{col_name}-start"]
_col2, x2 = column_tracks[f"{col_name}-end"]
_row1, y1 = row_tracks[f"{row_name}-start"]
_row2, y2 = row_tracks[f"{row_name}-end"]
region = self._align(
from_corners(x1, y1, x2, y2),
grid_size,
container,
self.column_align,
self.row_align,
)
yield WidgetPlacement(region + gutter, widget, (0, order))
order += 1
return map
if __name__ == "__main__":
layout = GridLayout()
layout.add_column(size=20, name="a")
layout.add_column(size=10, name="b")
layout.add_row(fraction=1, name="top")
layout.add_row(fraction=2, name="bottom")
layout.add_areas(center="a-start|b-end,top")
# layout.set_repeat(True)
from ..widgets import Placeholder
layout.place(center=Placeholder())
from rich import print
print(layout.widgets)
map = layout.generate_map(100, 80)
print(map)

View File

@@ -61,11 +61,8 @@ class HorizontalLayout(Layout):
max_height = max(
max_height, content_height + offset_y + box_model.margin.bottom
)
add_placement(WidgetPlacement(region, widget, 0))
add_placement(WidgetPlacement(region, box_model.margin, widget, 0))
x = next_x + margin
max_width = x
total_region = Region(0, 0, int(max_width), int(max_height))
add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(displayed_children)

View File

@@ -0,0 +1,160 @@
from __future__ import annotations
from fractions import Fraction
from typing import TYPE_CHECKING, Iterable
from .._layout import ArrangeResult, Layout, WidgetPlacement
from .._resolve import resolve
from ..css.scalar import Scalar
from ..geometry import Region, Size, Spacing
if TYPE_CHECKING:
from ..widget import Widget
class TableLayout(Layout):
"""Used to layout Widgets in to a table."""
name = "table"
def arrange(
self, parent: Widget, children: list[Widget], size: Size
) -> ArrangeResult:
styles = parent.styles
row_scalars = styles.table_rows or [Scalar.parse("1fr")]
column_scalars = styles.table_columns or [Scalar.parse("1fr")]
gutter_horizontal = styles.table_gutter_horizontal
gutter_vertical = styles.table_gutter_vertical
table_size_columns = max(1, styles.table_size_columns)
table_size_rows = styles.table_size_rows
viewport = parent.screen.size
def cell_coords(column_count: int) -> Iterable[tuple[int, int]]:
"""Iterate over table coordinates ad infinitum.
Args:
column_count (int): 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 (int): Start column.
row_start (int): Start_row.
columns (int): Number of columns.
rows (int): Number of rows.
Returns:
set[tuple[int, int]]: 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[T]): Iterable of values.
count (int): Number of values to return.
Returns:
list[T]: 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]] = {}
column_count = table_size_columns
next_coord = iter(cell_coords(column_count)).__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()
# Resolve columns / rows
columns = resolve(
repeat_scalars(column_scalars, table_size_columns),
size.width,
gutter_vertical,
size,
viewport,
)
rows = resolve(
repeat_scalars(
row_scalars, table_size_rows if table_size_rows else row + 1
),
size.height,
gutter_horizontal,
size,
viewport,
)
placements: list[WidgetPlacement] = []
add_placement = placements.append
fraction_unit = Fraction(1)
widgets: list[Widget] = []
add_widget = widgets.append
max_column = len(columns) - 1
max_row = len(rows) - 1
margin = Spacing()
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)
width, height, margin = widget._get_box_model(
cell_size,
viewport,
fraction_unit,
)
region = (
Region(x, y, int(width), int(height))
.shrink(margin)
.clip_size(cell_size)
)
add_placement(WidgetPlacement(region, margin, widget))
add_widget(widget)
return (placements, set(widgets))

View File

@@ -54,10 +54,7 @@ class VerticalLayout(Layout):
)
next_y = y + content_height
region = Region(offset_x, int(y), int(content_width), int(next_y) - int(y))
add_placement(WidgetPlacement(region, widget, 0))
add_placement(WidgetPlacement(region, box_model.margin, widget, 0))
y = next_y + margin
total_region = Region(0, 0, size.width, int(y))
add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(children)

View File

@@ -4,16 +4,16 @@ from math import ceil
import rich.repr
from rich.color import Color
from rich.console import ConsoleOptions, RenderResult, RenderableType
from rich.console import ConsoleOptions, RenderableType, RenderResult
from rich.segment import Segment, Segments
from rich.style import Style, StyleType
from textual.reactive import Reactive
from textual.renderables.blank import Blank
from . import events
from ._types import MessageTarget
from .geometry import Offset
from .message import Message
from .reactive import Reactive
from .renderables.blank import Blank
from .widget import Widget
@@ -86,7 +86,6 @@ class ScrollBarRender:
virtual_size: float = 50,
window_size: float = 20,
position: float = 0,
ascii_only: bool = False,
thickness: int = 1,
vertical: bool = True,
back_color: Color = Color.parse("#555555"),
@@ -94,15 +93,9 @@ class ScrollBarRender:
) -> Segments:
if vertical:
if ascii_only:
bars = ["|", "|", "|", "|", "|", "|", "|", "|"]
else:
bars = ["", "", "", "", "", "", "", ""]
bars = [" ", "", "", "", "", "", "", "", ""]
else:
if ascii_only:
bars = ["-", "-", "-", "-", "-", "-", "-", "-"]
else:
bars = ["", "", "", "", "", "", "", ""]
bars = ["", "", "", "", "", "", "", "", " "]
back = back_color
bar = bar_color
@@ -117,11 +110,11 @@ class ScrollBarRender:
if window_size and size and virtual_size and size != virtual_size:
step_size = virtual_size / size
start = int(position / step_size * 8)
end = start + max(8, int(ceil(window_size / step_size * 8)))
start = int(position / step_size * 9)
end = start + max(9, int(ceil(window_size / step_size * 9)))
start_index, start_bar = divmod(start, 8)
end_index, end_bar = divmod(end, 8)
start_index, start_bar = divmod(start, 9)
end_index, end_bar = divmod(end, 9)
upper = {"@click": "scroll_up"}
lower = {"@click": "scroll_down"}
@@ -138,14 +131,14 @@ class ScrollBarRender:
if start_index < len(segments):
segments[start_index] = _Segment(
bars[7 - start_bar] * width_thickness,
bars[8 - start_bar] * width_thickness,
_Style(bgcolor=back, color=bar, meta=foreground_meta)
if vertical
else _Style(bgcolor=bar, color=back, meta=foreground_meta),
)
if end_index < len(segments):
segments[end_index] = _Segment(
bars[7 - end_bar] * width_thickness,
bars[8 - end_bar] * width_thickness,
_Style(bgcolor=bar, color=back, meta=foreground_meta)
if vertical
else _Style(bgcolor=back, color=bar, meta=foreground_meta),
@@ -296,6 +289,7 @@ class ScrollBarCorner(Widget):
super().__init__(name=name)
def render(self) -> RenderableType:
assert self.parent is not None
styles = self.parent.styles
color = styles.scrollbar_corner_color
return Blank(color)

View File

@@ -185,7 +185,7 @@ class Widget(DOMNode):
@property
def _allow_scroll(self) -> bool:
"""Check if both axes may be scrolled.
"""Check if both axis may be scrolled.
Returns:
bool: True if horizontal and vertical scrolling is enabled.

View File

@@ -24,14 +24,10 @@ def test_arrange_dock_top():
container, [child, header], Size(80, 24), Size(80, 24)
)
assert placements == [
WidgetPlacement(Region(0, 0, 80, 1), header, order=TOP_Z, fixed=True),
WidgetPlacement(Region(0, 1, 80, 23), child, order=0, fixed=False),
WidgetPlacement(
region=Region(x=0, y=1, width=80, height=23),
widget=None,
order=0,
fixed=False,
Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
),
WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
]
assert widgets == {child, header}
assert spacing == Spacing(1, 0, 0, 0)
@@ -48,14 +44,10 @@ def test_arrange_dock_left():
container, [child, header], Size(80, 24), Size(80, 24)
)
assert placements == [
WidgetPlacement(Region(0, 0, 10, 24), header, order=TOP_Z, fixed=True),
WidgetPlacement(Region(10, 0, 70, 24), child, order=0, fixed=False),
WidgetPlacement(
region=Region(x=10, y=0, width=70, height=24),
widget=None,
order=0,
fixed=False,
Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
),
WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
]
assert widgets == {child, header}
assert spacing == Spacing(0, 0, 0, 10)
@@ -72,14 +64,10 @@ def test_arrange_dock_right():
container, [child, header], Size(80, 24), Size(80, 24)
)
assert placements == [
WidgetPlacement(Region(70, 0, 10, 24), header, order=TOP_Z, fixed=True),
WidgetPlacement(Region(0, 0, 70, 24), child, order=0, fixed=False),
WidgetPlacement(
region=Region(x=0, y=0, width=70, height=24),
widget=None,
order=0,
fixed=False,
Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
),
WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
]
assert widgets == {child, header}
assert spacing == Spacing(0, 10, 0, 0)
@@ -96,14 +84,10 @@ def test_arrange_dock_bottom():
container, [child, header], Size(80, 24), Size(80, 24)
)
assert placements == [
WidgetPlacement(Region(0, 23, 80, 1), header, order=TOP_Z, fixed=True),
WidgetPlacement(Region(0, 0, 80, 23), child, order=0, fixed=False),
WidgetPlacement(
region=Region(x=0, y=0, width=80, height=23),
widget=None,
order=0,
fixed=False,
Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
),
WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
]
assert widgets == {child, header}
assert spacing == Spacing(0, 0, 1, 0)

View File

@@ -195,6 +195,10 @@ def test_crop_size():
assert Region(10, 20, 100, 200).crop_size((500, 40)) == Region(10, 20, 100, 40)
def test_clip_size():
assert Region(10, 10, 100, 80).clip_size((50, 100)) == Region(10, 10, 50, 80)
def test_region_overlaps():
assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20))
assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20))

View File

@@ -1,5 +1,5 @@
from textual._layout import WidgetPlacement
from textual.geometry import Region, Size
from textual.geometry import Region, Size, Spacing
from textual.layouts.center import CenterLayout
from textual.widget import Widget
@@ -19,15 +19,10 @@ def test_center_layout():
expected = [
WidgetPlacement(
region=Region(x=25, y=7, width=10, height=5),
margin=Spacing(),
widget=child,
order=0,
fixed=False,
),
WidgetPlacement(
region=Region(x=25, y=7, width=10, height=5),
widget=None,
order=0,
fixed=False,
),
]
assert placements == expected

58
tests/test_resolve.py Normal file
View File

@@ -0,0 +1,58 @@
import pytest
from textual.geometry import Size
from textual.css.scalar import Scalar
from textual._resolve import resolve
def test_resolve_empty():
assert resolve([], 10, 1, Size(20, 10), Size(80, 24)) == []
@pytest.mark.parametrize(
"scalars,total,gutter,result",
[
(["10"], 100, 0, [(0, 10)]),
(
["10", "20"],
100,
0,
[(0, 10), (10, 20)],
),
(
["10", "20"],
100,
1,
[(0, 10), (11, 20)],
),
(
["10", "1fr"],
100,
1,
[(0, 10), (11, 89)],
),
(
["1fr", "1fr"],
100,
0,
[(0, 50), (50, 50)],
),
(
["3", "1fr", "1fr", "1"],
100,
1,
[(0, 3), (4, 46), (51, 47), (99, 1)],
),
],
)
def test_resolve(scalars, total, gutter, result):
assert (
resolve(
[Scalar.parse(scalar) for scalar in scalars],
total,
gutter,
Size(40, 20),
Size(80, 24),
)
== result
)