Merge pull request #1167 from Textualize/anim-fix

Fix animation on styles
This commit is contained in:
Will McGugan
2022-11-12 15:50:09 +00:00
committed by GitHub
9 changed files with 131 additions and 47 deletions

View File

@@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- It is now possible to `await` a `DOMQuery.remove`. Note that this changes
the return value of `DOMQuery.remove`, which uses to return `self`.
https://github.com/Textualize/textual/issues/1094
- Added Pilot.wait_for_animation
### Changed
@@ -32,6 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026
- Fixed issues with animation. Now objects of different types may be animated.
## [0.4.0] - 2022-11-08

View File

@@ -5,7 +5,7 @@ import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass
from functools import partial
from typing import Any, Callable, TypeVar, TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Callable, TypeVar
from . import _clock
from ._callback import invoke
@@ -23,6 +23,11 @@ if TYPE_CHECKING:
EasingFunction = Callable[[float], float]
class AnimationError(Exception):
"""An issue prevented animation from starting."""
T = TypeVar("T")
@@ -118,7 +123,7 @@ class BoundAnimator:
def __call__(
self,
attribute: str,
value: float | Animatable,
value: str | float | Animatable,
*,
final_value: object = ...,
duration: float | None = None,
@@ -140,6 +145,12 @@ class BoundAnimator:
on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None.
"""
start_value = getattr(self._obj, attribute)
if isinstance(value, str) and hasattr(start_value, "parse"):
# Color and Scalar have a parse method
# I'm exploiting a coincidence here, but I think this should be a first-class concept
# TODO: add a `Parsable` protocol
value = start_value.parse(value)
easing_function = EASING[easing] if isinstance(easing, str) else easing
return self._animator.animate(
self._obj,
@@ -270,9 +281,11 @@ class Animator:
easing_function = EASING[easing] if isinstance(easing, str) else easing
animation: Animation | None = None
if hasattr(obj, "__textual_animation__"):
animation = getattr(obj, "__textual_animation__")(
attribute,
getattr(obj, attribute),
value,
start_time,
duration=duration,
@@ -280,7 +293,17 @@ class Animator:
easing=easing_function,
on_complete=on_complete,
)
if animation is None:
if not isinstance(value, (int, float)) and not isinstance(
value, Animatable
):
raise AnimationError(
f"Don't know how to animate {value!r}; "
"Can only animate <int>, <float>, or objects with a blend method"
)
start_value = getattr(obj, attribute)
if start_value == value:

View File

@@ -43,7 +43,7 @@ def resolve(
(
(scalar, None)
if scalar.is_fraction
else (scalar, scalar.resolve_dimension(size, viewport))
else (scalar, scalar.resolve(size, viewport))
)
for scalar in dimensions
]

View File

@@ -65,7 +65,7 @@ def get_box_model(
else:
# An explicit width
styles_width = styles.width
content_width = styles_width.resolve_dimension(
content_width = styles_width.resolve(
sizing_container - styles.margin.totals, viewport, width_fraction
)
if is_border_box and styles_width.excludes_border:
@@ -73,14 +73,14 @@ def get_box_model(
if styles.min_width is not None:
# Restrict to minimum width, if set
min_width = styles.min_width.resolve_dimension(
min_width = styles.min_width.resolve(
content_container, viewport, width_fraction
)
content_width = max(content_width, min_width)
if styles.max_width is not None:
# Restrict to maximum width, if set
max_width = styles.max_width.resolve_dimension(
max_width = styles.max_width.resolve(
content_container, viewport, width_fraction
)
if is_border_box:
@@ -100,7 +100,7 @@ def get_box_model(
else:
styles_height = styles.height
# Explicit height set
content_height = styles_height.resolve_dimension(
content_height = styles_height.resolve(
sizing_container - styles.margin.totals, viewport, height_fraction
)
if is_border_box and styles_height.excludes_border:
@@ -108,14 +108,14 @@ def get_box_model(
if styles.min_height is not None:
# Restrict to minimum height, if set
min_height = styles.min_height.resolve_dimension(
min_height = styles.min_height.resolve(
content_container, viewport, height_fraction
)
content_height = max(content_height, min_height)
if styles.max_height is not None:
# Restrict maximum height, if set
max_height = styles.max_height.resolve_dimension(
max_height = styles.max_height.resolve(
content_container, viewport, height_fraction
)
content_height = min(content_height, max_height)

View File

@@ -268,7 +268,7 @@ class Scalar(NamedTuple):
return scalar
@lru_cache(maxsize=4096)
def resolve_dimension(
def resolve(
self, size: Size, viewport: Size, fraction_unit: Fraction | None = None
) -> Fraction:
"""Resolve scalar with units in to a dimensions.
@@ -363,8 +363,8 @@ class ScalarOffset(NamedTuple):
"""
x, y = self
return Offset(
round(x.resolve_dimension(size, viewport)),
round(y.resolve_dimension(size, viewport)),
round(x.resolve(size, viewport)),
round(y.resolve(size, viewport)),
)

View File

@@ -2,25 +2,26 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from .scalar import ScalarOffset
from .scalar import ScalarOffset, Scalar
from .._animator import Animation
from .._animator import EasingFunction
from .._types import CallbackType
from ..geometry import Offset
if TYPE_CHECKING:
from ..widget import Widget
from .styles import Styles
from ..dom import DOMNode
from .styles import StylesBase
class ScalarAnimation(Animation):
def __init__(
self,
widget: Widget,
styles: Styles,
widget: DOMNode,
styles: StylesBase,
start_time: float,
attribute: str,
value: ScalarOffset,
value: ScalarOffset | Scalar,
duration: float | None,
speed: float | None,
easing: EasingFunction,
@@ -40,8 +41,8 @@ class ScalarAnimation(Animation):
size = widget.outer_size
viewport = widget.app.size
self.start: Offset = getattr(styles, attribute).resolve(size, viewport)
self.destination: Offset = value.resolve(size, viewport)
self.start = getattr(styles, attribute).resolve(size, viewport)
self.destination = value.resolve(size, viewport)
if speed is not None:
distance = self.start.get_distance_to(self.destination)
@@ -62,7 +63,7 @@ class ScalarAnimation(Animation):
value = self.start.blend(self.destination, eased_factor)
else:
value = self.start + (self.destination - self.start) * eased_factor
current = self.styles._rules.get(self.attribute)
current = self.styles.get_rule(self.attribute)
if current != value:
setattr(self.styles, f"{self.attribute}", value)

View File

@@ -300,6 +300,44 @@ class StylesBase(ABC):
link_hover_background = ColorProperty("transparent")
link_hover_style = StyleFlagsProperty()
def __textual_animation__(
self,
attribute: str,
start_value: object,
value: object,
start_time: float,
duration: float | None,
speed: float | None,
easing: EasingFunction,
on_complete: CallbackType | None = None,
) -> ScalarAnimation | None:
if self.node is None:
return None
# Check we are animating a Scalar or Scalar offset
if isinstance(start_value, (Scalar, ScalarOffset)):
# If destination is a number, we can convert that to a scalar
if isinstance(value, (int, float)):
value = Scalar(value, Unit.CELLS, Unit.CELLS)
# We can only animate to Scalar
if not isinstance(value, (Scalar, ScalarOffset)):
return None
return ScalarAnimation(
self.node,
self,
start_time,
attribute,
value,
duration=duration,
speed=speed,
easing=easing,
on_complete=on_complete,
)
return None
def __eq__(self, styles: object) -> bool:
"""Check that Styles contains the same rules."""
if not isinstance(styles, StylesBase):
@@ -627,30 +665,6 @@ class Styles(StylesBase):
if self.important:
yield "important", self.important
def __textual_animation__(
self,
attribute: str,
value: Any,
start_time: float,
duration: float | None,
speed: float | None,
easing: EasingFunction,
on_complete: CallbackType | None = None,
) -> ScalarAnimation | None:
if isinstance(value, ScalarOffset):
return ScalarAnimation(
self.node,
self,
start_time,
attribute,
value,
duration=duration,
speed=speed,
easing=easing,
on_complete=on_complete,
)
return None
def _get_border_css_lines(
self, rules: RulesMap, name: str
) -> Iterable[tuple[str, str]]:
@@ -935,7 +949,7 @@ class RenderStyles(StylesBase):
def animate(
self,
attribute: str,
value: float | Animatable,
value: str | float | Animatable,
*,
final_value: object = ...,
duration: float | None = None,

View File

@@ -40,8 +40,13 @@ class Pilot:
Args:
delay (float, optional): Seconds to pause. Defaults to 50ms.
"""
# These sleep zeros, are to force asyncio to give up a time-slice,
await asyncio.sleep(delay)
async def wait_for_animation(self) -> None:
"""Wait for any animation to complete."""
await self._app.animator.wait_for_idle()
async def exit(self, result: object) -> None:
"""Exit the app with the given result.

39
tests/test_animation.py Normal file
View File

@@ -0,0 +1,39 @@
from time import perf_counter
from textual.app import App, ComposeResult
from textual.widgets import Static
class AnimApp(App):
CSS = """
#foo {
height: 1;
}
"""
def compose(self) -> ComposeResult:
yield Static("foo", id="foo")
async def test_animate_height() -> None:
"""Test animating styles.height works."""
# Styles.height is a scalar, which makes it more complicated to animate
app = AnimApp()
async with app.run_test() as pilot:
static = app.query_one(Static)
assert static.size.height == 1
assert static.styles.height.value == 1
static.styles.animate("height", 100, duration=0.5, easing="linear")
start = perf_counter()
# Wait for the animation to finished
await pilot.wait_for_animation()
elapsed = perf_counter() - start
# Check that the full time has elapsed
assert elapsed >= 0.5
# Check the height reached the maximum
assert static.styles.height.value == 100