From 0f811def015280a2febcf2c13195b08b6c11c6bb Mon Sep 17 00:00:00 2001 From: tr1x_em Date: Sat, 9 Aug 2025 19:57:24 +0530 Subject: [PATCH] Apple Like Notificiation Centre --- modules/controlcenter/main.py | 2 +- modules/notification/notification_center.py | 497 ++++++++++++++++---- styles/notification-center.css | 242 +++++++++- 3 files changed, 639 insertions(+), 102 deletions(-) diff --git a/modules/controlcenter/main.py b/modules/controlcenter/main.py index c56bfd9..4b185e1 100644 --- a/modules/controlcenter/main.py +++ b/modules/controlcenter/main.py @@ -67,12 +67,12 @@ class ModusControlCenter(Window): self.add_keybinding("Escape", self.hide_controlcenter) - self.network_service.connect("wifi-device-added", self.on_network_ready) volume = 100 wlan = modus_service.sc("wlan-changed", self.wlan_changed) bluetooth = modus_service.sc("bluetooth-changed", self.bluetooth_changed) music = modus_service.sc("music-changed", self.audio_changed) + self.network_service.connect("wifi-device-added", self.on_network_ready) # Store signal connections for cleanup self._signal_connections.extend( [ diff --git a/modules/notification/notification_center.py b/modules/notification/notification_center.py index e282908..b820a10 100644 --- a/modules/notification/notification_center.py +++ b/modules/notification/notification_center.py @@ -1,13 +1,282 @@ +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 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 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 + + # Create collapsed state (shows only latest notification) + self.create_collapsed_state() + + 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", + ), + ], + ), + ], + ) + 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", + children=[ + 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) + + # Create expanded state (hidden initially) + self.create_expanded_state() + + 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-all", + label="×", + on_clicked=self.close_all, + h_align="end", + ), + ], + ) + + 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): + if event.button == 1: # Left click + self.expand() + return True + + def expand(self, *args): + self.is_expanded = True + self.collapsed_eventbox.set_visible(False) + self.expanded_box.set_visible(True) + + def collapse(self, *args): + self.is_expanded = False + self.collapsed_eventbox.set_visible(True) + self.expanded_box.set_visible(False) + + 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}" + ) class NotificationCenterWidget(NotificationWidget): @@ -15,13 +284,17 @@ class NotificationCenterWidget(NotificationWidget): self.notification_id = notification.cache_id super().__init__( - notification._notification, timeout_ms=0, show_close_button=True, **kwargs + 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", icon_size=18), @@ -34,11 +307,11 @@ class NotificationCenterWidget(NotificationWidget): 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, @@ -100,11 +373,15 @@ class NotificationCenterWidget(NotificationWidget): 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) + 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}") - + 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}") @@ -135,6 +412,10 @@ class NotificationCenter(Window): 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 ) @@ -150,27 +431,10 @@ class NotificationCenter(Window): name="noti-center-box", ) - header = Box( - orientation="h", - spacing=10, - style="margin-bottom: 10px;", - ) - - header.add( - Label( - label="Notification Center", - h_align="start", - h_expand=True, - style="font-size: 16px; font-weight: bold; color: #ffffff;", - ) - ) - - main_box.add(header) - - self.scrolled = ScrolledWindow(h_expand=True, v_expand=True) + self.scrolled = ScrolledWindow(h_expand=False, v_expand=False) self.notifications_box = Box( - v_expand=True, - h_expand=True, + v_expand=False, + h_expand=False, style="margin: 1px 0px 1px 1px;", orientation="v", spacing=5, @@ -178,84 +442,154 @@ class NotificationCenter(Window): 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) + # # 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 All", + label="Clear", on_clicked=self.clear_all_notifications, visible=(notification_service.count > 0), ) - main_box.add(self.clear_all_button) + self.button_centre_box = CenterBox( + center_children=[self.clear_all_button], + ) + main_box.add(self.button_centre_box) self.children = main_box - # Load existing notifications - for cached_notification in notification_service.cached_notifications: - self.notifications_box.add( - NotificationCenterWidget(notification=cached_notification) - ) + # 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: - notification_widget = NotificationCenterWidget( - notification=cached_notification - ) - # Insert at the beginning to show newest first - self.notifications_box.pack_start(notification_widget, False, False, 0) - notification_widget.show_all() - logger.debug( - f"Added notification widget for {cached_notification.app_name}" - ) + 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 widget: {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: - # Find and remove the corresponding widget with cache cleanup - for child in self.notifications_box.get_children(): - if ( - hasattr(child, "notification_id") - and child.notification_id == cached_notification.cache_id - ): - # Clean up the widget's cached icons AND images before destroying - cleanup_notification_specific_caches( - app_icon_source=getattr(child, 'app_icon_source', None), - notification_image_cache_key=getattr(child, 'notification_image_cache_key', None) - ) - logger.debug(f"Cleaned up all caches for removed notification: {child.notification_id}") - - child.destroy() - break - logger.debug( - f"Removed notification widget and all caches for {cached_notification.app_name}" + 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 widget: {e}") + logger.error(f"Error removing notification from group: {e}") def on_clear_all(self, service): try: - # Clear all remaining cached notification images AND icons - # Individual notifications clean up their own images, this handles any remaining + # 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 widgets and remaining cached images") + logger.debug("Cleared all notification groups and remaining cached images") except Exception as e: - logger.error(f"Error clearing notification widgets: {e}") + logger.error(f"Error clearing notification groups: {e}") def on_count_changed(self, service, count=None): current_count = notification_service.count @@ -264,8 +598,11 @@ class NotificationCenter(Window): 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 - # Individual notifications clean up their own images, this handles any remaining cleanup_all_notification_caches() # Clear ALL caches (icons + images) notification_service.clear_all_cached_notifications() if hasattr(self, "mousecapture"): diff --git a/styles/notification-center.css b/styles/notification-center.css index 92f86cc..d83d59c 100644 --- a/styles/notification-center.css +++ b/styles/notification-center.css @@ -1,26 +1,182 @@ #noti-center-box { - background-color: alpha(#fff, 0.09); + background-color: transparent; - border: 1px solid alpha(#111, 0.3); + /* border: 1px solid alpha(#111, 0.3); */ border-radius: 10px; - padding: 20px; + margin-right: 10px; + /* padding: 20px; */ /* margin: 6px; */ } -#noti-clear-button { - min-width: 10px; - min-height: 40px; - border-radius: 10px; +#noti-clear-button label { + /* min-width: 10px; */ + background-color: alpha(#202828, 0.8); + min-height: 25px; + min-width: 70px; + font-size: 15px; + font-weight: 500; + font-family: "SF Pro Rounded"; + border: 1.5px solid #525155; + border-radius: 15px; margin-top: 10px; } -#noti-clear-button { - background-color: alpha(#888, 0.09); -} #noti-clear-button:hover { background-color: alpha(#fff, 0.09); } +/* Expandable notification group styles */ +#notification-group { + margin: 4px 0; +} + +/* Single notification (no stacking) */ +#single-notification-content { + background-color: alpha(#202828, 0.8); + border: 1.5px solid #525155; + border-radius: 12px; + padding: 12px; + transition: all 0.2s ease; +} + +#single-notification-content:hover { + background-color: alpha(#999, 0.9); + border: 1.5px solid #525155; +} + +/* Stacked notification container */ +#notification-stack-container { + background-color: transparent; + margin: 8px 0; +} + +/* Bottom shadow layer (deepest) - offset to show stacking from below */ +#stack-shadow-bottom { + background-color: alpha(#202828, 0.9); + border: 1.5px solid #525155; + border-radius: 12px; + opacity: 1; + min-height: 16px; + margin-left: 20px; + margin-right: -4px; + margin-bottom: -8px; +} + +/* Middle shadow layer */ +#stack-shadow-middle { + background-color: alpha(#202828, 0.9); + border: 1.5px solid #525155; + border-radius: 12px; + opacity: 1; + min-height: 20px; + margin-left: 10px; + margin-right: -2px; + margin-bottom: -12px; +} + +/* Main notification content (top layer) */ +#stack-main-notification { + background-color: alpha(#202828, 1); + border: 1.5px solid #525155; + border-radius: 12px; + padding: 12px; + transition: all 0.2s ease; +} + +/* Hover effects for stacked notifications */ +#notification-stack-container:hover #stack-main-notification { + background-color: alpha(#525155, 0.1); + border: 1px solid #525155; +} + +#notification-stack-container:hover #stack-shadow-middle { + opacity: 0.7; +} + +#notification-stack-container:hover #stack-shadow-bottom { + opacity: 0.5; +} + +/* Collapsed notification state */ +#notification-content-collapsed { + background-color: alpha(#000, 0.05); + border: 1px solid #525155; + border-radius: 12px; + padding: 12px; + margin: 2px 0; + transition: all 0.2s ease; +} + +#notification-content-collapsed:hover { + background-color: alpha(#525155, 0.1); + border: 1px solid #525155; +} + +/* Notification count badge */ +#notification-count-label { + background-color: alpha(#fff, 0.2); + color: rgba(255, 255, 255, 0.8); + border-radius: 10px; + padding: 2px 6px; + font-size: 10px; + font-weight: 500; + min-width: 16px; +} + +/* Expanded notification group header */ +#notification-group-header { + background-color: alpha(#000, 0.05); + border: 1px solid #525155; + border-radius: 12px 12px 0 0; + padding: 8px 12px; + margin: 2px 0 0 0; +} + +#notification-group-title { + color: #ffffff; + font-weight: bold; + font-size: 14px; +} + +#notification-show-less { + background-color: transparent; + color: rgba(255, 255, 255, 0.7); + border: 1px solid alpha(#fff, 0.2); + border-radius: 6px; + padding: 2px 8px; + font-size: 11px; + margin-right: 8px; +} + +#notification-show-less:hover { + background-color: alpha(#fff, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +#notification-close-all { + background-color: transparent; + color: rgba(255, 255, 255, 0.7); + border: 1px solid alpha(#fff, 0.2); + border-radius: 6px; + padding: 2px 8px; + font-size: 12px; + min-width: 24px; +} + +#notification-close-all:hover { + background-color: alpha(#ff5555, 0.2); + color: rgba(255, 255, 255, 0.9); + border-color: alpha(#ff5555, 0.4); +} + +/* Expanded notification list */ +#notification-group-expanded { + border: 1px solid #525155; + border-top: none; + border-radius: 0 0 12px 12px; + /* background-color: alpha(#000, 0.02); */ +} + /* Stacked notification group styles */ .stacked-notification-group { margin: 4px 0; @@ -30,11 +186,8 @@ } .stacked-notification-item { - background-color: alpha(#000, 0.4); - border: 0.5px solid alpha(#111, 0.4); - box-shadow: - inset 0 0 200px 0 alpha(#111, 0.5), - inset 0 0 0 0.5px alpha(#aaa, 0.4); + background-color: alpha(#000, 0.05); + border: 1px solid #525155; border-radius: 12px; transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); } @@ -50,11 +203,8 @@ } #stacked-notification-item:hover { - background-color: alpha(#111, 0.5); - box-shadow: - inset 0 0 200px 0 alpha(#111, 0.5), - inset 0 0 0 0.5px alpha(#aaa, 0.4), - 0 4px 12px alpha(#000, 0.3); + background-color: alpha(#525155, 0.1); + border: 1px solid #525155; } /* Stack count indicator */ @@ -72,7 +222,57 @@ /* Make expanded notifications clickable with subtle hover effect */ #expanded-notification-list:hover { - background-color: alpha(#111, 0.1); + background-color: alpha(#111, 0.9); border-radius: 8px; transition: background-color 0.2s ease; } + +#notification-group-title { + font-family: "SF Pro Rounded"; + font-size: 19px; + margin: 10px; + font-weight: bold; +} +#notification-show-less { + font-family: "SF Pro Rounded"; + background-color: alpha(#111, 0.9); + font-size: 12px; + margin: 10px; + border-radius: 15px; +} + +#notification-close-all { + margin: 10px 10px 10px 0; + background-color: alpha(#111, 0.9); + border-radius: 15px; +} + +#notif-close-button { + background-color: transparent; +} + +#notification-group-expanded { + background-color: transparent; + border: none; +} + +#notification-content { + background-color: transparent; +} +#notification-count-label { + background-color: transparent; + color: white; + font-family: "SF Pro Rounded"; + font-size: 13px; +} +#notification-centre-notifs { + background-color: alpha(#1c2328, 0.8); + min-height: 40px; + padding: 10px; + border: 1.5px solid #525155; + border-radius: 12px; +} + +#notification-centre-notifs:last-child { + margin-bottom: 10px; +}