grid align

This commit is contained in:
Will McGugan
2021-07-10 08:08:48 +01:00
parent 54d80927c9
commit a445cb12fa
5 changed files with 241 additions and 34 deletions

42
examples/calculator.py Normal file
View File

@@ -0,0 +1,42 @@
from textual.app import App
from textual import events
from textual.view import View
from textual.widgets import Placeholder
from textual.layouts.grid import GridLayout
class GridTest(App):
async def on_load(self, event: events.Load) -> None:
await self.bind("q,ctrl+c", "quit", "Quit")
async def on_startup(self, event: events.Startup) -> None:
layout = GridLayout()
view = await self.push_view(View(layout=layout))
layout.add_column(name="col1", max_size=20)
layout.add_column(name="col2", max_size=20)
layout.add_column(name="col3", max_size=20)
layout.add_column(name="col4", max_size=20)
layout.add_row(name="numbers", max_size=10)
layout.add_row(name="row1", max_size=10)
layout.add_row(name="row2", max_size=10)
layout.add_row(name="row3", max_size=10)
layout.add_row(name="row4", max_size=10)
layout.add_area("numbers", ("col1-start", "col4-end"), "numbers")
layout.add_area("zero", ("col1-start", "col2-end"), "row4")
layout.add_area("dot", "col3", "row4")
layout.add_area("equals", "col4", "row4")
layout.add_widget(Placeholder(name="numbers"), area="numbers")
layout.add_widget(Placeholder(name="0"), area="zero")
layout.add_widget(Placeholder(name="."), area="dot")
layout.add_widget(Placeholder(name="="), area="equals")
layout.set_gap(1)
layout.set_align("center", "center")
GridTest.run(title="Calculator Test")

View File

@@ -14,7 +14,7 @@ class GridTest(App):
layout = GridLayout()
view = await self.push_view(View(layout=layout))
layout.add_column(fraction=1, name="left", minimum_size=20)
layout.add_column(fraction=1, name="left", min_size=20)
layout.add_column(size=30, name="center")
layout.add_column(fraction=1, name="right")

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
import sys
from fractions import Fraction
from typing import cast, List, Optional, Sequence
if sys.version_info >= (3, 8):
from typing import Protocol
else:
from typing_extensions import Protocol # pragma: no cover
class Edge(Protocol):
"""Any object that defines an edge (such as Layout)."""
size: Optional[int] = None
fraction: int = 1
min_size: int = 1
def layout_resolve(total: int, edges: Sequence[Edge]) -> List[int]:
"""Divide total space to satisfy size, ratio, and minimum_size, constraints.
The returned list of integers should add up to total in most cases, unless it is
impossible to satisfy all the constraints. For instance, if there are two edges
with a minimum size of 20 each and `total` is 30 then the returned list will be
greater than total. In practice, this would mean that a Layout object would
clip the rows that would overflow the screen height.
Args:
total (int): Total number of characters.
edges (List[Edge]): Edges within total space.
Returns:
List[int]: Number of characters for each edge.
"""
# Size of edge or None for yet to be determined
sizes = [(edge.size or None) for edge in edges]
_Fraction = Fraction
# While any edges haven't been calculated
while None in sizes:
# Get flexible edges and index to map these back on to sizes list
flexible_edges = [
(index, edge)
for index, (size, edge) in enumerate(zip(sizes, edges))
if size is None
]
# Remaining space in total
remaining = total - sum(size or 0 for size in sizes)
if remaining <= 0:
# No room for flexible edges
return [
((edge.min_size or 1) if size is None else size)
for size, edge in zip(sizes, edges)
]
# Calculate number of characters in a ratio portion
portion = _Fraction(
remaining, sum((edge.fraction or 1) for _, edge in flexible_edges)
)
# If any edges will be less than their minimum, replace size with the minimum
for index, edge in flexible_edges:
if portion * edge.fraction <= edge.min_size:
sizes[index] = edge.min_size
# New fixed size will invalidate calculations, so we need to repeat the process
break
else:
# Distribute flexible space and compensate for rounding error
# Since edge sizes can only be integers we need to add the remainder
# to the following line
remainder = _Fraction(0)
for index, edge in flexible_edges:
size, remainder = divmod(portion * edge.fraction + remainder, 1)
sizes[index] = size
break
# Sizes now contains integers only
return cast(List[int], sizes)

View File

