diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8a00e1fd..671f519b4d 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 + +### Added + +- Added `immediate` switch to `Signal.publish` + ## [0.63.3] - 2024-05-24 ### Fixed diff --git a/src/textual/dom.py b/src/textual/dom.py index bc42f0b6ce..06bd9f2265 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1505,7 +1505,7 @@ def refresh_bindings(self) -> None: See [actions](/guide/actions#dynamic-actions) for how to use this method. """ - self.call_later(self.screen.refresh_bindings) + self.screen.refresh_bindings() async def action_toggle(self, attribute_name: str) -> None: """Toggle an attribute on the node. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 807fd2d35f..8b7fd18dca 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -245,7 +245,7 @@ def app(self) -> "App[object]": return node @property - def _is_linked_to_app(self) -> bool: + def is_attached(self) -> bool: """Is this node linked to the app through the DOM?""" node: MessagePump | None = self @@ -275,19 +275,6 @@ def log(self) -> Logger: """ return self.app._logger - @property - def is_attached(self) -> bool: - """Is the node attached to the app via the DOM?""" - from .app import App - - node = self - - while not isinstance(node, App): - if node._parent is None: - return False - node = node._parent - return True - def _attach(self, parent: MessagePump) -> None: """Set the parent, and therefore attach this node to the tree. diff --git a/src/textual/screen.py b/src/textual/screen.py index b8de2a2b89..78a4c7f9c0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -739,7 +739,9 @@ def _extend_compose(self, widgets: list[Widget]) -> None: def _on_mount(self, event: events.Mount) -> None: """Set up the tooltip-clearing signal when we mount.""" - self.screen_layout_refresh_signal.subscribe(self, self._maybe_clear_tooltip) + self.screen_layout_refresh_signal.subscribe( + self, self._maybe_clear_tooltip, immediate=True + ) async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) diff --git a/src/textual/signal.py b/src/textual/signal.py index 4701478992..8fe5d43a23 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -52,7 +52,12 @@ def __rich_repr__(self) -> rich.repr.Result: yield "name", self._name yield "subscriptions", list(self._subscriptions.keys()) - def subscribe(self, node: MessagePump, callback: SignalCallbackType) -> None: + def subscribe( + self, + node: MessagePump, + callback: SignalCallbackType, + immediate: bool = False, + ) -> None: """Subscribe a node to this signal. When the signal is published, the callback will be invoked. @@ -60,17 +65,32 @@ def subscribe(self, node: MessagePump, callback: SignalCallbackType) -> None: Args: node: Node to subscribe. callback: A callback function which takes a single argument and returns anything (return type ignored). + immediate: Invoke the callback immediately on publish if `True`, otherwise post it to the DOM node to be + called once existing messages have been processed. Raises: SignalError: Raised when subscribing a non-mounted widget. """ + if not node.is_running: raise SignalError( f"Node must be running to subscribe to a signal (has {node} been mounted)?" ) + + if immediate: + + def signal_callback(data: object): + """Invoke the callback immediately.""" + callback(data) + + else: + + def signal_callback(data: object): + """Post the callback to the node, to call at the next opertunity.""" + node.call_next(callback, data) + callbacks = self._subscriptions.setdefault(node, []) - if callback not in callbacks: - callbacks.append(callback) + callbacks.append(signal_callback) def unsubscribe(self, node: MessagePump) -> None: """Unsubscribe a node from this signal. diff --git a/src/textual/widget.py b/src/textual/widget.py index 3c474f3ce2..029e411905 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -934,7 +934,7 @@ def mount( Only one of ``before`` or ``after`` can be provided. If both are provided a ``MountError`` will be raised. """ - if not self._is_linked_to_app: + if not self.is_attached: raise MountError(f"Can't mount widget(s) before {self!r} is mounted") # Check for duplicate IDs in the incoming widgets ids_to_mount = [widget.id for widget in widgets if widget.id is not None] @@ -1126,7 +1126,7 @@ async def recompose(self) -> None: if self._parent is not None: async with self.batch(): await self.query("*").exclude(".-textual-system").remove() - if self._is_linked_to_app: + if self.is_attached: await self.mount_all(compose(self)) def _post_register(self, app: App) -> None: diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 214fba314c..3dffdb33b6 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections import defaultdict +from typing import TYPE_CHECKING import rich.repr from rich.text import Text @@ -11,6 +12,9 @@ from ..reactive import reactive from ..widget import Widget +if TYPE_CHECKING: + from ..screen import Screen + @rich.repr.auto class FooterKey(Widget): @@ -157,9 +161,9 @@ def compose(self) -> ComposeResult: ) def on_mount(self) -> None: - def bindings_changed(screen) -> None: + async def bindings_changed(screen: Screen) -> None: if screen is self.screen: - self.call_next(self.recompose) + await self.recompose() self.screen.bindings_updated_signal.subscribe(self, bindings_changed) diff --git a/tests/test_suspend.py b/tests/test_suspend.py index 7ed223724d..b2d95cff0d 100644 --- a/tests/test_suspend.py +++ b/tests/test_suspend.py @@ -48,8 +48,8 @@ def on_resume(self, _) -> None: calls.add("resume signal") def on_mount(self) -> None: - self.app_suspend_signal.subscribe(self, self.on_suspend) - self.app_resume_signal.subscribe(self, self.on_resume) + self.app_suspend_signal.subscribe(self, self.on_suspend, immediate=True) + self.app_resume_signal.subscribe(self, self.on_resume, immediate=True) async with SuspendApp(driver_class=HeadlessSuspendDriver).run_test( headless=False