This commit is contained in:
sankalp committed 2025-06-28 23:35:29 +05:45
1 parent f0fcfe5531
commit ca4ac4ffad
11 files changed
+1570 -650

No files matched your search

+222 -135
View File
@@ -1,70 +1,89 @@
import json
import locale
import os
import uuid
from datetime import datetime, timedelta
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import GdkPixbuf, GLib, Gtk
from loguru import logger
from gi.repository import GLib, Gtk, Gdk
import config.data as data
import utils.icons as icons
from modules.notification_popup import NotificationBox, cache_notification_pixbuf, load_scaled_pixbuf
from modules.notification_popup import NotificationBox, load_scaled_pixbuf, get_shared_notification_history
from utils.custom_image import CustomImage
from utils.wayland import WaylandWindow as Window
PERSISTENT_DIR = f"/tmp/{data.APP_NAME}/notifications"
PERSISTENT_HISTORY_FILE = os.path.join(PERSISTENT_DIR, "notification_history.json")
CONFIG_FILE = get_relative_path("../../../config/assets/config.json")
class NotificationIndicator(Button):
def __init__(self, **kwargs):
super().__init__(name="button-bar-notifications", **kwargs)
# Create the notification icon
self.notification_icon = Label(
name="notification-icon",
markup=icons.notifications
name="notification-icon", markup=icons.notifications
)
# Add the icon directly
self.add(self.notification_icon)
# Connect click handler
self.connect("clicked", self.on_clicked)
# Set default tooltip
self.set_tooltip_text("Notifications")
# Popup window for showing notifications
self.popup_window = None
self._connect_to_dnd_state()
def on_clicked(self, _button):
"""Handle click to show/hide notification popup."""
if self.popup_window and self.popup_window.get_visible():
self.popup_window.set_visible(False)
else:
self.show_notifications_popup()
def show_notifications_popup(self):
"""Show popup window with notification history."""
if self.popup_window:
self.popup_window.destroy()
print(f"[NotificationIndicator] Opening notification history popup")
# Create popup window using the notification history directly
self.popup_window = NotificationHistoryWindow()
self.popup_window.show_all()
def _connect_to_dnd_state(self):
try:
self.notification_history = get_shared_notification_history()
self.notification_history.connect('dnd-state-changed', self._on_dnd_state_changed)
self._update_icon_for_dnd_state(self.notification_history.do_not_disturb_enabled)
except Exception as e:
print(f"Could not connect to DND state: {e}")
def _on_dnd_state_changed(self, notification_history, dnd_enabled):
self._update_icon_for_dnd_state(dnd_enabled)
def _update_icon_for_dnd_state(self, dnd_enabled):
if dnd_enabled:
self.notification_icon.set_markup(icons.notifications_off)
self.set_tooltip_text("Do Not Disturb enabled")
else:
self.notification_icon.set_markup(icons.notifications)
self.set_tooltip_text("Notifications")
class HistoricalNotification(object):
def __init__(self, id, app_icon, summary, body, app_name, timestamp, cached_image_path=None):
def __init__(
self, id, app_icon, summary, body, app_name, timestamp, cached_image_path=None
):
self.id = id
self.app_icon = app_icon
self.summary = summary
@@ -79,11 +98,7 @@ class HistoricalNotification(object):
class NotificationHistory(Box):
def __init__(self, **kwargs):
super().__init__(
name="notification-history",
orientation="v",
**kwargs
)
super().__init__(name="notification-history", orientation="v", **kwargs)
self.containers = []
self.header_label = Label(
@@ -148,7 +163,10 @@ class NotificationHistory(Box):
propagate_width=False,
propagate_height=False,
)
self.scrolled_window_viewport_box = Box(orientation="v", children=[self.notifications_list, self.no_notifications_box])
self.scrolled_window_viewport_box = Box(
orientation="v",
children=[self.notifications_list, self.no_notifications_box],
)
self.scrolled_window.add_with_viewport(self.scrolled_window_viewport_box)
self.persistent_notifications = []
self.add(self.history_header)
@@ -157,13 +175,72 @@ class NotificationHistory(Box):
self._cleanup_orphan_cached_images()
self.schedule_midnight_update()
self._connect_to_shared_history()
self._load_and_sync_dnd_state()
self.LIMITED_APPS_HISTORY = ["Spotify"]
def _connect_to_shared_history(self):
try:
shared_history = get_shared_notification_history()
shared_history.connect('notification-added', self._on_shared_notification_added)
except Exception as e:
print(f"Could not connect to shared notification history: {e}")
def _on_shared_notification_added(self, shared_history):
GLib.idle_add(self._refresh_history)
def _refresh_history(self):
try:
for child in self.notifications_list.get_children()[:]:
self.notifications_list.remove(child)
if hasattr(child, 'destroy'):
child.destroy()
self.containers.clear()
if os.path.exists(PERSISTENT_HISTORY_FILE):
with open(PERSISTENT_HISTORY_FILE, "r") as f:
self.persistent_notifications = json.load(f)
for note in reversed(self.persistent_notifications[-50:]):
self._add_historical_notification(note)
self.update_no_notifications_label_visibility()
except Exception as e:
print(f"Error refreshing notification history: {e}")
def _load_and_sync_dnd_state(self):
try:
dnd_enabled = False
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, "r") as f:
config_data = json.load(f)
dnd_enabled = config_data.get("dnd_enabled", False)
shared_history = get_shared_notification_history()
shared_history.do_not_disturb_enabled = dnd_enabled
self.header_switch.set_active(dnd_enabled)
self.do_not_disturb_enabled = dnd_enabled
except Exception as e:
print(f"Could not sync DND state: {e}")
def get_ordinal(self, n):
if 11 <= (n % 100) <= 13:
return 'th'
return "th"
else:
return {1: 'st', 2: 'nd', 3: 'rd'}.get(n % 10, 'th')
return {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
def get_date_header(self, dt):
now = datetime.now()
@@ -176,9 +253,9 @@ class NotificationHistory(Box):
else:
original_locale = locale.getlocale(locale.LC_TIME)
try:
locale.setlocale(locale.LC_TIME, ('en_US', 'UTF-8'))
locale.setlocale(locale.LC_TIME, ("en_US", "UTF-8"))
except locale.Error:
locale.setlocale(locale.LC_TIME, 'C')
locale.setlocale(locale.LC_TIME, "C")
try:
day = dt.day
ordinal = self.get_ordinal(day)
@@ -193,7 +270,9 @@ class NotificationHistory(Box):
def schedule_midnight_update(self):
now = datetime.now()
next_midnight = datetime.combine(now.date() + timedelta(days=1), datetime.min.time())
next_midnight = datetime.combine(
now.date() + timedelta(days=1), datetime.min.time()
)
delta_seconds = (next_midnight - now).total_seconds()
GLib.timeout_add_seconds(int(delta_seconds), self.on_midnight)
@@ -212,7 +291,7 @@ class NotificationHistory(Box):
h_align="center",
h_expand=True,
)
]
],
)
def rebuild_with_separators(self):
@@ -225,7 +304,9 @@ class NotificationHistory(Box):
current_date_header = None
last_date_header = None
for container in sorted(self.containers, key=lambda x: x.arrival_time, reverse=True):
for container in sorted(
self.containers, key=lambda x: x.arrival_time, reverse=True
):
arrival_time = container.arrival_time
date_header = self.get_date_header(arrival_time)
if date_header != current_date_header:
@@ -245,56 +326,58 @@ class NotificationHistory(Box):
def on_do_not_disturb_changed(self, switch, pspec):
self.do_not_disturb_enabled = switch.get_active()
logger.info(f"Do Not Disturb mode {'enabled' if self.do_not_disturb_enabled else 'disabled'}")
try:
shared_history = get_shared_notification_history()
shared_history.set_do_not_disturb_enabled(self.do_not_disturb_enabled)
except Exception as e:
print(f"Could not update shared DND state: {e}")
def clear_history(self, *args):
for child in self.notifications_list.get_children()[:]:
container = child
notif_box = container.notification_box if hasattr(container, "notification_box") else None
notif_box = (
container.notification_box
if hasattr(container, "notification_box")
else None
)
if notif_box:
notif_box.destroy(from_history_delete=True)
self.notifications_list.remove(child)
child.destroy()
# Clear notifications from the fabric service
try:
# Clear all notifications from the fabric service
# Note: fabric Notifications doesn't have clear_all_notifications method
# so we just clear our local history
logger.info("Notification history cleared.")
except Exception as e:
logger.error(f"Error clearing notification history: {e}")
# Also clear persistent file if it exists
if os.path.exists(PERSISTENT_HISTORY_FILE):
try:
os.remove(PERSISTENT_HISTORY_FILE)
logger.info("Notification history cleared and persistent file deleted.")
except Exception as e:
logger.error(f"Error deleting persistent history file: {e}")
print(f"Error deleting persistent history file: {e}")
self.persistent_notifications = []
self.containers = []
self.rebuild_with_separators()
def _load_persistent_history(self):
"""Load notifications from persistent file."""
if not os.path.exists(PERSISTENT_DIR):
os.makedirs(PERSISTENT_DIR, exist_ok=True)
# Load from persistent file
if os.path.exists(PERSISTENT_HISTORY_FILE):
try:
with open(PERSISTENT_HISTORY_FILE, "r") as f:
self.persistent_notifications = json.load(f)
logger.info(f"Loading {len(self.persistent_notifications)} notifications from persistent history")
# Add notifications to display (in reverse order to show newest first)
for note in reversed(self.persistent_notifications[-50:]): # Last 50 notifications
for note in reversed(self.persistent_notifications[-50:]):
self._add_historical_notification(note)
except Exception as e:
logger.error(f"Error loading persistent history: {e}")
print(f"Error loading persistent history: {e}")
self.persistent_notifications = []
else:
self.persistent_notifications = []
@@ -306,7 +389,7 @@ class NotificationHistory(Box):
with open(PERSISTENT_HISTORY_FILE, "w") as f:
json.dump(self.persistent_notifications, f)
except Exception as e:
logger.error(f"Error saving persistent history: {e}")
print(f"Error saving persistent history: {e}")
def update_no_notifications_label_visibility(self):
has_notifications = bool(self.containers)
@@ -314,35 +397,39 @@ class NotificationHistory(Box):
self.notifications_list.set_visible(has_notifications)
def _cleanup_orphan_cached_images(self):
logger.debug("Starting orphan cached image cleanup.")
if not os.path.exists(PERSISTENT_DIR):
logger.debug("Cache directory does not exist, skipping cleanup.")
return
cached_files = [f for f in os.listdir(PERSISTENT_DIR) if f.startswith("notification_") and f.endswith(".png")]
cached_files = [
f
for f in os.listdir(PERSISTENT_DIR)
if f.startswith("notification_") and f.endswith(".png")
]
if not cached_files:
logger.debug("No cached image files found, skipping cleanup.")
return
history_uuids = {note.get("id") for note in self.persistent_notifications if note.get("id")}
history_uuids = {
note.get("id") for note in self.persistent_notifications if note.get("id")
}
deleted_count = 0
for cached_file in cached_files:
try:
uuid_from_filename = cached_file[len("notification_"):-len(".png")]
uuid_from_filename = cached_file[len("notification_") : -len(".png")]
if uuid_from_filename not in history_uuids:
cache_file_path = os.path.join(PERSISTENT_DIR, cached_file)
os.remove(cache_file_path)
logger.info(f"Deleted orphan cached image: {cache_file_path}")
deleted_count += 1
else:
logger.debug(f"Cached image {cached_file} found in history, keeping it.")
except Exception as e:
logger.error(f"Error processing cached file {cached_file} during cleanup: {e}")
if deleted_count > 0:
logger.info(f"Orphan cached image cleanup finished. Deleted {deleted_count} images.")
else:
logger.info("Orphan cached image cleanup finished. No orphan images found.")
deleted_count += 1
except Exception as e:
print(
f"Error processing cached file {cached_file} during cleanup: {e}"
)
def _add_historical_notification(self, note):
hist_notif = HistoricalNotification(
@@ -388,11 +475,9 @@ class NotificationHistory(Box):
name="notification-image",
orientation="v",
children=[
CustomImage(
pixbuf=load_scaled_pixbuf(hist_box, 48, 48)
),
CustomImage(pixbuf=load_scaled_pixbuf(hist_box, 48, 48)),
Box(v_expand=True),
]
],
)
self.hist_notif_summary_label = Label(
name="notification-summary",
@@ -408,23 +493,41 @@ class NotificationHistory(Box):
ellipsization="end",
)
self.hist_notif_body_label = Label(
name="notification-body",
markup=hist_notif.body,
h_align="start",
ellipsization="end",
line_wrap="word-char",
) if hist_notif.body else Box()
self.hist_notif_body_label.set_single_line_mode(True) if hist_notif.body else None
self.hist_notif_body_label = (
Label(
name="notification-body",
markup=hist_notif.body,
h_align="start",
ellipsization="end",
line_wrap="word-char",
)
if hist_notif.body
else Box()
)
self.hist_notif_body_label.set_single_line_mode(
True
) if hist_notif.body else None
self.hist_notif_summary_box = Box(
name="notification-summary-box",
orientation="h",
children=[
self.hist_notif_summary_label,
Box(name="notif-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
Box(
name="notif-sep",
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
),
self.hist_notif_app_name_label,
Box(name="notif-sep", h_expand=False, v_expand=False, h_align="center", v_align="center"),
Box(
name="notif-sep",
h_expand=False,
v_expand=False,
h_align="center",
v_align="center",
),
self.hist_time_label,
],
)
@@ -441,7 +544,9 @@ class NotificationHistory(Box):
self.hist_notif_close_button = Button(
name="notif-close-button",
child=Label(name="notif-close-label", markup=icons.cancel),
on_clicked=lambda *_: self.delete_historical_notification(hist_notif.id, container),
on_clicked=lambda *_: self.delete_historical_notification(
hist_notif.id, container
),
)
self.hist_notif_close_button_box = Box(
orientation="v",
@@ -482,9 +587,8 @@ class NotificationHistory(Box):
if removed_from_list:
self.persistent_notifications = new_persistent_notifications
logger.info(f"Notification with ID {target_note_id_str} was marked for removal from persistent_notifications list.")
else:
logger.warning(f"Notification with ID {target_note_id_str} was NOT found in persistent_notifications list. The list remains unchanged.")
self._save_persistent_history()
container.destroy()
@@ -494,10 +598,10 @@ class NotificationHistory(Box):
class NotificationHistoryWindow(Window):
def __init__(self):
# Create the notification history widget directly
self.notification_history = NotificationHistory()
# Create scrolled window with proper sizing
scrolled = ScrolledWindow(
name="notifications-scrolled",
child=self.notification_history,
@@ -508,7 +612,7 @@ class NotificationHistoryWindow(Window):
h_expand=True,
v_expand=True,
propagate_width=False,
propagate_height=False
propagate_height=False,
)
main_box = Box(
@@ -517,20 +621,17 @@ class NotificationHistoryWindow(Window):
spacing=8,
h_expand=True,
v_expand=True,
children=[scrolled]
children=[scrolled],
)
# Determine popup position based on dock position
dock_position = data.DOCK_POSITION
popup_anchor = self._get_popup_anchor(dock_position)
print(f"[NotificationPopup] Dock position: {dock_position}, Popup anchor: {popup_anchor}")
# Get margin based on dock position
margin = self._get_popup_margin(dock_position)
super().__init__(
name="notifications-popup-window",
anchor=popup_anchor,
margin=margin,
child=main_box,
@@ -538,89 +639,75 @@ class NotificationHistoryWindow(Window):
exclusive=False,
keyboard_mode="on-demand",
visible=True,
all_visible=True
all_visible=True,
)
# Auto-hide when clicking outside or pressing escape
self.connect("button-press-event", self.on_button_press)
self.connect("key-press-event", self.on_key_press)
# Make sure the window can receive key events
self.set_can_focus(True)
self.grab_focus()
# Set tooltip with keyboard shortcuts
self.set_tooltip_text("Keyboard shortcuts:\n• Escape: Close popup\n• Ctrl+D: Toggle DND\n• Ctrl+A: Clear all notifications")
self.set_tooltip_text(
"Keyboard shortcuts:\n• Escape: Close popup\n• Ctrl+D: Toggle DND\n• Ctrl+A: Clear all notifications"
)
def _get_popup_anchor(self, dock_position):
"""Determine popup anchor position based on dock position."""
# Position popup on the opposite side of the dock for better UX
anchor_map = {
"Top": "top", # Dock at top -> popup at top right
"Bottom": "bottom", # Dock at bottom -> popup at bottom right
"Left": "left", # Dock at left -> popup at top left
"Right": "right" # Dock at right -> popup at top right
"Top": "top", # Dock at top -> popup at top right
"Bottom": "bottom", # Dock at bottom -> popup at bottom right
"Left": "left", # Dock at left -> popup at top left
"Right": "right", # Dock at right -> popup at top right
}
return anchor_map.get(dock_position, "bottom") # Default fallback
return anchor_map.get(dock_position, "bottom")
def _get_popup_margin(self, dock_position):
"""Get appropriate margin based on dock position."""
# Add margin to avoid overlapping with dock
margin_map = {
"Top": "60px 10px 10px 10px", # Top dock -> margin from top
"Bottom": "10px 10px 60px 10px", # Bottom dock -> margin from bottom
"Left": "10px 10px 10px 60px", # Left dock -> margin from left
"Right": "10px 60px 10px 10px" # Right dock -> margin from right
"Top": "60px 10px 10px 10px",
"Bottom": "10px 10px 60px 10px",
"Left": "10px 10px 10px 60px",
"Right": "10px 60px 10px 10px",
}
return margin_map.get(dock_position, "10px 10px 10px 10px") # Default fallback
return margin_map.get(dock_position, "10px 10px 10px 10px")
def refresh_popup(self):
"""Refresh the popup content by updating the notification history."""
# The notification history will automatically update itself
# when notifications are added/removed from the service
pass
def on_key_press(self, _widget, event):
"""Handle key press events."""
from gi.repository import Gdk
# Check if Escape key was pressed
if event.keyval == Gdk.KEY_Escape:
print(f"[NotificationPopup] Escape key pressed, closing popup")
self.set_visible(False)
return True # Event handled
# Check for Ctrl+D to toggle DND
self.set_visible(False)
return True
elif event.state & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_d:
print(f"[NotificationPopup] Ctrl+D pressed, toggling DND")
# Toggle DND via the notification history
current_dnd = self.notification_history.do_not_disturb_enabled
self.notification_history.header_switch.set_active(not current_dnd)
return True # Event handled
return True
# Check for Ctrl+A to clear all notifications
elif event.state & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_a:
print(f"[NotificationPopup] Ctrl+A pressed, clearing all notifications")
self.notification_history.clear_history()
return True # Event handled
return True
return False # Let other handlers process the event
def on_button_press(self, _widget, _event):
"""Hide popup when clicking outside."""
# This is a simple implementation - in a real scenario you'd want
# more sophisticated outside-click detection
return False
def on_button_press(self, _widget, _event):
return False
class Notifications(Box):
def __init__(self, **kwargs):
super().__init__(
name="notifications",
orientation="h" if not data.VERTICAL else "v",
spacing=4,
children=[NotificationIndicator()],
**kwargs
**kwargs,
)
self.show_all()