feat(notification): add swipe-to-dismiss and close-button popup
- Replace CSS opacity toggle with Gtk.Revealer CROSSFADE for close button - Wrap close button in EventBox for reliable hover detection - Add 80ms delayed hide to prevent race condition on boundary crossing - Implement swipe-right-to-dismiss with _fixed.move() for smooth 60fps - Skip SlideRevealer.hide() on swipe dismiss to prevent skeleton flash
This commit is contained in:
1 parent
87b093eabb
commit
71b20f8a2c
1 file changed
+257
-140
@@ -9,7 +9,6 @@ from fabric.notifications import (
|
||||
from fabric.utils import Gdk, GdkPixbuf, GLib, Gtk, logger, os
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
@@ -444,6 +443,39 @@ class ActionButton(Button):
|
||||
self.action.parent.close("dismissed-by-user")
|
||||
|
||||
|
||||
def _get_urgency_class(notification: Notification) -> str:
|
||||
urgency = getattr(notification, "urgency", None)
|
||||
if urgency is None:
|
||||
return "normal"
|
||||
urgency_str = str(urgency).lower()
|
||||
if "low" in urgency_str:
|
||||
return "low"
|
||||
if "critical" in urgency_str:
|
||||
return "critical"
|
||||
return "normal"
|
||||
|
||||
|
||||
def _get_relative_time(notification: Notification) -> str:
|
||||
try:
|
||||
import time as _time
|
||||
|
||||
ts = getattr(notification, "time", None)
|
||||
if ts is None:
|
||||
return ""
|
||||
diff = _time.time() - float(ts)
|
||||
if diff < 60:
|
||||
return "now"
|
||||
mins = int(diff / 60)
|
||||
if mins < 60:
|
||||
return f"{mins}m ago"
|
||||
hours = int(mins / 60)
|
||||
if hours < 24:
|
||||
return f"{hours}h ago"
|
||||
return f"{int(hours / 24)}d ago"
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
class NotificationWidget(Box):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -453,18 +485,15 @@ class NotificationWidget(Box):
|
||||
name="notification",
|
||||
**kwargs,
|
||||
):
|
||||
# Get current timeout from config manager if not provided
|
||||
if timeout_ms is None:
|
||||
timeout_ms = self._get_current_notification_timeout()
|
||||
|
||||
self.show_close_button = show_close_button
|
||||
self.close_button = None
|
||||
self._is_hovered = False
|
||||
self.notification_image_cache_key = None # Track cached image for cleanup
|
||||
self.app_icon_source = (
|
||||
notification.app_icon
|
||||
) # Track app icon source for cleanup
|
||||
self._should_cleanup_cache = False # Only cleanup cache on manual dismissal
|
||||
self.notification_image_cache_key = None
|
||||
self.app_icon_source = notification.app_icon
|
||||
self._should_cleanup_cache = False
|
||||
|
||||
content_box = Box(
|
||||
name=f"{name}-inner-content",
|
||||
@@ -490,6 +519,9 @@ class NotificationWidget(Box):
|
||||
children=[self.hover_event_box],
|
||||
)
|
||||
|
||||
# Apply urgency CSS class
|
||||
self.get_style_context().add_class(_get_urgency_class(notification))
|
||||
|
||||
self.notification = notification
|
||||
self.timeout_ms = timeout_ms
|
||||
self._timeout_id = None
|
||||
@@ -498,129 +530,99 @@ class NotificationWidget(Box):
|
||||
self.show_all()
|
||||
|
||||
def create_header(self, notification):
|
||||
"""Create notification header with optimized cached app icon - SINGLE CACHE SIZE"""
|
||||
"""Create notification header with app icon + app name + time"""
|
||||
try:
|
||||
# Get 64x64 cached icon and scale down to 24x24 for header
|
||||
cached_app_icon_pixbuf = cache_notification_icon(
|
||||
notification.app_icon or notification.app_name, (64, 64)
|
||||
)
|
||||
|
||||
if cached_app_icon_pixbuf:
|
||||
# Scale down the 64x64 cached icon to 24x24 for header display
|
||||
header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple(
|
||||
24, 24, GdkPixbuf.InterpType.BILINEAR
|
||||
16, 16, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
app_icon = ClippingBox(
|
||||
name="notification-icon",
|
||||
children=Image(pixbuf=header_icon_pixbuf),
|
||||
)
|
||||
else:
|
||||
# Fallback to theme icon if caching fails completely
|
||||
app_icon = ClippingBox(
|
||||
name="notification-icon",
|
||||
children=svg_file("notifications/notification-active.svg", size=24),
|
||||
children=svg_file("notifications/notification-active.svg", size=16),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cached header icon: {e}")
|
||||
# Ultimate fallback
|
||||
app_icon = ClippingBox(
|
||||
name="notification-icon",
|
||||
children=svg_file("notifications/notification-active.svg", size=24),
|
||||
children=svg_file("notifications/notification-active.svg", size=16),
|
||||
)
|
||||
|
||||
return CenterBox(
|
||||
name="notification-title",
|
||||
start_children=[
|
||||
Box(
|
||||
spacing=4,
|
||||
children=[
|
||||
app_icon,
|
||||
Label(
|
||||
notification.app_name,
|
||||
name="notification-app-name",
|
||||
h_align="start",
|
||||
),
|
||||
],
|
||||
)
|
||||
return Box(
|
||||
name="notification-header",
|
||||
orientation="h",
|
||||
spacing=4,
|
||||
children=[
|
||||
app_icon,
|
||||
Label(
|
||||
notification.app_name or "",
|
||||
name="notification-app-name",
|
||||
h_align="start",
|
||||
ellipsization="end",
|
||||
),
|
||||
],
|
||||
end_children=[Box()],
|
||||
)
|
||||
|
||||
def create_content(self, notification):
|
||||
if self.show_close_button:
|
||||
self.close_button = Button(
|
||||
name="notification-close-button",
|
||||
image=CustomImage(icon_name="close-symbolic", icon_size=18),
|
||||
on_clicked=lambda *_: self._manual_close(),
|
||||
)
|
||||
self.close_button.get_style_context().add_class("mac-close-button")
|
||||
setup_cursor_hover(self.close_button)
|
||||
else:
|
||||
self.close_button = None
|
||||
|
||||
return Box(
|
||||
name="notification-content",
|
||||
spacing=8,
|
||||
orientation="v",
|
||||
spacing=6,
|
||||
children=[
|
||||
ClippingBox(
|
||||
name="notification-image",
|
||||
children=Image(pixbuf=self._get_notification_pixbuf(notification)),
|
||||
),
|
||||
Box(
|
||||
name="notification-text",
|
||||
orientation="v",
|
||||
v_align="center",
|
||||
spacing=8,
|
||||
children=[
|
||||
ClippingBox(
|
||||
name="notification-image",
|
||||
children=Image(
|
||||
pixbuf=self._get_notification_pixbuf(notification)
|
||||
),
|
||||
),
|
||||
Box(
|
||||
name="notification-summary-box",
|
||||
orientation="h",
|
||||
name="notification-text",
|
||||
orientation="v",
|
||||
v_align="center",
|
||||
h_expand=True,
|
||||
children=[
|
||||
self.create_header(notification),
|
||||
Label(
|
||||
name="notification-summary",
|
||||
markup=escape_markup_text(
|
||||
notification.summary.replace("\n", " ")
|
||||
),
|
||||
h_align="start",
|
||||
max_chars_width=40,
|
||||
max_chars_width=38,
|
||||
ellipsization="end",
|
||||
),
|
||||
(
|
||||
Label(
|
||||
markup=escape_markup_text(
|
||||
notification.body.replace("\n", " ")
|
||||
),
|
||||
h_align="start",
|
||||
max_chars_width=42,
|
||||
ellipsization="end",
|
||||
)
|
||||
if notification.body
|
||||
else Box()
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
Label(
|
||||
markup=escape_markup_text(
|
||||
notification.body.replace("\n", " ")
|
||||
),
|
||||
h_align="start",
|
||||
max_chars_width=45,
|
||||
ellipsization="end",
|
||||
)
|
||||
if notification.body
|
||||
else Label(
|
||||
markup="",
|
||||
h_align="start",
|
||||
ellipsization="end",
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
Box(h_expand=True),
|
||||
Box(
|
||||
orientation="v",
|
||||
children=[
|
||||
self.close_button,
|
||||
Box(v_expand=True),
|
||||
],
|
||||
)
|
||||
if self.show_close_button
|
||||
else Box(h_expand=True),
|
||||
],
|
||||
)
|
||||
|
||||
def _on_enter_notify(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
|
||||
self._is_hovered = True
|
||||
self.pause_timeout()
|
||||
return False
|
||||
@@ -628,7 +630,6 @@ class NotificationWidget(Box):
|
||||
def _on_leave_notify(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
|
||||
self._is_hovered = False
|
||||
self.resume_timeout()
|
||||
return False
|
||||
@@ -857,10 +858,43 @@ class NotificationRevealer(SlideRevealer):
|
||||
self.notif_box = NotificationWidget(notification, show_close_button=True)
|
||||
self.notification = notification
|
||||
self.on_transition_end = on_transition_end
|
||||
# Reference to NotificationCenter window for queue clearing
|
||||
self.parent_window = parent_window
|
||||
self._is_closing = False
|
||||
|
||||
# Close button pinned to top-right of the whole popup
|
||||
self.close_button = Button(
|
||||
name="notification-close-button",
|
||||
image=CustomImage(icon_name="close-symbolic", icon_size=14),
|
||||
on_clicked=lambda *_: self._manual_close(),
|
||||
)
|
||||
self.close_button.set_halign(Gtk.Align.CENTER)
|
||||
self.close_button.set_valign(Gtk.Align.CENTER)
|
||||
setup_cursor_hover(self.close_button)
|
||||
|
||||
self._is_close_button_hovered = False
|
||||
self._is_popup_hovered = False
|
||||
self._close_button_hide_timeout_id = None
|
||||
|
||||
self.close_button_event_box = EventBox(
|
||||
events=["enter-notify-event", "leave-notify-event"],
|
||||
child=self.close_button,
|
||||
)
|
||||
self.close_button_event_box.connect(
|
||||
"enter-notify-event", self._on_close_button_enter
|
||||
)
|
||||
self.close_button_event_box.connect(
|
||||
"leave-notify-event", self._on_close_button_leave
|
||||
)
|
||||
|
||||
self.close_button_revealer = Gtk.Revealer(
|
||||
transition_type=Gtk.RevealerTransitionType.CROSSFADE,
|
||||
transition_duration=200,
|
||||
child=self.close_button_event_box,
|
||||
halign=Gtk.Align.END,
|
||||
valign=Gtk.Align.START,
|
||||
reveal_child=False,
|
||||
)
|
||||
|
||||
# Enhanced swipe detection variables for Android-style animation
|
||||
self._drag_start_y = 0
|
||||
self._drag_start_x = 0
|
||||
@@ -880,7 +914,6 @@ class NotificationRevealer(SlideRevealer):
|
||||
self._animation_in_progress = False
|
||||
self._spring_timer_id = None
|
||||
self._anim_timeout_id = None
|
||||
self._css_provider = None
|
||||
|
||||
# Wrap notification in EventBox for swipe detection
|
||||
self.event_box = EventBox(
|
||||
@@ -888,14 +921,27 @@ class NotificationRevealer(SlideRevealer):
|
||||
"button-press-event",
|
||||
"button-release-event",
|
||||
"motion-notify-event",
|
||||
"enter-notify-event",
|
||||
"leave-notify-event",
|
||||
],
|
||||
child=self.notif_box,
|
||||
)
|
||||
self.event_box.connect("enter-notify-event", self._on_popup_enter)
|
||||
self.event_box.connect("leave-notify-event", self._on_popup_leave)
|
||||
self.event_box.connect("button-press-event", self._on_button_press)
|
||||
self.event_box.connect("button-release-event", self._on_button_release)
|
||||
self.event_box.connect("motion-notify-event", self._on_motion_notify)
|
||||
|
||||
# Overlay so close button sits at top-right of the whole popup
|
||||
self._popup_overlay = Gtk.Overlay()
|
||||
self._popup_overlay.add(self.event_box)
|
||||
self._popup_overlay.add_overlay(self.close_button_revealer)
|
||||
self._popup_overlay.show_all()
|
||||
|
||||
super().__init__(
|
||||
child=self.event_box,
|
||||
child=self._popup_overlay,
|
||||
direction="right",
|
||||
duration=280, # Faster, smoother duration
|
||||
duration=280,
|
||||
)
|
||||
|
||||
smooth_revealer_animation(self)
|
||||
@@ -909,65 +955,140 @@ class NotificationRevealer(SlideRevealer):
|
||||
"""Smoother easing function for better animation quality"""
|
||||
return 1 - pow(1 - t, 3)
|
||||
|
||||
def _on_popup_enter(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._is_popup_hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
self.close_button_revealer.set_reveal_child(True)
|
||||
return False
|
||||
|
||||
def _on_popup_leave(self, widget, event):
|
||||
if hasattr(event, "detail") and event.detail == Gdk.NotifyType.INFERIOR:
|
||||
return False
|
||||
self._is_popup_hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_enter(self, widget, event):
|
||||
self._is_close_button_hovered = True
|
||||
self._cancel_close_button_hide()
|
||||
return False
|
||||
|
||||
def _on_close_button_leave(self, widget, event):
|
||||
self._is_close_button_hovered = False
|
||||
self._schedule_close_button_hide()
|
||||
return False
|
||||
|
||||
def _schedule_close_button_hide(self):
|
||||
self._cancel_close_button_hide()
|
||||
self._close_button_hide_timeout_id = GLib.timeout_add(
|
||||
80, self._do_hide_close_button
|
||||
)
|
||||
|
||||
def _cancel_close_button_hide(self):
|
||||
if self._close_button_hide_timeout_id is not None:
|
||||
GLib.source_remove(self._close_button_hide_timeout_id)
|
||||
self._close_button_hide_timeout_id = None
|
||||
|
||||
def _do_hide_close_button(self):
|
||||
self._close_button_hide_timeout_id = None
|
||||
if not self._is_popup_hovered and not self._is_close_button_hovered:
|
||||
self.close_button_revealer.set_reveal_child(False)
|
||||
return False
|
||||
|
||||
def _on_button_press(self, widget, event):
|
||||
if event.button != 1:
|
||||
return False
|
||||
self._is_dragging = True
|
||||
self._drag_start_x = event.x_root
|
||||
self._drag_start_y = event.y_root
|
||||
self._current_offset = 0
|
||||
self._last_drag_time = GLib.get_monotonic_time() / 1000
|
||||
self._drag_velocity = 0
|
||||
if self._spring_timer_id:
|
||||
GLib.source_remove(self._spring_timer_id)
|
||||
self._spring_timer_id = None
|
||||
return False
|
||||
|
||||
def _on_button_release(self, widget, event):
|
||||
if not self._is_dragging:
|
||||
return False
|
||||
self._is_dragging = False
|
||||
if (
|
||||
abs(self._current_offset) > NOTIFICATION_WIDTH * self._dismiss_threshold
|
||||
or abs(self._drag_velocity) > self._swipe_velocity_threshold
|
||||
):
|
||||
self._swipe_in_progress = True
|
||||
self._animate_dismiss(self._current_offset)
|
||||
else:
|
||||
self._animate_spring_back()
|
||||
return False
|
||||
|
||||
def _on_motion_notify(self, widget, event):
|
||||
if not self._is_dragging:
|
||||
return False
|
||||
current_x = event.x_root
|
||||
delta_x = current_x - self._drag_start_x
|
||||
if delta_x <= 0:
|
||||
return False
|
||||
self._current_offset = delta_x
|
||||
self._calculate_drag_velocity(current_x)
|
||||
self._fixed.move(self.child, round(delta_x), 0)
|
||||
progress = min(1.0, delta_x / NOTIFICATION_WIDTH)
|
||||
self.child.set_opacity(1.0 - (progress * 0.5))
|
||||
return False
|
||||
|
||||
def _animate_spring_back(self):
|
||||
if self._spring_timer_id:
|
||||
GLib.source_remove(self._spring_timer_id)
|
||||
start_offset = self._current_offset
|
||||
start_time = GLib.get_monotonic_time() / 1000
|
||||
duration = 200
|
||||
|
||||
def step():
|
||||
elapsed = (GLib.get_monotonic_time() / 1000) - start_time
|
||||
progress = min(1.0, elapsed / duration)
|
||||
eased = self._ease_out_cubic(progress)
|
||||
offset = start_offset * (1.0 - eased)
|
||||
self._fixed.move(self.child, round(offset), 0)
|
||||
self.child.set_opacity(1.0 - (abs(offset) / NOTIFICATION_WIDTH * 0.5))
|
||||
if progress >= 1.0:
|
||||
self._fixed.move(self.child, 0, 0)
|
||||
self.child.set_opacity(1.0)
|
||||
self._spring_timer_id = None
|
||||
return False
|
||||
return True
|
||||
|
||||
self._spring_timer_id = GLib.timeout_add(16, step)
|
||||
|
||||
def _ease_out_quart(self, t):
|
||||
"""Even smoother easing for ultra-smooth animations"""
|
||||
return 1 - pow(1 - t, 4)
|
||||
|
||||
def _apply_transform(self, offset_x, opacity, scale):
|
||||
"""Apply smooth CSS transforms for animation"""
|
||||
try:
|
||||
# Create CSS transformation
|
||||
transform_css = f"""
|
||||
opacity: {opacity};
|
||||
transform: translateX({offset_x}px) scale({scale});
|
||||
transition: none;
|
||||
"""
|
||||
|
||||
# Apply to the notification box
|
||||
if hasattr(self.notif_box, "get_style_context"):
|
||||
style_context = self.notif_box.get_style_context()
|
||||
if style_context:
|
||||
# Use CSS provider for smooth transforms
|
||||
if not hasattr(self, "_css_provider") or not self._css_provider:
|
||||
self._css_provider = Gtk.CssProvider()
|
||||
style_context.add_provider(
|
||||
self._css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
|
||||
css_data = f"* {{ {transform_css} }}"
|
||||
self._css_provider.load_from_data(css_data.encode())
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Transform apply failed (non-critical): {e}")
|
||||
|
||||
def _animate_dismiss(self, start_offset):
|
||||
"""Animate the notification sliding out with smooth 60fps animation"""
|
||||
target_offset = NOTIFICATION_WIDTH + 50
|
||||
duration = 200 # Slightly longer for smoother feel
|
||||
|
||||
if self._spring_timer_id:
|
||||
GLib.source_remove(self._spring_timer_id)
|
||||
|
||||
start_time = GLib.get_monotonic_time() / 1000
|
||||
offset_diff = target_offset - start_offset
|
||||
duration = 200
|
||||
|
||||
def animate_step():
|
||||
current_time = GLib.get_monotonic_time() / 1000
|
||||
elapsed = current_time - start_time
|
||||
progress = min(1.0, elapsed / duration)
|
||||
|
||||
# Use smoother easing for premium feel
|
||||
eased_progress = self._ease_out_quart(progress)
|
||||
current_offset = start_offset + (offset_diff * eased_progress)
|
||||
|
||||
# Smoother fade and scale transitions
|
||||
opacity = max(0.0, 1.0 - (progress * 0.9)) # Gentler fade
|
||||
scale = max(0.9, 1.0 - (progress * 0.1)) # Subtle scale
|
||||
|
||||
self._apply_transform(current_offset, opacity, scale)
|
||||
self._fixed.move(self.child, round(current_offset), 0)
|
||||
self.child.set_opacity(max(0.0, 1.0 - (progress * 0.9)))
|
||||
|
||||
if progress >= 1.0:
|
||||
# Mark notification for cache cleanup on swipe dismissal
|
||||
self.notif_box._should_cleanup_cache = True
|
||||
try:
|
||||
self.notification.close("dismissed-by-user")
|
||||
@@ -977,9 +1098,8 @@ class NotificationRevealer(SlideRevealer):
|
||||
|
||||
return True
|
||||
|
||||
# Use consistent 60fps timing
|
||||
self._animation_in_progress = True
|
||||
self._spring_timer_id = GLib.timeout_add(16, animate_step) # ~60 FPS
|
||||
self._spring_timer_id = GLib.timeout_add(16, animate_step)
|
||||
|
||||
def _calculate_drag_velocity(self, current_x):
|
||||
"""Calculate the velocity of the drag gesture"""
|
||||
@@ -1017,17 +1137,19 @@ class NotificationRevealer(SlideRevealer):
|
||||
GLib.source_remove(self._spring_timer_id)
|
||||
self._spring_timer_id = None
|
||||
|
||||
# Swipe already animated the child off-screen — skip revealer animation
|
||||
if self._swipe_in_progress:
|
||||
self.notif_box.destroy()
|
||||
if self.on_transition_end:
|
||||
self.on_transition_end()
|
||||
self.destroy()
|
||||
return
|
||||
|
||||
# Use different slide directions based on dismiss reason
|
||||
if reason == "expired":
|
||||
# Gentle fade-out for auto-dismiss
|
||||
self.set_slide_direction("left")
|
||||
self.duration = 250 # Slightly slower for natural feel
|
||||
elif self._swipe_in_progress:
|
||||
# Quick slide for swipe dismissals
|
||||
self.duration = 150
|
||||
self.set_slide_direction("right")
|
||||
self.duration = 250
|
||||
else:
|
||||
# Smooth slide for manual close
|
||||
self.set_slide_direction("right")
|
||||
self.duration = 200
|
||||
|
||||
@@ -1038,8 +1160,13 @@ class NotificationRevealer(SlideRevealer):
|
||||
timeout_duration, lambda: self._on_animation_complete(True)
|
||||
)
|
||||
|
||||
def _manual_close(self):
|
||||
self.notif_box._should_cleanup_cache = True
|
||||
self.notification.close("dismissed-by-user")
|
||||
|
||||
def destroy(self):
|
||||
# Clean up CSS provider and timers
|
||||
# Clean up timers
|
||||
self._cancel_close_button_hide()
|
||||
if self._spring_timer_id:
|
||||
GLib.source_remove(self._spring_timer_id)
|
||||
self._spring_timer_id = None
|
||||
@@ -1055,16 +1182,6 @@ class NotificationRevealer(SlideRevealer):
|
||||
logger.error(f"An error occurred: {e}")
|
||||
self._closed_handler_id = 0
|
||||
|
||||
# Clean up CSS provider from style context
|
||||
if self._css_provider:
|
||||
try:
|
||||
style_context = self.notif_box.get_style_context()
|
||||
if style_context:
|
||||
style_context.remove_provider(self._css_provider)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
self._css_provider = None
|
||||
|
||||
super().destroy()
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user