From 8be6ea91f6e8a8d24d385975f1a5a7714cf27894 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Feb 2022 15:32:02 +0000 Subject: [PATCH 1/4] fix and test for animator --- src/textual/_animator.py | 25 +++--- tests/test_animator.py | 166 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 16 deletions(-) create mode 100644 tests/test_animator.py diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 248d2c3e5d..08620bcd98 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,26 +45,19 @@ 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 + value = self.final_value else: factor = min(1.0, (time - self.start_time) / self.duration) eased_factor = self.easing(factor) if factor == 1.0: - value = self.end_value + value = self.final_value elif isinstance(self.start_value, Animatable): assert isinstance( self.end_value, Animatable @@ -88,7 +81,7 @@ def blend(start: AnimatableT, end: AnimatableT, factor: float) -> AnimatableT: + (self.start_value - self.end_value) * eased_factor ) setattr(self.obj, self.attribute, value) - return value == self.end_value + return value == self.final_value class BoundAnimator: @@ -101,7 +94,7 @@ 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, @@ -153,7 +146,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, diff --git a/tests/test_animator.py b/tests/test_animator.py new file mode 100644 index 0000000000..2814c048f9 --- /dev/null +++ b/tests/test_animator.py @@ -0,0 +1,166 @@ +from __future__ import annotations + + +from dataclasses import dataclass +from unittest.mock import Mock + +import pytest + + +from textual._animator import SimpleAnimation + + +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 to animate.""" + + foo: float | None = 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 + animatable = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animatable, + "foo", + time, + 3.0, + start_value=20.0, + end_value=50.0, + final_value=None, + easing=lambda x: x, + ) + + assert animation(time) is False + assert animatable.foo == 20.0 + + assert animation(time + 1.0) is False + assert animatable.foo == 30.0 + + assert animation(time + 2.0) is False + assert animatable.foo == 40.0 + + assert animation(time + 2.9) is False + assert pytest.approx(animatable.foo, 49.0) + + assert animation(time + 3.0) is True # True to indicate animation is complete + assert animatable.foo is None # This is final_value + + assert animation(time + 3.0) is True + assert animatable.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 + 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 + animatable = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animatable, + "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 animatable.foo == 50.0 + + assert animation(time + 1.0) is False + assert animatable.foo == 40.0 + + assert animation(time + 2.0) is False + assert animatable.foo == 30.0 + + assert animation(time + 3.0) is True + assert animatable.foo == 20.0 + + +def test_animatable(): + """Test SimpleAnimation works with the Animatable protocol""" + + animatable = AnimateTest() + + # Fake wall-clock time + time = 100.0 + + # Object that does the animation + animation = SimpleAnimation( + animatable, + "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 animatable.bar.value == 20.0 + + assert animation(time + 1.0) is False + assert animatable.bar.value == 30.0 + + assert animation(time + 2.0) is False + assert animatable.bar.value == 40.0 + + assert animation(time + 2.9) is False + assert pytest.approx(animatable.bar.value, 49.0) + + assert animation(time + 3.0) is True # True to indicate animation is complete + assert animatable.bar.value == 50.0 From 73a1b5377ca9a4a6ba7f1e21f481dd33df3590dd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Feb 2022 15:37:47 +0000 Subject: [PATCH 2/4] refinement --- src/textual/_animator.py | 52 +++++++++++++++++++--------------------- tests/test_animator.py | 8 ++++--- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 08620bcd98..d0fc2aa805 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -51,37 +51,35 @@ class SimpleAnimation(Animation): def __call__(self, time: float) -> bool: if self.duration == 0: + 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.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) + 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.final_value + return factor >= 1 class BoundAnimator: diff --git a/tests/test_animator.py b/tests/test_animator.py index 2814c048f9..e64a50ae51 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -22,7 +22,7 @@ def blend(self, destination: Animatable, factor: float) -> Animatable: @dataclass class AnimateTest: - """An object to animate.""" + """An object with animatable properties.""" foo: float | None = 0 # Plain float that may be set to None on final_value bar: Animatable = Animatable(0) # A mock object supporting the animatable protocol @@ -49,6 +49,8 @@ def test_simple_animation(): easing=lambda x: x, ) + assert animatable.foo == 0.0 + assert animation(time) is False assert animatable.foo == 20.0 @@ -58,7 +60,7 @@ def test_simple_animation(): assert animation(time + 2.0) is False assert animatable.foo == 40.0 - assert animation(time + 2.9) is False + assert animation(time + 2.9) is False # Not quite final value assert pytest.approx(animatable.foo, 49.0) assert animation(time + 3.0) is True # True to indicate animation is complete @@ -89,7 +91,7 @@ def test_simple_animation_duration_zero(): easing=lambda x: x, ) - assert animation(time) is True + 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 From 8fa6749fdd4bd2c1ed7d9ea19afcaf8fe2034212 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Feb 2022 16:26:46 +0000 Subject: [PATCH 3/4] more tests of animation --- src/textual/_animator.py | 19 ++++-- tests/test_animator.py | 132 ++++++++++++++++++++++++++++++++------- 2 files changed, 121 insertions(+), 30 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index d0fc2aa805..02365c4cdc 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -98,7 +98,7 @@ def __call__( 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, @@ -122,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.""" @@ -163,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: @@ -207,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 index e64a50ae51..a30bee8520 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -7,7 +7,8 @@ import pytest -from textual._animator import SimpleAnimation +from textual._animator import Animator, SimpleAnimation +from textual._easing import EASING, DEFAULT_EASING class Animatable: @@ -24,7 +25,7 @@ def blend(self, destination: Animatable, factor: float) -> Animatable: class AnimateTest: """An object with animatable properties.""" - foo: float | None = 0 # Plain float that may be set to None on final_value + 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 @@ -32,14 +33,14 @@ def test_simple_animation(): """Test an animation from one float to another.""" # Thing that may be animated - animatable = AnimateTest() + animate_test = AnimateTest() # Fake wall-clock time time = 100.0 # Object that does the animation animation = SimpleAnimation( - animatable, + animate_test, "foo", time, 3.0, @@ -49,25 +50,25 @@ def test_simple_animation(): easing=lambda x: x, ) - assert animatable.foo == 0.0 + assert animate_test.foo == 0.0 assert animation(time) is False - assert animatable.foo == 20.0 + assert animate_test.foo == 20.0 assert animation(time + 1.0) is False - assert animatable.foo == 30.0 + assert animate_test.foo == 30.0 assert animation(time + 2.0) is False - assert animatable.foo == 40.0 + assert animate_test.foo == 40.0 assert animation(time + 2.9) is False # Not quite final value - assert pytest.approx(animatable.foo, 49.0) + assert pytest.approx(animate_test.foo, 49.0) assert animation(time + 3.0) is True # True to indicate animation is complete - assert animatable.foo is None # This is final_value + assert animate_test.foo is None # This is final_value assert animation(time + 3.0) is True - assert animatable.foo is None + assert animate_test.foo is None def test_simple_animation_duration_zero(): @@ -102,14 +103,14 @@ 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 - animatable = AnimateTest() + animate_Test = AnimateTest() # Fake wall-clock time time = 100.0 # Object that does the animation animation = SimpleAnimation( - animatable, + animate_Test, "foo", time, 3.0, @@ -120,29 +121,29 @@ def test_simple_animation_reverse(): ) assert animation(time) is False - assert animatable.foo == 50.0 + assert animate_Test.foo == 50.0 assert animation(time + 1.0) is False - assert animatable.foo == 40.0 + assert animate_Test.foo == 40.0 assert animation(time + 2.0) is False - assert animatable.foo == 30.0 + assert animate_Test.foo == 30.0 assert animation(time + 3.0) is True - assert animatable.foo == 20.0 + assert animate_Test.foo == 20.0 def test_animatable(): """Test SimpleAnimation works with the Animatable protocol""" - animatable = AnimateTest() + animate_test = AnimateTest() # Fake wall-clock time time = 100.0 # Object that does the animation animation = SimpleAnimation( - animatable, + animate_test, "bar", time, 3.0, @@ -153,16 +154,99 @@ def test_animatable(): ) assert animation(time) is False - assert animatable.bar.value == 20.0 + assert animate_test.bar.value == 20.0 assert animation(time + 1.0) is False - assert animatable.bar.value == 30.0 + assert animate_test.bar.value == 30.0 assert animation(time + 2.0) is False - assert animatable.bar.value == 40.0 + assert animate_test.bar.value == 40.0 assert animation(time + 2.9) is False - assert pytest.approx(animatable.bar.value, 49.0) + assert pytest.approx(animate_test.bar.value, 49.0) assert animation(time + 3.0) is True # True to indicate animation is complete - assert animatable.bar.value == 50.0 + 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 + + 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 + + animator() + assert animate_test.foo == 0 + + 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() From 9ea2c6e7416dc00639a8fa46c9b5215063c793d1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 21 Feb 2022 16:48:20 +0000 Subject: [PATCH 4/4] assert animation frame called --- tests/test_animator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_animator.py b/tests/test_animator.py index a30bee8520..1b7113692d 100644 --- a/tests/test_animator.py +++ b/tests/test_animator.py @@ -175,6 +175,7 @@ class TestAnimator(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 @@ -203,9 +204,11 @@ def test_animator(): 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()