diff --git a/Modules/Notification/Notification.qml b/Modules/Notification/Notification.qml index bdfac37b..000b4c2b 100644 --- a/Modules/Notification/Notification.qml +++ b/Modules/Notification/Notification.qml @@ -19,7 +19,6 @@ Variants { required property ShellScreen modelData - // Access the notification model from the service property ListModel notificationModel: NotificationService.activeList // Loader is active when there are notifications @@ -28,11 +27,10 @@ Variants { // Keep loader active briefly after last notification to allow animations to complete Timer { id: delayTimer - interval: Style.animationSlow + 200 // Animation duration + buffer + interval: Style.animationSlow + 200 repeat: false } - // Start delay timer when last notification is removed Connections { target: notificationModel function onCountChanged() { @@ -43,105 +41,80 @@ Variants { } sourceComponent: PanelWindow { + id: notifWindow screen: modelData WlrLayershell.namespace: "noctalia-notifications-" + (screen?.name || "unknown") - WlrLayershell.layer: (Settings.data.notifications && Settings.data.notifications.overlayLayer) ? WlrLayer.Overlay : WlrLayer.Top + WlrLayershell.layer: (Settings.data.notifications?.overlayLayer) ? WlrLayer.Overlay : WlrLayer.Top + WlrLayershell.exclusionMode: ExclusionMode.Ignore color: Color.transparent - readonly property string location: (Settings.data.notifications && Settings.data.notifications.location) ? Settings.data.notifications.location : "top_right" - readonly property bool isTop: (location === "top") || (location.length >= 3 && location.substring(0, 3) === "top") - readonly property bool isBottom: (location === "bottom") || (location.length >= 6 && location.substring(0, 6) === "bottom") - readonly property bool isLeft: location.indexOf("_left") >= 0 - readonly property bool isRight: location.indexOf("_right") >= 0 - readonly property bool isCentered: (location === "top" || location === "bottom") + // Parse location setting + readonly property string location: Settings.data.notifications?.location || "top_right" + readonly property bool isTop: location.startsWith("top") + readonly property bool isBottom: location.startsWith("bottom") + readonly property bool isLeft: location.endsWith("_left") + readonly property bool isRight: location.endsWith("_right") + readonly property bool isCentered: location === "top" || location === "bottom" - readonly property int baseWidth: Math.round(460 * Style.uiScaleRatio) + readonly property int notifWidth: Math.round(420 * Style.uiScaleRatio) - // Store connection for cleanup - property var animateConnection: null + // Calculate bar offset once + readonly property int barOffset: { + const barPos = Settings.data.bar.position + const isFloating = Settings.data.bar.floating + const floatMarginV = isFloating ? Settings.data.bar.marginVertical * Style.marginXL : 0 + const floatMarginH = isFloating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0 - // Anchor selection based on location (window edges) + // Check if bar is on same edge as notifications + if ((isTop && barPos === "top") || (isBottom && barPos === "bottom")) { + return Style.barHeight + floatMarginV + } + if ((isLeft && barPos === "left") || (isRight && barPos === "right")) { + return Style.barHeight + floatMarginH + } + return 0 + } + + // Anchoring anchors.top: isTop anchors.bottom: isBottom anchors.left: isLeft anchors.right: isRight - // Margins depending on bar position and chosen location - margins.top: { - if (!(anchors.top)) - return 0 - var base = Style.marginM - if (Settings.data.bar.position === "top") { - var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL : 0 - return (Style.barHeight) + base + floatExtraV - } - return base - } + // Margins for PanelWindow (not anchors.topMargin!) + margins.top: isTop ? Style.marginM + barOffset : 0 + margins.bottom: isBottom ? Style.marginM + barOffset : 0 + margins.left: isLeft ? -Style.marginXL * 1.65 + barOffset : 0 + margins.right: isRight ? -Style.marginXL * 1.65 + barOffset : 0 - margins.bottom: { - if (!(anchors.bottom)) - return 0 - var base = Style.marginM - if (Settings.data.bar.position === "bottom") { - var floatExtraV = Settings.data.bar.floating ? Settings.data.bar.marginVertical * Style.marginXL : 0 - return (Style.barHeight) + base + floatExtraV - } - return base - } + implicitWidth: notifWidth + implicitHeight: notificationStack.implicitHeight + Style.marginL - margins.left: { - if (!(anchors.left)) - return 0 - var base = Style.marginM - if (Settings.data.bar.position === "left") { - var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0 - return (Style.barHeight) + base + floatExtraH - } - return base - } + property var animateConnection: null - margins.right: { - if (!(anchors.right)) - return 0 - var base = Style.marginM - if (Settings.data.bar.position === "right") { - var floatExtraH = Settings.data.bar.floating ? Settings.data.bar.marginHorizontal * Style.marginXL : 0 - return (Style.barHeight) + base + floatExtraH - } - return base - } - - implicitWidth: baseWidth - implicitHeight: notificationStack.implicitHeight - WlrLayershell.exclusionMode: ExclusionMode.Ignore - - // Connect to animation signal from service Component.onCompleted: { animateConnection = NotificationService.animateAndRemove.connect(function (notificationId) { - // Find the delegate by notification ID var delegate = null - if (notificationStack && notificationStack.children && notificationStack.children.length > 0) { + if (notificationStack?.children) { for (var i = 0; i < notificationStack.children.length; i++) { var child = notificationStack.children[i] - if (child && child.notificationId === notificationId) { + if (child?.notificationId === notificationId) { delegate = child break } } } - if (delegate && delegate.animateOut) { + if (delegate?.animateOut) { delegate.animateOut() } else { - // Force removal without animation as fallback NotificationService.dismissActiveNotification(notificationId) } }) } - // Disconnect when destroyed to prevent memory leaks Component.onDestruction: { if (animateConnection) { NotificationService.animateAndRemove.disconnect(animateConnection) @@ -149,20 +122,20 @@ Variants { } } - // Main notification container ColumnLayout { id: notificationStack - // Anchor the stack inside the window based on chosen location - anchors.top: parent.isTop ? parent.top : undefined - anchors.bottom: parent.isBottom ? parent.bottom : undefined - anchors.left: parent.isLeft ? parent.left : undefined - anchors.right: parent.isRight ? parent.right : undefined - anchors.horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined - spacing: -Style.marginS - width: baseWidth - visible: true - // Animate when notifications are added/removed + anchors { + top: parent.isTop ? parent.top : undefined + bottom: parent.isBottom ? parent.bottom : undefined + left: parent.isLeft ? parent.left : undefined + right: parent.isRight ? parent.right : undefined + horizontalCenter: parent.isCentered ? parent.horizontalCenter : undefined + } + + spacing: -Style.marginS + width: notifWidth + Behavior on implicitHeight { enabled: !Settings.data.general.animationDisabled SpringAnimation { @@ -173,73 +146,74 @@ Variants { } } - // Multiple notifications display Repeater { model: notificationModel + delegate: Item { id: card - // Store the notification ID and data for reference property string notificationId: model.id property var notificationData: model + property int hoverCount: 0 + property bool isRemoving: false - Layout.preferredWidth: baseWidth - Layout.preferredHeight: notificationLayout.implicitHeight + Style.marginL * 2 + readonly property int animationDelay: index * 100 + readonly property int slideDistance: 300 + + Layout.preferredWidth: notifWidth + Layout.preferredHeight: notificationContent.implicitHeight + Style.marginL * 2 Layout.maximumHeight: Layout.preferredHeight - // Background rectangle (source for shadow effect) + // Animation properties + property real scaleValue: 0.8 + property real opacityValue: 0.0 + property real slideOffset: 0 + + scale: scaleValue + opacity: opacityValue + y: slideOffset + + readonly property real slideInOffset: notifWindow.isTop ? -slideDistance : slideDistance + readonly property real slideOutOffset: slideInOffset + + // Background with border Rectangle { id: cardBackground anchors.fill: parent - anchors.leftMargin: Style.marginM + anchors.margins: Style.marginM anchors.rightMargin: Style.marginXL - anchors.topMargin: Style.marginM - anchors.bottomMargin: Style.marginM radius: Style.radiusL border.color: Qt.alpha(Color.mOutline, Settings.data.notifications.backgroundOpacity || 1.0) border.width: Style.borderS color: Qt.alpha(Color.mSurface, Settings.data.notifications.backgroundOpacity || 1.0) - // Optimized progress bar container (on top of background) + // Progress bar Rectangle { - id: progressBarContainer anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right height: 2 color: Color.transparent - visible: true - // Pre-calculate available width for the progress bar - readonly property real availableWidth: parent.width - (2 * cardBackground.radius) + readonly property real availableWidth: parent.width - (2 * parent.radius) - // Actual progress bar - centered and symmetric Rectangle { id: progressBar height: parent.height - - // Center the bar and make it shrink symmetrically - x: cardBackground.radius + (parent.availableWidth * (1 - model.progress)) / 2 + x: parent.parent.radius + (parent.availableWidth * (1 - model.progress)) / 2 width: parent.availableWidth * model.progress color: { - var baseColor - if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) - baseColor = Color.mError - else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) - baseColor = Color.mOnSurface - else - baseColor = Color.mPrimary + var baseColor = model.urgency === 2 ? Color.mError : model.urgency === 0 ? Color.mOnSurface : Color.mPrimary return Qt.alpha(baseColor, Settings.data.notifications.backgroundOpacity || 1.0) } antialiasing: true - // Smooth progress animation Behavior on width { enabled: !card.isRemoving NumberAnimation { - duration: 100 // Quick but smooth + duration: 100 easing.type: Easing.Linear } } @@ -255,23 +229,12 @@ Variants { } } - // MultiEffect applied to background only NDropShadows { anchors.fill: cardBackground source: cardBackground } - // Animation properties - property real scaleValue: 0.8 - property real opacityValue: 0.0 - property real slideOffset: 0 - property bool isRemoving: false - - // Staggered animation delay based on index - readonly property int animationDelay: index * 100 - - property int hoverCount: 0 - + // Hover handling onHoverCountChanged: { if (hoverCount > 0) { resumeTimer.stop() @@ -306,69 +269,35 @@ Variants { } } - // Scale, fade, and slide animation - scale: scaleValue - opacity: opacityValue - - // Slide animation based on notification position (vertical only for stacking) - y: slideOffset - - // Calculate slide direction based on notification location - readonly property real slideDistance: 300 - readonly property real slideInOffset: { - // For vertical stacking, always slide from top - if (parent.isTop) - return -slideDistance - if (parent.isBottom) - return slideDistance - return 0 - } - readonly property real slideOutOffset: { - // Slide out in the same direction as slide in - if (parent.isTop) - return -slideDistance - if (parent.isBottom) - return slideDistance - return 0 - } - - // Animate in when the item is created + // Animation setup Component.onCompleted: { if (Settings.data.general.animationDisabled) { - // No animation - set to final state immediately slideOffset = 0 scaleValue = 1.0 opacityValue = 1.0 } else { - // Start from slide position slideOffset = slideInOffset scaleValue = 0.8 opacityValue = 0.0 - - // Delay animation based on index for staggered effect animInDelayTimer.interval = animationDelay animInDelayTimer.start() } } - // Timer for staggered animation start Timer { id: animInDelayTimer interval: 0 repeat: false onTriggered: { - // Animate to final position slideOffset = 0 scaleValue = 1.0 opacityValue = 1.0 } } - // Animate out when being removed function animateOut() { if (isRemoving) return - // Prevent multiple animations isRemoving = true if (!Settings.data.general.animationDisabled) { slideOffset = slideOutOffset @@ -377,7 +306,6 @@ Variants { } } - // Timer for delayed removal after animation Timer { id: removalTimer interval: Style.animationSlow @@ -387,14 +315,12 @@ Variants { } } - // Check if this notification is being removed onIsRemovingChanged: { if (isRemoving) { removalTimer.start() } } - // Animation behaviors with spring physics Behavior on scale { enabled: !Settings.data.general.animationDisabled SpringAnimation { @@ -423,24 +349,20 @@ Variants { } } + // Content ColumnLayout { - id: notificationLayout + id: notificationContent anchors.fill: parent - anchors.leftMargin: Style.marginM + anchors.margins: Style.marginM anchors.rightMargin: Style.marginXL - anchors.topMargin: Style.marginM - anchors.bottomMargin: Style.marginM spacing: Style.marginM - // Main content section RowLayout { Layout.fillWidth: true spacing: Style.marginL Layout.margins: Style.marginM ColumnLayout { - // For real-time notification always show the original image - // as the cached version is most likely still processing. NImageCircled { Layout.preferredWidth: Math.round(40 * Style.uiScaleRatio) Layout.preferredHeight: Math.round(40 * Style.uiScaleRatio) @@ -457,12 +379,11 @@ Variants { } } - // Text content ColumnLayout { Layout.fillWidth: true spacing: Style.marginS - // Header section with app name and timestamp + // Header with urgency indicator RowLayout { Layout.fillWidth: true spacing: Style.marginS @@ -470,16 +391,9 @@ Variants { Rectangle { Layout.preferredWidth: 6 Layout.preferredHeight: 6 - radius: Style.radiusXS - color: { - if (model.urgency === NotificationUrgency.Critical || model.urgency === 2) - return Color.mError - else if (model.urgency === NotificationUrgency.Low || model.urgency === 0) - return Color.mOnSurface - else - return Color.mPrimary - } Layout.alignment: Qt.AlignVCenter + radius: Style.radiusXS + color: model.urgency === 2 ? Color.mError : model.urgency === 0 ? Color.mOnSurface : Color.mPrimary } NText { @@ -518,19 +432,14 @@ Variants { visible: text.length > 0 } - // Notification actions + // Actions Flow { Layout.fillWidth: true spacing: Style.marginS Layout.topMargin: Style.marginM - flow: Flow.LeftToRight - layoutDirection: Qt.LeftToRight - // Store the notification ID for access in button delegates property string parentNotificationId: notificationId - - // Parse actions from JSON string property var parsedActions: { try { return model.actionsJson ? JSON.parse(model.actionsJson) : [] @@ -551,7 +460,6 @@ Variants { text: { var actionText = actionData.text || "Open" - // If text contains comma, take the part after the comma (the display text) if (actionText.includes(",")) { return actionText.split(",")[1] || actionText } @@ -573,7 +481,7 @@ Variants { } } - // Close button positioned absolutely + // Close button NIconButton { icon: "close" tooltipText: I18n.tr("tooltips.close")