From 2451931237e352a9339e751591282e79b6b7d9eb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 4 Sep 2025 17:37:01 +0100 Subject: [PATCH 1/7] eager tasks --- CHANGELOG.md | 6 ++++++ src/textual/app.py | 4 +++- src/textual/widgets/_footer.py | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58e84af5e2..27ee065aa8 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/). +## Unreleased + +### Changed + +- Eager tasks are now enabled On Python3.12 and above + ## [6.1.0] - 2025-08-01 ### Added diff --git a/src/textual/app.py b/src/textual/app.py index f6f7fec722..e67df3955e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2166,7 +2166,9 @@ async def run_auto_pilot( self._thread_init() - app._loop = asyncio.get_running_loop() + loop = app._loop = asyncio.get_running_loop() + if hasattr(asyncio, "eager_task_factory"): + loop.set_task_factory(asyncio.eager_task_factory) with app._context(): try: await app._process_messages( diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index d61e130816..07572e1b90 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from collections import defaultdict from itertools import groupby from typing import TYPE_CHECKING @@ -311,7 +312,8 @@ def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: event.stop() event.prevent_default() - def on_mount(self) -> None: + async def on_mount(self) -> None: + await asyncio.sleep(0) self.call_next(self.bindings_changed, self.screen) def bindings_changed(screen: Screen) -> None: From 711ce2bf32ac8eda87e77574099718a92294ae3e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 4 Sep 2025 17:39:54 +0100 Subject: [PATCH 2/7] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27ee065aa8..942cfa5f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- Eager tasks are now enabled On Python3.12 and above +- Eager tasks are now enabled On Python3.12 and above https://github.com/Textualize/textual/pull/6102 ## [6.1.0] - 2025-08-01 From df7a3043ccc8ad6aa396271ddb8b6f9a04de6778 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 5 Sep 2025 10:29:35 +0100 Subject: [PATCH 3/7] start messages fix --- src/textual/app.py | 4 +++- src/textual/dom.py | 11 ++++++++++- src/textual/message_pump.py | 4 ++-- src/textual/widget.py | 2 +- src/textual/widgets/_directory_tree.py | 2 +- src/textual/widgets/_toast.py | 2 ++ 6 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index e67df3955e..39929d5b3e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2098,6 +2098,7 @@ async def run_app(app: App[ReturnType]) -> None: await pilot._wait_for_screen() yield pilot finally: + await asyncio.sleep(0) # Shutdown the app cleanly await app._shutdown() await app_task @@ -3444,7 +3445,7 @@ def _register_child( self._registry.add(child) child._attach(parent) child._post_register(self) - child._start_messages() + # child._start_messages() def _register( self, @@ -3497,6 +3498,7 @@ def _register( self._register(widget, *widget._nodes, cache=cache) for widget in new_widgets: apply_stylesheet(widget, cache=cache) + widget._start_messages() if not self._running: # If the app is not running, prevent awaiting of the widget tasks diff --git a/src/textual/dom.py b/src/textual/dom.py index 265de2f7b7..69ee6c8ba4 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -409,13 +409,22 @@ def children(self) -> Sequence["Widget"]: @property def displayed_children(self) -> Sequence[Widget]: - """The displayed children (where node.display==True). + """The displayed children (where `node.display==True`). Returns: A sequence of widgets. """ return self._nodes.displayed + @property + def displayed_and_visible_children(self) -> Sequence[Widget]: + """The displayed children (where `node.display==True` and `node.visible==True`). + + Returns: + A sequence of widgets. + """ + return self._nodes.displayed_and_visible + @property def is_empty(self) -> bool: """Are there no displayed children?""" diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index fba821fafb..61d7a1289c 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -586,7 +586,7 @@ async def _pre_process(self) -> bool: await self._dispatch_message(events.Mount()) else: await self._dispatch_message(events.Mount()) - self.check_idle() + # self.check_idle() self._post_mount() except Exception as error: self.app._handle_exception(error) @@ -620,7 +620,7 @@ async def _process_messages_loop(self) -> None: """Process messages until the queue is closed.""" _rich_traceback_guard = True self._thread_id = threading.get_ident() - + await asyncio.sleep(0) while not self._closed: try: message = await self._get_message() diff --git a/src/textual/widget.py b/src/textual/widget.py index 1d272f8c2f..63ab9664c1 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -717,10 +717,10 @@ def _cover(self, widget: Widget) -> None: self._uncover() self._cover_widget = widget widget._parent = self - widget._start_messages() widget._post_register(self.app) self.app.stylesheet.apply(widget) self.refresh(layout=True) + widget._start_messages() def _uncover(self) -> None: """Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover].""" diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 0a0b98a1f8..24a2426e86 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -525,7 +525,7 @@ def _load_directory(self, node: TreeNode[DirEntry]) -> list[Path]: key=lambda path: (not self._safe_is_dir(path), path.name.lower()), ) - @work(exclusive=True) + @work() async def _loader(self) -> None: """Background loading queue processor.""" worker = get_current_worker() diff --git a/src/textual/widgets/_toast.py b/src/textual/widgets/_toast.py index c43ee09fcd..4d10114c8e 100644 --- a/src/textual/widgets/_toast.py +++ b/src/textual/widgets/_toast.py @@ -144,6 +144,7 @@ class ToastRack(Container, inherit_css=False): DEFAULT_CSS = """ ToastRack { + display: none; layer: _toastrack; width: 1fr; height: auto; @@ -175,6 +176,7 @@ def show(self, notifications: Notifications) -> None: Args: notifications: The notifications to show. """ + self.display = bool(notifications) # Look for any stale toasts and remove them. for toast in self.query(Toast): if toast._notification not in notifications: From 164db9778b351fb0811824ea726a5e2794b64e9c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 5 Sep 2025 14:48:38 +0100 Subject: [PATCH 4/7] update placeholder snapshot --- .../test_placeholder_render.svg | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_placeholder_render.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_placeholder_render.svg index de60bf5f6f..c9d1d83f90 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_placeholder_render.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_placeholder_render.svg @@ -39,15 +39,15 @@ .terminal-r5 { fill: #eee8e3 } .terminal-r6 { fill: #eeeddf } .terminal-r7 { fill: #e8ede4 } -.terminal-r8 { fill: #dfe8ec } -.terminal-r9 { fill: #e5e2e8 } -.terminal-r10 { fill: #e2e5eb } -.terminal-r11 { fill: #dfe8ec;font-weight: bold } -.terminal-r12 { fill: #e5e2e8;font-weight: bold } -.terminal-r13 { fill: #e1eceb } -.terminal-r14 { fill: #e3ede7 } -.terminal-r15 { fill: #e1eceb;font-weight: bold } -.terminal-r16 { fill: #dfebec } +.terminal-r8 { fill: #e3ede7 } +.terminal-r9 { fill: #dfebec } +.terminal-r10 { fill: #e1eceb } +.terminal-r11 { fill: #e3ede7;font-weight: bold } +.terminal-r12 { fill: #dfebec;font-weight: bold } +.terminal-r13 { fill: #e2e5eb } +.terminal-r14 { fill: #dfe8ec } +.terminal-r15 { fill: #e2e5eb;font-weight: bold } +.terminal-r16 { fill: #e5e2e8 } @@ -133,7 +133,7 @@ - + Placeholder p2 here! From 0ad230f87ef3f0d03f416f0827ca64eadd04f78b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 5 Sep 2025 14:51:58 +0100 Subject: [PATCH 5/7] remove comments --- src/textual/app.py | 1 - src/textual/message_pump.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 39929d5b3e..26c3f818f4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3445,7 +3445,6 @@ def _register_child( self._registry.add(child) child._attach(parent) child._post_register(self) - # child._start_messages() def _register( self, diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 61d7a1289c..857db03a35 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -586,7 +586,6 @@ async def _pre_process(self) -> bool: await self._dispatch_message(events.Mount()) else: await self._dispatch_message(events.Mount()) - # self.check_idle() self._post_mount() except Exception as error: self.app._handle_exception(error) From 656ae29c63cf65f7c5a19e0c0d93b1ec55617f02 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 5 Sep 2025 15:32:56 +0100 Subject: [PATCH 6/7] make placeholder order consistent --- src/textual/message_pump.py | 4 ++++ src/textual/widgets/_placeholder.py | 17 +++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 857db03a35..ff1550d76d 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -531,9 +531,13 @@ async def _close_messages(self, wait: bool = True) -> None: except CancelledError: pass + def pre_start_messages(self) -> None: + """Called prior to starting the message task.""" + def _start_messages(self) -> None: """Start messages task.""" self._thread_init() + self.pre_start_messages() if self.app._running: self._task = create_task( diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index bc1257d78d..0ef68d4c83 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -3,7 +3,7 @@ from __future__ import annotations from itertools import cycle -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING from weakref import WeakKeyDictionary from typing_extensions import Literal, Self @@ -84,7 +84,7 @@ class Placeholder(Widget): """ # Consecutive placeholders get assigned consecutive colors. - _COLORS: WeakKeyDictionary[App, Iterator[str]] = WeakKeyDictionary() + _COLORS: WeakKeyDictionary[App, int] = WeakKeyDictionary() _SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]" variant: Reactive[PlaceholderVariant] = reactive[PlaceholderVariant]("default") @@ -125,17 +125,22 @@ def __init__( self.variant = self.validate_variant(variant) """The current variant of the placeholder.""" + self._color_offset = 0 + # Set a cycle through the variants with the correct starting point. self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED) while next(self._variants_cycle) != self.variant: pass + def pre_start_messages(self) -> None: + self._COLORS[self.app] = self._COLORS.setdefault(self.app, -1) + 1 + self._color_offset = self._COLORS[self.app] + async def _on_compose(self, event: events.Compose) -> None: """Set the color for this placeholder.""" - colors = Placeholder._COLORS.setdefault( - self.app, cycle(_PLACEHOLDER_BACKGROUND_COLORS) - ) - self.styles.background = f"{next(colors)} 50%" + color_count = len(_PLACEHOLDER_BACKGROUND_COLORS) + color = _PLACEHOLDER_BACKGROUND_COLORS[self._color_offset % color_count] + self.styles.background = f"{color} 50%" def render(self) -> RenderResult: """Render the placeholder. From 4be5e3ace0e6883cdb0c940c8f280653d9de8863 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 5 Sep 2025 15:47:46 +0100 Subject: [PATCH 7/7] predictable placeholder --- src/textual/message_pump.py | 4 ---- src/textual/widgets/_placeholder.py | 11 ++++++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index ff1550d76d..857db03a35 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -531,13 +531,9 @@ async def _close_messages(self, wait: bool = True) -> None: except CancelledError: pass - def pre_start_messages(self) -> None: - """Called prior to starting the message task.""" - def _start_messages(self) -> None: """Start messages task.""" self._thread_init() - self.pre_start_messages() if self.app._running: self._task = create_task( diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 0ef68d4c83..95b4061818 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -13,6 +13,7 @@ if TYPE_CHECKING: from textual.app import RenderResult +from textual._context import NoActiveAppError from textual.css._error_tools import friendly_list from textual.reactive import Reactive, reactive from textual.widget import Widget @@ -125,17 +126,17 @@ def __init__( self.variant = self.validate_variant(variant) """The current variant of the placeholder.""" - self._color_offset = 0 + try: + self._COLORS[self.app] = self._COLORS.setdefault(self.app, -1) + 1 + self._color_offset = self._COLORS[self.app] + except NoActiveAppError: + self._color_offset = 0 # Set a cycle through the variants with the correct starting point. self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED) while next(self._variants_cycle) != self.variant: pass - def pre_start_messages(self) -> None: - self._COLORS[self.app] = self._COLORS.setdefault(self.app, -1) + 1 - self._color_offset = self._COLORS[self.app] - async def _on_compose(self, event: events.Compose) -> None: """Set the color for this placeholder.""" color_count = len(_PLACEHOLDER_BACKGROUND_COLORS)