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
+ '''
+
+
+ '''
+# ---
# name: test_grid_layout_basic
'''