✨ feat(ui): add NotificationPopup and related styles for displaying system notifications

This commit is contained in:
sankalp committed 2025-06-19 15:25:56 +05:45
1 parent 7cb5cb05d6
commit e0691986c2
4 files changed
+613 -6

No files matched your search

+1
View File
@@ -9,6 +9,7 @@
@import url("./styles/launcher.css");
@import url("./styles/kanban.css");
@import url("./styles/calendar.css");
@import url("./styles/notification.css");
* {
all: unset;
+3 -6
View File
@@ -12,6 +12,7 @@ from modules.dock.main import Dock
from modules.switcher import ApplicationSwitcher
from modules.osd import OSD
from modules.launcher import Launcher
from modules.notification_popup import NotificationPopup
gi.require_version("GLib", "2.0")
@@ -48,6 +49,7 @@ if __name__ == "__main__":
switcher = ApplicationSwitcher()
osd = OSD()
launcher = Launcher()
nc = NotificationPopup()
# Set corners visibility based on config
corners_visible = config.get("corners_visible", True)
@@ -61,12 +63,7 @@ if __name__ == "__main__":
_ = color_css_file.connect("changed", lambda *_: set_css())
app = Application(
f"{APP_NAME}",
dock,
corners,
switcher,
osd,
launcher,
f"{APP_NAME}", dock, corners, switcher, osd, launcher, nc
) # Make sure corners is added to the app
def set_css():
+472
View File
@@ -0,0 +1,472 @@
import os
import utils.icons as icons
from fabric.notifications import (
Notification,
NotificationAction,
NotificationCloseReason,
)
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.revealer import Revealer
from fabric.widgets.circularprogressbar import CircularProgressBar
from gi.repository import Gdk, GdkPixbuf, GLib
from loguru import logger
from services import notification_service
from utils.custom_image import CustomImage
from utils.wayland import WaylandWindow as Window
from utils.animator import Animator
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, *_):
self.action.invoke()
self.action.parent.close("dismissed-by-user")
class NotificationWidget(Box):
def __init__(self, notification: Notification, timeout_ms=5000, **kwargs):
self.notification = notification
self.is_expanded = False
self.expanded_revealer = Revealer(
name="notification-expanded-revealer",
transition_type="slide-down",
transition_duration=250,
reveal_child=False,
child=self.create_expanded_content(notification),
)
super().__init__(
name="notification-box",
orientation="v",
h_align="fill",
h_expand=True,
children=[
self.create_content(notification),
self.expanded_revealer,
],
)
self.timeout_ms = timeout_ms
self._timeout_id = None
self.close_button_animator = Animator(
bezier_curve=(0.25, 0.1, 0.25, 1.0),
duration=0.3,
min_value=0.0,
max_value=1.0,
repeat=False,
)
self.close_button_animator.connect("notify::value", self.on_hover_animation_value_changed)
self.timeout_progress_animator = Animator(
bezier_curve=(1.0, 0.0, 1.0, 1.0),
duration=timeout_ms / 1000.0,
min_value=0.0,
max_value=1.0,
repeat=False,
)
self.timeout_progress_animator.connect("notify::value", self.on_timeout_progress_changed)
self.timeout_progress_animator.connect("finished", self.on_timeout_finished)
self.start_timeout()
def create_header(self, notification):
app_icon = (
Image(
name="notification-icon",
image_file=notification.app_icon[7:],
size=24,
)
if "file://" in notification.app_icon
else Image(
name="notification-icon",
icon_name="dialog-information-symbolic" or notification.app_icon,
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()],
)
def create_content(self, notification):
return Box(
name="notification-content",
spacing=8,
children=[
Box(
name="notification-image",
children=CustomImage(
pixbuf=notification.image_pixbuf.scale_simple(
48, 48, GdkPixbuf.InterpType.BILINEAR
)
if notification.image_pixbuf
else self.get_pixbuf(notification.app_icon, 48, 48)
),
),
Box(
name="notification-text",
orientation="v",
v_align="center",
children=[
Box(
name="notification-summary-box",
orientation="h",
children=[
Label(
name="notification-summary",
markup=notification.summary.replace("\n", " "),
h_align="start",
ellipsization="end",
),
Label(
name="notification-app-name",
markup=" | " + notification.app_name,
h_align="start",
ellipsization="end",
),
],
),
self.create_body_label(notification),
],
),
Box(h_expand=True),
Box(
orientation="v",
children=[
Box(
orientation="h",
spacing=4,
children=[
self.create_chevron_button(),
self.create_close_button(),
],
),
Box(v_expand=True),
],
),
],
)
def get_pixbuf(self, icon_path, width, height):
if icon_path.startswith("file://"):
icon_path = icon_path[7:]
if not os.path.exists(icon_path):
logger.warning(f"Icon path does not exist: {icon_path}")
return None
try:
pixbuf = GdkPixbuf.Pixbuf.new_from_file(icon_path)
return pixbuf.scale_simple(width, height, GdkPixbuf.InterpType.BILINEAR)
except Exception as e:
logger.error(f"Failed to load or scale icon: {e}")
return None
def create_action_buttons(self, notification):
if not notification.actions:
return Box()
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 create_close_button(self):
close_icon = Label(
name="notif-close-label",
markup=icons.cancel
)
self.close_progress_bar = CircularProgressBar(
name="notif-close-progress",
size=28,
line_width=2,
start_angle=0,
end_angle=360,
value=0.0,
min_value=0.0,
max_value=1.0,
child=close_icon,
)
close_button = Button(
name="notif-close-button",
child=self.close_progress_bar,
on_clicked=lambda *_: self.notification.close("dismissed-by-user"),
)
close_button.connect(
"enter-notify-event", lambda *_: self.hover_button(close_button)
)
close_button.connect(
"leave-notify-event", lambda *_: self.unhover_button(close_button)
)
return close_button
def create_body_label(self, notification):
if not notification.body:
return Box()
# Store reference to body label for expanding/collapsing
self.body_label = Label(
name="notification-body",
markup=notification.body.replace("\n", " "),
h_align="start",
ellipsization="end",
)
return self.body_label
def create_chevron_button(self):
# Only show chevron button if there's something to expand
# (either long text that gets ellipsized or action buttons)
has_expandable_content = (
(self.notification.body and len(self.notification.body) > 50) or # Long text
bool(self.notification.actions) # Has action buttons
)
if not has_expandable_content:
return Box() # Return empty box if nothing to expand
self.chevron_icon = Label(
name="notif-chevron-label",
markup=icons.chevron_down
)
chevron_button = Button(
name="notif-chevron-button",
child=self.chevron_icon,
on_clicked=self.toggle_expanded,
)
chevron_button.connect(
"enter-notify-event", lambda *_: self.hover_button(chevron_button)
)
chevron_button.connect(
"leave-notify-event", lambda *_: self.unhover_button(chevron_button)
)
return chevron_button
def create_expanded_content(self, notification):
# Only show action buttons in expanded content and the body text will be expanded in place
if notification.actions:
return Box(
name="notification-expanded-content",
orientation="v",
spacing=8,
children=[
self.create_action_buttons(notification)
],
)
else:
return Box()
def toggle_expanded(self, *_):
self.is_expanded = not self.is_expanded
self.expanded_revealer.set_reveal_child(self.is_expanded)
# Update chevron icon direction
if self.is_expanded:
self.chevron_icon.set_markup(icons.chevron_up)
else:
self.chevron_icon.set_markup(icons.chevron_down)
# Toggle body label properties
if hasattr(self, 'body_label') and self.body_label:
if self.is_expanded:
# Show full text without ellipsization and enable wrapping
self.body_label.set_markup(self.notification.body) # Keep original newlines
self.body_label.set_property("ellipsize", 0)
self.body_label.set_property("wrap", True)
self.body_label.set_property("wrap-mode", 2)
else:
# Show truncated text with ellipsization
self.body_label.set_markup(self.notification.body.replace("\n", " "))
self.body_label.set_property("ellipsize", 3)
self.body_label.set_property("wrap", False)
def on_hover_animation_value_changed(self, animator, value):
# This is for hover effects only - we don't update the progress bar here
# as the timeout progress takes priority
pass
def on_timeout_progress_changed(self, animator, value):
if hasattr(self, 'close_progress_bar'):
# Update progress bar value based on timeout progress (convert to float to avoid type issues)
self.close_progress_bar.value = float(animator.value)
def on_timeout_finished(self, animator):
# The notification will be closed by the existing timeout mechanism
pass
def start_timeout(self):
self.stop_timeout()
if hasattr(self, 'timeout_progress_animator'):
self.timeout_progress_animator.play()
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
if hasattr(self, 'timeout_progress_animator'):
self.timeout_progress_animator.stop()
def close_notification(self):
self.notification.close("expired")
self.stop_timeout()
return False
def pause_timeout(self):
self.stop_timeout()
def resume_timeout(self):
self.start_timeout()
def destroy(self):
self.stop_timeout()
if hasattr(self, 'close_button_animator'):
self.close_button_animator.stop()
if hasattr(self, 'timeout_progress_animator'):
self.timeout_progress_animator.stop()
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")
# On hover, show full progress (indicating ready to close)
if hasattr(self, 'close_progress_bar'):
self.close_progress_bar.value = 1.0
def unhover_button(self, button):
self.resume_timeout()
self.set_pointer_cursor(button, "arrow")
# On unhover, resume showing timeout progress
if hasattr(self, 'timeout_progress_animator') and hasattr(self, 'close_progress_bar'):
# Resume the timeout progress display
current_progress = float(self.timeout_progress_animator.value)
self.close_progress_bar.value = current_progress
class NotificationRevealer(Revealer):
def __init__(self, notification: Notification, **kwargs):
self.notif_box = NotificationWidget(notification)
self._notification = notification
super().__init__(
child=Box(
children=[self.notif_box],
),
transition_duration=250,
transition_type="slide-down",
)
self.connect(
"notify::child-revealed",
lambda *_: self.destroy() if not self.get_child_revealed() else None,
)
self._notification.connect("closed", self.on_resolved)
def on_resolved(
self,
notification: Notification,
reason: NotificationCloseReason,
):
self.set_reveal_child(False)
class NotificationPopup(Window):
def __init__(self):
self._server = notification_service
self.cache_notification_service = notification_service
self.notifications = Box(
v_expand=True,
h_expand=True,
style="margin: 1px 0px 1px 1px;",
orientation="v",
spacing=5,
)
self._server.connect("notification-added", self.on_new_notification)
super().__init__(
anchor="top right",
child=self.notifications,
layer="overlay",
all_visible=True,
visible=True,
exclusive=False,
)
def on_new_notification(self, fabric_notif, id):
notification: Notification = fabric_notif.get_notification_from_id(id)
self.cache_notification_service.cache_notification(notification)
# Only show popup if DND is not enabled
if not self.cache_notification_service.dont_disturb:
new_box = NotificationRevealer(fabric_notif.get_notification_from_id(id))
self.notifications.add(new_box)
new_box.set_reveal_child(True)
logger.info(f"[Notification] New notification from {notification.app_name}")
else:
logger.info(
f"[Notification] DND enabled, notification from {
notification.app_name
} not shown"
)
+137
View File
@@ -0,0 +1,137 @@
#notification-action-buttons button.start-action {
border-radius: 20px;
}
#notification-action-buttons button {
background-color: var(--surface);
padding: 8px;
border-radius: 20px;
transition: background-color 0.1s ease;
}
#notification-action-buttons button:hover {
background-color: var(--surface-bright);
}
#notification-action-buttons button:active {
background-color: var(--primary);
}
#notification-action-buttons button:active #button-label {
color: var(--shadow);
}
#notification-image image {
border-radius: 16px;
color: var(--on-surface);
}
#notification-summary,
#button-label {
font-weight: bold;
}
#notification-summary {
font-weight: bold;
color: var(--primary);
}
#notification-app-name {
color: var(--outline);
}
#action-button {
margin-top: 8px;
}
#notification-box {
border-radius: 20px;
padding: 16px;
background-color: var(--shadow);
margin: 4px;
min-width: 336px;
transition: background-color 0.1s ease;
}
#boxed-notification-revealer {
margin-top: -14px;
}
#notif-close-button {
border-radius: 16px;
padding: 4px;
transition: background-color 0.1s ease;
}
#notif-close-button:hover,
#notif-close-button:focus {
background-color: var(--surface-bright);
}
#notif-close-button:active {
background-color: var(--red-dim);
}
#notif-close-label {
color: var(--red-dim);
font-size: 20px;
}
#notif-close-button:active #notif-close-label {
color: var(--shadow);
}
#notif-close-progress {
color: var(--surface-bright);
border-color: var(--red-dim);
background-color: transparent;
transition: border-color 0.2s ease;
}
#notif-close-button:hover #notif-close-progress {
border-color: var(--red);
color: var(--red);
}
#notif-close-button:active #notif-close-progress {
border-color: var(--shadow);
color: var(--shadow);
}
/* Chevron button styling */
#notif-chevron-button {
border-radius: 16px;
padding: 8px;
transition: background-color 0.1s ease;
}
#notif-chevron-button:hover,
#notif-chevron-button:focus {
background-color: var(--surface-bright);
}
#notif-chevron-button:active {
background-color: var(--primary);
}
#notif-chevron-label {
color: var(--primary);
font-size: 20px;
}
#notif-chevron-button:active #notif-chevron-label {
color: var(--shadow);
}
/* Expanded content styling */
#notification-expanded-content {
padding-top: 8px;
margin-top: 4px;
}
/* Action buttons in expanded content */
#notification-expanded-content #notification-action-buttons {
margin-top: 30px;
}