From bc8a17f9b17b8764a617ea6253fac87342a1145e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 8 Mar 2026 21:33:49 +0800 Subject: [PATCH 1/7] pause gc on scroll --- CHANGELOG.md | 11 +++++++++++ src/textual/_animator.py | 4 ++++ src/textual/app.py | 23 +++++++++++++++++++++++ src/textual/message_pump.py | 11 ++++++++++- src/textual/scrollbar.py | 2 ++ src/textual/widget.py | 3 +++ 6 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64060a4bb1..4096a52530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ 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/). +## Unreleased + +### Changed + +- Replace circuar references in DOM with weak references to improve GC times +- When animating an attribute a second time, the original `on_complete` is now called + +### Added + +- Added experimental `App.PAUSE_GC_ON_SCROLL_` boolean (disabled by default) + ## [8.0.2] - 2026-03-03 ### Changed diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 75927ec9a4..24c513a514 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -459,6 +459,10 @@ def _animate( if current_animation is not None and current_animation == animation: return + if current_animation is not None: + if (on_complete := current_animation.on_complete) is not None: + on_complete() + self._animations[animation_key] = animation self._timer.resume() self._idle_event.clear() diff --git a/src/textual/app.py b/src/textual/app.py index 8c65e351bb..b02232ff26 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -510,6 +510,10 @@ class MyApp(App[None]): Contents are the same as [`HORIZONTAL_BREAKPOINTS`][textual.app.App.HORIZONTAL_BREAKPOINTS], but the integer is compared to the height, rather than the width. """ + # TODO: Enable by default after lengthy testing period + PAUSE_GC_ON_SCROLL: ClassVar[bool] = False + """Pause garbage collection when scrolling, for potentially smoother scrolling with many widgets (experimental).""" + _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = { "focus": lambda app: app.app_focus, "blur": lambda app: not app.app_focus, @@ -838,6 +842,9 @@ def __init__( self._compose_screen: Screen | None = None """The screen composed by App.compose.""" + self._scroll_count = 0 + """Number of current scroll operations.""" + if self.ENABLE_COMMAND_PALETTE: for _key, binding in self._bindings: if binding.action in {"command_palette", "app.command_palette"}: @@ -984,6 +991,22 @@ def clipboard(self) -> str: """ return self._clipboard + def _scroll_start(self) -> None: + """A scroll animation started.""" + if self.PAUSE_GC_ON_SCROLL: + import gc + + gc.disable() + self._scroll_count += 1 + + def _scroll_end(self) -> None: + """A scroll animation ended.""" + self._scroll_count -= 1 + if self._scroll_count == 0 and self.PAUSE_GC_ON_SCROLL: + import gc + + gc.enable() + def format_title(self, title: str, sub_title: str) -> Content: """Format the title for display. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index a4ed5e7c25..6d2fb9dd12 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -27,7 +27,7 @@ TypeVar, cast, ) -from weakref import WeakSet +from weakref import WeakSet, ref from textual import Logger, events, log, messages from textual._callback import invoke @@ -143,6 +143,15 @@ def __init__(self, parent: MessagePump | None = None) -> None: """ + @property + def _parent(self) -> MessagePump | None: + """The current parent message pump (if set).""" + return None if self.__parent is None else self.__parent() + + @_parent.setter + def _parent(self, parent: MessagePump | None) -> None: + self.__parent = None if parent is None else ref(parent) + @cached_property def _message_queue(self) -> Queue[Message | None]: return Queue() diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 31785936e5..f07fb2aa34 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -360,6 +360,7 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: event.stop() def _on_mouse_capture(self, event: events.MouseCapture) -> None: + self.app._scroll_start() self.styles.pointer = "grabbing" if isinstance(self._parent, Widget): self._parent.release_anchor() @@ -367,6 +368,7 @@ def _on_mouse_capture(self, event: events.MouseCapture) -> None: self.grabbed_position = self.position def _on_mouse_release(self, event: events.MouseRelease) -> None: + self.app._scroll_end() self.styles.pointer = "default" self.grabbed = None if self.vertical and isinstance(self.parent, Widget): diff --git a/src/textual/widget.py b/src/textual/widget.py index cd810e01e4..b3eae83933 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2722,6 +2722,7 @@ def _scroll_to( def _animate_on_complete() -> None: """set last scroll time, and invoke callback.""" + self.app._scroll_end() self._last_scroll_time = monotonic() if on_complete is not None: self.call_next(on_complete) @@ -2738,6 +2739,7 @@ def _animate_on_complete() -> None: assert x is not None self.scroll_target_x = x if x != self.scroll_x: + self.app._scroll_start() self.animate( "scroll_x", self.scroll_target_x, @@ -2752,6 +2754,7 @@ def _animate_on_complete() -> None: assert y is not None self.scroll_target_y = y if y != self.scroll_y: + self.app._scroll_start() self.animate( "scroll_y", self.scroll_target_y, From 791029354ed0e185a6c01d7676f43c0359ad54d5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 9 Mar 2026 17:32:40 +0800 Subject: [PATCH 2/7] fix for app in tests --- tests/test_arrange.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_arrange.py b/tests/test_arrange.py index db004b0bb3..9a14dcfa57 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -1,6 +1,7 @@ import pytest from textual._arrange import TOP_Z, arrange +from textual._context import active_app from textual.app import App from textual.geometry import NULL_OFFSET, Region, Size, Spacing from textual.layout import WidgetPlacement @@ -17,7 +18,9 @@ async def test_arrange_empty(): async def test_arrange_dock_top(): container = Widget(id="container") - container._parent = App() + app = App() + active_app.set(app) + container._parent = app child = Widget(id="child") header = Widget(id="header") header.styles.dock = "top" @@ -63,7 +66,9 @@ async def test_arrange_dock_left(): async def test_arrange_dock_right(): container = Widget(id="container") - container._parent = App() + app = App() + active_app.set(app) + container._parent = app child = Widget(id="child") header = Widget(id="header") header.styles.dock = "right" @@ -88,7 +93,9 @@ async def test_arrange_dock_right(): async def test_arrange_dock_bottom(): container = Widget(id="container") - container._parent = App() + app = App() + active_app.set(app) + container._parent = app child = Widget(id="child") header = Widget(id="header") header.styles.dock = "bottom" From 4413df00bde0ecdbffce947494febdca7a833aea Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 9 Mar 2026 18:27:37 +0800 Subject: [PATCH 3/7] test fixes --- src/textual/_animator.py | 12 ++++++------ tests/test_animation.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 24c513a514..299333059d 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -421,9 +421,10 @@ def _animate( ) start_value = getattr(obj, attribute) - if start_value == value: self._animations.pop(animation_key, None) + if on_complete is not None: + self.app.call_later(on_complete) return if duration is not None: @@ -455,13 +456,12 @@ def _animate( assert animation is not None, "animation expected to be non-None" - current_animation = self._animations.get(animation_key) - if current_animation is not None and current_animation == animation: - return - - if current_animation is not None: + 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 5428f7a91a..f40c1fa888 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -211,3 +211,36 @@ async def test_cancel_widget_non_animation() -> None: assert not pilot.app.animator.is_being_animated(widget, "counter") await widget.stop_animation("counter") 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.""" + + complete_count = 0 + + class AnimApp(App): + x = var(0) + + def on_key(self) -> None: + + def on_complete() -> None: + nonlocal complete_count + complete_count += 1 + + self.animator.animate( + self, + "x", + 100 + complete_count, + 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 pilot.wait_for_animation() + # Check that on_complete callback was invoked twice + assert complete_count == 2 From 4cc067344e690d31ecd77d0f00d7b795cecb1897 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 9 Mar 2026 18:31:03 +0800 Subject: [PATCH 4/7] docstrings --- src/textual/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b02232ff26..dde2f8f9ec 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -493,7 +493,7 @@ class MyApp(App[None]): A breakpoint consists of a tuple containing the minimum width where the class should applied, and the name of the class to set. - Note that only one class name is set, and if you should avoid having more than one breakpoint set for the same size. + Note that only one class name is set, and you should avoid having more than one breakpoint set for the same size. Example: ```python @@ -512,7 +512,7 @@ class MyApp(App[None]): # TODO: Enable by default after lengthy testing period PAUSE_GC_ON_SCROLL: ClassVar[bool] = False - """Pause garbage collection when scrolling, for potentially smoother scrolling with many widgets (experimental).""" + """Pause Python GC (Garbage Collection) when scrolling, for potentially smoother scrolling with many widgets (experimental).""" _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = { "focus": lambda app: app.app_focus, From 0a44222381924d2d511fd5c2a133b9a1771cce02 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 9 Mar 2026 20:26:59 +0800 Subject: [PATCH 5/7] Better naming --- src/textual/app.py | 20 ++++++++++---------- src/textual/scrollbar.py | 4 ++-- src/textual/widget.py | 6 +++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index dde2f8f9ec..a0974af08e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -510,7 +510,7 @@ class MyApp(App[None]): Contents are the same as [`HORIZONTAL_BREAKPOINTS`][textual.app.App.HORIZONTAL_BREAKPOINTS], but the integer is compared to the height, rather than the width. """ - # TODO: Enable by default after lengthy testing period + # TODO: Enable by default after suitable testing period PAUSE_GC_ON_SCROLL: ClassVar[bool] = False """Pause Python GC (Garbage Collection) when scrolling, for potentially smoother scrolling with many widgets (experimental).""" @@ -842,8 +842,8 @@ def __init__( self._compose_screen: Screen | None = None """The screen composed by App.compose.""" - self._scroll_count = 0 - """Number of current scroll operations.""" + self._realtime_animation_count = 0 + """Number of current realtime animations, such as scrolling.""" if self.ENABLE_COMMAND_PALETTE: for _key, binding in self._bindings: @@ -991,18 +991,18 @@ def clipboard(self) -> str: """ return self._clipboard - def _scroll_start(self) -> None: - """A scroll animation started.""" + def _realtime_animation_begin(self) -> None: + """A scroll or other animation that must be smooth has begun.""" if self.PAUSE_GC_ON_SCROLL: import gc gc.disable() - self._scroll_count += 1 + self._realtime_animation_count += 1 - def _scroll_end(self) -> None: - """A scroll animation ended.""" - self._scroll_count -= 1 - if self._scroll_count == 0 and self.PAUSE_GC_ON_SCROLL: + def _realtime_animation_complete(self) -> None: + """A scroll or other animation that must be smooth has completed.""" + self._realtime_animation_count -= 1 + if self._realtime_animation_count == 0 and self.PAUSE_GC_ON_SCROLL: import gc gc.enable() diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index f07fb2aa34..68fa22dd02 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -360,7 +360,7 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: event.stop() def _on_mouse_capture(self, event: events.MouseCapture) -> None: - self.app._scroll_start() + self.app._realtime_animation_begin() self.styles.pointer = "grabbing" if isinstance(self._parent, Widget): self._parent.release_anchor() @@ -368,7 +368,7 @@ def _on_mouse_capture(self, event: events.MouseCapture) -> None: self.grabbed_position = self.position def _on_mouse_release(self, event: events.MouseRelease) -> None: - self.app._scroll_end() + self.app._realtime_animation_complete() self.styles.pointer = "default" self.grabbed = None if self.vertical and isinstance(self.parent, Widget): diff --git a/src/textual/widget.py b/src/textual/widget.py index b3eae83933..be2e9ad893 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2722,7 +2722,7 @@ def _scroll_to( def _animate_on_complete() -> None: """set last scroll time, and invoke callback.""" - self.app._scroll_end() + self.app._realtime_animation_complete() self._last_scroll_time = monotonic() if on_complete is not None: self.call_next(on_complete) @@ -2739,7 +2739,7 @@ def _animate_on_complete() -> None: assert x is not None self.scroll_target_x = x if x != self.scroll_x: - self.app._scroll_start() + self.app._realtime_animation_begin() self.animate( "scroll_x", self.scroll_target_x, @@ -2754,7 +2754,7 @@ def _animate_on_complete() -> None: assert y is not None self.scroll_target_y = y if y != self.scroll_y: - self.app._scroll_start() + self.app._realtime_animation_begin() self.animate( "scroll_y", self.scroll_target_y, From a3bb8dda9d5c438962ca76e7b01b00eb4f8c57a5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 9 Mar 2026 20:35:32 +0800 Subject: [PATCH 6/7] bump --- CHANGELOG.md | 7 ++++--- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4096a52530..c6b466b36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +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/). -## Unreleased +## [8.1.0] - 2026-03-09 ### Changed -- Replace circuar references in DOM with weak references to improve GC times -- When animating an attribute a second time, the original `on_complete` is now called +- Replace circuar references in DOM with weak references to improve GC times https://github.com/Textualize/textual/pull/6410 +- When animating an attribute a second time, the original `on_complete` is now called https://github.com/Textualize/textual/pull/6410 ### Added @@ -3381,6 +3381,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.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 [8.0.0]: https://github.com/Textualize/textual/compare/v7.5.0...v8.0.0 diff --git a/pyproject.toml b/pyproject.toml index 5ccf7dbcd0..a2ae6d66e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "8.0.2" +version = "8.1.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From 76a09fee688b6bac8c2139f0d66d05d843295447 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 9 Mar 2026 20:36:34 +0800 Subject: [PATCH 7/7] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6b466b36d..153783f58b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added experimental `App.PAUSE_GC_ON_SCROLL_` boolean (disabled by default) +- Added experimental `App.PAUSE_GC_ON_SCROLL_` boolean (disabled by default) https://github.com/Textualize/textual/pull/6410 ## [8.0.2] - 2026-03-03