diff --git a/examples/calculator.py b/examples/calculator.py new file mode 100644 index 000000000..9c56f5e39 --- /dev/null +++ b/examples/calculator.py @@ -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") \ No newline at end of file diff --git a/examples/grid.py b/examples/grid.py index 16c16b1f6..1f8582a4c 100644 --- a/examples/grid.py +++ b/examples/grid.py @@ -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") diff --git a/src/textual/_layout_resolve.py b/src/textual/_layout_resolve.py new file mode 100644 index 000000000..3655eb6bf --- /dev/null +++ b/src/textual/_layout_resolve.py @@ -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) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 7e7c92a99..2a77323b2 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -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) diff --git a/src/textual/view.py b/src/textual/view.py index 63422abad..95efefe0d 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -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)