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() layout = GridLayout()
view = await self.push_view(View(layout=layout)) 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(size=30, name="center")
layout.add_column(fraction=1, name="right") 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 __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from itertools import cycle
import sys
from typing import Iterable, NamedTuple from typing import Iterable, NamedTuple
from .._layout_resolve import layout_resolve from .._layout_resolve import layout_resolve
from .._loop import loop_last from .._loop import loop_last
from ..geometry import Point, Region from ..geometry import Dimensions, Point, Region
from ..layout import Layout, OrderedRegion from ..layout import Layout, OrderedRegion
from ..widget import Widget 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 @dataclass
class GridOptions: class GridOptions:
size: int | None = None size: int | None = None
fraction: int = 1 fraction: int = 1
minimum_size: int = 1 min_size: int = 1
maximum_size: int | None = None max_size: int | None = None
name: str | None = None name: str | None = None
@@ -34,6 +45,11 @@ class GridLayout(Layout):
self.widgets: dict[Widget, str | None] = {} self.widgets: dict[Widget, str | None] = {}
self.column_gap = 1 self.column_gap = 1
self.row_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__() super().__init__()
def add_column( def add_column(
@@ -41,11 +57,16 @@ class GridLayout(Layout):
*, *,
size: int | None = None, size: int | None = None,
fraction: int = 1, fraction: int = 1,
minimum_size: int = 1, min_size: int = 1,
max_size: int | None = None,
name: str | None = None, name: str | None = None,
) -> None: ) -> None:
options = GridOptions( 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) self.columns.append(options)
@@ -54,11 +75,16 @@ class GridLayout(Layout):
*, *,
size: int | None = None, size: int | None = None,
fraction: int = 1, fraction: int = 1,
minimum_size: int = 1, min_size: int = 1,
max_size: int | None = None,
name: str | None = None, name: str | None = None,
) -> None: ) -> None:
options = GridOptions( 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) self.rows.append(options)
@@ -80,7 +106,7 @@ class GridLayout(Layout):
area = GridArea(column_start, column_end, row_start, row_end) area = GridArea(column_start, column_end, row_start, row_end)
self.areas[name] = area 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.column_gap = column
self.row_gap = column if row is None else row self.row_gap = column if row is None else row
@@ -88,36 +114,87 @@ class GridLayout(Layout):
self.widgets[widget] = area self.widgets[widget] = area
return widget 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( def generate_map(
self, width: int, height: int, offset: Point = Point(0, 0) self, width: int, height: int, offset: Point = Point(0, 0)
) -> dict[Widget, OrderedRegion]: ) -> dict[Widget, OrderedRegion]:
def resolve( def resolve(
size: int, edges: list[GridOptions], gap: int size: int, edges: list[GridOptions], gap: int, repeat: bool
) -> Iterable[tuple[int, int]]: ) -> Iterable[tuple[int, int]]:
tracks = [ 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) for track, edge in zip(layout_resolve(size, edges), edges)
] ]
if repeat:
tracks = cycle(tracks)
total = 0 total = 0
for last, track in loop_last(tracks): for index, track in enumerate(tracks):
yield total, total + track if last else total + track - gap yield total, total + track - gap if total + track < size else total + track
total += track total += track
if total >= size and index >= len(edges):
break
def resolve_tracks( def resolve_tracks(
grid: list[GridOptions], size: int, gap: int grid: list[GridOptions], size: int, gap: int, repeat: bool
) -> dict[str, int]: ) -> tuple[dict[str, list[int]], int]:
spans = ( spans = (
(options.name, span) (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: for name, (start, end) in spans:
tracks[f"{name}-start"] = start max_size = max(max_size, end)
tracks[f"{name}-end"] = end tracks[f"{name}-start"].append(start)
return tracks tracks[f"{name}-end"].append(end)
return tracks, max_size
column_tracks = resolve_tracks(self.columns, width, self.column_gap) column_tracks, column_size = resolve_tracks(
row_tracks = resolve_tracks(self.rows, height, self.row_gap) 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_areas = (
(widget, area) (widget, area)
@@ -130,31 +207,38 @@ class GridLayout(Layout):
from_corners = Region.from_corners from_corners = Region.from_corners
for widget, area in widget_areas: for widget, area in widget_areas:
column_start, column_end, row_start, row_end = self.areas[area] column_start, column_end, row_start, row_end = self.areas[area]
x1 = column_tracks[column_start] try:
x2 = column_tracks[column_end] x1 = column_tracks[column_start][0]
y1 = row_tracks[row_start] x2 = column_tracks[column_end][0]
y2 = row_tracks[row_end] y1 = row_tracks[row_start][0]
map[widget] = OrderedRegion(from_corners(x1, y1, x2, y2), (0, order)) 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 order += 1
# Widgets with no area assigned.
auto_widgets = [widget for widget, area in self.widgets.items() if area is None]
return map return map
if __name__ == "__main__": if __name__ == "__main__":
layout = GridLayout() layout = GridLayout()
layout.add_column(size=20, name="left")
layout.add_column(fraction=2, name="middle") layout.add_column(size=20, name="a")
layout.add_column(fraction=1, name="right") layout.add_column(size=10, name="b")
layout.add_row(fraction=1, name="top") layout.add_row(fraction=1, name="top")
layout.add_row(fraction=2, name="bottom") layout.add_row(fraction=2, name="bottom")
layout.add_area("center", "middle", "top") layout.add_area("center", "middle", "top")
from ..widgets import Static
layout.add_widget(Static("foo"), "center")
from rich import print from rich import print
print(layout.widgets) print(layout.widgets)

View File

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