From 01a2d1aecc5e229de9c6de2de97a95516cf4a553 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sat, 21 Jun 2025 21:30:45 +0545 Subject: [PATCH] added notification history plugins --- .gitignore | 11 +- config/data.py | 4 +- config/dock.json | 3 - config/{ => json}/emoji.json | 0 config/{ => json}/launcher.json | 5 + config/otp_accounts.json | 23 - config/settings/gui.py | 4 +- config/settings/utils.py | 2 +- main.py | 1 - modules/dock/components/applications.py | 12 +- modules/launcher/main.py | 30 +- modules/launcher/plugin_manager.py | 3 +- modules/launcher/plugins/emoji.py | 2 +- modules/launcher/plugins/notifications.py | 543 ++++++++++++++++++++++ modules/launcher/plugins/otp.py | 4 +- modules/launcher/trigger_config.py | 11 +- modules/notification_popup.py | 35 +- styles/launcher.css | 38 ++ 18 files changed, 651 insertions(+), 80 deletions(-) delete mode 100644 config/dock.json rename config/{ => json}/emoji.json (100%) rename config/{ => json}/launcher.json (94%) delete mode 100644 config/otp_accounts.json create mode 100644 modules/launcher/plugins/notifications.py diff --git a/.gitignore b/.gitignore index 596ee7c..613b8df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -__pycache__ -config/assets/config.json -config/dock.conf -config/config.json +**/__pycache__/ +*.pyc +*.pyo +*.pyd +config/json/config.json +config/json/dock.json +config/json/accounts.json diff --git a/config/data.py b/config/data.py index cec9739..dbe70f0 100644 --- a/config/data.py +++ b/config/data.py @@ -28,7 +28,7 @@ CURRENT_HEIGHT = screen.get_height() WALLPAPERS_DIR_DEFAULT = get_relative_path("../assets/wallpapers_example") -CONFIG_FILE = get_relative_path("../config/config.json") +CONFIG_FILE = get_relative_path("../config/json/config.json") MATUGEN_STATE_FILE = os.path.join(CONFIG_DIR, "matugen") WORKSPACE_NUMS = False @@ -40,7 +40,7 @@ DOCK_THEME = "Pills" def load_config(): """Load the configuration from config.json""" - config_path = os.path.expanduser(f"~/{APP_NAME_CAP}/config/config.json") + config_path = os.path.expanduser(f"~/{APP_NAME_CAP}/config/json/config.json") config = {} if os.path.exists(config_path): diff --git a/config/dock.json b/config/dock.json deleted file mode 100644 index 7564bbf..0000000 --- a/config/dock.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "pinned_apps": [] -} \ No newline at end of file diff --git a/config/emoji.json b/config/json/emoji.json similarity index 100% rename from config/emoji.json rename to config/json/emoji.json diff --git a/config/launcher.json b/config/json/launcher.json similarity index 94% rename from config/launcher.json rename to config/json/launcher.json index 939bd41..d48ef8a 100644 --- a/config/launcher.json +++ b/config/json/launcher.json @@ -109,6 +109,11 @@ "otp": { "icon": "auth", "description": "Manage TOTP codes and 2FA authentication" + }, + "notif": { + "icon": "notifications", + "description": "Notification History - Search and manage notification history", + "examples": ["notif", "notif clear", "notif search term"] } }, "settings": { diff --git a/config/otp_accounts.json b/config/otp_accounts.json deleted file mode 100644 index f28947f..0000000 --- a/config/otp_accounts.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "newww": { - "secret": "K3D4IHGVKQ2GQAJDCT7OSL4IS6PRTLXU", - "issuer": "", - "algorithm": "SHA1", - "digits": 6, - "period": 30 - }, - "n": { - "secret": "K3D4IHGVKQ2GQAJDCT7OSL4IS6PRTLXU", - "issuer": "", - "algorithm": "SHA1", - "digits": 6, - "period": 30 - }, - "qq": { - "secret": "K3D4IHGVKQ2GQAJDCT7OSL4IS6PRTLXU", - "issuer": "", - "algorithm": "SHA1", - "digits": 6, - "period": 30 - } -} \ No newline at end of file diff --git a/config/settings/gui.py b/config/settings/gui.py index de7ae55..9b7800a 100644 --- a/config/settings/gui.py +++ b/config/settings/gui.py @@ -906,7 +906,9 @@ class HyprConfGUI(Window): start_time = time.time() print(f"{start_time:.4f}: Background task started.") - config_json = os.path.expanduser(f"~/{APP_NAME_CAP}/config/config.json") + config_json = os.path.expanduser( + f"~/{APP_NAME_CAP}/config/json/config.json" + ) os.makedirs(os.path.dirname(config_json), exist_ok=True) try: with open(config_json, "w") as f: diff --git a/config/settings/utils.py b/config/settings/utils.py index 02ffcce..633eccf 100644 --- a/config/settings/utils.py +++ b/config/settings/utils.py @@ -192,7 +192,7 @@ def load_bind_vars(): # Usar .copy() para no modificar DEFAULTS accidentalmente bind_vars.update(settings_constants.DEFAULTS.copy()) - config_json = os.path.expanduser(f"~/{APP_NAME_CAP}/config/config.json") + config_json = os.path.expanduser(f"~/{APP_NAME_CAP}/config/json/config.json") if os.path.exists(config_json): try: with open(config_json, "r") as f: diff --git a/main.py b/main.py index 9220dc6..000f54f 100644 --- a/main.py +++ b/main.py @@ -75,5 +75,4 @@ if __name__ == "__main__": app.set_css() - app.run() diff --git a/modules/dock/components/applications.py b/modules/dock/components/applications.py index 0fdc4f1..0c2279c 100644 --- a/modules/dock/components/applications.py +++ b/modules/dock/components/applications.py @@ -14,7 +14,7 @@ from utils.icon_resolver import IconResolver def read_config(): """Read and return the full configuration from the JSON file, handling missing file.""" - config_path = get_relative_path("../../../config/dock.json") + config_path = get_relative_path("../../../config/json/dock.json") try: with open(config_path, "r") as file: config_data = json.load(file) @@ -80,7 +80,7 @@ class Applications(Box): self.conn = get_hyprland_connection() self.icon_resolver = IconResolver() self.pinned = self.config.get("pinned_apps", []) - self.config_path = get_relative_path("../../../config/dock.json") + self.config_path = get_relative_path("../../../config/json/dock.json") self.app_map = {} self._all_apps = get_desktop_applications() self.app_identifiers = self._build_app_identifiers_map() @@ -254,7 +254,7 @@ class Applications(Box): ) def update_pinned_apps_file(self): - config_path = get_relative_path("../../../config/dock.json") + config_path = get_relative_path("../../../config/json/dock.json") try: with open(config_path, "w") as file: json.dump(self.config, file, indent=4) @@ -558,7 +558,7 @@ class Applications(Box): self.dock_instance.prevent_hiding(False) # Check if the drag ended outside the dock - if hasattr(self, '_dragged_widget') and self._dragged_widget: + if hasattr(self, "_dragged_widget") and self._dragged_widget: children = self.get_children() source_index = children.index(self._dragged_widget) @@ -579,8 +579,8 @@ class Applications(Box): self.update_applications() # Clear the dragged widget reference - if hasattr(self, '_dragged_widget'): - delattr(self, '_dragged_widget') + if hasattr(self, "_dragged_widget"): + delattr(self, "_dragged_widget") def get_focused(self): try: diff --git a/modules/launcher/main.py b/modules/launcher/main.py index 95c5929..5de60e4 100644 --- a/modules/launcher/main.py +++ b/modules/launcher/main.py @@ -791,10 +791,9 @@ class Launcher(Window): self.selected_index += 1 self._update_selection() else: - # At last result, go to header buttons - self.focus_mode = "header" - self.header_button_index = 0 - self._update_header_focus() + # At last result, wrap around to first result + self.selected_index = 0 + self._update_selection() elif self.results: # If not in results mode but have results, enter results mode at first item self.focus_mode = "results" @@ -810,13 +809,19 @@ class Launcher(Window): self.header_buttons[self.header_button_index].emit("clicked") return True - # Check for Shift+Enter to pin application + # Check for Shift+Enter for alternative actions if event.state & Gdk.ModifierType.SHIFT_MASK: if self.results and 0 <= self.selected_index < len(self.results): result = self.results[self.selected_index] - if result.data and result.data.get("pin_action"): - result.data["pin_action"]() - return True + if result.data: + # Check for generic alternative action first + if result.data.get("alt_action"): + result.data["alt_action"]() + return True + # Fallback to pin_action for backward compatibility + elif result.data.get("pin_action"): + result.data["pin_action"]() + return True # Check if the selected result has a custom widget with Entry fields if self.results and 0 <= self.selected_index < len(self.results): @@ -1197,16 +1202,15 @@ class Launcher(Window): self._focus_search_entry_without_selection() def _navigate_results_forward(self): - """Navigate to next result or exit results mode.""" + """Navigate to next result or wrap around to first result.""" if self.results and self.selected_index < len(self.results) - 1: # Move to next result self.selected_index += 1 self._update_selection() else: - # Exit results mode and go to header - self.focus_mode = "header" - self.header_button_index = 0 - self._update_header_focus() + # At last result, wrap around to first result + self.selected_index = 0 + self._update_selection() def _navigate_results_backward(self): """Navigate to previous result or exit results mode.""" diff --git a/modules/launcher/plugin_manager.py b/modules/launcher/plugin_manager.py index b3a6601..7afedd5 100644 --- a/modules/launcher/plugin_manager.py +++ b/modules/launcher/plugin_manager.py @@ -98,7 +98,8 @@ class PluginManager: "reminders", "network", "bluetooth", - "otp" + "otp", + "notifications", ] for plugin_name in default_plugins: diff --git a/modules/launcher/plugins/emoji.py b/modules/launcher/plugins/emoji.py index bb42dda..de614ac 100644 --- a/modules/launcher/plugins/emoji.py +++ b/modules/launcher/plugins/emoji.py @@ -24,7 +24,7 @@ class EmojiPlugin(PluginBase): self.display_name = "Emoji" self.description = "Search and copy emojis" self.emoji_data = {} - self.emoji_path = get_relative_path("../../../config/emoji.json") + self.emoji_path = get_relative_path("../../../config/json/emoji.json") def initialize(self): """Initialize the emoji plugin.""" diff --git a/modules/launcher/plugins/notifications.py b/modules/launcher/plugins/notifications.py new file mode 100644 index 0000000..12606eb --- /dev/null +++ b/modules/launcher/plugins/notifications.py @@ -0,0 +1,543 @@ +""" +Notification History plugin for the launcher. +Provides access to notification history with search, view, and management capabilities. +""" + +import time +from datetime import datetime +from typing import List, Optional +from modules.launcher.plugin_base import PluginBase +from modules.launcher.result import Result +from services import notification_service +from gi.repository import GdkPixbuf, GLib +from fabric.widgets.box import Box +from fabric.widgets.label import Label +from fabric.widgets.button import Button +import utils.icons as icons + + +class NotificationDetailWidget(Box): + """Widget for displaying detailed notification content.""" + + def __init__(self, notification_data: dict, plugin_instance): + super().__init__(name="notification-detail", orientation="v", spacing=8) + + self.notification_data = notification_data + self.plugin = plugin_instance + + # Set size constraints to fit within launcher + self.set_size_request(530, 200) + self.set_vexpand(False) + self.set_hexpand(True) + + self.setup_ui() + + def setup_ui(self): + """Setup the detail view UI.""" + # Header with title and close button + header_box = Box(orientation="h", spacing=8) + + # Title + title_label = Label( + label=f"📧 {self.notification_data.get('app_name', 'Unknown')}", + name="notification-detail-title", + h_align="start", + h_expand=True + ) + header_box.add(title_label) + + # Close button + close_btn = Button( + child=Label(markup=icons.cancel, name="notification-close-icon"), + name="notification-close-btn", + on_clicked=lambda *_: self.plugin._close_detail_view() + ) + header_box.add(close_btn) + + self.add(header_box) + + # Content area + content_box = Box(orientation="v", spacing=4) + + # Summary + summary = self.notification_data.get('summary', 'No title') + summary_label = Label( + label=f"Subject: {summary}", + name="notification-detail-summary", + h_align="start", + ellipsize="end" + ) + content_box.add(summary_label) + + # Body content + body = self.notification_data.get('body', '') + if body: + body_label = Label( + label=body, + name="notification-detail-body", + h_align="start", + v_align="start", + ellipsize="none" + ) + # Set line wrapping using properties + body_label.set_property("wrap", True) + body_label.set_property("wrap-mode", 2) # WORD wrap mode + content_box.add(body_label) + + # Timestamp + timestamp = self.notification_data.get('timestamp', time.time()) + time_str = self.plugin._format_timestamp(timestamp) + time_label = Label( + label=f"Time: {time_str}", + name="notification-detail-time", + h_align="start" + ) + content_box.add(time_label) + + self.add(content_box) + + # Action buttons + actions_box = Box(orientation="h", spacing=8) + + # Remove button + remove_btn = Button( + child=Label(markup=f"{icons.trash} Remove", name="notification-action-label"), + name="notification-action-btn", + on_clicked=lambda *_: self._remove_notification() + ) + actions_box.add(remove_btn) + + self.add(actions_box) + + def _remove_notification(self): + """Remove this notification.""" + notif_id = self.notification_data.get('notification_id', 0) + self.plugin._clear_notification(notif_id) + + +class NotificationsPlugin(PluginBase): + def __init__(self): + super().__init__() + self.name = "notifications" + self.display_name = "Notification History" + self.description = "Search and manage notification history" + + # Settings + self.max_results = 15 + self.notification_service = notification_service + self.showing_detail_for = None # Track which notification is showing details + + def initialize(self): + """Initialize the plugin.""" + self.set_triggers(["notif", "notif "]) + + # Connect to notification service signals to refresh when notifications change + self.notification_service.connect("notification_count", self._on_notification_count_changed) + + def cleanup(self): + """Cleanup the plugin.""" + # Disconnect from notification service signals + try: + self.notification_service.disconnect("notification_count", self._on_notification_count_changed) + except: + pass + + def _format_timestamp(self, timestamp: float) -> str: + """Format timestamp to human-readable string.""" + try: + dt = datetime.fromtimestamp(timestamp) + now = datetime.now() + + # If today, show time only + if dt.date() == now.date(): + return dt.strftime("%H:%M") + + # If this week, show day and time + days_diff = (now.date() - dt.date()).days + if days_diff < 7: + return dt.strftime("%a %H:%M") + + # Otherwise show date + return dt.strftime("%m/%d %H:%M") + except: + return "Unknown" + + def _get_notification_icon(self, notification) -> Optional[GdkPixbuf.Pixbuf]: + """Get icon for notification.""" + try: + # Try to use notification's image first + if hasattr(notification, 'image_pixbuf') and notification.image_pixbuf: + return notification.image_pixbuf.scale_simple( + 32, 32, GdkPixbuf.InterpType.BILINEAR + ) + + # Try app icon + if hasattr(notification, 'app_icon') and notification.app_icon: + if notification.app_icon.startswith("file://"): + icon_path = notification.app_icon[7:] + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path) + return pixbuf.scale_simple(32, 32, GdkPixbuf.InterpType.BILINEAR) + except: + pass + + return None + except: + return None + + def _truncate_text(self, text: str, max_length: int = 60) -> str: + """Truncate text to specified length.""" + if not text: + return "" + if len(text) <= max_length: + return text + return text[:max_length-3] + "..." + + def _trigger_refresh(self): + """Trigger launcher refresh to return to default notification view.""" + try: + from gi.repository import GLib + + def trigger_refresh(): + try: + # Try to access the launcher through the fabric Application + from fabric import Application + + app = Application.get_default() + + if app and hasattr(app, "launcher"): + launcher = app.launcher + if launcher and hasattr(launcher, "search_entry"): + # Get current search text to preserve the query + current_text = launcher.search_entry.get_text() + # Trigger the search to refresh results + if hasattr(launcher, "_perform_search"): + launcher._perform_search(current_text) + return False + + # Fallback: try to find launcher instance through other means + import gc + + for obj in gc.get_objects(): + if ( + hasattr(obj, "__class__") + and obj.__class__.__name__ == "Launcher" + ): + if hasattr(obj, "search_entry") and hasattr(obj, "_perform_search"): + current_text = obj.search_entry.get_text() + obj._perform_search(current_text) + return False + + except Exception as e: + print(f"Error forcing launcher refresh: {e}") + + return False # Don't repeat + + # Use immediate refresh + GLib.timeout_add(10, trigger_refresh) + + except Exception as e: + print(f"Could not trigger refresh: {e}") + + def _clear_notification(self, notification_id: int): + """Remove a specific notification from history.""" + try: + # Debug: Print notification removal + print(f"[NotificationPlugin] Removing notification ID: {notification_id}") + + # Get count before removal for comparison + count_before = len(self.notification_service.all_notifications) + + # Remove the notification + self.notification_service.remove_notification(notification_id) + + # Get count after removal + count_after = len(self.notification_service.all_notifications) + + # Debug: Print removal result + print(f"[NotificationPlugin] Notifications before: {count_before}, after: {count_after}") + + if count_after < count_before: + print(f"[NotificationPlugin] Successfully removed notification {notification_id}") + else: + print(f"[NotificationPlugin] Warning: Notification {notification_id} may not have been removed") + + # Trigger immediate refresh using the same pattern as OTP plugin + self._trigger_refresh() + + except Exception as e: + print(f"[NotificationPlugin] Error removing notification {notification_id}: {e}") + # Still try to refresh even if there was an error + self._trigger_refresh() + + def _clear_all_notifications(self): + """Clear all notifications from history.""" + self.notification_service.clear_all_notifications() + + def _toggle_dnd(self, enable: bool = None): + """Toggle or set DND mode.""" + try: + if enable is None: + # Toggle current state + new_state = not self.notification_service.dont_disturb + else: + # Set specific state + new_state = enable + + self.notification_service.dont_disturb = new_state + status = "enabled" if new_state else "disabled" + print(f"[NotificationPlugin] Do Not Disturb {status}") + + # Trigger refresh to show updated status + self._trigger_refresh() + + except Exception as e: + print(f"[NotificationPlugin] Error toggling DND: {e}") + + def _on_notification_count_changed(self, service, count: int): + """Handle notification count changes (new notifications added or removed).""" + try: + print(f"[NotificationPlugin] Notification count changed to {count}, triggering refresh") + self._trigger_refresh() + except Exception as e: + print(f"[NotificationPlugin] Error handling notification count change: {e}") + + def _get_notification_count(self) -> int: + """Get total number of notifications.""" + try: + return len(self.notification_service.all_notifications) + except: + return 0 + + def _show_notification_details(self, notification_data: dict): + """Show detailed view of a notification.""" + notif_id = notification_data.get('notification_id', 0) + self.showing_detail_for = notif_id + self._trigger_refresh() + + def _close_detail_view(self): + """Close the detail view and return to list.""" + self.showing_detail_for = None + self._trigger_refresh() + + def query(self, query_string: str) -> List[Result]: + """Search notification history.""" + results = [] + + # Check if we should show detail view for a specific notification + if self.showing_detail_for is not None: + try: + notifications = self.notification_service.get_deserialized() + for notif in notifications: + notif_data = notif.serialize() if hasattr(notif, 'serialize') else {} + notif_id = notif_data.get('id', 0) + + if notif_id == self.showing_detail_for: + # Create detail widget for this notification + detail_data = { + 'notification_id': notif_id, + 'app_name': getattr(notif, 'app_name', 'Unknown'), + 'timestamp': getattr(notif, 'timestamp', time.time()), + 'summary': getattr(notif, 'summary', 'No title'), + 'body': getattr(notif, 'body', '') + } + + detail_widget = NotificationDetailWidget(detail_data, self) + + results.append(Result( + title="Notification Details", + subtitle="Press Escape to go back to list", + icon_markup=icons.notifications, + action=lambda: None, + relevance=1.0, + plugin_name=self.name, + custom_widget=detail_widget, + data={"type": "detail_view", "keep_launcher_open": True} + )) + return results + except Exception as e: + print(f"Error showing notification details: {e}") + self.showing_detail_for = None + + # Handle special commands + if query_string.lower() in ["clear"]: + results.append(Result( + title="Clear All Notifications", + subtitle="Remove all notifications from history", + description="This will permanently delete all notification history", + icon_markup=icons.trash, + relevance=1.0, + plugin_name=self.name, + action=self._clear_all_notifications + )) + return results + + # Handle DND commands + if query_string.lower() in ["off"]: + current_dnd = self.notification_service.dont_disturb + results.append(Result( + title="Enable Do Not Disturb", + subtitle="Turn off notification popups" + (" (already enabled)" if current_dnd else ""), + description="Notifications will still be saved to history but won't show popups", + icon_markup=icons.notifications_off, + relevance=1.0, + plugin_name=self.name, + action=lambda: self._toggle_dnd(True), + data={"keep_launcher_open": True} + )) + return results + + if query_string.lower() in ["on"]: + current_dnd = self.notification_service.dont_disturb + results.append(Result( + title="Disable Do Not Disturb", + subtitle="Turn on notification popups" + (" (already disabled)" if not current_dnd else ""), + description="Notifications will show popups normally", + icon_markup=icons.notifications, + relevance=1.0, + plugin_name=self.name, + action=lambda: self._toggle_dnd(False), + data={"keep_launcher_open": True} + )) + return results + + # Handle remove command + if query_string.lower().startswith("remove ") or query_string.lower().startswith("delete "): + search_term = query_string[7:].strip() if query_string.lower().startswith("remove ") else query_string[7:].strip() + if search_term: + results.append(Result( + title=f"Remove notifications containing '{search_term}'", + subtitle="Search for notifications to remove", + description="This will show notifications matching your search term for removal", + icon_markup=icons.trash, + relevance=1.0, + plugin_name=self.name, + action=lambda: None # Will be handled by showing matching notifications + )) + + try: + # Get notifications from service + notifications = self.notification_service.get_deserialized() + + if not notifications: + results.append(Result( + title="No notifications found", + subtitle="Your notification history is empty", + icon_markup=icons.notifications, + relevance=0.5, + plugin_name=self.name + )) + return results + + # Show count if no query + if not query_string: + count = len(notifications) + dnd_status = self.notification_service.dont_disturb + + # Add DND status result + if dnd_status: + results.append(Result( + title="Do Not Disturb: ON", + subtitle="Notification popups are disabled • Type 'on' to enable", + icon_markup=icons.notifications_off, + relevance=0.9, + plugin_name=self.name, + action=lambda: self._toggle_dnd(False), + data={"keep_launcher_open": True} + )) + else: + results.append(Result( + title="Do Not Disturb: OFF", + subtitle="Notification popups are enabled • Type 'off' to disable", + icon_markup=icons.notifications, + relevance=0.9, + plugin_name=self.name, + action=lambda: self._toggle_dnd(True), + data={"keep_launcher_open": True} + )) + + results.append(Result( + title=f"Notification History ({count} notifications)", + subtitle="Type to search notifications or 'clear' to remove all", + icon_markup=icons.notifications, + relevance=0.8, + plugin_name=self.name + )) + + # Filter notifications based on query + query_lower = query_string.lower() if query_string else "" + filtered_notifications = [] + + for notif in notifications: + # Get notification data + notif_data = notif.serialize() if hasattr(notif, 'serialize') else {} + notif_id = notif_data.get('id', 0) + timestamp = getattr(notif, 'timestamp', time.time()) + + # Search in summary, body, and app name + searchable_text = " ".join([ + getattr(notif, 'summary', ''), + getattr(notif, 'body', ''), + getattr(notif, 'app_name', '') + ]).lower() + + if not query_lower or query_lower in searchable_text: + filtered_notifications.append((notif, notif_id, timestamp)) + + # Sort by timestamp (newest first) + filtered_notifications.sort(key=lambda x: x[2], reverse=True) + + # Limit results (leave room for header if no query) + max_notif_results = self.max_results - (1 if not query_string else 0) + filtered_notifications = filtered_notifications[:max_notif_results] + + # Create results + for notif, notif_id, timestamp in filtered_notifications: + summary = getattr(notif, 'summary', 'No title') + body = getattr(notif, 'body', '') + app_name = getattr(notif, 'app_name', 'Unknown') + + # Create title and subtitle + title = self._truncate_text(summary, 50) + subtitle_parts = [] + + if body: + subtitle_parts.append(self._truncate_text(body, 40)) + + subtitle_parts.append(f"from {app_name}") + subtitle_parts.append(self._format_timestamp(timestamp)) + subtitle = " • ".join(subtitle_parts) + + # Get icon + icon = self._get_notification_icon(notif) + + result = Result( + title=title, + subtitle=subtitle, + description="", # Don't show description by default + icon=icon, + icon_markup=icons.notifications if not icon else None, + relevance=1.0, + plugin_name=self.name, + action=lambda nd={"notification_id": notif_id, "app_name": app_name, "timestamp": timestamp, "summary": summary, "body": body}: self._show_notification_details(nd), + data={ + "notification_id": notif_id, + "app_name": app_name, + "timestamp": timestamp, + "can_remove": True, + "alt_action": lambda nid=notif_id: self._clear_notification(nid), + "keep_launcher_open": True + } + ) + results.append(result) + + except Exception as e: + results.append(Result( + title="Error loading notifications", + subtitle=str(e), + icon_markup=icons.alert, + relevance=0.0, + plugin_name=self.name + )) + + return results diff --git a/modules/launcher/plugins/otp.py b/modules/launcher/plugins/otp.py index ddf271d..9ee9dfa 100644 --- a/modules/launcher/plugins/otp.py +++ b/modules/launcher/plugins/otp.py @@ -32,7 +32,9 @@ class OTPPlugin(PluginBase): self.display_name = "OTP Manager" self.description = "Manage TOTP codes and 2FA authentication" - self.secrets_file = Path(get_relative_path("../../../config/otp_accounts.json")) + self.secrets_file = Path( + get_relative_path("../../../config/json/accounts.json") + ) self.secrets: Dict[str, Dict] = {} self.last_update = 0 diff --git a/modules/launcher/trigger_config.py b/modules/launcher/trigger_config.py index 3591997..9d39376 100644 --- a/modules/launcher/trigger_config.py +++ b/modules/launcher/trigger_config.py @@ -10,11 +10,10 @@ import utils.icons as icons from fabric.utils import get_relative_path - class TriggerConfig: def __init__(self, config_path: str = None): if config_path is None: - config_path = get_relative_path("../../config/launcher.json") + config_path = get_relative_path("../../config/json/launcher.json") self.config_path = config_path @@ -39,8 +38,6 @@ class TriggerConfig: } self.settings = {**default_settings, **self.config.get("settings", {})} - - def get_trigger_examples(self, trigger: str) -> List[str]: examples = self.launcher_config.get(trigger, {}).get("examples", []) if not examples: @@ -63,9 +60,3 @@ class TriggerConfig: def get_all_triggers(self) -> Dict[str, Dict[str, Any]]: return self.launcher_config.copy() - - - - - - diff --git a/modules/notification_popup.py b/modules/notification_popup.py index df157cf..37e3ebe 100644 --- a/modules/notification_popup.py +++ b/modules/notification_popup.py @@ -80,10 +80,10 @@ class NotificationWidget(Box): self.timeout_ms = timeout_ms self._timeout_id = None - + self.close_button_animator = Animator( - bezier_curve=(0.25, 0.1, 0.25, 1.0), - duration=0.3, + bezier_curve=(0.25, 0.1, 0.25, 1.0), + duration=0.3, min_value=0.0, max_value=1.0, repeat=False, @@ -91,8 +91,8 @@ class NotificationWidget(Box): self.close_button_animator.connect("notify::value", self.on_hover_animation_value_changed) self.timeout_progress_animator = Animator( - bezier_curve=(1.0, 0.0, 1.0, 1.0), - duration=timeout_ms / 1000.0, + bezier_curve=(1.0, 0.0, 1.0, 1.0), + duration=timeout_ms / 1000.0, min_value=0.0, max_value=1.0, repeat=False, @@ -234,7 +234,7 @@ class NotificationWidget(Box): line_width=2, start_angle=0, end_angle=360, - value=0.0, + value=0.0, min_value=0.0, max_value=1.0, child=close_icon, @@ -324,13 +324,13 @@ class NotificationWidget(Box): if self.is_expanded: # Show full text without ellipsization and enable wrapping self.body_label.set_markup(self.notification.body) # Keep original newlines - self.body_label.set_property("ellipsize", 0) + self.body_label.set_property("ellipsize", 0) self.body_label.set_property("wrap", True) - self.body_label.set_property("wrap-mode", 2) + self.body_label.set_property("wrap-mode", 2) else: # Show truncated text with ellipsization self.body_label.set_markup(self.notification.body.replace("\n", " ")) - self.body_label.set_property("ellipsize", 3) + self.body_label.set_property("ellipsize", 3) self.body_label.set_property("wrap", False) def on_hover_animation_value_changed(self, animator, value): @@ -406,9 +406,10 @@ class NotificationWidget(Box): class NotificationRevealer(Revealer): - def __init__(self, notification: Notification, **kwargs): + def __init__(self, notification: Notification, cache_service, **kwargs): self.notif_box = NotificationWidget(notification) self._notification = notification + self.cache_service = cache_service super().__init__( child=Box( children=[self.notif_box], @@ -429,6 +430,9 @@ class NotificationRevealer(Revealer): notification: Notification, reason: NotificationCloseReason, ): + # Cache the notification to history when it's actually closed/dismissed + self.cache_service.cache_notification(notification) + logger.info(f"[Notification] Cached notification from {notification.app_name} (reason: {reason})") self.set_reveal_child(False) @@ -456,17 +460,22 @@ class NotificationPopup(Window): def on_new_notification(self, fabric_notif, id): notification: Notification = fabric_notif.get_notification_from_id(id) - self.cache_notification_service.cache_notification(notification) # Only show popup if DND is not enabled if not self.cache_notification_service.dont_disturb: - new_box = NotificationRevealer(fabric_notif.get_notification_from_id(id)) + new_box = NotificationRevealer( + fabric_notif.get_notification_from_id(id), + self.cache_notification_service + ) self.notifications.add(new_box) new_box.set_reveal_child(True) logger.info(f"[Notification] New notification from {notification.app_name}") else: + # Even in DND mode, we should cache the notification when it would have been closed + # For DND notifications, cache them immediately since they won't be shown + self.cache_notification_service.cache_notification(notification) logger.info( f"[Notification] DND enabled, notification from { notification.app_name - } not shown" + } cached without showing popup" ) diff --git a/styles/launcher.css b/styles/launcher.css index ee9f77e..8f78088 100644 --- a/styles/launcher.css +++ b/styles/launcher.css @@ -175,3 +175,41 @@ border-radius: 8px; margin-bottom: 4px; } + +/* Launcher notification widget styling */ +#launcher-notification-wrapper { + margin: 4px 0; +} + +#launcher-notification-wrapper #notification-box { + margin: 0; + min-width: 500px; +} + +#launcher-notification-actions { + padding: 8px 16px; + background-color: var(--surface); + border-radius: 0 0 20px 20px; + margin-top: -4px; +} + +#launcher-notification-actions #notification-action-btn { + background-color: var(--surface-bright); + border-radius: 12px; + padding: 6px 12px; + transition: all 0.2s ease; +} + +#launcher-notification-actions #notification-action-btn:hover { + background-color: var(--red-dim); +} + +#launcher-notification-actions #notification-action-btn:hover #notification-action-label { + color: var(--background); +} + +#launcher-notification-actions #notification-action-label { + color: var(--red-dim); + font-size: 12px; + font-weight: 600; +}