Skip to content

Commit ac7ef91

Browse files
authored
Merge pull request #6410 from Textualize/reduce-circular-refs
pause gc on scroll
2 parents 431dc7d + 76a09fe commit ac7ef91

File tree

9 files changed

+103
-10
lines changed

9 files changed

+103
-10
lines changed

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## [8.1.0] - 2026-03-09
9+
10+
### Changed
11+
12+
- Replace circuar references in DOM with weak references to improve GC times https://github.com/Textualize/textual/pull/6410
13+
- When animating an attribute a second time, the original `on_complete` is now called https://github.com/Textualize/textual/pull/6410
14+
15+
### Added
16+
17+
- Added experimental `App.PAUSE_GC_ON_SCROLL_` boolean (disabled by default) https://github.com/Textualize/textual/pull/6410
18+
819
## [8.0.2] - 2026-03-03
920

1021
### Changed
@@ -3370,6 +3381,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
33703381
- New handler system for messages that doesn't require inheritance
33713382
- Improved traceback handling
33723383

3384+
[8.1.0]: https://github.com/Textualize/textual/compare/v8.0.2...v8.1.0
33733385
[8.0.2]: https://github.com/Textualize/textual/compare/v8.0.1...v8.0.2
33743386
[8.0.1]: https://github.com/Textualize/textual/compare/v8.0.0...v8.0.1
33753387
[8.0.0]: https://github.com/Textualize/textual/compare/v7.5.0...v8.0.0

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "textual"
3-
version = "8.0.2"
3+
version = "8.1.0"
44
homepage = "https://github.com/Textualize/textual"
55
repository = "https://github.com/Textualize/textual"
66
documentation = "https://textual.textualize.io/"

src/textual/_animator.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -421,9 +421,10 @@ def _animate(
421421
)
422422

423423
start_value = getattr(obj, attribute)
424-
425424
if start_value == value:
426425
self._animations.pop(animation_key, None)
426+
if on_complete is not None:
427+
self.app.call_later(on_complete)
427428
return
428429

