From 70ca3fef77ee8bdc6e3ac28589a6fa08c024cc69 Mon Sep 17 00:00:00 2001 From: AlexanderCurl Date: Sat, 18 Apr 2026 17:46:06 +0100 Subject: Replaced file structures for themes in the correct format --- .../Notifications/Popup/NotificationPopup.qml | 1164 ++++++++++++++++++++ .../Popup/NotificationPopupManager.qml | 239 ++++ 2 files changed, 1403 insertions(+) create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopup.qml create mode 100644 raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml (limited to 'raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup') diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopup.qml new file mode 100644 index 0000000..8c9f7f4 --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -0,0 +1,1164 @@ +import QtQuick +import QtQuick.Controls +import Quickshell +import Quickshell.Wayland +import Quickshell.Services.Notifications +import qs.Common +import qs.Services +import qs.Widgets + +PanelWindow { + id: win + + WindowBlur { + targetWindow: win + blurX: content.x + content.cardInset + swipeTx.x + tx.x + blurY: content.y + content.cardInset + swipeTx.y + tx.y + blurWidth: !win._finalized ? Math.max(0, content.width - content.cardInset * 2) : 0 + blurHeight: !win._finalized ? Math.max(0, content.height - content.cardInset * 2) : 0 + blurRadius: Theme.cornerRadius + } + + WlrLayershell.namespace: "dms:notification-popup" + + required property var notificationData + required property string notificationId + readonly property bool hasValidData: notificationData && notificationData.notification + readonly property alias hovered: cardHoverHandler.hovered + property int screenY: 0 + property bool exiting: false + property bool _isDestroying: false + property bool _finalized: false + property real _lastReportedAlignedHeight: -1 + property real _storedTopMargin: 0 + property real _storedBottomMargin: 0 + readonly property string clearText: I18n.tr("Dismiss") + property bool descriptionExpanded: false + readonly property bool hasExpandableBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0 + onDescriptionExpandedChanged: { + popupHeightChanged(); + } + onImplicitHeightChanged: { + const aligned = Theme.px(implicitHeight, dpr); + if (Math.abs(aligned - _lastReportedAlignedHeight) < 0.5) + return; + _lastReportedAlignedHeight = aligned; + popupHeightChanged(); + } + + readonly property bool compactMode: SettingsData.notificationCompactMode + readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding + readonly property real popupIconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal + readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS + readonly property real contentBottomClearance: 8 + readonly property real actionButtonHeight: compactMode ? 20 : 24 + readonly property real collapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) + contentBottomClearance + readonly property real privacyCollapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2) + contentBottomClearance + readonly property real basePopupHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing + readonly property real basePopupHeightPrivacy: cardPadding * 2 + privacyCollapsedContentHeight + actionButtonHeight + contentSpacing + + signal entered + signal exitStarted + signal exitFinished + signal popupHeightChanged + + function startExit() { + if (exiting || _isDestroying) { + return; + } + exiting = true; + exitStarted(); + exitAnim.restart(); + exitWatchdog.restart(); + if (NotificationService.removeFromVisibleNotifications) + NotificationService.removeFromVisibleNotifications(win.notificationData); + } + + function forceExit() { + if (_isDestroying) { + return; + } + _isDestroying = true; + exiting = true; + visible = false; + exitWatchdog.stop(); + finalizeExit("forced"); + } + + function finalizeExit(reason) { + if (_finalized) { + return; + } + + _finalized = true; + _isDestroying = true; + exitWatchdog.stop(); + wrapperConn.enabled = false; + wrapperConn.target = null; + win.exitFinished(); + } + + visible: !_finalized + WlrLayershell.layer: { + const envLayer = Quickshell.env("DMS_NOTIFICATION_LAYER"); + if (envLayer) { + switch (envLayer) { + case "bottom": + return WlrLayershell.Bottom; + case "overlay": + return WlrLayershell.Overlay; + case "background": + return WlrLayershell.Background; + case "top": + return WlrLayershell.Top; + } + } + + if (!notificationData) + return WlrLayershell.Top; + + SettingsData.notificationOverlayEnabled; + + const shouldUseOverlay = (SettingsData.notificationOverlayEnabled) || (notificationData.urgency === NotificationUrgency.Critical); + + return shouldUseOverlay ? WlrLayershell.Overlay : WlrLayershell.Top; + } + WlrLayershell.exclusiveZone: -1 + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + color: "transparent" + readonly property real contentImplicitWidth: screen ? Math.min(400, Math.max(320, screen.width * 0.23)) : 380 + readonly property real contentImplicitHeight: { + if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) + return basePopupHeightPrivacy; + if (!descriptionExpanded) + return basePopupHeight; + const bodyTextHeight = bodyText.contentHeight || 0; + const collapsedBodyHeight = Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2); + if (bodyTextHeight > collapsedBodyHeight + 2) + return basePopupHeight + bodyTextHeight - collapsedBodyHeight; + return basePopupHeight; + } + implicitWidth: contentImplicitWidth + (windowShadowPad * 2) + implicitHeight: contentImplicitHeight + (windowShadowPad * 2) + + Behavior on implicitHeight { + enabled: !exiting && !_isDestroying + NumberAnimation { + id: implicitHeightAnim + duration: descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.emphasized + } + } + + onHasValidDataChanged: { + if (!hasValidData && !exiting && !_isDestroying) { + forceExit(); + } + } + Component.onCompleted: { + _lastReportedAlignedHeight = Theme.px(implicitHeight, dpr); + _storedTopMargin = getTopMargin(); + _storedBottomMargin = getBottomMargin(); + if (SettingsData.notificationPopupPrivacyMode) + descriptionExpanded = false; + if (hasValidData) { + Qt.callLater(() => enterX.restart()); + } else { + forceExit(); + } + } + onNotificationDataChanged: { + if (!_isDestroying) { + if (SettingsData.notificationPopupPrivacyMode) + descriptionExpanded = false; + wrapperConn.target = win.notificationData || null; + notificationConn.target = (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null; + } + } + onEntered: { + if (!_isDestroying) { + enterDelay.start(); + } + } + Component.onDestruction: { + _isDestroying = true; + exitWatchdog.stop(); + if (notificationData && notificationData.timer) { + notificationData.timer.stop(); + } + } + + property bool isTopCenter: SettingsData.notificationPopupPosition === -1 + property bool isBottomCenter: SettingsData.notificationPopupPosition === SettingsData.Position.BottomCenter + property bool isCenterPosition: isTopCenter || isBottomCenter + readonly property real maxPopupShadowBlurPx: Math.max((Theme.elevationLevel3 && Theme.elevationLevel3.blurPx !== undefined) ? Theme.elevationLevel3.blurPx : 12, (Theme.elevationLevel4 && Theme.elevationLevel4.blurPx !== undefined) ? Theme.elevationLevel4.blurPx : 16) + readonly property real maxPopupShadowOffsetXPx: Math.max(Math.abs(Theme.elevationOffsetX(Theme.elevationLevel3)), Math.abs(Theme.elevationOffsetX(Theme.elevationLevel4))) + readonly property real maxPopupShadowOffsetYPx: Math.max(Math.abs(Theme.elevationOffsetY(Theme.elevationLevel3, 6)), Math.abs(Theme.elevationOffsetY(Theme.elevationLevel4, 8))) + readonly property real windowShadowPad: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled ? Theme.snap(Math.max(16, maxPopupShadowBlurPx + Math.max(maxPopupShadowOffsetXPx, maxPopupShadowOffsetYPx) + 8), dpr) : 0 + + anchors.top: true + anchors.left: true + anchors.bottom: false + anchors.right: false + + mask: contentInputMask + + Region { + id: contentInputMask + item: contentMaskRect + } + + Item { + id: contentMaskRect + visible: false + x: content.x + y: content.y + width: alignedWidth + height: alignedHeight + } + + margins { + top: getWindowTopMargin() + bottom: 0 + left: getWindowLeftMargin() + right: 0 + } + + function getBarInfo() { + if (!screen) + return { + topBar: 0, + bottomBar: 0, + leftBar: 0, + rightBar: 0 + }; + return SettingsData.getAdjacentBarInfo(screen, SettingsData.notificationPopupPosition, { + id: "notification-popup", + screenPreferences: [screen.name], + autoHide: false + }); + } + + function getTopMargin() { + const popupPos = SettingsData.notificationPopupPosition; + const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left; + if (!isTop) + return 0; + + const barInfo = getBarInfo(); + const base = barInfo.topBar > 0 ? barInfo.topBar : Theme.popupDistance; + return base + screenY; + } + + function getBottomMargin() { + const popupPos = SettingsData.notificationPopupPosition; + const isBottom = isBottomCenter || popupPos === SettingsData.Position.Bottom || popupPos === SettingsData.Position.Right; + if (!isBottom) + return 0; + + const barInfo = getBarInfo(); + const base = barInfo.bottomBar > 0 ? barInfo.bottomBar : Theme.popupDistance; + return base + screenY; + } + + function getLeftMargin() { + if (isCenterPosition) + return screen ? (screen.width - alignedWidth) / 2 : 0; + + const popupPos = SettingsData.notificationPopupPosition; + const isLeft = popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom; + if (!isLeft) + return 0; + + const barInfo = getBarInfo(); + return barInfo.leftBar > 0 ? barInfo.leftBar : Theme.popupDistance; + } + + function getRightMargin() { + if (isCenterPosition) + return 0; + + const popupPos = SettingsData.notificationPopupPosition; + const isRight = popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Right; + if (!isRight) + return 0; + + const barInfo = getBarInfo(); + return barInfo.rightBar > 0 ? barInfo.rightBar : Theme.popupDistance; + } + + function getContentX() { + if (!screen) + return 0; + + const popupPos = SettingsData.notificationPopupPosition; + const barLeft = getLeftMargin(); + const barRight = getRightMargin(); + + if (isCenterPosition) + return Theme.snap((screen.width - alignedWidth) / 2, dpr); + if (popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom) + return Theme.snap(barLeft, dpr); + return Theme.snap(screen.width - alignedWidth - barRight, dpr); + } + + function getContentY() { + if (!screen) + return 0; + + const popupPos = SettingsData.notificationPopupPosition; + const barTop = getTopMargin(); + const barBottom = getBottomMargin(); + const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left; + if (isTop) + return Theme.snap(barTop, dpr); + return Theme.snap(screen.height - alignedHeight - barBottom, dpr); + } + + function getWindowLeftMargin() { + if (!screen) + return 0; + return Theme.snap(getContentX() - windowShadowPad, dpr); + } + + function getWindowTopMargin() { + if (!screen) + return 0; + return Theme.snap(getContentY() - windowShadowPad, dpr); + } + + readonly property bool screenValid: win.screen && !_isDestroying + readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1 + readonly property real alignedWidth: Theme.px(Math.max(0, implicitWidth - (windowShadowPad * 2)), dpr) + readonly property real alignedHeight: Theme.px(Math.max(0, implicitHeight - (windowShadowPad * 2)), dpr) + + Item { + id: content + + x: Theme.snap(windowShadowPad, dpr) + y: Theme.snap(windowShadowPad, dpr) + width: alignedWidth + height: alignedHeight + visible: !win._finalized + scale: cardHoverHandler.hovered ? 1.01 : 1.0 + transformOrigin: Item.Center + + Behavior on scale { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + property real swipeOffset: 0 + readonly property real dismissThreshold: isCenterPosition ? height * 0.4 : width * 0.35 + readonly property real swipeFadeStartRatio: 0.75 + readonly property real swipeTravelDistance: isCenterPosition ? height : width + readonly property real swipeFadeStartOffset: swipeTravelDistance * swipeFadeStartRatio + readonly property real swipeFadeDistance: Math.max(1, swipeTravelDistance - swipeFadeStartOffset) + readonly property bool swipeActive: swipeDragHandler.active + property bool swipeDismissing: false + + readonly property bool shadowsAllowed: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled + readonly property var elevLevel: cardHoverHandler.hovered ? Theme.elevationLevel4 : Theme.elevationLevel3 + readonly property real cardInset: Theme.snap(4, win.dpr) + readonly property real shadowRenderPadding: shadowsAllowed ? Theme.snap(Math.max(16, shadowBlurPx + Math.max(Math.abs(shadowOffsetX), Math.abs(shadowOffsetY)) + 8), win.dpr) : 0 + property real shadowBlurPx: shadowsAllowed ? (elevLevel && elevLevel.blurPx !== undefined ? elevLevel.blurPx : 12) : 0 + property real shadowOffsetX: shadowsAllowed ? Theme.elevationOffsetX(elevLevel) : 0 + property real shadowOffsetY: shadowsAllowed ? Theme.elevationOffsetY(elevLevel, 6) : 0 + + Behavior on shadowBlurPx { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Behavior on shadowOffsetX { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Behavior on shadowOffsetY { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + ElevationShadow { + id: bgShadowLayer + anchors.fill: parent + anchors.margins: -content.shadowRenderPadding + level: content.elevLevel + fallbackOffset: 6 + shadowBlurPx: content.shadowBlurPx + shadowOffsetX: content.shadowOffsetX + shadowOffsetY: content.shadowOffsetY + shadowColor: content.shadowsAllowed && content.elevLevel ? Theme.elevationShadowColor(content.elevLevel) : "transparent" + shadowEnabled: !win._isDestroying && win.screenValid && content.shadowsAllowed + layer.textureSize: Qt.size(Math.round(width * win.dpr), Math.round(height * win.dpr)) + layer.textureMirroring: ShaderEffectSource.MirrorVertically + + sourceRect.anchors.fill: undefined + sourceRect.x: content.shadowRenderPadding + content.cardInset + sourceRect.y: content.shadowRenderPadding + content.cardInset + sourceRect.width: Math.max(0, content.width - (content.cardInset * 2)) + sourceRect.height: Math.max(0, content.height - (content.cardInset * 2)) + sourceRect.radius: Theme.cornerRadius + sourceRect.color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + sourceRect.border.color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08) + sourceRect.border.width: notificationData && notificationData.urgency === NotificationUrgency.Critical ? 2 : 0 + + Rectangle { + x: bgShadowLayer.sourceRect.x + y: bgShadowLayer.sourceRect.y + width: bgShadowLayer.sourceRect.width + height: bgShadowLayer.sourceRect.height + radius: bgShadowLayer.sourceRect.radius + visible: notificationData && notificationData.urgency === NotificationUrgency.Critical + opacity: 1 + clip: true + + gradient: Gradient { + orientation: Gradient.Horizontal + + GradientStop { + position: 0 + color: Theme.primary + } + + GradientStop { + position: 0.02 + color: Theme.primary + } + + GradientStop { + position: 0.021 + color: "transparent" + } + } + } + } + + Rectangle { + anchors.fill: parent + anchors.margins: content.cardInset + radius: Theme.cornerRadius + color: "transparent" + border.color: BlurService.borderColor + border.width: BlurService.borderWidth + z: 100 + } + + Item { + id: backgroundContainer + anchors.fill: parent + anchors.margins: content.cardInset + clip: true + + HoverHandler { + id: cardHoverHandler + } + + Connections { + target: cardHoverHandler + function onHoveredChanged() { + if (!notificationData || win.exiting || win._isDestroying) + return; + if (cardHoverHandler.hovered) { + if (notificationData.timer) + notificationData.timer.stop(); + } else if (notificationData.popup && notificationData.timer) { + notificationData.timer.restart(); + } + } + } + + LayoutMirroring.enabled: I18n.isRtl + LayoutMirroring.childrenInherit: true + + Item { + id: notificationContent + + readonly property real expandedTextHeight: bodyText.contentHeight || 0 + readonly property real collapsedBodyHeight: Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2) + readonly property real effectiveCollapsedHeight: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? win.privacyCollapsedContentHeight : win.collapsedContentHeight + readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > collapsedBodyHeight + 2) ? (expandedTextHeight - collapsedBodyHeight) : 0 + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: cardPadding + anchors.leftMargin: Theme.spacingL + anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin + height: effectiveCollapsedHeight + extraHeight + clip: SettingsData.notificationPopupPrivacyMode && !descriptionExpanded + + DankCircularImage { + id: iconContainer + + readonly property string rawImage: notificationData?.image || "" + readonly property string iconFromImage: { + if (rawImage.startsWith("image://icon/")) + return rawImage.substring(13); + return ""; + } + readonly property bool imageHasSpecialPrefix: { + const icon = iconFromImage; + return icon.startsWith("material:") || icon.startsWith("svg:") || icon.startsWith("unicode:") || icon.startsWith("image:"); + } + readonly property bool hasNotificationImage: rawImage !== "" && !rawImage.startsWith("image://icon/") + readonly property bool needsImagePersist: hasNotificationImage && rawImage.startsWith("image://qsimage/") && !notificationData.persistedImagePath + + width: popupIconSize + height: popupIconSize + anchors.left: parent.left + anchors.top: parent.top + anchors.topMargin: { + if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) { + const headerSummary = Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2; + return Math.max(0, headerSummary / 2 - popupIconSize / 2); + } + if (descriptionExpanded) + return Math.max(0, Theme.fontSizeSmall * 1.2 + (Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) / 2 - popupIconSize / 2); + return Math.max(0, Theme.fontSizeSmall * 1.2 + (textContainer.height - Theme.fontSizeSmall * 1.2) / 2 - popupIconSize / 2); + } + + imageSource: { + if (!notificationData) + return ""; + if (hasNotificationImage) + return notificationData.cleanImage || ""; + if (imageHasSpecialPrefix) + return ""; + const appIcon = notificationData.appIcon; + if (!appIcon) + return iconFromImage ? Paths.resolveIconUrl(iconFromImage) : ""; + if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/")) + return appIcon; + if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:")) + return ""; + return Paths.resolveIconPath(appIcon); + } + + hasImage: hasNotificationImage + fallbackIcon: { + if (imageHasSpecialPrefix) + return iconFromImage; + return notificationData?.appIcon || iconFromImage || ""; + } + fallbackText: { + const appName = notificationData?.appName || "?"; + return appName.charAt(0).toUpperCase(); + } + + onImageStatusChanged: { + if (imageStatus === Image.Ready && needsImagePersist) { + const cachePath = NotificationService.getImageCachePath(notificationData); + saveImageToFile(cachePath); + } + } + + onImageSaved: filePath => { + if (!notificationData) + return; + notificationData.persistedImagePath = filePath; + const wrapperId = notificationData.notification?.id?.toString() || ""; + if (wrapperId) + NotificationService.updateHistoryImage(wrapperId, filePath); + } + } + + Column { + id: textContainer + + anchors.left: iconContainer.right + anchors.leftMargin: Theme.spacingM + anchors.right: parent.right + anchors.top: parent.top + spacing: Theme.notificationContentSpacing + + Row { + id: headerRow + width: parent.width + spacing: Theme.spacingXS + visible: headerAppNameText.text.length > 0 || headerTimeText.text.length > 0 + + StyledText { + id: headerAppNameText + text: notificationData ? (notificationData.appName || "") : "" + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Normal + elide: Text.ElideRight + maximumLineCount: 1 + width: Math.min(implicitWidth, parent.width - headerSeparator.implicitWidth - headerTimeText.implicitWidth - parent.spacing * 2) + } + + StyledText { + id: headerSeparator + text: (headerAppNameText.text.length > 0 && headerTimeText.text.length > 0) ? " • " : "" + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Normal + } + + StyledText { + id: headerTimeText + text: notificationData ? (notificationData.timeStr || "") : "" + color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7) + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Normal + } + } + + StyledText { + text: notificationData ? (notificationData.summary || "") : "" + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + width: parent.width + elide: Text.ElideRight + horizontalAlignment: Text.AlignLeft + maximumLineCount: 1 + visible: text.length > 0 + } + + StyledText { + id: bodyText + property bool hasMoreText: truncated + + text: notificationData ? (notificationData.htmlBody || "") : "" + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + width: parent.width + elide: descriptionExpanded ? Text.ElideNone : Text.ElideRight + horizontalAlignment: Text.AlignLeft + maximumLineCount: descriptionExpanded ? -1 : (compactMode ? 1 : 2) + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + visible: text.length > 0 + opacity: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? 0 : 1 + linkColor: Theme.primary + onLinkActivated: link => Qt.openUrlExternally(link) + + MouseArea { + anchors.fill: parent + cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (bodyText.hasMoreText || descriptionExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor + + onClicked: mouse => { + if (!parent.hoveredLink && (bodyText.hasMoreText || descriptionExpanded)) + win.descriptionExpanded = !win.descriptionExpanded; + } + + propagateComposedEvents: true + onPressed: mouse => { + if (parent.hoveredLink) + mouse.accepted = false; + } + onReleased: mouse => { + if (parent.hoveredLink) + mouse.accepted = false; + } + } + } + + StyledText { + text: I18n.tr("Message Content", "notification privacy mode placeholder") + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + width: parent.width + visible: SettingsData.notificationPopupPrivacyMode && !descriptionExpanded && win.hasExpandableBody + } + } + } + + DankActionButton { + id: closeButton + + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: cardPadding + anchors.rightMargin: Theme.spacingL + iconName: "close" + iconSize: compactMode ? 14 : 16 + buttonSize: compactMode ? 20 : 24 + z: 15 + + onClicked: { + if (notificationData && !win.exiting) + notificationData.popup = false; + } + } + + DankActionButton { + id: expandButton + + anchors.right: closeButton.left + anchors.rightMargin: Theme.spacingXS + anchors.top: parent.top + anchors.topMargin: cardPadding + iconName: descriptionExpanded ? "expand_less" : "expand_more" + iconSize: compactMode ? 14 : 16 + buttonSize: compactMode ? 20 : 24 + z: 15 + visible: SettingsData.notificationPopupPrivacyMode && win.hasExpandableBody + + onClicked: { + if (win.hasExpandableBody) + win.descriptionExpanded = !win.descriptionExpanded; + } + } + + Row { + visible: cardHoverHandler.hovered + opacity: visible ? 1 : 0 + anchors.right: clearButton.visible ? clearButton.left : parent.right + anchors.rightMargin: clearButton.visible ? contentSpacing : Theme.spacingL + anchors.top: notificationContent.bottom + anchors.topMargin: contentSpacing + spacing: contentSpacing + z: 20 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + + Repeater { + model: notificationData ? (notificationData.actions || []) : [] + + Rectangle { + property bool isHovered: false + + width: Math.max(actionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth) + height: actionButtonHeight + radius: Theme.notificationButtonCornerRadius + color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent" + + StyledText { + id: actionText + + text: modelData.text || "Open" + color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + anchors.centerIn: parent + elide: Text.ElideRight + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onEntered: parent.isHovered = true + onExited: parent.isHovered = false + onClicked: { + if (modelData && modelData.invoke) + modelData.invoke(); + if (notificationData && !win.exiting) + notificationData.popup = false; + } + } + } + } + } + + Rectangle { + id: clearButton + + property bool isHovered: false + readonly property int actionCount: notificationData ? (notificationData.actions || []).length : 0 + + visible: actionCount < 3 && cardHoverHandler.hovered + opacity: visible ? 1 : 0 + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Theme.standardEasing + } + } + anchors.right: parent.right + anchors.rightMargin: Theme.spacingL + anchors.top: notificationContent.bottom + anchors.topMargin: contentSpacing + width: Math.max(clearTextLabel.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth) + height: actionButtonHeight + radius: Theme.notificationButtonCornerRadius + color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent" + z: 20 + + StyledText { + id: clearTextLabel + + text: win.clearText + color: clearButton.isHovered ? Theme.primary : Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium + anchors.centerIn: parent + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + acceptedButtons: Qt.LeftButton + onEntered: clearButton.isHovered = true + onExited: clearButton.isHovered = false + onClicked: { + if (notificationData && !win.exiting) + NotificationService.dismissNotification(notificationData); + } + } + } + + MouseArea { + id: cardHoverArea + + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.LeftButton | Qt.RightButton + cursorShape: Qt.PointingHandCursor + propagateComposedEvents: true + z: -1 + onClicked: mouse => { + if (!notificationData || win.exiting) + return; + if (mouse.button === Qt.RightButton) { + popupContextMenu.popup(); + } else if (mouse.button === Qt.LeftButton) { + const canExpand = bodyText.hasMoreText || win.descriptionExpanded || (SettingsData.notificationPopupPrivacyMode && win.hasExpandableBody); + if (canExpand) { + win.descriptionExpanded = !win.descriptionExpanded; + } else if (notificationData.actions && notificationData.actions.length > 0) { + notificationData.actions[0].invoke(); + NotificationService.dismissNotification(notificationData); + } else { + notificationData.popup = false; + } + } + } + } + } + + DragHandler { + id: swipeDragHandler + target: null + xAxis.enabled: !isCenterPosition + yAxis.enabled: isCenterPosition + + onActiveChanged: { + if (active || win.exiting || content.swipeDismissing) + return; + + if (Math.abs(content.swipeOffset) > content.dismissThreshold) { + content.swipeDismissing = true; + swipeDismissAnim.start(); + } else { + content.swipeOffset = 0; + } + } + + onTranslationChanged: { + if (win.exiting) + return; + + const raw = isCenterPosition ? translation.y : translation.x; + if (isTopCenter) { + content.swipeOffset = Math.min(0, raw); + } else if (isBottomCenter) { + content.swipeOffset = Math.max(0, raw); + } else { + const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; + content.swipeOffset = isLeft ? Math.min(0, raw) : Math.max(0, raw); + } + } + } + + opacity: { + const swipeAmount = Math.abs(content.swipeOffset); + if (swipeAmount <= content.swipeFadeStartOffset) + return 1; + const fadeProgress = (swipeAmount - content.swipeFadeStartOffset) / content.swipeFadeDistance; + return Math.max(0, 1 - fadeProgress); + } + + Behavior on opacity { + enabled: !content.swipeActive + NumberAnimation { + duration: Theme.shortDuration + } + } + + Behavior on swipeOffset { + enabled: !content.swipeActive && !content.swipeDismissing + NumberAnimation { + duration: Theme.notificationExitDuration + easing.type: Theme.standardEasing + } + } + + NumberAnimation { + id: swipeDismissAnim + target: content + property: "swipeOffset" + to: isTopCenter ? -content.height : isBottomCenter ? content.height : (SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom ? -content.width : content.width) + duration: Theme.notificationExitDuration + easing.type: Easing.OutCubic + onStopped: { + NotificationService.dismissNotification(notificationData); + win.forceExit(); + } + } + + transform: [ + Translate { + id: swipeTx + x: isCenterPosition ? 0 : content.swipeOffset + y: isCenterPosition ? content.swipeOffset : 0 + }, + Translate { + id: tx + x: { + if (isCenterPosition) + return 0; + const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; + return isLeft ? -Anims.slidePx : Anims.slidePx; + } + y: isTopCenter ? -Anims.slidePx : isBottomCenter ? Anims.slidePx : 0 + } + ] + } + + NumberAnimation { + id: enterX + + target: tx + property: isCenterPosition ? "y" : "x" + from: { + if (isTopCenter) + return -Anims.slidePx; + if (isBottomCenter) + return Anims.slidePx; + const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; + return isLeft ? -Anims.slidePx : Anims.slidePx; + } + to: 0 + duration: Theme.notificationEnterDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: isCenterPosition ? Theme.expressiveCurves.standardDecel : Theme.expressiveCurves.emphasizedDecel + onStopped: { + if (!win.exiting && !win._isDestroying) { + if (isCenterPosition) { + if (Math.abs(tx.y) < 0.5) + win.entered(); + } else { + if (Math.abs(tx.x) < 0.5) + win.entered(); + } + } + } + } + + ParallelAnimation { + id: exitAnim + + onStopped: finalizeExit("animStopped") + + PropertyAnimation { + target: tx + property: isCenterPosition ? "y" : "x" + from: 0 + to: { + if (isTopCenter) + return -Anims.slidePx; + if (isBottomCenter) + return Anims.slidePx; + const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom; + return isLeft ? -Anims.slidePx : Anims.slidePx; + } + duration: Theme.notificationExitDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel + } + + NumberAnimation { + target: content + property: "opacity" + from: 1 + to: 0 + duration: Theme.notificationExitDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.standardAccel + } + + NumberAnimation { + target: content + property: "scale" + from: 1 + to: 0.98 + duration: Theme.notificationExitDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel + } + } + + Connections { + id: wrapperConn + + function onPopupChanged() { + if (!win.notificationData || win._isDestroying) + return; + if (!win.notificationData.popup && !win.exiting) + startExit(); + } + + target: win.notificationData || null + ignoreUnknownSignals: true + enabled: !win._isDestroying + } + + Connections { + id: notificationConn + + function onDropped() { + if (!win._isDestroying && !win.exiting) + forceExit(); + } + + target: (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null + ignoreUnknownSignals: true + enabled: !win._isDestroying + } + + Timer { + id: enterDelay + + interval: 160 + repeat: false + onTriggered: { + if (notificationData && notificationData.timer && !exiting && !_isDestroying) + notificationData.timer.start(); + } + } + + Timer { + id: exitWatchdog + + interval: 600 + repeat: false + onTriggered: finalizeExit("watchdog") + } + + Behavior on screenY { + id: screenYAnim + + enabled: !exiting && !_isDestroying + + NumberAnimation { + duration: Theme.shortDuration + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.standardDecel + } + } + + Menu { + id: popupContextMenu + width: 220 + contentHeight: 130 + margins: -1 + popupType: Popup.Window + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + + background: Rectangle { + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + radius: Theme.cornerRadius + border.width: 0 + border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12) + } + + MenuItem { + id: setNotificationRulesItem + text: I18n.tr("Set notification rules") + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + const appName = notificationData?.appName || ""; + const desktopEntry = notificationData?.desktopEntry || ""; + SettingsData.addNotificationRuleForNotification(appName, desktopEntry); + PopoutService.openSettingsWithTab("notifications"); + } + } + + MenuItem { + id: muteUnmuteItem + readonly property bool isMuted: SettingsData.isAppMuted(notificationData?.appName || "", notificationData?.desktopEntry || "") + text: isMuted ? I18n.tr("Unmute popups for %1").arg(notificationData?.appName || I18n.tr("this app")) : I18n.tr("Mute popups for %1").arg(notificationData?.appName || I18n.tr("this app")) + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + const appName = notificationData?.appName || ""; + const desktopEntry = notificationData?.desktopEntry || ""; + if (isMuted) { + SettingsData.removeMuteRuleForApp(appName, desktopEntry); + } else { + SettingsData.addMuteRuleForApp(appName, desktopEntry); + if (notificationData && !exiting) + NotificationService.dismissNotification(notificationData); + } + } + } + + MenuItem { + text: I18n.tr("Dismiss") + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + if (notificationData && !exiting) + NotificationService.dismissNotification(notificationData); + } + } + } +} diff --git a/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml new file mode 100644 index 0000000..02bfc0f --- /dev/null +++ b/raveos-hyprland-theme/theme-data/DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml @@ -0,0 +1,239 @@ +import QtQuick +import qs.Common +import qs.Services + +QtObject { + id: manager + + property var modelData + property int topMargin: 0 + readonly property bool compactMode: SettingsData.notificationCompactMode + readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding + readonly property real popupIconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal + readonly property real actionButtonHeight: compactMode ? 20 : 24 + readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS + readonly property real popupSpacing: compactMode ? 0 : Theme.spacingXS + readonly property real collapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) + readonly property int baseNotificationHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing + popupSpacing + property var popupWindows: [] + property var destroyingWindows: new Set() + property var pendingDestroys: [] + property int destroyDelayMs: 100 + property Component popupComponent + + popupComponent: Component { + NotificationPopup { + onExitFinished: manager._onPopupExitFinished(this) + onPopupHeightChanged: manager._onPopupHeightChanged(this) + } + } + + property Connections notificationConnections + + notificationConnections: Connections { + function onVisibleNotificationsChanged() { + manager._sync(NotificationService.visibleNotifications); + } + + target: NotificationService + } + + property Timer sweeper + + property Timer destroyTimer: Timer { + interval: destroyDelayMs + running: false + repeat: false + onTriggered: manager._processDestroyQueue() + } + + function _processDestroyQueue() { + if (pendingDestroys.length === 0) + return; + const p = pendingDestroys.shift(); + if (p && p.destroy) { + try { + p.destroy(); + } catch (e) {} + } + if (pendingDestroys.length > 0) + destroyTimer.restart(); + } + + function _scheduleDestroy(p) { + if (!p) + return; + pendingDestroys.push(p); + if (!destroyTimer.running) + destroyTimer.restart(); + } + + sweeper: Timer { + interval: 500 + running: false + repeat: true + onTriggered: { + const toRemove = []; + for (const p of popupWindows) { + if (!p) { + toRemove.push(p); + continue; + } + const isZombie = p.status === Component.Null || (!p.visible && !p.exiting) || (!p.notificationData && !p._isDestroying) || (!p.hasValidData && !p._isDestroying); + if (isZombie) { + toRemove.push(p); + if (p.forceExit) { + p.forceExit(); + } else if (p.destroy) { + try { + p.destroy(); + } catch (e) {} + } + } + } + if (toRemove.length) { + popupWindows = popupWindows.filter(p => toRemove.indexOf(p) === -1); + _repositionAll(); + } + if (popupWindows.length === 0) + sweeper.stop(); + } + } + + function _hasWindowFor(w) { + return popupWindows.some(p => p && p.notificationData === w && !p._isDestroying && p.status !== Component.Null); + } + + function _isValidWindow(p) { + return p && p.status !== Component.Null && !p._isDestroying && p.hasValidData; + } + + function _isFocusedScreen() { + if (!SettingsData.notificationFocusedMonitor) + return true; + const focused = CompositorService.getFocusedScreen(); + return focused && manager.modelData && focused.name === manager.modelData.name; + } + + function _sync(newWrappers) { + for (const p of popupWindows.slice()) { + if (!_isValidWindow(p) || p.exiting) + continue; + if (p.notificationData && newWrappers.indexOf(p.notificationData) === -1) { + p.notificationData.removedByLimit = true; + p.notificationData.popup = false; + } + } + for (const w of newWrappers) { + if (w && !_hasWindowFor(w) && _isFocusedScreen()) + _insertAtTop(w); + } + } + + function _popupHeight(p) { + return (p.alignedHeight || p.implicitHeight || (baseNotificationHeight - popupSpacing)) + popupSpacing; + } + + function _insertAtTop(wrapper) { + if (!wrapper) + return; + const notificationId = wrapper?.notification ? wrapper.notification.id : ""; + const win = popupComponent.createObject(null, { + "notificationData": wrapper, + "notificationId": notificationId, + "screenY": topMargin, + "screen": manager.modelData + }); + if (!win) + return; + if (!win.hasValidData) { + win.destroy(); + return; + } + popupWindows.unshift(win); + _repositionAll(); + if (!sweeper.running) + sweeper.start(); + } + + function _repositionAll() { + const active = popupWindows.filter(p => _isValidWindow(p) && p.notificationData?.popup && !p.exiting); + + const pinnedSlots = []; + for (const p of active) { + if (!p.hovered) + continue; + pinnedSlots.push({ + y: p.screenY, + end: p.screenY + _popupHeight(p) + }); + } + pinnedSlots.sort((a, b) => a.y - b.y); + + let currentY = topMargin; + for (const win of active) { + if (win.hovered) + continue; + for (const slot of pinnedSlots) { + if (currentY >= slot.y - 1 && currentY < slot.end) + currentY = slot.end; + } + win.screenY = currentY; + currentY += _popupHeight(win); + } + } + + function _onPopupHeightChanged(p) { + if (!p || p.exiting || p._isDestroying) + return; + if (popupWindows.indexOf(p) === -1) + return; + _repositionAll(); + } + + function _onPopupExitFinished(p) { + if (!p) + return; + const windowId = p.toString(); + if (destroyingWindows.has(windowId)) + return; + destroyingWindows.add(windowId); + const i = popupWindows.indexOf(p); + if (i !== -1) { + popupWindows.splice(i, 1); + popupWindows = popupWindows.slice(); + } + if (NotificationService.releaseWrapper && p.notificationData) + NotificationService.releaseWrapper(p.notificationData); + _scheduleDestroy(p); + Qt.callLater(() => destroyingWindows.delete(windowId)); + _repositionAll(); + } + + function cleanupAllWindows() { + sweeper.stop(); + destroyTimer.stop(); + pendingDestroys = []; + for (const p of popupWindows.slice()) { + if (p) { + try { + if (p.forceExit) { + p.forceExit(); + } else if (p.destroy) { + p.destroy(); + } + } catch (e) {} + } + } + popupWindows = []; + destroyingWindows.clear(); + } + + onPopupWindowsChanged: { + if (popupWindows.length > 0 && !sweeper.running) { + sweeper.start(); + } else if (popupWindows.length === 0 && sweeper.running) { + sweeper.stop(); + } + } +} -- cgit v1.3