diff --git a/sandbox/will/calculator.css b/sandbox/will/calculator.css new file mode 100644 index 000000000..90b1c7617 --- /dev/null +++ b/sandbox/will/calculator.css @@ -0,0 +1,35 @@ +Screen { + overflow: auto; +} + +#calculator { + layout: table; + table-size: 4; + table-gutter: 1 2; + table-columns: 1fr; + table-rows: 2fr 1fr 1fr 1fr 1fr 1fr; + margin: 1 2; + min-height:26; + min-width: 50; +} + +Button { + width: 100%; + height: 100%; +} + +.display { + column-span: 4; + content-align: right middle; + padding: 0 1; + height: 100%; + background: $panel-darken-2; +} + +.special { + tint: $text-panel 20%; +} + +.zero { + column-span: 2; +} diff --git a/sandbox/will/calculator.py b/sandbox/will/calculator.py new file mode 100644 index 000000000..44a6c3926 --- /dev/null +++ b/sandbox/will/calculator.py @@ -0,0 +1,36 @@ +from textual.app import App + +from textual.layout import Container +from textual.widgets import Button, Static + + +class CalculatorApp(App): + def compose(self): + yield Container( + Static("0", classes="display"), + Button("AC", classes="special"), + Button("+/-", classes="special"), + Button("%", classes="special"), + Button("÷", variant="warning"), + Button("7"), + Button("8"), + Button("9"), + Button("×", variant="warning"), + Button("4"), + Button("5"), + Button("6"), + Button("-", variant="warning"), + Button("1"), + Button("2"), + Button("3"), + Button("+", variant="warning"), + Button("0", classes="operator zero"), + Button("."), + Button("=", variant="warning"), + id="calculator", + ) + + +app = CalculatorApp(css_path="calculator.css") +if __name__ == "__main__": + app.run() diff --git a/sandbox/will/spacing.css b/sandbox/will/spacing.css new file mode 100644 index 000000000..4e256a341 --- /dev/null +++ b/sandbox/will/spacing.css @@ -0,0 +1,12 @@ +Screen { + + overflow: auto; + +} + +Static { + background: blue 20%; + height: 100%; + margin: 2 4; + min-width: 30; +} diff --git a/sandbox/will/spacing.py b/sandbox/will/spacing.py new file mode 100644 index 000000000..9e7cb8b0e --- /dev/null +++ b/sandbox/will/spacing.py @@ -0,0 +1,10 @@ +from textual.app import App +from textual.widgets import Static + + +class SpacingApp(App): + def compose(self): + yield Static() + + +app = SpacingApp(css_path="spacing.css") diff --git a/sandbox/will/table_layout.css b/sandbox/will/table_layout.css new file mode 100644 index 000000000..a37f1c169 --- /dev/null +++ b/sandbox/will/table_layout.css @@ -0,0 +1,23 @@ +Screen { + layout: table; + table-columns: 2fr 1fr 1fr; + table-rows: 1fr 1fr; + table-gutter: 1 2; +} + +Static { +border: solid white; +background: blue 20%; +height: 100%; +width: 100%; +} + +#foo { + row-span: 2; +} + +#last { + column-span: 3; + margin: 1; + +} diff --git a/sandbox/will/table_layout.py b/sandbox/will/table_layout.py new file mode 100644 index 000000000..709217c38 --- /dev/null +++ b/sandbox/will/table_layout.py @@ -0,0 +1,19 @@ +from textual.app import App + +from textual.widgets import Static + + +class TableLayoutApp(App): + def compose(self): + yield Static("foo", id="foo") + yield Static("bar") + yield Static("baz") + + yield Static("foo") + yield Static("bar") + yield Static("baz", id="last") + + +app = TableLayoutApp(css_path="table_layout.css") +if __name__ == "__main__": + app.run() diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 33c37b513..6e74161a4 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -48,7 +48,7 @@ def arrange( _WidgetPlacement = WidgetPlacement top_z = TOP_Z scroll_spacing = Spacing() - + null_spacing = Spacing() get_dock = attrgetter("styles.dock") for widgets in dock_layers.values(): @@ -94,7 +94,9 @@ def arrange( (widget_width, widget_height), size ) dock_region = dock_region.shrink(margin).translate(align_offset) - add_placement(_WidgetPlacement(dock_region, dock_widget, top_z, True)) + add_placement( + _WidgetPlacement(dock_region, null_spacing, dock_widget, top_z, True) + ) dock_spacing = Spacing(top, right, bottom, left) region = size.region.shrink(dock_spacing) @@ -109,9 +111,9 @@ def arrange( if placement_offset: layout_placements = [ _WidgetPlacement( - _region + placement_offset, layout_widget, order, fixed + _region + placement_offset, margin, layout_widget, order, fixed ) - for _region, layout_widget, order, fixed in layout_placements + for _region, margin, layout_widget, order, fixed in layout_placements ] placements.extend(layout_placements) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 8d5c299b7..1f43b9b24 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -400,17 +400,16 @@ class Compositor: get_layer_index = layers_to_index.get # Add all the widgets - for sub_region, sub_widget, z, fixed in placements: + for sub_region, margin, sub_widget, z, fixed in placements: # Combine regions with children to calculate the "virtual size" if fixed: widget_region = sub_region + placement_offset else: - total_region = total_region.union(sub_region.grow(spacing)) + total_region = total_region.union( + sub_region.grow(spacing + margin) + ) widget_region = sub_region + placement_scroll_offset - if sub_widget is None: - continue - widget_order = order + (get_layer_index(sub_widget.layer, 0), z) add_widget( diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 04c56e00f..8fdd4a946 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -25,7 +25,8 @@ class WidgetPlacement(NamedTuple): """The position, size, and relative order of a widget within its parent.""" region: Region - widget: Widget | None = None # A widget of None means empty space + margin: Spacing + widget: Widget order: int = 0 fixed: bool = False diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py new file mode 100644 index 000000000..cf10dfcb5 --- /dev/null +++ b/src/textual/_resolve.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from fractions import Fraction +from itertools import accumulate +from typing import cast, Sequence + +from .css.scalar import Scalar +from .geometry import Size + + +def resolve( + dimensions: Sequence[Scalar], + total: int, + gutter: int, + size: Size, + viewport: Size, +) -> list[tuple[int, int]]: + """Resolve a list of dimensions. + + Args: + dimensions (Sequence[Scalar]): Scalars for column / row sizes. + total (int): Total space to divide. + gutter (int): Gutter between rows / columns. + size (Size): Size of container. + viewport (Size): Size of viewport. + + Returns: + list[tuple[int, int]]: List of (, ) + """ + + resolved: list[tuple[Scalar, Fraction | None]] = [ + ( + (scalar, None) + if scalar.is_fraction + else (scalar, scalar.resolve_dimension(size, viewport)) + ) + for scalar in dimensions + ] + + from_float = Fraction.from_float + total_fraction = from_float( + sum(scalar.value for scalar, fraction in resolved if fraction is None) + ) + + if total_fraction: + total_gutter = gutter * (len(dimensions) - 1) + consumed = sum(fraction for _, fraction in resolved if fraction is not None) + remaining = max(Fraction(0), Fraction(total - total_gutter) - consumed) + fraction_unit = Fraction(remaining, total_fraction) + resolved_fractions = [ + from_float(scalar.value) * fraction_unit if fraction is None else fraction + for scalar, fraction in resolved + ] + else: + resolved_fractions = cast( + "list[Fraction]", [fraction for _, fraction in resolved] + ) + + fraction_gutter = Fraction(gutter) + offsets = [0] + [ + int(fraction) + for fraction in accumulate( + value + for fraction in resolved_fractions + for value in (fraction, fraction_gutter) + ) + ] + results = [ + (offset1, offset2 - offset1) + for offset1, offset2 in zip(offsets[::2], offsets[1::2]) + ] + + return results diff --git a/src/textual/css/_help_text.py b/src/textual/css/_help_text.py index 2dc87dd1b..e148426ed 100644 --- a/src/textual/css/_help_text.py +++ b/src/textual/css/_help_text.py @@ -608,7 +608,29 @@ def scrollbar_size_single_axis_help_text(property_name: str) -> HelpText: summary=f"Invalid value for [i]{property_name}[/]", bullets=[ Bullet( - markup=f"The [i]{property_name}[/] property can only be set to a positive integer, greather than zero", + markup=f"The [i]{property_name}[/] property can only be set to a positive integer, greater than zero", + examples=[ + Example(f"{property_name}: 2;"), + ], + ), + ], + ) + + +def integer_help_text(property_name: str) -> HelpText: + """Help text to show when the user supplies an invalid integer value. + + Args: + property_name (str): The name of the property + + Returns: + HelpText: Renderable for displaying the help text for this property + """ + return HelpText( + summary=f"Invalid value for [i]{property_name}[/]", + bullets=[ + Bullet( + markup=f"An integer value is expected here", examples=[ Example(f"{property_name}: 2;"), ], @@ -732,3 +754,12 @@ def style_flags_property_help_text( ).get_by_context(context), ], ) + + +def table_rows_or_columns_help_text( + property_name: str, value: str, context: StylingContext +): + property_name = _contextualize_property_name(property_name, context) + return HelpText( + summary=f"Invalid value '{value}' in [i]{property_name}[/] property" + ) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 027a88441..175504a85 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -194,6 +194,43 @@ class ScalarProperty: obj.refresh(layout=True) +class ScalarListProperty: + def __set_name__(self, owner: Styles, name: str) -> None: + self.name = name + + def __get__( + self, obj: StylesBase, objtype: type[StylesBase] | None = None + ) -> tuple[Scalar, ...] | None: + value = obj.get_rule(self.name) + return value + + def __set__( + self, obj: StylesBase, value: str | Iterable[str | float] | None + ) -> None: + if value is None: + obj.clear_rule(self.name) + obj.refresh(layout=True) + return + parse_values: Iterable[str | float] + if isinstance(value, str): + parse_values = value.split() + else: + parse_values = value + + scalars = [] + for parse_value in parse_values: + if isinstance(parse_value, (int, float)): + scalars.append(Scalar.from_number(parse_value)) + else: + scalars.append( + Scalar.parse(parse_value) + if isinstance(parse_value, str) + else parse_value + ) + if obj.set_rule(self.name, tuple(scalars)): + obj.refresh(layout=True) + + class BoxProperty: """Descriptor for getting and setting outlines and borders along a single edge. For example "border-right", "outline-bottom", etc. diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index f94b966b6..2af067c5a 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -8,22 +8,24 @@ import rich.repr from ._error_tools import friendly_list from ._help_renderables import HelpText from ._help_text import ( - spacing_invalid_value_help_text, - spacing_wrong_number_of_values_help_text, - scalar_help_text, - color_property_help_text, - string_enum_help_text, + align_help_text, border_property_help_text, - layout_property_help_text, + color_property_help_text, dock_property_help_text, fractional_property_help_text, - align_help_text, + integer_help_text, + layout_property_help_text, offset_property_help_text, offset_single_axis_help_text, - style_flags_property_help_text, property_invalid_value_help_text, + scalar_help_text, scrollbar_size_property_help_text, scrollbar_size_single_axis_help_text, + spacing_invalid_value_help_text, + spacing_wrong_number_of_values_help_text, + string_enum_help_text, + style_flags_property_help_text, + table_rows_or_columns_help_text, text_align_help_text, ) from .constants import ( @@ -841,6 +843,102 @@ class StylesBuilder: self.error(name, token, scrollbar_size_single_axis_help_text(name)) self.styles._rules["scrollbar_size_horizontal"] = value + def _process_table_rows_or_columns(self, name: str, tokens: list[Token]) -> None: + scalars: list[Scalar] = [] + for token in tokens: + if token.name == "number": + scalars.append(Scalar.from_number(float(token.value))) + elif token.name == "scalar": + scalars.append( + Scalar.parse( + token.value, + percent_unit=Unit.WIDTH if name == "rows" else Unit.HEIGHT, + ) + ) + else: + self.error( + name, + token, + table_rows_or_columns_help_text(name, token.value, context="css"), + ) + self.styles._rules[name.replace("-", "_")] = scalars + + process_table_rows = _process_table_rows_or_columns + process_table_columns = _process_table_rows_or_columns + + def _process_integer(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) != 1: + self.error(name, tokens[0], integer_help_text(name)) + else: + token = tokens[0] + if token.name != "number" or not token.value.isdigit(): + self.error(name, token, integer_help_text(name)) + value = int(token.value) + if value == 0: + self.error(name, token, integer_help_text(name)) + self.styles._rules[name.replace("-", "_")] = value + + process_table_gutter_horizontal = _process_integer + process_table_gutter_vertical = _process_integer + process_column_span = _process_integer + process_row_span = _process_integer + process_table_size_columns = _process_integer + process_table_size_rows = _process_integer + + def process_table_gutter(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) == 1: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["table_gutter_horizontal"] = value + self.styles._rules["table_gutter_vertical"] = value + + elif len(tokens) == 2: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["table_gutter_horizontal"] = value + token = tokens[1] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["table_gutter_vertical"] = value + + else: + self.error(name, tokens[0], "expected two integers here") + + def process_table_size(self, name: str, tokens: list[Token]) -> None: + if not tokens: + return + if len(tokens) == 1: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["table_size_columns"] = value + self.styles._rules["table_size_rows"] = 0 + + elif len(tokens) == 2: + token = tokens[0] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["table_size_columns"] = value + token = tokens[1] + if token.name != "number": + self.error(name, token, integer_help_text(name)) + value = max(0, int(token.value)) + self.styles._rules["table_size_rows"] = value + + else: + self.error(name, tokens[0], "expected two integers here") + def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None: """ Returns a valid CSS property "Python" name, or None if no close matches could be found. diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index 0025551d7..124ecb9bc 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -32,7 +32,7 @@ VALID_BORDER: Final[set[EdgeType]] = { "wide", } VALID_EDGE: Final = {"top", "right", "bottom", "left"} -VALID_LAYOUT: Final = {"vertical", "horizontal", "center"} +VALID_LAYOUT: Final = {"vertical", "horizontal", "center", "table"} VALID_BOX_SIZING: Final = {"border-box", "content-box"} VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index e8a25c514..2fe837e9e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -27,6 +27,7 @@ from ._style_properties import ( NameListProperty, NameProperty, OffsetProperty, + ScalarListProperty, ScalarProperty, SpacingProperty, StringEnumProperty, @@ -144,6 +145,16 @@ class RulesMap(TypedDict, total=False): content_align_horizontal: AlignHorizontal content_align_vertical: AlignVertical + table_size_rows: int + table_size_columns: int + table_gutter_horizontal: int + table_gutter_vertical: int + table_rows: tuple[Scalar, ...] + table_columns: tuple[Scalar, ...] + + row_span: int + column_span: int + text_align: TextAlign @@ -253,6 +264,17 @@ class StylesBase(ABC): content_align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top") content_align = AlignProperty() + table_rows = ScalarListProperty() + table_columns = ScalarListProperty() + + table_size_columns = IntegerProperty(default=1, layout=True) + table_size_rows = IntegerProperty(default=0, layout=True) + table_gutter_horizontal = IntegerProperty(default=0, layout=True) + table_gutter_vertical = IntegerProperty(default=0, layout=True) + + row_span = IntegerProperty(default=1, layout=True) + column_span = IntegerProperty(default=1, layout=True) + text_align = StringEnumProperty(VALID_TEXT_ALIGN, "start") def __eq__(self, styles: object) -> bool: @@ -780,6 +802,30 @@ class Styles(StylesBase): ) elif has_rule("content_align_vertical"): append_declaration("content-align-vertical", self.content_align_vertical) + elif has_rule("table_columns"): + append_declaration( + "table-columns", + " ".join(str(scalar) for scalar in self.table_columns or ()), + ) + elif has_rule("table_rows"): + append_declaration( + "table-rows", + " ".join(str(scalar) for scalar in self.table_rows or ()), + ) + elif has_rule("table_size_columns"): + append_declaration("table-size-columns", str(self.table_size_columns)) + elif has_rule("table_size_rows"): + append_declaration("table-size-rows", str(self.table_size_rows)) + elif has_rule("table_gutter_horizontal"): + append_declaration( + "table-gutter-horizontal", str(self.table_gutter_horizontal) + ) + elif has_rule("table_gutter_vertical"): + append_declaration("table-gutter-vertical", str(self.table_gutter_vertical)) + elif has_rule("row_span"): + append_declaration("row-span", str(self.row_span)) + elif has_rule("column_span"): + append_declaration("column-span", str(self.column_span)) lines.sort() return lines diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 3dfd634c6..7812431be 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -551,6 +551,19 @@ class Region(NamedTuple): height + expand_height * 2, ) + def clip_size(self, size: tuple[int, int]) -> Region: + """Clip the size to fit within minimum values. + + Args: + size (tuple[int, int]): Maximum width and height. + + Returns: + Region: No region, not bigger than size. + """ + x, y, width, height = self + max_width, max_height = size + return Region(x, y, min(width, max_width), min(height, max_height)) + @lru_cache(maxsize=1024) def overlaps(self, other: Region) -> bool: """Check if another region overlaps this region. diff --git a/src/textual/layouts/center.py b/src/textual/layouts/center.py index d8da84e10..288c2d38d 100644 --- a/src/textual/layouts/center.py +++ b/src/textual/layouts/center.py @@ -17,7 +17,6 @@ class CenterLayout(Layout): ) -> ArrangeResult: placements: list[WidgetPlacement] = [] - total_regions: list[Region] = [] parent_size = parent.outer_size container_width, container_height = size @@ -32,8 +31,6 @@ class CenterLayout(Layout): x = margin.left + max(0, (container_width - margin_width) // 2) y = margin.top + max(0, (container_height - margin_height) // 2) region = Region(x, y, int(width), int(height)) - total_regions.append(region.grow(margin)) - placements.append(WidgetPlacement(region, widget, 0)) + placements.append(WidgetPlacement(region, margin, widget, 0)) - placements.append(WidgetPlacement(Region.from_union(total_regions), None, 0)) return placements, set(children) diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index 5dd4eae9d..d0f3de049 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,15 +1,16 @@ from __future__ import annotations from .._layout import Layout -from .horizontal import HorizontalLayout -from .vertical import VerticalLayout from .center import CenterLayout - +from .horizontal import HorizontalLayout +from .table import TableLayout +from .vertical import VerticalLayout LAYOUT_MAP: dict[str, type[Layout]] = { - "vertical": VerticalLayout, - "horizontal": HorizontalLayout, "center": CenterLayout, + "horizontal": HorizontalLayout, + "table": TableLayout, + "vertical": VerticalLayout, } diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py deleted file mode 100644 index e2124f804..000000000 --- a/src/textual/layouts/grid.py +++ /dev/null @@ -1,443 +0,0 @@ -# TODO: This is deprecated (and probably doesn't work any more) - -from __future__ import annotations - -import sys -from collections import defaultdict -from dataclasses import dataclass -from itertools import cycle, product -from logging import getLogger -from operator import itemgetter -from typing import Iterable, NamedTuple, TYPE_CHECKING - -from .._layout_resolve import layout_resolve -from ..geometry import Size, Offset, Region -from .._layout import Layout, WidgetPlacement - -if TYPE_CHECKING: - from ..widget import Widget - from ..screen import Screen - - -if sys.version_info >= (3, 8): - from typing import Literal -else: - from typing_extensions import Literal - - -log = getLogger("rich") - -GridAlign = Literal["start", "end", "center", "stretch"] - - -@dataclass -class GridOptions: - name: str - size: int | None = None - fraction: int = 1 - min_size: int = 1 - max_size: int | None = None - - -class GridArea(NamedTuple): - col_start: str - col_end: str - row_start: str - row_end: str - - -class GridLayout(Layout): - def __init__( - self, - gap: tuple[int, int] | int | None = None, - gutter: tuple[int, int] | int | None = None, - align: tuple[GridAlign, GridAlign] | None = None, - ) -> None: - self.columns: list[GridOptions] = [] - self.rows: list[GridOptions] = [] - self.areas: dict[str, GridArea] = {} - self.widgets: dict[Widget, str | None] = {} - self.column_gap = 0 - self.row_gap = 0 - self.column_repeat = False - self.row_repeat = False - self.column_align: GridAlign = "start" - self.row_align: GridAlign = "start" - self.column_gutter: int = 0 - self.row_gutter: int = 0 - self.hidden_columns: set[str] = set() - self.hidden_rows: set[str] = set() - - if gap is not None: - if isinstance(gap, tuple): - self.set_gap(*gap) - else: - self.set_gap(gap) - - if gutter is not None: - if isinstance(gutter, tuple): - self.set_gutter(*gutter) - else: - self.set_gutter(gutter) - - if align is not None: - self.set_align(*align) - - super().__init__() - - def is_row_visible(self, row_name: str) -> bool: - return row_name not in self.hidden_rows - - def is_column_visible(self, column_name: str) -> bool: - return column_name not in self.hidden_columns - - def show_row(self, row_name: str, visible: bool = True) -> bool: - changed = (row_name in self.hidden_rows) == visible - if visible: - self.hidden_rows.discard(row_name) - else: - self.hidden_rows.add(row_name) - if changed: - self.require_update() - return True - return False - - def show_column(self, column_name: str, visible: bool = True) -> bool: - changed = (column_name in self.hidden_columns) == visible - if visible: - self.hidden_columns.discard(column_name) - else: - self.hidden_columns.add(column_name) - if changed: - self.require_update() - return True - return False - - def add_column( - self, - name: str, - *, - size: int | None = None, - fraction: int = 1, - min_size: int = 1, - max_size: int | None = None, - repeat: int = 1, - ) -> None: - names = ( - [name] - if repeat == 1 - else [f"{name}{count}" for count in range(1, repeat + 1)] - ) - append = self.columns.append - for name in names: - append( - GridOptions( - name, - size=size, - fraction=fraction, - min_size=min_size, - max_size=max_size, - ) - ) - self.require_update() - - def add_row( - self, - name: str, - *, - size: int | None = None, - fraction: int = 1, - min_size: int = 1, - max_size: int | None = None, - repeat: int = 1, - ) -> None: - names = ( - [name] - if repeat == 1 - else [f"{name}{count}" for count in range(1, repeat + 1)] - ) - append = self.rows.append - for name in names: - append( - GridOptions( - name, - size=size, - fraction=fraction, - min_size=min_size, - max_size=max_size, - ) - ) - self.require_update() - - def _add_area( - self, name: str, columns: str | tuple[str, str], rows: str | tuple[str, str] - ) -> None: - if isinstance(columns, str): - column_start = f"{columns}-start" - column_end = f"{columns}-end" - else: - column_start, column_end = columns - - if isinstance(rows, str): - row_start = f"{rows}-start" - row_end = f"{rows}-end" - else: - row_start, row_end = rows - - self.areas[name] = GridArea(column_start, column_end, row_start, row_end) - - def add_areas(self, **areas: str) -> None: - for name, area in areas.items(): - area = area.replace(" ", "") - column, _, row = area.partition(",") - - column_start, column_sep, column_end = column.partition("|") - row_start, row_sep, row_end = row.partition("|") - - self._add_area( - name, - (column_start, column_end) if column_sep else column, - (row_start, row_end) if row_sep else row, - ) - self.require_update() - - 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 - self.require_update() - - def set_gutter(self, column: int, row: int | None = None) -> None: - self.column_gutter = column - self.row_gutter = column if row is None else row - self.require_update() - - def add_widget(self, widget: Widget, area: str | None = None) -> Widget: - self.widgets[widget] = area - self.require_update() - return widget - - def place(self, *auto_widgets: Widget, **area_widgets: Widget) -> None: - widgets = self.widgets - for area, widget in area_widgets.items(): - widgets[widget] = area - for widget in auto_widgets: - widgets[widget] = None - self.require_update() - - 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 - self.require_update() - - 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 - self.require_update() - - @classmethod - def _align( - cls, - region: Region, - grid_size: Size, - container: Size, - col_align: GridAlign, - row_align: GridAlign, - ) -> Region: - 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 - - offset = Offset( - align(grid_size.width, container.width, col_align), - align(grid_size.height, container.height, row_align), - ) - - region = region.translate(offset) - return region - - def get_widgets(self) -> Iterable[Widget]: - return self.widgets.keys() - - def arrange( - self, view: Screen, size: Size, scroll: Offset - ) -> Iterable[WidgetPlacement]: - """Generate a map that associates widgets with their location on screen. - - Args: - width (int): [description] - height (int): [description] - offset (Point, optional): [description]. Defaults to Point(0, 0). - - Returns: - dict[Widget, OrderedRegion]: [description] - """ - width, height = size - - def resolve( - size: int, edges: list[GridOptions], gap: int, repeat: bool - ) -> Iterable[tuple[int, int]]: - total_gap = gap * (len(edges) - 1) - tracks: Iterable[int] - tracks = [ - track if edge.max_size is None else min(edge.max_size, track) - for track, edge in zip(layout_resolve(size - total_gap, edges), edges) - ] - if repeat: - tracks = cycle(tracks) - total = 0 - edge_count = len(edges) - for index, track in enumerate(tracks): - if total + track >= size and index >= edge_count: - break - yield total, total + track - total += track + gap - - def resolve_tracks( - grid: list[GridOptions], size: int, gap: int, repeat: bool - ) -> tuple[list[str], dict[str, tuple[int, int]], int, int]: - spans = [ - (options.name, span) - for options, span in zip(cycle(grid), resolve(size, grid, gap, repeat)) - ] - - max_size = 0 - tracks: dict[str, tuple[int, int]] = {} - counts: dict[str, int] = defaultdict(int) - if repeat: - names = [] - for index, (name, (start, end)) in enumerate(spans): - max_size = max(max_size, end) - counts[name] += 1 - count = counts[name] - names.append(f"{name}-{count}") - tracks[f"{name}-{count}-start"] = (index, start) - tracks[f"{name}-{count}-end"] = (index, end) - else: - names = [name for name, _span in spans] - for index, (name, (start, end)) in enumerate(spans): - max_size = max(max_size, end) - tracks[f"{name}-start"] = (index, start) - tracks[f"{name}-end"] = (index, end) - - return names, tracks, len(spans), max_size - - container = Size(width - self.column_gutter * 2, height - self.row_gutter * 2) - column_names, column_tracks, column_count, column_size = resolve_tracks( - [ - options - for options in self.columns - if options.name not in self.hidden_columns - ], - container.width, - self.column_gap, - self.column_repeat, - ) - row_names, row_tracks, row_count, row_size = resolve_tracks( - [options for options in self.rows if options.name not in self.hidden_rows], - container.height, - self.row_gap, - self.row_repeat, - ) - grid_size = Size(column_size, row_size) - - widget_areas = ( - (widget, area) - for widget, area in self.widgets.items() - if area and widget.display - ) - - free_slots = { - (col, row) for col, row in product(range(column_count), range(row_count)) - } - order = 1 - from_corners = Region.from_corners - gutter = Offset(self.column_gutter, self.row_gutter) - for widget, area in widget_areas: - column_start, column_end, row_start, row_end = self.areas[area] - try: - col1, x1 = column_tracks[column_start] - col2, x2 = column_tracks[column_end] - row1, y1 = row_tracks[row_start] - row2, y2 = row_tracks[row_end] - except (KeyError, IndexError): - continue - - free_slots.difference_update( - product(range(col1, col2 + 1), range(row1, row2 + 1)) - ) - - region = self._align( - from_corners(x1, y1, x2, y2), - grid_size, - container, - self.column_align, - self.row_align, - ) - yield WidgetPlacement(region + gutter, widget, (0, order)) - order += 1 - - # Widgets with no area assigned. - auto_widgets = (widget for widget, area in self.widgets.items() if area is None) - - grid_slots = sorted( - ( - slot - for slot in product(range(column_count), range(row_count)) - if slot in free_slots - ), - key=itemgetter(1, 0), # TODO: other orders - ) - - for widget, (col, row) in zip(auto_widgets, grid_slots): - - col_name = column_names[col] - row_name = row_names[row] - _col1, x1 = column_tracks[f"{col_name}-start"] - _col2, x2 = column_tracks[f"{col_name}-end"] - - _row1, y1 = row_tracks[f"{row_name}-start"] - _row2, y2 = row_tracks[f"{row_name}-end"] - - region = self._align( - from_corners(x1, y1, x2, y2), - grid_size, - container, - self.column_align, - self.row_align, - ) - yield WidgetPlacement(region + gutter, widget, (0, order)) - order += 1 - - return map - - -if __name__ == "__main__": - layout = GridLayout() - - 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_areas(center="a-start|b-end,top") - # layout.set_repeat(True) - - from ..widgets import Placeholder - - layout.place(center=Placeholder()) - - from rich import print - - print(layout.widgets) - - map = layout.generate_map(100, 80) - print(map) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 7360c0c2e..fd03b09b7 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -61,11 +61,8 @@ class HorizontalLayout(Layout): max_height = max( max_height, content_height + offset_y + box_model.margin.bottom ) - add_placement(WidgetPlacement(region, widget, 0)) + add_placement(WidgetPlacement(region, box_model.margin, widget, 0)) x = next_x + margin max_width = x - total_region = Region(0, 0, int(max_width), int(max_height)) - add_placement(WidgetPlacement(total_region, None, 0)) - return placements, set(displayed_children) diff --git a/src/textual/layouts/table.py b/src/textual/layouts/table.py new file mode 100644 index 000000000..0f5a78dea --- /dev/null +++ b/src/textual/layouts/table.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from fractions import Fraction +from typing import TYPE_CHECKING, Iterable + +from .._layout import ArrangeResult, Layout, WidgetPlacement +from .._resolve import resolve +from ..css.scalar import Scalar +from ..geometry import Region, Size, Spacing + +if TYPE_CHECKING: + from ..widget import Widget + + +class TableLayout(Layout): + """Used to layout Widgets in to a table.""" + + name = "table" + + def arrange( + self, parent: Widget, children: list[Widget], size: Size + ) -> ArrangeResult: + styles = parent.styles + row_scalars = styles.table_rows or [Scalar.parse("1fr")] + column_scalars = styles.table_columns or [Scalar.parse("1fr")] + gutter_horizontal = styles.table_gutter_horizontal + gutter_vertical = styles.table_gutter_vertical + table_size_columns = max(1, styles.table_size_columns) + table_size_rows = styles.table_size_rows + viewport = parent.screen.size + + def cell_coords(column_count: int) -> Iterable[tuple[int, int]]: + """Iterate over table coordinates ad infinitum. + + Args: + column_count (int): Number of columns + + """ + row = 0 + while True: + for column in range(column_count): + yield (column, row) + row += 1 + + def widget_coords( + column_start: int, row_start: int, columns: int, rows: int + ) -> set[tuple[int, int]]: + """Get coords occupied by a cell. + + Args: + column_start (int): Start column. + row_start (int): Start_row. + columns (int): Number of columns. + rows (int): Number of rows. + + Returns: + set[tuple[int, int]]: Set of coords. + """ + return { + (column, row) + for column in range(column_start, column_start + columns) + for row in range(row_start, row_start + rows) + } + + def repeat_scalars(scalars: Iterable[Scalar], count: int) -> list[Scalar]: + """Repeat an iterable of scalars as many times as required to return + a list of `count` values. + + Args: + scalars (Iterable[T]): Iterable of values. + count (int): Number of values to return. + + Returns: + list[T]: A list of values. + """ + limited_values = list(scalars)[:] + while len(limited_values) < count: + limited_values.extend(scalars) + return limited_values[:count] + + cell_map: dict[tuple[int, int], tuple[Widget, bool]] = {} + cell_size_map: dict[Widget, tuple[int, int, int, int]] = {} + + column_count = table_size_columns + next_coord = iter(cell_coords(column_count)).__next__ + cell_coord = (0, 0) + column = row = 0 + + for child in children: + child_styles = child.styles + column_span = child_styles.column_span or 1 + row_span = child_styles.row_span or 1 + # Find a slot where this cell fits + # A cell on a previous row may have a row span + while True: + column, row = cell_coord + coords = widget_coords(column, row, column_span, row_span) + if cell_map.keys().isdisjoint(coords): + for coord in coords: + cell_map[coord] = (child, coord == cell_coord) + cell_size_map[child] = ( + column, + row, + column_span - 1, + row_span - 1, + ) + break + else: + cell_coord = next_coord() + continue + cell_coord = next_coord() + + # Resolve columns / rows + columns = resolve( + repeat_scalars(column_scalars, table_size_columns), + size.width, + gutter_vertical, + size, + viewport, + ) + rows = resolve( + repeat_scalars( + row_scalars, table_size_rows if table_size_rows else row + 1 + ), + size.height, + gutter_horizontal, + size, + viewport, + ) + + placements: list[WidgetPlacement] = [] + add_placement = placements.append + fraction_unit = Fraction(1) + widgets: list[Widget] = [] + add_widget = widgets.append + max_column = len(columns) - 1 + max_row = len(rows) - 1 + margin = Spacing() + for widget, (column, row, column_span, row_span) in cell_size_map.items(): + x = columns[column][0] + if row > max_row: + break + y = rows[row][0] + x2, cell_width = columns[min(max_column, column + column_span)] + y2, cell_height = rows[min(max_row, row + row_span)] + cell_size = Size(cell_width + x2 - x, cell_height + y2 - y) + width, height, margin = widget._get_box_model( + cell_size, + viewport, + fraction_unit, + ) + region = ( + Region(x, y, int(width), int(height)) + .shrink(margin) + .clip_size(cell_size) + ) + add_placement(WidgetPlacement(region, margin, widget)) + add_widget(widget) + + return (placements, set(widgets)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index dcba4cacc..266dfd74d 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -54,10 +54,7 @@ class VerticalLayout(Layout): ) next_y = y + content_height region = Region(offset_x, int(y), int(content_width), int(next_y) - int(y)) - add_placement(WidgetPlacement(region, widget, 0)) + add_placement(WidgetPlacement(region, box_model.margin, widget, 0)) y = next_y + margin - total_region = Region(0, 0, size.width, int(y)) - add_placement(WidgetPlacement(total_region, None, 0)) - return placements, set(children) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 689a09016..b9dd00774 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -4,16 +4,16 @@ from math import ceil import rich.repr from rich.color import Color -from rich.console import ConsoleOptions, RenderResult, RenderableType +from rich.console import ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment, Segments from rich.style import Style, StyleType -from textual.reactive import Reactive -from textual.renderables.blank import Blank from . import events from ._types import MessageTarget from .geometry import Offset from .message import Message +from .reactive import Reactive +from .renderables.blank import Blank from .widget import Widget @@ -86,7 +86,6 @@ class ScrollBarRender: virtual_size: float = 50, window_size: float = 20, position: float = 0, - ascii_only: bool = False, thickness: int = 1, vertical: bool = True, back_color: Color = Color.parse("#555555"), @@ -94,15 +93,9 @@ class ScrollBarRender: ) -> Segments: if vertical: - if ascii_only: - bars = ["|", "|", "|", "|", "|", "|", "|", "|"] - else: - bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] + bars = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] else: - if ascii_only: - bars = ["-", "-", "-", "-", "-", "-", "-", "-"] - else: - bars = ["█", "▉", "▊", "▋", "▌", "▍", "▎", "▏"] + bars = ["█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", " "] back = back_color bar = bar_color @@ -117,11 +110,11 @@ class ScrollBarRender: if window_size and size and virtual_size and size != virtual_size: step_size = virtual_size / size - start = int(position / step_size * 8) - end = start + max(8, int(ceil(window_size / step_size * 8))) + start = int(position / step_size * 9) + end = start + max(9, int(ceil(window_size / step_size * 9))) - start_index, start_bar = divmod(start, 8) - end_index, end_bar = divmod(end, 8) + start_index, start_bar = divmod(start, 9) + end_index, end_bar = divmod(end, 9) upper = {"@click": "scroll_up"} lower = {"@click": "scroll_down"} @@ -138,14 +131,14 @@ class ScrollBarRender: if start_index < len(segments): segments[start_index] = _Segment( - bars[7 - start_bar] * width_thickness, + bars[8 - start_bar] * width_thickness, _Style(bgcolor=back, color=bar, meta=foreground_meta) if vertical else _Style(bgcolor=bar, color=back, meta=foreground_meta), ) if end_index < len(segments): segments[end_index] = _Segment( - bars[7 - end_bar] * width_thickness, + bars[8 - end_bar] * width_thickness, _Style(bgcolor=bar, color=back, meta=foreground_meta) if vertical else _Style(bgcolor=back, color=bar, meta=foreground_meta), @@ -296,6 +289,7 @@ class ScrollBarCorner(Widget): super().__init__(name=name) def render(self) -> RenderableType: + assert self.parent is not None styles = self.parent.styles color = styles.scrollbar_corner_color return Blank(color) diff --git a/src/textual/widget.py b/src/textual/widget.py index ab0ea550f..0a0d357ac 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -185,7 +185,7 @@ class Widget(DOMNode): @property def _allow_scroll(self) -> bool: - """Check if both axes may be scrolled. + """Check if both axis may be scrolled. Returns: bool: True if horizontal and vertical scrolling is enabled. diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 16680cc68..580d68e02 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -24,14 +24,10 @@ def test_arrange_dock_top(): container, [child, header], Size(80, 24), Size(80, 24) ) assert placements == [ - WidgetPlacement(Region(0, 0, 80, 1), header, order=TOP_Z, fixed=True), - WidgetPlacement(Region(0, 1, 80, 23), child, order=0, fixed=False), WidgetPlacement( - region=Region(x=0, y=1, width=80, height=23), - widget=None, - order=0, - fixed=False, + Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), + WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), ] assert widgets == {child, header} assert spacing == Spacing(1, 0, 0, 0) @@ -48,14 +44,10 @@ def test_arrange_dock_left(): container, [child, header], Size(80, 24), Size(80, 24) ) assert placements == [ - WidgetPlacement(Region(0, 0, 10, 24), header, order=TOP_Z, fixed=True), - WidgetPlacement(Region(10, 0, 70, 24), child, order=0, fixed=False), WidgetPlacement( - region=Region(x=10, y=0, width=70, height=24), - widget=None, - order=0, - fixed=False, + Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), + WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] assert widgets == {child, header} assert spacing == Spacing(0, 0, 0, 10) @@ -72,14 +64,10 @@ def test_arrange_dock_right(): container, [child, header], Size(80, 24), Size(80, 24) ) assert placements == [ - WidgetPlacement(Region(70, 0, 10, 24), header, order=TOP_Z, fixed=True), - WidgetPlacement(Region(0, 0, 70, 24), child, order=0, fixed=False), WidgetPlacement( - region=Region(x=0, y=0, width=70, height=24), - widget=None, - order=0, - fixed=False, + Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), + WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] assert widgets == {child, header} assert spacing == Spacing(0, 10, 0, 0) @@ -96,14 +84,10 @@ def test_arrange_dock_bottom(): container, [child, header], Size(80, 24), Size(80, 24) ) assert placements == [ - WidgetPlacement(Region(0, 23, 80, 1), header, order=TOP_Z, fixed=True), - WidgetPlacement(Region(0, 0, 80, 23), child, order=0, fixed=False), WidgetPlacement( - region=Region(x=0, y=0, width=80, height=23), - widget=None, - order=0, - fixed=False, + Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), + WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), ] assert widgets == {child, header} assert spacing == Spacing(0, 0, 1, 0) diff --git a/tests/test_geometry.py b/tests/test_geometry.py index df345158a..766dc703f 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -195,6 +195,10 @@ def test_crop_size(): assert Region(10, 20, 100, 200).crop_size((500, 40)) == Region(10, 20, 100, 40) +def test_clip_size(): + assert Region(10, 10, 100, 80).clip_size((50, 100)) == Region(10, 10, 50, 80) + + def test_region_overlaps(): assert Region(10, 10, 30, 20).overlaps(Region(0, 0, 20, 20)) assert not Region(10, 10, 5, 5).overlaps(Region(15, 15, 20, 20)) diff --git a/tests/test_layouts_center.py b/tests/test_layouts_center.py index 52cbd0cc6..fecd97dcd 100644 --- a/tests/test_layouts_center.py +++ b/tests/test_layouts_center.py @@ -1,5 +1,5 @@ from textual._layout import WidgetPlacement -from textual.geometry import Region, Size +from textual.geometry import Region, Size, Spacing from textual.layouts.center import CenterLayout from textual.widget import Widget @@ -19,15 +19,10 @@ def test_center_layout(): expected = [ WidgetPlacement( region=Region(x=25, y=7, width=10, height=5), + margin=Spacing(), widget=child, order=0, fixed=False, ), - WidgetPlacement( - region=Region(x=25, y=7, width=10, height=5), - widget=None, - order=0, - fixed=False, - ), ] assert placements == expected diff --git a/tests/test_resolve.py b/tests/test_resolve.py new file mode 100644 index 000000000..4fac5ded2 --- /dev/null +++ b/tests/test_resolve.py @@ -0,0 +1,58 @@ +import pytest + +from textual.geometry import Size +from textual.css.scalar import Scalar +from textual._resolve import resolve + + +def test_resolve_empty(): + assert resolve([], 10, 1, Size(20, 10), Size(80, 24)) == [] + + +@pytest.mark.parametrize( + "scalars,total,gutter,result", + [ + (["10"], 100, 0, [(0, 10)]), + ( + ["10", "20"], + 100, + 0, + [(0, 10), (10, 20)], + ), + ( + ["10", "20"], + 100, + 1, + [(0, 10), (11, 20)], + ), + ( + ["10", "1fr"], + 100, + 1, + [(0, 10), (11, 89)], + ), + ( + ["1fr", "1fr"], + 100, + 0, + [(0, 50), (50, 50)], + ), + ( + ["3", "1fr", "1fr", "1"], + 100, + 1, + [(0, 3), (4, 46), (51, 47), (99, 1)], + ), + ], +) +def test_resolve(scalars, total, gutter, result): + assert ( + resolve( + [Scalar.parse(scalar) for scalar in scalars], + total, + gutter, + Size(40, 20), + Size(80, 24), + ) + == result + )