Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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/"
Expand Down
12 changes: 8 additions & 4 deletions src/textual/_animator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down
25 changes: 24 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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"}:
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 10 additions & 1 deletion src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/textual/scrollbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,15 @@ 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()
self.grabbed = event.mouse_position
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):
Expand Down
3 changes: 3 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions tests/test_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 10 additions & 3 deletions tests/test_arrange.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand Down
Loading