diff --git a/examples/basic.css b/examples/basic.css index 6e831d217..bc40ac4f6 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -12,7 +12,7 @@ App > View { layer: panels; border-right: outer #09312e; offset-x: -100%; - transition: offset-x 1.2s in_cubic 200ms, offset-y 1s linear; + transition: offset 1.2s in_cubic 200ms; } #sidebar.-active { diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 2d8f4c3bd..292766ea4 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -8,14 +8,15 @@ from typing import Callable, TypeVar from dataclasses import dataclass +from . import log from ._easing import DEFAULT_EASING, EASING from ._timer import Timer from ._types import MessageTarget if sys.version_info >= (3, 8): - from typing import Protocol + from typing import Protocol, runtime_checkable else: - from typing_extensions import Protocol + from typing_extensions import Protocol, runtime_checkable EasingFunction = Callable[[float], float] @@ -23,6 +24,7 @@ EasingFunction = Callable[[float], float] T = TypeVar("T") +@runtime_checkable class Animatable(Protocol): def blend(self: T, destination: T, factor: float) -> T: ... @@ -47,28 +49,30 @@ class Animation: def blend(start: AnimatableT, end: AnimatableT, factor: float) -> AnimatableT: return start.blend(end, factor) - blend_function = ( - blend_float if isinstance(self.start_value, (int, float)) else blend - ) - if self.duration == 0: value = self.end_value else: factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing_function(factor) - # value = blend_function(self.start_value, self.end_value, eased_factor) - if self.end_value > self.start_value: - eased_factor = self.easing_function(factor) - value = ( - self.start_value - + (self.end_value - self.start_value) * eased_factor - ) + if isinstance(self.start_value, Animatable): + assert isinstance(self.end_value, Animatable) + value = self.start_value.blend(self.end_value, eased_factor) else: - eased_factor = 1 - self.easing_function(factor) - value = ( - self.end_value + (self.start_value - self.end_value) * eased_factor - ) + assert isinstance(self.start_value, float) + assert isinstance(self.end_value, float) + if self.end_value > self.start_value: + eased_factor = self.easing_function(factor) + value = ( + self.start_value + + (self.end_value - self.start_value) * eased_factor + ) + else: + eased_factor = 1 - self.easing_function(factor) + value = ( + self.end_value + + (self.start_value - self.end_value) * eased_factor + ) setattr(self.obj, self.attribute, value) return value == self.end_value @@ -134,7 +138,7 @@ class Animator: speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, ) -> None: - + log("animate", obj, attribute, value) start_time = time() animation_key = (obj, attribute) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 691f56962..58c8365fd 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -10,6 +10,7 @@ from rich.color import Color import rich.repr from rich.style import Style +from .. import log from .errors import StyleValueError from ._error_tools import friendly_list from .constants import ( @@ -134,6 +135,18 @@ class Styles: layers = NameListProperty() transitions = TransitionsProperty() + ANIMATABLE = { + "offset-x", + "offset-y", + "offset", + "padding", + "margin", + "width", + "height", + "min_width", + "min_height", + } + @classmethod @lru_cache(maxsize=1024) def parse(cls, css: str, path: str) -> Styles: @@ -173,6 +186,12 @@ class Styles: def has_offset(self) -> bool: return self._rule_offset is not None + def get_transition(self, key: str) -> Transition | None: + if key in self.ANIMATABLE: + return self.transitions.get(key, None) + else: + return None + def extract_rules( self, specificity: tuple[int, int, int] ) -> list[tuple[str, tuple[int, int, int, int], Any]]: @@ -188,7 +207,7 @@ class Styles: ] return rules - def apply_rules(self, rules: Iterable[tuple[str, Any]]): + def apply_rules(self, rules: Iterable[tuple[str, object]]): for key, value in rules: setattr(self, f"_rule_{key}", value) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index d5db397f4..7a1c5f694 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -129,7 +129,7 @@ class Stylesheet: for name, specificity_rules in rule_attributes.items() ] - node.styles.apply_rules(node_rules) + node.apply_style_rules(node_rules) def update(self, root: DOMNode) -> None: """Update a node and its children.""" diff --git a/src/textual/dom.py b/src/textual/dom.py index a053e5e13..269c5d4b6 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import cast, Iterable, Iterator, TYPE_CHECKING +from typing import Any, cast, Iterable, Iterator, TYPE_CHECKING from rich.highlighter import ReprHighlighter import rich.repr @@ -135,11 +135,14 @@ class DOMNode(MessagePump): add_children(tree, self) return tree + def apply_style_rules(self, rules: Iterable[tuple[str, Any]]) -> None: + self.styles.apply_rules(rules) + def reset_styles(self) -> None: from .widget import Widget for node in self.walk_children(): - node.styles = Styles() + # node.styles = Styles() if isinstance(node, Widget): node.clear_render_cache() @@ -185,7 +188,6 @@ class DOMNode(MessagePump): """Toggle class names""" self._classes.symmetric_difference_update(class_names) self.app.stylesheet.update(self.app) - self.log(self.styles.css) def has_psuedo_class(self, *class_names: str) -> bool: """Check for psuedo class (such as hover, focus etc)""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 066de6bc9..0483c6c81 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -7,6 +7,7 @@ from typing import ( TYPE_CHECKING, Callable, ClassVar, + Iterable, NamedTuple, cast, ) @@ -236,6 +237,30 @@ class Widget(DOMNode): offset_x, offset_y = self.root_view.get_offset(self) return self.root_view.get_style_at(x + offset_x, y + offset_y) + def apply_style_rules(self, rules: Iterable[tuple[str, Any]]) -> None: + styles = self.styles + is_animatable = styles.ANIMATABLE.__contains__ + for key, value in rules: + current = getattr(styles, f"_rule_{key}") + if current == value: + continue + self.log(key, "=", value) + if is_animatable(key): + self.log("animatable", key) + transition = styles.get_transition(key) + self.log("transition", transition) + if transition is None: + setattr(styles, f"_rule_{key}", value) + else: + duration, easing, delay = transition + self.log("ANIMATING") + self.app.animator.animate( + styles, key, value, duration=duration, easing=easing + ) + else: + self.log("not animatable") + setattr(styles, f"_rule_{key}", value) + async def call_later(self, callback: Callable, *args, **kwargs) -> None: await self.app.call_later(callback, *args, **kwargs)