from fabric.widgets.centerbox import CenterBox
from loguru import logger
from collections import defaultdict
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from modules.notification.notification import (
NotificationWidget,
cleanup_notification_image_cache,
cleanup_all_notification_caches,
cleanup_notification_specific_caches,
)
from services.modus import notification_service
from widgets.wayland import WaylandWindow as Window
from fabric.widgets.image import Image
from fabric.widgets.eventbox import EventBox
from widgets.custom_image import CustomImage
from fabric.widgets.revealer import Revealer
class ExpandableNotificationGroup(Box):
def __init__(self, app_name, notifications, **kwargs):
super().__init__(
name="notification-group", orientation="v", spacing=0, **kwargs
)
self.app_name = app_name
self.notifications = notifications
self.is_expanded = False # Always start collapsed
# Create collapsed state first (shows only latest notification)
self.create_collapsed_state()
# Create expanded state (hidden initially)
self.create_expanded_state()
# Ensure we start in collapsed state
self.collapsed_eventbox.set_visible(True)
self.expanded_box.set_visible(False)
def create_collapsed_state(self):
from widgets.custom_image import CustomImage
latest_notification = self.notifications[0] # Most recent notification
# Create clickable event box
self.collapsed_eventbox = EventBox(
events=["button-press-event"],
)
self.collapsed_eventbox.connect("button-press-event", self.on_clicked)
# Only create stacked effect if we have multiple notifications
num_notifications = len(self.notifications)
if num_notifications == 1:
# Single notification - no stacking needed
single_notification = Box(
name="single-notification-content",
spacing=8,
children=[
Box(
name="notification-image",
children=CustomImage(
pixbuf=self._get_notification_pixbuf(
latest_notification._notification
)
),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
h_expand=True,
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=f"{self.app_name}",
h_align="start",
ellipsization="end",
),
],
),
Label(
name="notification-body",
markup=latest_notification._notification.summary.replace(
"\n", " "
),
h_align="start",
ellipsization="end",
),
],
),
Box(
orientation="v",
children=[
Button(
name="notification-close",
image=CustomImage(
icon_name="close-symbolic", icon_size=18
),
visible=True,
on_clicked=lambda *_: self._close_single_notification_and_stop_propagation(latest_notification),
),
Box(v_expand=True),
],
),
],
)
self.collapsed_eventbox.add(single_notification)
else:
# Multiple notifications - create stacked effect
# Create container for the entire stack
stack_container = Box(
name="notification-stack-container",
orientation="v",
spacing=0,
)
# Add bottom shadow layer first (deepest)
if num_notifications >= 3:
bottom_shadow = Box(
name="stack-shadow-bottom",
# style="min-height: 16px; margin-left: 20px; margin-right: -4px; margin-bottom: -8px;",
)
stack_container.add(bottom_shadow)
# Add middle shadow layer
if num_notifications >= 2:
middle_shadow = Box(
name="stack-shadow-middle",
# style="min-height: 20px; margin-left: 10px; margin-right: -2px; margin-bottom: -12px;",
)
stack_container.add(middle_shadow)
# Add the main notification content on top
main_notification = Box(
name="stack-main-notification",
spacing=8,
children=[
Box(
name="notification-image",
children=CustomImage(
pixbuf=self._get_notification_pixbuf(
latest_notification._notification
)
),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
h_expand=True,
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=f"{self.app_name}",
h_align="start",
ellipsization="end",
),
],
),
Label(
name="notification-body",
markup=latest_notification._notification.summary.replace(
"\n", " "
),
h_align="start",
ellipsization="end",
),
],
),
Box(
name="notification-count",
orientation="v",
children=[
Button(
name="notification-close",
image=CustomImage(
icon_name="close-symbolic", icon_size=18
),
visible=True,
on_clicked=lambda *_: self._close_single_notification_and_stop_propagation(latest_notification),
),
Label(
name="notification-count-label",
label=f"{len(self.notifications)}",
h_align="end",
)
],
),
],
)
stack_container.add(main_notification)
self.collapsed_eventbox.add(stack_container)
self.add(self.collapsed_eventbox)
# Note: expanded state creation moved to __init__
def create_expanded_state(self):
self.expanded_box = Box(
name="notification-group-expanded",
orientation="v",
spacing=5,
visible=False,
)
# Header with app name and controls
# App name and show less button
header_content = Box(
orientation="h",
h_expand=True,
children=[
Label(
name="notification-group-title",
markup=f"{self.app_name}",
h_align="start",
h_expand=True,
),
Button(
name="notification-show-less",
label="Show less",
on_clicked=self.collapse,
h_align="end",
),
Button(
name="notification-close-summery",
h_expand=False,
v_expand=False,
on_clicked=self.close_all,
image=CustomImage(
icon_name="close-symbolic",
name="notification-close-header",
icon_size=18,
h_align="end",
),
visible=True, # Initially hidden
),
],
)
self.expanded_box.add(header_content)
# Individual notifications
for notification in self.notifications:
notification_widget = NotificationCenterWidget(notification=notification)
self.expanded_box.add(notification_widget)
self.add(self.expanded_box)
def _get_notification_pixbuf(self, notification):
# Use the same logic as NotificationWidget
try:
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
return notification.image_pixbuf.scale_simple(
35, 35, 2
) # GdkPixbuf.InterpType.BILINEAR = 2
except Exception:
pass
# Fallback to app icon
try:
from modules.notification.notification import cache_notification_icon
cached_app_icon = cache_notification_icon(notification.app_icon, (35, 35))
if cached_app_icon:
return cached_app_icon
except Exception:
pass
# Ultimate fallback
from modules.notification.notification import get_fallback_notification_icon
return get_fallback_notification_icon((35, 35))
def on_clicked(self, widget, event):
logger.debug(f"Notification group clicked: {self.app_name}, button: {event.button}")
if event.button == 1: # Left click
if len(self.notifications) > 1: # Only expand if there are multiple notifications
self.expand()
else:
logger.debug("Single notification group - no expansion needed")
return True
def expand(self, *args):
"""Expand to show all notifications in this group"""
self.is_expanded = True
self.collapsed_eventbox.set_visible(False)
self.expanded_box.set_visible(True)
logger.debug(f"Expanded notification group: {self.app_name}")
def collapse(self, *args):
"""Collapse to show only the summary notification"""
self.is_expanded = False
self.collapsed_eventbox.set_visible(True)
self.expanded_box.set_visible(False)
logger.debug(f"Collapsed notification group: {self.app_name}")
def close_all(self, *args):
# Close all notifications in this group
for notification in self.notifications:
try:
cleanup_notification_specific_caches(
app_icon_source=getattr(notification, "app_icon_source", None),
notification_image_cache_key=getattr(
notification, "notification_image_cache_key", None
),
)
notification_service.remove_cached_notification(notification.cache_id)
except Exception as e:
logger.error(
f"Error removing notification {notification.cache_id}: {e}"
)
def _close_single_notification(self, notification):
"""Close a single notification from this group"""
try:
cleanup_notification_specific_caches(
app_icon_source=getattr(notification, "app_icon_source", None),
notification_image_cache_key=getattr(
notification, "notification_image_cache_key", None
),
)
notification_service.remove_cached_notification(notification.cache_id)
logger.debug(f"Closed single notification: {notification.cache_id}")
except Exception as e:
logger.error(f"Error removing single notification {notification.cache_id}: {e}")
def _close_single_notification_and_stop_propagation(self, notification):
"""Close notification and prevent click from expanding the group"""
self._close_single_notification(notification)
return True # Stop event propagation
class NotificationCenterWidget(NotificationWidget):
def __init__(self, notification, **kwargs):
self.notification_id = notification.cache_id
super().__init__(
notification._notification,
timeout_ms=0,
show_close_button=True,
name="notification-centre-notifs",
**kwargs,
)
def create_content(self, notification):
# Create our custom close button for notification center
from widgets.custom_image import CustomImage
self.close_button = Button(
name="notif-close-button",
image=CustomImage(
icon_name="close-symbolic", name="notification-close", icon_size=18
),
visible=True, # Always visible in notification center
on_clicked=self._on_close_clicked,
)
self.close_button.connect(
"enter-notify-event", lambda *_: self.hover_button(self.close_button)
)
self.close_button.connect(
"leave-notify-event", lambda *_: self.unhover_button(self.close_button)
)
# Create the content box manually with our custom close button
from fabric.widgets.box import Box
from fabric.widgets.label import Label
return Box(
name="notification-content",
spacing=8,
children=[
Box(
name="notification-image",
children=CustomImage(
pixbuf=self._get_notification_pixbuf(notification)
),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=notification.summary.replace("\n", " "),
h_align="start",
ellipsization="end",
),
],
),
(
Label(
markup=notification.body.replace("\n", " "),
h_align="start",
ellipsization="end",
)
if notification.body
else Label(
markup="",
h_align="start",
ellipsization="end",
)
),
],
),
Box(h_expand=True),
Box(
orientation="v",
children=[
self.close_button, # Use our custom close button
Box(v_expand=True),
],
),
],
)
# Override to disable the action buttons
def create_action_buttons(self, notification):
return Box(name="notification-action-buttons")
def _on_close_clicked(self, *args):
try:
# Clean up this notification's cached icons AND images
cleanup_notification_specific_caches(
app_icon_source=getattr(self, "app_icon_source", None),
notification_image_cache_key=getattr(
self, "notification_image_cache_key", None
),
)
logger.debug(
f"Cleaned up all caches for notification center: {self.notification_id}"
)
notification_service.remove_cached_notification(self.notification_id)
except Exception as e:
logger.error(f"Error removing notification {self.notification_id}: {e}")
# Override to disable timeout functionality
def start_timeout(self):
pass
# Override to disable timeout functionality
def stop_timeout(self):
pass
# Override to disable auto-close functionality
def close_notification(self):
pass
class NotificationCenter(Window):
def __init__(self):
super().__init__(
layer="overlay",
anchor="top right",
visible=False,
keyboard_mode="on-demand",
title="modus",
)
NOTIFICATION_CENTER_WIDTH = 410
self.set_size_request(NOTIFICATION_CENTER_WIDTH, 600)
# Group notifications by app name
self.notification_groups = defaultdict(list)
self.group_widgets = {}
notification_service.connect(
"cached-notification-added", self.on_notification_added
)
notification_service.connect(
"cached-notification-removed", self.on_notification_removed
)
notification_service.connect("clear-all", self.on_clear_all)
notification_service.connect("notify::count", self.on_count_changed)
main_box = Box(
orientation="v",
spacing=5,
name="noti-center-box",
)
self.scrolled = ScrolledWindow(h_expand=True, v_expand=True)
self.notifications_box = Box(
v_expand=True,
h_expand=True,
style="margin: 1px 0px 1px 1px;",
orientation="v",
spacing=5,
)
self.scrolled.add(self.notifications_box)
main_box.add(self.scrolled)
# No notifications label
self.not_found_label = Label(
label="No notifications",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True,
style="color: #888888; font-style: italic; margin: 20px;",
visible=(notification_service.count == 0),
)
main_box.add(self.not_found_label)
self.clear_all_button = Button(
name="noti-clear-button",
label="Clear",
on_clicked=self.clear_all_notifications,
visible=(notification_service.count > 0),
)
self.button_centre_box = CenterBox(
center_children=[self.clear_all_button],
)
main_box.add(self.button_centre_box)
self.revealer = Revealer(
child=main_box,
transition="slide-down",
transition_duration=600,
child_revealed=False,
)
self.children = self.revealer
# Load existing notifications and group them
self._rebuild_notification_groups()
self.add_keybinding("Escape", self._on_escape_pressed)
self.connect("destroy", self._on_destroy)
def _rebuild_notification_groups(self):
"""Rebuild notification groups from scratch"""
# Clear existing groups
self.notification_groups.clear()
self.group_widgets.clear()
# Clear notifications box
for child in self.notifications_box.get_children():
child.destroy()
# Group notifications by app name
for cached_notification in notification_service.cached_notifications:
app_name = cached_notification._notification.app_name
self.notification_groups[app_name].append(cached_notification)
# Create group widgets
for app_name, notifications in self.notification_groups.items():
# Sort notifications by timestamp (newest first)
notifications.sort(key=lambda n: getattr(n, "timestamp", 0), reverse=True)
group_widget = ExpandableNotificationGroup(app_name, notifications)
self.group_widgets[app_name] = group_widget
self.notifications_box.add(group_widget)
def on_notification_added(self, service, cached_notification):
try:
app_name = cached_notification._notification.app_name
# Add to groups
self.notification_groups[app_name].insert(
0, cached_notification
) # Insert at beginning (newest first)
# Update or create group widget
if app_name in self.group_widgets:
# Update existing group
group_widget = self.group_widgets[app_name]
group_widget.notifications = self.notification_groups[app_name]
# Refresh the group widget
self._refresh_group_widget(group_widget)
else:
# Create new group widget
group_widget = ExpandableNotificationGroup(
app_name, self.notification_groups[app_name]
)
self.group_widgets[app_name] = group_widget
self.notifications_box.pack_start(group_widget, False, False, 0)
group_widget.show_all()
logger.debug(f"Added notification to group {app_name}")
except Exception as e:
logger.error(f"Error adding notification to group: {e}")
def _refresh_group_widget(self, group_widget):
"""Refresh a group widget's content"""
try:
# Remove existing children
for child in group_widget.get_children():
group_widget.remove(child)
# Recreate content
group_widget.create_collapsed_state()
group_widget.show_all()
except Exception as e:
logger.error(f"Error refreshing group widget: {e}")
def on_notification_removed(self, service, cached_notification):
try:
app_name = cached_notification._notification.app_name
# Remove from groups
if app_name in self.notification_groups:
self.notification_groups[app_name] = [
n
for n in self.notification_groups[app_name]
if n.cache_id != cached_notification.cache_id
]
# If no more notifications for this app, remove group widget
if not self.notification_groups[app_name]:
if app_name in self.group_widgets:
group_widget = self.group_widgets[app_name]
group_widget.destroy()
del self.group_widgets[app_name]
del self.notification_groups[app_name]
else:
# Update existing group widget
group_widget = self.group_widgets[app_name]
group_widget.notifications = self.notification_groups[app_name]
self._refresh_group_widget(group_widget)
# Clean up caches
cleanup_notification_specific_caches(
app_icon_source=getattr(cached_notification, "app_icon_source", None),
notification_image_cache_key=getattr(
cached_notification, "notification_image_cache_key", None
),
)
logger.debug(f"Removed notification from group {app_name}")
except Exception as e:
logger.error(f"Error removing notification from group: {e}")
def on_clear_all(self, service):
try:
# Clear all groups
self.notification_groups.clear()
self.group_widgets.clear()
# Clear all remaining cached notification images AND icons
cleanup_all_notification_caches()
for child in self.notifications_box.get_children():
child.destroy()
logger.debug("Cleared all notification groups and remaining cached images")
except Exception as e:
logger.error(f"Error clearing notification groups: {e}")
def on_count_changed(self, service, count=None):
current_count = notification_service.count
self.not_found_label.set_visible(current_count == 0)
self.clear_all_button.set_visible(current_count > 0)
self.scrolled.set_visible(current_count > 0)
def clear_all_notifications(self, *_):
# Clear all groups
self.notification_groups.clear()
self.group_widgets.clear()
# Clear all remaining cached notification images AND icons when clear all is clicked
cleanup_all_notification_caches() # Clear ALL caches (icons + images)
notification_service.clear_all_cached_notifications()
if hasattr(self, "mousecapture"):
self.mousecapture.hide_child_window()
def _on_escape_pressed(self, *_):
if hasattr(self, "mousecapture"):
self.mousecapture.hide_child_window()
def _init_mousecapture(self, mousecapture):
self.mousecapture = mousecapture
def _set_mousecapture(self, visible):
self.revealer.child_revealed = visible
# # No need to refresh on visibility change since we use signals
# pass
def _on_destroy(self, *_):
try:
notification_service.disconnect(
"cached-notification-added", self.on_notification_added
)
notification_service.disconnect(
"cached-notification-removed", self.on_notification_removed
)
notification_service.disconnect("clear-all", self.on_clear_all)
notification_service.disconnect("notify::count", self.on_count_changed)
except Exception as e:
logger.error(f"Error disconnecting signals: {e}")