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