Merge pull request #1067 from Textualize/fr-unit

Fr unit
This commit is contained in:
Will McGugan
2022-10-31 13:37:00 +00:00
committed by GitHub
13 changed files with 467 additions and 61 deletions

96
sandbox/will/fr.py Normal file
View File

@@ -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()

View File

@@ -60,10 +60,9 @@ def arrange(
for dock_widget in dock_widgets: for dock_widget in dock_widgets:
edge = dock_widget.styles.dock edge = dock_widget.styles.dock
fraction_unit = Fraction( box_model = dock_widget._get_box_model(
size.height if edge in ("top", "bottom") else size.width 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_fraction, widget_height_fraction, margin = box_model
widget_width = int(widget_width_fraction) + margin.width widget_width = int(widget_width_fraction) + margin.width

View File

@@ -1,12 +1,23 @@
from __future__ import annotations from __future__ import annotations
import sys
from fractions import Fraction from fractions import Fraction
from itertools import accumulate 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 .css.scalar import Scalar
from .geometry import Size 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( def resolve(
dimensions: Sequence[Scalar], dimensions: Sequence[Scalar],
@@ -71,3 +82,79 @@ def resolve(
] ]
return results 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)

View File

@@ -1271,7 +1271,12 @@ class App(Generic[ReturnType], DOMNode):
self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer") self.set_timer(screenshot_timer, on_screenshot, name="screenshot timer")
async def _on_compose(self) -> None: 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) await self.mount_all(widgets)
def _on_idle(self) -> None: def _on_idle(self) -> None:

View File

@@ -20,7 +20,8 @@ def get_box_model(
styles: StylesBase, styles: StylesBase,
container: Size, container: Size,
viewport: Size, viewport: Size,
fraction_unit: Fraction, width_fraction: Fraction,
height_fraction: Fraction,
get_content_width: Callable[[Size, Size], int], get_content_width: Callable[[Size, Size], int],
get_content_height: Callable[[Size, Size, int], int], get_content_height: Callable[[Size, Size, int], int],
) -> BoxModel: ) -> BoxModel:
@@ -30,6 +31,8 @@ def get_box_model(
styles (StylesBase): Styles object. styles (StylesBase): Styles object.
container (Size): The size of the widget container. container (Size): The size of the widget container.
viewport (Size): The viewport size. 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_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. 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 # An explicit width
styles_width = styles.width styles_width = styles.width
content_width = styles_width.resolve_dimension( 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: if is_border_box and styles_width.excludes_border:
content_width -= gutter.width content_width -= gutter.width
@@ -71,14 +74,14 @@ def get_box_model(
if styles.min_width is not None: if styles.min_width is not None:
# Restrict to minimum width, if set # Restrict to minimum width, if set
min_width = styles.min_width.resolve_dimension( min_width = styles.min_width.resolve_dimension(
content_container, viewport, fraction_unit content_container, viewport, width_fraction
) )
content_width = max(content_width, min_width) content_width = max(content_width, min_width)
if styles.max_width is not None: if styles.max_width is not None:
# Restrict to maximum width, if set # Restrict to maximum width, if set
max_width = styles.max_width.resolve_dimension( max_width = styles.max_width.resolve_dimension(
content_container, viewport, fraction_unit content_container, viewport, width_fraction
) )
if is_border_box: if is_border_box:
max_width -= gutter.width max_width -= gutter.width
@@ -98,7 +101,7 @@ def get_box_model(
styles_height = styles.height styles_height = styles.height
# Explicit height set # Explicit height set
content_height = styles_height.resolve_dimension( 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: if is_border_box and styles_height.excludes_border:
content_height -= gutter.height content_height -= gutter.height
@@ -106,14 +109,14 @@ def get_box_model(
if styles.min_height is not None: if styles.min_height is not None:
# Restrict to minimum height, if set # Restrict to minimum height, if set
min_height = styles.min_height.resolve_dimension( min_height = styles.min_height.resolve_dimension(
content_container, viewport, fraction_unit content_container, viewport, height_fraction
) )
content_height = max(content_height, min_height) content_height = max(content_height, min_height)
if styles.max_height is not None: if styles.max_height is not None:
# Restrict maximum height, if set # Restrict maximum height, if set
max_height = styles.max_height.resolve_dimension( max_height = styles.max_height.resolve_dimension(
content_container, viewport, fraction_unit content_container, viewport, height_fraction
) )
content_height = min(content_height, max_height) content_height = min(content_height, max_height)

View File

@@ -145,9 +145,7 @@ class GridLayout(Layout):
y2, cell_height = rows[min(max_row, row + row_span)] y2, cell_height = rows[min(max_row, row + row_span)]
cell_size = Size(cell_width + x2 - x, cell_height + y2 - y) cell_size = Size(cell_width + x2 - x, cell_height + y2 - y)
width, height, margin = widget._get_box_model( width, height, margin = widget._get_box_model(
cell_size, cell_size, viewport, fraction_unit, fraction_unit
viewport,
fraction_unit,
) )
region = ( region = (
Region(x, y, int(width + margin.width), int(height + margin.height)) Region(x, y, int(width + margin.width), int(height + margin.height))

View File

@@ -1,12 +1,11 @@
from __future__ import annotations from __future__ import annotations
from fractions import Fraction from fractions import Fraction
from typing import cast
from textual.geometry import Size, Region from .._resolve import resolve_box_models
from textual._layout import ArrangeResult, Layout, WidgetPlacement from ..geometry import Size, Region
from .._layout import ArrangeResult, Layout, WidgetPlacement
from textual.widget import Widget from ..widget import Widget
class HorizontalLayout(Layout): class HorizontalLayout(Layout):
@@ -22,20 +21,16 @@ class HorizontalLayout(Layout):
placements: list[WidgetPlacement] = [] placements: list[WidgetPlacement] = []
add_placement = placements.append add_placement = placements.append
x = max_height = Fraction(0) x = max_height = Fraction(0)
parent_size = parent.outer_size parent_size = parent.outer_size
styles = [child.styles for child in children if child.styles.width is not None] box_models = resolve_box_models(
total_fraction = sum( [child.styles.width for child in children],
[int(style.width.value) for style in styles if style.width.is_fraction] 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 = [ margins = [
max((box1.margin.right, box2.margin.left)) max((box1.margin.right, box2.margin.left))

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from fractions import Fraction from fractions import Fraction
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from .._resolve import resolve_box_models
from ..geometry import Region, Size from ..geometry import Region, Size
from .._layout import ArrangeResult, Layout, WidgetPlacement from .._layout import ArrangeResult, Layout, WidgetPlacement
@@ -21,19 +22,15 @@ class VerticalLayout(Layout):
placements: list[WidgetPlacement] = [] placements: list[WidgetPlacement] = []
add_placement = placements.append add_placement = placements.append
parent_size = parent.outer_size parent_size = parent.outer_size
styles = [child.styles for child in children if child.styles.height is not None] box_models = resolve_box_models(
total_fraction = sum( [child.styles.height for child in children],
[int(style.height.value) for style in styles if style.height.is_fraction] 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 = [ margins = [
max((box1.margin.bottom, box2.margin.top)) max((box1.margin.bottom, box2.margin.top))

View File

@@ -425,14 +425,19 @@ class Widget(DOMNode):
) )
def _get_box_model( def _get_box_model(
self, container: Size, viewport: Size, fraction_unit: Fraction self,
container: Size,
viewport: Size,
width_fraction: Fraction,
height_fraction: Fraction,
) -> BoxModel: ) -> BoxModel:
"""Process the box model for this widget. """Process the box model for this widget.
Args: Args:
container (Size): The size of the container widget (with a layout) container (Size): The size of the container widget (with a layout)
viewport (Size): The viewport size. 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: Returns:
BoxModel: The size and margin for this widget. BoxModel: The size and margin for this widget.
@@ -441,7 +446,8 @@ class Widget(DOMNode):
self.styles, self.styles,
container, container,
viewport, viewport,
fraction_unit, width_fraction,
height_fraction,
self.get_content_width, self.get_content_width,
self.get_content_height, self.get_content_height,
) )
@@ -1946,8 +1952,13 @@ class Widget(DOMNode):
async def handle_key(self, event: events.Key) -> bool: async def handle_key(self, event: events.Key) -> bool:
return await self.dispatch_key(event) return await self.dispatch_key(event)
async def _on_compose(self, event: events.Compose) -> None: 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(*widgets) await self.mount(*widgets)
def _on_mount(self, event: events.Mount) -> None: def _on_mount(self, event: events.Mount) -> None:

File diff suppressed because one or more lines are too long

View File

@@ -85,6 +85,10 @@ def test_header_render(snap_compare):
assert snap_compare("docs/examples/widgets/header.py") assert snap_compare("docs/examples/widgets/header.py")
def test_fr_units(snap_compare):
assert snap_compare("tests/snapshots/fr_units.py")
# --- CSS properties --- # --- CSS properties ---
# We have a canonical example for each CSS property that is shown in their docs. # 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. # If any of these change, something has likely broken, so snapshot each of them.

View File

@@ -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()

View File

@@ -26,7 +26,7 @@ def test_content_box():
assert False, "must not be called" assert False, "must not be called"
box_model = get_box_model( 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 # Size should be inclusive of padding / border
assert box_model == BoxModel(Fraction(10), Fraction(8), Spacing(0, 0, 0, 0)) 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" styles.box_sizing = "content-box"
box_model = get_box_model( 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 # width and height have added padding / border to accommodate content
assert box_model == BoxModel(Fraction(14), Fraction(12), Spacing(0, 0, 0, 0)) assert box_model == BoxModel(Fraction(14), Fraction(12), Spacing(0, 0, 0, 0))
@@ -53,7 +53,7 @@ def test_width():
return 10 return 10
box_model = get_box_model( 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)) 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) styles.margin = (1, 2, 3, 4)
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4))
@@ -69,7 +69,7 @@ def test_width():
styles.width = "auto" styles.width = "auto"
box_model = get_box_model( 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 # Setting width to auto should call get_auto_width
assert box_model == BoxModel(Fraction(10), Fraction(16), Spacing(1, 2, 3, 4)) assert box_model == BoxModel(Fraction(10), Fraction(16), Spacing(1, 2, 3, 4))
@@ -78,7 +78,7 @@ def test_width():
styles.width = "100vw" styles.width = "100vw"
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(80), Fraction(16), Spacing(1, 2, 3, 4))
@@ -86,7 +86,7 @@ def test_width():
styles.width = "100%" styles.width = "100%"
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4))
@@ -94,7 +94,7 @@ def test_width():
styles.max_width = "50%" styles.max_width = "50%"
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(30), Fraction(16), Spacing(1, 2, 3, 4))
@@ -111,7 +111,7 @@ def test_height():
return 10 return 10
box_model = get_box_model( 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)) 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) styles.margin = (1, 2, 3, 4)
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4))
@@ -127,7 +127,7 @@ def test_height():
styles.height = "100vh" styles.height = "100vh"
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(54), Fraction(24), Spacing(1, 2, 3, 4))
@@ -135,7 +135,7 @@ def test_height():
styles.height = "100%" styles.height = "100%"
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(54), Fraction(16), Spacing(1, 2, 3, 4))
@@ -143,7 +143,7 @@ def test_height():
styles.margin = 2 styles.margin = 2
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(56), Fraction(10), Spacing(2, 2, 2, 2))
@@ -152,7 +152,7 @@ def test_height():
styles.max_height = "50%" styles.max_height = "50%"
box_model = get_box_model( 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)) 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" assert False, "must not be called"
box_model = get_box_model( 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)) 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" assert False, "must not be called"
box_model = get_box_model( 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)) assert box_model == BoxModel(Fraction(40), Fraction(30), Spacing(0, 0, 0, 0))