diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 248d2c3e5..08620bcd9 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -16,7 +16,7 @@ from ._types import MessageTarget 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 @@ T = TypeVar("T") @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 @@ class SimpleAnimation(Animation): + (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 @@ class BoundAnimator: 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 @@ class Animator: 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 000000000..2814c048f --- /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