Notification & misc enhancements #13

Merged
tr1xem merged 9 commits from macos into macos 2025-08-02 16:30:43 +00:00
13 changed files with 292 additions and 130 deletions

No files matched your search

+1
View File
@@ -13,3 +13,4 @@ config/hypr/colors.conf
fabric/**
fabric/
# styles/colors.css
.aider*
+3 -3
View File
@@ -1,3 +1,3 @@
<svg viewBox="-10 -10 45 45" width="28" height="28" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1">
<polyline points="6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5"></polyline>
</svg>
<svg width="24" height="24" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="m11.5 5c-0.277 0-0.5 0.223-0.5 0.5v4.875l-1.719-1.5c-0.312-0.273-0.757-0.22-1.03 0.094a0.753 0.753 0 0 0 0.062 1.062l2.25 1.969-2.25 1.969a0.753 0.753 0 0 0-0.063 1.062c0.274 0.313 0.719 0.367 1.031 0.094l1.72-1.5v4.875c0 0.277 0.222 0.5 0.5 0.5 0.282 0 0.437-0.02 0.655-0.188l3.532-3.156a0.78 0.78 0 0 0 0.218-0.375 0.745 0.745 0 0 0 0.032-0.281 0.723 0.723 0 0 0-0.032-0.156 0.78 0.78 0 0 0-0.218-0.375l-2.813-2.47 2.813-2.468a0.78 0.78 0 0 0 0.218-0.375 0.712 0.712 0 0 0 0-0.437 0.78 0.78 0 0 0-0.218-0.375l-3.532-3.157c-0.199-0.16-0.427-0.187-0.656-0.187zm0.5 2.063 2.094 1.875-2.094 1.812zm0 6.187 2.094 1.813-2.094 1.875z" color="#bebebe" fill="#f2f2f2" overflow="visible"/>
</svg>

Before

Width:  |  Height:  |  Size: 235 B

After

Width:  |  Height:  |  Size: 777 B

+29 -3
View File
@@ -1,4 +1,30 @@
<svg viewBox="-10 -10 45 45" width="28" height="28" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1">
<polyline points="6.5 6.5 17.5 17.5 12 23 12 1 17.5 6.5 6.5 17.5"></polyline>
<line x1="2" y1="2" x2="22" y2="22" stroke="#fff" stroke-width="2"></line>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
id="svg6"
version="1.1"
width="24"
height="24">
<metadata
id="metadata12">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs10" />
<path
id="path2"
d="M 6.3515625,4.7363281 5.4121094,5.6777344 17.601562,17.867188 18.541016,16.925781 13.267578,11.652344 15.6875,9.53125 c 0.106935,-0.1017801 0.18279,-0.2318176 0.21875,-0.375 0.04605,-0.1421978 0.04605,-0.2953022 0,-0.4375 C 15.87029,8.5755676 15.794435,8.4455301 15.6875,8.34375 L 12.15625,5.1875 C 11.95725,5.0275 11.729,5 11.5,5 11.223,5 11,5.223 11,5.5 V 9.3847656 Z M 12,7.0625 14.09375,8.9375 12.195312,10.580078 12,10.384766 Z M 10.494141,12.060547 8.3125,13.96875 c -0.310445,0.276247 -0.3384165,0.751761 -0.0625,1.0625 0.273,0.313 0.71825,0.36675 1.03125,0.09375 l 1.720703,-1.5 V 18.5 c 0,0.277 0.222,0.5 0.5,0.5 0.282,0 0.436297,-0.0195 0.654297,-0.1875 L 14.84375,16.410156 13.779297,15.345703 12,16.9375 v -3.371094 z"
style="color:#bebebe;overflow:visible;fill:#f2f2f2;marker:none" />
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 1.5 KiB

+6 -6
View File
@@ -1,6 +1,6 @@
<svg viewBox="-4 -4 32 32" width="24" height="24" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1">
<path d="M5 12.55a11 11 0 0 1 14.08 0"></path>
<path d="M1.42 9a16 16 0 0 1 21.16 0"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
<svg width="24" height="24" version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs>
<style id="current-color-scheme" type="text/css">.ColorScheme-Text { color:#dedede; }</style>
</defs>
<path class="ColorScheme-Text" d="m12 7.0206c-2.6432 0-5.1397 1.1665-6.85 3.2009a0.63845 0.63845 0 0 0 0.49005 1.0491c0.18222 0 0.36305-0.0773 0.48971-0.22742 1.4657-1.7438 3.6057-2.7446 5.8703-2.7446 2.2646 0 4.4046 1.0012 5.8703 2.7446 0.12631 0.15116 0.30714 0.22777 0.48971 0.22777a0.63776 0.63776 0 0 0 0.4897-1.0495c-1.7097-2.0344-4.2068-3.2009-6.8504-3.2009zm0 2.707c-1.8877 0-3.6709 0.83378-4.8922 2.2863a0.63894 0.63894 0 1 0 0.97838 0.82204 5.1055 5.1055 0 0 1 3.9135-1.8301c1.5102 0 2.9369 0.66744 3.9142 1.8301 0.12631 0.15116 0.30784 0.22777 0.48971 0.22777a0.63845 0.63845 0 0 0 0.48833-1.0495c-1.2217-1.4529-3.0038-2.2867-4.8922-2.2867zm0 2.8126a3.8272 3.8272 0 0 0-2.9358 1.3704 0.63845 0.63845 0 0 0 0.079374 0.90038 0.63603 0.63603 0 0 0 0.90038-0.07765 2.5507 2.5507 0 0 1 1.9561-0.91453c0.75475 0 1.4677 0.33303 1.9568 0.91453a0.63707 0.63707 0 0 0 0.90004 0.07765 0.63845 0.63845 0 0 0 0.07937-0.90038 3.8272 3.8272 0 0 0-2.9362-1.3704zm-0.05452 2.4264c-0.49971 0.04832-0.98079 0.42172-0.99736 0.97838a1.0522 1.0522 0 0 0 2.1041 0c-0.08869-0.74716-0.6067-1.026-1.1068-0.97838z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 1.3 KiB

+9 -9
View File
@@ -1,9 +1,9 @@
<svg viewBox="-10 -10 45 45" width="28" height="28" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1">
<line x1="1" y1="1" x2="23" y2="23"></line>
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"></path>
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"></path>
<path d="M10.71 5.05A16 16 0 0 1 22.58 9"></path>
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"></path>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path>
<line x1="12" y1="20" x2="12.01" y2="20"></line>
</svg>
<svg width="16" height="16" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill="#dedede" opacity=".35">
<path d="m8 3c-2.8405-3e-7 -5.4324 1.0071-7.4863 2.6582a0.5 0.5 0 0 0-0.095703 0.072266 0.5 0.5 0 0 0-0.1543 0.35938 0.5 0.5 0 0 0 0.5 0.5 0.5 0.5 0 0 0 0.31836-0.11328c1.891-1.5388 4.2898-2.4766 6.918-2.4766 2.6282-3e-7 5.0269 0.93776 6.918 2.4766a0.5 0.5 0 0 0 0.31836 0.11328 0.5 0.5 0 0 0 0.5-0.5 0.5 0.5 0 0 0-0.1543-0.35938 0.5 0.5 0 0 0-0.0957-0.072266c-2.0539-1.6511-4.6459-2.6582-7.4863-2.6582z" fill-rule="evenodd" style="paint-order:stroke fill markers"/>
<path d="m8 9c-1.3525-2e-7 -2.5832 0.46891-3.5859 1.2246-0.11034 0.09497-0.16795 0.23918-0.16797 0.38477 0 0.27614 0.22386 0.5 0.5 0.5 0.12436-3.26e-4 0.24413-0.04698 0.33594-0.13086 0.82215-0.60306 1.8199-0.97852 2.918-0.97852 1.0981-2e-7 2.0958 0.37546 2.918 0.97852 0.09181 0.08388 0.21158 0.13053 0.33594 0.13086 0.27614 0 0.5-0.22386 0.5-0.5-1.6e-5 -0.14559-0.05762-0.2898-0.16797-0.38477-1.0027-0.7557-2.2334-1.2246-3.5859-1.2246z"/>
<path d="m8 6c-2.0731 0-3.9681 0.71726-5.4883 1.8965a0.5 0.5 0 0 0-0.10156 0.074219 0.5 0.5 0 0 0-0.15625 0.36133 0.5 0.5 0 0 0 0.5 0.5 0.5 0.5 0 0 0 0.32031-0.11523c1.3588-1.0688 3.0627-1.7168 4.9258-1.7168 1.8631-2e-7 3.567 0.64796 4.9258 1.7168a0.5 0.5 0 0 0 0.32031 0.11523 0.5 0.5 0 0 0 0.5-0.5 0.5 0.5 0 0 0-0.15625-0.36133 0.5 0.5 0 0 0-0.10156-0.074219c-1.5202-1.1792-3.4152-1.8965-5.4883-1.8965z" fill-rule="evenodd" style="paint-order:stroke fill markers"/>
<circle cx="8" cy="13" r="1" fill-rule="evenodd" style="paint-order:stroke fill markers"/>
</g>
<rect transform="rotate(45)" x="1.3137" y="-.5" width="20" height="1" rx=".5" ry=".5" fill="#dedede" stroke-linecap="round" stroke-linejoin="round" stroke-width=".48307" style="paint-order:markers stroke fill"/>
</svg>

Before

Width:  |  Height:  |  Size: 519 B

After

Width:  |  Height:  |  Size: 1.8 KiB

+131 -46
View File
@@ -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)
+2 -2
View File
@@ -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"
),
+2 -2
View File
@@ -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()
+2 -1
View File
@@ -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;
+2 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+8 -1
View File
@@ -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;
+96 -55
View File
@@ -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()