"""
Notification Bell Widget for Qtile.
A widget that displays a bell icon which changes color based on notification status:
- Gray when there are no notifications
- Red with a count badge when there are notifications
This widget listens to DBus notifications to track notification count.
Optionally persists notification count across config reloads using StateStorage.
Clicking the bell opens a full-screen popup showing all notifications with a trash bin to clear them.
The widget can use a shared NotificationCenter instance or manage its own notifications.
"""
import time
from datetime import datetime
from libqtile.widget import base
from libqtile.log_utils import logger
[docs]
class NotificationBell(base._TextBox):
"""
A bell icon widget that indicates notification status.
The icon is gray (no notifications) or red (with count) when notifications exist.
Usage in config:
from qtile_expanded.widgets import NotificationBell
widget_list = [
NotificationBell(),
# ... other widgets
]
Attributes:
notifications_count: Current count of notifications (default: 0)
notifications: List of notification dicts (default: [])
icon_no_notifications: Icon to display when no notifications (default: bell outline)
icon_with_notifications: Icon to display when notifications exist (default: bell outline)
color_no_notifications: Color when no notifications (default: "#888888")
color_with_notifications: Color when notifications exist (default: "#ff0000")
show_count: Whether to show notification count badge (default: True)
count_format: Format string for count display (default: "{count}")
popup_enabled: Whether clicking opens a popup (default: True)
use_notification_center: Use shared NotificationCenter (default: False)
"""
defaults = [
("notifications_count", 0, "Current count of notifications."),
("notifications", [], "List of notification dicts."),
("icon_no_notifications", "\u23fa", "Icon when no notifications (FA bell outline)."),
("icon_with_notifications", "\u23fa", "Icon when notifications exist (FA bell outline)."),
("color_no_notifications", "#888888", "Color when no notifications."),
("color_with_notifications", "#ff0000", "Color when notifications exist."),
("show_count", True, "Whether to show notification count badge."),
("count_format", "{count}", "Format string for count display."),
("font", "FontAwesome", "Font to use for icons."),
("fontsize", 14, "Font size."),
("padding", 3, "Padding around the icon."),
("persist_state", True, "Whether to persist notifications across reloads."),
("storage_namespace", "notification_bell", "Namespace for state storage."),
("popup_enabled", True, "Whether clicking opens a popup."),
("popup_background", "#1a1a2e", "Popup background color."),
("popup_foreground", "#eeeeee", "Popup text color."),
("popup_max_notifications", 10, "Max notifications to show in popup."),
("click_behavior", "toggle", "Click behavior: 'toggle' or 'open'. 'toggle' opens/closes popup, 'open' always opens."),
("use_notification_center", False, "Use shared NotificationCenter instance instead of own state."),
]
[docs]
def __init__(self, **config):
"""Initialize the NotificationBell widget."""
super().__init__("", **config)
self.add_defaults(NotificationBell.defaults)
self.notifications_count = 0
self.notifications = []
self.storage = None
self.popup = None
self.qtile = None
self._notification_center = None
# Use NotificationCenter or own state
if self.use_notification_center:
self._setup_notification_center()
else:
# Set up own state persistence if enabled
if self.persist_state:
self._setup_storage()
# Try to connect to notification daemon via DBus
self._setup_dbus_listener()
def _setup_notification_center(self):
"""Set up connection to NotificationCenter."""
try:
from qtile_expanded.extensions.notification_center import NotificationCenter
self._notification_center = NotificationCenter.get_instance()
# Sync initial state
self._sync_from_center()
except ImportError:
logger.warning(
"NotificationBell: qtile_expanded.extensions.notification_center not available, "
"falling back to own state"
)
self._notification_center = None
# Fall back to own state
if self.persist_state:
self._setup_storage()
self._setup_dbus_listener()
def _sync_from_center(self):
"""Sync notifications from NotificationCenter."""
if self._notification_center:
self.notifications = list(self._notification_center.notifications)
self.notifications_count = len(self.notifications)
self.update_text()
def _setup_storage(self):
"""Set up persistent storage for notifications."""
try:
from qtile_expanded.storage import StateStorage
self.storage = StateStorage(self.storage_namespace)
# Load persisted notifications
self.notifications = self.storage.get("notifications", [])
self.notifications_count = len(self.notifications)
self.update_text()
logger.info(
f"NotificationBell: Loaded {self.notifications_count} notifications from storage"
)
except ImportError:
logger.warning(
"NotificationBell: qtile_expanded.storage not available, "
"state persistence disabled"
)
self.storage = None
except Exception as e:
logger.warning(f"NotificationBell: Could not set up storage: {e}")
self.storage = None
def _save_state(self):
"""Save notifications to persistent storage."""
if self.storage is not None:
self.storage.set("notifications", self.notifications)
def _setup_dbus_listener(self):
"""Set up DBus listener for notifications."""
# If using NotificationCenter, it already handles DBus
if self.use_notification_center and self._notification_center:
return
try:
import dbus
from dbus.mainloop.glib import DBusGMainLoop
DBusGMainLoop(set_as_default=True)
bus = dbus.SessionBus()
# Listen to org.freedesktop.Notifications interface
bus.add_signal_receiver(
self._on_notification_received,
signal_name="Notify",
dbus_interface="org.freedesktop.Notifications",
)
logger.info("NotificationBell: DBus notification listener started")
except ImportError:
logger.warning(
"NotificationBell: dbus-python not installed. "
"Notification count will not update automatically. "
"You can still set notifications manually."
)
except Exception as e:
logger.warning(f"NotificationBell: Could not set up DBus listener: {e}")
def _on_notification_received(self, *args, **kwargs):
"""Handle incoming notification signal from DBus."""
# If using NotificationCenter, it already handles notifications
if self.use_notification_center and self._notification_center:
self._sync_from_center()
return
# args typically: app_name, replaces_id, app_icon, summary, body, actions, hints, expire_timeout
# Extract notification data from args
app_name = args[0] if len(args) > 0 else "Unknown"
replaces_id = args[1] if len(args) > 1 else 0
app_icon = args[2] if len(args) > 2 else None
summary = args[3] if len(args) > 3 else ""
body = args[4] if len(args) > 4 else ""
hints = args[6] if len(args) > 6 else {}
# Create notification dict
notification = {
"app_name": app_name,
"summary": summary,
"body": body,
"icon": app_icon,
"urgency": 1, # Default urgency
"timestamp": datetime.now().isoformat(),
"id": replaces_id or int(time.time() * 1000),
}
# Check for urgency in hints
if hints and "urgency" in hints:
try:
notification["urgency"] = int(hints["urgency"])
except (ValueError, TypeError):
pass
# Add to notifications list
self.notifications.append(notification)
self.notifications_count = len(self.notifications)
self.update_text()
self._save_state()
logger.debug(f"NotificationBell: Received notification from {app_name}, count={self.notifications_count}")
[docs]
def add_notification(self, summary="", body="", app_name="", **kwargs):
"""
Manually add a notification. Useful when DBus is not available.
Args:
summary: Notification summary/title
body: Notification body text
app_name: Application name
**kwargs: Additional notification data (icon, urgency, etc.)
"""
# If using NotificationCenter, delegate to it
if self.use_notification_center and self._notification_center:
self._notification_center.add_notification(
summary=summary, body=body, app_name=app_name, **kwargs
)
self._sync_from_center()
return
notification = {
"app_name": app_name,
"summary": summary,
"body": body,
"timestamp": datetime.now().isoformat(),
"id": int(time.time() * 1000) + len(self.notifications),
}
notification.update(kwargs)
self.notifications.append(notification)
self.notifications_count = len(self.notifications)
self.update_text()
self._save_state()
[docs]
def clear_notifications(self):
"""Clear all notifications."""
if self.use_notification_center and self._notification_center:
self._notification_center.clear()
self._sync_from_center()
else:
self.notifications = []
self.notifications_count = 0
self.update_text()
self._save_state()
# Close popup if open
if self.popup and hasattr(self.popup, 'win') and self.popup.win.visible:
self.popup.hide()
[docs]
def set_notifications(self, notifications):
"""
Set the notifications list directly.
Args:
notifications: List of notification dicts
"""
self.notifications = list(notifications) if notifications else []
self.notifications_count = len(self.notifications)
self.update_text()
self._save_state()
[docs]
def dismiss_notification(self, index):
"""
Dismiss a specific notification by index.
Args:
index: Index of notification to dismiss
"""
if self.use_notification_center and self._notification_center:
return self._notification_center.dismiss_notification(index)
if 0 <= index < len(self.notifications):
del self.notifications[index]
self.notifications_count = len(self.notifications)
self.update_text()
self._save_state()
return True
return False
[docs]
def update_text(self):
"""Update the displayed text based on notification count."""
if self.notifications_count > 0:
icon = self.icon_with_notifications
color = self.color_with_notifications
if self.show_count:
count_str = self.count_format.format(count=self.notifications_count)
text = f"{icon} {count_str}"
else:
text = icon
else:
icon = self.icon_no_notifications
color = self.color_no_notifications
text = icon
self.text = text
self.color = color
self.draw()
def _show_popup(self):
"""Show the notification popup."""
if not self.popup_enabled:
return
# Get qtile instance if not already set
if self.qtile is None:
from libqtile import qtile as q
self.qtile = q
# Use NotificationCenter popup if available
if self.use_notification_center and self._notification_center:
self._notification_center.show_popup(
self.qtile,
background=self.popup_background,
foreground=self.popup_foreground,
max_notifications=self.popup_max_notifications,
)
return
# Create popup if doesn't exist
if self.popup is None:
from qtile_expanded.extensions.popup import NotificationPopup
self.popup = NotificationPopup(
self.qtile,
notifications=self.notifications,
on_close=self._on_popup_close,
on_clear=self.clear_notifications,
on_dismiss=self.dismiss_notification,
background=self.popup_background,
foreground=self.popup_foreground,
max_notifications=self.popup_max_notifications,
)
# Update notifications and show
self.popup.update_notifications(self.notifications)
self.popup.show()
def _hide_popup(self):
"""Hide the notification popup."""
if self.popup and hasattr(self.popup, 'win') and self.popup.win.visible:
self.popup.hide()
elif self.use_notification_center and self._notification_center:
self._notification_center.hide_popup()
def _toggle_popup(self):
"""Toggle the notification popup."""
if self.popup and hasattr(self.popup, 'win') and self.popup.win.visible:
self._hide_popup()
else:
self._show_popup()
def _on_popup_close(self):
"""Handle popup close event."""
pass
[docs]
def calculate_length(self):
"""Calculate the length of the widget text."""
return len(self.text) * self.fontsize + self.padding * 2
[docs]
def info(self):
"""Return info about the widget for debugging."""
return dict(
name=self.__class__.__name__,
notifications_count=self.notifications_count,
notifications=self.notifications,
)