mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
* Ensure prior scrolling animations dont interfere with new scroll_to calls * Adding test for animator force cancellation * Updating changelog * Different approach * Running on_complete later * Scheduling on_complete callback after animation completes rather than immediately invoking * Reverting _scroll_to implementation
293 lines
7.3 KiB
Python
293 lines
7.3 KiB
Python
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 DEFAULT_EASING, 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 animate_test.foo == pytest.approx(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 animate_test.bar.value == pytest.approx(49.0)
|
|
|
|
assert animation(time + 3.0) is True # True to indicate animation is complete
|
|
assert animate_test.bar.value == 50.0
|
|
|
|
|
|
class MockAnimator(Animator):
|
|
"""A mock animator."""
|
|
|
|
def __init__(self, *args) -> None:
|
|
super().__init__(*args)
|
|
self._time = 0.0
|
|
self._on_animation_frame_called = False
|
|
|
|
def on_animation_frame(self):
|
|
self._on_animation_frame_called = True
|
|
|
|
def _get_time(self):
|
|
return self._time
|
|
|
|
|
|
async def test_animator():
|
|
target = Mock()
|
|
animator = MockAnimator(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
|
|
|
|
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 = MockAnimator(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
|
|
|
|
|
|
async def test_animator_on_complete_callback_not_fired_before_duration_ends():
|
|
callback = Mock()
|
|
animate_test = AnimateTest()
|
|
animator = MockAnimator(Mock())
|
|
|
|
animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback)
|
|
|
|
animator._time = 9
|
|
animator()
|
|
|
|
assert not callback.called
|
|
|
|
|
|
async def test_animator_on_complete_callback_fired_at_duration():
|
|
callback = Mock()
|
|
animate_test = AnimateTest()
|
|
mock_app = Mock()
|
|
animator = MockAnimator(mock_app)
|
|
|
|
animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback)
|
|
|
|
animator._time = 10
|
|
animator()
|
|
|
|
# Ensure that the callback is scheduled to run after the duration is up.
|
|
mock_app.call_later.assert_called_once_with(callback)
|
|
|
|
|
|
def test_force_stop_animation():
|
|
callback = Mock()
|
|
animate_test = AnimateTest()
|
|
mock_app = Mock()
|
|
animator = MockAnimator(mock_app)
|
|
|
|
animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback)
|
|
|
|
assert animator.is_being_animated(animate_test, "foo")
|
|
assert animate_test.foo != 200
|
|
|
|
animator.force_stop_animation(animate_test, "foo")
|
|
|
|
# The animation of the attribute was force cancelled.
|
|
assert not animator.is_being_animated(animate_test, "foo")
|
|
assert animate_test.foo == 200
|
|
|
|
# The on_complete callback was scheduled.
|
|
mock_app.call_later.assert_called_once_with(callback)
|