From 3b9bc0d536810e45d87410a18a58aa51429fbdf5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 23 Jan 2023 11:57:48 +0100 Subject: [PATCH] fix stuck screen --- src/textual/_callback.py | 49 ++++++++++++++++++-- src/textual/app.py | 14 +++++- src/textual/message_pump.py | 82 +++++++++++++++++++++++----------- src/textual/screen.py | 26 ++++++----- src/textual/widget.py | 7 ++- src/textual/widgets/_footer.py | 1 + 6 files changed, 136 insertions(+), 43 deletions(-) diff --git a/src/textual/_callback.py b/src/textual/_callback.py index b16644b4a..8e55a8c82 100644 --- a/src/textual/_callback.py +++ b/src/textual/_callback.py @@ -1,9 +1,17 @@ from __future__ import annotations +import asyncio from functools import lru_cache - from inspect import signature, isawaitable -from typing import Any, Callable +from typing import Any, Callable, TYPE_CHECKING + +from . import active_app + +if TYPE_CHECKING: + from .app import App + +# Maximum seconds before warning about a slow callback +INVOKE_TIMEOUT_WARNING = 3 @lru_cache(maxsize=2048) @@ -12,7 +20,7 @@ def count_parameters(func: Callable) -> int: return len(signature(func).parameters) -async def invoke(callback: Callable, *params: object) -> Any: +async def _invoke(callback: Callable, *params: object) -> Any: """Invoke a callback with an arbitrary number of parameters. Args: @@ -27,3 +35,38 @@ async def invoke(callback: Callable, *params: object) -> Any: if isawaitable(result): result = await result return result + + +async def invoke(callback: Callable, *params: object) -> Any: + """Invoke a callback with an arbitrary number of parameters. + + Args: + callback: The callable to be invoked. + + Returns: + The return value of the invoked callable. + """ + + app: App | None + try: + app = active_app.get() + except LookupError: + app = None + + if app is not None and "debug" in app.features: + # In debug mode we will warn about callbacks that may be stuck + def log_slow() -> None: + """Log a message regarding a slow callback.""" + app.log.warning( + f"Callback {callback} is still pending after {INVOKE_TIMEOUT_WARNING} seconds" + ) + + call_later_handle = asyncio.get_running_loop().call_later( + INVOKE_TIMEOUT_WARNING, log_slow + ) + try: + return await _invoke(callback, *params) + finally: + call_later_handle.cancel() + else: + return await _invoke(callback, *params) diff --git a/src/textual/app.py b/src/textual/app.py index 22e68cdda..85dd37235 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1191,9 +1191,13 @@ class App(Generic[ReturnType], DOMNode): _screen = self.get_screen(screen) if not _screen.is_running: widgets = self._register(self, _screen) - return (_screen, AwaitMount(_screen, widgets)) + await_mount = AwaitMount(_screen, widgets) + self.call_next(await_mount) + return (_screen, await_mount) else: - return (_screen, AwaitMount(_screen, [])) + await_mount = AwaitMount(_screen, []) + self.call_next(await_mount) + return (_screen, await_mount) def _replace_screen(self, screen: Screen) -> Screen: """Handle the replaced screen. @@ -2013,6 +2017,12 @@ class App(Generic[ReturnType], DOMNode): await self._close_messages() async def _on_resize(self, event: events.Resize) -> None: + # print(self.screen, self._screen_stack) + # print(self.screen.size) + # print(self.screen.is_running) + # print((self.screen._message_queue)) + # print(self.screen._active_message) + # print(self.screen._task) event.stop() await self.screen.post_message(event) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 33544cfbd..11949d0db 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -64,6 +64,7 @@ class MessagePumpMeta(type): class MessagePump(metaclass=MessagePumpMeta): def __init__(self, parent: MessagePump | None = None) -> None: self._message_queue: Queue[Message | None] = Queue() + self._active_nessage: Message | None = None self._parent = parent self._running: bool = False self._closing: bool = False @@ -75,6 +76,7 @@ class MessagePump(metaclass=MessagePumpMeta): self._last_idle: float = time() self._max_idle: float | None = None self._mounted_event = asyncio.Event() + self._next_callbacks: list[CallbackType] = [] @property def task(self) -> Task: @@ -271,10 +273,22 @@ class MessagePump(metaclass=MessagePumpMeta): Args: callback: Callable to call next. + *args: Positional arguments to pass to the callable. + **kwargs: Keyword arguments to pass to the callable. """ message = events.Callback(self, callback=partial(callback, *args, **kwargs)) self.post_message_no_wait(message) + def call_next(self, callback: Callable, *args, **kwargs) -> None: + """Schedule a callback to run immediately after processing the current message. + + Args: + callback: Callable to run after current event. + *args: Positional arguments to pass to the callable. + **kwargs: Keyword arguments to pass to the callable. + """ + self._next_callbacks.append(partial(callback, *args, **kwargs)) + def _on_invoke_later(self, message: messages.InvokeLater) -> None: # Forward InvokeLater message to the Screen self.app.screen._invoke_later(message.callback) @@ -367,35 +381,52 @@ class MessagePump(metaclass=MessagePumpMeta): except MessagePumpClosed: break + self._active_message = message + try: - await self._dispatch_message(message) - except CancelledError: - raise + try: + await self._dispatch_message(message) + except CancelledError: + raise + except Exception as error: + self._mounted_event.set() + self.app._handle_exception(error) + break + finally: + + self._message_queue.task_done() + + current_time = time() + + # Insert idle events + if self._message_queue.empty() or ( + self._max_idle is not None + and current_time - self._last_idle > self._max_idle + ): + self._last_idle = current_time + if not self._closed: + event = events.Idle(self) + for _cls, method in self._get_dispatch_methods( + "on_idle", event + ): + try: + await invoke(method, event) + except Exception as error: + self.app._handle_exception(error) + break + finally: + self._active_message = None + + async def _flush_next_callbacks(self) -> None: + """Invoke pending callbacks in next callbacks queue.""" + callbacks = self._next_callbacks.copy() + self._next_callbacks.clear() + for callback in callbacks: + try: + await invoke(callback) except Exception as error: - self._mounted_event.set() self.app._handle_exception(error) break - finally: - - self._message_queue.task_done() - current_time = time() - - # Insert idle events - if self._message_queue.empty() or ( - self._max_idle is not None - and current_time - self._last_idle > self._max_idle - ): - self._last_idle = current_time - if not self._closed: - event = events.Idle(self) - for _cls, method in self._get_dispatch_methods( - "on_idle", event - ): - try: - await invoke(method, event) - except Exception as error: - self.app._handle_exception(error) - break async def _dispatch_message(self, message: Message) -> None: """Dispatch a message received from the message queue. @@ -412,6 +443,7 @@ class MessagePump(metaclass=MessagePumpMeta): await self.on_event(message) else: await self._on_message(message) + await self._flush_next_callbacks() def _get_dispatch_methods( self, method_name: str, message: Message diff --git a/src/textual/screen.py b/src/textual/screen.py index 0d2b1dae3..c08485011 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -367,19 +367,20 @@ class Screen(Widget): # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() - async with self.app._dom_lock: - if self.is_current: - if self._layout_required: - self._refresh_layout() - self._layout_required = False - self._dirty_widgets.clear() - if self._repaint_required: - self._dirty_widgets.clear() - self._dirty_widgets.add(self) - self._repaint_required = False + if self.is_current: + async with self.app._dom_lock: + if self.is_current: + if self._layout_required: + self._refresh_layout() + self._layout_required = False + self._dirty_widgets.clear() + if self._repaint_required: + self._dirty_widgets.clear() + self._dirty_widgets.add(self) + self._repaint_required = False - if self._dirty_widgets: - self.update_timer.resume() + if self._dirty_widgets: + self.update_timer.resume() # The Screen is idle - a good opportunity to invoke the scheduled callbacks await self._invoke_and_clear_callbacks() @@ -423,6 +424,7 @@ class Screen(Widget): def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: """Refresh the layout (can change size and positions of widgets).""" + print("Screen._refresh_layout", size, full) size = self.outer_size if size is None else size if not size: return diff --git a/src/textual/widget.py b/src/textual/widget.py index 1088e3b42..e1979d548 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -91,6 +91,9 @@ class AwaitMount: self._parent = parent self._widgets = widgets + async def __call__(self) -> None: + await self + def __await__(self) -> Generator[None, None, None]: async def await_mount() -> None: if self._widgets: @@ -606,7 +609,9 @@ class Widget(DOMNode): parent, *widgets, before=insert_before, after=insert_after ) - return AwaitMount(self, mounted) + await_mount = AwaitMount(self, mounted) + self.call_next(await_mount) + return await_mount def move_child( self, diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index fd8353ab6..24e6826c0 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -55,6 +55,7 @@ class Footer(Widget): async def watch_highlight_key(self, value) -> None: """If highlight key changes we need to regenerate the text.""" self._key_text = None + self.refresh() def on_mount(self) -> None: watch(self.screen, "focused", self._focus_changed)