mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
grid align
This commit is contained in:
42
examples/calculator.py
Normal file
42
examples/calculator.py
Normal 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")
|
||||
@@ -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")
|
||||
|
||||
|
||||
79
src/textual/_layout_resolve.py
Normal file
79
src/textual/_layout_resolve.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user