This commit is contained in:
timka123 committed 2026-10-18 18:02:05 +03:00
commit 17a0a3c4c9
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)