429430
if duration is not None:
@@ -455,9 +456,12 @@ def _animate(
455456

456457
assert animation is not None, "animation expected to be non-None"
457458

458-
current_animation = self._animations.get(animation_key)
459-
if current_animation is not None and current_animation == animation:
460-
return
459+
if (current_animation := self._animations.get(animation_key)) is not None:
460+
if (on_complete := current_animation.on_complete) is not None:
461+
on_complete()
462+
self._animations.pop(animation_key)
463+
if current_animation == animation:
464+
return
461465

462466
self._animations[animation_key] = animation
463467
self._timer.resume()

src/textual/app.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,7 @@ class MyApp(App[None]):
493493
494494
A breakpoint consists of a tuple containing the minimum width where the class should applied, and the name of the class to set.
495495
496-
Note that only one class name is set, and if you should avoid having more than one breakpoint set for the same size.
496+
Note that only one class name is set, and you should avoid having more than one breakpoint set for the same size.
497497
498498
Example:
499499
```python
@@ -510,6 +510,10 @@ class MyApp(App[None]):
510510
Contents are the same as [`HORIZONTAL_BREAKPOINTS`][textual.app.App.HORIZONTAL_BREAKPOINTS], but the integer is compared to the height, rather than the width.
511511
"""
512512

513+
# TODO: Enable by default after suitable testing period
514+
PAUSE_GC_ON_SCROLL: ClassVar[bool] = False
515+
"""Pause Python GC (Garbage Collection) when scrolling, for potentially smoother scrolling with many widgets (experimental)."""
516+
513517
_PSEUDO_CLASSES: ClassVar[dict[str, Callable[[App[Any]], bool]]] = {
514518
"focus": lambda app: app.app_focus,
515519
"blur": lambda app: not app.app_focus,
@@ -838,6 +842,9 @@ def __init__(
838842
self._compose_screen: Screen | None = None
839843
"""The screen composed by App.compose."""
840844

845+
self._realtime_animation_count = 0
846+
"""Number of current realtime animations, such as scrolling."""
847+
841848
if self.ENABLE_COMMAND_PALETTE:
842849
for _key, binding in self._bindings:
843850
if binding.action in {"command_palette", "app.command_palette"}:
@@ -984,6 +991,22 @@ def clipboard(self) -> str:
984991
"""
985992
return self._clipboard
986993

994+
def _realtime_animation_begin(self) -> None:
995+
"""A scroll or other animation that must be smooth has begun."""
996+
if self.PAUSE_GC_ON_SCROLL:
997+
import gc
998+
999+
gc.disable()
1000+
self._realtime_animation_count += 1
1001+
1002+
def _realtime_animation_complete(self) -> None:
1003+
"""A scroll or other animation that must be smooth has completed."""
1004+
self._realtime_animation_count -= 1
1005+
if self._realtime_animation_count == 0 and self.PAUSE_GC_ON_SCROLL:
1006+
import gc
1007+
1008+
gc.enable()
1009+
9871010
def format_title(self, title: str, sub_title: str) -> Content:
9881011
"""Format the title for display.
9891012

src/textual/message_pump.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
TypeVar,
2828
cast,
2929
)
30-
from weakref import WeakSet
30+
from weakref import WeakSet, ref
3131

3232
from textual import Logger, events, log, messages
3333
from textual._callback import invoke
@@ -143,6 +143,15 @@ def __init__(self, parent: MessagePump | None = None) -> None:
143143
144144
"""
145145

146+
@property
147+
def _parent(self) -> MessagePump | None:
148+
"""The current parent message pump (if set)."""
149+
return None if self.__parent is None else self.__parent()
150+
151+
@_parent.setter
152+
def _parent(self, parent: MessagePump | None) -> None:
153+
self.__parent = None if parent is None else ref(parent)
154+
146155
@cached_property
147156
def _message_queue(self) -> Queue[Message | None]:
148157
return Queue()

src/textual/scrollbar.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,13 +360,15 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None:
360360
event.stop()
361361

362362
def _on_mouse_capture(self, event: events.MouseCapture) -> None:
363+
self.app._realtime_animation_begin()
363364
self.styles.pointer = "grabbing"
364365
if isinstance(self._parent, Widget):
365366
self._parent.release_anchor()
366367
self.grabbed = event.mouse_position
367368
self.grabbed_position = self.position
368369

369370
def _on_mouse_release(self, event: events.MouseRelease) -> None:
371+
self.app._realtime_animation_complete()
370372
self.styles.pointer = "default"
371373
self.grabbed = None
372374
if self.vertical and isinstance(self.parent, Widget):

src/textual/widget.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,6 +2722,7 @@ def _scroll_to(
27222722

27232723
def _animate_on_complete() -> None:
27242724
"""set last scroll time, and invoke callback."""
2725+
self.app._realtime_animation_complete()
27252726
self._last_scroll_time = monotonic()
27262727
if on_complete is not None:
27272728
self.call_next(on_complete)
@@ -2738,6 +2739,7 @@ def _animate_on_complete() -> None:
27382739
assert x is not None
27392740
self.scroll_target_x = x
27402741
if x != self.scroll_x:
2742+
self.app._realtime_animation_begin()
27412743
self.animate(
27422744
"scroll_x",
27432745
self.scroll_target_x,
@@ -2752,6 +2754,7 @@ def _animate_on_complete() -> None:
27522754
assert y is not None
27532755
self.scroll_target_y = y
27542756
if y != self.scroll_y:
2757+
self.app._realtime_animation_begin()
27552758
self.animate(
27562759
"scroll_y",
27572760
self.scroll_target_y,

tests/test_animation.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,3 +211,36 @@ async def test_cancel_widget_non_animation() -> None:
211211
assert not pilot.app.animator.is_being_animated(widget, "counter")
212212
await widget.stop_animation("counter")
213213
assert not pilot.app.animator.is_being_animated(widget, "counter")
214+
215+
216+
async def test_double_animation_on_complete() -> None:
217+
"""Test that animating an attribute a second time, fires its `on_complete` callback."""
218+
219+
complete_count = 0
220+
221+
class AnimApp(App):
222+
x = var(0)
223+
224+
def on_key(self) -> None:
225+
226+
def on_complete() -> None:
227+
nonlocal complete_count
228+
complete_count += 1
229+
230+
self.animator.animate(
231+
self,
232+
"x",
233+
100 + complete_count,
234+
duration=0.1,
235+
on_complete=on_complete,
236+
)
237+
238+
app = AnimApp()
239+
async with app.run_test() as pilot:
240+
# Press space twice to initiate 2 animations
241+
await pilot.press("space")
242+
await pilot.press("space")
243+
# Wait for animations to complete
244+
await pilot.wait_for_animation()
245+
# Check that on_complete callback was invoked twice
246+
assert complete_count == 2

tests/test_arrange.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22

33
from textual._arrange import TOP_Z, arrange
4+
from textual._context import active_app
45
from textual.app import App
56
from textual.geometry import NULL_OFFSET, Region, Size, Spacing
67
from textual.layout import WidgetPlacement
@@ -17,7 +18,9 @@ async def test_arrange_empty():
1718

1819
async def test_arrange_dock_top():
1920
container = Widget(id="container")
20-
container._parent = App()
21+
app = App()
22+
active_app.set(app)
23+
container._parent = app
2124
child = Widget(id="child")
2225
header = Widget(id="header")
2326
header.styles.dock = "top"
@@ -63,7 +66,9 @@ async def test_arrange_dock_left():
6366

6467
async def test_arrange_dock_right():
6568
container = Widget(id="container")
66-
container._parent = App()
69+
app = App()
70+
active_app.set(app)
71+
container._parent = app
6772
child = Widget(id="child")
6873
header = Widget(id="header")
6974
header.styles.dock = "right"
@@ -88,7 +93,9 @@ async def test_arrange_dock_right():
8893

8994
async def test_arrange_dock_bottom():
9095
container = Widget(id="container")
91-
container._parent = App()
96+
app = App()
97+
active_app.set(app)
98+
container._parent = app
9299
child = Widget(id="child")
93100
header = Widget(id="header")
94101
header.styles.dock = "bottom"

0 commit comments

Comments
 (0)