Files
Modus/modules/notification/notification.py
T
sankalp e97311e2b0 feat(modules): restructure battery UI and add dynamic layout rebuilding
New Features
- Introduce `get_battery_icon_file` to generate battery icons based on percentage and charging state.
- Add fallback Bluetooth control using `rfkill` when Fabric Bluetooth fails.

Enhancements
- Refactor layout management with `_rebuild_layout_from_config` and dynamic indicator instantiation.
- Replace large `EnergyModeButton` class with lightweight utility functions.
- Simplify notification icon updates using `dynamic_file`.

Bug Fixes
- No explicit bug fixes; changes focus on feature additions and refactoring.
2026-02-26 06:24:44 +05:45

1279 lines
46 KiB
Python

import hashlib
import time
from fabric.notifications import (
Notification,
NotificationAction,
NotificationCloseReason,
)
from fabric.utils import Gdk, GdkPixbuf, GLib, 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
from fabric.widgets.wayland import WaylandWindow as Window
import config.data as data
from modules.notification.unified_cache import (
UNIFIED_NOTIFICATION_CACHE_DIR as _SHARED_NOTIFICATION_CACHE_DIR,
)
from modules.notification.unified_cache import (
cleanup_cache as _unified_cleanup_cache,
)
from modules.notification.unified_cache import (
cleanup_old_cache_files as _unified_cleanup_old_cache_files,
)
from modules.notification.unified_cache import (
ensure_cache_dir as _ensure_unified_cache_dir,
)
from modules.notification.unified_cache import (
get_fallback_icon as _shared_fallback_icon,
)
from modules.notification.unified_cache import (
get_from_cache as _unified_get_from_cache,
)
from modules.notification.unified_cache import (
get_unified_cache_key,
)
from modules.notification.unified_cache import (
save_to_cache as _unified_save_to_cache,
)
from services.config import get_config, on_config_change
from services.modus import notification_service
from utils.functions import escape_markup_text, parse_timeout_string
from utils.roam import modus_service
from widgets.clipping_box import ClippingBox
from widgets.custom_image import CustomImage
from widgets.customrevealer import SlideRevealer
NOTIFICATION_WIDTH = 360
NOTIFICATION_IMAGE_SIZE = 48
# Use shared unified cache directory from unified_cache
NOTIFICATION_ICON_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR
NOTIFICATION_IMAGE_CACHE_DIR = _SHARED_NOTIFICATION_CACHE_DIR
def ensure_notification_cache_dirs():
"""Ensure unified notification cache directory exists"""
_ensure_unified_cache_dir()
def cleanup_old_cache_files():
"""Clean up old notification cache files (older than 7 days)"""
try:
_unified_cleanup_old_cache_files()
except Exception as e:
logger.warning(f"Failed to cleanup notification cache: {e}")
get_cache_key = get_unified_cache_key
def save_pixbuf_to_cache(pixbuf, cache_key, cache_dir):
"""Save a pixbuf to the specified cache directory"""
try:
ensure_notification_cache_dirs()
# Delegate to unified cache; ignore returned key for compatibility
result = _unified_save_to_cache(pixbuf, cache_key)
if result and result[0]:
return result[0]
return None
except Exception as e:
logger.warning(f"Failed to cache notification icon: {e}")
return None
def get_cached_pixbuf(cache_key, fallback_size=(48, 48), cache_dir=None):
"""Get a cached pixbuf or return None if not found"""
if cache_dir is None:
cache_dir = NOTIFICATION_ICON_CACHE_DIR
try:
pixbuf = _unified_get_from_cache(cache_key, fallback_size)
if pixbuf:
return pixbuf
except Exception as e:
logger.warning(f"Failed to load cached notification icon: {e}")
return None
def cache_notification_icon(source, size=(48, 48), app_name=None):
"""Optimized notification icon caching with immediate pixbuf generation and caching"""
try:
ensure_notification_cache_dirs()
# Handle different source types with optimized caching
if isinstance(source, str):
cache_key = get_unified_cache_key(source, size, app_name)
# Check cache first for immediate return
cached_pixbuf = get_cached_pixbuf(
cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
)
if cached_pixbuf:
return cached_pixbuf
# Load, cache, and return icon in one optimized flow
if source.startswith("file://"):
# Local file URL - process and cache immediately
file_path = source[7:]
pixbuf = load_and_cache_local_icon(file_path, cache_key, size)
elif os.path.exists(source):
# Direct file path - process and cache immediately
pixbuf = load_and_cache_local_icon(source, cache_key, size)
else:
# Icon name - resolve from theme and cache immediately
pixbuf = load_and_cache_theme_icon(source, cache_key, size)
return pixbuf
elif hasattr(source, "scale_simple"):
# Already a pixbuf - cache it directly with optimized flow
cache_key = get_unified_cache_key(source, size, app_name)
# Check cache first
cached_pixbuf = get_cached_pixbuf(
cache_key, fallback_size=size, cache_dir=NOTIFICATION_ICON_CACHE_DIR
)
if cached_pixbuf:
return cached_pixbuf
# Scale once and cache immediately
scaled_pixbuf = source.scale_simple(
size[0], size[1], GdkPixbuf.InterpType.BILINEAR
)
save_pixbuf_to_cache(scaled_pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
return scaled_pixbuf
except Exception as e:
logger.warning(f"Failed to cache notification icon: {e}")
# Return fallback with caching
return get_fallback_notification_icon(size)
def load_and_cache_local_icon(file_path, cache_key, size):
"""Load a local icon file and cache it"""
try:
if os.path.exists(file_path):
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
file_path, size[0], size[1], True
)
save_pixbuf_to_cache(pixbuf, cache_key, NOTIFICATION_ICON_CACHE_DIR)
return pixbuf
except Exception as e:
logger.warning(f"Failed to load local notification icon {file_path}: {e}")
return get_fallback_notification_icon(size)
def load_and_cache_theme_icon(icon_name, cache_key, size):
"""Load an icon from the current theme and cache it"""
# For simplicity, just use fallback since theme icon loading is complex in GTK4
return get_fallback_notification_icon(size)
def get_fallback_notification_icon(size=(48, 48)):
return _shared_fallback_icon(size)
def get_notification_image_cache_key(notification_id, image_pixbuf):
"""Generate a deterministic cache key based on image content to prevent duplicate caching"""
try:
# Use image content hash for deterministic caching
if image_pixbuf and hasattr(image_pixbuf, "get_pixels"):
try:
pixel_data = image_pixbuf.get_pixels()
image_hash = hashlib.md5(pixel_data).hexdigest()[:8]
return image_hash
except Exception:
# If pixel data fails, use image dimensions + timestamp
try:
width = image_pixbuf.get_width()
height = image_pixbuf.get_height()
dimension_hash = hashlib.md5(
f"{width}x{height}".encode()
).hexdigest()[:8]
return dimension_hash
except Exception:
pass
# Fallback to timestamp for invalid pixbufs
return str(int(time.time()))[:8]
except Exception:
# Ultimate fallback
return str(int(time.time()))[:8]
def cache_notification_image(notification_id, image_pixbuf, size=(64, 64)):
"""Smart notification image caching that avoids duplicate caching"""
try:
ensure_notification_cache_dirs()
# Generate deterministic cache key based on image content
cache_key = get_notification_image_cache_key(notification_id, image_pixbuf)
try:
# Let unified cache handle scaling and saving
saved_path, _ = _unified_save_to_cache(image_pixbuf, cache_key, size)
if saved_path:
return saved_path, cache_key
return None, None
except Exception as scale_error:
logger.debug(
f"Failed to cache image (temp file likely gone): {scale_error}"
)
return None, None
except Exception as e:
logger.warning(f"Failed to cache notification image: {e}")
return None, None
def get_cached_notification_image(cache_key):
"""Get a cached notification image or return None if not found"""
try:
return _unified_get_from_cache(cache_key)
except Exception as e:
logger.warning(f"Failed to load cached notification image: {e}")
return None
def cleanup_notification_image_cache(cache_key=None):
"""Clean up notification image cache - specific key or all"""
try:
ensure_notification_cache_dirs()
_unified_cleanup_cache(cache_key)
except Exception as e:
logger.warning(f"Failed to cleanup notification image cache: {e}")
def cleanup_notification_specific_caches(
app_icon_source=None, notification_image_cache_key=None
):
"""Clean up caches specific to a notification (both app icon and notification image) - SINGLE ICON SIZE"""
try:
# Clean up notification image cache
if notification_image_cache_key:
_unified_cleanup_cache(notification_image_cache_key)
# Clean up app icon cache for this specific source (only 35x35 version)
if app_icon_source:
# Only clean 35x35 version since we only cache this size now
cache_key_35 = get_unified_cache_key(app_icon_source, (35, 35))
_unified_cleanup_cache(cache_key_35)
except Exception as e:
logger.warning(f"Failed to cleanup notification specific caches: {e}")
def cleanup_all_notification_caches():
"""Clean up ALL notification caches (icons and images)"""
try:
_unified_cleanup_cache()
except Exception as e:
logger.warning(f"Failed to cleanup all notification caches: {e}")
def verify_cache_persistence():
"""Verify that cached icons persist and can be loaded after restart"""
try:
icon_cache_files = []
image_cache_files = []
if os.path.exists(NOTIFICATION_ICON_CACHE_DIR):
icon_cache_files = [
f for f in os.listdir(NOTIFICATION_ICON_CACHE_DIR) if f.endswith(".png")
]
if os.path.exists(NOTIFICATION_IMAGE_CACHE_DIR):
image_cache_files = [
f
for f in os.listdir(NOTIFICATION_IMAGE_CACHE_DIR)
if f.endswith(".png")
]
# Test loading a few cached items to verify they work
for cache_file in icon_cache_files[:2]: # Test first 2 icon files
try:
cache_path = os.path.join(NOTIFICATION_ICON_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached icon {cache_file}: {e}")
for cache_file in image_cache_files[:2]: # Test first 2 image files
try:
cache_path = os.path.join(NOTIFICATION_IMAGE_CACHE_DIR, cache_file)
test_pixbuf = GdkPixbuf.Pixbuf.new_from_file(cache_path)
except Exception as e:
logger.warning(f"Failed to load cached image {cache_file}: {e}")
return len(icon_cache_files) + len(image_cache_files) > 0
except Exception as e:
logger.error(f"Failed to verify cache persistence: {e}")
return False
def migrate_persistent_notifications():
"""Migrate persistent notifications to use cached assets when temp files are gone"""
try:
from services.modus import notification_service
migrated_count = 0
for cached_notification in notification_service.cached_notifications:
notification = cached_notification._notification
# Check if notification has image_pixbuf but temp file might be gone
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
try:
# Try to access pixel data to test if temp file still exists
notification.image_pixbuf.get_pixels()
except Exception:
# Temp file is gone, ensure app icon is cached as fallback
try:
if hasattr(notification, "app_icon") and notification.app_icon:
cache_notification_icon(notification.app_icon, (35, 35))
migrated_count += 1
except Exception as cache_error:
logger.debug(
f"Failed to cache app icon for {notification.app_name}: {
cache_error
}"
)
except Exception as e:
logger.warning(f"Failed to migrate persistent notifications: {e}")
# Initialize cache and verify persistence on module load
ensure_notification_cache_dirs()
cleanup_old_cache_files()
verify_cache_persistence()
# Run migration for persistent notifications on startup
try:
migrate_persistent_notifications()
except Exception as e:
logger.debug(f"Migration skipped (service not ready): {e}")
def preload_notification_assets(notification):
"""Preload and cache notification assets with robust error handling for persistent notifications - SINGLE ICON SIZE"""
try:
# Cache app icon only at content size (35x35) - scale down for headers at runtime
if hasattr(notification, "app_icon") and notification.app_icon:
try:
# Only cache at 35x35 to reduce disk usage - headers will scale this down
cache_notification_icon(notification.app_icon, (35, 35))
except Exception as icon_error:
logger.debug(
f"Failed to preload app icon for {notification.app_name}: {
icon_error
}"
)
# Cache notification image if available
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
cache_notification_image(
notification.id, notification.image_pixbuf, (35, 35)
)
except Exception as e:
logger.warning(f"Failed to preload notification assets: {e}")
def smooth_revealer_animation(revealer: SlideRevealer, duration: int = 280):
"""Configure revealer for ultra-smooth animation"""
revealer.duration = duration
class ActionButton(Button):
def __init__(
self, action: NotificationAction, index: int, total: int, notification_box
):
super().__init__(
name="action-button",
h_expand=True,
on_clicked=self.on_clicked,
child=Label(name="button-label", label=action.label),
)
self.action = action
self.notification_box = notification_box
style_class = (
"start-action"
if index == 0
else "end-action"
if index == total - 1
else "middle-action"
)
self.add_style_class(style_class)
self.connect(
"enter-notify-event", lambda *_: notification_box.hover_button(self)
)
self.connect(
"leave-notify-event", lambda *_: notification_box.unhover_button(self)
)
def on_clicked(self, *_):
# Mark for cache cleanup when action button is clicked
self.notification_box._should_cleanup_cache = True
self.action.invoke()
self.action.parent.close("dismissed-by-user")
class NotificationWidget(Box):
def __init__(
self,
notification: Notification,
timeout_ms=None,
show_close_button=True,
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
super().__init__(
size=(NOTIFICATION_WIDTH, -1),
name=name,
orientation="v",
h_align="fill",
h_expand=True,
children=[
self.create_content(notification),
self.create_action_buttons(notification),
],
)
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):
"""Create notification header with optimized cached app icon - SINGLE CACHE SIZE"""
try:
# Get 35x35 cached icon and scale down to 24x24 for header
cached_app_icon_pixbuf = cache_notification_icon(
notification.app_icon, (35, 35)
)
if cached_app_icon_pixbuf:
# Scale down the 35x35 cached icon to 24x24 for header display
header_icon_pixbuf = cached_app_icon_pixbuf.scale_simple(
24, 24, 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=Image(
icon_name="notifications",
icon_size=24,
),
)
except Exception as e:
logger.warning(f"Failed to load cached header icon: {e}")
# Ultimate fallback
app_icon = ClippingBox(
name="notification-icon",
children=Image(
icon_name="notifications",
icon_size=24,
),
)
return CenterBox(
name="notification-title",
start_children=[
Box(
spacing=4,
children=[
app_icon,
Label(
notification.app_name,
name="notification-app-name",
h_align="start",
),
],
)
],
end_children=[
self.create_close_button() if self.show_close_button else Box()
],
)
def create_content(self, notification):
return Box(
name="notification-content",
spacing=8,
children=[
ClippingBox(
name="notification-image",
children=Image(pixbuf=self._get_notification_pixbuf(notification)),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=escape_markup_text(
notification.summary.replace("\n", " ")
),
h_align="start",
max_chars_width=40,
ellipsization="end",
),
# Label(
# name="notification-app-name",
# markup=" | " + notification.app_name,
# h_align="start",
# ellipsization="end",
# ),
],
),
(
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=[
Button(
name="notification-close-button",
image=CustomImage(icon_name="close-symbolic", icon_size=18),
visible=True, # Initially hidden
on_clicked=lambda *_: self._manual_close(),
),
Box(v_expand=True),
],
),
],
)
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):
"""Get pixbuf with caching support"""
try:
# Use the icon caching system
cached_pixbuf = cache_notification_icon(icon_path, (width, height))
if cached_pixbuf:
return cached_pixbuf
except Exception as e:
logger.warning(f"Failed to get cached pixbuf for {icon_path}: {e}")
# Fallback to original method if caching fails
if icon_path.startswith("file://"):
icon_path = icon_path[7:]
if not os.path.exists(icon_path):
return get_fallback_notification_icon((width, height))
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
if pixbuf:
return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
else:
return get_fallback_notification_icon((width, height))
except Exception as e:
logger.error(f"Failed to load or scale icon: {e}")
return get_fallback_notification_icon((width, height))
def _get_notification_pixbuf(self, notification):
"""Simplified notification pixbuf with NO CACHING for image-pixmap to prevent disk bloat"""
notification_id = getattr(notification, "id", int(time.time()))
try:
# Try to get cached notification image first
if hasattr(notification, "image_pixbuf") and notification.image_pixbuf:
try:
cache_key = get_notification_image_cache_key(
notification_id, notification.image_pixbuf
)
cached_image = get_cached_notification_image(cache_key)
if cached_image:
return cached_image
except Exception as image_error:
logger.debug(f"Notification image processing failed: {image_error}")
# Continue to app icon fallback
except Exception as e:
logger.debug(f"Failed to process notification image: {e}")
# Use cached app icon as fallback
try:
app_icon_source = getattr(notification, "app_icon", None)
if app_icon_source:
cached_app_icon = cache_notification_icon(app_icon_source, (35, 35))
if cached_app_icon:
return cached_app_icon
except Exception as e:
logger.debug(f"Failed to get cached app icon: {e}")
# Ultimate fallback
return get_fallback_notification_icon((35, 35))
def create_action_buttons(self, notification):
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)
],
)
def start_timeout(self):
self.stop_timeout()
self._timeout_id = GLib.timeout_add(self.timeout_ms, self.close_notification)
def stop_timeout(self):
if self._timeout_id is not None:
GLib.source_remove(self._timeout_id)
self._timeout_id = None
def close_notification(self):
self.notification.close("expired")
self.stop_timeout()
return False
def pause_timeout(self):
self.stop_timeout()
def resume_timeout(self):
if not self._is_hovered: # Only resume if not hovered
self.start_timeout()
def _manual_close(self):
"""Handle manual close button click - just close notification"""
self.notification.close("dismissed-by-user")
def destroy(self):
self.stop_timeout()
# Only clean up caches if this was a manual dismissal
if self._should_cleanup_cache:
cleanup_notification_specific_caches(
app_icon_source=getattr(self, "app_icon_source", None),
notification_image_cache_key=getattr(
self, "notification_image_cache_key", None
),
)
else:
logger.debug("Preserved caches for timeout/auto-dismissed notification")
super().destroy()
# @staticmethod
def set_pointer_cursor(self, widget, cursor_name):
window = widget.get_window()
if window:
cursor = Gdk.Cursor.new_from_name(widget.get_display(), cursor_name)
window.set_cursor(cursor)
def hover_button(self, button):
self.pause_timeout()
self.set_pointer_cursor(button, "hand2")
def unhover_button(self, button):
# Don't resume timeout here since the notification itself might still be hovered
self.set_pointer_cursor(button, "arrow")
@staticmethod
def _get_current_notification_timeout():
"""Get the current notification timeout from config manager."""
try:
timeout_str = get_config(
"notification_timeout", data.NOTIFICATION_TIMEOUT_STR
)
from config.data import parse_timeout_string
return parse_timeout_string(timeout_str)
except Exception as e:
logger.warning(f"Failed to get notification timeout from config: {e}")
return data.NOTIFICATION_TIMEOUT
class NotificationRevealer(SlideRevealer):
def __init__(
self,
notification: Notification,
on_transition_end=None,
parent_window=None,
**kwargs,
):
self.notif_box = NotificationWidget(notification, show_close_button=False)
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
# Enhanced swipe detection variables for Android-style animation
self._drag_start_y = 0
self._drag_start_x = 0
self._is_dragging = False
self._swipe_threshold = 80 # Distance to trigger auto-dismiss
self._swipe_velocity_threshold = (
150 # Velocity to trigger dismiss even on shorter swipes
)
self._swipe_in_progress = False
self._current_offset = 0
self._last_drag_time = 0
self._drag_velocity = 0
self._spring_back_duration = 200 # Duration for spring-back animation
self._dismiss_threshold = 0.3 # Dismiss if swiped 30% of width
# Animation state
self._animation_in_progress = False
self._spring_timer_id = None
self._css_provider = None
# Wrap notification in EventBox for swipe detection
self.event_box = EventBox(
events=[
"button-press-event",
"button-release-event",
"motion-notify-event",
],
child=self.notif_box,
)
super().__init__(
child=self.event_box,
direction="right",
duration=280, # Faster, smoother duration
)
smooth_revealer_animation(self)
# Connect our own handler that manages the slide animation
self.notification.connect("closed", self.on_resolved)
self._animation_in_progress = True
def _ease_out_cubic(self, t):
"""Smoother easing function for better animation quality"""
return 1 - pow(1 - t, 3)
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:
from gi.repository import Gtk
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
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)
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")
except:
pass
return False
return True
# Use consistent 60fps timing
self._animation_in_progress = True
self._spring_timer_id = GLib.timeout_add(16, animate_step) # ~60 FPS
def _calculate_drag_velocity(self, current_x):
"""Calculate the velocity of the drag gesture"""
current_time = GLib.get_monotonic_time() / 1000
if self._last_drag_time > 0:
time_diff = current_time - self._last_drag_time
if time_diff > 0:
distance_diff = current_x - self._drag_start_x - self._current_offset
self._drag_velocity = abs(distance_diff / time_diff)
self._last_drag_time = current_time
def _on_animation_complete(self, is_hiding=False):
if is_hiding:
# Manually destroy the notification widget since we disconnected its handler
self.notif_box.destroy()
if self.on_transition_end:
self.on_transition_end()
self.destroy()
def on_resolved(
self,
_notification: Notification,
reason: NotificationCloseReason,
):
if self._is_closing:
return
self._is_closing = True
# Clean up any ongoing animations
if self._spring_timer_id:
GLib.source_remove(self._spring_timer_id)
# 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")
else:
# Smooth slide for manual close
self.set_slide_direction("right")
self.duration = 200
self.hide()
# Consistent timing for smooth transitions
timeout_duration = self.duration + 50
GLib.timeout_add(timeout_duration, lambda: self._on_animation_complete(True))
def destroy(self):
# Clean up CSS provider and timers
if self._spring_timer_id:
GLib.source_remove(self._spring_timer_id)
super().destroy()
class NotificationState:
IDLE = 0
SHOWING = 1
HIDING = 2
TRANSITIONING = 3 # New state for smooth transitions
class ModusNoti(Window):
def __init__(self):
# Local config state mirroring services.config usage
self._current_config = {
"notification_timeout": data.NOTIFICATION_TIMEOUT_STR,
"notification_ignored_apps": data.NOTIFICATION_IGNORED_APPS_HISTORY,
"notification_limited_apps_history": data.NOTIFICATION_LIMITED_APPS_HISTORY,
}
# Subscribe to config changes and apply initial config
on_config_change(self._on_config_changed)
self._apply_initial_config()
self._server = notification_service
self.notifications = Box(
v_expand=True,
h_expand=True,
style="margin: 1px 0px 1px 1px;",
orientation="v",
spacing=5,
)
# Enhanced queue system for ultra-smooth transitions
self.notification_queue = []
self.current_notification = None
self.notification_state = NotificationState.IDLE
self._transition_timer_id = None
self._debounce_timer_id = None
self._last_notification_time = 0
# Queue management settings for smooth behavior
self.MAX_QUEUE_SIZE = 3 # Limit queue to prevent overwhelming
self.TRANSITION_DELAY = 100 # Smoother transition timing
self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications
self._server.connect("notification-added", self.on_new_notification)
Window.__init__(
self,
anchor="top right",
child=self.notifications,
layer="overlay",
title="modus-notifications", # More specific title for debugging
all_visible=True,
visible=False, # Start hidden, show only when we have content
exclusive=False,
)
def on_new_notification(self, fabric_notif, id):
notification: Notification = fabric_notif.get_notification_from_id(id)
# Check if notification still exists (might have been removed already)
if not notification:
return
if self._server.dont_disturb or modus_service.dont_disturb:
# Notification is already cached by the service, just don't show popup
return
# Check if notification should be ignored based on current config
if self._should_ignore_notification(notification):
return
# Preload assets immediately for optimal caching and display performance
preload_notification_assets(notification)
# Implement smart queue management for smooth transitions
current_time = GLib.get_monotonic_time() / 1000
# If queue is getting full, remove oldest notifications smoothly
if len(self.notification_queue) >= self.MAX_QUEUE_SIZE:
# Remove oldest notification from queue (not current showing one)
if self.notification_queue:
oldest = self.notification_queue.pop(0)
try:
oldest.close("dismissed-by-user")
except:
pass
# Add new notification to queue
self.notification_queue.append(notification)
# Debounce rapid notifications for smoother experience
if self._debounce_timer_id:
GLib.source_remove(self._debounce_timer_id)
self._debounce_timer_id = GLib.timeout_add(
self.DEBOUNCE_DELAY,
lambda: self._process_notification_queue_debounced() or False,
)
def _process_notification_queue_debounced(self):
"""Process queue after debounce delay for smooth transitions"""
self._debounce_timer_id = None
self._process_notification_queue()
return False
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
):
# Smooth transition: start hiding current notification
self.notification_state = NotificationState.TRANSITIONING
self._start_smooth_transition()
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_smooth_transition(self):
"""Start smooth transition between notifications"""
if self.current_notification and not self.current_notification._is_closing:
# Don't mark for cache cleanup during smooth transitions
# to maintain performance
# Use shorter timeout for smooth transitions
self.current_notification.notif_box.timeout_ms = 100
# Force close current notification with smooth animation
try:
self.current_notification.notification.close("expired")
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)
# Check if notification is still valid (might have been removed)
if not notification or not hasattr(notification, "app_icon"):
# Skip invalid notifications and try next one
if self.notification_queue:
self._show_next_notification()
return
self.notification_state = NotificationState.SHOWING
new_box = NotificationRevealer(
notification,
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)
except:
pass
self.notifications.children = [new_box]
# Show the window now that we have content to display
self.set_visible(True)
new_box.show()
self.notifications.queue_resize()
def start_animation():
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):
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.notification_state = NotificationState.IDLE
# Process next notification with optimized delay for ultra-smooth transitions
if self.notification_queue:
self._transition_timer_id = GLib.timeout_add(
self.TRANSITION_DELAY, # Consistent smooth timing
lambda: self._show_next_notification() or False,
)
else:
# Hide window when no more notifications to show
self.set_visible(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):
"""Clear queue with smooth cleanup"""
queue_length = len(self.notification_queue)
if queue_length > 0:
# Smooth dismissal of queued notifications
for notification in list(self.notification_queue):
try:
notification.close("dismissed-by-user")
except:
pass
self.notification_queue.clear()
# Also clean current notification if showing
if self.current_notification:
# Mark for cache cleanup when clearing queue
self.current_notification.notif_box._should_cleanup_cache = True
# Clear animation timers
if self._transition_timer_id:
GLib.source_remove(self._transition_timer_id)
self._transition_timer_id = None
if self._debounce_timer_id:
GLib.source_remove(self._debounce_timer_id)
self._debounce_timer_id = None
def get_queue_length(self):
return len(self.notification_queue)
def _should_ignore_notification(self, notification):
"""Check if notification should be ignored based on current config."""
try:
ignored_apps = self._current_config.get("notification_ignored_apps", [])
app_name = getattr(notification, "app_name", "")
# Check if app name is in ignored list
if app_name in ignored_apps:
return True
return False
except Exception as e:
logger.warning(f"Failed to check if notification should be ignored: {e}")
return False
def update_config(self, new_config):
"""Update notification configuration dynamically."""
try:
# Handle notification timeout changes
if "notification_timeout" in new_config:
timeout_str = new_config["notification_timeout"]
# Parse the timeout string to milliseconds
timeout_ms = parse_timeout_string(timeout_str)
# Handle ignored apps changes
if "notification_ignored_apps" in new_config:
ignored_apps = new_config["notification_ignored_apps"]
# Handle limited apps history changes
if "notification_limited_apps_history" in new_config:
limited_apps = new_config["notification_limited_apps_history"]
# Update the current config
self._current_config.update(new_config)
except Exception as e:
logger.error(f"[ModusNoti] Failed to update config: {e}")
def _apply_initial_config(self):
try:
timeout_val = get_config(
"notification_timeout", data.NOTIFICATION_TIMEOUT_STR
)
ignored_apps = get_config(
"notification_ignored_apps", data.NOTIFICATION_IGNORED_APPS_HISTORY
)
limited_apps = get_config(
"notification_limited_apps_history",
data.NOTIFICATION_LIMITED_APPS_HISTORY,
)
initial = {
"notification_timeout": timeout_val,
"notification_ignored_apps": ignored_apps,
"notification_limited_apps_history": limited_apps,
}
# Only apply if different
self.update_config(initial)
except Exception as e:
logger.error(f"[ModusNoti] Failed to apply initial config: {e}")
def _on_config_changed(self, new_config: dict, old_config: dict):
try:
changes = {}
for key in (
"notification_timeout",
"notification_ignored_apps",
"notification_limited_apps_history",
):
if key in new_config and new_config.get(key) != old_config.get(key):
changes[key] = new_config.get(key)
if changes:
self.update_config(changes)
except Exception as e:
logger.error(f"[ModusNoti] Error handling config change: {e}")