From 18d54703083a8b6b4a9ad4342fc8020efdab2cdf Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Fri, 27 Jun 2025 02:07:58 +0545 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20-=20Added=20Notifications=20comp?= =?UTF-8?q?onent=20with=20dock=20and=20system=20tray=20indicators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/data.py | 2 + config/settings/constants.py | 1 + modules/dock/components/__init__.py | 25 +- modules/dock/components/music_player.py | 90 ++++- modules/dock/components/notifications.py | 457 +++++++++++++++++++++++ modules/notification_popup.py | 164 +++++--- styles/components.css | 57 +++ styles/indicators.css | 170 ++++++++- utils/audio_visualizer.py | 56 ++- utils/icons.py | 1 + 10 files changed, 937 insertions(+), 86 deletions(-) create mode 100644 modules/dock/components/notifications.py diff --git a/config/data.py b/config/data.py index e6a1961..bbf9f18 100644 --- a/config/data.py +++ b/config/data.py @@ -80,6 +80,7 @@ if os.path.exists(CONFIG_FILE): "date_time": config.get("dock_date_time_visible", True), "controls": config.get("dock_controls_visible", True), "indicators": config.get("dock_indicators_visible", True), + "notifications": config.get("dock_notifications_visible", True), "systray": config.get("dock_tray_visible", True), "applications": config.get("dock_applications_visible", True), "music_player": config.get("dock_music_player_visible", True), @@ -111,6 +112,7 @@ else: "date_time": True, "controls": True, "indicators": True, + "notifications": True, "systray": True, "applications": True, "music_player": True, diff --git a/config/settings/constants.py b/config/settings/constants.py index a91ed81..52f57af 100644 --- a/config/settings/constants.py +++ b/config/settings/constants.py @@ -54,6 +54,7 @@ DEFAULTS = { "dock_date_time_visible": True, "dock_controls_visible": True, "dock_indicators_visible": True, + "dock_notifications_visible": True, "dock_applications_visible": True, "dock_language_visible": True, 'dock_hide_special_workspace': True, diff --git a/modules/dock/components/__init__.py b/modules/dock/components/__init__.py index dbf2aa4..c8997be 100644 --- a/modules/dock/components/__init__.py +++ b/modules/dock/components/__init__.py @@ -19,6 +19,7 @@ from .controls import Controls from .indicators import Indicators from .metrics import Metrics from .music_player import MusicPlayer +from .notifications import Notifications from .workspaces import workspace import gi @@ -111,6 +112,7 @@ class DockComponents(Box): ) self.controls = Controls() self.indicators = Indicators() + self.notifications = Notifications() self.music_player = MusicPlayer() self.systray = SystemTray( icon_size=18, @@ -126,6 +128,7 @@ class DockComponents(Box): self.controls, self.applications, self.indicators, + self.notifications, self.battery, self.music_player, self.date_time, @@ -158,6 +161,7 @@ class DockComponents(Box): "date_time": self.date_time, "controls": self.controls, "indicators": self.indicators, + "notifications": self.notifications, "music_player": self.music_player, "systray": self.systray, "applications": self.applications, @@ -173,6 +177,11 @@ class DockComponents(Box): widget._update_visibility_based_on_content() else: widget.set_visible(self.component_visibility[component_name]) + # Special handling for music player - let it manage its own visibility based on media state + elif component_name == "music_player": + # Music player manages its own visibility based on config and media state + # Don't override its visibility here, let it handle it internally + pass else: widget.set_visible(self.component_visibility[component_name]) @@ -184,6 +193,7 @@ class DockComponents(Box): "date_time": self.date_time, "controls": self.controls, "indicators": self.indicators, + "notifications": self.notifications, "music_player": self.music_player, "systray": self.systray, "applications": self.applications, @@ -194,9 +204,18 @@ class DockComponents(Box): self.component_visibility[component_name] = not self.component_visibility[ component_name ] - components[component_name].set_visible( - self.component_visibility[component_name] - ) + + # Special handling for music player - update the global config and let it handle its own visibility + if component_name == "music_player": + # Update the global config data + import config.data as data + data.DOCK_COMPONENTS_VISIBILITY[component_name] = self.component_visibility[component_name] + # Let the music player update its own visibility based on the new config + components[component_name]._update_display() + else: + components[component_name].set_visible( + self.component_visibility[component_name] + ) config_file = get_relative_path("../../../config/assets/config.json") if os.path.exists(config_file): diff --git a/modules/dock/components/music_player.py b/modules/dock/components/music_player.py index 7b96689..b62d7e1 100644 --- a/modules/dock/components/music_player.py +++ b/modules/dock/components/music_player.py @@ -51,7 +51,14 @@ class MusicPlayer(EventBox): self._create_music_player_view() self.add(self.main_container) self.connect("button-press-event", self._on_music_player_clicked) - self.set_visible(False) + + # Set initial visibility based on configuration + initial_visibility = data.DOCK_COMPONENTS_VISIBILITY.get("music_player", True) + self.set_visible(initial_visibility) + + # Ensure visualizer is disabled in vertical mode + if data.VERTICAL: + self._show_visualizer = False GLib.timeout_add(100, self._delayed_initial_update) @@ -60,7 +67,7 @@ class MusicPlayer(EventBox): self.album_thumbnail = CircleImage( name="music-album-thumbnail", - size=34, + size=30, v_align="center", visible=True, ) @@ -68,13 +75,21 @@ class MusicPlayer(EventBox): self.album_thumbnail.add_events(Gdk.EventMask.BUTTON_PRESS_MASK) self.album_thumbnail.connect("button-press-event", self._on_thumbnail_clicked) + # Create track label (hidden in vertical mode) self.track_label = Label( name="music-track", label="No Media", ellipsize=0, h_align="center", ) - self.track_label.set_size_request(100, -1) + + if data.VERTICAL: + self.track_label.set_visible(False) # Hide label in vertical mode + self.track_label.set_size_request(-1, -1) + else: + self.track_label.set_visible(True) + self.track_label.set_size_request(100, -1) + self.track_label.set_hexpand(False) self.track_label.set_vexpand(False) @@ -105,6 +120,11 @@ class MusicPlayer(EventBox): end_children=self.controls_box, ) + # Add vertical class for CSS styling + if data.VERTICAL: + self.music_player_box.add_style_class("vertical") + self.main_container.add_style_class("vertical") + self.main_container.add(self.music_player_box) def _load_album_art(self, art_url): @@ -343,7 +363,19 @@ class MusicPlayer(EventBox): self._connect_player_signals(self._current_player) if not self._current_player: - self.set_visible(False) + # Only hide if the component is disabled in settings + # If enabled in settings, show a "No Media" state instead of hiding + if not data.DOCK_COMPONENTS_VISIBILITY.get("music_player", True): + self.set_visible(False) + else: + # Show "No Media" state when enabled but no player is active + self.set_visible(True) + # Clear tooltips when no player is active + self.set_tooltip_text("No media player active") + self.album_thumbnail.set_tooltip_text("No media player active") + if not data.VERTICAL: + self.track_label.set_tooltip_text("No media player active") + if self._update_timeout_id: GLib.source_remove(self._update_timeout_id) self._update_timeout_id = None @@ -353,7 +385,12 @@ class MusicPlayer(EventBox): self._stop_spin_animation() return False - self.set_visible(True) + # Only show if enabled in configuration + if data.DOCK_COMPONENTS_VISIBILITY.get("music_player", True): + self.set_visible(True) + else: + self.set_visible(False) + return False album_art_url = self._current_player.album_image_url if album_art_url: @@ -370,18 +407,20 @@ class MusicPlayer(EventBox): else: play_pause_icon.set_markup(icons.play) - track_title = self._current_player.track_title or "Unknown Track" - max_display_chars = 12 + # Skip text processing in vertical mode since label is hidden + if not data.VERTICAL: + track_title = self._current_player.track_title or "Unknown Track" + max_display_chars = 12 - self._original_title = track_title - self._title_needs_marquee = len(track_title) > max_display_chars + self._original_title = track_title + self._title_needs_marquee = len(track_title) > max_display_chars - if self._original_title != getattr(self, "_last_original_title", ""): - self._last_original_title = self._original_title - self._marquee_position = 0 - self._start_marquee_animation() + if self._original_title != getattr(self, "_last_original_title", ""): + self._last_original_title = self._original_title + self._marquee_position = 0 + self._start_marquee_animation() - self._update_marquee_text() + self._update_marquee_text() self._update_tooltips() self.prev_button.set_sensitive(self._current_player.can_go_previous) @@ -420,6 +459,11 @@ class MusicPlayer(EventBox): if not self._current_player: return + # Skip updating label text in vertical mode since it's hidden + if data.VERTICAL: + return + + # Use the same character limit as in _update_display max_display_chars = 12 if self._title_needs_marquee: @@ -465,7 +509,14 @@ class MusicPlayer(EventBox): player_name = self._current_player.player_name or "Media Player" tooltip_text = f"{full_track_title}\nby {track_artist}\n\nPlayer: {player_name}" - self.track_label.set_tooltip_text(tooltip_text) + + if data.VERTICAL: + # In vertical mode, show tooltip on the main container and album thumbnail + self.set_tooltip_text(tooltip_text) + self.album_thumbnail.set_tooltip_text(tooltip_text) + else: + # In horizontal mode, show tooltip on the track label + self.track_label.set_tooltip_text(tooltip_text) def _on_play_pause_clicked(self, _button): if self._current_player: @@ -485,13 +536,15 @@ class MusicPlayer(EventBox): self._update_display() def _on_thumbnail_clicked(self, _widget, _event): - if self._current_player: + if self._current_player and not data.VERTICAL: + # Only toggle visualizer in horizontal mode self._show_visualizer = not self._show_visualizer self._update_stack_display() return True def _on_music_player_clicked(self, _widget, _event): - if self._current_player: + if self._current_player and not data.VERTICAL: + # Only toggle visualizer in horizontal mode self._show_visualizer = not self._show_visualizer self._update_stack_display() return True @@ -511,7 +564,8 @@ class MusicPlayer(EventBox): self.cavalcade = None def _update_stack_display(self): - if self._show_visualizer and self._current_player: + # Never show visualizer in vertical mode + if self._show_visualizer and self._current_player and not data.VERTICAL: self._create_visualizer_if_needed() if self.cavalcade_box: self.cavalcade_box.set_visible(True) diff --git a/modules/dock/components/notifications.py b/modules/dock/components/notifications.py new file mode 100644 index 0000000..a85068a --- /dev/null +++ b/modules/dock/components/notifications.py @@ -0,0 +1,457 @@ +from fabric.widgets.box import Box +from fabric.widgets.button import Button +from fabric.widgets.label import Label +from fabric.widgets.scrolledwindow import ScrolledWindow + +import config.data as data +import utils.icons as icons +from services import notification_service +from modules.notification_popup import NotificationWidget +from utils.wayland import WaylandWindow as Window + + +class NotificationIndicator(Button): + def __init__(self, **kwargs): + super().__init__(name="button-bar-notifications", **kwargs) + + self.notification_service = notification_service + self.notification_count = 0 + + # Create the notification icon (switches between normal and dots) + self.notification_icon = Label( + name="notification-icon", + markup=icons.notifications + ) + + # Add the icon directly (no overlay needed) + self.add(self.notification_icon) + + # Connect to notification service signals + self.notification_service.connect("notification_count", self.on_notification_count_changed) + self.notification_service.connect("notification-added", self.on_notification_added) + self.notification_service.connect("dnd", self.on_dnd_changed) + + # Connect click handler + self.connect("clicked", self.on_clicked) + + # Initialize count + self.update_count() + + # Popup window for showing notifications + self.popup_window = None + + def on_notification_count_changed(self, _service, count): + """Handle notification count changes.""" + print(f"[NotificationIndicator] Count changed to {count}") + self.notification_count = count + self.update_count() + + # If popup is open, refresh it to show new notifications + if self.popup_window and self.popup_window.get_visible(): + print(f"[NotificationIndicator] Popup is open, refreshing...") + self.popup_window.refresh_popup() + + def on_notification_added(self, _service, _notification_id): + """Handle new notifications being added.""" + print(f"[NotificationIndicator] New notification added: {_notification_id}") + # Update count immediately when new notification arrives + self.notification_count = self.notification_service.count + self.update_count() + + # If popup is open, refresh it to show new notifications + if self.popup_window and self.popup_window.get_visible(): + print(f"[NotificationIndicator] Popup is open, refreshing...") + self.popup_window.refresh_popup() + + def on_dnd_changed(self, _service, dnd_enabled): + """Handle DND status changes.""" + print(f"[NotificationIndicator] DND changed: {dnd_enabled}") + self.update_count() # This will update the icon based on DND status + + # If popup is open, refresh it to reflect DND state + if self.popup_window and self.popup_window.get_visible(): + print(f"[NotificationIndicator] DND changed, refreshing popup...") + self.popup_window.refresh_popup() + + def update_count(self): + """Update the notification icon display.""" + count = self.notification_service.count + self.notification_count = count + dnd_enabled = self.notification_service.dont_disturb + + if dnd_enabled: + # Show DND icon when DND is enabled + self.notification_icon.set_markup(icons.notifications_off) + self.set_tooltip_text(f"Do Not Disturb enabled ({count} notification{'s' if count != 1 else ''})") + elif count >= 1: + # Show notifications_dots icon when there are notifications + self.notification_icon.set_markup(icons.notifications_dots) + self.set_tooltip_text(f"{count} notification{'s' if count != 1 else ''}") + else: + # Show regular notifications icon when no notifications + self.notification_icon.set_markup(icons.notifications) + self.set_tooltip_text("No notifications") + + 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 cached notifications.""" + if self.popup_window: + self.popup_window.destroy() + + # Get cached notifications (always get fresh data) + notifications = self.notification_service.get_deserialized() + print(f"[NotificationIndicator] Opening popup with {len(notifications)} notifications") + + # Create popup window (handles both empty and filled states) + self.popup_window = NotificationPopupWindow(notifications, self.notification_service) + self.popup_window.show_all() + + +class NotificationPopupWindow(Window): + def __init__(self, notifications, notification_service): + self.notification_service = notification_service + + # Create scrolled container for notifications + self.notifications_box = Box( + name="notifications-popup-box", + orientation="v", + spacing=8, + h_expand=True, + v_expand=False + ) + + if not notifications: + # Show "No notifications" message + no_notif_icon = Label( + name="no-notifications-icon", + markup=icons.notifications, + h_align="center" + ) + + no_notif_message = Label( + name="no-notifications-message", + label="No notifications", + h_align="center", + v_align="center" + ) + + no_notif_content = Box( + name="no-notifications-content", + orientation="v", + spacing=16, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + children=[no_notif_icon, no_notif_message] + ) + + self.notifications_box.add(no_notif_content) + else: + # Add notifications to the box (limit to recent 10) + recent_notifications = notifications[-10:] if len(notifications) > 10 else notifications + recent_notifications.reverse() # Show newest first + + for notification in recent_notifications: + # Create notification widget with dock callback passed during initialization + notif_widget = NotificationWidget( + notification, + show_progress=False, + dock_callback=self.remove_notification_from_cache + ) + + self.notifications_box.add(notif_widget) + + # Create scrolled window with proper sizing + scrolled = ScrolledWindow( + name="notifications-scrolled", + child=self.notifications_box, + h_scrollbar_policy="never", + v_scrollbar_policy="automatic", + min_content_size=(400, 450), + max_content_size=(450, 500), + h_expand=True, + v_expand=True, + propagate_width=False, + propagate_height=False + ) + + # Create header with DND toggle and clear all button + header_children = [ + Label( + name="notifications-title", + label="Notifications", + h_align="start", + h_expand=True + ) + ] + + # Add DND toggle button with icon + dnd_icon = icons.notifications_off if self.notification_service.dont_disturb else icons.notifications + dnd_tooltip = "Do Not Disturb: ON" if self.notification_service.dont_disturb else "Do Not Disturb: OFF" + self.dnd_button = Button( + name="dnd-toggle-button", + child=Label( + name="dnd-toggle-icon", + markup=dnd_icon + ), + on_clicked=self.toggle_dnd + ) + self.dnd_button.set_tooltip_text(dnd_tooltip) + header_children.append(self.dnd_button) + + if notifications: # Only add clear button if there are notifications + header_children.append( + Button( + name="clear-all-button", + label="Clear All", + on_clicked=self.clear_all_notifications + ) + ) + + header_box = Box( + name="notifications-header", + orientation="h", + spacing=8, + children=header_children + ) + + main_box = Box( + name="notifications-popup-main", + orientation="v", + spacing=8, + h_expand=True, + v_expand=True, + children=[header_box, 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, + layer="top", + exclusive=False, + keyboard_mode="on-demand", + 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") + + 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 + } + return anchor_map.get(dock_position, "bottom") # Default fallback + + 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 + } + return margin_map.get(dock_position, "10px 10px 10px 10px") # Default fallback + + def update_dnd_button_icon(self): + """Update the DND button icon and tooltip to reflect current DND state.""" + if hasattr(self, 'dnd_button'): + dnd_enabled = self.notification_service.dont_disturb + dnd_icon = icons.notifications_off if dnd_enabled else icons.notifications + dnd_tooltip = "Do Not Disturb: ON" if dnd_enabled else "Do Not Disturb: OFF" + + # Update the button icon + button_label = self.dnd_button.get_child() + button_label.set_markup(dnd_icon) + + # Update tooltip + self.dnd_button.set_tooltip_text(dnd_tooltip) + + print(f"[NotificationPopup] Updated DND button icon: {dnd_icon}") + + + + + def remove_notification_from_cache(self, notification): + """Remove a specific notification from cache and refresh popup.""" + print(f"[NotificationPopup] Removing notification from cache: {notification.summary}") + + # Find and remove the notification from the service cache + # We need to find it by matching summary and app_name since IDs might differ + all_notifications = self.notification_service.all_notifications + for i, cached_notif in enumerate(all_notifications): + if (cached_notif.get("summary") == notification.summary and + cached_notif.get("app_name") == notification.app_name): + print(f"[NotificationPopup] Found matching notification, removing...") + self.notification_service.all_notifications.pop(i) + self.notification_service._write_notifications(self.notification_service.all_notifications) + self.notification_service.emit("notification_count", len(self.notification_service.all_notifications)) + break + + # Refresh the popup immediately + self.refresh_popup() + + def on_notification_closed(self, notification, reason): + """Handle when a notification is closed - refresh the popup.""" + # Small delay to allow the notification service to update + from gi.repository import GLib + GLib.timeout_add(100, self.refresh_popup) + + def refresh_popup(self): + """Refresh the popup content.""" + # Get updated notifications + notifications = self.notification_service.get_deserialized() + + # Clear current content + for child in self.notifications_box.get_children(): + self.notifications_box.remove(child) + + if not notifications: + # Show "No notifications" message + no_notif_icon = Label( + name="no-notifications-icon", + markup=icons.notifications, + h_align="center" + ) + + no_notif_message = Label( + name="no-notifications-message", + label="No notifications", + h_align="center", + v_align="center" + ) + + no_notif_content = Box( + name="no-notifications-content", + orientation="v", + spacing=16, + h_align="center", + v_align="center", + h_expand=True, + v_expand=True, + children=[no_notif_icon, no_notif_message] + ) + + self.notifications_box.add(no_notif_content) + else: + # Add updated notifications + recent_notifications = notifications[-10:] if len(notifications) > 10 else notifications + recent_notifications.reverse() # Show newest first + + for notification in recent_notifications: + notif_widget = NotificationWidget( + notification, + show_progress=False, + dock_callback=self.remove_notification_from_cache + ) + self.notifications_box.add(notif_widget) + + self.notifications_box.show_all() + return False # Don't repeat the timeout + + def toggle_dnd(self, button): + """Toggle Do Not Disturb mode.""" + current_dnd = self.notification_service.dont_disturb + new_dnd = not current_dnd + self.notification_service.dont_disturb = new_dnd + + print(f"[NotificationPopup] DND toggled: {new_dnd}") + + # Update the DND button icon using the centralized method + self.update_dnd_button_icon() + + # Emit DND signal + self.notification_service.emit("dnd", new_dnd) + + def clear_all_notifications(self, _button): + """Clear all cached notifications.""" + self.notification_service.clear_all_notifications() + self.set_visible(False) + + 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 + elif event.state & Gdk.ModifierType.CONTROL_MASK and event.keyval == Gdk.KEY_d: + print(f"[NotificationPopup] Ctrl+D pressed, toggling DND") + # Toggle DND state directly + current_dnd = self.notification_service.dont_disturb + new_dnd = not current_dnd + self.notification_service.dont_disturb = new_dnd + + print(f"[NotificationPopup] DND toggled via keyboard: {new_dnd}") + + # Update the DND button icon immediately + self.update_dnd_button_icon() + + # Emit DND signal + self.notification_service.emit("dnd", new_dnd) + + return True # Event handled + + # 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") + if self.notification_service.count > 0: + self.clear_all_notifications(None) # Pass None as button parameter + return True # Event handled + + 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 + + + +class Notifications(Box): + def __init__(self, **kwargs): + super().__init__( + name="notifications", + orientation="h" if not data.VERTICAL else "v", + spacing=4, + children=[NotificationIndicator()], + **kwargs + ) + self.show_all() diff --git a/modules/notification_popup.py b/modules/notification_popup.py index 45797f3..8e45147 100644 --- a/modules/notification_popup.py +++ b/modules/notification_popup.py @@ -50,13 +50,44 @@ class ActionButton(Button): ) def on_clicked(self, *_): - self.action.invoke() - self.action.parent.close("dismissed-by-user") + print(f"[ActionButton] Action button clicked: {self.action.label}") + print(f"[ActionButton] Action details - Key: {getattr(self.action, 'key', 'N/A')}") + print(f"[ActionButton] Action parent: {self.action.parent.app_name} - {self.action.parent.summary}") + + # First, execute the action + try: + print(f"[ActionButton] Invoking action: {self.action.label}") + result = self.action.invoke() + print(f"[ActionButton] Action invoked successfully, result: {result}") + print(f"[ActionButton] Action type: {type(self.action)}") + + # Try to get more info about what the action does + if hasattr(self.action, '__dict__'): + print(f"[ActionButton] Action attributes: {self.action.__dict__}") + + except Exception as e: + print(f"[ActionButton] Error invoking action: {e}") + import traceback + traceback.print_exc() + + # Then, after a longer delay, handle notification removal + if hasattr(self, '_dock_popup_callback') and self._dock_popup_callback is not None: + print(f"[ActionButton] Scheduling dock notification removal in 500ms") + # Longer delay to allow action to complete properly + from gi.repository import GLib + GLib.timeout_add(500, lambda: self._dock_popup_callback(self.action.parent)) + else: + # For regular popup notifications, also add a small delay + print(f"[ActionButton] Scheduling regular close in 200ms (no dock callback)") + from gi.repository import GLib + GLib.timeout_add(200, lambda: self.action.parent.close("dismissed-by-user")) class NotificationWidget(Box): - def __init__(self, notification: Notification, timeout_ms=5000, **kwargs): + def __init__(self, notification: Notification, timeout_ms=5000, show_progress=True, dock_callback=None, **kwargs): self.notification = notification + self.show_progress = show_progress + self._dock_popup_callback = dock_callback self.is_expanded = False @@ -81,30 +112,35 @@ 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, - min_value=0.0, - max_value=1.0, - repeat=False, - ) - self.close_button_animator.connect( - "notify::value", self.on_hover_animation_value_changed - ) + if self.show_progress: + self.close_button_animator = Animator( + bezier_curve=(0.25, 0.1, 0.25, 1.0), + duration=0.3, + min_value=0.0, + max_value=1.0, + repeat=False, + ) + 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, - min_value=0.0, - max_value=1.0, - repeat=False, - ) - self.timeout_progress_animator.connect( - "notify::value", self.on_timeout_progress_changed - ) - self.timeout_progress_animator.connect("finished", self.on_timeout_finished) + self.timeout_progress_animator = Animator( + 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, + ) + self.timeout_progress_animator.connect( + "notify::value", self.on_timeout_progress_changed + ) + self.timeout_progress_animator.connect("finished", self.on_timeout_finished) - self.start_timeout() + self.start_timeout() + else: + # No timeout for dock notifications + self.close_button_animator = None + self.timeout_progress_animator = None def create_header(self, notification): app_icon = ( @@ -216,35 +252,51 @@ class NotificationWidget(Box): def create_action_buttons(self, notification): if not notification.actions: return Box() + + action_buttons = [] + for i, action in enumerate(notification.actions): + action_button = ActionButton(action, i, len(notification.actions), self) + # Pass the dock callback to action buttons if available + if hasattr(self, '_dock_popup_callback') and self._dock_popup_callback is not None: + print(f"[NotificationWidget] Setting dock callback on action button: {action.label}") + action_button._dock_popup_callback = self._dock_popup_callback + else: + print(f"[NotificationWidget] No dock callback available for action button: {action.label}") + action_buttons.append(action_button) + 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) - ], + children=action_buttons, ) def create_close_button(self): close_icon = Label(name="notif-close-label", markup=icons.cancel) - self.close_progress_bar = CircularProgressBar( - name="notif-close-progress", - size=28, - line_width=2, - start_angle=0, - end_angle=360, - value=0.0, - min_value=0.0, - max_value=1.0, - child=close_icon, - ) + if self.show_progress: + # Create with circular progress bar for timeout notifications + self.close_progress_bar = CircularProgressBar( + name="notif-close-progress", + size=28, + line_width=2, + start_angle=0, + end_angle=360, + value=0.0, + min_value=0.0, + max_value=1.0, + child=close_icon, + ) + button_child = self.close_progress_bar + else: + # Create simple button without progress bar for dock notifications + self.close_progress_bar = None + button_child = close_icon close_button = Button( name="notif-close-button", - child=self.close_progress_bar, - on_clicked=lambda *_: self.notification.close("dismissed-by-user"), + child=button_child, + on_clicked=self.on_close_clicked, ) close_button.connect( "enter-notify-event", lambda *_: self.hover_button(close_button) @@ -267,6 +319,17 @@ class NotificationWidget(Box): ) return self.body_label + def on_close_clicked(self, *_): + """Handle close button click.""" + print(f"[NotificationWidget] Close button clicked for notification: {self.notification.summary}") + + # For dock notifications, we need to manually remove from cache + if hasattr(self, '_dock_popup_callback'): + self._dock_popup_callback(self.notification) + else: + # For regular popup notifications + self.notification.close("dismissed-by-user") + def create_chevron_button(self): # Only show chevron button if there's something to expand # (either long text that gets ellipsized or action buttons) @@ -306,6 +369,7 @@ class NotificationWidget(Box): return Box() def toggle_expanded(self, *_): + print(f"[NotificationWidget] Chevron button clicked, expanding: {not self.is_expanded}") self.is_expanded = not self.is_expanded self.expanded_revealer.set_reveal_child(self.is_expanded) @@ -336,7 +400,7 @@ class NotificationWidget(Box): pass def on_timeout_progress_changed(self, animator, value): - if hasattr(self, "close_progress_bar"): + if hasattr(self, "close_progress_bar") and self.close_progress_bar is not None: # Update progress bar value based on timeout progress (convert to float to avoid type issues) self.close_progress_bar.value = float(animator.value) @@ -384,21 +448,23 @@ class NotificationWidget(Box): window.set_cursor(cursor) def hover_button(self, button): - self.pause_timeout() + if self.show_progress: + self.pause_timeout() self.set_pointer_cursor(button, "hand2") # On hover, show full progress (indicating ready to close) - if hasattr(self, "close_progress_bar"): + if hasattr(self, "close_progress_bar") and self.close_progress_bar is not None: self.close_progress_bar.value = 1.0 def unhover_button(self, button): - self.resume_timeout() + if self.show_progress: + self.resume_timeout() self.set_pointer_cursor(button, "arrow") # On unhover, resume showing timeout progress - if hasattr(self, "timeout_progress_animator") and hasattr( - self, "close_progress_bar" - ): + if (hasattr(self, "timeout_progress_animator") and + hasattr(self, "close_progress_bar") and + self.close_progress_bar is not None): # Resume the timeout progress display current_progress = float(self.timeout_progress_animator.value) self.close_progress_bar.value = current_progress diff --git a/styles/components.css b/styles/components.css index c1ae3b9..c4c2fed 100644 --- a/styles/components.css +++ b/styles/components.css @@ -301,3 +301,60 @@ menu > menuitem:hover > label { #music-player-indicator-button:active #music-player-indicator-active { color: var(--shadow); } + +/* Vertical mode specific styles */ +#music-player-container.vertical { + padding: 8px 4px; +} + +#music-player-centerbox.vertical { + padding: 4px 0; +} + +#music-player-centerbox.vertical #music-track { + font-size: 10px; + margin: 4px 0; +} + +#music-player.vertical { + padding: 8px 4px; +} + +/* Adjust controls layout for vertical mode */ +#music-controls.vertical { + padding: 2px; +} + +#music-controls.vertical #music-prev, +#music-controls.vertical #music-next, +#music-controls.vertical #music-play-pause { + padding: 2px; + margin: 1px 0; +} + +#music-controls.vertical #music-play-pause { + padding: 4px; +} + +#music-controls.vertical label { + font-size: 12px; +} + +#music-controls.vertical #music-play-pause label { + font-size: 14px; +} + +/* Player indicators in vertical mode */ +#music-player-indicators.vertical { + padding: 2px; +} + +#music-player-indicators.vertical #music-player-indicator-button { + padding: 1px; + margin: 1px 0; +} + +#music-player-indicators.vertical #music-player-indicator, +#music-player-indicators.vertical #music-player-indicator-active { + font-size: 10px; +} diff --git a/styles/indicators.css b/styles/indicators.css index 883596b..144ca02 100644 --- a/styles/indicators.css +++ b/styles/indicators.css @@ -39,6 +39,174 @@ #network-label, #bluetooth-label, -#recording-label { +#recording-label, +#notification-icon { font-size: 20px; } + +#button-bar-notifications { + border-radius: 12px; + padding: 4px; +} + +#button-bar-notifications:hover { + background-color: var(--surface-bright); +} + + +#notifications-popup-main { + background-color: var(--surface); + margin:20px; +} + +#notifications-popup-box { + background-color: var(--surface); +} + +#notifications-scrolled { + background-color: var(--surface); + border-radius: 8px; +} + +#notifications-scrolled scrollbar { + background-color: var(--surface-container); + border-radius: 4px; +} + +#notifications-scrolled scrollbar slider { + background-color: var(--outline); + border-radius: 4px; + min-width: 8px; + min-height: 8px; +} + +#notifications-scrolled scrollbar slider:hover { + background-color: var(--outline-variant); +} + +/* Style close button in dock notifications */ +#notifications-popup-window #notif-close-button { + background-color: var(--surface-container); + border-radius: 50%; + min-width: 28px; + min-height: 28px; + padding: 4px; + border: none; +} + +#notifications-popup-window #notif-close-button:hover { + background-color: var(--error-container); +} + +#notifications-popup-window #notif-close-label { + color: var(--on-surface-variant); + font-size: 14px; +} + +#notifications-popup-window #notif-close-button:hover #notif-close-label { + color: var(--on-error-container); +} + +#notifications-header { + padding: 8px; + border-bottom: 1px solid var(--outline); +} + +#notifications-title { + font-size: 16px; + font-weight: bold; + color: var(--on-surface); +} + +#dnd-toggle-button { + background-color: var(--surface-container); + color: var(--on-surface-variant); + border-radius: 8px; + padding: 4px 8px; + border: 1px solid var(--outline); + font-size: 12px; + font-weight: 500; +} + +#dnd-toggle-button:hover { + background-color: var(--surface-bright); +} + +#clear-all-button { + background-color: var(--primary); + color: var(--on-primary); + border-radius: 8px; + padding: 4px 8px; +} + +#clear-all-button:hover { + background-color: var(--primary-bright); +} + +#notifications-content { + padding: 8px; + background-color: var(--surface-container); + border-radius: 8px; + margin-top: 4px; + min-height: 200px; +} + +#no-notifications-content { + background-color: var(--surface-container); + border-radius: 8px; + padding: 40px 20px; + margin: 20px; + min-width: 292px; +} + +#no-notifications-icon { + font-size: 48px; + color: var(--on-surface-variant); + opacity: 0.6; +} + +#no-notifications-message { + color: var(--on-surface-variant); + font-size: 16px; + font-weight: 500; +} + +#simple-notification-box { + background-color: var(--surface-bright); + border-radius: 8px; + padding: 8px; + margin: 4px 0; +} + +#simple-notification-box:hover { + background-color: var(--surface-dim); +} + +#notification-remove-button { + background-color: var(--error); + color: var(--on-error); + border-radius: 50%; + padding: 2px 6px; + font-weight: bold; +} + +#notification-remove-button:hover { + background-color: var(--error-bright); +} + +#no-notifications-content { + padding: 48px 32px; + min-height: 200px; +} + +#no-notifications-icon { + font-size: 48px; + color: var(--outline); + opacity: 0.6; +} + +#no-notifications-message { + font-size: 16px; + color: var(--on-surface); + opacity: 0.7; +} diff --git a/utils/audio_visualizer.py b/utils/audio_visualizer.py index 1b1e909..e473cb4 100644 --- a/utils/audio_visualizer.py +++ b/utils/audio_visualizer.py @@ -12,6 +12,8 @@ from fabric.widgets.overlay import Overlay from gi.repository import Gdk, GLib, Gtk from loguru import logger +import config.data as data + def get_bars(file_path): config = configparser.ConfigParser() @@ -205,25 +207,49 @@ class Spectrum: def redraw(self, widget, cr): """Draw spectrum graph""" cr.set_source_rgba(*self.color) - dx = 3 - center_y = self.sizes.area.height / 2 # center vertical of the drawing area - for i, value in enumerate(self.audio_sample): - width = self.sizes.area.width / self.sizes.number - self.sizes.padding - radius = width / 2 - height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2 - if height == self.sizes.zero / 2 + 1: - height *= 0.5 + if data.VERTICAL: + # Vertical mode: draw bars horizontally (rotated 90 degrees) + dy = 3 + center_x = self.sizes.area.width / 2 # center horizontal of the drawing area - height = min(height, self.max_height) + for i, value in enumerate(self.audio_sample): + height = self.sizes.area.height / self.sizes.number - self.sizes.padding + radius = height / 2 + width = max(self.sizes.bar.width * min(value, 1), self.sizes.zero) / 2 + if width == self.sizes.zero / 2 + 1: + width *= 0.5 - # Draw rectangle and arcs for rounded ends - cr.rectangle(dx, center_y - height, width, height * 2) - cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi) - cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi) + width = min(width, self.max_height) - cr.close_path() - dx += width + self.sizes.padding + # Draw rectangle and arcs for rounded ends (horizontal bars) + cr.rectangle(center_x - width, dy, width * 2, height) + cr.arc(center_x - width, dy + radius, radius, 0, 2 * pi) + cr.arc(center_x + width, dy + radius, radius, 0, 2 * pi) + + cr.close_path() + dy += height + self.sizes.padding + else: + # Horizontal mode: draw bars vertically (original behavior) + dx = 3 + center_y = self.sizes.area.height / 2 # center vertical of the drawing area + + for i, value in enumerate(self.audio_sample): + width = self.sizes.area.width / self.sizes.number - self.sizes.padding + radius = width / 2 + height = max(self.sizes.bar.height * min(value, 1), self.sizes.zero) / 2 + if height == self.sizes.zero / 2 + 1: + height *= 0.5 + + height = min(height, self.max_height) + + # Draw rectangle and arcs for rounded ends (vertical bars) + cr.rectangle(dx, center_y - height, width, height * 2) + cr.arc(dx + radius, center_y - height, radius, 0, 2 * pi) + cr.arc(dx + radius, center_y + height, radius, 0, 2 * pi) + + cr.close_path() + dx += width + self.sizes.padding cr.fill() def size_update(self, *args): diff --git a/utils/icons.py b/utils/icons.py index f2c2685..d6e3bae 100644 --- a/utils/icons.py +++ b/utils/icons.py @@ -84,6 +84,7 @@ bluetooth: str = "" night: str = "" coffee: str = "" notifications: str = "" +notifications_dots: str = "" wifi_off: str = "" bluetooth_off: str = ""