mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
added fraction units and fixed rounding in layout
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user