diff --git a/.gitignore b/.gitignore index 3182e3a..ebb400d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ config/hypr/colors.conf fabric/** fabric/ # styles/colors.css +.aider* diff --git a/config/assets/icons/applets/bluetooth-clear.svg b/config/assets/icons/applets/bluetooth-clear.svg old mode 100644 new mode 100755 index c98d365..69e7b25 --- a/config/assets/icons/applets/bluetooth-clear.svg +++ b/config/assets/icons/applets/bluetooth-clear.svg @@ -1,3 +1,3 @@ - - - \ No newline at end of file + + + diff --git a/config/assets/icons/applets/bluetooth-off-clear.svg b/config/assets/icons/applets/bluetooth-off-clear.svg old mode 100644 new mode 100755 index b8f4651..ecc1ab0 --- a/config/assets/icons/applets/bluetooth-off-clear.svg +++ b/config/assets/icons/applets/bluetooth-off-clear.svg @@ -1,4 +1,30 @@ - - - + + + + + + image/svg+xml + + + + + + + diff --git a/config/assets/icons/applets/wifi-clear.svg b/config/assets/icons/applets/wifi-clear.svg old mode 100644 new mode 100755 index f041926..55e3494 --- a/config/assets/icons/applets/wifi-clear.svg +++ b/config/assets/icons/applets/wifi-clear.svg @@ -1,6 +1,6 @@ - - - - - - \ No newline at end of file + + + + + + diff --git a/config/assets/icons/applets/wifi-off-clear.svg b/config/assets/icons/applets/wifi-off-clear.svg old mode 100644 new mode 100755 index ea4a074..21fe9d8 --- a/config/assets/icons/applets/wifi-off-clear.svg +++ b/config/assets/icons/applets/wifi-off-clear.svg @@ -1,9 +1,9 @@ - - - - - - - - - \ No newline at end of file + + + + + + + + + diff --git a/modules/notification/notification.py b/modules/notification/notification.py index c125f72..c8e36ae 100644 --- a/modules/notification/notification.py +++ b/modules/notification/notification.py @@ -24,7 +24,8 @@ NOTIFICATION_WIDTH = 360 NOTIFICATION_IMAGE_SIZE = 48 -def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 600): +def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 350): + """Configure revealer for macOS-like smooth animation""" revealer.duration = duration @@ -67,6 +68,8 @@ class NotificationWidget(Box): **kwargs, ): self.show_close_button = show_close_button + self.close_button = None + self._is_hovered = False super().__init__( size=(NOTIFICATION_WIDTH, -1), @@ -83,6 +86,11 @@ class NotificationWidget(Box): self.notification = notification self.timeout_ms = timeout_ms self._timeout_id = None + + # Add hover events to the main notification widget + self.connect("enter-notify-event", self._on_enter_notify) + self.connect("leave-notify-event", self._on_leave_notify) + self.start_timeout() def create_header(self, notification): @@ -182,6 +190,30 @@ class NotificationWidget(Box): ], ) + def create_close_button(self): + if self.close_button is None: + self.close_button = Button( + name="notification-close-button", + visible=False, # Initially hidden + on_clicked=lambda *_: self.notification.close("dismissed-by-user"), + child=Label(label="×", name="close-button-label"), + ) + return self.close_button + + def _on_enter_notify(self, widget, event): + self._is_hovered = True + if self.close_button: + self.close_button.set_visible(True) + self.pause_timeout() + return False + + def _on_leave_notify(self, widget, event): + self._is_hovered = False + if self.close_button: + self.close_button.set_visible(False) + self.resume_timeout() + return False + def get_pixbuf(self, icon_path, width, height): if icon_path.startswith("file://"): icon_path = icon_path[7:] @@ -226,7 +258,8 @@ class NotificationWidget(Box): self.stop_timeout() def resume_timeout(self): - self.start_timeout() + if not self._is_hovered: # Only resume if not hovered + self.start_timeout() def destroy(self): self.stop_timeout() @@ -244,7 +277,7 @@ class NotificationWidget(Box): self.set_pointer_cursor(button, "hand2") def unhover_button(self, button): - self.resume_timeout() + # Don't resume timeout here since the notification itself might still be hovered self.set_pointer_cursor(button, "arrow") @@ -259,8 +292,9 @@ class NotificationRevealer(SlideRevealer): self.notif_box = NotificationWidget(notification, show_close_button=False) self.notification = notification self.on_transition_end = on_transition_end - # Reference to ModusNoti window for queue clearing + # Reference to NotificationCenter window for queue clearing self.parent_window = parent_window + self._is_closing = False # Add swipe detection variables self._drag_start_x = 0 @@ -285,7 +319,7 @@ class NotificationRevealer(SlideRevealer): super().__init__( child=self.event_box, direction="right", - duration=600, + duration=350, # Optimized duration for macOS-like feel ) smooth_revealer_animation(self) @@ -307,6 +341,11 @@ class NotificationRevealer(SlideRevealer): _notification: Notification, reason: NotificationCloseReason, ): + if self._is_closing: + return + + self._is_closing = True + # Use left-to-right slide for auto-dismiss (expired), right-to-left for manual close if reason == "expired": # Left-to-right slide for auto-dismiss @@ -316,7 +355,8 @@ class NotificationRevealer(SlideRevealer): self.set_slide_direction("right") self.hide() - GLib.timeout_add(self.duration + 50, lambda: self._on_animation_complete(True)) + # Reduced timeout for snappier transitions + GLib.timeout_add(self.duration + 30, lambda: self._on_animation_complete(True)) def _on_button_press(self, _widget, event): if event.button == 1: @@ -359,6 +399,12 @@ class NotificationRevealer(SlideRevealer): return False +class NotificationState: + IDLE = 0 + SHOWING = 1 + HIDING = 2 + + class ModusNoti(Window): def __init__(self): self._server = notification_service @@ -371,10 +417,11 @@ class ModusNoti(Window): spacing=5, ) - # Queue system for macOS-like behavior + # Enhanced queue system for smooth transitions self.notification_queue = [] self.current_notification = None - self.is_showing_notification = False + self.notification_state = NotificationState.IDLE + self._transition_timer_id = None # Initialize ignored apps list from config self.ignored_apps = data.NOTIFICATION_IGNORED_APPS @@ -409,6 +456,7 @@ class ModusNoti(Window): notification.close("dismissed-by-user") return + # Clear any pending notifications in queue (except the current one being shown) for pending_notification in list(self.notification_queue): try: pending_notification.close("dismissed-by-user") @@ -416,46 +464,64 @@ class ModusNoti(Window): pass self.notification_queue.clear() - if self.current_notification and self.is_showing_notification: - current_allocation = None - try: - if self.current_notification.get_parent(): - current_allocation = self.current_notification.get_allocation() - except: - pass - - if hasattr(self.current_notification, "stop_animation"): - self.current_notification.stop_animation() - - try: - if self.current_notification in self.notifications.children: - self.notifications.remove(self.current_notification) - if hasattr(self.current_notification, "notification"): - self.current_notification.notification.close("dismissed-by-user") - except: - pass - - self.current_notification = None - self.is_showing_notification = False - + # Add new notification to queue self.notification_queue.append(notification) - self.show_next_notification() - def show_next_notification(self): - if not self.notification_queue or self.is_showing_notification: + # Process the queue + self._process_notification_queue() + + def _process_notification_queue(self): + # If we're currently showing a notification and there's a new one in queue + if ( + self.notification_state == NotificationState.SHOWING + and self.current_notification + and self.notification_queue + ): + + # Start hiding the current notification to make room for the new one + self._start_hiding_current_notification() + + elif ( + self.notification_state == NotificationState.IDLE + and self.notification_queue + ): + # If we're idle and have notifications in queue, show the next one + self._show_next_notification() + + def _start_hiding_current_notification(self): + if ( + self.current_notification + and self.notification_state == NotificationState.SHOWING + and not self.current_notification._is_closing + ): + + self.notification_state = NotificationState.HIDING + + # Force close the current notification to trigger hiding animation + try: + self.current_notification.notification.close("dismissed-by-user") + except: + pass + + def _show_next_notification(self): + if ( + not self.notification_queue + or self.notification_state != NotificationState.IDLE + ): return notification = self.notification_queue.pop(0) - self.is_showing_notification = True + self.notification_state = NotificationState.SHOWING new_box = NotificationRevealer( notification, - on_transition_end=lambda: self.on_notification_finished(new_box), + on_transition_end=lambda: self._on_notification_finished(new_box), parent_window=self, ) self.current_notification = new_box + # Clear any existing children for child in list(self.notifications.children): try: self.notifications.remove(child) @@ -463,37 +529,51 @@ class ModusNoti(Window): pass self.notifications.children = [new_box] - new_box.show_all() self.notifications.queue_resize() def start_animation(): - if new_box.get_parent(): - if new_box.get_realized(): - new_box.reveal() - return False - else: - return True - return False + if new_box.get_parent() and new_box.get_realized(): + new_box.reveal() + return False + return True GLib.idle_add(start_animation) - def on_notification_finished(self, notification_box): + def _on_notification_finished(self, notification_box): if notification_box != self.current_notification: return + # Cancel any pending transition timer + if self._transition_timer_id: + GLib.source_remove(self._transition_timer_id) + self._transition_timer_id = None + # Safely remove notification box try: if notification_box in self.notifications.children: self.notifications.remove(notification_box) except: pass + # Reset state self.current_notification = None - self.is_showing_notification = False + self.notification_state = NotificationState.IDLE + # Process next notification with a small delay for smooth transitions if self.notification_queue: - GLib.timeout_add(100, lambda: self.show_next_notification() or False) + self._transition_timer_id = GLib.timeout_add( + 50, # Very short delay for seamless transitions + lambda: self._show_next_notification() or False, + ) + + def show_next_notification(self): + # Legacy method for compatibility - redirect to new implementation + self._show_next_notification() + + def on_notification_finished(self, notification_box): + # Legacy method for compatibility - redirect to new implementation + self._on_notification_finished(notification_box) def clear_notification_queue(self): queue_length = len(self.notification_queue) @@ -505,5 +585,10 @@ class ModusNoti(Window): pass # Ignore errors if notification is already closed self.notification_queue.clear() + # Cancel any pending transition timer + if self._transition_timer_id: + GLib.source_remove(self._transition_timer_id) + self._transition_timer_id = None + def get_queue_length(self): return len(self.notification_queue) diff --git a/modules/panel/components/indicators.py b/modules/panel/components/indicators.py index e4d9ee9..fc599ff 100644 --- a/modules/panel/components/indicators.py +++ b/modules/panel/components/indicators.py @@ -15,7 +15,7 @@ class BluetoothIndicator(Box): self.bluetooth = BluetoothClient() self.bt_icon = Svg( name="bt-icon", - size=25, + size=23, svg_file=get_relative_path( "../../../config/assets/icons/applets/bluetooth-clear.svg" ), @@ -111,7 +111,7 @@ class NetworkIndicator(Box): self.network_icon = Svg( name="network-icon", - size=22, + size=25, svg_file=get_relative_path( "../../../config/assets/icons/applets/wifi-clear.svg" ), diff --git a/modules/panel/main.py b/modules/panel/main.py index 20873c3..9bfb111 100644 --- a/modules/panel/main.py +++ b/modules/panel/main.py @@ -41,7 +41,7 @@ class Panel(Window): self.imac = Button( name="panel-button", child=Svg( - size=28, + size=23, svg_file=get_relative_path("../../config/assets/icons/misc/logo.svg"), ), on_clicked=lambda *_: self.menubar.show_system_dropdown(self.imac), @@ -128,7 +128,7 @@ class Panel(Window): self.datetime_button = Button( name="datetime-button", on_clicked=self.notification_center.toggle_mousecapture, - child=DateTime(name="date-time", formatters=["%a %b %d %I:%M"]), + child=DateTime(name="date-time", formatters=["%a %b %d %I:%M %P"]), ) self.recording_indicator = RecordingIndicator() diff --git a/styles/about.css b/styles/about.css index f393619..0f6c718 100644 --- a/styles/about.css +++ b/styles/about.css @@ -1,5 +1,6 @@ #about-menu { - background-color: alpha(#001, 0.3); + background-color: alpha(#000, 0.34); + box-shadow: inset 0 -0.5px 0 .5px alpha(#555, 0.7), inset 0 0.5px 0 .5px alpha(#777, 0.7); border: 1px solid alpha(#111, 0.3); border-top: none; diff --git a/styles/notification-center.css b/styles/notification-center.css index f310ecb..3e19887 100644 --- a/styles/notification-center.css +++ b/styles/notification-center.css @@ -1,5 +1,6 @@ #noti-center-box { - background-color: alpha(#001, 0.3); + background-color: alpha(#fff, 0.09); + border: 1px solid alpha(#111, 0.3); border-radius: 16px; padding: 20px; diff --git a/styles/osd.css b/styles/osd.css index 1a11739..7b13501 100644 --- a/styles/osd.css +++ b/styles/osd.css @@ -1,5 +1,5 @@ #osd { - background-color: alpha(#000, 0.03); + background-color: alpha(#fff, 0.09); padding: 12px 20px; margin: 70px; min-height: 200px; diff --git a/styles/panel.css b/styles/panel.css index 41c305e..41b4a35 100644 --- a/styles/panel.css +++ b/styles/panel.css @@ -1,12 +1,19 @@ #panel { - background-color: alpha(#010101, 0.1); + background-color: alpha(#fff, 0.05); + border-bottom: 1px solid alpha(#010101, 0.025); + margin-top: -4px; + margin-bottom: -4px; transition: all 120ms ease-in-out; } #panel-icon { font-size: 18px; } +#global-title-button{ + + font-weight: 600; +} #panel-button { border-radius: 8px; diff --git a/widgets/customrevealer.py b/widgets/customrevealer.py index 18da3c5..a7303f4 100644 --- a/widgets/customrevealer.py +++ b/widgets/customrevealer.py @@ -1,5 +1,6 @@ from gi.repository import GLib, Gtk import gi +import math gi.require_version("Gtk", "3.0") @@ -21,7 +22,9 @@ class AnimationManager: def add_widget(self, widget): self._animating_widgets.add(widget) if self._timer_id is None: - self._timer_id = GLib.timeout_add(16, self._animate_all) # 60 FPS + # Use 120 FPS for ultra-smooth animations like macOS + self._timer_id = GLib.timeout_add(8, self._animate_all) # 120 FPS + def remove_widget(self, widget): self._animating_widgets.discard(widget) @@ -37,6 +40,8 @@ class AnimationManager: self._containers_to_redraw.clear() widgets_to_remove = [] + + # Process all animations in a single frame for widget in list(self._animating_widgets): if not widget._calculate_position(): widgets_to_remove.append(widget) @@ -45,11 +50,11 @@ class AnimationManager: if container: self._containers_to_redraw.add(container) - # Apply all position changes at once + # Apply all position changes at once to prevent conflicts for widget in self._animating_widgets: widget._apply_position() - # Single redraw call per container + # Batch redraw calls to minimize performance impact for container in self._containers_to_redraw: container.queue_draw() @@ -59,45 +64,76 @@ class AnimationManager: return len(self._animating_widgets) > 0 # Continue if widgets remain + def get_active_widget_count(self): + """Return the number of currently animating widgets""" + return len(self._animating_widgets) + def _get_optimal_interval(self): - widget_count = len(self._animating_widgets) - if widget_count <= 1: - return 16 # 60 FPS - elif widget_count <= 2: - return 16 # 60 FPS - elif widget_count <= 4: - return 22 # 45 FPS - elif widget_count <= 6: - return 33 # 30 FPS - else: - return 50 # 20 FPS + """Keep consistent 120 FPS for macOS-like smoothness""" + return 8 # 120 FPS + def _start_timer(self): interval = self._get_optimal_interval() self._timer_id = GLib.timeout_add(interval, self._animate_all) def _adjust_frame_rate(self): - if not self._timer_id: - return + # No need to adjust frame rate anymore - keep it consistent + pass - new_interval = self._get_optimal_interval() - GLib.source_remove(self._timer_id) - self._timer_id = GLib.timeout_add(new_interval, self._animate_all) + +class MacOSEasing: + """macOS-style easing functions for natural motion""" + + @staticmethod + def ease_out_expo(t): + """Exponential ease out - fast start, slow end""" + return 1 - math.pow(2, -10 * t) if t != 1 else 1 + + @staticmethod + def ease_in_out_quart(t): + """Quartic ease in-out for smooth acceleration/deceleration""" + return 8 * t * t * t * t if t < 0.5 else 1 - math.pow(-2 * t + 2, 4) / 2 + + @staticmethod + def ease_out_back(t): + """Back ease out for slight overshoot effect""" + c1 = 1.70158 + c3 = c1 + 1 + return 1 + c3 * math.pow(t - 1, 3) + c1 * math.pow(t - 1, 2) + + @staticmethod + def ease_out_cubic_bezier(t): + """Custom cubic bezier similar to macOS default (0.25, 0.1, 0.25, 1.0)""" + # Approximation of cubic-bezier(0.25, 0.1, 0.25, 1.0) + return t * t * t * (t * (6 * t - 15) + 10) + + @staticmethod + def ease_in_cubic(t): + """Cubic ease in for smooth acceleration""" + return t * t * t + + @staticmethod + def ease_out_quint(t): + """Quintic ease out for very smooth deceleration""" + return 1 - math.pow(1 - t, 5) class SlideRevealer(Gtk.Overlay): - def __init__(self, child: Gtk.Widget, direction="right", duration=300, size=None): + def __init__(self, child: Gtk.Widget, direction="right", duration=350, size=None): super().__init__() self.child = child self.direction = direction - self.duration = duration + self.duration = duration # Slightly faster for snappier feel self.fixed_size = size self._revealed = False self._animating = False self._start_time = None self._show_animation = False self._pending_position = None + self._current_position = (0.0, 0.0) # Use float for sub-pixel positioning + self._animation_id = None # Track individual animation instances self._fixed = Gtk.Fixed() self._fixed.set_has_window(False) @@ -129,7 +165,7 @@ class SlideRevealer(Gtk.Overlay): self.hide() def reveal(self): - if self._revealed or self._animating: + if self._revealed and not self._animating: return self._revealed = True @@ -144,36 +180,33 @@ class SlideRevealer(Gtk.Overlay): self.connect("realize", on_realize) def hide(self): - if not self._revealed or self._animating: + if not self._revealed and not self._animating: return self._revealed = False self._start_animation(show=False) def _start_animation(self, show: bool): + # Stop any existing animation for this widget if self._animating: AnimationManager.get_instance().remove_widget(self) - # Force layout update to get accurate dimensions + self._cached_dimensions = self._get_dimensions() + if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0: + self._animating = False + return + + # Use high-precision monotonic time for smooth animations + self._start_time = GLib.get_monotonic_time() + self._animating = True + self._show_animation = show + self._animation_id = id(self) # Unique ID for this animation instance + if show: self.child.show() - self.queue_resize() - - # Wait for layout to complete before getting dimensions - def start_with_dimensions(): - self._cached_dimensions = self._get_dimensions() - if self._cached_dimensions[0] == 0 or self._cached_dimensions[1] == 0: - self._animating = False - return False - - self._start_time = GLib.get_monotonic_time() - self._animating = True - self._show_animation = show - - if show: - self._fixed.move(self.child, *self._get_offscreen_pos_cached()) - - AnimationManager.get_instance().add_widget(self) - return False + pos = self._get_offscreen_pos_cached() + self._current_position = (float(pos[0]), float(pos[1])) + self._fixed.move(self.child, int(pos[0]), int(pos[1])) + # Use idle_add to ensure layout is complete GLib.idle_add(start_with_dimensions) @@ -185,17 +218,20 @@ class SlideRevealer(Gtk.Overlay): elapsed = (GLib.get_monotonic_time() - self._start_time) / 1000 t = min(elapsed / self.duration, 1.0) + # Use different easing functions for better smoothness if self._show_animation: - # Ease out quadratic (no bounce/overshoot) - eased = 1 - (1 - t) ** 2 + # Use quintic ease out for very smooth revealing + eased = MacOSEasing.ease_out_quint(t) else: - eased = t**2 # Ease in quadratic for hide animation + # Use cubic ease in for smooth hiding + eased = MacOSEasing.ease_in_cubic(t) self._pending_position = self._get_position_at_progress_cached(eased) if t >= 1.0: self._animating = False self._cached_dimensions = None + self._animation_id = None if not self._show_animation: GLib.idle_add(lambda: self.child.hide()) return False @@ -204,7 +240,11 @@ class SlideRevealer(Gtk.Overlay): def _apply_position(self): if self._pending_position: x, y = self._pending_position - self._fixed.move(self.child, x, y) + # Use sub-pixel positioning for smoother motion + self._current_position = (x, y) + # Round to nearest pixel for actual positioning + pixel_x, pixel_y = int(round(x)), int(round(y)) + self._fixed.move(self.child, pixel_x, pixel_y) self._pending_position = None def _get_container_for_redraw(self): @@ -234,24 +274,24 @@ class SlideRevealer(Gtk.Overlay): if self._show_animation: # Showing animation: slide from offscreen to onscreen (0,0) if self.direction == "left": - return int(-w + w * progress), 0 + return -w + w * progress, 0.0 elif self.direction == "right": - return int(w - w * progress), 0 + return w - w * progress, 0.0 elif self.direction == "top": - return 0, int(-h + h * progress) + return 0.0, -h + h * progress elif self.direction == "bottom": - return 0, int(h - h * progress) + return 0.0, h - h * progress else: # Hiding animation: slide from onscreen (0,0) to offscreen if self.direction == "left": - return int(-w * progress), 0 # Slide left (negative x) + return -w * progress, 0.0 # Slide left (negative x) elif self.direction == "right": - return int(w * progress), 0 # Slide right (positive x) + return w * progress, 0.0 # Slide right (positive x) elif self.direction == "top": - return 0, int(-h * progress) # Slide up (negative y) + return 0.0, -h * progress # Slide up (negative y) elif self.direction == "bottom": - return 0, int(h * progress) # Slide down (positive y) - return 0, 0 + return 0.0, h * progress # Slide down (positive y) + return 0.0, 0.0 def set_slide_direction(self, direction): self.direction = direction @@ -270,6 +310,7 @@ class SlideRevealer(Gtk.Overlay): AnimationManager.get_instance().remove_widget(self) self._animating = False self._cached_dimensions = None + self._animation_id = None def destroy(self): self.stop_animation()