382 lines
9.7 KiB
Python
382 lines
9.7 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
from enum import Enum, unique
|
|
from fractions import Fraction
|
|
from functools import lru_cache
|
|
from typing import Iterable, NamedTuple
|
|
|
|
import rich.repr
|
|
|
|
from textual.geometry import Offset, Size, clamp
|
|
|
|
|
|
class ScalarError(Exception):
|
|
"""Base class for exceptions raised by the Scalar class."""
|
|
|
|
|
|
class ScalarResolveError(ScalarError):
|
|
"""Raised for errors resolving scalars (unlikely to occur in practice)."""
|
|
|
|
|
|
class ScalarParseError(ScalarError):
|
|
"""Raised when a scalar couldn't be parsed from a string."""
|
|
|
|
|
|
@unique
|
|
class Unit(Enum):
|
|
"""Enumeration of the various units inherited from CSS."""
|
|
|
|
CELLS = 1
|
|
FRACTION = 2
|
|
PERCENT = 3
|
|
WIDTH = 4
|
|
HEIGHT = 5
|
|
VIEW_WIDTH = 6
|
|
VIEW_HEIGHT = 7
|
|
AUTO = 8
|
|
|
|
|
|
UNIT_SYMBOL = {
|
|
Unit.CELLS: "",
|
|
Unit.FRACTION: "fr",
|
|
Unit.PERCENT: "%",
|
|
Unit.WIDTH: "w",
|
|
Unit.HEIGHT: "h",
|
|
Unit.VIEW_WIDTH: "vw",
|
|
Unit.VIEW_HEIGHT: "vh",
|
|
}
|
|
|
|
SYMBOL_UNIT = {v: k for k, v in UNIT_SYMBOL.items()}
|
|
|
|
_MATCH_SCALAR = re.compile(r"^(-?\d+\.?\d*)(fr|%|w|h|vw|vh)?$").match
|
|
_FRACTION_ONE = Fraction(1)
|
|
|
|
|
|
def _resolve_cells(
|
|
value: float, size: Size, viewport: Size, fraction_unit: Fraction
|
|
) -> Fraction:
|
|
"""Resolves explicit cell size, i.e. width: 10
|
|
|
|
Args:
|
|
value: Scalar value.
|
|
size: Size of widget.
|
|
viewport: Size of viewport.
|
|
fraction_unit: Size of fraction, i.e. size of 1fr as a Fraction.
|
|
|
|
Returns:
|
|
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: Scalar value.
|
|
size: Size of widget.
|
|
viewport: Size of viewport.
|
|
fraction_unit: Size of fraction, i.e. size of 1fr as a Fraction.
|
|
|
|
Returns:
|
|
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: Scalar value.
|
|
size: Size of widget.
|
|
viewport: Size of viewport.
|
|
fraction_unit: Size of fraction, i.e. size of 1fr as a Fraction.
|
|
|
|
Returns:
|
|
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: Scalar value.
|
|
size: Size of widget.
|
|
viewport: Size of viewport.
|
|
fraction_unit: Size of fraction, i.e. size of 1fr as a Fraction.
|
|
|
|
Returns:
|
|
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: Scalar value.
|
|
size: Size of widget.
|
|
viewport: Size of viewport.
|
|
fraction_unit: Size of fraction, i.e. size of 1fr as a Fraction.
|
|
|
|
Returns:
|
|
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: Scalar value.
|
|
size: Size of widget.
|
|
viewport: Size of viewport.
|
|
fraction_unit: Size of fraction, i.e. size of 1fr as a Fraction.
|
|
|
|
Returns:
|
|
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]:
|
|
"""Get symbols for an iterable of units.
|
|
|
|
Args:
|
|
units: A number of units.
|
|
|
|
Returns:
|
|
List of symbols.
|
|
"""
|
|
return [UNIT_SYMBOL[unit] for unit in units]
|
|
|
|
|
|
class Scalar(NamedTuple):
|
|
"""A numeric value and a unit."""
|
|
|
|
value: float
|
|
unit: Unit
|
|
percent_unit: Unit
|
|
|
|
def __str__(self) -> str:
|
|
value, unit, _ = self
|
|
if unit == Unit.AUTO:
|
|
return "auto"
|
|
return f"{int(value) if value.is_integer() else value}{self.symbol}"
|
|
|
|
@property
|
|
def is_cells(self) -> bool:
|
|
"""Check if the Scalar is explicit cells."""
|
|
return self.unit == Unit.CELLS
|
|
|
|
@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
|
|
def from_number(cls, value: float) -> Scalar:
|
|
"""Create a scalar with cells unit.
|
|
|
|
Args:
|
|
value: A number of cells.
|
|
|
|
Returns:
|
|
New Scalar.
|
|
"""
|
|
return cls(float(value), Unit.CELLS, Unit.WIDTH)
|
|
|
|
@classmethod
|
|
@lru_cache(maxsize=1024)
|
|
def parse(cls, token: str, percent_unit: Unit = Unit.WIDTH) -> Scalar:
|
|
"""Parse a string into a Scalar
|
|
|
|
Args:
|
|
token: A string containing a scalar, e.g. "3.14fr"
|
|
|
|
Raises:
|
|
ScalarParseError: If the value is not a valid scalar
|
|
|
|
Returns:
|
|
New scalar
|
|
"""
|
|
if token.lower() == "auto":
|
|
scalar = cls(1.0, Unit.AUTO, Unit.AUTO)
|
|
else:
|
|
match = _MATCH_SCALAR(token)
|
|
if match is None:
|
|
raise ScalarParseError(f"{token!r} is not a valid scalar")
|
|
value, unit_name = match.groups()
|
|
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
|
|
return scalar
|
|
|
|
@lru_cache(maxsize=4096)
|
|
def resolve(
|
|
self, size: Size, viewport: Size, fraction_unit: Fraction | None = None
|
|
) -> Fraction:
|
|
"""Resolve scalar with units into a dimensions.
|
|
|
|
Args:
|
|
size: Size of the container.
|
|
viewport: Size of the viewport (typically terminal size)
|
|
|
|
Raises:
|
|
ScalarResolveError: If the unit is unknown.
|
|
|
|
Returns:
|
|
A size (in cells)
|
|
"""
|
|
value, unit, percent_unit = self
|
|
|
|
if unit == Unit.PERCENT:
|
|
unit = percent_unit
|
|
try:
|
|
dimension = RESOLVE_MAP[unit](
|
|
value, size, viewport, fraction_unit or _FRACTION_ONE
|
|
)
|
|
except KeyError:
|
|
raise ScalarResolveError(f"expected dimensions; found {str(self)!r}")
|
|
return dimension
|
|
|
|
def copy_with(
|
|
self,
|
|
value: float | None = None,
|
|
unit: Unit | None = None,
|
|
percent_unit: Unit | None = None,
|
|
) -> Scalar:
|
|
"""Get a copy of this Scalar, with values optionally modified
|
|
|
|
Args:
|
|
value: The new value, or None to keep the same value
|
|
unit: The new unit, or None to keep the same unit
|
|
percent_unit: The new percent_unit, or None to keep the same percent_unit
|
|
"""
|
|
return Scalar(
|
|
value if value is not None else self.value,
|
|
unit if unit is not None else self.unit,
|
|
percent_unit if percent_unit is not None else self.percent_unit,
|
|
)
|
|
|
|
|
|
@rich.repr.auto(angular=True)
|
|
class ScalarOffset(NamedTuple):
|
|
"""An Offset with two scalars, used to animate between to Scalars."""
|
|
|
|
x: Scalar
|
|
y: Scalar
|
|
|
|
@classmethod
|
|
def null(cls) -> ScalarOffset:
|
|
"""Get a null scalar offset (0, 0)."""
|
|
return NULL_SCALAR
|
|
|
|
@classmethod
|
|
def from_offset(cls, offset: tuple[int, int]) -> ScalarOffset:
|
|
"""Create a Scalar offset from a tuple of integers.
|
|
|
|
Args:
|
|
offset: Offset in cells.
|
|
|
|
Returns:
|
|
New offset.
|
|
"""
|
|
x, y = offset
|
|
return cls(
|
|
Scalar(x, Unit.CELLS, Unit.WIDTH),
|
|
Scalar(y, Unit.CELLS, Unit.HEIGHT),
|
|
)
|
|
|
|
def __bool__(self) -> bool:
|
|
x, y = self
|
|
return bool(x.value or y.value)
|
|
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
|
yield None, str(self.x)
|
|
yield None, str(self.y)
|
|
|
|
def resolve(self, size: Size, viewport: Size) -> Offset:
|
|
"""Resolve the offset into cells.
|
|
|
|
Args:
|
|
size: Size of container.
|
|
viewport: Size of viewport.
|
|
|
|
Returns:
|
|
Offset in cells.
|
|
"""
|
|
x, y = self
|
|
return Offset(
|
|
round(x.resolve(size, viewport)),
|
|
round(y.resolve(size, viewport)),
|
|
)
|
|
|
|
|
|
NULL_SCALAR = ScalarOffset(Scalar.from_number(0), Scalar.from_number(0))
|
|
|
|
|
|
def percentage_string_to_float(string: str) -> float:
|
|
"""Convert a string percentage e.g. '20%' to a float e.g. 20.0.
|
|
|
|
Args:
|
|
string: The percentage string to convert.
|
|
"""
|
|
string = string.strip()
|
|
if string.endswith("%"):
|
|
float_percentage = clamp(float(string[:-1]) / 100.0, 0.0, 1.0)
|
|
else:
|
|
float_percentage = float(string)
|
|
return float_percentage
|