ai-station/.venv/lib/python3.12/site-packages/textual/css/scalar.py

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