init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1,133 @@
|
||||
"""Desktop notifications with buttons, over org.freedesktop.Notifications.
|
||||
|
||||
Qt's `QSystemTrayIcon.showMessage` can only show text. The real spec — which
|
||||
KDE, GNOME and anything using a freedesktop daemon implement — takes *actions*,
|
||||
so "Pomodoro finished" can carry [Start break] [Skip] [+5 min] and act without
|
||||
you finding the window. Notifications are also replaced in place (the same id),
|
||||
so a running timer never stacks up a column of toasts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Callable
|
||||
|
||||
from PyQt6.QtCore import QMetaType, QObject, QVariant, pyqtSlot
|
||||
from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SERVICE = "org.freedesktop.Notifications"
|
||||
_PATH = "/org/freedesktop/Notifications"
|
||||
_IFACE = "org.freedesktop.Notifications"
|
||||
|
||||
# Urgency levels from the freedesktop spec.
|
||||
URGENCY_LOW, URGENCY_NORMAL, URGENCY_CRITICAL = 0, 1, 2
|
||||
|
||||
|
||||
class Notifier(QObject):
|
||||
"""Sends notifications and routes their button presses back to callables."""
|
||||
|
||||
def __init__(self, app_name: str = "Kadence", parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._app_name = app_name
|
||||
self._bus = QDBusConnection.sessionBus()
|
||||
self._interface = QDBusInterface(_SERVICE, _PATH, _IFACE, self._bus)
|
||||
self._handlers: dict[int, dict[str, Callable[[], None]]] = {}
|
||||
self._last_id = 0
|
||||
self._available = self._interface.isValid()
|
||||
if self._available:
|
||||
self._bus.connect(_SERVICE, _PATH, _IFACE, "ActionInvoked", self._on_action)
|
||||
self._bus.connect(_SERVICE, _PATH, _IFACE, "NotificationClosed", self._on_closed)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
def capabilities(self) -> list[str]:
|
||||
if not self._available:
|
||||
return []
|
||||
reply = self._interface.call("GetCapabilities")
|
||||
arguments = reply.arguments()
|
||||
return list(arguments[0]) if arguments else []
|
||||
|
||||
def notify(
|
||||
self,
|
||||
summary: str,
|
||||
body: str = "",
|
||||
*,
|
||||
actions: dict[str, tuple[str, Callable[[], None]]] | None = None,
|
||||
urgency: int = URGENCY_NORMAL,
|
||||
timeout_ms: int = 8000,
|
||||
replace: bool = True,
|
||||
icon: str = "kadence",
|
||||
sound: bool = True,
|
||||
) -> int:
|
||||
"""Show a notification. `actions` maps key -> (label, callback)."""
|
||||
if not self._available:
|
||||
return 0
|
||||
|
||||
action_list: list[str] = []
|
||||
handlers: dict[str, Callable[[], None]] = {}
|
||||
for key, (label, callback) in (actions or {}).items():
|
||||
action_list += [key, label]
|
||||
handlers[key] = callback
|
||||
|
||||
hints = {
|
||||
"urgency": QVariant(urgency),
|
||||
"desktop-entry": QVariant("kadence"),
|
||||
"x-kde-origin-name": QVariant(self._app_name),
|
||||
}
|
||||
if not sound:
|
||||
hints["suppress-sound"] = QVariant(True)
|
||||
|
||||
# The spec's signature is `susssasa{sv}i`: `replaces_id` is a uint32 and
|
||||
# `actions` is an array of strings. Python ints marshal as int32 and a
|
||||
# Python list as an array of variants, so both need spelling out or the
|
||||
# daemon rejects the call as "no such method".
|
||||
reply = self._interface.call(
|
||||
"Notify",
|
||||
self._app_name,
|
||||
QDBusArgument(self._last_id if replace else 0, QMetaType.Type.UInt.value),
|
||||
icon,
|
||||
summary,
|
||||
body,
|
||||
QDBusArgument(action_list, QMetaType.Type.QStringList.value),
|
||||
hints,
|
||||
timeout_ms,
|
||||
)
|
||||
if reply.errorName():
|
||||
log.warning("notification refused: %s — %s", reply.errorName(), reply.errorMessage())
|
||||
return 0
|
||||
|
||||
arguments = reply.arguments()
|
||||
try:
|
||||
notification_id = int(arguments[0]) if arguments else 0
|
||||
except (TypeError, ValueError):
|
||||
log.warning("unexpected Notify reply: %r", arguments)
|
||||
return 0
|
||||
|
||||
self._last_id = notification_id
|
||||
if handlers:
|
||||
self._handlers[notification_id] = handlers
|
||||
return notification_id
|
||||
|
||||
def close(self, notification_id: int) -> None:
|
||||
if self._available and notification_id:
|
||||
self._interface.call("CloseNotification", notification_id)
|
||||
|
||||
@pyqtSlot(QDBusMessage)
|
||||
def _on_action(self, message: QDBusMessage) -> None:
|
||||
arguments = message.arguments()
|
||||
if len(arguments) < 2:
|
||||
return
|
||||
notification_id, key = int(arguments[0]), str(arguments[1])
|
||||
handler = self._handlers.get(notification_id, {}).get(key)
|
||||
if handler:
|
||||
handler()
|
||||
|
||||
@pyqtSlot(QDBusMessage)
|
||||
def _on_closed(self, message: QDBusMessage) -> None:
|
||||
arguments = message.arguments()
|
||||
if arguments:
|
||||
self._handlers.pop(int(arguments[0]), None)
|
||||
Reference in new issue
Block a user