diff --git a/quickshell/Common/ConnectedModeState.qml b/quickshell/Common/ConnectedModeState.qml index af1f2144..0938c848 100644 --- a/quickshell/Common/ConnectedModeState.qml +++ b/quickshell/Common/ConnectedModeState.qml @@ -236,12 +236,35 @@ Singleton { }; } + function _sameNotificationGeometry(a, b) { + if (!a || !b) + return false; + return Math.abs(Number(a.bodyX) - Number(b.bodyX)) < 0.5 + && Math.abs(Number(a.bodyY) - Number(b.bodyY)) < 0.5 + && Math.abs(Number(a.bodyW) - Number(b.bodyW)) < 0.5 + && Math.abs(Number(a.bodyH) - Number(b.bodyH)) < 0.5; + } + + function _sameNotificationState(a, b) { + if (!a || !b) + return false; + return a.visible === b.visible + && a.barSide === b.barSide + && a.omitStartConnector === b.omitStartConnector + && a.omitEndConnector === b.omitEndConnector + && _sameNotificationGeometry(a, b); + } + function setNotificationState(screenName, state) { if (!screenName || !state) return false; + const normalized = _normalizeNotificationState(state); + if (_sameNotificationState(notificationStates[screenName], normalized)) + return true; + const next = _cloneNotificationStates(); - next[screenName] = _normalizeNotificationState(state); + next[screenName] = normalized; notificationStates = next; return true; } diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index bc124cb9..fef04494 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -1069,6 +1069,9 @@ Singleton { return base === 0 ? 0 : Math.round(base * 0.85); } + readonly property int notificationInlineExpandDuration: notificationAnimationBaseDuration === 0 ? 0 : 185 + readonly property int notificationInlineCollapseDuration: notificationAnimationBaseDuration === 0 ? 0 : 150 + readonly property real notificationIconSizeNormal: 56 readonly property real notificationIconSizeCompact: 48 readonly property real notificationExpandedIconSizeNormal: 48 diff --git a/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index 8da0b93e..53c120d7 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCard.qml @@ -16,6 +16,7 @@ Rectangle { property bool userInitiatedExpansion: false property bool isAnimating: false property bool animateExpansion: true + property bool isDescriptionToggleAnimation: false property bool _retainedExpandedContent: false property bool _clipAnimatedContent: false property real expandedContentOpacity: expanded ? 1 : 0 @@ -64,6 +65,8 @@ Rectangle { } function expansionMotionDuration() { + if (isDescriptionToggleAnimation) + return descriptionExpanded ? Theme.notificationInlineExpandDuration : Theme.notificationInlineCollapseDuration; return root.connectedFrameMode ? Theme.variantDuration(Theme.popoutAnimationDuration, root.expanded) : (root.expanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration); } @@ -414,6 +417,7 @@ Rectangle { onClicked: mouse => { if (!parent.hoveredLink && (parent.hasMoreText || descriptionExpanded)) { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = true; const messageId = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.notification && notificationGroup.latestNotification.notification.id) ? (notificationGroup.latestNotification.notification.id + "_desc") : ""; NotificationService.toggleMessageExpansion(messageId); Qt.callLater(() => { @@ -423,7 +427,7 @@ Rectangle { } } - propagateComposedEvents: true + propagateComposedEvents: false onPressed: mouse => { if (parent.hoveredLink) mouse.accepted = false; @@ -580,7 +584,12 @@ Rectangle { } Behavior on height { - enabled: false + enabled: expandedDelegateWrapper.__delegateInitialized && root.animateExpansion && root.userInitiatedExpansion + NumberAnimation { + duration: root.expansionMotionDuration() + easing.type: Easing.BezierSpline + easing.bezierCurve: root.expansionMotionCurve() + } } Item { @@ -719,6 +728,7 @@ Rectangle { onClicked: mouse => { if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = true; NotificationService.toggleMessageExpansion(modelData?.notification?.id || ""); Qt.callLater(() => { if (root && !root.isAnimating) @@ -727,7 +737,7 @@ Rectangle { } } - propagateComposedEvents: true + propagateComposedEvents: false onPressed: mouse => { if (parent.hoveredLink) { mouse.accepted = false; @@ -985,6 +995,7 @@ Rectangle { cursorShape: Qt.PointingHandCursor onClicked: { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = false; NotificationService.toggleGroupExpansion(notificationGroup?.key || ""); } z: -1 @@ -1008,6 +1019,7 @@ Rectangle { buttonSize: compactMode ? 24 : 28 onClicked: { root.userInitiatedExpansion = true; + root.isDescriptionToggleAnimation = false; NotificationService.toggleGroupExpansion(notificationGroup?.key || ""); } } @@ -1034,6 +1046,7 @@ Rectangle { } else { root.isAnimating = false; root.userInitiatedExpansion = false; + root.isDescriptionToggleAnimation = false; root._retainedExpandedContent = false; root._clipAnimatedContent = false; } diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index 6317ec62..1045aa35 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -25,6 +25,9 @@ PanelWindow { default: return "top"; } } + readonly property int inlineExpandDuration: Theme.notificationInlineExpandDuration + readonly property int inlineCollapseDuration: Theme.notificationInlineCollapseDuration + property bool inlineHeightAnimating: false WindowBlur { targetWindow: win @@ -57,6 +60,7 @@ PanelWindow { property real _lastReportedAlignedHeight: -1 property real _storedTopMargin: 0 property real _storedBottomMargin: 0 + property bool _inlineGeometryReady: false readonly property bool directionalEffect: Theme.isDirectionalEffect readonly property bool depthEffect: Theme.isDepthEffect readonly property real entryTravel: { @@ -84,14 +88,8 @@ PanelWindow { 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(); + if (connectedFrameMode) + popupChromeGeometryChanged(); } readonly property bool compactMode: SettingsData.notificationCompactMode @@ -182,23 +180,86 @@ PanelWindow { return basePopupHeightPrivacy; if (!descriptionExpanded) return basePopupHeight; - const bodyTextHeight = bodyText.contentHeight || 0; + const bodyTextHeight = expandedBodyMeasure.contentHeight || bodyText.contentHeight || 0; const collapsedBodyHeight = Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2); if (bodyTextHeight > collapsedBodyHeight + 2) return basePopupHeight + bodyTextHeight - collapsedBodyHeight; return basePopupHeight; } + readonly property real targetAlignedHeight: Theme.px(Math.max(0, contentImplicitHeight), dpr) + property real renderedAlignedHeight: targetAlignedHeight + property real allocatedAlignedHeight: targetAlignedHeight + readonly property bool inlineGeometryGrowing: targetAlignedHeight >= renderedAlignedHeight + readonly property bool contentAnchorsTop: isTopCenter + || SettingsData.notificationPopupPosition === SettingsData.Position.Top + || SettingsData.notificationPopupPosition === SettingsData.Position.Left + readonly property real renderedContentOffsetY: contentAnchorsTop ? 0 : Math.max(0, allocatedAlignedHeight - renderedAlignedHeight) implicitWidth: contentImplicitWidth + (windowShadowPad * 2) - implicitHeight: contentImplicitHeight + (windowShadowPad * 2) + implicitHeight: allocatedAlignedHeight + (windowShadowPad * 2) - Behavior on implicitHeight { + function inlineMotionDuration(growing) { + return growing ? inlineExpandDuration : inlineCollapseDuration; + } + + function syncInlineTargetHeight() { + const target = Math.max(0, Number(targetAlignedHeight)); + if (isNaN(target)) + return; + + if (!_inlineGeometryReady) { + renderedHeightAnim.stop(); + renderedAlignedHeight = target; + allocatedAlignedHeight = target; + _lastReportedAlignedHeight = target; + return; + } + + const currentRendered = Math.max(0, Number(renderedAlignedHeight)); + const nextAllocation = Math.max(target, currentRendered, allocatedAlignedHeight); + if (Math.abs(nextAllocation - allocatedAlignedHeight) >= 0.5) + allocatedAlignedHeight = nextAllocation; + + if (Math.abs(target - renderedAlignedHeight) < 0.5) { + finishInlineHeightAnimation(); + return; + } + + renderedAlignedHeight = target; + if (connectedFrameMode) + popupChromeGeometryChanged(); + if (inlineMotionDuration(target >= currentRendered) <= 0) + Qt.callLater(() => finishInlineHeightAnimation()); + } + + function finishInlineHeightAnimation() { + const target = Math.max(0, Number(targetAlignedHeight)); + if (isNaN(target)) + return; + if (Math.abs(renderedAlignedHeight - target) >= 0.5) + renderedAlignedHeight = target; + if (Math.abs(allocatedAlignedHeight - target) >= 0.5) + allocatedAlignedHeight = target; + _lastReportedAlignedHeight = renderedAlignedHeight; + popupHeightChanged(); + if (connectedFrameMode) + popupChromeGeometryChanged(); + } + + onTargetAlignedHeightChanged: syncInlineTargetHeight() + onAllocatedAlignedHeightChanged: { + if (connectedFrameMode) + popupChromeGeometryChanged(); + } + + Behavior on renderedAlignedHeight { enabled: !exiting && !_isDestroying NumberAnimation { - id: implicitHeightAnim - duration: descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration + id: renderedHeightAnim + duration: win.inlineMotionDuration(win.inlineGeometryGrowing) easing.type: Easing.BezierSpline - easing.bezierCurve: descriptionExpanded ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve - onFinished: win.popupHeightChanged() + easing.bezierCurve: win.inlineGeometryGrowing ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + onRunningChanged: win.inlineHeightAnimating = running + onFinished: win.finishInlineHeightAnimation() } } @@ -208,7 +269,11 @@ PanelWindow { } } Component.onCompleted: { - _lastReportedAlignedHeight = Theme.px(implicitHeight, dpr); + renderedHeightAnim.stop(); + renderedAlignedHeight = targetAlignedHeight; + allocatedAlignedHeight = targetAlignedHeight; + _inlineGeometryReady = true; + _lastReportedAlignedHeight = renderedAlignedHeight; _storedTopMargin = getTopMargin(); _storedBottomMargin = getBottomMargin(); if (SettingsData.notificationPopupPrivacyMode) @@ -379,7 +444,7 @@ PanelWindow { return Theme.snap(screen.width - alignedWidth - barRight, dpr); } - function getContentY() { + function getAllocatedContentY() { if (!screen) return 0; @@ -389,7 +454,11 @@ PanelWindow { 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); + return Theme.snap(screen.height - allocatedAlignedHeight - barBottom, dpr); + } + + function getContentY() { + return Theme.snap(getAllocatedContentY() + renderedContentOffsetY, dpr); } function getWindowLeftMargin() { @@ -401,7 +470,7 @@ PanelWindow { function getWindowTopMargin() { if (!screen) return 0; - return Theme.snap(getContentY() - windowShadowPad, dpr); + return Theme.snap(getAllocatedContentY() - windowShadowPad, dpr); } function _swipeDismissTarget() { @@ -481,7 +550,7 @@ PanelWindow { 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) + readonly property real alignedHeight: renderedAlignedHeight onScreenYChanged: popupChromeGeometryChanged() onScreenChanged: popupChromeGeometryChanged() onConnectedFrameModeChanged: popupChromeGeometryChanged() @@ -492,11 +561,11 @@ PanelWindow { id: content x: Theme.snap(windowShadowPad, dpr) - y: Theme.snap(windowShadowPad, dpr) + y: Theme.snap(windowShadowPad + renderedContentOffsetY, dpr) width: alignedWidth height: alignedHeight visible: !win._finalized && !chromeOnlyExit - scale: cardHoverHandler.hovered ? 1.01 : 1.0 + scale: (!win.inlineHeightAnimating && cardHoverHandler.hovered) ? 1.01 : 1.0 transformOrigin: Item.Center Behavior on scale { @@ -537,21 +606,21 @@ PanelWindow { Behavior on shadowBlurPx { NumberAnimation { - duration: win.descriptionExpanded ? Theme.notificationExpandDuration : Theme.shortDuration + duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration easing.type: Theme.standardEasing } } Behavior on shadowOffsetX { NumberAnimation { - duration: win.descriptionExpanded ? Theme.notificationExpandDuration : Theme.shortDuration + duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration easing.type: Theme.standardEasing } } Behavior on shadowOffsetY { NumberAnimation { - duration: win.descriptionExpanded ? Theme.notificationExpandDuration : Theme.shortDuration + duration: win.inlineHeightAnimating ? win.inlineExpandDuration : Theme.shortDuration easing.type: Theme.standardEasing } } @@ -652,10 +721,23 @@ PanelWindow { LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true + StyledText { + id: expandedBodyMeasure + + visible: false + width: Math.max(0, backgroundContainer.width - Theme.spacingL - (Theme.spacingL + Theme.notificationHoverRevealMargin) - popupIconSize - Theme.spacingM) + text: notificationData ? (notificationData.htmlBody || "") : "" + font.pixelSize: Theme.fontSizeSmall + elide: Text.ElideNone + horizontalAlignment: Text.AlignLeft + maximumLineCount: -1 + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + } + Item { id: notificationContent - readonly property real expandedTextHeight: bodyText.contentHeight || 0 + readonly property real expandedTextHeight: expandedBodyMeasure.contentHeight || 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 @@ -825,7 +907,7 @@ PanelWindow { win.descriptionExpanded = !win.descriptionExpanded; } - propagateComposedEvents: true + propagateComposedEvents: false onPressed: mouse => { if (parent.hoveredLink) mouse.accepted = false; diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml index ec216019..16201079 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml @@ -141,6 +141,8 @@ QtObject { return false; if (!p.notificationData?.popup && !p.exiting) return false; + if (p.exiting && p.notificationData?.removedByLimit && _layoutWindows().length > 0) + return true; if (!p.exiting && p.popupChromeOpenProgress && p.popupChromeOpenProgress() < chromeOpenProgressThreshold) return false; // Keep the connected shell until the card is almost fully closed. @@ -408,6 +410,33 @@ QtObject { }; } + function _filledMaxStackChromeEdge(candidates, stackEdge) { + const layoutWindows = _layoutWindows(); + if (layoutWindows.length < NotificationService.maxVisibleNotifications) + return null; + const anchorsTop = _stackAnchorsTop(); + const layoutAnchorEdge = _stackAnchoredChromeEdge(layoutWindows); + const anchorEdge = layoutAnchorEdge !== null ? layoutAnchorEdge : (stackEdge !== null ? stackEdge : _stackAnchoredChromeEdge(candidates)); + if (anchorEdge === null) + return null; + let span = 0; + for (const p of layoutWindows) { + const rect = _popupChromeRect(p, false); + if (!rect) + continue; + span += Math.max(0, rect.bottom - rect.y); + } + if (span <= 0) + return null; + if (layoutWindows.length > 1) + span += popupSpacing * (layoutWindows.length - 1); + return { + anchorsTop: anchorsTop, + startEdge: anchorEdge.edge, + edge: anchorsTop ? anchorEdge.edge + span : anchorEdge.edge - span + }; + } + function _syncNotificationChromeState() { const screenName = manager.modelData?.name || ""; if (!screenName) @@ -455,6 +484,16 @@ QtObject { if (!stackEdge.anchorsTop && stackEdge.edge > maxYEnd) maxYEnd = stackEdge.edge; } + const filledMaxStackEdge = _filledMaxStackChromeEdge(chromeCandidates, stackEdge); + if (filledMaxStackEdge !== null) { + if (filledMaxStackEdge.anchorsTop) { + minY = filledMaxStackEdge.startEdge; + maxYEnd = filledMaxStackEdge.edge; + } else { + minY = filledMaxStackEdge.edge; + maxYEnd = filledMaxStackEdge.startEdge; + } + } const anchorsTop = stackEdge !== null ? stackEdge.anchorsTop : _stackAnchorsTop(); const closeGapAnchorEdge = _closeGapChromeAnchorEdge(anchorsTop); if (closeGapAnchorEdge !== null) {