From d4dac52c8a332b5d17ea85057ed2f575241c8d08 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 10 Mar 2026 12:09:11 +0800 Subject: [PATCH 1/3] fix on complete --- .gitignore | 5 +++++ CHANGELOG.md | 7 +++++++ pyproject.toml | 3 ++- src/textual/_animator.py | 3 --- tests/test_animation.py | 1 + 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index d3b2bcb969..97b3813f5c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ docs/build docs/source/_build tools/*.txt playground/ +.mypy_cache/ +.screenshot_cache/ # Byte-compiled / optimized / DLL files __pycache__/ @@ -129,3 +131,6 @@ sandbox/ # Used by mkdocs-material social plugin .cache + +# Snapshot tests output +__snapshots__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ac56f495c3..e619870676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [8.1.1] - 2026-03-10 + +### Fixed + +- Hotfix for animation on complete + ## [8.1.0] - 2026-03-10 ### Changed @@ -3381,6 +3387,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[8.1.1]: https://github.com/Textualize/textual/compare/v8.1.0...v8.1.1 [8.1.0]: https://github.com/Textualize/textual/compare/v8.0.2...v8.1.0 [8.0.2]: https://github.com/Textualize/textual/compare/v8.0.1...v8.0.2 [8.0.1]: https://github.com/Textualize/textual/compare/v8.0.0...v8.0.1 diff --git a/pyproject.toml b/pyproject.toml index a2ae6d66e2..a1ca675c94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "8.1.0" +version = "8.1.1" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" @@ -36,6 +36,7 @@ include = [ # that works around that. { path = "docs-offline/**/*", format = "sdist" }, ] +exclude = ["tests/snapshot_tests/__snapshots__"] [tool.poetry.urls] "Bug Tracker" = "https://github.com/Textualize/textual/issues" diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 299333059d..4c13158d5e 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -459,9 +459,6 @@ def _animate( if (current_animation := self._animations.get(animation_key)) is not None: if (on_complete := current_animation.on_complete) is not None: on_complete() - self._animations.pop(animation_key) - if current_animation == animation: - return self._animations[animation_key] = animation self._timer.resume() diff --git a/tests/test_animation.py b/tests/test_animation.py index f40c1fa888..59ab2039e9 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -244,3 +244,4 @@ def on_complete() -> None: await pilot.wait_for_animation() # Check that on_complete callback was invoked twice assert complete_count == 2 + assert app.x == 101 From 11a3139b36daae3393f124c696204fd091a4834c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 10 Mar 2026 12:10:28 +0800 Subject: [PATCH 2/3] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e619870676..f34ee2b40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed -- Hotfix for animation on complete +- Hotfix for animation on complete https://github.com/Textualize/textual/pull/6412 ## [8.1.0] - 2026-03-10 From 6163ba1e6c6a0526b88064dbe19731e7358db86b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 10 Mar 2026 17:44:54 +0800 Subject: [PATCH 3/3] detailed tests --- src/textual/_animator.py | 2 +- tests/test_animation.py | 80 +++++++++++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 4c13158d5e..a6970b4990 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -458,7 +458,7 @@ def _animate( if (current_animation := self._animations.get(animation_key)) is not None: if (on_complete := current_animation.on_complete) is not None: - on_complete() + self.app.call_later(on_complete) self._animations[animation_key] = animation self._timer.resume() diff --git a/tests/test_animation.py b/tests/test_animation.py index 59ab2039e9..9784e3db95 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -213,35 +213,89 @@ async def test_cancel_widget_non_animation() -> None: assert not pilot.app.animator.is_being_animated(widget, "counter") -async def test_double_animation_on_complete() -> None: - """Test that animating an attribute a second time, fires its `on_complete` callback.""" +async def test_double_animation_same_value_on_complete() -> None: + """Test that animating an attribute a second time to the same value, fires its `on_complete` callback.""" - complete_count = 0 + completed: list[str] = [] class AnimApp(App): x = var(0) - def on_key(self) -> None: + async def animate_a(self) -> None: def on_complete() -> None: - nonlocal complete_count - complete_count += 1 + completed.append("a") self.animator.animate( self, "x", - 100 + complete_count, + 100, + duration=0.1, + on_complete=on_complete, + ) + + async def animate_b(self) -> None: + + def on_complete() -> None: + completed.append("b") + + self.animator.animate( + self, + "x", + 100, + duration=0.1, + on_complete=on_complete, + ) + + app = AnimApp() + async with app.run_test() as pilot: + await app.animate_a() + assert app.x != 100 + await app.animate_b() + await pilot.wait_for_animation() + assert completed == ["a", "b"] + assert app.x == 100 + + +async def test_double_animation_different_value_on_complete() -> None: + """Test that animating an attribute a second time to a different value, fires its `on_complete` callback.""" + + completed: list[str] = [] + + class AnimApp(App): + x = var(0) + + async def animate_a(self) -> None: + + def on_complete() -> None: + completed.append("a") + + self.animator.animate( + self, + "x", + 100, + duration=0.1, + on_complete=on_complete, + ) + + async def animate_b(self) -> None: + + def on_complete() -> None: + completed.append("b") + + self.animator.animate( + self, + "x", + 101, duration=0.1, on_complete=on_complete, ) app = AnimApp() async with app.run_test() as pilot: - # Press space twice to initiate 2 animations - await pilot.press("space") - await pilot.press("space") - # Wait for animations to complete + await app.animate_a() + assert app.x != 101 + await app.animate_b() # animate to different value await pilot.wait_for_animation() - # Check that on_complete callback was invoked twice - assert complete_count == 2 + assert completed == ["a", "b"] assert app.x == 101