config/modules/home/services/quickshell/qml/Widgets/Notifications/Notification.qml

368 lines
15 KiB
QML
Raw Normal View History

2025-07-22 20:21:21 -04:00
// System notification manager
import QtQuick
import QtQuick.Controls
import Quickshell.Services.Notifications
import "root:/Data" as Data
import "root:/Core" as Core
Item {
id: root
required property var shell
required property var notificationServer
// Dynamic height based on visible notifications
property int calculatedHeight: Math.min(notifications.length, maxNotifications) * 100 + 100 // Add 100px for bottom margin
// Simple array to store notifications with tracking
property var notifications: []
property int maxNotifications: 5
property var animatedNotificationIds: ({}) // Track which notifications have been animated
// Handle new notifications
Connections {
target: notificationServer
function onNotification(notification) {
if (!notification || !notification.id) return
// Filter empty notifications
if (!notification.appName && !notification.summary && !notification.body) {
return
}
// Filter ignored applications (case-insensitive) - same logic as NotificationService
var shouldIgnore = false
if (notification.appName && Data.Settings.ignoredApps && Data.Settings.ignoredApps.length > 0) {
for (var i = 0; i < Data.Settings.ignoredApps.length; i++) {
if (Data.Settings.ignoredApps[i].toLowerCase() === notification.appName.toLowerCase()) {
shouldIgnore = true
break
}
}
}
if (shouldIgnore) {
return // Don't display ignored notifications
}
// Create simple notification object
let newNotification = {
"id": notification.id,
"appName": notification.appName || "App",
"summary": notification.summary || "",
"body": notification.body || "",
"timestamp": Date.now(),
"shouldSlideOut": false,
"icon": notification.icon || notification.image || notification.appIcon || "",
"rawNotification": notification // Keep reference to original
}
// Add to beginning
notifications.unshift(newNotification)
// Trigger model update first to let new notification animate
notificationsChanged()
// Delay trimming to let new notification animate
if (notifications.length > maxNotifications) {
trimTimer.restart()
}
}
}
// Timer to delay trimming notifications (let new ones animate first)
Timer {
id: trimTimer
interval: 500 // Wait 500ms before trimming
running: false
repeat: false
onTriggered: {
if (notifications.length > maxNotifications) {
notifications = notifications.slice(0, maxNotifications)
notificationsChanged()
}
}
}
// Global timer to check for expired notifications
Timer {
id: cleanupTimer
interval: Math.min(500, Data.Settings.displayTime / 10) // Check every 500ms or 1/10th of display time, whichever is shorter
running: true
repeat: true
onTriggered: {
let currentTime = Date.now()
let hasExpiredNotifications = false
// Mark notifications older than displayTime setting for slide-out
for (let i = 0; i < notifications.length; i++) {
let notification = notifications[i]
let age = currentTime - notification.timestamp
if (age >= Data.Settings.displayTime && !notification.shouldSlideOut) {
notification.shouldSlideOut = true
hasExpiredNotifications = true
}
}
// Trigger update if any notifications were marked for slide-out
if (hasExpiredNotifications) {
notificationsChanged()
}
}
}
function removeNotification(notificationId) {
let initialLength = notifications.length
notifications = notifications.filter(function(n) { return n.id !== notificationId })
if (notifications.length !== initialLength) {
// Remove from animated tracking
delete animatedNotificationIds[notificationId]
notificationsChanged()
}
}
// Simple Column with Repeater
Column {
anchors.right: parent.right
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.leftMargin: 40 // Create space on left for top-left corner
anchors.rightMargin: Data.Settings.borderWidth + 20 // Border width plus corner space
anchors.bottomMargin: 100 // Create more space at bottom for bottom corner
spacing: 0
Repeater {
model: notifications.length // Show all notifications, not just maxNotifications
delegate: Rectangle {
id: notificationRect
property var notification: index < notifications.length ? notifications[index] : null
width: 400
height: 100
color: Data.ThemeManager.bgColor
// Only bottom visible notification gets bottom-left radius
radius: 0
bottomLeftRadius: index === Math.min(notifications.length, maxNotifications) - 1 && index < maxNotifications ? 15 : 0
// Only show if within maxNotifications limit
visible: index < maxNotifications
// Animation state
property bool hasSlideIn: false
// Check for expiration and start slide-out if needed
onNotificationChanged: {
if (notification && notification.shouldSlideOut && !slideOutAnimation.running) {
slideOutAnimation.start()
}
}
// Start off-screen for new notifications
Component.onCompleted: {
if (notification) {
// Check if notification should slide out immediately
if (notification.shouldSlideOut) {
slideOutAnimation.start()
return
}
// Check if this notification is truly new (recently added)
let notificationAge = Date.now() - notification.timestamp
let shouldAnimate = !animatedNotificationIds[notification.id] && notificationAge < 1000 // Only animate if less than 1 second old
if (shouldAnimate) {
x = 420
opacity = 0
hasSlideIn = false
slideInAnimation.start()
// Mark as animated
animatedNotificationIds[notification.id] = true
} else {
x = 0
opacity = 1
hasSlideIn = true
// Mark as animated if not already
animatedNotificationIds[notification.id] = true
}
}
}
// Slide-in animation
ParallelAnimation {
id: slideInAnimation
NumberAnimation {
target: notificationRect
property: "x"
to: 0
duration: 300
easing.type: Easing.OutCubic
}
NumberAnimation {
target: notificationRect
property: "opacity"
to: 1
duration: 300
easing.type: Easing.OutCubic
}
onFinished: {
hasSlideIn = true
}
}
// Slide-out animation
ParallelAnimation {
id: slideOutAnimation
NumberAnimation {
target: notificationRect
property: "x"
to: 420
duration: 250
easing.type: Easing.InCubic
}
NumberAnimation {
target: notificationRect
property: "opacity"
to: 0
duration: 250
easing.type: Easing.InCubic
}
onFinished: {
if (notification) {
removeNotification(notification.id)
}
}
}
// Click to dismiss
MouseArea {
anchors.fill: parent
onClicked: slideOutAnimation.start()
}
// Content
Row {
anchors.fill: parent
anchors.margins: 15
spacing: 12
// App icon
Rectangle {
width: 32
height: 32
radius: 16
color: Qt.rgba(255, 255, 255, 0.1)
anchors.verticalCenter: parent.verticalCenter
// Application icon (if available)
Image {
id: appIcon
source: {
if (!notification || !notification.icon) return ""
let icon = notification.icon
// Apply same processing as tray system
if (icon.includes("?path=")) {
const [name, path] = icon.split("?path=");
const fileName = name.substring(name.lastIndexOf("/") + 1);
return `file://${path}/${fileName}`;
}
// Handle file paths properly
if (icon.startsWith('/')) {
return "file://" + icon
}
return icon
}
anchors.fill: parent
anchors.margins: 2
fillMode: Image.PreserveAspectFit
smooth: true
visible: source.toString() !== ""
onStatusChanged: {
// Icon status handling can be added here if needed
}
}
// Fallback text (first letter of app name)
Text {
anchors.centerIn: parent
text: notification && notification.appName ? notification.appName.charAt(0).toUpperCase() : "!"
color: Data.ThemeManager.accentColor
font.family: "Roboto"
font.pixelSize: 16
font.bold: true
visible: !appIcon.visible
}
}
// Content
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - 60
spacing: 4
Text {
text: notification ? notification.appName : ""
color: Data.ThemeManager.accentColor
font.family: "Roboto"
font.bold: true
font.pixelSize: 15
width: Math.min(parent.width, 250) // Earlier line break
elide: Text.ElideRight
}
Text {
text: notification ? notification.summary : ""
color: Data.ThemeManager.fgColor
font.family: "Roboto"
font.pixelSize: 14
width: Math.min(parent.width, 250) // Earlier line break
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text.length > 0
}
Text {
text: notification ? notification.body : ""
color: Qt.lighter(Data.ThemeManager.fgColor, 1.3)
font.family: "Roboto"
font.pixelSize: 13
width: Math.min(parent.width, 250) // Earlier line break
wrapMode: Text.Wrap
maximumLineCount: 2
elide: Text.ElideRight
visible: text.length > 0
}
}
}
// Top corner for first notification
Core.Corners {
position: "bottomright"
size: 1.3
fillColor: Data.ThemeManager.bgColor
offsetX: -361
offsetY: -13
visible: index === 0 && index < maxNotifications
}
// Bottom corner for last visible notification
Core.Corners {
position: "bottomright"
size: 1.3
fillColor: Data.ThemeManager.bgColor
offsetX: 39
offsetY: 78
visible: index === Math.min(notifications.length, maxNotifications) - 1 && index < maxNotifications
}
}
}
}
}