Repository: Textualize/textual

Issue:
No way to wait for all animations (current and scheduled) to be done.
The coroutine `Animator.wait_for_idle` will wait for animations that are in effect to be done.
However, it does not take into account transitions that are scheduled.

It would be nice if we could await for _all_ transitions, both the ones that are happening and the ones that are scheduled to happen in the future.

This will come in handy for testing and _might_ also be useful for users in apps.


Discussion:

Resolving patch:
diff --git a/src/textual/_animator.py b/src/textual/_animator.py
--- a/src/textual/_animator.py
+++ b/src/textual/_animator.py
@@ -21,6 +21,9 @@
 if TYPE_CHECKING:
     from textual.app import App
 
+    AnimationKey = tuple[int, str]
+    """Animation keys are the id of the object and the attribute being animated."""
+
 EasingFunction = Callable[[float], float]
 
 
@@ -166,10 +169,19 @@ def __call__(
 
 
 class Animator:
-    """An object to manage updates to a given attribute over a period of time."""
+    """An object to manage updates to a given attribute over a period of time.
+
+    Attrs:
+        _animations: Dictionary that maps animation keys to the corresponding animation
+            instances.
+        _scheduled: Keys corresponding to animations that have been scheduled but not yet
+            started.
+        app: The app that owns the animator object.
+    """
 
     def __init__(self, app: App, frames_per_second: int = 60) -> None:
-        self._animations: dict[tuple[object, str], Animation] = {}
+        self._animations: dict[AnimationKey, Animation] = {}
+        self._scheduled: set[AnimationKey] = set()
         self.app = app
         self._timer = Timer(
             app,
@@ -179,11 +191,15 @@ def __init__(self, app: App, frames_per_second: int = 60) -> None:
             callback=self,
             pause=True,
         )
+        # Flag if no animations are currently taking place.
         self._idle_event = asyncio.Event()
+        # Flag if no animations are currently taking place and none are scheduled.
+        self._complete_event = asyncio.Event()
 
     async def start(self) -> None:
         """Start the animator task."""
         self._idle_event.set()
+        self._complete_event.set()
         self._timer.start()
 
     async def stop(self) -> None:
@@ -194,11 +210,17 @@ async def stop(self) -> None:
             pass
         finally:
             self._idle_event.set()
+            self._complete_event.set()
 
     def bind(self, obj: object) -> BoundAnimator:
-        """Bind the animator to a given objects."""
+        """Bind the animator to a given object."""
         return BoundAnimator(self, obj)
 
+    def is_being_animated(self, obj: object, attribute: str) -> bool:
+        """Does the object/attribute pair have an ongoing or scheduled animation?"""
+        key = (id(obj), attribute)
+        return key in self._animations or key in self._scheduled
+
     def animate(
         self,
         obj: object,
@@ -237,6 +259,8 @@ def animate(
             on_complete=on_complete,
         )
         if delay:
+            self._scheduled.add((id(obj), attribute))
+            self._complete_event.clear()
             self.app.set_timer(delay, animate_callback)
         else:
             animate_callback()
@@ -273,13 +297,14 @@ def _animate(
             duration is None and speed is not None
         ), "An Animation should have a duration OR a speed"
 
+        animation_key = (id(obj), attribute)
+        self._scheduled.discard(animation_key)
+
         if final_value is ...:
             final_value = value
 
         start_time = self._get_time()
 
-        animation_key = (id(obj), attribute)
-
         easing_function = EASING[easing] if isinstance(easing, str) else easing
 
         animation: Animation | None = None
@@ -342,11 +367,14 @@ def _animate(
         self._animations[animation_key] = animation
         self._timer.resume()
         self._idle_event.clear()
+        self._complete_event.clear()
 
     async def __call__(self) -> None:
         if not self._animations:
             self._timer.pause()
             self._idle_event.set()
+            if not self._scheduled:
+                self._complete_event.set()
         else:
             animation_time = self._get_time()
             animation_keys = list(self._animations.keys())
@@ -368,3 +396,7 @@ def _get_time(self) -> float:
     async def wait_for_idle(self) -> None:
         """Wait for any animations to complete."""
         await self._idle_event.wait()
+
+    async def wait_until_complete(self) -> None:
+        """Wait for any current and scheduled animations to complete."""
+        await self._complete_event.wait()
diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py
--- a/src/textual/css/stylesheet.py
+++ b/src/textual/css/stylesheet.py
@@ -449,6 +449,8 @@ def replace_rules(
         get_new_render_rule = new_render_rules.get
 
         if animate:
+            animator = node.app.animator
+            base = node.styles.base
             for key in modified_rule_keys:
                 # Get old and new render rules
                 old_render_value = get_current_render_rule(key)
@@ -456,13 +458,18 @@ def replace_rules(
                 # Get new rule value (may be None)
                 new_value = rules.get(key)
 
-                # Check if this can / should be animated
-                if is_animatable(key) and new_render_value != old_render_value:
+                # Check if this can / should be animated. It doesn't suffice to check
+                # if the current and target values are different because a previous
+                # animation may have been scheduled but may have not started yet.
+                if is_animatable(key) and (
+                    new_render_value != old_render_value
+                    or animator.is_being_animated(base, key)
+                ):
                     transition = new_styles._get_transition(key)
                     if transition is not None:
                         duration, easing, delay = transition
-                        node.app.animator.animate(
-                            node.styles.base,
+                        animator.animate(
+                            base,
                             key,
                             new_render_value,
                             final_value=new_value,
diff --git a/src/textual/pilot.py b/src/textual/pilot.py
--- a/src/textual/pilot.py
+++ b/src/textual/pilot.py
@@ -43,9 +43,13 @@ async def pause(self, delay: float = 50 / 1000) -> None:
         await asyncio.sleep(delay)
 
     async def wait_for_animation(self) -> None:
-        """Wait for any animation to complete."""
+        """Wait for any current animation to complete."""
         await self._app.animator.wait_for_idle()
 
+    async def wait_for_scheduled_animations(self) -> None:
+        """Wait for any current and scheduled animations to complete."""
+        await self._app.animator.wait_until_complete()
+
     async def exit(self, result: ReturnType) -> None:
         """Exit the app with the given result.
 


Test patch:
diff --git a/tests/test_animation.py b/tests/test_animation.py
--- a/tests/test_animation.py
+++ b/tests/test_animation.py
@@ -9,7 +9,7 @@ class AnimApp(App):
     #foo {
         height: 1;
     }
-    
+
     """
 
     def compose(self) -> ComposeResult:
@@ -37,3 +37,124 @@ async def test_animate_height() -> None:
         assert elapsed >= 0.5
         # Check the height reached the maximum
         assert static.styles.height.value == 100
+
+
+async def test_scheduling_animation() -> None:
+    """Test that scheduling an animation works."""
+
+    app = AnimApp()
+    delay = 0.1
+
+    async with app.run_test() as pilot:
+        styles = app.query_one(Static).styles
+        styles.background = "black"
+
+        styles.animate("background", "white", delay=delay, duration=0)
+
+        await pilot.pause(0.9 * delay)
+        assert styles.background.rgb == (0, 0, 0)  # Still black
+
+        await pilot.wait_for_scheduled_animations()
+        assert styles.background.rgb == (255, 255, 255)
+
+
+async def test_wait_for_current_animations() -> None:
+    """Test that we can wait only for the current animations taking place."""
+
+    app = AnimApp()
+
+    delay = 10
+
+    async with app.run_test() as pilot:
+        styles = app.query_one(Static).styles
+        styles.animate("height", 100, duration=0.1)
+        start = perf_counter()
+        styles.animate("height", 200, duration=0.1, delay=delay)
+
+        # Wait for the first animation to finish
+        await pilot.wait_for_animation()
+        elapsed = perf_counter() - start
+        assert elapsed < (delay / 2)
+
+
+async def test_wait_for_current_and_scheduled_animations() -> None:
+    """Test that we can wait for current and scheduled animations."""
+
+    app = AnimApp()
+
+    async with app.run_test() as pilot:
+        styles = app.query_one(Static).styles
+
+        start = perf_counter()
+        styles.animate("height", 50, duration=0.01)
+        styles.animate("background", "black", duration=0.01, delay=0.05)
+
+        await pilot.wait_for_scheduled_animations()
+        elapsed = perf_counter() - start
+        assert elapsed >= 0.06
+        assert styles.background.rgb == (0, 0, 0)
+
+
+async def test_reverse_animations() -> None:
+    """Test that you can create reverse animations.
+
+    Regression test for #1372 https://github.com/Textualize/textual/issues/1372
+    """
+
+    app = AnimApp()
+
+    async with app.run_test() as pilot:
+        static = app.query_one(Static)
+        styles = static.styles
+
+        # Starting point.
+        styles.background = "black"
+        assert styles.background.rgb == (0, 0, 0)
+
+        # First, make sure we can go from black to white and back, step by step.
+        styles.animate("background", "white", duration=0.01)
+        await pilot.wait_for_animation()
+        assert styles.background.rgb == (255, 255, 255)
+
+        styles.animate("background", "black", duration=0.01)
+        await pilot.wait_for_animation()
+        assert styles.background.rgb == (0, 0, 0)
+
+        # Now, the actual test is to make sure we go back to black if creating both at once.
+        styles.animate("background", "white", duration=0.01)
+        styles.animate("background", "black", duration=0.01)
+        await pilot.wait_for_animation()
+        assert styles.background.rgb == (0, 0, 0)
+
+
+async def test_schedule_reverse_animations() -> None:
+    """Test that you can schedule reverse animations.
+
+    Regression test for #1372 https://github.com/Textualize/textual/issues/1372
+    """
+
+    app = AnimApp()
+
+    async with app.run_test() as pilot:
+        static = app.query_one(Static)
+        styles = static.styles
+
+        # Starting point.
+        styles.background = "black"
+        assert styles.background.rgb == (0, 0, 0)
+
+        # First, make sure we can go from black to white and back, step by step.
+        styles.animate("background", "white", delay=0.01, duration=0.01)
+        await pilot.wait_for_scheduled_animations()
+        assert styles.background.rgb == (255, 255, 255)
+
+        styles.animate("background", "black", delay=0.01, duration=0.01)
+        await pilot.wait_for_scheduled_animations()
+        assert styles.background.rgb == (0, 0, 0)
+
+        # Now, the actual test is to make sure we go back to black if scheduling both at once.
+        styles.animate("background", "white", delay=0.01, duration=0.01)
+        await pilot.pause(0.005)
+        styles.animate("background", "black", delay=0.01, duration=0.01)
+        await pilot.wait_for_scheduled_animations()
+        assert styles.background.rgb == (0, 0, 0)