diff --git a/config/assets/icons/applets/dnd-clear.svg b/config/assets/icons/applets/dnd-clear.svg new file mode 100644 index 0000000..19b053f --- /dev/null +++ b/config/assets/icons/applets/dnd-clear.svg @@ -0,0 +1,4 @@ + + + + diff --git a/config/data.py b/config/data.py index 6d3f59e..1a9609a 100644 --- a/config/data.py +++ b/config/data.py @@ -104,6 +104,7 @@ if os.path.exists(CONFIG_FILE): NOTIFICATION_TIMEOUT_STR = config.get("notification_timeout", "5s") NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) + NOTIFICATION_IGNORED_APPS = config.get("notification_ignored_apps", ["Hyprshot"]) DOCK_COMPONENTS_VISIBILITY = { "workspace": config.get("dock_workspace_visible", True), @@ -139,6 +140,7 @@ else: NOTIFICATION_TIMEOUT_STR = "5s" NOTIFICATION_TIMEOUT = parse_timeout_string(NOTIFICATION_TIMEOUT_STR) + NOTIFICATION_IGNORED_APPS = ["Hyprshot"] DOCK_COMPONENTS_VISIBILITY = { "workspace": True, diff --git a/main.css b/main.css index 29c603a..0e6e559 100644 --- a/main.css +++ b/main.css @@ -8,6 +8,7 @@ @import url("./styles/notification.css"); @import url("./styles/dropdown.css"); @import url("./styles/about.css"); +@import url("./styles/notification-center.css"); * { all: unset; diff --git a/main.py b/main.py index 3ab34a5..7625575 100644 --- a/main.py +++ b/main.py @@ -6,9 +6,8 @@ from fabric import Application from fabric.utils import get_relative_path, monitor_file from modules.dock import Dock from modules.launcher.main import Launcher +from modules.notification.notification import ModusNoti from modules.osd import OSD -from modules.about import About -from modules.notification import ModusNoti # from modules.corners import Corners from modules.panel.main import Panel diff --git a/modules/notification.py b/modules/notification.py deleted file mode 100644 index b7ec033..0000000 --- a/modules/notification.py +++ /dev/null @@ -1,337 +0,0 @@ -import os -import sys -from typing import cast - -from gi.repository import GdkPixbuf, GLib # type: ignore - -from config.data import NOTIFICATION_TIMEOUT -from fabric.notifications import ( - Notification, - NotificationAction, - NotificationCloseReason, - Notifications, -) -from fabric.utils import invoke_repeater -from fabric.widgets.box import Box -from fabric.widgets.button import Button -from fabric.widgets.eventbox import EventBox -from fabric.widgets.label import Label -from utils.roam import modus_service -from widgets.custom_image import CustomImage -from widgets.customrevealer import SlideRevealer -from widgets.wayland import WaylandWindow as Window - -sys.path.append(os.path.join(os.path.dirname(__file__), "..")) - -NOTIFICATION_WIDTH = 360 -NOTIFICATION_IMAGE_SIZE = 48 - - -# Improved animation smoothness and consistent behavior for rapid notifications. -def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 600): - revealer.duration = duration - - -def get_notification_image_pixbuf( - notification: Notification, -) -> GdkPixbuf.Pixbuf | None: - width = NOTIFICATION_IMAGE_SIZE - height = NOTIFICATION_IMAGE_SIZE - - try: - if notification.image_file and os.path.exists(notification.image_file): - pixbuf = GdkPixbuf.Pixbuf.new_from_file(notification.image_file) - return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) - elif notification.image_pixbuf: - return notification.image_pixbuf.scale_simple( - width, height, GdkPixbuf.InterpType.BILINEAR - ) - except Exception as e: - print("Failed to load image:", e) - - return None - - -class NotificationWidget(Box): - def __init__(self, notification: Notification, **kwargs): - super().__init__( - size=(NOTIFICATION_WIDTH, -1), - name="notification", - spacing=8, - orientation="v", - **kwargs, - ) - - self._notification = notification - - self.image = notification.app_icon - body_container = Box(name="noti-image", spacing=4, orientation="h") - - pixbuf = get_notification_image_pixbuf(notification) - image_file = None - - if notification.app_icon: - if notification.app_icon.startswith("file://"): - image_file = notification.app_icon[7:] - elif os.path.exists(notification.app_icon): - image_file = notification.app_icon - - if image_file: - body_container.add( - CustomImage( - name="noti-image", - image_file=image_file, - size=NOTIFICATION_IMAGE_SIZE, - ) - ) - elif pixbuf: - body_container.add( - CustomImage( - name="noti-image", - image_pixbuf=pixbuf, - size=NOTIFICATION_IMAGE_SIZE, - ) - ) - else: - body_container.add( - CustomImage( - name="noti-image", - icon_name="dialog-information-symbolic", - icon_size=NOTIFICATION_IMAGE_SIZE, - ) - ) - - body_container.add( - Box( - spacing=4, - orientation="v", - children=[ - Box( - orientation="h", - children=[ - Label( - markup=self._notification.summary, # type: ignore - ellipsization="end", - max_chars_width=20, - style_classes="summary", - ) - ], - h_expand=True, - v_expand=True, - ), - Label( - label=self._notification.body, # type: ignore - v_align="start", - h_align="start", - ellipsization="end", - max_chars_width=34, - style_classes="body", - ), - ], - h_expand=True, - v_expand=True, - ) - ) - - self.add(body_container) - - if actions := self._notification.actions: - actions = cast(list[NotificationAction], actions) # type: ignore - self.add( - Box( - spacing=4, - orientation="h", - children=[ - Button( - label=action.label, - h_expand=True, - name="notification-action", - v_expand=True, - on_clicked=lambda *_, action=action: ( - action.invoke(), - action.parent.close("dismissed-by-user"), - ), - ) - for action in actions # type: ignore - ], - ) - ) - - self._notification.connect( - "closed", - self.destroy_noti, - ) - - invoke_repeater( - NOTIFICATION_TIMEOUT, - lambda: self._notification.close("expired"), - initial_call=False, - ) - - def close_noti(self, *_): - modus_service.remove_notification(self._notification["id"]) - self._notification.close() - self.destroy() - - def destroy_noti(self, *_): - (parent.remove(self) if (parent := self.get_parent()) else None,) # type: ignore - self.destroy() - - -class NotificationRevealer(SlideRevealer): - def __init__(self, notification: Notification, on_transition_end=None, **kwargs): - self.notif_box = NotificationWidget(notification) - self.notification = notification - self.on_transition_end = on_transition_end - - # Add swipe detection variables - self._drag_start_x = 0 - self._drag_start_y = 0 - self._is_dragging = False - self._swipe_threshold = 100 # pixels to trigger swipe dismiss - - # Wrap notification in EventBox for swipe detection - self.event_box = EventBox( - events=[ - "button-press-event", - "button-release-event", - "motion-notify-event", - ], - child=self.notif_box, - ) - - self.event_box.connect("button-press-event", self._on_button_press) - self.event_box.connect("button-release-event", self._on_button_release) - self.event_box.connect("motion-notify-event", self._on_motion) - - super().__init__( - child=self.event_box, - direction="right", - duration=600, - ) - - smooth_revealer_animation(self) - - # Disconnect the destroy_noti handler from the notification widget - # to prevent immediate destruction that interferes with slide animation - self.notification.disconnect_by_func(self.notif_box.destroy_noti) - - # Connect our own handler that manages the slide animation - self.notification.connect("closed", self.on_resolved) - - def _on_animation_complete(self, is_hiding=False): - if is_hiding: - # Manually destroy the notification widget since we disconnected its handler - self.notif_box.destroy_noti() - - if self.on_transition_end: - self.on_transition_end() - self.destroy() - - def on_resolved( - self, - _notification: Notification, - reason: NotificationCloseReason, - ): - # Use left-to-right slide for auto-dismiss (expired), right-to-left for manual close - if reason == "expired": - # Left-to-right slide for auto-dismiss - self.set_slide_direction("left") - else: - # Right-to-left slide for manual close - self.set_slide_direction("right") - - self.hide() - GLib.timeout_add(self.duration + 50, lambda: self._on_animation_complete(True)) - - def _on_button_press(self, _widget, event): - if event.button == 1: - self._drag_start_x = event.x - self._drag_start_y = event.y - self._is_dragging = True - return False - - def _on_button_release(self, _widget, event): - if self._is_dragging and event.button == 1: - self._is_dragging = False - - # Calculate swipe distance - dx = event.x - self._drag_start_x - dy = abs(event.y - self._drag_start_y) - - if dx > self._swipe_threshold and dy < 50: # 50px vertical tolerance - # Follow the swipe gesture direction - self.set_slide_direction("right") - self.hide() - # Schedule cleanup after animation - GLib.timeout_add( - self.duration + 50, lambda: self._on_animation_complete(True) - ) - return False - - def _on_motion(self, _widget, event): - if self._is_dragging: - # TODO: Add visual feedback during swipe - # Could add translation or opacity changes here - pass - return False - - -class ModusNoti(Window): - def __init__(self): - self._server = Notifications() - self.notifications = Box( - v_expand=True, - h_expand=True, - title="Notification Center", - style="margin: 1px 0px 1px 1px;", - orientation="v", - spacing=5, - ) - self._server.connect("notification-added", self.on_new_notification) - super().__init__( - anchor="top right", - child=self.notifications, - layer="overlay", - all_visible=True, - visible=True, - exclusive=False, - ) - - def on_new_notification(self, fabric_notif, id): - notification: Notification = fabric_notif.get_notification_from_id(id) - - # Check if Do Not Disturb is enabled - if modus_service.dont_disturb: - # Still cache the notification for notification center, but don't show it - modus_service.cache_notification(notification) - # Close the notification immediately to prevent it from showing - notification.close("dismissed-by-user") - return - - new_box = NotificationRevealer( - notification, on_transition_end=lambda: self.notifications.remove(new_box) - ) - - # Insert at the beginning (index 0) to show new notifications at the top - current_children = list(self.notifications.children) - current_children.insert(0, new_box) - self.notifications.children = current_children - - # This ensures each notification's container is fully laid out before animation - - if len(current_children) >= 4: - # Animate close for the oldest notification if we exceed the limit - oldest_notification = current_children[-1] - if hasattr(oldest_notification, 'notification'): - # Trigger the close animation by closing the underlying notification - # This will use the same slide animation as auto-dismiss (left-to-right) - oldest_notification.notification.close("expired") - current_children.pop() - - def start_animation(): - if new_box.get_parent(): # Only animate if still in the tree - new_box.reveal() - return False - - GLib.timeout_add(32, start_animation) diff --git a/modules/notification/notification.py b/modules/notification/notification.py new file mode 100644 index 0000000..c125f72 --- /dev/null +++ b/modules/notification/notification.py @@ -0,0 +1,509 @@ +import os + +from gi.repository import Gdk, GdkPixbuf, GLib # type: ignore +from loguru import logger + +import config.data as data +from fabric.notifications import ( + Notification, + NotificationAction, + NotificationCloseReason, +) +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.centerbox import CenterBox +from fabric.widgets.eventbox import EventBox +from fabric.widgets.image import Image +from fabric.widgets.label import Label +from utils.roam import modus_service, notification_service +from widgets.custom_image import CustomImage +from widgets.customrevealer import SlideRevealer +from widgets.wayland import WaylandWindow as Window + +NOTIFICATION_WIDTH = 360 +NOTIFICATION_IMAGE_SIZE = 48 + + +def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 600): + revealer.duration = duration + + +class ActionButton(Button): + def __init__( + self, action: NotificationAction, index: int, total: int, notification_box + ): + super().__init__( + name="action-button", + h_expand=True, + on_clicked=self.on_clicked, + child=Label(name="button-label", label=action.label), + ) + self.action = action + self.notification_box = notification_box + style_class = ( + "start-action" + if index == 0 + else "end-action" if index == total - 1 else "middle-action" + ) + self.add_style_class(style_class) + self.connect( + "enter-notify-event", lambda *_: notification_box.hover_button(self) + ) + self.connect( + "leave-notify-event", lambda *_: notification_box.unhover_button(self) + ) + + def on_clicked(self, *_): + self.action.invoke() + self.action.parent.close("dismissed-by-user") + + +class NotificationWidget(Box): + def __init__( + self, + notification: Notification, + timeout_ms=data.NOTIFICATION_TIMEOUT, + show_close_button=True, + **kwargs, + ): + self.show_close_button = show_close_button + + super().__init__( + size=(NOTIFICATION_WIDTH, -1), + name="notification", + orientation="v", + h_align="fill", + h_expand=True, + children=[ + self.create_content(notification), + self.create_action_buttons(notification), + ], + ) + + self.notification = notification + self.timeout_ms = timeout_ms + self._timeout_id = None + self.start_timeout() + + def create_header(self, notification): + app_icon = ( + Image( + name="notification-icon", + image_file=notification.app_icon[7:], + size=24, + ) + if "file://" in notification.app_icon + else Image( + name="notification-icon", + icon_name="dialog-information-symbolic" or notification.app_icon, + icon_size=24, + ) + ) + + return CenterBox( + name="notification-title", + start_children=[ + Box( + spacing=4, + children=[ + app_icon, + Label( + notification.app_name, + name="notification-app-name", + h_align="start", + ), + ], + ) + ], + end_children=[ + self.create_close_button() if self.show_close_button else Box() + ], + ) + + def create_content(self, notification): + return Box( + name="notification-content", + spacing=8, + children=[ + Box( + name="notification-image", + children=CustomImage( + pixbuf=( + notification.image_pixbuf.scale_simple( + 48, 48, GdkPixbuf.InterpType.BILINEAR + ) + if notification.image_pixbuf + else self.get_pixbuf(notification.app_icon, 48, 48) + ) + ), + ), + 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( + name="notification-app-name", + markup=" | " + notification.app_name, + h_align="start", + ellipsization="end", + ), + ], + ), + ( + Label( + markup=notification.body.replace("\n", " "), + h_align="start", + ellipsization="end", + ) + if notification.body + else Box() + ), + ], + ), + Box(h_expand=True), + Box( + orientation="v", + children=[ + self.create_close_button() if self.show_close_button else Box(), + Box(v_expand=True), + ], + ), + ], + ) + + def get_pixbuf(self, icon_path, width, height): + if icon_path.startswith("file://"): + icon_path = icon_path[7:] + + if not os.path.exists(icon_path): + logger.warning(f"Icon path does not exist: {icon_path}") + return None + + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR) + except Exception as e: + logger.error(f"Failed to load or scale icon: {e}") + return None + + def create_action_buttons(self, notification): + return Box( + name="notification-action-buttons", + spacing=4, + h_expand=True, + children=[ + ActionButton(action, i, len(notification.actions), self) + for i, action in enumerate(notification.actions) + ], + ) + + def start_timeout(self): + self.stop_timeout() + self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification) + + def stop_timeout(self): + if self._timeout_id is not None: + GLib.source_remove(self._timeout_id) + self._timeout_id = None + + def close_notification(self): + self.notification.close("expired") + self.stop_timeout() + return False + + def pause_timeout(self): + self.stop_timeout() + + def resume_timeout(self): + self.start_timeout() + + def destroy(self): + self.stop_timeout() + super().destroy() + + # @staticmethod + def set_pointer_cursor(self, widget, cursor_name): + window = widget.get_window() + if window: + cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name) + window.set_cursor(cursor) + + def hover_button(self, button): + self.pause_timeout() + self.set_pointer_cursor(button, "hand2") + + def unhover_button(self, button): + self.resume_timeout() + self.set_pointer_cursor(button, "arrow") + + +class NotificationRevealer(SlideRevealer): + def __init__( + self, + notification: Notification, + on_transition_end=None, + parent_window=None, + **kwargs, + ): + self.notif_box = NotificationWidget(notification, show_close_button=False) + self.notification = notification + self.on_transition_end = on_transition_end + # Reference to ModusNoti window for queue clearing + self.parent_window = parent_window + + # Add swipe detection variables + self._drag_start_x = 0 + self._drag_start_y = 0 + self._is_dragging = False + self._swipe_threshold = 100 # pixels to trigger swipe dismiss + + # Wrap notification in EventBox for swipe detection + self.event_box = EventBox( + events=[ + "button-press-event", + "button-release-event", + "motion-notify-event", + ], + child=self.notif_box, + ) + + self.event_box.connect("button-press-event", self._on_button_press) + self.event_box.connect("button-release-event", self._on_button_release) + self.event_box.connect("motion-notify-event", self._on_motion) + + super().__init__( + child=self.event_box, + direction="right", + duration=600, + ) + + smooth_revealer_animation(self) + + # Connect our own handler that manages the slide animation + self.notification.connect("closed", self.on_resolved) + + def _on_animation_complete(self, is_hiding=False): + if is_hiding: + # Manually destroy the notification widget since we disconnected its handler + self.notif_box.destroy() + + if self.on_transition_end: + self.on_transition_end() + self.destroy() + + def on_resolved( + self, + _notification: Notification, + reason: NotificationCloseReason, + ): + # Use left-to-right slide for auto-dismiss (expired), right-to-left for manual close + if reason == "expired": + # Left-to-right slide for auto-dismiss + self.set_slide_direction("left") + else: + # Right-to-left slide for manual close + self.set_slide_direction("right") + + self.hide() + GLib.timeout_add(self.duration + 50, lambda: self._on_animation_complete(True)) + + def _on_button_press(self, _widget, event): + if event.button == 1: + self._drag_start_x = event.x + self._drag_start_y = event.y + self._is_dragging = True + return False + + def _on_button_release(self, _widget, event): + if event.button == 3: # Right click + try: + if self.parent_window: + self.parent_window.clear_notification_queue() + # Also dismiss current notification + self.notification.close("dismissed-by-user") + except: + pass # Ignore errors + return True + + elif self._is_dragging and event.button == 1: + self._is_dragging = False + + # Calculate swipe distance + dx = event.x - self._drag_start_x + dy = abs(event.y - self._drag_start_y) + + # Left-to-right swipe: dismiss current notification only + if dx > self._swipe_threshold and dy < 50: # 50px vertical tolerance + try: + self.notification.close("dismissed-by-user") + except: + pass # Ignore errors + return False + + def _on_motion(self, _widget, event): + if self._is_dragging: + # TODO: Add visual feedback during swipe + # Could add translation or opacity changes here + pass + return False + + +class ModusNoti(Window): + def __init__(self): + self._server = notification_service + self.notifications = Box( + v_expand=True, + h_expand=True, + title="Notification Center", + style="margin: 1px 0px 1px 1px;", + orientation="v", + spacing=5, + ) + + # Queue system for macOS-like behavior + self.notification_queue = [] + self.current_notification = None + self.is_showing_notification = False + + # Initialize ignored apps list from config + self.ignored_apps = data.NOTIFICATION_IGNORED_APPS + + self._server.connect("notification-added", self.on_new_notification) + super().__init__( + anchor="top right", + child=self.notifications, + layer="overlay", + all_visible=True, + visible=True, + exclusive=False, + ) + + def on_new_notification(self, fabric_notif, id): + notification: Notification = fabric_notif.get_notification_from_id(id) + + # Cache the notification to the modus service for persistence + try: + modus_service.cache_notification(notification) + logger.debug( + f"Cached notification: {notification.app_name} - {notification.summary}" + ) + except Exception as e: + logger.error(f"Failed to cache notification: {e}") + + # Check if the notification is in the "do not disturb" mode, hacky way + if self._server.dont_disturb or notification.app_name in self.ignored_apps: + return + + if modus_service.dont_disturb: + notification.close("dismissed-by-user") + return + + for pending_notification in list(self.notification_queue): + try: + pending_notification.close("dismissed-by-user") + except: + pass + self.notification_queue.clear() + + if self.current_notification and self.is_showing_notification: + current_allocation = None + try: + if self.current_notification.get_parent(): + current_allocation = self.current_notification.get_allocation() + except: + pass + + if hasattr(self.current_notification, "stop_animation"): + self.current_notification.stop_animation() + + try: + if self.current_notification in self.notifications.children: + self.notifications.remove(self.current_notification) + if hasattr(self.current_notification, "notification"): + self.current_notification.notification.close("dismissed-by-user") + except: + pass + + self.current_notification = None + self.is_showing_notification = False + + self.notification_queue.append(notification) + self.show_next_notification() + + def show_next_notification(self): + if not self.notification_queue or self.is_showing_notification: + return + + notification = self.notification_queue.pop(0) + self.is_showing_notification = True + + new_box = NotificationRevealer( + notification, + on_transition_end=lambda: self.on_notification_finished(new_box), + parent_window=self, + ) + + self.current_notification = new_box + + for child in list(self.notifications.children): + try: + self.notifications.remove(child) + except: + pass + + self.notifications.children = [new_box] + + new_box.show_all() + self.notifications.queue_resize() + + def start_animation(): + if new_box.get_parent(): + if new_box.get_realized(): + new_box.reveal() + return False + else: + return True + return False + + GLib.idle_add(start_animation) + + def on_notification_finished(self, notification_box): + if notification_box != self.current_notification: + return + + # Safely remove notification box + try: + if notification_box in self.notifications.children: + self.notifications.remove(notification_box) + except: + pass + # Reset state + self.current_notification = None + self.is_showing_notification = False + + if self.notification_queue: + GLib.timeout_add(100, lambda: self.show_next_notification() or False) + + def clear_notification_queue(self): + queue_length = len(self.notification_queue) + if queue_length > 0: + for notification in list(self.notification_queue): + try: + notification.close("dismissed-by-user") + except: + pass # Ignore errors if notification is already closed + self.notification_queue.clear() + + def get_queue_length(self): + return len(self.notification_queue) diff --git a/modules/notification/notification_center.py b/modules/notification/notification_center.py new file mode 100644 index 0000000..56d9669 --- /dev/null +++ b/modules/notification/notification_center.py @@ -0,0 +1,179 @@ +from loguru import logger + +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 +from utils.roam import modus_service +from widgets.wayland import WaylandWindow as Window +from fabric.widgets.image import Image + + +class NotificationCenterWidget(NotificationWidget): + def __init__(self, notification, notification_id, **kwargs): + self.notification_id = notification_id + + super().__init__(notification, timeout_ms=0, show_close_button=True, **kwargs) + + def create_close_button(self): + close_button = Button( + name="notif-close-button", + child=Image(icon_name="close-symbolic", name="notif-close-label", size=5), + on_clicked=self._on_close_clicked, + ) + close_button.connect( + "enter-notify-event", lambda *_: self.hover_button(close_button) + ) + close_button.connect( + "leave-notify-event", lambda *_: self.unhover_button(close_button) + ) + return close_button + + # 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: + modus_service.remove_notification(self.notification_id) + except Exception as e: + logger.error(f"Error removing notification {self.notification_id}: {e}") + finally: + self.destroy() + + # 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) + + main_box = Box( + orientation="v", + spacing=5, + name="noti-center-box", + ) + + # Header + header = Box( + orientation="h", + spacing=10, + style="margin-bottom: 10px;", + ) + header.add( + Label( + label="Notification Center", + h_align="center", + 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.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) + + self.clear_all_button = Button( + name="noti-clear-button", + label="Clear All", + on_clicked=self.clear_all_notifications, + ) + main_box.add(self.clear_all_button) + + self.children = main_box + + # Connect service signal + modus_service.connect( + "notification_count_changed", self._on_notification_count_changed + ) + + self.add_keybinding("Escape", self._on_escape_pressed) + + self.connect("destroy", self._on_destroy) + + def clear_all_notifications(self, *_): + modus_service.clear_all_notifications() + self.refresh_notifications() + self.mousecapture.hide_child_window() + + def _on_escape_pressed(self, *_): + self.mousecapture.hide_child_window() + + def _init_mousecapture(self, mousecapture): + self.mousecapture = mousecapture + + def _set_mousecapture(self, visible): + self.refresh_notifications() + + def _on_notification_count_changed(self, *_): + self.refresh_notifications() + + def _on_destroy(self, *_): + # Disconnect service signal + modus_service.disconnect( + "notification_count_changed", self._on_notification_count_changed + ) + + def refresh_notifications(self): + try: + for child in self.notifications_box.get_children(): + child.destroy() + + notifications_with_ids = modus_service.get_deserialized_with_ids() + logger.info(f"Found {len(notifications_with_ids)} notifications to display") + + if not notifications_with_ids: + no_notifications_label = Label( + label="No notifications", + h_align="center", + style="color: #888888; font-style: italic; margin: 20px;", + ) + self.notifications_box.add(no_notifications_label) + return + + for notification, notification_id in notifications_with_ids: + try: + logger.debug( + f"Notification from {notification.app_name}: ID={ + notification_id + }" + ) + notification_widget = NotificationCenterWidget( + notification=notification, notification_id=notification_id + ) + self.notifications_box.add(notification_widget) + except Exception as e: + logger.error(f"Error creating notification widget: {e}") + + self.clear_all_button.set_visible(len(notifications_with_ids) > 0) + + except Exception as e: + logger.error(f"Error refreshing notifications: {e}") diff --git a/modules/panel/components/menubar.py b/modules/panel/components/menubar.py index 8d25855..4275287 100644 --- a/modules/panel/components/menubar.py +++ b/modules/panel/components/menubar.py @@ -15,10 +15,10 @@ from widgets.mousecapture import DropDownMouseCapture class AppName: def __init__(self, path="/usr/share/applications"): - self.files = os.listdir(path) if os.path.exists(path) else [] + self.files = os.listdir(path) self.path = path - def get_app_name(self, wmclass): + def get_app_name(self, wmclass, format_=False): desktop_file = "" for f in self.files: if f.startswith(wmclass + ".desktop"): @@ -28,17 +28,39 @@ class AppName: if desktop_file == "": return wmclass - try: - with open(os.path.join(self.path, desktop_file), "r") as f: - lines = f.readlines() - for line in lines: - if line.startswith("Name="): - desktop_app_name = line.split("=")[1].strip() - break - except: - return wmclass + with open(os.path.join(self.path, desktop_file), "r") as f: + lines = f.readlines() + for line in lines: + if line.startswith("Name="): + desktop_app_name = line.split("=")[1].strip() + break return desktop_app_name + def get_app_exec(self, wmclass, format_=False): + desktop_file = "" + for f in self.files: + if f.startswith(wmclass + ".desktop"): + desktop_file = f + + desktop_app_name = wmclass + + if desktop_file == "": + return wmclass + with open(os.path.join(self.path, desktop_file), "r") as f: + lines = f.readlines() + for line in lines: + if line.startswith("Exec="): + desktop_app_name = line.split("=")[1].strip() + break + return desktop_app_name + + def get_desktop_file(self, wmclass): + desktop_file = "" + for f in self.files: + if f.startswith(wmclass + ".desktop"): + desktop_file = f + return desktop_file + def format_app_name(self, title, wmclass, update=False): name = wmclass if name == "": @@ -62,8 +84,6 @@ app_name_class = AppName() def format_window(title, wmclass): name = app_name_class.format_app_name(title, wmclass, True) - if not name or name == "": - return "Desktop" return name @@ -263,10 +283,11 @@ class MenuBarDropdowns: "label", f"About {value}" ), ) + self.global_menu_button_title = Button( child=ActiveWindow( formatter=FormattedString( - "{ format_window('None', 'Hyprland') if win_title == '' and win_class == '' else format_window(win_title, win_class) }", + "{ format_window(win_title, win_class) if win_title or win_class else 'Hyprland' }", format_window=format_window, ) ), @@ -274,6 +295,7 @@ class MenuBarDropdowns: style_classes="button", on_clicked=lambda _: self.global_menu_title.toggle_mousecapture(), ) + self.global_menu_title.child_window.set_pointing_to( self.global_menu_button_title ) diff --git a/modules/panel/main.py b/modules/panel/main.py index 1994580..20873c3 100644 --- a/modules/panel/main.py +++ b/modules/panel/main.py @@ -7,6 +7,7 @@ from fabric.widgets.datetime import DateTime from fabric.widgets.revealer import Revealer from fabric.widgets.svg import Svg from modules.controlcenter.main import ModusControlCenter +from modules.notification.notification_center import NotificationCenter from modules.panel.components.enhanced_system_tray import apply_enhanced_system_tray from modules.panel.components.indicators import ( BatteryIndicator, @@ -15,6 +16,7 @@ from modules.panel.components.indicators import ( ) from modules.panel.components.menubar import MenuBar from modules.panel.components.recording_indicator import RecordingIndicator +from utils.roam import modus_service from widgets.mousecapture import MouseCapture from widgets.wayland import WaylandWindow as Window @@ -39,12 +41,27 @@ class Panel(Window): self.imac = Button( name="panel-button", child=Svg( - size=22, + size=28, svg_file=get_relative_path("../../config/assets/icons/misc/logo.svg"), ), on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac), ) + # DND indicator + self.dnd_icon = Svg( + size=34, + svg_file=get_relative_path( + "../../config/assets/icons/applets/dnd-clear.svg" + ), + ) + + self.dnd_indicator = Button( + name="dnd-indicator", + child=self.dnd_icon, + style="opacity: 0.2;", + visible=True, + ) + self.tray = SystemTray(name="system-tray", spacing=4, icon_size=20) self.tray_revealer = Revealer( @@ -81,7 +98,7 @@ class Panel(Window): name="panel-button", on_clicked=lambda *_: self.search_apps(), child=Svg( - size=20, + size=22, svg_file=get_relative_path("../../config/assets/icons/misc/search.svg"), ), ) @@ -102,6 +119,18 @@ class Panel(Window): ), ) + # Notification Center with MouseCapture + self.notification_center = MouseCapture( + layer="overlay", child_window=NotificationCenter() + ) + + # Clickable DateTime for notification center + self.datetime_button = Button( + name="datetime-button", + on_clicked=self.notification_center.toggle_mousecapture, + child=DateTime(name="date-time", formatters=["%a %b %d %I:%M"]), + ) + self.recording_indicator = RecordingIndicator() self.children = CenterBox( @@ -122,16 +151,22 @@ class Panel(Window): spacing=4, orientation="h", children=[ + self.dnd_indicator, self.tray_revealer, self.chevron_button, self.indicators, self.search, self.control_center_button, - DateTime(name="date-time", formatters=["%a %b %d %I:%M"]), + self.datetime_button, ], ), ) + # Connect to DND state changes + modus_service.connect("dont-disturb-changed", self.on_dnd_changed) + # Set initial DND state + self.update_dnd_indicator(modus_service.dont_disturb) + return self.show_all() def search_apps(self): @@ -149,3 +184,16 @@ class Panel(Window): self.chevron_button.get_child().set_from_file( get_relative_path("../../config/assets/icons/misc/chevron-right.svg") ) + + def on_dnd_changed(self, _, dnd_state): + """Handle DND state changes from the service.""" + self.update_dnd_indicator(dnd_state) + + def update_dnd_indicator(self, dnd_enabled): + """Update the DND indicator opacity based on DND state.""" + if dnd_enabled: + # 100% opacity when DND is enabled + self.dnd_indicator.set_style("opacity: 1.0;") + else: + # 20% opacity when DND is disabled + self.dnd_indicator.set_style("opacity: 0.2;") diff --git a/services/custom_notification.py b/services/custom_notification.py new file mode 100644 index 0000000..a5a68fc --- /dev/null +++ b/services/custom_notification.py @@ -0,0 +1,276 @@ +import json +import os +import threading +from typing import Dict, List + +from fabric import Signal +from fabric.notifications import Notification, Notifications, NotificationSerializedData +from loguru import logger + +import config.data as data + +PERSISTENT_DIR = f"/tmp/{data.APP_NAME}/notifications" +NOTIFICATION_CACHE_FILE = os.path.join(PERSISTENT_DIR, "notification_history.json") + + +def write_json_file(data: Dict, path: str): + try: + with open(path, "w") as f: + json.dump(data, f, indent=4, ensure_ascii=False) + except Exception as e: + logger.warning(f"Failed to write json: {e}") + + +class CustomNotifications(Notifications): + """A service to manage the notifications.""" + + @Signal + def clear_all(self, value: bool) -> None: + """Signal emitted when notifications are emptied.""" + # Implement as needed for your application + + @Signal + def notification_count(self, value: int) -> None: + """Signal emitted when a new notification is added.""" + # Implement as needed for your application + + @Signal + def dnd(self, value: bool) -> None: + """Signal emitted when dnd is toggled.""" + # Implement as needed for your application + + @property + def count(self) -> int: + """Return the count of notifications.""" + return len(self.all_notifications) + + @property + def dont_disturb(self) -> bool: + """Return the pause status.""" + return self._dont_disturb + + @dont_disturb.setter + def dont_disturb(self, value: bool): + """Set the pause status.""" + self._dont_disturb = value + self.emit("dnd", value) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.all_notifications = [] + self._count = 0 # Will be updated to highest ID when loading + self.deserialized_notifications = [] + self._dont_disturb = False + self._lock = threading.Lock() # Add missing lock for thread safety + self._load_notifications() + + def _load_notifications(self): + """Read and validate notifications from the cache file.""" + if not os.path.exists(NOTIFICATION_CACHE_FILE): + return + + try: + with open(NOTIFICATION_CACHE_FILE, "r") as file: + original_data = json.load(file) + + original_data.reverse() + + valid_notifications = [] + highest_id = self._count + + for notification in original_data: + try: + self._deserialize_notification(notification) + valid_notifications.append(notification) + highest_id = max(highest_id, notification.get("id", 0)) + except Exception as e: + msg = f"[Notification] Invalid: {str(e)[:50]}" + logger.exception(f"{msg}") + + # Write only if the validated data differs from what was originally loaded + if valid_notifications != original_data: + write_json_file(valid_notifications, NOTIFICATION_CACHE_FILE) + logger.info("[Notification] Notifications written successfully.") + + self.all_notifications = valid_notifications + self._count = highest_id + + del valid_notifications + del original_data + + except (json.JSONDecodeError, KeyError, ValueError, IndexError) as e: + logger.exception(f"[Notification] {e}") + + def remove_notification(self, id: int): + """Remove a notification by ID, ensuring thread safety.""" + with self._lock: + item = next((p for p in self.all_notifications if p["id"] == id), None) + if item: + self.all_notifications.remove(item) + self._persist_and_emit() + + if len(self.all_notifications) == 0: + self.emit("clear_all", True) + + def cache_notification(self, widget_config, data: Notification, max_count: int): + """Cache a notification, ensuring thread safety.""" + with self._lock: + self._cleanup_invalid_notifications() + new_notification = self._create_serialized_notification(data) + self._enforce_per_app_limit(widget_config, new_notification, max_count) + self.all_notifications.append(new_notification) + self._enforce_global_limit(max_count) + self._persist_and_emit() + + def _cleanup_invalid_notifications(self): + """Remove any invalid notifications.""" + + valid_notifications = [] + invalid_count = 0 + + for notification in self.all_notifications: + try: + self._deserialize_notification(notification) + valid_notifications.append(notification) + except Exception as e: + msg = f"[Notification] Removing invalid: {str(e)[:50]}" + logger.debug(msg) + invalid_id = notification.get("id", 0) + self.emit("notification-closed", invalid_id, "dismissed-by-limit") + invalid_count += 1 + + if invalid_count > 0: + self.all_notifications = valid_notifications + self._persist_and_emit() + del valid_notifications + logger.info(f"[Notification] Cleaned {invalid_count} invalid notifications") + + def _create_serialized_notification(self, data: Notification) -> dict: + """Generate a new notification with a unique ID.""" + self._count += 1 + serialized = data.serialize() + serialized.update( + { + "id": self._count, + "app_name": data.app_name, + } + ) + return serialized + + def _enforce_global_limit(self, max_count: int): + """Remove oldest notifications if total count exceeds global limit.""" + while len(self.all_notifications) > max_count: + oldest = self.all_notifications.pop(0) + self.emit("notification-closed", oldest["id"], "dismissed-by-limit") + + def _enforce_per_app_limit( + self, widget_config, new_notification: dict, max_count: int + ): + """Ensure per-app limits are respected.""" + app_name = new_notification["app_name"] + per_app_limits = widget_config.get("notification", {}).get("per_app_limits", {}) + app_limit = per_app_limits.get(app_name, max_count) + + app_notifications = [ + n for n in self.all_notifications if n["app_name"] == app_name + ] + + if len(app_notifications) >= app_limit: + app_notifications.sort(key=lambda x: x["id"]) # Oldest first + to_remove = len(app_notifications) - app_limit + 1 + for old in app_notifications[:to_remove]: + self.all_notifications.remove(old) + self.emit("notification-closed", old["id"], "dismissed-by-limit") + + def _deserialize_notification(self, notification: NotificationSerializedData): + """Deserialize a notification.""" + return Notification.deserialize(notification) + + def _persist_and_emit(self): + """Persist notifications and emit relevant signals.""" + write_json_file(self.all_notifications, NOTIFICATION_CACHE_FILE) + self.emit("notification_count", len(self.all_notifications)) + + def clear_all_notifications(self): + """Empty the notifications.""" + logger.info("[Notification] Clearing all notifications") + + # Clear notifications but preserve the highest ID we've seen + highest_id = self._count + + self.all_notifications.clear() + + self._persist_and_emit() + + logger.info("[Notification] Notifications written successfully.") + + self.emit("clear_all", True) + + # Restore the ID counter so new notifications get unique IDs + self._count = highest_id + + def get_deserialized(self) -> List[Notification]: + """Return the notifications.""" + + def deserialize_with_id(notification): + """Helper to deserialize and return result with ID.""" + try: + return (self._deserialize_notification(notification), None) + except Exception as e: + msg = f"[Notification] Deserialize failed: {str(e)[:50]}" + logger.exception(f"{msg}") + return (None, notification.get("id")) + + # Process all notifications at once + results = [ + deserialize_with_id(notification) for notification in self.all_notifications + ] + + # Split into successful and failed + deserialized = [] + invalid_ids = [] + for result, error_id in results: + if result is not None: + deserialized.append(result) + elif error_id is not None: + invalid_ids.append(error_id) + + # Clean up invalid notifications + for invalid_id in invalid_ids: + self.remove_notification(invalid_id) + + return deserialized + + def get_deserialized_with_ids(self) -> List[tuple[Notification, int]]: + """Return the notifications with their IDs as tuples.""" + + def deserialize_with_id(notification): + """Helper to deserialize and return result with ID.""" + try: + deserialized = self._deserialize_notification(notification) + notification_id = notification.get("id") + return (deserialized, notification_id, None) + except Exception as e: + msg = f"[Notification] Deserialize failed: {str(e)[:50]}" + logger.exception(f"{msg}") + return (None, notification.get("id"), notification.get("id")) + + # Process all notifications at once + results = [ + deserialize_with_id(notification) for notification in self.all_notifications + ] + + # Split into successful and failed + deserialized_with_ids = [] + invalid_ids = [] + for result, notification_id, error_id in results: + if result is not None: + deserialized_with_ids.append((result, notification_id)) + elif error_id is not None: + invalid_ids.append(error_id) + + # Clean up invalid notifications + for invalid_id in invalid_ids: + self.remove_notification(invalid_id) + + return deserialized_with_ids diff --git a/services/modus.py b/services/modus.py index 9aded1d..5b5d7e0 100644 --- a/services/modus.py +++ b/services/modus.py @@ -4,7 +4,9 @@ from loguru import logger from fabric.core.service import Property, Service, Signal from fabric.notifications import Notification +from services.custom_notification import CustomNotifications +notification_service = CustomNotifications() class ModusService(Service): @Signal @@ -22,12 +24,6 @@ class ModusService(Service): @Signal def dock_apps_changed(self, new_dock_apps: str) -> None: ... - @Signal - def clear_all_changed(self, value: bool) -> None: ... - - @Signal - def notification_count_changed(self, value: int) -> None: ... - @Signal def dont_disturb_changed(self, value: bool) -> None: ... @@ -55,6 +51,9 @@ class ModusService(Service): @Signal def show_notificationcenter_changed(self, value: bool) -> None: ... + @Signal + def notification_count_changed(self, value: int) -> None: ... + @Property(str, flags="read-write") def current_active_app_name(self) -> str: return self._current_active_app_name @@ -79,10 +78,6 @@ class ModusService(Service): def dock_apps(self) -> str: return self._dock_apps - @Property(str, flags="read-write") - def notification_count(self) -> int: - return self._notification_count - @Property(str, flags="read-write") def dont_disturb(self) -> bool: return self._dont_disturb @@ -220,8 +215,6 @@ class ModusService(Service): self._battery = "" self._bluetooth = "" self._dock_apps = "" - self._notifications = [] - self._notification_count = len(self._notifications) self._dont_disturb = False self._current_active_app_name = "" self._music = "" @@ -233,43 +226,29 @@ class ModusService(Service): self._dock_width = 0 self._dock_height = 0 - self.notifications = [] def remove_notification(self, id: int): - item = next((p for p in self._notifications if p["id"] == id), None) - if item is None: - return - index = self._notifications.index(item) - - self._notifications.pop(index) - self._notification_count -= 1 - self.notification_count_changed(self._notification_count) - if self._notification_count == 0: - self.clear_all_changed(True) + notification_service.remove_notification(id) + self.notification_count_changed(self.notification_count) def cache_notification(self, data: Notification): - existing_data = self._notifications - serialized_data = data.serialize() - serialized_data.update({"id": self._notification_count + 1}) - existing_data.append(serialized_data) - - self._notification_count += 1 - self._notifications = existing_data - self.notification_count_changed(self._notification_count) + widget_config = {"notification": {"per_app_limits": {}}} + notification_service.cache_notification(widget_config, data, max_count=100) + self.notification_count_changed(self.notification_count) def clear_all_notifications(self): - self._notifications = [] - self._notification_count = 0 - - self.clear_all_changed(True) - self.notification_count_changed(self._notification_count) + notification_service.clear_all_notifications() + self.notification_count_changed(self.notification_count) def get_deserialized(self) -> List[Notification]: - if len(self.notifications) <= 0: - self.notifications = [ - Notification.deserialize(data) for data in self._notifications - ] - return self.notifications + return notification_service.get_deserialized() + + def get_deserialized_with_ids(self): + return notification_service.get_deserialized_with_ids() + + @property + def notification_count(self) -> int: + return notification_service.count global modus_service diff --git a/styles/colors.css b/styles/colors.css index 2a3fdc9..26eb35f 100644 --- a/styles/colors.css +++ b/styles/colors.css @@ -1,32 +1,32 @@ :vars { - --foreground: #f1dfdc; - --background: #1a1110; - --cursor: #f1dfdc; - --primary: #ffb4a8; - --on-primary: #561e17; - --secondary: #e7bdb6; - --on-secondary: #442925; - --tertiary: #dec48c; - --on-tertiary: #3e2e04; - --surface: #1a1110; - --surface-bright: #423735; + --foreground: #e4e1e9; + --background: #131318; + --cursor: #e4e1e9; + --primary: #bcc2ff; + --on-primary: #242b61; + --secondary: #c4c5dd; + --on-secondary: #2d2f42; + --tertiary: #e6bad6; + --on-tertiary: #45263d; + --surface: #131318; + --surface-bright: #39393f; --error: #ffb4ab; --error-dim: #ff8678; --on-error: #690005; --error-container: #93000a; - --outline: #a08c89; + --outline: #90909a; --shadow: #000000; - --red: #ffb4a8; - --red-dim: #ff8875; - --green: #b7d085; - --green-dim: #a1c260; - --yellow: #dec56e; - --yellow-dim: #d5b444; - --blue: #cebdfe; - --blue-dim: #a98bfd; - --magenta: #fcb0d5; - --magenta-dim: #fa7fbb; - --cyan: #84d5c4; - --cyan-dim: #5ec8b2; + --red: #ffb2b9; + --red-dim: #ff7f8b; + --green: #95d5a7; + --green-dim: #70c789; + --yellow: #b8cf84; + --yellow-dim: #a3c15f; + --blue: #bdc2ff; + --blue-dim: #8a93ff; + --magenta: #e4b7f3; + --magenta-dim: #d48bec; + --cyan: #82d3e2; + --cyan-dim: #59c4d8; --white: #82d3e0; } diff --git a/styles/notification-center.css b/styles/notification-center.css new file mode 100644 index 0000000..f310ecb --- /dev/null +++ b/styles/notification-center.css @@ -0,0 +1,74 @@ +#noti-center-box { + background-color: alpha(#001, 0.3); + border: 1px solid alpha(#111, 0.3); + border-radius: 16px; + padding: 20px; + margin: 6px; +} + +#noti-clear-button { + min-width: 10px; + min-height: 40px; + border-radius: 50px; + margin-top: 10px; +} + +#noti-clear-button:hover { + background-color: var(--surface-bright); +} + +/* Stacked notification group styles */ +.stacked-notification-group { + margin: 8px 0; +} + +.stacked-notification-container { +} + +.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); + border-radius: 12px; + transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +#notif-clear-btn { + border-radius: 10px; + font-size: 12px; + padding: 2px; +} + +#notif-clear-btn:hover { + background-color: var(--surface-bright); +} + +#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); +} + +/* Stack count indicator */ +#stack-count-indicator { + font-size: 10px; + color: rgba(255, 255, 255, 0.7); + margin: 2px 8px; + font-weight: 500; +} + +/* Expanded view styles - notifications use their default styling */ +#expanded-notification-list { + margin-top: 8px; +} + +/* Make expanded notifications clickable with subtle hover effect */ +#expanded-notification-list:hover { + background-color: alpha(#111, 0.1); + border-radius: 8px; + transition: background-color 0.2s ease; +} diff --git a/styles/notification.css b/styles/notification.css index e5fa3ac..366ba0a 100644 --- a/styles/notification.css +++ b/styles/notification.css @@ -10,55 +10,73 @@ transition: all 0.25s cubic-bezier(0.25, 0.46, 0.45, 0.94); } -#notification .summary { - font-size: 20px; +#notification-action-buttons button.start-action { + border-radius: 20px; +} + +#notification-action-buttons button { + background-color: alpha(#000, 0.3); + padding: 8px; + border-radius: 20px; + transition: background-color 0.1s ease; +} + +#notification-action-buttons button:hover { + background-color: var(--surface-bright); +} + +#notification-action-buttons button:active { + background-color: var(--primary); +} + +#notification-action-buttons button:active #button-label { + color: var(--shadow); +} + +#notification-image image { + border-radius: 16px; + color: var(--on-surface); +} + +#notification-summary, +#button-label { font-weight: bold; } -#notification .body { - color: #ddd; - margin-top: 0.5rem; - font-weight: normal; +#notification-summary { + font-weight: bold; + color: var(--primary); } -#noti-image { - border-radius: 7px; +#notification-app-name { + color: var(--outline); } -#env-noti-center { - border-radius: 1.25rem; +#action-button { + margin-top: 8px; } -#notification-action { - background-color: #e0e0e0; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-weight: 500; - font-size: 0.9rem; - transition: - background-color 0.2s ease, - color 0.2s ease; -} -#notification-action label { - color: #000000; -} -#notification-action:hover label { - color: #ffffff; +#notif-close-button { + background-color: var(--surface); + border-radius: 16px; + padding: 4px; + transition: background-color 0.1s ease; } -#noti-image image { - border-radius: 25px; -} -#notification-action:hover { - background-color: #007aff; /* macOS-style blue */ - color: #ffffff; +#notif-close-button:hover, +#notif-close-button:focus { + background-color: var(--surface-bright); } -#notification-center { - background-color: alpha(#000, 0.3); - box-shadow: inset 0 0 0 1px alpha(#aaa, 0.4); - border: 1px solid alpha(#111, 0.4); - border-radius: 1.25rem; - padding: 1rem; +#notif-close-button:active { + background-color: var(--red-dim); +} + +#notif-close-label { + color: var(--red-dim); + font-size: 20px; +} + +#notif-close-button:active #notif-close-label { + color: var(--shadow); } diff --git a/utils/roam.py b/utils/roam.py index a8f7c45..4127d98 100644 --- a/utils/roam.py +++ b/utils/roam.py @@ -1,27 +1,41 @@ -import sys - from loguru import logger from fabric.audio import Audio -from services.modus import ModusService + +from services.modus import ModusService, notification_service as notification_service_instance global modus_service try: modus_service = ModusService() except Exception as e: - logger.error("[Main] Failed to create ModusService:", e) - sys.exit(1) + logger.error(f"[Main] Failed to create ModusService: {e}") + modus_service = None -# global notification_service -# try: -# notification_service = Notifications() -# except Exception as e: -# logger.error("[Main] Failed to create NotificationService:", e) -# sys.exit(1) +if modus_service is None: + logger.warning( + "[Main] ModusService was not initialized. Functionality may be limited." + ) + +global notification_service +try: + notification_service = notification_service_instance +except Exception as e: + logger.error(f"[Main] Failed to create NotificationService: {e}") + notification_service = None + +if notification_service is None: + logger.warning( + "[Main] NotificationService was not initialized. Notifications may not work." + ) global audio_service try: audio_service = Audio() except Exception as e: - logger.error("[Main] Failed to create AudioService:", e) - sys.exit(1) + logger.error(f"[Main] Failed to create AudioService: {e}") + audio_service = None + +if audio_service is None: + logger.warning( + "[Main] AudioService was not initialized. Audio features may not work." + )