Skip to content

Commit 266a89f

Browse files
authored
Cancelling scrolling animations on new scroll_to calls (#4081)
* 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
1 parent cd5e309 commit 266a89f

File tree

5 files changed

+78
-13
lines changed

5 files changed

+78
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1818
- Made `textual.cache` (formerly `textual._cache`) public https://github.com/Textualize/textual/pull/3976
1919
- `Tab.label` can now be used to change the label of a tab https://github.com/Textualize/textual/pull/3979
2020
- Changed the default notification timeout from 3 to 5 seconds https://github.com/Textualize/textual/pull/4059
21+
- Prior scroll animations are now cancelled on new scrolls https://github.com/Textualize/textual/pull/4081
2122

2223
### Added
2324

src/textual/_animator.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,11 @@ def _animate(
409409
end_value=value,
410410
final_value=final_value,
411411
easing=easing_function,
412-
on_complete=on_complete,
412+
on_complete=(
413+
partial(self.app.call_later, on_complete)
414+
if on_complete is not None
415+
else None
416+
),
413417
)
414418
assert animation is not None, "animation expected to be non-None"
415419

@@ -483,7 +487,34 @@ async def stop_animation(
483487
elif key in self._animations:
484488
await self._stop_running_animation(key, complete)
485489

486-
async def __call__(self) -> None:
490+
def force_stop_animation(self, obj: object, attribute: str) -> None:
491+
"""Force stop an animation on an attribute. This will immediately stop the animation,
492+
without running any associated callbacks, setting the attribute to its final value.
493+
494+
Args:
495+
obj: The object containing the attribute.
496+
attribute: The name of the attribute.
497+
498+
Note:
499+
If there is no animation scheduled or running, this is a no-op.
500+
"""
501+
from .css.scalar_animation import ScalarAnimation
502+
503+
animation_key = (id(obj), attribute)
504+
try:
505+
animation = self._animations.pop(animation_key)
506+
except KeyError:
507+
return
508+
509+
if isinstance(animation, SimpleAnimation):
510+
setattr(obj, attribute, animation.end_value)
511+
elif isinstance(animation, ScalarAnimation):
512+
setattr(obj, attribute, animation.final_value)
513+
514+
if animation.on_complete is not None:
515+
animation.on_complete()
516+
517+
def __call__(self) -> None:
487518
if not self._animations:
488519
self._timer.pause()
489520
self._idle_event.set()
@@ -497,7 +528,8 @@ async def __call__(self) -> None:
497528
animation_complete = animation(animation_time)
498529
if animation_complete:
499530
del self._animations[animation_key]
500-
await animation.invoke_callback()
531+
if animation.on_complete is not None:
532+
animation.on_complete()
501533

502534
def _get_time(self) -> float:
503535
"""Get the current wall clock time, via the internal Timer.
@@ -506,7 +538,7 @@ def _get_time(self) -> float:
506538
The wall clock time.
507539
"""
508540
# N.B. We could remove this method and always call `self._timer.get_time()` internally,
509-
# but it's handy to have in mocking situations
541+
# but it's handy to have in mocking situations.
510542
return _time.get_time()
511543

512544
async def wait_for_idle(self) -> None:

src/textual/css/styles.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from abc import ABC, abstractmethod
44
from dataclasses import dataclass, field
5-
from functools import lru_cache
5+
from functools import lru_cache, partial
66
from operator import attrgetter
77
from typing import TYPE_CHECKING, Any, Iterable, NamedTuple, cast
88

@@ -395,7 +395,11 @@ def __textual_animation__(
395395
duration=duration,
396396
speed=speed,
397397
easing=easing,
398-
on_complete=on_complete,
398+
on_complete=(
399+
partial(self.node.app.call_later, on_complete)
400+
if on_complete is not None
401+
else None
402+
),
399403
)
400404
return None
401405

src/textual/widget.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,6 +1911,11 @@ def _scroll_to(
19111911
maybe_scroll_x = x is not None and (self.allow_horizontal_scroll or force)
19121912
maybe_scroll_y = y is not None and (self.allow_vertical_scroll or force)
19131913
scrolled_x = scrolled_y = False
1914+
1915+
animator = self.app.animator
1916+
animator.force_stop_animation(self, "scroll_x")
1917+
animator.force_stop_animation(self, "scroll_y")
1918+
19141919
if animate:
19151920
# TODO: configure animation speed
19161921
if duration is None and speed is None:

tests/test_animator.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,19 +203,19 @@ async def test_animator():
203203
assert animator._animations[(id(animate_test), "foo")] == expected
204204
assert not animator._on_animation_frame_called
205205

206-
await animator()
206+
animator()
207207
assert animate_test.foo == 0
208208

209209
animator._time = 5
210-
await animator()
210+
animator()
211211
assert animate_test.foo == 50
212212

213213
# New animation in the middle of an existing one
214214
animator.animate(animate_test, "foo", 200, duration=1)
215215
assert animate_test.foo == 50
216216

217217
animator._time = 6
218-
await animator()
218+
animator()
219219
assert animate_test.foo == 200
220220

221221

@@ -251,19 +251,42 @@ async def test_animator_on_complete_callback_not_fired_before_duration_ends():
251251
animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback)
252252

253253
animator._time = 9
254-
await animator()
254+
animator()
255255

256256
assert not callback.called
257257

258258

259259
async def test_animator_on_complete_callback_fired_at_duration():
260260
callback = Mock()
261261
animate_test = AnimateTest()
262-
animator = MockAnimator(Mock())
262+
mock_app = Mock()
263+
animator = MockAnimator(mock_app)
263264

264265
animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback)
265266

266267
animator._time = 10
267-
await animator()
268+
animator()
269+
270+
# Ensure that the callback is scheduled to run after the duration is up.
271+
mock_app.call_later.assert_called_once_with(callback)
272+
273+
274+
def test_force_stop_animation():
275+
callback = Mock()
276+
animate_test = AnimateTest()
277+
mock_app = Mock()
278+
animator = MockAnimator(mock_app)
279+
280+
animator.animate(animate_test, "foo", 200, duration=10, on_complete=callback)
281+
282+
assert animator.is_being_animated(animate_test, "foo")
283+
assert animate_test.foo != 200
284+
285+
animator.force_stop_animation(animate_test, "foo")
286+
287+
# The animation of the attribute was force cancelled.
288+
assert not animator.is_being_animated(animate_test, "foo")
289+
assert animate_test.foo == 200
268290

269-
callback.assert_called_once_with()
291+
# The on_complete callback was scheduled.
292+
mock_app.call_later.assert_called_once_with(callback)

0 commit comments

Comments
 (0)