diff --git a/sandbox/will/fr.py b/sandbox/will/fr.py new file mode 100644 index 000000000..e82b895db --- /dev/null +++ b/sandbox/will/fr.py @@ -0,0 +1,96 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Static + + +class StaticText(Static): + pass + + +class Header(Static): + pass + + +class Footer(Static): + pass + + +class FrApp(App): + + CSS = """ + Screen { + layout: horizontal; + align: center middle; + + } + + Vertical { + + } + + Header { + background: $boost; + + content-align: center middle; + text-align: center; + color: $text; + height: 3; + border: tall $warning; + } + + Horizontal { + height: 1fr; + align: center middle; + } + + Footer { + background: $boost; + + content-align: center middle; + text-align: center; + + color: $text; + height: 6; + border: tall $warning; + } + + StaticText { + background: $boost; + height: 8; + content-align: center middle; + text-align: center; + color: $text; + } + + #foo { + width: 10; + border: tall $primary; + } + + #bar { + width: 1fr; + border: tall $error; + + } + + #baz { + width: 20; + border: tall $success; + } + + """ + + def compose(self) -> ComposeResult: + yield Vertical( + Header("HEADER"), + Horizontal( + StaticText("foo", id="foo"), + StaticText("bar", id="bar"), + StaticText("baz", id="baz"), + ), + Footer("FOOTER"), + ) + + +app = FrApp() +app.run() diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index 2d89fad11..2bf706e0f 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -60,10 +60,9 @@ def arrange( for dock_widget in dock_widgets: edge = dock_widget.styles.dock - fraction_unit = Fraction( - size.height if edge in ("top", "bottom") else size.width + box_model = dock_widget._get_box_model( + size, viewport, Fraction(size.width), Fraction(size.height) ) - box_model = dock_widget._get_box_model(size, viewport, fraction_unit) widget_width_fraction, widget_height_fraction, margin = box_model widget_width = int(widget_width_fraction) + margin.width diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index cf10dfcb5..3ace9759d 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -1,12 +1,23 @@ from __future__ import annotations +import sys from fractions import Fraction from itertools import accumulate -from typing import cast, Sequence +from typing import cast, Sequence, TYPE_CHECKING +from .box_model import BoxModel from .css.scalar import Scalar from .geometry import Size +if TYPE_CHECKING: + from .widget import Widget + + +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + def resolve( dimensions: Sequence[Scalar], @@ -71,3 +82,79 @@ def resolve( ] return results + + +def resolve_box_models( + dimensions: list[Scalar | None], + widgets: list[Widget], + size: Size, + parent_size: Size, + dimension: Literal["width", "height"] = "width", +) -> list[BoxModel]: + """Resolve box models for a list of dimensions + + Args: + dimensions (list[Scalar | None]): A list of Scalars or Nones for each dimension. + widgets (list[Widget]): Widgets in resolve. + size (Size): size of container. + parent_size (Size): Size of parent. + dimensions (Literal["width", "height"]): Which dimension to resolve. + + Returns: + list[BoxModel]: List of resolved box models. + """ + + fraction_width = Fraction(size.width) + fraction_height = Fraction(size.height) + box_models: list[BoxModel | None] = [ + ( + None + if dimension is not None and dimension.is_fraction + else widget._get_box_model( + size, parent_size, fraction_width, fraction_height + ) + ) + for (dimension, widget) in zip(dimensions, widgets) + ] + + if dimension == "width": + total_remaining = sum( + box_model.width for box_model in box_models if box_model is not None + ) + remaining_space = max(0, size.width - total_remaining) + else: + total_remaining = sum( + box_model.height for box_model in box_models if box_model is not None + ) + remaining_space = max(0, size.height - total_remaining) + + fraction_unit = Fraction( + remaining_space, + int( + sum( + dimension.value + for dimension in dimensions + if dimension and dimension.is_fraction + ) + ) + or 1, + ) + if dimension == "width": + width_fraction = fraction_unit + height_fraction = Fraction(size.height) + else: + width_fraction = Fraction(size.width) + height_fraction = fraction_unit + + box_models = [ + box_model + or widget._get_box_model( + size, + parent_size, + width_fraction, + height_fraction, + ) + for widget, box_model in zip(widgets, box_models) + ] + + return cast("list[BoxModel]", box_models) diff --git a/src/textual/app.py b/src/textual/app.py index b3e2b8179..9e96a4b94 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1271,7 +1271,12 @@ class App(Generic[ReturnType], DOMNode): self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") async def _on_compose(self) -> None: - widgets = list(self.compose()) + try: + widgets = list(self.compose()) + except TypeError as error: + raise TypeError( + f"{self!r} compose() returned an invalid response; {error}" + ) from None await self.mount_all(widgets) def _on_idle(self) -> None: diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 7271bef54..2bd93aa00 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -20,7 +20,8 @@ def get_box_model( styles: StylesBase, container: Size, viewport: Size, - fraction_unit: Fraction, + width_fraction: Fraction, + height_fraction: Fraction, get_content_width: Callable[[Size, Size], int], get_content_height: Callable[[Size, Size, int], int], ) -> BoxModel: @@ -30,6 +31,8 @@ def get_box_model( styles (StylesBase): Styles object. container (Size): The size of the widget container. viewport (Size): The viewport size. + width_fraction (Fraction): A fraction used for 1 `fr` unit on the width dimension. + height_fraction (Fraction):A fraction used for 1 `fr` unit on the height dimension. get_auto_width (Callable): A callable which accepts container size and parent size and returns a width. get_auto_height (Callable): A callable which accepts container size and parent size and returns a height. @@ -63,7 +66,7 @@ def get_box_model( # An explicit width styles_width = styles.width content_width = styles_width.resolve_dimension( - sizing_container - styles.margin.totals, viewport, fraction_unit + sizing_container - styles.margin.totals, viewport, width_fraction ) if is_border_box and styles_width.excludes_border: content_width -= gutter.width @@ -71,14 +74,14 @@ def get_box_model( if styles.min_width is not None: # Restrict to minimum width, if set min_width = styles.min_width.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, width_fraction ) content_width = max(content_width, min_width) if styles.max_width is not None: # Restrict to maximum width, if set max_width = styles.max_width.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, width_fraction ) if is_border_box: max_width -= gutter.width @@ -98,7 +101,7 @@ def get_box_model( styles_height = styles.height # Explicit height set content_height = styles_height.resolve_dimension( - sizing_container - styles.margin.totals, viewport, fraction_unit + sizing_container - styles.margin.totals, viewport, height_fraction ) if is_border_box and styles_height.excludes_border: content_height -= gutter.height @@ -106,14 +109,14 @@ def get_box_model( if styles.min_height is not None: # Restrict to minimum height, if set min_height = styles.min_height.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, height_fraction ) content_height = max(content_height, min_height) if styles.max_height is not None: # Restrict maximum height, if set max_height = styles.max_height.resolve_dimension( - content_container, viewport, fraction_unit + content_container, viewport, height_fraction ) content_height = min(content_height, max_height) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index ea9466461..cff1de36d 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -145,9 +145,7 @@ class GridLayout(Layout): 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, + cell_size, viewport, fraction_unit, fraction_unit ) region = ( Region(x, y, int(width + margin.width), int(height + margin.height)) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 1216beb4e..210ee95d3 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -1,12 +1,11 @@ from __future__ import annotations from fractions import Fraction -from typing import cast -from textual.geometry import Size, Region -from textual._layout import ArrangeResult, Layout, WidgetPlacement - -from textual.widget import Widget +from .._resolve import resolve_box_models +from ..geometry import Size, Region +from .._layout import ArrangeResult, Layout, WidgetPlacement +from ..widget import Widget class HorizontalLayout(Layout): @@ -22,20 +21,16 @@ class HorizontalLayout(Layout): placements: list[WidgetPlacement] = [] add_placement = placements.append - x = max_height = Fraction(0) parent_size = parent.outer_size - styles = [child.styles for child in children if child.styles.width is not None] - total_fraction = sum( - [int(style.width.value) for style in styles if style.width.is_fraction] + box_models = resolve_box_models( + [child.styles.width for child in children], + children, + size, + parent_size, + dimension="width", ) - fraction_unit = Fraction(size.width, total_fraction or 1) - - box_models = [ - widget._get_box_model(size, parent_size, fraction_unit) - for widget in cast("list[Widget]", children) - ] margins = [ max((box1.margin.right, box2.margin.left)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 9460bf0db..ccdcd1be3 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -3,6 +3,7 @@ from __future__ import annotations from fractions import Fraction from typing import TYPE_CHECKING +from .._resolve import resolve_box_models from ..geometry import Region, Size from .._layout import ArrangeResult, Layout, WidgetPlacement @@ -21,19 +22,15 @@ class VerticalLayout(Layout): placements: list[WidgetPlacement] = [] add_placement = placements.append - parent_size = parent.outer_size - styles = [child.styles for child in children if child.styles.height is not None] - total_fraction = sum( - [int(style.height.value) for style in styles if style.height.is_fraction] + box_models = resolve_box_models( + [child.styles.height for child in children], + children, + size, + parent_size, + dimension="height", ) - fraction_unit = Fraction(size.height, total_fraction or 1) - - box_models = [ - widget._get_box_model(size, parent_size, fraction_unit) - for widget in children - ] margins = [ max((box1.margin.bottom, box2.margin.top)) diff --git a/src/textual/widget.py b/src/textual/widget.py index a26b5f8b4..13fd2ab5b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -425,14 +425,19 @@ class Widget(DOMNode): ) def _get_box_model( - self, container: Size, viewport: Size, fraction_unit: Fraction + self, + container: Size, + viewport: Size, + width_fraction: Fraction, + height_fraction: Fraction, ) -> BoxModel: """Process the box model for this widget. Args: container (Size): The size of the container widget (with a layout) viewport (Size): The viewport size. - fraction_unit (Fraction): The unit used for `fr` units. + width_fraction (Fraction): A fraction used for 1 `fr` unit on the width dimension. + height_fraction (Fraction):A fraction used for 1 `fr` unit on the height dimension. Returns: BoxModel: The size and margin for this widget. @@ -441,7 +446,8 @@ class Widget(DOMNode): self.styles, container, viewport, - fraction_unit, + width_fraction, + height_fraction, self.get_content_width, self.get_content_height, ) @@ -1946,8 +1952,13 @@ class Widget(DOMNode): async def handle_key(self, event: events.Key) -> bool: return await self.dispatch_key(event) - async def _on_compose(self, event: events.Compose) -> None: - widgets = list(self.compose()) + async def _on_compose(self) -> None: + try: + widgets = list(self.compose()) + except TypeError as error: + raise TypeError( + f"{self!r} compose() returned an invalid response; {error}" + ) from None await self.mount(*widgets) def _on_mount(self, event: events.Mount) -> None: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index e01117b7d..5af9492f8 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -4913,6 +4913,162 @@ ''' # --- +# name: test_fr_units + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + FRApp + + + + + + + + + + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + HEADER + + + + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ┏━━━━━━━━┓┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓┏━━━━━━┓ + foobarbaz + + + + + + + + + + + + ┗━━━━━━━━┛┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛┗━━━━━━┛ + ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + FOOTER + + ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + + + + + ''' +# --- # name: test_grid_layout_basic ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index a4a4accaa..42e34ee9a 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -85,6 +85,10 @@ def test_header_render(snap_compare): assert snap_compare("docs/examples/widgets/header.py") +def test_fr_units(snap_compare): + assert snap_compare("tests/snapshots/fr_units.py") + + # --- CSS properties --- # We have a canonical example for each CSS property that is shown in their docs. # If any of these change, something has likely broken, so snapshot each of them. diff --git a/tests/snapshots/fr_units.py b/tests/snapshots/fr_units.py new file mode 100644 index 000000000..d9bcddeb9 --- /dev/null +++ b/tests/snapshots/fr_units.py @@ -0,0 +1,55 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal, Vertical +from textual.widgets import Static + + +class StaticText(Static): + pass + + +class FRApp(App): + + CSS = """ + StaticText { + height: 1fr; + background: $boost; + border: heavy white; + } + #foo { + width: 10; + } + #bar { + width: 1fr; + } + #baz { + width: 8; + } + #header { + height: 1fr + } + + Horizontal { + height: 2fr; + } + + #footer { + height: 4; + } + + """ + + def compose(self) -> ComposeResult: + yield Vertical( + StaticText("HEADER", id="header"), + Horizontal( + StaticText("foo", id="foo"), + StaticText("bar", id="bar"), + StaticText("baz", id="baz"), + ), + StaticText("FOOTER", id="footer"), + ) + + +if __name__ == "__main__": + app = FRApp() + app.run() diff --git a/tests/test_box_model.py b/tests/test_box_model.py index 1c82620ff..889140041 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -26,7 +26,7 @@ def test_content_box(): assert False, "must not be called" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) # Size should be inclusive of padding / border assert box_model == BoxModel(Fraction(10), Fraction(8), Spacing(0, 0, 0, 0)) @@ -35,7 +35,7 @@ def test_content_box(): styles.box_sizing = "content-box" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) # width and height have added padding / border to accommodate content assert box_model == BoxModel(Fraction(14), Fraction(12), Spacing(0, 0, 0, 0)) @@ -53,7 +53,7 @@ def test_width(): return 10 box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) @@ -61,7 +61,7 @@ def test_width(): styles.margin = (1, 2, 3, 4) box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -69,7 +69,7 @@ def test_width(): styles.width = "auto" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) # Setting width to auto should call get_auto_width assert box_model == BoxModel(Fraction(10), Fraction(16), Spacing(1, 2, 3, 4)) @@ -78,7 +78,7 @@ def test_width(): styles.width = "100vw" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(80), Fraction(16), Spacing(1, 2, 3, 4)) @@ -86,7 +86,7 @@ def test_width(): styles.width = "100%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -94,7 +94,7 @@ def test_width(): styles.max_width = "50%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(30), Fraction(16), Spacing(1, 2, 3, 4)) @@ -111,7 +111,7 @@ def test_height(): return 10 box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(60), Fraction(20), Spacing(0, 0, 0, 0)) @@ -119,7 +119,7 @@ def test_height(): styles.margin = (1, 2, 3, 4) box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -127,7 +127,7 @@ def test_height(): styles.height = "100vh" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(24), Spacing(1, 2, 3, 4)) @@ -135,7 +135,7 @@ def test_height(): styles.height = "100%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4)) @@ -143,7 +143,7 @@ def test_height(): styles.margin = 2 box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2)) @@ -152,7 +152,7 @@ def test_height(): styles.max_height = "50%" box_model = get_box_model( - styles, Size(60, 20), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(60, 20), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(54), Fraction(10), Spacing(1, 2, 3, 4)) @@ -173,7 +173,7 @@ def test_max(): assert False, "must not be called" box_model = get_box_model( - styles, Size(40, 30), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(40, 30), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0)) @@ -194,6 +194,6 @@ def test_min(): assert False, "must not be called" box_model = get_box_model( - styles, Size(40, 30), Size(80, 24), one, get_auto_width, get_auto_height + styles, Size(40, 30), Size(80, 24), one, one, get_auto_width, get_auto_height ) assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0))