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()
|
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")
|
||||||
|
|
||||||
|
|||||||
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 __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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user