From 079d41473ff62263b9159501f1d5645366085bc9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 26 Nov 2021 14:13:01 +0000 Subject: [PATCH] scalars in CSS --- examples/basic.css | 1 + examples/basic.py | 2 +- src/textual/css/_style_properties.py | 36 ++++++++++++++++++++----- src/textual/css/_styles_builder.py | 40 +++++++++++++++++----------- src/textual/css/scalar.py | 37 +++++++++++++------------ src/textual/css/styles.py | 8 ++++-- src/textual/css/tokenize.py | 3 +-- src/textual/layout_map.py | 10 +++++-- src/textual/widget.py | 6 ----- 9 files changed, 89 insertions(+), 54 deletions(-) diff --git a/examples/basic.css b/examples/basic.css index e2c35c226..ecb6ba376 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -11,6 +11,7 @@ App > DockView { height: 1fr; layer: panels; border-right: outer #09312e; + offset-x: -50%; } #header { diff --git a/examples/basic.py b/examples/basic.py index dcb9ecd5c..969fa2335 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -16,4 +16,4 @@ class BasicApp(App): ) -BasicApp.run(css_file="basic.css") +BasicApp.run(log="textual.log", css_file="basic.css") diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 364f4e2d4..133a421dc 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -6,7 +6,14 @@ import rich.repr from rich.color import Color from rich.style import Style -from .scalar import get_symbols, UNIT_SYMBOL, Unit, Scalar, ScalarParseError +from .scalar import ( + get_symbols, + UNIT_SYMBOL, + Unit, + Scalar, + ScalarOffset, + ScalarParseError, +) from ..geometry import Offset, Spacing, SpacingDimensions from .constants import NULL_SPACING, VALID_EDGE from .errors import StyleTypeError, StyleValueError @@ -42,7 +49,7 @@ class ScalarProperty: if value is None: new_value = None elif isinstance(value, float): - new_value = Scalar(value, Unit.CELLS) + new_value = Scalar(value, Unit.CELLS, Unit.WIDTH) elif isinstance(value, Scalar): new_value = value elif isinstance(value, str): @@ -57,7 +64,7 @@ class ScalarProperty: f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}" ) if new_value is not None and new_value.is_percent: - new_value = Scalar(new_value.value, self.percent_unit) + new_value = Scalar(new_value.value, self.percent_unit, Unit.WIDTH) setattr(obj, self.internal_name, new_value) return value @@ -258,11 +265,26 @@ class OffsetProperty: def __set_name__(self, owner: Styles, name: str) -> None: self._internal_name = f"_rule_{name}" - def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Offset: - return getattr(obj, self._internal_name) or Offset() + def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> ScalarOffset: + return getattr(obj, self._internal_name) or ScalarOffset( + Scalar.from_number(0), Scalar.from_number(0) + ) - def __set__(self, obj: Styles, offset: tuple[int, int]) -> tuple[int, int]: - _offset = Offset(*offset) + def __set__( + self, obj: Styles, offset: tuple[int | str, int | str] + ) -> tuple[int | str, int | str]: + x, y = offset + scalar_x = ( + Scalar.parse(x, Unit.WIDTH) + if isinstance(x, str) + else Scalar(x, Unit.CELLS, Unit.WIDTH) + ) + scalar_y = ( + Scalar.parse(y, Unit.HEIGHT) + if isinstance(y, str) + else Scalar(y, Unit.CELLS, Unit.HEIGHT) + ) + _offset = ScalarOffset(scalar_x, scalar_y) setattr(obj, self._internal_name, _offset) return offset diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 26e0c9614..bc0a2ec1c 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import cast +from typing import cast, NoReturn import rich.repr from rich.color import ANSI_COLOR_NAMES, Color @@ -11,7 +11,7 @@ from .errors import DeclarationError, StyleValueError from ._error_tools import friendly_list from ..geometry import Offset, Spacing, SpacingDimensions from .model import Declaration -from .scalar import Scalar +from .scalar import Scalar, ScalarOffset, Unit from .styles import DockGroup, Styles from .types import Edge, Display, Visibility from .tokenize import Token @@ -27,7 +27,7 @@ class StylesBuilder: def __repr__(self) -> str: return "StylesBuilder()" - def error(self, name: str, token: Token, message: str) -> None: + def error(self, name: str, token: Token, message: str) -> NoReturn: raise DeclarationError(name, token, message) def add_declaration(self, declaration: Declaration) -> None: @@ -210,13 +210,14 @@ class StylesBuilder: self.error(name, tokens[0], "expected two numbers in declaration") else: token1, token2 = tokens - if token1.name != "number": - self.error(name, token1, f"expected a number (found {token1.value!r})") - if token2.name != "number": - self.error(name, token2, f"expected a number (found {token1.value!r})") - self.styles._rule_offset = Offset( - int(float(token1.value)), int(float(token2.value)) - ) + + if token1.name != "scalar": + self.error(name, token1, f"expected a scalar; found {token1.value!r}") + if token2.name != "scalar": + self.error(name, token2, f"expected a scalar; found {token1.value!r}") + scalar_x = Scalar.parse(token1.value, Unit.WIDTH) + scalar_y = Scalar.parse(token2.value, Unit.HEIGHT) + self.styles._rule_offset = ScalarOffset(scalar_x, scalar_y) def process_offset_x(self, name: str, tokens: list[Token]) -> None: if not tokens: @@ -224,9 +225,12 @@ class StylesBuilder: if len(tokens) != 1: self.error(name, tokens[0], f"expected a single number") else: - x = int(float(tokens[0].value)) + token = tokens[0] + if token.name != "scalar": + self.error(name, token, f"expected a scalar; found {token.value!r}") + x = Scalar.parse(token.value, Unit.WIDTH) y = self.styles.offset.y - self.styles._rule_offset = Offset(x, y) + self.styles._rule_offset = ScalarOffset(x, y) def process_offset_y(self, name: str, tokens: list[Token]) -> None: if not tokens: @@ -234,9 +238,12 @@ class StylesBuilder: if len(tokens) != 1: self.error(name, tokens[0], f"expected a single number") else: - y = int(float(tokens[0].value)) + token = tokens[0] + if token.name != "scalar": + self.error(name, token, f"expected a scalar; found {token.value!r}") + y = Scalar.parse(token.value, Unit.HEIGHT) x = self.styles.offset.x - self.styles._rule_offset = Offset(x, y) + self.styles._rule_offset = ScalarOffset(x, y) def process_layout(self, name: str, tokens: list[Token]) -> None: if tokens: @@ -247,7 +254,10 @@ class StylesBuilder: def process_text(self, name: str, tokens: list[Token]) -> None: style_definition = " ".join(token.value for token in tokens) - style = Style.parse(style_definition) + try: + style = Style.parse(style_definition) + except Exception as error: + self.error(name, tokens[0], f"failed to parse style; {error}") self.styles.text = style def process_text_color(self, name: str, tokens: list[Token]) -> None: diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 03380c36d..d7f42537a 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -40,15 +40,15 @@ UNIT_SYMBOL = { 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], - Unit.HEIGHT: lambda value, size, viewport: size[1], - Unit.VIEW_WIDTH: lambda value, size, viewport: viewport[0], - Unit.VIEW_HEIGHT: lambda value, size, viewport: viewport[1], + 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, } @@ -69,9 +69,10 @@ class Scalar(NamedTuple): value: float unit: Unit + percent_unit: Unit def __str__(self) -> str: - value, _unit = self + value, _unit, _ = self return f"{int(value) if value.is_integer() else value}{self.symbol}" @property @@ -80,12 +81,12 @@ class Scalar(NamedTuple): @property def cells(self) -> int | None: - value, unit = self + value, unit, _ = self return int(value) if unit == Unit.CELLS else None @property def fraction(self) -> int | None: - value, unit = self + value, unit, _ = self return int(value) if unit == Unit.FRACTION else None @property @@ -93,7 +94,11 @@ class Scalar(NamedTuple): return UNIT_SYMBOL[self.unit] @classmethod - def parse(cls, token: str) -> Scalar: + def from_number(cls, value: float) -> Scalar: + return cls(value, Unit.CELLS, Unit.WIDTH) + + @classmethod + def parse(cls, token: str, percent_unit: Unit = Unit.WIDTH) -> Scalar: """Parse a string in to a Scalar Args: @@ -109,16 +114,11 @@ class Scalar(NamedTuple): 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 ""]) + scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit) return scalar - def resolve( - self, - size: tuple[int, int], - viewport: tuple[int, int], - percent_unit: Unit = Unit.WIDTH, - ) -> float: - value, unit = self + def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> float: + value, unit, percent_unit = self if unit == Unit.PERCENT: unit = percent_unit try: @@ -139,8 +139,7 @@ class ScalarOffset(NamedTuple): def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> Offset: x, y = self return Offset( - round(x.resolve(size, viewport, percent_unit=Unit.WIDTH)), - round(y.resolve(size, viewport, percent_unit=Unit.HEIGHT)), + round(x.resolve(size, viewport)), round(y.resolve(size, viewport)) ) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 9cf0b15e9..4a0bcceff 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -18,7 +18,7 @@ from .constants import ( NULL_SPACING, ) from ..geometry import NULL_OFFSET, Offset, Spacing -from .scalar import Scalar, Unit +from .scalar import Scalar, ScalarOffset, Unit from ._style_properties import ( BorderProperty, BoxProperty, @@ -63,7 +63,7 @@ class Styles: _rule_padding: Spacing | None = None _rule_margin: Spacing | None = None - _rule_offset: Offset | None = None + _rule_offset: ScalarOffset | None = None _rule_border_top: tuple[str, Style] | None = None _rule_border_right: tuple[str, Style] | None = None @@ -144,6 +144,10 @@ class Styles: """Check if an outline is present.""" return any(edge for edge, _style in self.outline) + @property + def has_offset(self) -> bool: + return self._rule_offset is not None + def extract_rules( self, specificity: tuple[int, int, int] ) -> list[tuple[str, tuple[int, int, int, int], Any]]: diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index b5912398b..c72c81d9a 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -44,8 +44,7 @@ expect_declaration_content = Expect( declaration_end=r"\n|;", whitespace=r"\s+", comment_start=r"\/\*", - percentage=r"\d+\%", - scalar=r"\-?\d+\.?\d*(?:fr|%)?", + scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)?", color=r"\#[0-9a-fA-F]{6}|color\([0-9]{1,3}\)|rgb\(\d{1,3}\,\s?\d{1,3}\,\s?\d{1,3}\)", key_value=r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+", token="[a-zA-Z_-]+", diff --git a/src/textual/layout_map.py b/src/textual/layout_map.py index dcf93cf2a..97b3997e4 100644 --- a/src/textual/layout_map.py +++ b/src/textual/layout_map.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import ItemsView, KeysView, ValuesView, NamedTuple from . import log -from .geometry import Region, Size +from .geometry import Offset, Region, Size from operator import attrgetter from .widget import Widget @@ -47,7 +47,13 @@ class LayoutMap: if widget in self.widgets: return - self.widgets[widget] = RenderRegion(region + widget.layout_offset, order, clip) + layout_offset = Offset(0, 0) + if widget.styles.has_offset: + log("r", region, "c", clip.size) + layout_offset = widget.styles.offset.resolve(region.size, clip.size) + log("layout_offset", layout_offset) + + self.widgets[widget] = RenderRegion(region + layout_offset, order, clip) if isinstance(widget, View): view: View = widget diff --git a/src/textual/widget.py b/src/textual/widget.py index b28627e20..4c79ccde1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -185,12 +185,6 @@ class Widget(DOMNode): assert self._animate is not None return self._animate - @property - def layout_offset(self) -> tuple[int, int]: - """Get the layout offset as a tuple.""" - x, y = self.styles.offset - return round(x), round(y) - @property def gutter(self) -> Spacing: mt, mr, mb, bl = self.margin or (0, 0, 0, 0)