diff --git a/README.md b/README.md index 2e75bf2..5e2428f 100644 --- a/README.md +++ b/README.md @@ -82,15 +82,22 @@ 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_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, ) diff --git a/examples/eventloop.py b/examples/eventloop.py index 0fbbce5..083e738 100644 --- a/examples/eventloop.py +++ b/examples/eventloop.py @@ -34,8 +34,9 @@ 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 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, ) diff --git a/examples/eventloop_handlers.py b/examples/eventloop_handlers.py index a601b92..38e252f 100644 --- a/examples/eventloop_handlers.py +++ b/examples/eventloop_handlers.py @@ -15,12 +15,16 @@ def on_dispatched(identifier: str) -> None: print(f"Notification '{identifier}' is showing now") +def on_cleared(identifier: str) -> None: + print(f"Notification '{identifier}' was closed w/o user interaction") + + def on_clicked(identifier: str) -> None: print(f"Notification '{identifier}' was clicked") 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: @@ -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 diff --git a/examples/synchronous.py b/examples/synchronous.py index 7acae0a..d43c2b7 100644 --- a/examples/synchronous.py +++ b/examples/synchronous.py @@ -15,16 +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_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, ) diff --git a/src/desktop_notifier/backends/base.py b/src/desktop_notifier/backends/base.py index a670ffa..c81a59f 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 @@ -160,6 +161,13 @@ def handle_dispatched(self, identifier: str) -> None: elif self.on_dispatched: self.on_dispatched(identifier) + def handle_cleared(self, identifier: str) -> None: + notification = self._clear_notification_from_cache(identifier) + if notification and notification.on_cleared: + notification.on_cleared() + elif self.on_cleared: + self.on_cleared(identifier) + def handle_clicked(self, identifier: str) -> None: notification = self._clear_notification_from_cache(identifier) if notification and notification.on_clicked: diff --git a/src/desktop_notifier/backends/dbus.py b/src/desktop_notifier/backends/dbus.py index ca2d9f9..c42fa12 100644 --- a/src/desktop_notifier/backends/dbus.py +++ b/src/desktop_notifier/backends/dbus.py @@ -270,6 +270,8 @@ def _on_closed(self, nid: int, reason: int) -> None: if reason == NOTIFICATION_CLOSED_DISMISSED: self.handle_dismissed(identifier) + else: + self.handle_cleared(identifier) async def _get_capabilities(self) -> frozenset[Capability]: if not self.interface: @@ -282,6 +284,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 9f854e6..9766bb7 100644 --- a/src/desktop_notifier/backends/winrt.py +++ b/src/desktop_notifier/backends/winrt.py @@ -261,11 +261,11 @@ def _on_dismissed( if not sender: return - if ( - dismissed_args - and dismissed_args.reason == ToastDismissalReason.USER_CANCELED - ): - self.handle_dismissed(sender.tag) + 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) 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 c2fba64..a37e609 100644 --- a/src/desktop_notifier/common.py +++ b/src/desktop_notifier/common.py @@ -240,6 +240,9 @@ class Notification: on_dispatched: Callable[[], Any] | None = field(default=None, repr=False) """Method to call when the notification was sent to the notifications server for display""" + on_cleared: Callable[[], Any] | None = field(default=None, repr=False) + """Method to call when the notification is cleared without user interaction""" + on_clicked: Callable[[], Any] | None = field(default=None, repr=False) """Method to call when the notification is clicked""" @@ -309,6 +312,11 @@ class Capability(Enum): ON_DISPATCHED = auto() """Supports on-dispatched callbacks""" + ON_CLEARED = auto() + """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 ee635de..1c0e203 100644 --- a/src/desktop_notifier/main.py +++ b/src/desktop_notifier/main.py @@ -216,6 +216,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, @@ -240,6 +241,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, @@ -290,6 +292,26 @@ 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. 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. + + ..note:: On Linux, notifications servers might signal events of other + applications as well and will lead to executing this callback. + """ + 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 ab17fdd..06fe5d6 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, @@ -152,6 +154,15 @@ def on_dispatched(self) -> Callable[[str], Any] | None: def on_dispatched(self, handler: Callable[[str], Any] | None) -> None: self._async_api.on_dispatched = handler + @property + def on_cleared(self) -> Callable[[str], Any] | None: + """See :meth:`desktop_notifier.main.DesktopNotifier.on_cleared`""" + return self._async_api.on_cleared + + @on_cleared.setter + def on_cleared(self, handler: Callable[[str], Any] | None) -> None: + self._async_api.on_cleared = handler + @property def on_clicked(self) -> Callable[[str], Any] | None: """See :meth:`desktop_notifier.main.DesktopNotifier.on_clicked`""" 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)