From f210474ded76d4238a688858ad4b55c041e4b96e Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Tue, 26 Nov 2024 14:32:54 +0100 Subject: [PATCH 1/2] Add on_cleared event This event is fired when a notification was closed without user interaction, e.g. because the notification timed out (DBus only, and only if supported by the notifications server; undetectable by capabilities), or because the notification was closed by another process (DBus only). --- README.md | 2 + examples/eventloop.py | 2 + examples/eventloop_handlers.py | 6 +++ examples/synchronous.py | 2 + src/desktop_notifier/backends/base.py | 9 +++++ src/desktop_notifier/backends/dbus.py | 3 ++ src/desktop_notifier/backends/winrt.py | 10 ++--- src/desktop_notifier/common.py | 7 ++++ src/desktop_notifier/main.py | 19 +++++++++ src/desktop_notifier/sync.py | 2 + tests/test_callbacks.py | 54 ++++++++++++++++++++++++++ 11 files changed, 111 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2e75bf2..1329488 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,11 @@ async def main() -> None: on_replied=lambda text: print("Brutus replied:", text), ), on_dispatched=lambda: print("Notification showing"), + on_cleared=lambda: print("Notification timed out"), on_clicked=lambda: print("Notification clicked"), on_dismissed=lambda: print("Notification dismissed"), sound=DEFAULT_SOUND, + timeout=10, ) # Run the event loop forever to respond to user interactions with the notification. diff --git a/examples/eventloop.py b/examples/eventloop.py index 0fbbce5..2d26acc 100644 --- a/examples/eventloop.py +++ b/examples/eventloop.py @@ -34,9 +34,11 @@ async def main() -> None: on_replied=lambda text: print(f"Received reply '{text}'"), ), on_dispatched=lambda: print("Notification is showing now"), + on_cleared=lambda: print("Notification timed out"), on_clicked=lambda: print("Notification was clicked"), on_dismissed=lambda: print("Notification was dismissed"), sound=DEFAULT_SOUND, + timeout=10, ) # Run the event loop forever to respond to user interactions with the notification. diff --git a/examples/eventloop_handlers.py b/examples/eventloop_handlers.py index a601b92..65eee91 100644 --- a/examples/eventloop_handlers.py +++ b/examples/eventloop_handlers.py @@ -15,6 +15,10 @@ def on_dispatched(identifier: str) -> None: print(f"Notification '{identifier}' is showing now") +def on_cleared(identifier: str) -> None: + print(f"Notification '{identifier}' was cleared without user interaction") + + def on_clicked(identifier: str) -> None: print(f"Notification '{identifier}' was clicked") @@ -34,6 +38,7 @@ def on_replied(identifier: str, reply: str) -> None: async def main() -> None: notifier = DesktopNotifier(app_name="Sample App") notifier.on_dispatched = on_dispatched + notifier.on_cleared = on_cleared notifier.on_clicked = on_clicked notifier.on_dismissed = on_dismissed notifier.on_button_pressed = on_button_pressed @@ -52,6 +57,7 @@ async def main() -> None: button_title="Send", ), sound=DEFAULT_SOUND, + timeout=10, ) # Run the event loop forever to respond to user interactions with the notification. diff --git a/examples/synchronous.py b/examples/synchronous.py index 7acae0a..37b2bfa 100644 --- a/examples/synchronous.py +++ b/examples/synchronous.py @@ -24,7 +24,9 @@ on_replied=lambda text: print("Brutus replied:", text), ), on_dispatched=lambda: print("Notification showing"), + on_cleared=lambda: print("Notification timed out"), on_clicked=lambda: print("Notification clicked"), on_dismissed=lambda: print("Notification dismissed"), sound=DEFAULT_SOUND, + timeout=10, ) diff --git a/src/desktop_notifier/backends/base.py b/src/desktop_notifier/backends/base.py index 530b4a1..46e8928 100644 --- a/src/desktop_notifier/backends/base.py +++ b/src/desktop_notifier/backends/base.py @@ -30,6 +30,7 @@ def __init__(self, app_name: str, app_icon: Icon | None = None) -> None: self._notification_cache: dict[str, Notification] = dict() self.on_dispatched: Callable[[str], Any] | None = None + self.on_cleared: Callable[[str], Any] | None = None self.on_clicked: Callable[[str], Any] | None = None self.on_dismissed: Callable[[str], Any] | None = None self.on_button_pressed: Callable[[str, str], Any] | None = None @@ -154,6 +155,14 @@ def handle_dispatched( elif self.on_dispatched: self.on_dispatched(identifier) + def handle_cleared( + self, identifier: str, notification: Notification | None = None + ) -> None: + if notification and notification.on_cleared: + notification.on_cleared() + elif self.on_cleared: + self.on_cleared(identifier) + def handle_clicked( self, identifier: str, notification: Notification | None = None ) -> None: diff --git a/src/desktop_notifier/backends/dbus.py b/src/desktop_notifier/backends/dbus.py index f12010d..5fd73ac 100644 --- a/src/desktop_notifier/backends/dbus.py +++ b/src/desktop_notifier/backends/dbus.py @@ -253,6 +253,8 @@ def _on_closed(self, nid: int, reason: int) -> None: if reason == NOTIFICATION_CLOSED_DISMISSED: self.handle_dismissed(identifier, notification) + else: + self.handle_cleared(identifier, notification) async def get_capabilities(self) -> frozenset[Capability]: if not self.interface: @@ -265,6 +267,7 @@ async def get_capabilities(self) -> frozenset[Capability]: Capability.TIMEOUT, Capability.URGENCY, Capability.ON_DISPATCHED, + Capability.ON_CLEARED, } # Capabilities supported by some notification servers. diff --git a/src/desktop_notifier/backends/winrt.py b/src/desktop_notifier/backends/winrt.py index fc9f24c..9bdade8 100644 --- a/src/desktop_notifier/backends/winrt.py +++ b/src/desktop_notifier/backends/winrt.py @@ -263,11 +263,11 @@ def _on_dismissed( notification = self._clear_notification_from_cache(sender.tag) - if ( - dismissed_args - and dismissed_args.reason == ToastDismissalReason.USER_CANCELED - ): - self.handle_dismissed(sender.tag, notification) + if dismissed_args: + # ToastDismissalReason.APPLICATION_HIDDEN and ToastDismissalReason.TIMED_OUT + # both just indicate that the toast was sent to the notifications center + if dismissed_args.reason == ToastDismissalReason.USER_CANCELED: + self.handle_dismissed(sender.tag, notification) def _on_failed( self, sender: ToastNotification | None, failed_args: ToastFailedEventArgs | None diff --git a/src/desktop_notifier/common.py b/src/desktop_notifier/common.py index a223b39..68a934c 100644 --- a/src/desktop_notifier/common.py +++ b/src/desktop_notifier/common.py @@ -229,6 +229,9 @@ class Notification: on_dispatched: Callable[[], Any] | None = None """Method to call when the notification was sent to the notifications server for display""" + on_cleared: Callable[[], Any] | None = None + """Method to call when the notification is cleared without user interaction""" + on_clicked: Callable[[], Any] | None = None """Method to call when the notification is clicked""" @@ -300,6 +303,10 @@ class Capability(Enum): ON_DISPATCHED = auto() """Supports on-dispatched callbacks""" + ON_CLEARED = auto() + """Supports distinguishing between an user closing a notification, and clearing a + notification programmatically, and consequently supports on-cleared callbacks""" + ON_CLICKED = auto() """Supports on-clicked callbacks""" diff --git a/src/desktop_notifier/main.py b/src/desktop_notifier/main.py index 2a3399b..eb7d3fe 100644 --- a/src/desktop_notifier/main.py +++ b/src/desktop_notifier/main.py @@ -218,6 +218,7 @@ async def send( buttons: Sequence[Button] = (), reply_field: ReplyField | None = None, on_dispatched: Callable[[], Any] | None = None, + on_cleared: Callable[[], Any] | None = None, on_clicked: Callable[[], Any] | None = None, on_dismissed: Callable[[], Any] | None = None, attachment: Attachment | None = None, @@ -242,6 +243,7 @@ async def send( buttons=tuple(buttons), reply_field=reply_field, on_dispatched=on_dispatched, + on_cleared=on_cleared, on_clicked=on_clicked, on_dismissed=on_dismissed, attachment=attachment, @@ -294,6 +296,23 @@ def on_dispatched(self) -> Callable[[str], Any] | None: def on_dispatched(self, handler: Callable[[str], Any] | None) -> None: self._backend.on_dispatched = handler + @property + def on_cleared(self) -> Callable[[str], Any] | None: + """ + A method to call when a notification is cleared without user interaction + (e.g. after a timeout, or if cleared by another process) + + The method must take the notification identifier as a single argument. + + If the notification itself already specifies an on_cleared handler, it will be + used instead of the class-level handler. + """ + return self._backend.on_cleared + + @on_cleared.setter + def on_cleared(self, handler: Callable[[str], Any] | None) -> None: + self._backend.on_cleared = handler + @property def on_clicked(self) -> Callable[[str], Any] | None: """ diff --git a/src/desktop_notifier/sync.py b/src/desktop_notifier/sync.py index 7e9b794..ecd81ad 100644 --- a/src/desktop_notifier/sync.py +++ b/src/desktop_notifier/sync.py @@ -97,6 +97,7 @@ def send( buttons: Sequence[Button] = (), reply_field: ReplyField | None = None, on_dispatched: Callable[[], Any] | None = None, + on_cleared: Callable[[], Any] | None = None, on_clicked: Callable[[], Any] | None = None, on_dismissed: Callable[[], Any] | None = None, attachment: Attachment | None = None, @@ -113,6 +114,7 @@ def send( buttons=tuple(buttons), reply_field=reply_field, on_dispatched=on_dispatched, + on_cleared=on_cleared, on_clicked=on_clicked, on_dismissed=on_dismissed, attachment=attachment, diff --git a/tests/test_callbacks.py b/tests/test_callbacks.py index 076f29e..4184a5b 100644 --- a/tests/test_callbacks.py +++ b/tests/test_callbacks.py @@ -45,6 +45,46 @@ async def test_dispatched_callback_called(notifier: DesktopNotifier) -> None: notification_handler.assert_called_once() +@pytest.mark.asyncio +async def test_cleared_callback_called(notifier: DesktopNotifier) -> None: + await check_supported(notifier, Capability.ON_CLEARED) + + class_handler = Mock() + notification_handler = Mock() + notifier.on_cleared = class_handler + notification = Notification( + title="Julius Caesar", + message="Et tu, Brute?", + on_cleared=notification_handler, + ) + + identifier = await notifier.send_notification(notification) + await notifier.clear(identifier) + + class_handler.assert_not_called() + notification_handler.assert_called_once() + + +@pytest.mark.asyncio +async def test_cleared_callback_not_dismissed(notifier: DesktopNotifier) -> None: + await check_supported(notifier, Capability.ON_CLEARED) + + on_cleared = Mock() + on_dismissed = Mock() + notification = Notification( + title="Julius Caesar", + message="Et tu, Brute?", + on_cleared=on_cleared, + on_dismissed=on_dismissed, + ) + + identifier = await notifier.send_notification(notification) + await notifier.clear(identifier) + + on_dismissed.assert_not_called() + on_cleared.assert_called_once() + + @pytest.mark.asyncio async def test_clicked_callback_called(notifier: DesktopNotifier) -> None: await check_supported(notifier, Capability.ON_CLICKED) @@ -163,6 +203,20 @@ async def test_dispatched_fallback_handler_called(notifier: DesktopNotifier) -> class_handler.assert_called_with(identifier) +@pytest.mark.asyncio +async def test_cleared_fallback_handler_called(notifier: DesktopNotifier) -> None: + await check_supported(notifier, Capability.ON_CLEARED) + + class_handler = Mock() + notifier.on_cleared = class_handler + notification = Notification(title="Julius Caesar", message="Et tu, Brute?") + + identifier = await notifier.send_notification(notification) + await notifier.clear(identifier) + + class_handler.assert_called_with(identifier) + + @pytest.mark.asyncio async def test_clicked_fallback_handler_called(notifier: DesktopNotifier) -> None: await check_supported(notifier, Capability.ON_CLICKED) From e87b3cadd3a054e6734c7ec9d4074c3c3e3cd90c Mon Sep 17 00:00:00 2001 From: Daniel Rudolf Date: Mon, 21 Apr 2025 18:38:23 +0200 Subject: [PATCH 2/2] Update on_cleared event docs Improve docs of the `ON_CLEARED` capability and update other docs to allow distinguishing between expired and programatically closed notifications in the future. Actually adding support for expiring notifications is out-of-scope here. --- README.md | 21 +++++++++++++-------- examples/eventloop.py | 5 ++--- examples/eventloop_handlers.py | 5 ++--- examples/synchronous.py | 19 +++++++++++-------- src/desktop_notifier/common.py | 5 +++-- src/desktop_notifier/main.py | 2 +- 6 files changed, 32 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 1329488..5e2428f 100644 --- a/README.md +++ b/README.md @@ -82,18 +82,23 @@ async def main() -> None: buttons=[ Button( title="Mark as read", - on_pressed=lambda: print("Marked as read"), - ) + on_pressed=lambda: print("Button 'Mark as read' was clicked"), + ), + Button( + title="Click me!!", + on_pressed=lambda: print("Button 'Click me!!' was clicked"), + ), ], reply_field=ReplyField( - on_replied=lambda text: print("Brutus replied:", text), + title="Reply", + button_title="Send", + on_replied=lambda text: print(f"Received reply '{text}'"), ), - on_dispatched=lambda: print("Notification showing"), - on_cleared=lambda: print("Notification timed out"), - on_clicked=lambda: print("Notification clicked"), - on_dismissed=lambda: print("Notification dismissed"), + on_dispatched=lambda: print("Notification is showing now"), + on_cleared=lambda: print("Notification was closed w/o user interaction"), + on_clicked=lambda: print("Notification was clicked"), + on_dismissed=lambda: print("Notification was dismissed by the user"), sound=DEFAULT_SOUND, - timeout=10, ) # Run the event loop forever to respond to user interactions with the notification. diff --git a/examples/eventloop.py b/examples/eventloop.py index 2d26acc..083e738 100644 --- a/examples/eventloop.py +++ b/examples/eventloop.py @@ -34,11 +34,10 @@ async def main() -> None: on_replied=lambda text: print(f"Received reply '{text}'"), ), on_dispatched=lambda: print("Notification is showing now"), - on_cleared=lambda: print("Notification timed out"), + on_cleared=lambda: print("Notification was closed w/o user interaction"), on_clicked=lambda: print("Notification was clicked"), - on_dismissed=lambda: print("Notification was dismissed"), + on_dismissed=lambda: print("Notification was dismissed by the user"), sound=DEFAULT_SOUND, - timeout=10, ) # Run the event loop forever to respond to user interactions with the notification. diff --git a/examples/eventloop_handlers.py b/examples/eventloop_handlers.py index 65eee91..38e252f 100644 --- a/examples/eventloop_handlers.py +++ b/examples/eventloop_handlers.py @@ -16,7 +16,7 @@ def on_dispatched(identifier: str) -> None: def on_cleared(identifier: str) -> None: - print(f"Notification '{identifier}' was cleared without user interaction") + print(f"Notification '{identifier}' was closed w/o user interaction") def on_clicked(identifier: str) -> None: @@ -24,7 +24,7 @@ def on_clicked(identifier: str) -> None: def on_dismissed(identifier: str) -> None: - print(f"Notification '{identifier}' was dismissed") + print(f"Notification '{identifier}' was dismissed by the user") def on_button_pressed(identifier: str, button_identifier: str) -> None: @@ -57,7 +57,6 @@ async def main() -> None: button_title="Send", ), sound=DEFAULT_SOUND, - timeout=10, ) # Run the event loop forever to respond to user interactions with the notification. diff --git a/examples/synchronous.py b/examples/synchronous.py index 37b2bfa..d43c2b7 100644 --- a/examples/synchronous.py +++ b/examples/synchronous.py @@ -15,18 +15,21 @@ buttons=[ Button( title="Mark as read", - on_pressed=lambda: print("Marked as read"), - ) + on_pressed=lambda: print("Button 'Mark as read' was clicked"), + ), + Button( + title="Click me!!", + on_pressed=lambda: print("Button 'Click me!!' was clicked"), + ), ], reply_field=ReplyField( title="Reply", button_title="Send", - on_replied=lambda text: print("Brutus replied:", text), + on_replied=lambda text: print(f"Received reply '{text}'"), ), - on_dispatched=lambda: print("Notification showing"), - on_cleared=lambda: print("Notification timed out"), - on_clicked=lambda: print("Notification clicked"), - on_dismissed=lambda: print("Notification dismissed"), + on_dispatched=lambda: print("Notification is showing now"), + on_cleared=lambda: print("Notification was closed w/o user interaction"), + on_clicked=lambda: print("Notification was clicked"), + on_dismissed=lambda: print("Notification was dismissed by the user"), sound=DEFAULT_SOUND, - timeout=10, ) diff --git a/src/desktop_notifier/common.py b/src/desktop_notifier/common.py index 68a934c..c89e308 100644 --- a/src/desktop_notifier/common.py +++ b/src/desktop_notifier/common.py @@ -304,8 +304,9 @@ class Capability(Enum): """Supports on-dispatched callbacks""" ON_CLEARED = auto() - """Supports distinguishing between an user closing a notification, and clearing a - notification programmatically, and consequently supports on-cleared callbacks""" + """Supports on-cleared callbacks, which are called if a notification wasn't + cleared by user interaction, but programmatically; platforms not supporting + this distinction will call on-dismissed callbacks instead""" ON_CLICKED = auto() """Supports on-clicked callbacks""" diff --git a/src/desktop_notifier/main.py b/src/desktop_notifier/main.py index eb7d3fe..4f1175a 100644 --- a/src/desktop_notifier/main.py +++ b/src/desktop_notifier/main.py @@ -300,7 +300,7 @@ def on_dispatched(self, handler: Callable[[str], Any] | None) -> None: def on_cleared(self) -> Callable[[str], Any] | None: """ A method to call when a notification is cleared without user interaction - (e.g. after a timeout, or if cleared by another process) + (e.g. if cleared by another process) The method must take the notification identifier as a single argument.