From a05d9150c324e173f8868a1e031444d92c410d82 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 5 Jul 2024 17:24:20 +0100 Subject: [PATCH 01/22] prune --- src/textual/app.py | 217 +++++++++++++++++------------- src/textual/await_remove.py | 44 +++--- src/textual/css/query.py | 2 +- src/textual/message_pump.py | 12 +- src/textual/messages.py | 10 ++ src/textual/widget.py | 20 ++- src/textual/widgets/_list_view.py | 2 +- 7 files changed, 185 insertions(+), 122 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 0acfdcb904..df73e13b00 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -99,7 +99,7 @@ _get_key_display, _get_unicode_name_from_key, ) -from .messages import CallbackType +from .messages import CallbackType, Prune from .notifications import Notification, Notifications, Notify, SeverityLevel from .reactive import Reactive from .renderables.blank import Blank @@ -2804,13 +2804,13 @@ async def _close_all(self) -> None: for stack in self._screen_stacks.values(): for stack_screen in reversed(stack): if stack_screen._running: - await self._prune_node(stack_screen) + await self._prune(stack_screen) stack.clear() # Close pre-defined screens. for screen in self.SCREENS.values(): if isinstance(screen, Screen) and screen._running: - await self._prune_node(screen) + await self._prune(screen) # Close any remaining nodes # Should be empty by now @@ -3387,102 +3387,125 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: for child in widget._nodes: push(child) - def _remove_nodes( - self, widgets: list[Widget], parent: DOMNode | None + def _prune( + self, root: DOMNode, children: list[Widget] | None = None ) -> AwaitRemove: - """Remove nodes from DOM, and return an awaitable that awaits cleanup. - - Args: - widgets: List of nodes to remove. - parent: Parent node of widgets, or None for no parent. - - Returns: - Awaitable that returns when the nodes have been fully removed. - """ - - async def prune_widgets_task( - widgets: list[Widget], finished_event: asyncio.Event - ) -> None: - """Prune widgets as a background task. - - Args: - widgets: Widgets to prune. - finished_event: Event to set when complete. - """ - try: - await self._prune_nodes(widgets) - finally: - finished_event.set() - try: - self._update_mouse_over(self.screen) - except ScreenStackError: - pass - if parent is not None: - parent.refresh(layout=True) - - removed_widgets = self._detach_from_dom(widgets) - - finished_event = asyncio.Event() - remove_task = create_task( - prune_widgets_task(removed_widgets, finished_event), name="prune nodes" - ) - - await_remove = AwaitRemove(finished_event, remove_task) - self.call_next(await_remove) - return await_remove - - async def _prune_nodes(self, widgets: list[Widget]) -> None: - """Remove nodes and children. - - Args: - widgets: Widgets to remove. - """ - - for widget in widgets: - async with self._dom_lock: - await asyncio.shield(self._prune_node(widget)) - - async def _prune_node(self, root: Widget) -> None: - """Remove a node and its children. Children are removed before parents. - - Args: - root: Node to remove. - """ - # Pruning a node that has been removed is a no-op - - if root not in self._registry: - return - - node_children = list(self._walk_children(root)) - - for children in reversed(node_children): - # Closing children can be done asynchronously. - close_children = [ - child for child in children if child._running and not child._closing - ] - - # TODO: What if a message pump refuses to exit? - if close_children: - close_messages = [ - child._close_messages(wait=True) for child in close_children - ] - try: - # Close all the children - await asyncio.wait_for( - asyncio.gather(*close_messages), self.CLOSE_TIMEOUT - ) - except asyncio.TimeoutError: - # Likely a deadlock if we get here - # If not a deadlock, increase CLOSE_TIMEOUT, or set it to None - raise asyncio.TimeoutError( - f"Timeout waiting for {close_children!r} to close; possible deadlock (consider changing App.CLOSE_TIMEOUT)\n" - ) from None - finally: - for child in children: - self._unregister(child) + stack: list[DOMNode] = [] + if children is None: + stack.append(root) + else: + stack.extend(children) + nodes = [] - await root._close_messages(wait=True) - self._unregister(root) + while stack: + node = stack.pop() + if node._nodes: + stack.extend(node._nodes) + else: + nodes.append(node) + node.post_message(Prune(root)) + + assert root._task is not None + await_complete = AwaitRemove([node._task for node in nodes]) + + return await_complete + + # def _remove_nodes( + # self, widgets: list[Widget], parent: DOMNode | None + # ) -> AwaitRemove: + # """Remove nodes from DOM, and return an awaitable that awaits cleanup. + + # Args: + # widgets: List of nodes to remove. + # parent: Parent node of widgets, or None for no parent. + + # Returns: + # Awaitable that returns when the nodes have been fully removed. + # """ + + # async def prune_widgets_task( + # widgets: list[Widget], finished_event: asyncio.Event + # ) -> None: + # """Prune widgets as a background task. + + # Args: + # widgets: Widgets to prune. + # finished_event: Event to set when complete. + # """ + # try: + # await self._prune_nodes(widgets) + # finally: + # finished_event.set() + # try: + # self._update_mouse_over(self.screen) + # except ScreenStackError: + # pass + # if parent is not None: + # parent.refresh(layout=True) + + # removed_widgets = self._detach_from_dom(widgets) + + # finished_event = asyncio.Event() + # remove_task = create_task( + # prune_widgets_task(removed_widgets, finished_event), name="prune nodes" + # ) + + # await_remove = AwaitRemove(finished_event, remove_task) + # self.call_next(await_remove) + # return await_remove + + # async def _prune_nodes(self, widgets: list[Widget]) -> None: + # """Remove nodes and children. + + # Args: + # widgets: Widgets to remove. + # """ + + # for widget in widgets: + # async with self._dom_lock: + # await asyncio.shield(self._prune_node(widget)) + + # async def _prune_node(self, root: Widget) -> None: + # """Remove a node and its children. Children are removed before parents. + + # Args: + # root: Node to remove. + # """ + # # Pruning a node that has been removed is a no-op + + # if root not in self._registry: + # return + + # node_children = list(self._walk_children(root)) + + # for children in reversed(node_children): + # # Closing children can be done asynchronously. + # close_children = [ + # child for child in children if child._running and not child._closing + # ] + + # # TODO: What if a message pump refuses to exit? + # if close_children: + # close_messages = [ + # child._close_messages(wait=True) for child in close_children + # ] + # try: + # # Close all the children + # await asyncio.wait_for( + # asyncio.gather(*close_messages), self.CLOSE_TIMEOUT + # ) + # except asyncio.TimeoutError: + # # Likely a deadlock if we get here + # # If not a deadlock, increase CLOSE_TIMEOUT, or set it to None + # raise asyncio.TimeoutError( + # f"Timeout waiting for {close_children!r} to close; possible deadlock (consider changing App.CLOSE_TIMEOUT)\n" + # ) from None + # finally: + # for child in children: + # self._unregister(child) + + # await root._close_messages(wait=True) + # self._unregister(root) def _watch_app_focus(self, focus: bool) -> None: """Respond to changes in app focus.""" diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index f02fe5b840..2a4457a64c 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -2,26 +2,40 @@ An *optionally* awaitable object returned by methods that remove widgets. """ -from asyncio import Event, Task +from asyncio import Task, gather from typing import Generator +# class AwaitRemove: +# """An awaitable returned by a method that removes DOM nodes. -class AwaitRemove: - """An awaitable returned by a method that removes DOM nodes. +# Returned by [Widget.remove][textual.widget.Widget.remove] and +# [DOMQuery.remove][textual.css.query.DOMQuery.remove]. +# """ + +# def __init__(self, finished_flag: Event, task: Task) -> None: +# """Initialise the instance of ``AwaitRemove``. + +# Args: +# finished_flag: The asyncio event to wait on. +# task: The task which does the remove (required to keep a reference). +# """ +# self.finished_flag = finished_flag +# self._task = task - Returned by [Widget.remove][textual.widget.Widget.remove] and - [DOMQuery.remove][textual.css.query.DOMQuery.remove]. - """ +# async def __call__(self) -> None: +# await self - def __init__(self, finished_flag: Event, task: Task) -> None: - """Initialise the instance of ``AwaitRemove``. +# def __await__(self) -> Generator[None, None, None]: +# async def await_prune() -> None: +# """Wait for the prune operation to finish.""" +# await self.finished_flag.wait() - Args: - finished_flag: The asyncio event to wait on. - task: The task which does the remove (required to keep a reference). - """ - self.finished_flag = finished_flag - self._task = task +# return await_prune().__await__() + + +class AwaitRemove: + def __init__(self, tasks: list[Task]): + self._tasks = tasks async def __call__(self) -> None: await self @@ -29,6 +43,6 @@ async def __call__(self) -> None: def __await__(self) -> Generator[None, None, None]: async def await_prune() -> None: """Wait for the prune operation to finish.""" - await self.finished_flag.wait() + await gather(*self._tasks) return await_prune().__await__() diff --git a/src/textual/css/query.py b/src/textual/css/query.py index bc91f5253d..5cbda82f25 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -410,7 +410,7 @@ def remove(self) -> AwaitRemove: An awaitable object that waits for the widgets to be removed. """ app = active_app.get() - await_remove = app._remove_nodes(list(self), self._node) + await_remove = app._prune(self._node) return await_remove def set_styles( diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 27d1780320..0377bbc820 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -522,9 +522,15 @@ async def _process_messages(self) -> None: pass finally: self._running = False - if self._timers: - await Timer._stop_all(self._timers) - self._timers.clear() + try: + if self._timers: + await Timer._stop_all(self._timers) + self._timers.clear() + finally: + await self._message_loop_exit() + + async def _message_loop_exit(self) -> None: + pass async def _pre_process(self) -> bool: """Procedure to run before processing messages. diff --git a/src/textual/messages.py b/src/textual/messages.py index 91a54b2811..3b4128931c 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -9,6 +9,7 @@ from .message import Message if TYPE_CHECKING: + from .dom import DOMNode from .widget import Widget @@ -17,6 +18,15 @@ class CloseMessages(Message, verbose=True): """Requests message pump to close.""" +@rich.repr.auto +class Prune(Message, verbose=True): + root: DOMNode + + def __init__(self, root: DOMNode) -> None: + super().__init__() + self.root = root + + @rich.repr.auto class ExitApp(Message, verbose=True): """Exit the app.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 6cc0c4ee7d..55bd3f14db 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -409,6 +409,8 @@ def __init__( self._anchor_animate: bool = False """Flag to enable animation when scrolling anchored widgets.""" + self._prune: Widget | None = None + virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" @@ -3459,11 +3461,10 @@ def remove(self) -> AwaitRemove: Returns: An awaitable object that waits for the widget to be removed. """ - - await_remove = self.app._remove_nodes([self], self.parent) + await_remove = self.app._prune(self) return await_remove - def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove: + def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitComplete: """Remove the immediate children of this Widget from the DOM. Args: @@ -3478,8 +3479,8 @@ def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove: children_to_remove = [ child for child in self.children if match(parsed_selectors, child) ] - await_remove = self.app._remove_nodes(children_to_remove, self) - return await_remove + await_complete = self.app._prune(self, children_to_remove) + return await_complete @asynccontextmanager async def batch(self) -> AsyncGenerator[None, None]: @@ -3568,6 +3569,15 @@ def post_message(self, message: Message) -> bool: pass return super().post_message(message) + async def on_prune(self, event: messages.Prune) -> None: + if self._parent is not None and event.root is not self: + self._prune = event.root + await self._close_messages() + + async def _message_loop_exit(self) -> None: + if self._prune is not None and self._parent is not None: + self.post_message(messages.Prune(self._prune)) + async def _on_idle(self, event: events.Idle) -> None: """Called when there are no more events on the queue. diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 77cfc75bc9..cd0c1510f1 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -262,7 +262,7 @@ def remove_items(self, indices: Iterable[int]) -> AwaitRemove: for index in indices: items_to_remove.append(items[index]) - await_remove = self.app._remove_nodes(items_to_remove, self) + await_remove = self.app._prune(self, tems_to_remove) return await_remove def action_select_cursor(self) -> None: From 8a6ebeb02d58278d2f6b007411da9f3ff3fc3b99 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Jul 2024 14:00:56 +0100 Subject: [PATCH 02/22] debug prune --- src/textual/app.py | 81 +++++++++++++++++++------------ src/textual/css/query.py | 17 ++++++- src/textual/dom.py | 2 + src/textual/messages.py | 9 +--- src/textual/signal.py | 2 +- src/textual/widget.py | 31 +++++++----- src/textual/widgets/_list_view.py | 2 +- 7 files changed, 90 insertions(+), 54 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index df73e13b00..f105c85be4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -103,7 +103,6 @@ from .notifications import Notification, Notifications, Notify, SeverityLevel from .reactive import Reactive from .renderables.blank import Blank -from .rlock import RLock from .screen import ( ActiveBinding, Screen, @@ -582,7 +581,7 @@ def __init__( else None ) self._screenshot: str | None = None - self._dom_lock = RLock() + # self._dom_lock = RLock() self._dom_ready = False self._batch_count = 0 self._notifications = Notifications() @@ -2799,25 +2798,32 @@ def is_mounted(self, widget: Widget) -> bool: async def _close_all(self) -> None: """Close all message pumps.""" - async with self._dom_lock: - # Close all screens on all stacks: - for stack in self._screen_stacks.values(): - for stack_screen in reversed(stack): - if stack_screen._running: - await self._prune(stack_screen) - stack.clear() + print("_close_all") - # Close pre-defined screens. - for screen in self.SCREENS.values(): - if isinstance(screen, Screen) and screen._running: - await self._prune(screen) + # async with self._dom_lock: + # Close all screens on all stacks: + for stack in self._screen_stacks.values(): + for stack_screen in reversed(stack): + if stack_screen._running: + await self._prune(stack_screen) + stack.clear() - # Close any remaining nodes - # Should be empty by now - remaining_nodes = list(self._registry) + print(1) - for child in remaining_nodes: - await child._close_messages() + # Close pre-defined screens. + for screen in self.SCREENS.values(): + if isinstance(screen, Screen) and screen._running: + await self._prune(screen) + + print(2) + # Close any remaining nodes + # Should be empty by now + remaining_nodes = list(self._registry) + + for child in remaining_nodes: + await child._close_messages() + + print(3) async def _shutdown(self) -> None: self._begin_batch() # Prevents any layout / repaint while shutting down @@ -3387,27 +3393,38 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: for child in widget._nodes: push(child) - def _prune( - self, root: DOMNode, children: list[Widget] | None = None - ) -> AwaitRemove: - stack: list[DOMNode] = [] - if children is None: - stack.append(root) - else: - stack.extend(children) - nodes = [] - + def _prune(self, *nodes: DOMNode) -> AwaitRemove: + self.log("_prune", nodes) + stack: list[DOMNode] = [*nodes] + pruning_nodes = [] while stack: node = stack.pop() if node._nodes: + self.log("prune", node._nodes) + for prune_node in node._nodes: + pruning_nodes.append(prune_node) + prune_node._pruning = True stack.extend(node._nodes) else: - nodes.append(node) - node.post_message(Prune(root)) + self.log("leaf prune", node) + pruning_nodes.append(node) + node._pruning = True + node.post_message(Prune()) - assert root._task is not None - await_complete = AwaitRemove([node._task for node in nodes]) + try: + for node in pruning_nodes: + if node.screen.focused is node: + node.screen._reset_focus(node, pruning_nodes) + break + except NoScreen: + pass + self.log(nodes) + self.log([task for node in nodes if (task := node._task) is not None]) + await_complete = AwaitRemove( + [task for node in nodes if (task := node._task) is not None] + ) + self.call_next(await_complete) return await_complete # def _remove_nodes( diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 5cbda82f25..8bca085a1c 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -410,8 +410,21 @@ def remove(self) -> AwaitRemove: An awaitable object that waits for the widgets to be removed. """ app = active_app.get() - await_remove = app._prune(self._node) - return await_remove + if self._nodes: + return app._prune(*self._nodes) + return AwaitRemove([]) + + # async def remove_nodes() -> None: + # if self._nodes: + # await app._prune(*self._nodes) + # # await gather(*[app._prune(node) for node in self._nodes]) + + # await_complete = AwaitComplete(remove_nodes()) + # self._node.call_next(await_complete) + # return await_complete + + # await_remove = app._prune(self._node) + # return await_remove def set_styles( self, css: str | None = None, **update_styles diff --git a/src/textual/dom.py b/src/textual/dom.py index 9e7284c72d..d81ed9bdd6 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -206,6 +206,8 @@ def __init__( dict[str, tuple[MessagePump, Reactive | object]] | None ) = None + self._pruning = False + super().__init__() def set_reactive( diff --git a/src/textual/messages.py b/src/textual/messages.py index 3b4128931c..efa1fb673a 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -9,7 +9,6 @@ from .message import Message if TYPE_CHECKING: - from .dom import DOMNode from .widget import Widget @@ -19,12 +18,8 @@ class CloseMessages(Message, verbose=True): @rich.repr.auto -class Prune(Message, verbose=True): - root: DOMNode - - def __init__(self, root: DOMNode) -> None: - super().__init__() - self.root = root +class Prune(Message, verbose=True, bubble=False): + """Ask the node to prune (remove from DOM).""" @rich.repr.auto diff --git a/src/textual/signal.py b/src/textual/signal.py index 3f7cf78a28..7ed584f44d 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -115,7 +115,7 @@ def publish(self, data: SignalT) -> None: return for node, callbacks in list(self._subscriptions.items()): - if not (node.is_running and node.is_attached): + if not (node.is_running and node.is_attached) or node._pruning: # Removed nodes that are no longer running self._subscriptions.pop(node) else: diff --git a/src/textual/widget.py b/src/textual/widget.py index 55bd3f14db..65e267173c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -76,7 +76,7 @@ ) from .layouts.vertical import VerticalLayout from .message import Message -from .messages import CallbackType +from .messages import CallbackType, Prune from .notifications import SeverityLevel from .reactive import Reactive from .render import measure @@ -409,8 +409,6 @@ def __init__( self._anchor_animate: bool = False """Flag to enable animation when scrolling anchored widgets.""" - self._prune: Widget | None = None - virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" @@ -3464,7 +3462,7 @@ def remove(self) -> AwaitRemove: await_remove = self.app._prune(self) return await_remove - def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitComplete: + def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove: """Remove the immediate children of this Widget from the DOM. Args: @@ -3479,8 +3477,8 @@ def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitComplet children_to_remove = [ child for child in self.children if match(parsed_selectors, child) ] - await_complete = self.app._prune(self, children_to_remove) - return await_complete + await_remove = self.app._prune(*children_to_remove) + return await_remove @asynccontextmanager async def batch(self) -> AsyncGenerator[None, None]: @@ -3570,13 +3568,24 @@ def post_message(self, message: Message) -> bool: return super().post_message(message) async def on_prune(self, event: messages.Prune) -> None: - if self._parent is not None and event.root is not self: - self._prune = event.root - await self._close_messages() + print("ON PRUNE", self) + # if not self._pruning: + # return + if not self._nodes: + await self._close_messages(wait=False) async def _message_loop_exit(self) -> None: - if self._prune is not None and self._parent is not None: - self.post_message(messages.Prune(self._prune)) + print("_message loop exit", self) + if (parent := self._parent) is None: + return + assert isinstance(parent, DOMNode) + parent._nodes._remove(self) + self.app._registry.remove(self) + + if parent._pruning: + print("pruning parent", parent) + parent.post_message(Prune()) + self._detach() async def _on_idle(self, event: events.Idle) -> None: """Called when there are no more events on the queue. diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index cd0c1510f1..e5a4918338 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -262,7 +262,7 @@ def remove_items(self, indices: Iterable[int]) -> AwaitRemove: for index in indices: items_to_remove.append(items[index]) - await_remove = self.app._prune(self, tems_to_remove) + await_remove = self.app._prune(*items_to_remove) return await_remove def action_select_cursor(self) -> None: From 1f30942a0ebd1e6d9a66fb672acb83ac396ca6b3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Jul 2024 20:00:13 +0100 Subject: [PATCH 03/22] fox remove --- src/textual/app.py | 25 +++++-------------------- src/textual/css/query.py | 16 +--------------- src/textual/signal.py | 2 +- src/textual/widget.py | 4 +--- 4 files changed, 8 insertions(+), 39 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index f105c85be4..fd42f3f8f7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2798,9 +2798,6 @@ def is_mounted(self, widget: Widget) -> bool: async def _close_all(self) -> None: """Close all message pumps.""" - print("_close_all") - - # async with self._dom_lock: # Close all screens on all stacks: for stack in self._screen_stacks.values(): for stack_screen in reversed(stack): @@ -2808,14 +2805,11 @@ async def _close_all(self) -> None: await self._prune(stack_screen) stack.clear() - print(1) - # Close pre-defined screens. for screen in self.SCREENS.values(): if isinstance(screen, Screen) and screen._running: await self._prune(screen) - print(2) # Close any remaining nodes # Should be empty by now remaining_nodes = list(self._registry) @@ -2823,8 +2817,6 @@ async def _close_all(self) -> None: for child in remaining_nodes: await child._close_messages() - print(3) - async def _shutdown(self) -> None: self._begin_batch() # Prevents any layout / repaint while shutting down driver = self._driver @@ -3393,22 +3385,17 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: for child in widget._nodes: push(child) - def _prune(self, *nodes: DOMNode) -> AwaitRemove: - self.log("_prune", nodes) - stack: list[DOMNode] = [*nodes] - pruning_nodes = [] + def _prune(self, *nodes: Widget) -> AwaitRemove: + stack: list[Widget] = [*nodes] + pruning_nodes: list[Widget] = [] while stack: node = stack.pop() + node._pruning = True if node._nodes: - self.log("prune", node._nodes) - for prune_node in node._nodes: - pruning_nodes.append(prune_node) - prune_node._pruning = True + pruning_nodes.extend(node._nodes) stack.extend(node._nodes) else: - self.log("leaf prune", node) pruning_nodes.append(node) - node._pruning = True node.post_message(Prune()) try: @@ -3419,8 +3406,6 @@ def _prune(self, *nodes: DOMNode) -> AwaitRemove: except NoScreen: pass - self.log(nodes) - self.log([task for node in nodes if (task := node._task) is not None]) await_complete = AwaitRemove( [task for node in nodes if (task := node._task) is not None] ) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 8bca085a1c..fc4a883867 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -410,21 +410,7 @@ def remove(self) -> AwaitRemove: An awaitable object that waits for the widgets to be removed. """ app = active_app.get() - if self._nodes: - return app._prune(*self._nodes) - return AwaitRemove([]) - - # async def remove_nodes() -> None: - # if self._nodes: - # await app._prune(*self._nodes) - # # await gather(*[app._prune(node) for node in self._nodes]) - - # await_complete = AwaitComplete(remove_nodes()) - # self._node.call_next(await_complete) - # return await_complete - - # await_remove = app._prune(self._node) - # return await_remove + return app._prune(*self.nodes) def set_styles( self, css: str | None = None, **update_styles diff --git a/src/textual/signal.py b/src/textual/signal.py index 7ed584f44d..5960ea687d 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -108,7 +108,7 @@ def publish(self, data: SignalT) -> None: """ # Don't publish if the DOM is not ready or shutting down - if not self._owner.is_attached: + if not self._owner.is_attached or self._owner._pruning: return for ancestor_node in self._owner.ancestors_with_self: if not ancestor_node.is_running: diff --git a/src/textual/widget.py b/src/textual/widget.py index 65e267173c..91c72b37e6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3568,14 +3568,13 @@ def post_message(self, message: Message) -> bool: return super().post_message(message) async def on_prune(self, event: messages.Prune) -> None: - print("ON PRUNE", self) + self.log("ON PRUNE", self) # if not self._pruning: # return if not self._nodes: await self._close_messages(wait=False) async def _message_loop_exit(self) -> None: - print("_message loop exit", self) if (parent := self._parent) is None: return assert isinstance(parent, DOMNode) @@ -3583,7 +3582,6 @@ async def _message_loop_exit(self) -> None: self.app._registry.remove(self) if parent._pruning: - print("pruning parent", parent) parent.post_message(Prune()) self._detach() From ae13071b7f17c33fefa7c6d929a8f34f1d623531 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 7 Jul 2024 20:11:03 +0100 Subject: [PATCH 04/22] messages --- src/textual/widget.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 91c72b37e6..f2f7723fc8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3568,11 +3568,9 @@ def post_message(self, message: Message) -> bool: return super().post_message(message) async def on_prune(self, event: messages.Prune) -> None: - self.log("ON PRUNE", self) - # if not self._pruning: - # return - if not self._nodes: - await self._close_messages(wait=False) + if not self._pruning: + return + await self._close_messages(wait=False) async def _message_loop_exit(self) -> None: if (parent := self._parent) is None: @@ -3581,7 +3579,7 @@ async def _message_loop_exit(self) -> None: parent._nodes._remove(self) self.app._registry.remove(self) - if parent._pruning: + if parent._pruning and not len(parent._nodes): parent.post_message(Prune()) self._detach() From f954952bc5543fbbace3d8675cbf5abec79b8960 Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Mon, 8 Jul 2024 15:03:07 +0100 Subject: [PATCH 05/22] test(bindings): add regression test for #4382 (#4695) --- .../__snapshots__/test_snapshots.ambr | 158 ++++++++++++++++++ .../bindings_screen_overrides_show.py | 29 ++++ tests/snapshot_tests/test_snapshots.py | 5 + 3 files changed, 192 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/bindings_screen_overrides_show.py diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 52d6a2472d..d88b2812e8 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -1852,6 +1852,164 @@ ''' # --- +# name: test_bindings_screen_overrides_show + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HideBindingApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  p Binding shown  + + + + + ''' +# --- # name: test_blur_on_disabled ''' diff --git a/tests/snapshot_tests/snapshot_apps/bindings_screen_overrides_show.py b/tests/snapshot_tests/snapshot_apps/bindings_screen_overrides_show.py new file mode 100644 index 0000000000..03ecc6db1d --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/bindings_screen_overrides_show.py @@ -0,0 +1,29 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.binding import Binding +from textual.widgets import Footer + + +class ShowBindingScreen(Screen): + BINDINGS = [ + Binding("p", "app.pop_screen", "Binding shown"), + ] + + def compose(self) -> ComposeResult: + yield Footer() + + +class HideBindingApp(App): + """Regression test for https://github.com/Textualize/textual/issues/4382""" + + BINDINGS = [ + Binding("p", "app.pop_screen", "Binding hidden", show=False), + ] + + def on_mount(self) -> None: + self.push_screen(ShowBindingScreen()) + + +if __name__ == "__main__": + app = HideBindingApp() + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index afc494ab93..0610673b2b 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1294,3 +1294,8 @@ def test_grid_auto(snap_compare): """Test grid with keyline and auto-dimension.""" # https://github.com/Textualize/textual/issues/4678 assert snap_compare(SNAPSHOT_APPS_DIR / "grid_auto.py") + + +def test_bindings_screen_overrides_show(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/4382""" + assert snap_compare(SNAPSHOT_APPS_DIR / "bindings_screen_overrides_show.py") From a3ba81aa93fb8a86c8e7bf4dc3ea15584718ad62 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 Jul 2024 15:12:02 +0100 Subject: [PATCH 06/22] reverse prune --- src/textual/app.py | 3 ++- src/textual/widget.py | 17 ++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index fd42f3f8f7..068916e2c7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3388,6 +3388,8 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: def _prune(self, *nodes: Widget) -> AwaitRemove: stack: list[Widget] = [*nodes] pruning_nodes: list[Widget] = [] + for node in nodes: + node.post_message(Prune()) while stack: node = stack.pop() node._pruning = True @@ -3396,7 +3398,6 @@ def _prune(self, *nodes: Widget) -> AwaitRemove: stack.extend(node._nodes) else: pruning_nodes.append(node) - node.post_message(Prune()) try: for node in pruning_nodes: diff --git a/src/textual/widget.py b/src/textual/widget.py index f2f7723fc8..5971ee74df 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4,7 +4,7 @@ from __future__ import annotations -from asyncio import create_task, wait +from asyncio import create_task, gather, wait from collections import Counter from contextlib import asynccontextmanager from fractions import Fraction @@ -3568,19 +3568,18 @@ def post_message(self, message: Message) -> bool: return super().post_message(message) async def on_prune(self, event: messages.Prune) -> None: - if not self._pruning: - return await self._close_messages(wait=False) async def _message_loop_exit(self) -> None: - if (parent := self._parent) is None: - return + parent = self._parent + children = list(self.children) + for node in children: + node.post_message(Prune()) + await gather(*[node._task for node in children if node._task is not None]) + assert isinstance(parent, DOMNode) parent._nodes._remove(self) - self.app._registry.remove(self) - - if parent._pruning and not len(parent._nodes): - parent.post_message(Prune()) + self.app._registry.discard(self) self._detach() async def _on_idle(self, event: events.Idle) -> None: From a7d708050becd2895ba7ca6383989b6769605959 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 Jul 2024 18:53:04 +0100 Subject: [PATCH 07/22] prune other direction --- src/textual/app.py | 22 +++++++++++----------- src/textual/await_remove.py | 8 ++++++-- src/textual/dom.py | 4 +++- src/textual/message_pump.py | 21 ++++++++++++--------- src/textual/widget.py | 3 ++- src/textual/widgets/_tabs.py | 4 ++-- tests/test_unmount.py | 1 + tests/test_widget_mounting.py | 2 +- 8 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 068916e2c7..06222f2278 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1457,6 +1457,11 @@ def on_app_ready() -> None: app_ready_event.set() async def run_app(app: App) -> None: + """Run the apps message loop. + + Args: + app: App to run. + """ if message_hook is not None: message_hook_context_var.set(message_hook) app._loop = asyncio.get_running_loop() @@ -3386,23 +3391,18 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: push(child) def _prune(self, *nodes: Widget) -> AwaitRemove: - stack: list[Widget] = [*nodes] - pruning_nodes: list[Widget] = [] + pruning_nodes: set[Widget] = {*nodes} for node in nodes: node.post_message(Prune()) - while stack: - node = stack.pop() - node._pruning = True - if node._nodes: - pruning_nodes.extend(node._nodes) - stack.extend(node._nodes) - else: - pruning_nodes.append(node) + for child in node.walk_children(with_self=True): + assert isinstance(child, Widget) + child._pruning = True + pruning_nodes.add(child) try: for node in pruning_nodes: if node.screen.focused is node: - node.screen._reset_focus(node, pruning_nodes) + node.screen._reset_focus(node, list(pruning_nodes)) break except NoScreen: pass diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index 2a4457a64c..3bad256e36 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -2,6 +2,7 @@ An *optionally* awaitable object returned by methods that remove widgets. """ +import asyncio from asyncio import Task, gather from typing import Generator @@ -34,15 +35,18 @@ class AwaitRemove: - def __init__(self, tasks: list[Task]): + def __init__(self, tasks: list[Task]) -> None: self._tasks = tasks async def __call__(self) -> None: await self def __await__(self) -> Generator[None, None, None]: + current_task = asyncio.current_task() + tasks = [task for task in self._tasks if task is not current_task] + async def await_prune() -> None: """Wait for the prune operation to finish.""" - await gather(*self._tasks) + await gather(*tasks) return await_prune().__await__() diff --git a/src/textual/dom.py b/src/textual/dom.py index d81ed9bdd6..0bb74876c2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -786,7 +786,9 @@ def display(self) -> bool: my_widget.display = False # Hide my_widget ``` """ - return self.styles.display != "none" and not (self._closing or self._closed) + return self.styles.display != "none" and not ( + self._closing or self._closed or self._pruning + ) @display.setter def display(self, new_val: bool | str) -> None: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 0377bbc820..3b8b480130 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -369,11 +369,12 @@ def set_timer( Returns: A timer object. """ + timer = Timer( self, delay, name=name or f"set_timer#{Timer._timer_count}", - callback=callback, + callback=None if callback is None else partial(self.call_next, callback), repeat=0, pause=pause, ) @@ -468,13 +469,6 @@ def _on_invoke_later(self, message: messages.InvokeLater) -> None: message.callback, message._sender or active_message_pump.get() ) - def _close_messages_no_wait(self) -> None: - """Request the message queue to immediately exit.""" - self._message_queue.put_nowait(messages.CloseMessages()) - - async def _on_close_messages(self, message: messages.CloseMessages) -> None: - await self._close_messages() - async def _close_messages(self, wait: bool = True) -> None: """Close message queue, and optionally wait for queue to finish processing.""" if self._closed or self._closing: @@ -483,7 +477,7 @@ async def _close_messages(self, wait: bool = True) -> None: if self._timers: await Timer._stop_all(self._timers) self._timers.clear() - self._message_queue.put_nowait(events.Unmount()) + # self._message_queue.put_nowait(events.Unmount()) Reactive._reset_object(self) self._message_queue.put_nowait(None) if wait and self._task is not None and asyncio.current_task() != self._task: @@ -563,6 +557,13 @@ async def _pre_process(self) -> bool: def _post_mount(self): """Called after the object has been mounted.""" + def _close_messages_no_wait(self) -> None: + """Request the message queue to immediately exit.""" + self._message_queue.put_nowait(messages.CloseMessages()) + + async def _on_close_messages(self, message: messages.CloseMessages) -> None: + await self._close_messages() + async def _process_messages_loop(self) -> None: """Process messages until the queue is closed.""" _rich_traceback_guard = True @@ -808,6 +809,8 @@ def post_message(self, message: Message) -> bool: return True async def on_callback(self, event: events.Callback) -> None: + if self.app._closing: + return await invoke(event.callback) # TODO: Does dispatch_key belong on message pump? diff --git a/src/textual/widget.py b/src/textual/widget.py index 5971ee74df..df9cad9646 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3459,6 +3459,7 @@ def remove(self) -> AwaitRemove: Returns: An awaitable object that waits for the widget to be removed. """ + # assert asyncio.current_task() is not self._task await_remove = self.app._prune(self) return await_remove @@ -3576,7 +3577,7 @@ async def _message_loop_exit(self) -> None: for node in children: node.post_message(Prune()) await gather(*[node._task for node in children if node._task is not None]) - + await self._dispatch_message(events.Unmount()) assert isinstance(parent, DOMNode) parent._nodes._remove(self) self.app._registry.discard(self) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 33ce821238..e31ec4bfe5 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -510,7 +510,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: An optionally awaitable object that waits for the tab to be removed. """ if not tab_or_id: - return AwaitComplete(self.app._remove_nodes([], None)) + return AwaitComplete() if isinstance(tab_or_id, Tab): remove_tab = tab_or_id @@ -518,7 +518,7 @@ def remove_tab(self, tab_or_id: Tab | str | None) -> AwaitComplete: try: remove_tab = self.query_one(f"#tabs-list > #{tab_or_id}", Tab) except NoMatches: - return AwaitComplete(self.app._remove_nodes([], None)) + return AwaitComplete() removing_active_tab = remove_tab.has_class("-active") next_tab = self._next_active diff --git a/tests/test_unmount.py b/tests/test_unmount.py index 2301e4010a..dbb042fd6c 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -49,5 +49,6 @@ async def on_mount(self) -> None: "UnmountWidget#top-True-0", "MyScreen#main", ] + print(unmount_ids) assert unmount_ids == expected diff --git a/tests/test_widget_mounting.py b/tests/test_widget_mounting.py index 086e448684..7babed4781 100644 --- a/tests/test_widget_mounting.py +++ b/tests/test_widget_mounting.py @@ -19,7 +19,7 @@ async def test_mount_via_app() -> None: # Make a background set of widgets. widgets = [Static(id=f"starter-{n}") for n in range(10)] - async with App().run_test() as pilot: + async with App[None]().run_test() as pilot: with pytest.raises(WidgetError): await pilot.app.mount(SelfOwn()) From 2edcedd4063559f9a3e44a8f46268678f61df67d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 Jul 2024 20:15:31 +0100 Subject: [PATCH 08/22] test fixes --- src/textual/app.py | 18 +++++++++++++++-- src/textual/await_remove.py | 33 ++++++------------------------- src/textual/css/query.py | 2 +- src/textual/message_pump.py | 8 ++++---- src/textual/widget.py | 6 +++--- src/textual/widgets/_list_view.py | 2 +- tests/test_tooltips.py | 1 - tests/test_widget_child_moving.py | 15 +++++++++++--- 8 files changed, 43 insertions(+), 42 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 06222f2278..62680a9183 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3390,7 +3390,7 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: for child in widget._nodes: push(child) - def _prune(self, *nodes: Widget) -> AwaitRemove: + def _prune(self, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove: pruning_nodes: set[Widget] = {*nodes} for node in nodes: node.post_message(Prune()) @@ -3407,8 +3407,22 @@ def _prune(self, *nodes: Widget) -> AwaitRemove: except NoScreen: pass + def post_mount() -> None: + """Called after removing children.""" + + if parent is not None: + try: + screen = parent.screen + except (ScreenStackError, NoScreen): + pass + else: + if screen._running: + self._update_mouse_over(screen) + finally: + parent.refresh(layout=True) + await_complete = AwaitRemove( - [task for node in nodes if (task := node._task) is not None] + [task for node in nodes if (task := node._task) is not None], post_mount ) self.call_next(await_complete) return await_complete diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index 3bad256e36..2e99155217 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -6,37 +6,14 @@ from asyncio import Task, gather from typing import Generator -# class AwaitRemove: -# """An awaitable returned by a method that removes DOM nodes. - -# Returned by [Widget.remove][textual.widget.Widget.remove] and -# [DOMQuery.remove][textual.css.query.DOMQuery.remove]. -# """ - -# def __init__(self, finished_flag: Event, task: Task) -> None: -# """Initialise the instance of ``AwaitRemove``. - -# Args: -# finished_flag: The asyncio event to wait on. -# task: The task which does the remove (required to keep a reference). -# """ -# self.finished_flag = finished_flag -# self._task = task - -# async def __call__(self) -> None: -# await self - -# def __await__(self) -> Generator[None, None, None]: -# async def await_prune() -> None: -# """Wait for the prune operation to finish.""" -# await self.finished_flag.wait() - -# return await_prune().__await__() +from ._callback import invoke +from ._types import CallbackType class AwaitRemove: - def __init__(self, tasks: list[Task]) -> None: + def __init__(self, tasks: list[Task], post_remove: CallbackType) -> None: self._tasks = tasks + self._post_remove = post_remove async def __call__(self) -> None: await self @@ -48,5 +25,7 @@ def __await__(self) -> Generator[None, None, None]: async def await_prune() -> None: """Wait for the prune operation to finish.""" await gather(*tasks) + if self._post_remove: + await invoke(self._post_remove) return await_prune().__await__() diff --git a/src/textual/css/query.py b/src/textual/css/query.py index fc4a883867..6dec06f542 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -410,7 +410,7 @@ def remove(self) -> AwaitRemove: An awaitable object that waits for the widgets to be removed. """ app = active_app.get() - return app._prune(*self.nodes) + return app._prune(*self.nodes, parent=self._node) def set_styles( self, css: str | None = None, **update_styles diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 3b8b480130..5f35e75c72 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -465,9 +465,10 @@ def call_next(self, callback: Callback, *args: Any, **kwargs: Any) -> None: def _on_invoke_later(self, message: messages.InvokeLater) -> None: # Forward InvokeLater message to the Screen - self.app.screen._invoke_later( - message.callback, message._sender or active_message_pump.get() - ) + if self.app._running: + self.app.screen._invoke_later( + message.callback, message._sender or active_message_pump.get() + ) async def _close_messages(self, wait: bool = True) -> None: """Close message queue, and optionally wait for queue to finish processing.""" @@ -477,7 +478,6 @@ async def _close_messages(self, wait: bool = True) -> None: if self._timers: await Timer._stop_all(self._timers) self._timers.clear() - # self._message_queue.put_nowait(events.Unmount()) Reactive._reset_object(self) self._message_queue.put_nowait(None) if wait and self._task is not None and asyncio.current_task() != self._task: diff --git a/src/textual/widget.py b/src/textual/widget.py index df9cad9646..bc9dbdfdd9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3459,8 +3459,7 @@ def remove(self) -> AwaitRemove: Returns: An awaitable object that waits for the widget to be removed. """ - # assert asyncio.current_task() is not self._task - await_remove = self.app._prune(self) + await_remove = self.app._prune(self, parent=self._parent) return await_remove def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove: @@ -3478,7 +3477,7 @@ def remove_children(self, selector: str | type[QueryType] = "*") -> AwaitRemove: children_to_remove = [ child for child in self.children if match(parsed_selectors, child) ] - await_remove = self.app._prune(*children_to_remove) + await_remove = self.app._prune(*children_to_remove, parent=self._parent) return await_remove @asynccontextmanager @@ -3582,6 +3581,7 @@ async def _message_loop_exit(self) -> None: parent._nodes._remove(self) self.app._registry.discard(self) self._detach() + self._pruning = False async def _on_idle(self, event: events.Idle) -> None: """Called when there are no more events on the queue. diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index e5a4918338..a8f9e1ed3f 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -262,7 +262,7 @@ def remove_items(self, indices: Iterable[int]) -> AwaitRemove: for index in indices: items_to_remove.append(items[index]) - await_remove = self.app._prune(*items_to_remove) + await_remove = items.remove() return await_remove def action_select_cursor(self) -> None: diff --git a/tests/test_tooltips.py b/tests/test_tooltips.py index 3648615148..d6cc4b46fa 100644 --- a/tests/test_tooltips.py +++ b/tests/test_tooltips.py @@ -7,7 +7,6 @@ class TooltipApp(App[None]): - CSS = """ Static { width: 1fr; diff --git a/tests/test_widget_child_moving.py b/tests/test_widget_child_moving.py index f0ab58d303..4ceacc4927 100644 --- a/tests/test_widget_child_moving.py +++ b/tests/test_widget_child_moving.py @@ -137,9 +137,14 @@ async def test_move_before_end_of_child_list() -> None: async def test_move_before_permutations() -> None: """Test the different permutations of moving one widget before another.""" widgets = [Widget(id=f"widget-{n}") for n in range(10)] - perms = ((1, 0), (widgets[1], 0), (1, widgets[0]), (widgets[1], widgets[0])) + perms = ( + (1, 0), + (widgets[1], 0), + (1, widgets[0]), + (widgets[1], widgets[0]), + ) for child, target in perms: - async with App().run_test() as pilot: + async with App[None]().run_test() as pilot: container = Widget(*widgets) await pilot.app.mount(container) container.move_child(child, before=target) @@ -153,9 +158,13 @@ async def test_move_after_permutations() -> None: widgets = [Widget(id=f"widget-{n}") for n in range(10)] perms = ((0, 1), (widgets[0], 1), (0, widgets[1]), (widgets[0], widgets[1])) for child, target in perms: - async with App().run_test() as pilot: + async with App[None]().run_test() as pilot: container = Widget(*widgets) await pilot.app.mount(container) + await pilot.pause() + + print(1, container.children) + print(2, child) container.move_child(child, after=target) assert container._nodes[0].id == "widget-1" assert container._nodes[1].id == "widget-0" From db9bd3e1a5008ab5ffe4ee632822577d42fbae68 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 Jul 2024 20:45:22 +0100 Subject: [PATCH 09/22] fix pruning --- src/textual/app.py | 3 +++ src/textual/widget.py | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 62680a9183..b2b4134ed5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2747,6 +2747,9 @@ def _register( apply_stylesheet = self.stylesheet.apply for widget in widget_list: + widget._closing = False + widget._closed = False + widget._pruning = False if not isinstance(widget, Widget): raise AppError(f"Can't register {widget!r}; expected a Widget instance") if widget not in self._registry: diff --git a/src/textual/widget.py b/src/textual/widget.py index bc9dbdfdd9..9fc2a7bf61 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3581,7 +3581,6 @@ async def _message_loop_exit(self) -> None: parent._nodes._remove(self) self.app._registry.discard(self) self._detach() - self._pruning = False async def _on_idle(self, event: events.Idle) -> None: """Called when there are no more events on the queue. From 9ec5f17db82cbc56b84f9ed29d3ae666d360bfcb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 Jul 2024 22:37:04 +0100 Subject: [PATCH 10/22] reset focus --- src/textual/app.py | 120 ++++++--------------------------------------- 1 file changed, 14 insertions(+), 106 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b2b4134ed5..8e0d2afd8a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -581,7 +581,6 @@ def __init__( else None ) self._screenshot: str | None = None - # self._dom_lock = RLock() self._dom_ready = False self._batch_count = 0 self._notifications = Notifications() @@ -3394,6 +3393,14 @@ def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: push(child) def _prune(self, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove: + """Prune nodes from DOM. + + Args: + parent: Parent node. + + Returns: + Optional awaitable. + """ pruning_nodes: set[Widget] = {*nodes} for node in nodes: node.post_message(Prune()) @@ -3402,13 +3409,10 @@ def _prune(self, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove: child._pruning = True pruning_nodes.add(child) - try: - for node in pruning_nodes: - if node.screen.focused is node: - node.screen._reset_focus(node, list(pruning_nodes)) - break - except NoScreen: - pass + for node in pruning_nodes: + if node.screen.focused is node: + node.screen._reset_focus(node, list(pruning_nodes)) + break def post_mount() -> None: """Called after removing children.""" @@ -3425,108 +3429,12 @@ def post_mount() -> None: parent.refresh(layout=True) await_complete = AwaitRemove( - [task for node in nodes if (task := node._task) is not None], post_mount + [task for node in nodes if (task := node._task) is not None], + post_mount, ) self.call_next(await_complete) return await_complete - # def _remove_nodes( - # self, widgets: list[Widget], parent: DOMNode | None - # ) -> AwaitRemove: - # """Remove nodes from DOM, and return an awaitable that awaits cleanup. - - # Args: - # widgets: List of nodes to remove. - # parent: Parent node of widgets, or None for no parent. - - # Returns: - # Awaitable that returns when the nodes have been fully removed. - # """ - - # async def prune_widgets_task( - # widgets: list[Widget], finished_event: asyncio.Event - # ) -> None: - # """Prune widgets as a background task. - - # Args: - # widgets: Widgets to prune. - # finished_event: Event to set when complete. - # """ - # try: - # await self._prune_nodes(widgets) - # finally: - # finished_event.set() - # try: - # self._update_mouse_over(self.screen) - # except ScreenStackError: - # pass - # if parent is not None: - # parent.refresh(layout=True) - - # removed_widgets = self._detach_from_dom(widgets) - - # finished_event = asyncio.Event() - # remove_task = create_task( - # prune_widgets_task(removed_widgets, finished_event), name="prune nodes" - # ) - - # await_remove = AwaitRemove(finished_event, remove_task) - # self.call_next(await_remove) - # return await_remove - - # async def _prune_nodes(self, widgets: list[Widget]) -> None: - # """Remove nodes and children. - - # Args: - # widgets: Widgets to remove. - # """ - - # for widget in widgets: - # async with self._dom_lock: - # await asyncio.shield(self._prune_node(widget)) - - # async def _prune_node(self, root: Widget) -> None: - # """Remove a node and its children. Children are removed before parents. - - # Args: - # root: Node to remove. - # """ - # # Pruning a node that has been removed is a no-op - - # if root not in self._registry: - # return - - # node_children = list(self._walk_children(root)) - - # for children in reversed(node_children): - # # Closing children can be done asynchronously. - # close_children = [ - # child for child in children if child._running and not child._closing - # ] - - # # TODO: What if a message pump refuses to exit? - # if close_children: - # close_messages = [ - # child._close_messages(wait=True) for child in close_children - # ] - # try: - # # Close all the children - # await asyncio.wait_for( - # asyncio.gather(*close_messages), self.CLOSE_TIMEOUT - # ) - # except asyncio.TimeoutError: - # # Likely a deadlock if we get here - # # If not a deadlock, increase CLOSE_TIMEOUT, or set it to None - # raise asyncio.TimeoutError( - # f"Timeout waiting for {close_children!r} to close; possible deadlock (consider changing App.CLOSE_TIMEOUT)\n" - # ) from None - # finally: - # for child in children: - # self._unregister(child) - - # await root._close_messages(wait=True) - # self._unregister(root) - def _watch_app_focus(self, focus: bool) -> None: """Respond to changes in app focus.""" if focus: From ee45672f93ec877e722ad68459f0089a33859be7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 8 Jul 2024 23:06:01 +0100 Subject: [PATCH 11/22] test fix --- src/textual/app.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 8e0d2afd8a..d5fbfb5f06 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3404,15 +3404,18 @@ def _prune(self, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove: pruning_nodes: set[Widget] = {*nodes} for node in nodes: node.post_message(Prune()) - for child in node.walk_children(with_self=True): - assert isinstance(child, Widget) - child._pruning = True - pruning_nodes.add(child) + pruning_nodes.update(node.walk_children(with_self=True)) + + try: + screen = nodes[0].screen + except (ScreenStackError, NoScreen): + pass + else: + if screen.focused and screen.focused in pruning_nodes: + screen._reset_focus(screen.focused, list(pruning_nodes)) for node in pruning_nodes: - if node.screen.focused is node: - node.screen._reset_focus(node, list(pruning_nodes)) - break + node._pruning = True def post_mount() -> None: """Called after removing children.""" From 754c7efbaa1fe7e436e149a72f0cc8967447c5a0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:30:57 +0100 Subject: [PATCH 12/22] remove virtual dom --- src/textual/app.py | 3 ++- src/textual/await_remove.py | 10 ++++++++-- src/textual/widget.py | 3 ++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index d5fbfb5f06..3fbdbf3fab 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2820,7 +2820,6 @@ async def _close_all(self) -> None: # Close any remaining nodes # Should be empty by now remaining_nodes = list(self._registry) - for child in remaining_nodes: await child._close_messages() @@ -3401,6 +3400,8 @@ def _prune(self, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove: Returns: Optional awaitable. """ + if not nodes: + return AwaitRemove([]) pruning_nodes: set[Widget] = {*nodes} for node in nodes: node.post_message(Prune()) diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index 2e99155217..28698c1c94 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -2,6 +2,8 @@ An *optionally* awaitable object returned by methods that remove widgets. """ +from __future__ import annotations + import asyncio from asyncio import Task, gather from typing import Generator @@ -11,7 +13,11 @@ class AwaitRemove: - def __init__(self, tasks: list[Task], post_remove: CallbackType) -> None: + """An awaitable that waits for nodes to be removed.""" + + def __init__( + self, tasks: list[Task], post_remove: CallbackType | None = None + ) -> None: self._tasks = tasks self._post_remove = post_remove @@ -25,7 +31,7 @@ def __await__(self) -> Generator[None, None, None]: async def await_prune() -> None: """Wait for the prune operation to finish.""" await gather(*tasks) - if self._post_remove: + if self._post_remove is not None: await invoke(self._post_remove) return await_prune().__await__() diff --git a/src/textual/widget.py b/src/textual/widget.py index 9fc2a7bf61..76e4c7b583 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3572,9 +3572,10 @@ async def on_prune(self, event: messages.Prune) -> None: async def _message_loop_exit(self) -> None: parent = self._parent - children = list(self.children) + children = [*self.children, *self._get_virtual_dom()] for node in children: node.post_message(Prune()) + await gather(*[node._task for node in children if node._task is not None]) await self._dispatch_message(events.Unmount()) assert isinstance(parent, DOMNode) From fa8167c88fa49e12904d00c2aa55a50623194024 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:36:26 +0100 Subject: [PATCH 13/22] docstrings --- src/textual/message_pump.py | 2 +- src/textual/widget.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 5f35e75c72..4c2a9e3e47 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -524,7 +524,7 @@ async def _process_messages(self) -> None: await self._message_loop_exit() async def _message_loop_exit(self) -> None: - pass + """Called when the message loop has completed.""" async def _pre_process(self) -> bool: """Procedure to run before processing messages. diff --git a/src/textual/widget.py b/src/textual/widget.py index 76e4c7b583..77884c9199 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3571,6 +3571,7 @@ async def on_prune(self, event: messages.Prune) -> None: await self._close_messages(wait=False) async def _message_loop_exit(self) -> None: + """Clean up DOM tree.""" parent = self._parent children = [*self.children, *self._get_virtual_dom()] for node in children: From 72eaecc18cd811f14c973e51dec74ca6c1d83f4b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:37:24 +0100 Subject: [PATCH 14/22] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e0bbb1a92..7daf4a38b3 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 + +- More predictable DOM removals. + ## [0.71.0] - 2024-06-29 ### Changed From 466e3420a34f5f46cee4e51065e92b04ee2eae66 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:42:54 +0100 Subject: [PATCH 15/22] comments --- src/textual/widget.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 77884c9199..b19d70d666 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3568,18 +3568,23 @@ def post_message(self, message: Message) -> bool: return super().post_message(message) async def on_prune(self, event: messages.Prune) -> None: + """Close message loop when asked to prune.""" await self._close_messages(wait=False) async def _message_loop_exit(self) -> None: """Clean up DOM tree.""" parent = self._parent + # Post messages to children, asking them to prune children = [*self.children, *self._get_virtual_dom()] for node in children: node.post_message(Prune()) + # Wait for child nodes to exit await gather(*[node._task for node in children if node._task is not None]) + # Send unmount event await self._dispatch_message(events.Unmount()) assert isinstance(parent, DOMNode) + # Finalize removal from DOM parent._nodes._remove(self) self.app._registry.discard(self) self._detach() From ae1d174ff3c96f062e317fd12c9e53b704d81aab Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:43:21 +0100 Subject: [PATCH 16/22] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7daf4a38b3..bc194e41fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- More predictable DOM removals. +- More predictable DOM removals. https://github.com/Textualize/textual/pull/4708 ## [0.71.0] - 2024-06-29 From 63e586c95633a8208cff7a2bf8652ef9f84ea22f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:47:31 +0100 Subject: [PATCH 17/22] work around pruning --- src/textual/widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index b19d70d666..3c4631c303 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -941,7 +941,7 @@ def mount( Only one of ``before`` or ``after`` can be provided. If both are provided a ``MountError`` will be raised. """ - if self._closing: + if self._closing or self._pruning: return AwaitMount(self, []) if not self.is_attached: raise MountError(f"Can't mount widget(s) before {self!r} is mounted") @@ -1135,7 +1135,7 @@ async def recompose(self) -> None: Recomposing will remove children and call `self.compose` again to remount. """ - if not self.is_attached: + if not self.is_attached or self._pruning: return async with self.batch(): From c0e3434627e7f1891a2868b703573a566201b347 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:49:40 +0100 Subject: [PATCH 18/22] remove debug --- tests/test_widget_child_moving.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_widget_child_moving.py b/tests/test_widget_child_moving.py index 4ceacc4927..f4c89b607f 100644 --- a/tests/test_widget_child_moving.py +++ b/tests/test_widget_child_moving.py @@ -163,8 +163,6 @@ async def test_move_after_permutations() -> None: await pilot.app.mount(container) await pilot.pause() - print(1, container.children) - print(2, child) container.move_child(child, after=target) assert container._nodes[0].id == "widget-1" assert container._nodes[1].id == "widget-0" From cdfd8a102c35daa071505fbed262c56f13e693f2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 10:56:02 +0100 Subject: [PATCH 19/22] Remove debug --- tests/test_unmount.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_unmount.py b/tests/test_unmount.py index dbb042fd6c..2301e4010a 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -49,6 +49,5 @@ async def on_mount(self) -> None: "UnmountWidget#top-True-0", "MyScreen#main", ] - print(unmount_ids) assert unmount_ids == expected From b52f106cd0c395953e18fa8a65c01b57479a471f Mon Sep 17 00:00:00 2001 From: bmo Date: Tue, 9 Jul 2024 12:03:36 +0200 Subject: [PATCH 20/22] Fix typos in comments and documentation --- src/textual/app.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 0acfdcb904..91da61528e 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -642,7 +642,7 @@ def validate_title(self, title: Any) -> str: return str(title) def validate_sub_title(self, sub_title: Any) -> str: - """Make sure the sub-title is set to a string.""" + """Make sure the subtitle is set to a string.""" return str(sub_title) @property @@ -668,7 +668,7 @@ def return_code(self) -> int | None: Non-zero codes indicate errors. A value of 1 means the app exited with a fatal error. - If the app wasn't exited yet, this will be `None`. + If the app hasn't exited yet, this will be `None`. Example: The return code can be used to exit the process via `sys.exit`. @@ -1080,7 +1080,7 @@ def _log( ) -> None: """Write to logs or devtools. - Positional args will logged. Keyword args will be prefixed with the key. + Positional args will be logged. Keyword args will be prefixed with the key. Example: ```python @@ -1510,7 +1510,7 @@ async def run_async( mouse: Enable mouse support. size: Force terminal size to `(WIDTH, HEIGHT)`, or None to auto-detect. - auto_pilot: An auto pilot coroutine. + auto_pilot: An autopilot coroutine. Returns: App return value. @@ -1673,9 +1673,9 @@ async def _on_css_change(self) -> None: self.stylesheet.update(screen) def render(self) -> RenderResult: - """Render method inherited from widget, to render the screen's background. + """Render method, inherited from widget, to render the screen's background. - May be override to customize background visuals. + May be overridden to customize background visuals. """ return Blank(self.styles.background) @@ -1695,7 +1695,7 @@ def get_child_by_id( def get_child_by_id( self, id: str, expect_type: type[ExpectType] | None = None ) -> ExpectType | Widget: - """Get the first child (immediate descendent) of this DOMNode with the given ID. + """Get the first child (immediate descendant) of this DOMNode with the given ID. Args: id: The ID of the node to search for. @@ -2223,14 +2223,14 @@ def install_screen(self, screen: Screen, name: str) -> None: def uninstall_screen(self, screen: Screen | str) -> str | None: """Uninstall a screen. - If the screen was not previously installed then this method is a null-op. + If the screen was not previously installed, then this method is a null-op. Uninstalling a screen allows Textual to delete it when it is popped or switched. Note that uninstalling a screen is only required if you have previously installed it with [install_screen][textual.app.App.install_screen]. Textual will also uninstall screens automatically on exit. Args: - screen: The screen to uninstall or the name of a installed screen. + screen: The screen to uninstall or the name of an installed screen. Returns: The name of the screen that was uninstalled, or None if no screen was uninstalled. @@ -2691,7 +2691,7 @@ def _register_child( # position (for now) of meaning "okay really what I mean is # do an append, like if I'd asked to add with no before or # after". So... we insert before the next item in the node - # list, iff after isn't -1. + # list, if after isn't -1. parent._nodes._insert(after + 1, child) else: # At this point we appear to not be adding before or after, @@ -2774,7 +2774,7 @@ async def _disconnect_devtools(self): await self.devtools.disconnect() def _start_widget(self, parent: Widget, widget: Widget) -> None: - """Start a widget (run it's task) so that it can receive messages. + """Start a widget (run its task) so that it can receive messages. Args: parent: The parent of the Widget. From a10db5dea7f80786d0b399b955542e214d26f38c Mon Sep 17 00:00:00 2001 From: TomJGooding <101601846+TomJGooding@users.noreply.github.com> Date: Tue, 9 Jul 2024 11:07:07 +0100 Subject: [PATCH 21/22] docs(option list): restore missing component classes --- src/textual/widgets/_option_list.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_option_list.py b/src/textual/widgets/_option_list.py index 06bdf2a214..36477a98af 100644 --- a/src/textual/widgets/_option_list.py +++ b/src/textual/widgets/_option_list.py @@ -20,7 +20,7 @@ from ..strip import Strip if TYPE_CHECKING: - from typing_extensions import Self + from typing_extensions import Self, TypeAlias class DuplicateID(Exception): @@ -193,6 +193,15 @@ class OptionList(ScrollView, can_focus=True): "option-list--option-hover-highlighted", "option-list--separator", } + """ + | Class | Description | + | :- | :- | + | `option-list--option-disabled` | Target disabled options. | + | `option-list--option-highlighted` | Target the highlighted option. | + | `option-list--option-hover` | Target an option that has the mouse over it. | + | `option-list--option-hover-highlighted` | Target a highlighted option that has the mouse over it. | + | `option-list--separator` | Target the separators. | + """ highlighted: reactive[int | None] = reactive["int | None"](None) """The index of the currently-highlighted option, or `None` if no option is highlighted.""" From f2a22e9775655d1a2791dce0ee830b97bcbc42f3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 9 Jul 2024 11:09:39 +0100 Subject: [PATCH 22/22] orphaned functions --- src/textual/app.py | 87 ---------------------------------------------- 1 file changed, 87 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 3fbdbf3fab..995ad24967 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3304,93 +3304,6 @@ async def _on_app_blur(self, event: events.AppBlur) -> None: self.app_focus = False self.screen.refresh_bindings() - def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]: - """Detach a list of widgets from the DOM. - - Args: - widgets: The list of widgets to detach from the DOM. - - Returns: - The list of widgets that should be pruned. - - Note: - A side-effect of calling this function is that each parent of - each affected widget will be made to forget about the affected - child. - """ - - # We've been given a list of widgets to remove, but removing those - # will also result in other (descendent) widgets being removed. So - # to start with let's get a list of everything that's not going to - # be in the DOM by the time we've finished. Note that, at this - # point, it's entirely possible that there will be duplicates. - everything_to_remove: list[Widget] = [] - for widget in widgets: - everything_to_remove.extend( - widget.walk_children( - Widget, with_self=True, method="depth", reverse=True - ) - ) - - # Next up, let's quickly create a deduped collection of things to - # remove and ensure that, if one of them is the focused widget, - # focus gets moved to somewhere else. - dedupe_to_remove = set(everything_to_remove) - try: - if self.screen.focused in dedupe_to_remove: - self.screen._reset_focus( - self.screen.focused, - [ - to_remove - for to_remove in dedupe_to_remove - if to_remove.can_focus - ], - ) - except ScreenStackError: - pass - # Next, we go through the set of widgets we've been asked to remove - # and try and find the minimal collection of widgets that will - # result in everything else that should be removed, being removed. - # In other words: find the smallest set of ancestors in the DOM that - # will remove the widgets requested for removal, and also ensure - # that all knock-on effects happen too. - request_remove = set(widgets) - pruned_remove = [ - widget for widget in widgets if request_remove.isdisjoint(widget.ancestors) - ] - - # Now that we know that minimal set of widgets, we go through them - # and get their parents to forget about them. This has the effect of - # snipping each affected branch from the DOM. - for widget in pruned_remove: - if widget.parent is not None: - widget.parent._nodes._remove(widget) - - for node in pruned_remove: - node._detach() - - # Return the list of widgets that should end up being sent off in a - # prune event. - return pruned_remove - - def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: - """Walk children depth first, generating widgets and a list of their siblings. - - Returns: - The child widgets of root. - """ - stack: list[Widget] = [root] - pop = stack.pop - push = stack.append - - while stack: - widget = pop() - children = [*widget._nodes, *widget._get_virtual_dom()] - if children: - yield children - for child in widget._nodes: - push(child) - def _prune(self, *nodes: Widget, parent: DOMNode | None = None) -> AwaitRemove: """Prune nodes from DOM.