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

View File

@@ -1,13 +1,14 @@
from __future__ import annotations from __future__ import annotations
from enum import Enum, unique from enum import Enum, unique
from fractions import Fraction
from functools import lru_cache from functools import lru_cache
import re import re
from typing import Iterable, NamedTuple, TYPE_CHECKING from typing import Iterable, NamedTuple
import rich.repr import rich.repr
from ..geometry import Offset from ..geometry import Offset, Size
class ScalarError(Exception): class ScalarError(Exception):
@@ -24,6 +25,8 @@ class ScalarParseError(ScalarError):
@unique @unique
class Unit(Enum): class Unit(Enum):
"""Enumeration of the various units inherited from CSS."""
CELLS = 1 CELLS = 1
FRACTION = 2 FRACTION = 2
PERCENT = 3 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 _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: def _resolve_cells(
from ..widget import Widget value: float, size: Size, viewport: Size, fraction_unit: Fraction
from .styles import Styles ) -> Fraction:
from .._animator import EasingFunction """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]: def get_symbols(units: Iterable[Unit]) -> list[str]:
@@ -89,24 +191,34 @@ class Scalar(NamedTuple):
@property @property
def is_percent(self) -> bool: def is_percent(self) -> bool:
"""Check if the Scalar is a percentage unit."""
return self.unit == Unit.PERCENT return self.unit == Unit.PERCENT
@property
def is_fraction(self) -> bool:
"""Check if the unit is a fraction."""
return self.unit == Unit.FRACTION
@property @property
def cells(self) -> int | None: def cells(self) -> int | None:
"""Check if the unit is explicit cells."""
value, unit, _ = self value, unit, _ = self
return int(value) if unit == Unit.CELLS else None return int(value) if unit == Unit.CELLS else None
@property @property
def fraction(self) -> int | None: def fraction(self) -> int | None:
"""Get the fraction value, or None if not a value."""
value, unit, _ = self value, unit, _ = self
return int(value) if unit == Unit.FRACTION else None return int(value) if unit == Unit.FRACTION else None
@property @property
def symbol(self) -> str: def symbol(self) -> str:
"""Get the symbol of this unit."""
return UNIT_SYMBOL[self.unit] return UNIT_SYMBOL[self.unit]
@property @property
def is_auto(self) -> bool: def is_auto(self) -> bool:
"""Check if this is an auto unit."""
return self.unit == Unit.AUTO return self.unit == Unit.AUTO
@classmethod @classmethod
@@ -138,8 +250,8 @@ class Scalar(NamedTuple):
@lru_cache(maxsize=4096) @lru_cache(maxsize=4096)
def resolve_dimension( def resolve_dimension(
self, size: tuple[int, int], viewport: tuple[int, int] self, size: Size, viewport: Size, fraction_unit: Fraction | None = None
) -> int: ) -> Fraction:
"""Resolve scalar with units in to a dimensions. """Resolve scalar with units in to a dimensions.
Args: Args:
@@ -157,7 +269,9 @@ class Scalar(NamedTuple):
if unit == Unit.PERCENT: if unit == Unit.PERCENT:
unit = percent_unit unit = percent_unit
try: try:
dimension = int(RESOLVE_MAP[unit](value, size, viewport)) dimension = RESOLVE_MAP[unit](
value, size, viewport, fraction_unit or Fraction(1)
)
except KeyError: except KeyError:
raise ScalarResolveError(f"expected dimensions; found {str(self)!r}") raise ScalarResolveError(f"expected dimensions; found {str(self)!r}")
return dimension return dimension
@@ -184,6 +298,8 @@ class Scalar(NamedTuple):
@rich.repr.auto(angular=True) @rich.repr.auto(angular=True)
class ScalarOffset(NamedTuple): class ScalarOffset(NamedTuple):
"""An Offset with two scalars, used to animate between to Scalars."""
x: Scalar x: Scalar
y: Scalar y: Scalar
@@ -200,7 +316,7 @@ class ScalarOffset(NamedTuple):
yield None, str(self.x) yield None, str(self.x)
yield None, str(self.y) 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 x, y = self
return Offset( return Offset(
round(x.resolve_dimension(size, viewport)), round(x.resolve_dimension(size, viewport)),

View File

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

View File

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

View File

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

View File

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