diff --git a/CHANGELOG.md b/CHANGELOG.md index 64060a4bb1..153783f58b 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/). +## [8.1.0] - 2026-03-09 + +### Changed + +- 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 + +- Added experimental `App.PAUSE_GC_ON_SCROLL_` boolean (disabled by default) https://github.com/Textualize/textual/pull/6410 + ## [8.0.2] - 2026-03-03 ### Changed @@ -3370,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/" diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 75927ec9a4..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,9 +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 := 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/src/textual/app.py b/src/textual/app.py index 8c65e351bb..a0974af08e 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 @@ -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 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).""" + _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._realtime_animation_count = 0 + """Number of current realtime animations, such as scrolling.""" + 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 _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._realtime_animation_count += 1 + + 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() + 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..68fa22dd02 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._realtime_animation_begin() 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._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 cd810e01e4..be2e9ad893 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._realtime_animation_complete() 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._realtime_animation_begin() 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._realtime_animation_begin() self.animate( "scroll_y", self.scroll_target_y, 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 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"