diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 248d2c3e5d..02365c4cdc 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -16,7 +16,7 @@ if sys.version_info >= (3, 8): from typing import Protocol, runtime_checkable -else: +else: # pragma: no cover from typing_extensions import Protocol, runtime_checkable @@ -27,13 +27,13 @@ @runtime_checkable class Animatable(Protocol): - def blend(self: T, destination: T, factor: float) -> T: + def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover ... class Animation(ABC): @abstractmethod - def __call__(self, time: float) -> bool: + def __call__(self, time: float) -> bool: # pragma: no cover raise NotImplementedError("") @@ -45,50 +45,41 @@ class SimpleAnimation(Animation): duration: float start_value: float | Animatable end_value: float | Animatable - final_value: float | Animatable + final_value: object easing: EasingFunction def __call__(self, time: float) -> bool: - def blend_float(start: float, end: float, factor: float) -> float: - return start + (end - start) * factor - - AnimatableT = TypeVar("AnimatableT", bound=Animatable) - - def blend(start: AnimatableT, end: AnimatableT, factor: float) -> AnimatableT: - return start.blend(end, factor) if self.duration == 0: - value = self.end_value + setattr(self.obj, self.attribute, self.final_value) + return True + + factor = min(1.0, (time - self.start_time) / self.duration) + eased_factor = self.easing(factor) + + if factor == 1.0: + value = self.final_value + elif isinstance(self.start_value, Animatable): + assert isinstance( + self.end_value, Animatable + ), "end_value must be animatable" + value = self.start_value.blend(self.end_value, eased_factor) else: - factor = min(1.0, (time - self.start_time) / self.duration) - eased_factor = self.easing(factor) - - if factor == 1.0: - value = self.end_value - elif isinstance(self.start_value, Animatable): - assert isinstance( - self.end_value, Animatable - ), "end_value must be animatable" - value = self.start_value.blend(self.end_value, eased_factor) + assert isinstance(self.start_value, float), "`start_value` must be float" + assert isinstance(self.end_value, float), "`end_value` must be float" + if self.end_value > self.start_value: + eased_factor = self.easing(factor) + value = ( + self.start_value + + (self.end_value - self.start_value) * eased_factor + ) else: - assert isinstance( - self.start_value, float - ), "`start_value` must be float" - assert isinstance(self.end_value, float), "`end_value` must be float" - if self.end_value > self.start_value: - eased_factor = self.easing(factor) - value = ( - self.start_value - + (self.end_value - self.start_value) * eased_factor - ) - else: - eased_factor = 1 - self.easing(factor) - value = ( - self.end_value - + (self.start_value - self.end_value) * eased_factor - ) + eased_factor = 1 - self.easing(factor) + value = ( + self.end_value + (self.start_value - self.end_value) * eased_factor + ) setattr(self.obj, self.attribute, value) - return value == self.end_value + return factor >= 1 class BoundAnimator: @@ -101,13 +92,13 @@ def __call__( attribute: str, value: float, *, - final_value: Any = ..., + final_value: object = ..., duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, ) -> None: easing_function = EASING[easing] if isinstance(easing, str) else easing - self._animator.animate( + return self._animator.animate( self._obj, attribute=attribute, value=value, @@ -131,6 +122,10 @@ def __init__(self, target: MessageTarget, frames_per_second: int = 60) -> None: pause=True, ) + def get_time(self) -> float: + """Get the current wall clock time.""" + return time() + async def start(self) -> None: """Start the animator task.""" @@ -153,7 +148,7 @@ def animate( attribute: str, value: Any, *, - final_value: Any = ..., + final_value: object = ..., duration: float | None = None, speed: float | None = None, easing: EasingFunction | str = DEFAULT_EASING, @@ -172,7 +167,7 @@ def animate( if final_value is ...: final_value = value - start_time = time() + start_time = self.get_time() animation_key = (id(obj), attribute) if animation_key in self._animations: @@ -216,15 +211,18 @@ def animate( self._animations[animation_key] = animation self._timer.resume() - async def __call__(self) -> None: + def __call__(self) -> None: if not self._animations: self._timer.pause() else: - animation_time = time() + animation_time = self.get_time() animation_keys = list(self._animations.keys()) for animation_key in animation_keys: animation = self._animations[animation_key] if animation(animation_time): del self._animations[animation_key] - # TODO: We should be able to do animation without refreshing everything - self.target.view.refresh(True, True) + self.on_animation_frame() + + def on_animation_frame(self) -> None: + # TODO: We should be able to do animation without refreshing everything + self.target.view.refresh(True, True) diff --git a/tests/test_animator.py b/tests/test_animator.py new file mode 100644 index 0000000000..1b7113692d --- /dev/null +++ b/tests/test_animator.py @@ -0,0 +1,255 @@ +from __future__ import annotations + + +from dataclasses import dataclass +from unittest.mock import Mock + +import pytest + + +from textual._animator import Animator, SimpleAnimation +from textual._easing import EASING, DEFAULT_EASING + + +class Animatable: + """An animatable object.""" + + def __init__(self, value): + self.value = value + + def blend(self, destination: Animatable, factor: float) -> Animatable: + return Animatable(self.value + (destination.value - self.value) * factor) + + +@dataclass +class AnimateTest: + """An object with animatable properties.""" + + foo: float | None = 0.0 # Plain float that may be set to None on final_value + bar: Animatable = Animatable(0) # A mock object supporting the animatable protocol + + +def test_simple_animation(): + """Test an animation from one float to another.""" + + # Thing that may be animated + animate_test = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animate_test, + "foo", + time, + 3.0, + start_value=20.0, + end_value=50.0, + final_value=None, + easing=lambda x: x, + ) + + assert animate_test.foo == 0.0 + + assert animation(time) is False + assert animate_test.foo == 20.0 + + assert animation(time + 1.0) is False + assert animate_test.foo == 30.0 + + assert animation(time + 2.0) is False + assert animate_test.foo == 40.0 + + assert animation(time + 2.9) is False # Not quite final value + assert pytest.approx(animate_test.foo, 49.0) + + assert animation(time + 3.0) is True # True to indicate animation is complete + assert animate_test.foo is None # This is final_value + + assert animation(time + 3.0) is True + assert animate_test.foo is None + + +def test_simple_animation_duration_zero(): + """Test animation handles duration of 0.""" + + # Thing that may be animated + animatable = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animatable, + "foo", + time, + 0.0, + start_value=20.0, + end_value=50.0, + final_value=50.0, + easing=lambda x: x, + ) + + assert animation(time) is True # Duration is 0, so this is last value + assert animatable.foo == 50.0 + + assert animation(time + 1.0) is True + assert animatable.foo == 50.0 + + +def test_simple_animation_reverse(): + """Test an animation from one float to another, where the end value is less than the start.""" + + # Thing that may be animated + animate_Test = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animate_Test, + "foo", + time, + 3.0, + start_value=50.0, + end_value=20.0, + final_value=20.0, + easing=lambda x: x, + ) + + assert animation(time) is False + assert animate_Test.foo == 50.0 + + assert animation(time + 1.0) is False + assert animate_Test.foo == 40.0 + + assert animation(time + 2.0) is False + assert animate_Test.foo == 30.0 + + assert animation(time + 3.0) is True + assert animate_Test.foo == 20.0 + + +def test_animatable(): + """Test SimpleAnimation works with the Animatable protocol""" + + animate_test = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animate_test, + "bar", + time, + 3.0, + start_value=Animatable(20.0), + end_value=Animatable(50.0), + final_value=Animatable(50.0), + easing=lambda x: x, + ) + + assert animation(time) is False + assert animate_test.bar.value == 20.0 + + assert animation(time + 1.0) is False + assert animate_test.bar.value == 30.0 + + assert animation(time + 2.0) is False + assert animate_test.bar.value == 40.0 + + assert animation(time + 2.9) is False + assert pytest.approx(animate_test.bar.value, 49.0) + + assert animation(time + 3.0) is True # True to indicate animation is complete + assert animate_test.bar.value == 50.0 + + +class TestAnimator(Animator): + """A mock animator.""" + + def __init__(self, *args) -> None: + super().__init__(*args) + self._time = 0.0 + self._on_animation_frame_called = False + + def get_time(self): + return self._time + + def on_animation_frame(self): + self._on_animation_frame_called = True + + +def test_animator(): + + target = Mock() + animator = TestAnimator(target) + animate_test = AnimateTest() + + # Animate attribute "foo" on animate_test to 100.0 in 10 seconds + animator.animate(animate_test, "foo", 100.0, duration=10.0) + + expected = SimpleAnimation( + animate_test, + "foo", + 0.0, + duration=10.0, + start_value=0.0, + end_value=100.0, + final_value=100.0, + easing=EASING[DEFAULT_EASING], + ) + assert animator._animations[(id(animate_test), "foo")] == expected + assert not animator._on_animation_frame_called + + animator() + assert animate_test.foo == 0 + assert animator._on_animation_frame_called + + animator._time = 5 + animator() + assert animate_test.foo == 50 + + # New animation in the middle of an existing one + animator.animate(animate_test, "foo", 200, duration=1) + assert animate_test.foo == 50 + + animator._time = 6 + animator() + assert animate_test.foo == 200 + + +def test_bound_animator(): + + target = Mock() + animator = TestAnimator(target) + animate_test = AnimateTest() + + # Bind an animator so it animates attributes on the given object + bound_animator = animator.bind(animate_test) + + # Animate attribute "foo" on animate_test to 100.0 in 10 seconds + bound_animator("foo", 100.0, duration=10) + + expected = SimpleAnimation( + animate_test, + "foo", + 0, + duration=10, + start_value=0, + end_value=100, + final_value=100, + easing=EASING[DEFAULT_EASING], + ) + assert animator._animations[(id(animate_test), "foo")] == expected + + +def test_animator_get_time(): + target = Mock() + animator = Animator(target) + assert isinstance(animator.get_time(), float) + assert animator.get_time() <= animator.get_time()