✨ feat(notification): implement notification center with persistent storage and DND support
This commit is contained in:
15 files changed
+1260
-472
No files matched your search
@@ -0,0 +1,4 @@
|
||||
<svg viewBox="-10 -10 45 45" width="28" height="28">
|
||||
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="#fff"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 140 B |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
)
|
||||
|
||||
+51
-3
@@ -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;")
|
||||
@@ -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
|
||||
+20
-41
@@ -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
|
||||
|
||||
+24
-24
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+56
-38
@@ -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);
|
||||
}
|
||||
+27
-13
@@ -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."
|
||||
)
|
||||
Reference in new issue
Block a user