diff --git a/examples/basic.css b/examples/basic.css index ecb6ba376..d559e775f 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -12,6 +12,7 @@ App > DockView { layer: panels; border-right: outer #09312e; offset-x: -50%; + transition: offset-x 1.2s in_cubic 200ms, offset-y 1s linear; } #header { diff --git a/examples/basic.py b/examples/basic.py index 969fa2335..a6419d67b 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -14,6 +14,7 @@ class BasicApp(App): footer=Widget(), sidebar=Widget(), ) + self.panic(self.query("#sidebar").first().styles) BasicApp.run(log="textual.log", css_file="basic.css") diff --git a/poetry.lock b/poetry.lock index b2ced3887..cd06d1700 100644 --- a/poetry.lock +++ b/poetry.lock @@ -547,7 +547,7 @@ python-versions = "*" [[package]] name = "rich" -version = "10.14.0" +version = "10.15.2" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" category = "main" optional = false @@ -995,8 +995,8 @@ regex = [ {file = "regex-2021.8.21.tar.gz", hash = "sha256:faf08b0341828f6a29b8f7dd94d5cf8cc7c39bfc3e67b78514c54b494b66915a"}, ] rich = [ - {file = "rich-10.14.0-py3-none-any.whl", hash = "sha256:ab9cbfd7a3802d8c6f0fa91e974630e2a69447972dcbb9dfe9b01016dd95e38e"}, - {file = "rich-10.14.0.tar.gz", hash = "sha256:8bfe4546d56b4131298d3a9e571a0742de342f1593770bd0d4707299f772a0af"}, + {file = "rich-10.15.2-py3-none-any.whl", hash = "sha256:43b2c6ad51f46f6c94992aee546f1c177719f4e05aff8f5ea4d2efae3ebdac89"}, + {file = "rich-10.15.2.tar.gz", hash = "sha256:1dded089b79dd042b3ab5cd63439a338e16652001f0c16e73acdcf4997ad772d"}, ] six = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 133a421dc..41c98fc8f 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -17,6 +17,7 @@ from .scalar import ( from ..geometry import Offset, Spacing, SpacingDimensions from .constants import NULL_SPACING, VALID_EDGE from .errors import StyleTypeError, StyleValueError +from .transition import Transition from ._error_tools import friendly_list if TYPE_CHECKING: @@ -49,7 +50,7 @@ class ScalarProperty: if value is None: new_value = None elif isinstance(value, float): - new_value = Scalar(value, Unit.CELLS, Unit.WIDTH) + new_value = Scalar(float(value), Unit.CELLS, Unit.WIDTH) elif isinstance(value, Scalar): new_value = value elif isinstance(value, str): @@ -64,7 +65,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, Unit.WIDTH) + new_value = Scalar(float(new_value.value), self.percent_unit, Unit.WIDTH) setattr(obj, self.internal_name, new_value) return value @@ -277,12 +278,12 @@ class OffsetProperty: scalar_x = ( Scalar.parse(x, Unit.WIDTH) if isinstance(x, str) - else Scalar(x, Unit.CELLS, Unit.WIDTH) + else Scalar(float(x), Unit.CELLS, Unit.WIDTH) ) scalar_y = ( Scalar.parse(y, Unit.HEIGHT) if isinstance(y, str) - else Scalar(y, Unit.CELLS, Unit.HEIGHT) + else Scalar(float(y), Unit.CELLS, Unit.HEIGHT) ) _offset = ScalarOffset(scalar_x, scalar_y) setattr(obj, self._internal_name, _offset) @@ -419,3 +420,14 @@ class StyleFlagsProperty: style = Style.parse(" ".join(words)) setattr(obj, self._internal_name, style) return style_flags + + +class TransitionsProperty: + def __set_name__(self, owner: Styles, name: str) -> None: + self._name = name + self._internal_name = f"_rule_{name}" + + def __get__( + self, obj: Styles, objtype: type[Styles] | None = None + ) -> dict[str, Transition]: + return getattr(obj, self._internal_name, None) or {} diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 950534981..6ff8a0d6b 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -12,7 +12,7 @@ from ._error_tools import friendly_list from .._easing import EASING from ..geometry import Offset, Spacing, SpacingDimensions from .model import Declaration -from .scalar import Scalar, ScalarOffset, Unit, ScalarParseError +from .scalar import Scalar, ScalarOffset, Unit, ScalarError from .styles import DockGroup, Styles from .types import Edge, Display, Visibility from .tokenize import Token @@ -53,7 +53,7 @@ class StylesBuilder: try: process_method(declaration.name, tokens) except DeclarationError as error: - self.error(error.name, error.token, error.message) + raise except Exception as error: self.error(declaration.name, declaration.token, str(error)) @@ -389,26 +389,30 @@ class StylesBuilder: self.error(name, token, "expected time") try: duration = Scalar.parse(token.value).resolve_time() - except ScalarParseError as error: + except ScalarError as error: self.error(name, token, str(error)) token = next(iter_tokens) if token.name != "token": - if token.value not in EASING: - self.error( - name, - token, - f"expected easing function; found {token.value!r}", - ) - easing = token.value + self.error(name, token, "easing function expected") + + if token.value not in EASING: + self.error( + name, + token, + f"expected easing function; found {token.value!r}", + ) + easing = token.value token = next(iter_tokens) if token.name != "scalar": self.error(name, token, "expected time") try: delay = Scalar.parse(token.value).resolve_time() - except ScalarParseError as error: + except ScalarError as error: self.error(name, token, str(error)) except StopIteration: pass transitions[css_property] = Transition(duration, easing, delay) + + self.styles._rule_transitions = transitions diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 8aeded1cf..5dd6ff130 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -9,11 +9,15 @@ import rich.repr from ..geometry import Offset -class ScalarResolveError(Exception): +class ScalarError(Exception): pass -class ScalarParseError(Exception): +class ScalarResolveError(ScalarError): + pass + + +class ScalarParseError(ScalarError): pass @@ -99,7 +103,7 @@ class Scalar(NamedTuple): @classmethod def from_number(cls, value: float) -> Scalar: - return cls(value, Unit.CELLS, Unit.WIDTH) + return cls(float(value), Unit.CELLS, Unit.WIDTH) @classmethod def parse(cls, token: str, percent_unit: Unit = Unit.WIDTH) -> Scalar: @@ -130,7 +134,7 @@ class Scalar(NamedTuple): try: return RESOLVE_MAP[unit](value, size, viewport) except KeyError: - raise ScalarResolveError(f"unable to resolve {self!r} as dimensions") + raise ScalarResolveError(f"expected dimensions; found {str(self)!r}") def resolve_time(self) -> float: value, unit, _ = self @@ -138,7 +142,7 @@ class Scalar(NamedTuple): return value / 1000.0 elif unit == Unit.SECONDS: return value - raise ScalarResolveError(f"unable to resolve {self!r} as time") + raise ScalarResolveError(f"expected time; found {str(self)!r}") @rich.repr.auto(angular=True) @@ -147,8 +151,8 @@ class ScalarOffset(NamedTuple): y: Scalar def __rich_repr__(self) -> rich.repr.Result: - yield str(self.x) - yield str(self.y) + yield None, str(self.x) + yield None, str(self.y) def resolve(self, size: tuple[int, int], viewport: tuple[int, int]) -> Offset: x, y = self diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 4a0bcceff..949be558c 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -19,6 +19,7 @@ from .constants import ( ) from ..geometry import NULL_OFFSET, Offset, Spacing from .scalar import Scalar, ScalarOffset, Unit +from .transition import Transition from ._style_properties import ( BorderProperty, BoxProperty, @@ -33,6 +34,7 @@ from ._style_properties import ( StringProperty, StyleProperty, StyleFlagsProperty, + TransitionsProperty, ) from .types import Display, Edge, Visibility @@ -88,6 +90,8 @@ class Styles: _rule_layers: tuple[str, ...] | None = None _rule_layer: str | None = None + _rule_transitions: dict[str, Transition] | None = None + important: set[str] = field(default_factory=set) display = StringProperty(VALID_DISPLAY, "block") @@ -125,6 +129,7 @@ class Styles: layer = NameProperty() layers = NameListProperty() + transitions = TransitionsProperty() @property def has_border(self) -> bool: @@ -282,6 +287,14 @@ class Styles: append_declaration("min-width", str(self.min_width)) if self._rule_min_height is not None: append_declaration("min-height", str(self.min_height)) + if self._rule_transitions is not None: + append_declaration( + "transition", + ", ".join( + f"{name} {transition}" + for name, transition in self.transitions.items() + ), + ) lines.sort() return lines diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 9bf284414..b1ff95cd6 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -44,7 +44,7 @@ expect_declaration_content = Expect( declaration_end=r"\n|;", whitespace=r"\s+", comment_start=r"\/\*", - scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh)?", + scalar=r"\-?\d+\.?\d*(?:fr|%|w|h|vw|vh|s|ms)?", 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/css/transition.py b/src/textual/css/transition.py index da056b67d..9d9facf10 100644 --- a/src/textual/css/transition.py +++ b/src/textual/css/transition.py @@ -5,3 +5,12 @@ class Transition(NamedTuple): duration: float = 1.0 easing: str = "linear" delay: float = 0.0 + + def __str__(self) -> str: + duration, easing, delay = self + if delay: + return f"{duration:.1f}s {easing} {delay:.1f}" + elif easing != "linear": + return f"{duration:.1f}s {easing}" + else: + return f"{duration:.1f}s"