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:
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

View File

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

View File

@@ -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:
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:

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:
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:

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")
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.

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"
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))