From 8a9746df8fb413c4f9311dfd9a4047687259591d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 22 Apr 2024 17:05:46 +0100 Subject: [PATCH 1/2] signal arguments --- CHANGELOG.md | 4 ++++ src/textual/app.py | 8 ++++---- src/textual/screen.py | 8 +++++--- src/textual/signal.py | 32 +++++++++++++++++++++----------- tests/test_signal.py | 43 +++++++++++++++++++++++++++++++++++++++---- tests/test_suspend.py | 4 ++-- 6 files changed, 75 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3156174459..0dc54833b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed `TextArea` to end mouse selection only if currently selecting https://github.com/Textualize/textual/pull/4436 +### Changed + +- Added argument to signal callbacks + ## [0.57.1] - 2024-04-20 ### Fixed diff --git a/src/textual/app.py b/src/textual/app.py index 6a16f41fff..a5dba1e2cf 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -603,7 +603,7 @@ def __init__( self._original_stderr = sys.__stderr__ """The original stderr stream (before redirection etc).""" - self.app_suspend_signal = Signal(self, "app-suspend") + self.app_suspend_signal: Signal[App] = Signal(self, "app-suspend") """The signal that is published when the app is suspended. When [`App.suspend`][textual.app.App.suspend] is called this signal @@ -611,7 +611,7 @@ def __init__( [subscribe][textual.signal.Signal.subscribe] to this signal to perform work before the suspension takes place. """ - self.app_resume_signal = Signal(self, "app-resume") + self.app_resume_signal: Signal[App] = Signal(self, "app-resume") """The signal that is published when the app is resumed after a suspend. When the app is resumed after a @@ -3569,12 +3569,12 @@ def action_command_palette(self) -> None: def _suspend_signal(self) -> None: """Signal that the application is being suspended.""" - self.app_suspend_signal.publish() + self.app_suspend_signal.publish(self) @on(Driver.SignalResume) def _resume_signal(self) -> None: """Signal that the application is being resumed from a suspension.""" - self.app_resume_signal.publish() + self.app_resume_signal.publish(self) @contextmanager def suspend(self) -> Iterator[None]: diff --git a/src/textual/screen.py b/src/textual/screen.py index 861a298976..c4696d3357 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -221,7 +221,9 @@ def __init__( self.title = self.TITLE self.sub_title = self.SUB_TITLE - self.screen_layout_refresh_signal = Signal(self, "layout-refresh") + self.screen_layout_refresh_signal: Signal[Screen] = Signal( + self, "layout-refresh" + ) """The signal that is published when the screen's layout is refreshed.""" @property @@ -861,7 +863,7 @@ def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> Non self._compositor_refresh() if self.app._dom_ready: - self.screen_layout_refresh_signal.publish() + self.screen_layout_refresh_signal.publish(self.screen) else: self.app.post_message(events.Ready()) self.app._dom_ready = True @@ -966,7 +968,7 @@ def _clear_tooltip(self) -> None: self._tooltip_timer.stop() tooltip.display = False - def _maybe_clear_tooltip(self) -> None: + def _maybe_clear_tooltip(self, _) -> None: """Check if the widget under the mouse cursor still pertains to the tooltip. If they differ, the tooltip will be removed. diff --git a/src/textual/signal.py b/src/textual/signal.py index a1a1d80b8a..4b4c922a52 100644 --- a/src/textual/signal.py +++ b/src/textual/signal.py @@ -9,7 +9,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, TypeVar, Union from weakref import WeakKeyDictionary import rich.repr @@ -17,16 +17,21 @@ from textual import log if TYPE_CHECKING: - from ._types import IgnoreReturnCallbackType from .dom import DOMNode +SignalT = TypeVar("SignalT") + +SignalCallbackType = Union[ + Callable[[SignalT], Awaitable[Any]], Callable[[SignalT], Any] +] + class SignalError(Exception): """Raised for Signal errors.""" @rich.repr.auto(angular=True) -class Signal: +class Signal(Generic[SignalT]): """A signal that a widget may subscribe to, in order to invoke callbacks when an associated event occurs.""" def __init__(self, owner: DOMNode, name: str) -> None: @@ -38,23 +43,23 @@ def __init__(self, owner: DOMNode, name: str) -> None: """ self._owner = owner self._name = name - self._subscriptions: WeakKeyDictionary[ - DOMNode, list[IgnoreReturnCallbackType] - ] = WeakKeyDictionary() + self._subscriptions: WeakKeyDictionary[DOMNode, list[SignalCallbackType]] = ( + WeakKeyDictionary() + ) def __rich_repr__(self) -> rich.repr.Result: yield "owner", self._owner yield "name", self._name yield "subscriptions", list(self._subscriptions.keys()) - def subscribe(self, node: DOMNode, callback: IgnoreReturnCallbackType) -> None: + def subscribe(self, node: DOMNode, callback: SignalCallbackType) -> None: """Subscribe a node to this signal. When the signal is published, the callback will be invoked. Args: node: Node to subscribe. - callback: A callback function which takes no arguments, and returns anything (return type ignored). + callback: A callback function which takes a single argument and returns anything (return type ignored). Raises: SignalError: Raised when subscribing a non-mounted widget. @@ -75,8 +80,13 @@ def unsubscribe(self, node: DOMNode) -> None: """ self._subscriptions.pop(node, None) - def publish(self) -> None: - """Publish the signal (invoke subscribed callbacks).""" + def publish(self, data: SignalT) -> None: + """Publish the signal (invoke subscribed callbacks). + + Args: + data: An argument to pass to the callbacks. + + """ for node, callbacks in list(self._subscriptions.items()): if not node.is_running: @@ -86,7 +96,7 @@ def publish(self) -> None: # Call callbacks for callback in callbacks: try: - callback() + callback(data) except Exception as error: log.error( f"error publishing signal to {node} ignored (callback={callback}); {error}" diff --git a/tests/test_signal.py b/tests/test_signal.py index 7833ae70f4..c8562c4ab4 100644 --- a/tests/test_signal.py +++ b/tests/test_signal.py @@ -11,7 +11,7 @@ async def test_signal(): class TestLabel(Label): def on_mount(self) -> None: - def signal_result(): + def signal_result(_): nonlocal called called += 1 @@ -22,14 +22,14 @@ class TestApp(App): BINDINGS = [("space", "signal")] def __init__(self) -> None: - self.test_signal = Signal(self, "coffee ready") + self.test_signal: Signal[str] = Signal(self, "coffee ready") super().__init__() def compose(self) -> ComposeResult: yield TestLabel() def action_signal(self) -> None: - self.test_signal.publish() + self.test_signal.publish("foo") app = TestApp() async with app.run_test() as pilot: @@ -65,7 +65,7 @@ def test_signal_errors(): label = Label() # Check subscribing a non-running widget is an error with pytest.raises(SignalError): - test_signal.subscribe(label, lambda: None) + test_signal.subscribe(label, lambda _: None) def test_repr(): @@ -73,3 +73,38 @@ def test_repr(): app = App() test_signal = Signal(app, "test") assert isinstance(repr(test_signal), str) + + +async def test_signal_parameters(): + str_result: str | None = None + int_result: int | None = None + + class TestApp(App): + BINDINGS = [("space", "signal")] + + def __init__(self) -> None: + self.str_signal: Signal[str] = Signal(self, "str") + self.int_signal: Signal[int] = Signal(self, "int") + super().__init__() + + def action_signal(self) -> None: + self.str_signal.publish("foo") + self.int_signal.publish(3) + + def on_mount(self) -> None: + def on_str(my_str): + nonlocal str_result + str_result = my_str + + def on_int(my_int): + nonlocal int_result + int_result = my_int + + self.str_signal.subscribe(self, on_str) + self.int_signal.subscribe(self, on_int) + + app = TestApp() + async with app.run_test() as pilot: + await pilot.press("space") + assert str_result == "foo" + assert int_result == 3 diff --git a/tests/test_suspend.py b/tests/test_suspend.py index 8f4534dce1..7ed223724d 100644 --- a/tests/test_suspend.py +++ b/tests/test_suspend.py @@ -39,11 +39,11 @@ def resume_application_mode(self) -> None: calls.add("resume") class SuspendApp(App[None]): - def on_suspend(self) -> None: + def on_suspend(self, _) -> None: nonlocal calls calls.add("suspend signal") - def on_resume(self) -> None: + def on_resume(self, _) -> None: nonlocal calls calls.add("resume signal") From 9758bca440a7dc30f86778052c181883a651b5fa Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 22 Apr 2024 17:07:38 +0100 Subject: [PATCH 2/2] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc54833b4..0bc0b80d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed -- Added argument to signal callbacks +- Added argument to signal callbacks https://github.com/Textualize/textual/pull/4438 ## [0.57.1] - 2024-04-20