From abe5dfe1c77c876ce4e82922eff1874cc5ee04d9 Mon Sep 17 00:00:00 2001 From: tr1x_em Date: Sat, 2 Aug 2025 21:37:31 +0530 Subject: [PATCH] fix: resolve choppy animations during notification transitions Co-authored-by: aider (openai/claude-sonnet-4) --- modules/notification/notification.py | 122 ++++++++++++++++++--------- widgets/customrevealer.py | 39 +++++++-- 2 files changed, 112 insertions(+), 49 deletions(-) diff --git a/modules/notification/notification.py b/modules/notification/notification.py index a71f418..04f6c87 100644 --- a/modules/notification/notification.py +++ b/modules/notification/notification.py @@ -262,6 +262,7 @@ class NotificationRevealer(SlideRevealer): self.on_transition_end = on_transition_end # Reference to ModusNoti window for queue clearing self.parent_window = parent_window + self._is_closing = False # Add swipe detection variables self._drag_start_x = 0 @@ -308,6 +309,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 @@ -361,6 +367,12 @@ class NotificationRevealer(SlideRevealer): return False +class NotificationState: + IDLE = 0 + SHOWING = 1 + HIDING = 2 + + class ModusNoti(Window): def __init__(self): self._server = notification_service @@ -373,10 +385,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 @@ -411,6 +424,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") @@ -418,46 +432,54 @@ 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() + + # Process the queue + self._process_notification_queue() - def show_next_notification(self): - if not self.notification_queue or self.is_showing_notification: + 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) @@ -465,38 +487,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: - # Reduced delay for more responsive queuing - GLib.timeout_add(80, 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) @@ -508,5 +543,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/widgets/customrevealer.py b/widgets/customrevealer.py index 12bec0d..78d32b1 100644 --- a/widgets/customrevealer.py +++ b/widgets/customrevealer.py @@ -39,6 +39,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) @@ -47,11 +49,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() @@ -61,6 +63,10 @@ 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): """Keep consistent 120 FPS for macOS-like smoothness""" return 8 # 120 FPS @@ -99,6 +105,16 @@ class MacOSEasing: """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): @@ -115,6 +131,7 @@ class SlideRevealer(Gtk.Overlay): 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) @@ -146,18 +163,19 @@ 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 self._start_animation(show=True) 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) @@ -166,9 +184,11 @@ class SlideRevealer(Gtk.Overlay): 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() @@ -185,18 +205,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: - # Use macOS-style ease out for revealing (fast start, smooth end) - eased = MacOSEasing.ease_out_cubic_bezier(t) + # Use quintic ease out for very smooth revealing + eased = MacOSEasing.ease_out_quint(t) else: - # Use ease in-out for hiding (smooth both ends) - eased = MacOSEasing.ease_in_out_quart(t) + # 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 @@ -275,6 +297,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()