added fraction units and fixed rounding in layout

This commit is contained in:
Will McGugan
2022-05-29 13:57:46 +01:00
parent d45b044888
commit a0deca38f9
6 changed files with 210 additions and 51 deletions

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from fractions import Fraction
from typing import Callable, NamedTuple
from .css.styles import StylesBase
@@ -9,7 +10,9 @@ from .geometry import Size, Spacing
class BoxModel(NamedTuple):
"""The result of `get_box_model`."""
size: Size # Content + padding + border
# Content + padding + border
width: Fraction
height: Fraction
margin: Spacing # Additional margin
@@ -17,6 +20,7 @@ def get_box_model(
styles: StylesBase,
container: Size,
viewport: Size,
fraction_unit: Fraction,
get_content_width: Callable[[Size, Size], int],
get_content_height: Callable[[Size, Size, int], int],
) -> BoxModel:
@@ -32,7 +36,9 @@ def get_box_model(
Returns:
BoxModel: A tuple with the size of the content area and margin.
"""
content_width, content_height = container
_content_width, _content_height = container
content_width = Fraction(_content_width)
content_height = Fraction(_content_height)
is_border_box = styles.box_sizing == "border-box"
gutter = styles.gutter
margin = styles.margin
@@ -47,57 +53,67 @@ def get_box_model(
if styles.width is None:
# No width specified, fill available space
content_width = content_container.width - margin.width
content_width = Fraction(content_container.width - margin.width)
elif is_auto_width:
# When width is auto, we want enough space to always fit the content
content_width = get_content_width(
content_container - styles.margin.totals, viewport
content_width = Fraction(
get_content_width(content_container - styles.margin.totals, viewport)
)
else:
# An explicit width
content_width = styles.width.resolve_dimension(sizing_container, viewport)
content_width = styles.width.resolve_dimension(
sizing_container, viewport, fraction_unit
)
if is_border_box:
content_width -= gutter.width
if styles.min_width is not None:
# Restrict to minimum width, if set
min_width = styles.min_width.resolve_dimension(content_container, viewport)
min_width = styles.min_width.resolve_dimension(
content_container, viewport, fraction_unit
)
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)
max_width = styles.max_width.resolve_dimension(
content_container, viewport, fraction_unit
)
content_width = min(content_width, max_width)
content_width = max(1, content_width)
content_width = max(Fraction(1), content_width)
if styles.height is None:
# No height specified, fill the available space
content_height = content_container.height - margin.height
content_height = Fraction(content_container.height - margin.height)
elif is_auto_height:
# Calculate dimensions based on content
content_height = get_content_height(content_container, viewport, content_width)
content_height = Fraction(
get_content_height(content_container, viewport, int(content_width))
)
else:
# Explicit height set
content_height = styles.height.resolve_dimension(sizing_container, viewport)
content_height = styles.height.resolve_dimension(
sizing_container, viewport, fraction_unit
)
if is_border_box:
content_height -= gutter.height
if styles.min_height is not None:
# Restrict to minimum height, if set
min_height = styles.min_height.resolve_dimension(content_container, viewport)
min_height = styles.min_height.resolve_dimension(
content_container, viewport, fraction_unit
)
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)
max_height = styles.max_height.resolve_dimension(
content_container, viewport, fraction_unit
)
content_height = min(content_height, max_height)
content_height = max(1, content_height)
content_height = max(Fraction(1), content_height)
# Get box dimensions by adding gutter
size = Size(content_width, content_height) + gutter.totals
model = BoxModel(size, margin)
model = BoxModel(content_width, content_height, margin)
return model

View File

@@ -1,13 +1,14 @@
from __future__ import annotations
from enum import Enum, unique
from fractions import Fraction
from functools import lru_cache
import re
from typing import Iterable, NamedTuple, TYPE_CHECKING
from typing import Iterable, NamedTuple
import rich.repr
from ..geometry import Offset
from ..geometry import Offset, Size
class ScalarError(Exception):
@@ -24,6 +25,8 @@ class ScalarParseError(ScalarError):
@unique
class Unit(Enum):
"""Enumeration of the various units inherited from CSS."""
CELLS = 1
FRACTION = 2
PERCENT = 3
@@ -48,18 +51,117 @@ SYMBOL_UNIT = {v: k for k, v in UNIT_SYMBOL.items()}
_MATCH_SCALAR = re.compile(r"^(-?\d+\.?\d*)(fr|%|w|h|vw|vh)?$").match
RESOLVE_MAP = {
Unit.CELLS: lambda value, size, viewport: value,
Unit.WIDTH: lambda value, size, viewport: size[0] * value / 100,
Unit.HEIGHT: lambda value, size, viewport: size[1] * value / 100,
Unit.VIEW_WIDTH: lambda value, size, viewport: viewport[0] * value / 100,
Unit.VIEW_HEIGHT: lambda value, size, viewport: viewport[1] * value / 100,
}
if TYPE_CHECKING:
from ..widget import Widget
from .styles import Styles
from .._animator import EasingFunction
def _resolve_cells(
value: float, size: Size, viewport: Size, fraction_unit: Fraction
) -> Fraction:
"""Resolves explicit cell size, i.e. width: 10
Args:
value (float): Scalar value.
size (Size): Size of widget.
viewport (Size): Size of viewport.
fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction.
Returns:
Fraction: Resolved unit.
"""
return Fraction(value)
def _resolve_fraction(
value: float, size: Size, viewport: Size, fraction_unit: Fraction
) -> Fraction:
"""Resolves a fraction unit i.e. width: 2fr
Args:
value (float): Scalar value.
size (Size): Size of widget.
viewport (Size): Size of viewport.
fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction.
Returns:
Fraction: Resolved unit.
"""
return fraction_unit * Fraction(value)
def _resolve_width(
value: float, size: Size, viewport: Size, fraction_unit: Fraction
) -> Fraction:
"""Resolves width unit i.e. width: 50w.
Args:
value (float): Scalar value.
size (Size): Size of widget.
viewport (Size): Size of viewport.
fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction.
Returns:
Fraction: Resolved unit.
"""
return Fraction(value) * Fraction(size.width, 100)
def _resolve_height(
value: float, size: Size, viewport: Size, fraction_unit: Fraction
) -> Fraction:
"""Resolves height unit, i.e. height: 12h.
Args:
value (float): Scalar value.
size (Size): Size of widget.
viewport (Size): Size of viewport.
fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction.
Returns:
Fraction: Resolved unit.
"""
return Fraction(value) * Fraction(size.height, 100)
def _resolve_view_width(
value: float, size: Size, viewport: Size, fraction_unit: Fraction
) -> Fraction:
"""Resolves view width unit, i.e. width: 25vw.
Args:
value (float): Scalar value.
size (Size): Size of widget.
viewport (Size): Size of viewport.
fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction.
Returns:
Fraction: Resolved unit.
"""
return Fraction(value) * Fraction(viewport.width, 100)
def _resolve_view_height(
value: float, size: Size, viewport: Size, fraction_unit: Fraction
) -> Fraction:
"""Resolves view height unit, i.e. height: 25vh.
Args:
value (float): Scalar value.
size (Size): Size of widget.
viewport (Size): Size of viewport.
fraction_unit (Fraction): Size of fraction, i.e. size of 1fr as a Fraction.
Returns:
Fraction: Resolved unit.
"""
return Fraction(value) * Fraction(viewport.height, 100)
RESOLVE_MAP = {
Unit.CELLS: _resolve_cells,
Unit.FRACTION: _resolve_fraction,
Unit.WIDTH: _resolve_width,
Unit.HEIGHT: _resolve_height,
Unit.VIEW_WIDTH: _resolve_view_width,
Unit.VIEW_HEIGHT: _resolve_view_height,
}
def get_symbols(units: Iterable[Unit]) -> list[str]:
@@ -89,24 +191,34 @@ class Scalar(NamedTuple):
@property
def is_percent(self) -> bool:
"""Check if the Scalar is a percentage unit."""
return self.unit == Unit.PERCENT
@property
def is_fraction(self) -> bool:
"""Check if the unit is a fraction."""
return self.unit == Unit.FRACTION
@property
def cells(self) -> int | None:
"""Check if the unit is explicit cells."""
value, unit, _ = self
return int(value) if unit == Unit.CELLS else None
@property
def fraction(self) -> int | None:
"""Get the fraction value, or None if not a value."""
value, unit, _ = self
return int(value) if unit == Unit.FRACTION else None
@property
def symbol(self) -> str:
"""Get the symbol of this unit."""
return UNIT_SYMBOL[self.unit]
@property
def is_auto(self) -> bool:
"""Check if this is an auto unit."""
return self.unit == Unit.AUTO
@classmethod
@@ -138,8 +250,8 @@ class Scalar(NamedTuple):
@lru_cache(maxsize=4096)
def resolve_dimension(
self, size: tuple[int, int], viewport: tuple[int, int]
) -> int:
self, size: Size, viewport: Size, fraction_unit: Fraction | None = None
) -> Fraction:
"""Resolve scalar with units in to a dimensions.
Args:
@@ -157,7 +269,9 @@ class Scalar(NamedTuple):
if unit == Unit.PERCENT:
unit = percent_unit
try:
dimension = int(RESOLVE_MAP[unit](value, size, viewport))
dimension = RESOLVE_MAP[unit](
value, size, viewport, fraction_unit or Fraction(1)
)
except KeyError:
raise ScalarResolveError(f"expected dimensions; found {str(self)!r}")
return dimension
@@ -184,6 +298,8 @@ class Scalar(NamedTuple):
@rich.repr.auto(angular=True)
class ScalarOffset(NamedTuple):
"""An Offset with two scalars, used to animate between to Scalars."""
x: Scalar
y: Scalar
@@ -200,7 +316,7 @@ class ScalarOffset(NamedTuple):
yield None, str(self.x)
yield None, str(self.y)
def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> Offset:
def resolve(self, size: Size, viewport: Size) -> Offset:
x, y = self
return Offset(
round(x.resolve_dimension(size, viewport)),

View File

@@ -379,7 +379,7 @@ class DOMNode(MessagePump):
node.id = node_id
def walk_children(self, with_self: bool = True) -> Iterable[DOMNode]:
"""Generate all descendents of this node.
"""Generate all descendants of this node.
Args:
with_self (bool, optional): Also include self in the results. Defaults to True.

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from fractions import Fraction
from typing import cast
from textual.geometry import Size, Offset, Region
@@ -23,8 +24,15 @@ class HorizontalLayout(Layout):
x = max_width = max_height = 0
parent_size = parent.size
children = list(parent.children)
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]
)
fraction_unit = Fraction(size.height, total_fraction or 1)
box_models = [
widget.get_box_model(size, parent_size)
widget.get_box_model(size, parent_size, fraction_unit)
for widget in cast("list[Widget]", parent.children)
]
@@ -40,12 +48,18 @@ class HorizontalLayout(Layout):
displayed_children = parent.displayed_children
for widget, box_model, margin in zip(displayed_children, box_models, margins):
content_width, content_height = box_model.size
offset_y = widget.styles.align_height(content_height, parent_size.height)
region = Region(x, offset_y, content_width, content_height)
content_width, content_height, box_margin = box_model
offset_y = (
widget.styles.align_height(
int(content_height), size.height - box_margin.height
)
+ box_model.margin.top
)
next_x = x + content_width
region = Region(int(x), offset_y, int(next_x - int(x)), int(content_height))
max_height = max(max_height, content_height)
add_placement(WidgetPlacement(region, widget, 0))
x += region.width + margin
x = next_x + margin
max_width = x
total_region = Region(0, 0, max_width, max_height)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from fractions import Fraction
from typing import cast, TYPE_CHECKING
from ..geometry import Region, Size
@@ -21,8 +22,15 @@ class VerticalLayout(Layout):
parent_size = parent.size
children = list(parent.children)
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]
)
fraction_unit = Fraction(size.height, total_fraction or 1)
box_models = [
widget.get_box_model(size, parent_size)
widget.get_box_model(size, parent_size, fraction_unit)
for widget in cast("list[Widget]", parent.children)
]
@@ -33,22 +41,23 @@ class VerticalLayout(Layout):
if box_models:
margins.append(box_models[-1].margin.bottom)
y = box_models[0].margin.top if box_models else 0
y = Fraction(box_models[0].margin.top if box_models else 0)
displayed_children = cast("list[Widget]", parent.displayed_children)
for widget, box_model, margin in zip(displayed_children, box_models, margins):
content_width, content_height = box_model.size
content_width, content_height, box_margin = box_model
offset_x = (
widget.styles.align_width(
content_width, size.width - box_model.margin.width
int(content_width), size.width - box_margin.width
)
+ box_model.margin.left
)
region = Region(offset_x, y, content_width, content_height)
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))
y += region.height + margin
y = next_y + margin
total_region = Region(0, 0, size.width, y)
total_region = Region(0, 0, size.width, int(y))
add_placement(WidgetPlacement(total_region, None, 0))
return placements, set(displayed_children)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from fractions import Fraction
from typing import (
Any,
Awaitable,
@@ -155,7 +156,9 @@ class Widget(DOMNode):
self.CSS, f"{__file__}:<{self.__class__.__name__}>"
)
def get_box_model(self, container: Size, viewport: Size) -> BoxModel:
def get_box_model(
self, container: Size, viewport: Size, fraction_unit: Fraction
) -> BoxModel:
"""Process the box model for this widget.
Args:
@@ -169,6 +172,7 @@ class Widget(DOMNode):
self.styles,
container,
viewport,
fraction_unit,
self.get_content_width,
self.get_content_height,
)