@@ -1,21 +1,32 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from itertools import cycle
import sys
from typing import Iterable, NamedTuple
from .._layout_resolve import layout_resolve
from .._loop import loop_last
from ..geometry import Point, Region
from ..geometry import Dimensions, Point, Region
from ..layout import Layout, OrderedRegion
from ..widget import Widget
if sys.version_info >= (3, 8):
from typing import Literal
else:
from typing_extensions import Literal
GridAlign = Literal["start", "end", "center", "stretch"]
@dataclass
class GridOptions:
size: int | None = None
fraction: int = 1
minimum_size: int = 1
maximum_size: int | None = None
min_size: int = 1
max_size: int | None = None
name: str | None = None
@@ -34,6 +45,11 @@ class GridLayout(Layout):
self.widgets: dict[Widget, str | None] = {}
self.column_gap = 1
self.row_gap = 1
self.column_repeat = False
self.row_repeat = False
self.column_align: GridAlign = "start"
self.row_align: GridAlign = "start"
super().__init__()
def add_column(
@@ -41,11 +57,16 @@ class GridLayout(Layout):
*,
size: int | None = None,
fraction: int = 1,
minimum_size: int = 1,
min_size: int = 1,
max_size: int | None = None,
name: str | None = None,
) -> None:
options = GridOptions(
size=size, fraction=fraction, minimum_size=minimum_size, name=name
size=size,
fraction=fraction,
min_size=min_size,
max_size=max_size,
name=name,
)
self.columns.append(options)
@@ -54,11 +75,16 @@ class GridLayout(Layout):
*,
size: int | None = None,
fraction: int = 1,
minimum_size: int = 1,
min_size: int = 1,
max_size: int | None = None,
name: str | None = None,
) -> None:
options = GridOptions(
size=size, fraction=fraction, minimum_size=minimum_size, name=name
size=size,
fraction=fraction,
min_size=min_size,
max_size=max_size,
name=name,
)
self.rows.append(options)
@@ -80,7 +106,7 @@ class GridLayout(Layout):
area = GridArea(column_start, column_end, row_start, row_end)
self.areas[name] = area
def set_gaps(self, column: int, row: int | None) -> None:
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
@@ -88,36 +114,87 @@ class GridLayout(Layout):
self.widgets[widget] = area
return widget
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
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
@classmethod
def _align(
cls,
region: Region,
grid_size: Dimensions,
container: Dimensions,
col_align: GridAlign,
row_align: GridAlign,
) -> Region:
offset_x = 0
offset_y = 0
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
size = region.size
offset_x = align(grid_size.width, container.width, col_align)
offset_y = align(grid_size.height, container.height, row_align)
region = region.translate(offset_x, offset_y)
return region
def generate_map(
self, width: int, height: int, offset: Point = Point(0, 0)
) -> dict[Widget, OrderedRegion]:
def resolve(
size: int, edges: list[GridOptions], gap: int
size: int, edges: list[GridOptions], gap: int, repeat: bool
) -> Iterable[tuple[int, int]]:
tracks = [
track if edge.maximum_size is None else min(edge.maximum_size, track)
track if edge.max_size is None else min(edge.max_size, track)
for track, edge in zip(layout_resolve(size, edges), edges)
]
if repeat:
tracks = cycle(tracks)
total = 0
for last, track in loop_last(tracks):
yield total, total + track if last else total + track - gap
for index, track in enumerate(tracks):
yield total, total + track - gap if total + track < size else total + track
total += track
if total >= size and index >= len(edges):
break
def resolve_tracks(
grid: list[GridOptions], size: int, gap: int
) -> dict[str, int]:
grid: list[GridOptions], size: int, gap: int, repeat: bool
) -> tuple[dict[str, list[int]], int]:
spans = (
(options.name, span)
for options, span in zip(grid, resolve(size, grid, gap))
for options, span in zip(cycle(grid), resolve(size, grid, gap, repeat))
)
tracks: dict[str, int] = {}
max_size = 0
tracks: dict[str, list[int]] = defaultdict(list)
for name, (start, end) in spans:
tracks[f"{name}-start"] = start
tracks[f"{name}-end"] = end
return tracks
max_size = max(max_size, end)
tracks[f"{name}-start"].append(start)
tracks[f"{name}-end"].append(end)
return tracks, max_size
column_tracks = resolve_tracks(self.columns, width, self.column_gap)
row_tracks = resolve_tracks(self.rows, height, self.row_gap)
column_tracks, column_size = resolve_tracks(
self.columns, width, self.column_gap, self.column_repeat
)
row_tracks, row_size = resolve_tracks(
self.rows, height, self.row_gap, self.row_repeat
)
container_size = Dimensions(width, height)
grid_size = Dimensions(column_size, row_size)
widget_areas = (
(widget, area)
@@ -130,31 +207,38 @@ class GridLayout(Layout):
from_corners = Region.from_corners
for widget, area in widget_areas:
column_start, column_end, row_start, row_end = self.areas[area]
x1 = column_tracks[column_start]
x2 = column_tracks[column_end]
y1 = row_tracks[row_start]
y2 = row_tracks[row_end]
map[widget] = OrderedRegion(from_corners(x1, y1, x2, y2), (0, order))
try:
x1 = column_tracks[column_start][0]
x2 = column_tracks[column_end][0]
y1 = row_tracks[row_start][0]
y2 = row_tracks[row_end][0]
except (KeyError, IndexError):
continue
region = from_corners(x1, y1, x2, y2)
region = self._align(
region, grid_size, container_size, self.column_align, self.row_align
)
map[widget] = OrderedRegion(region, (0, order))
order += 1
# Widgets with no area assigned.
auto_widgets = [widget for widget, area in self.widgets.items() if area is None]
return map
if __name__ == "__main__":
layout = GridLayout()
layout.add_column(size=20, name="left")
layout.add_column(fraction=2, name="middle")
layout.add_column(fraction=1, name="right")
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_area("center", "middle", "top")
from ..widgets import Static
layout.add_widget(Static("foo"), "center")
from rich import print
print(layout.widgets)

View File

@@ -105,6 +105,8 @@ class View(Widget):
send_resize = shown
send_resize.update(resized)
for widget, region in self.layout:
if not self.is_mounted(widget):
await self.mount(widget)
if widget in send_resize:
widget.post_message_no_wait(
events.Resize(self, region.width, region.height)