From cc858c5557bf681de5eed5576acd538740d53c87 Mon Sep 17 00:00:00 2001 From: purian23 Date: Sat, 11 Apr 2026 22:09:45 -0400 Subject: [PATCH] frame(ConnectedMode): Wire up Notifications --- quickshell/Modules/Frame/FrameBorder.qml | 29 +-- quickshell/Modules/Frame/FrameWindow.qml | 217 +++++++++++------- .../Notifications/Popup/NotificationPopup.qml | 174 +++++++++++--- .../Popup/NotificationPopupManager.qml | 207 ++++++++++++++++- quickshell/Widgets/ConnectedShape.qml | 8 +- 5 files changed, 504 insertions(+), 131 deletions(-) diff --git a/quickshell/Modules/Frame/FrameBorder.qml b/quickshell/Modules/Frame/FrameBorder.qml index 6630b253..31112382 100644 --- a/quickshell/Modules/Frame/FrameBorder.qml +++ b/quickshell/Modules/Frame/FrameBorder.qml @@ -14,27 +14,22 @@ Item { required property real cutoutLeftInset required property real cutoutRightInset required property real cutoutRadius + property color borderColor: Qt.rgba(SettingsData.effectiveFrameColor.r, SettingsData.effectiveFrameColor.g, SettingsData.effectiveFrameColor.b, SettingsData.frameOpacity) Rectangle { id: borderRect anchors.fill: parent - // Bake frameOpacity into the color alpha rather than using the `opacity` property. - // Qt Quick can skip layer.effect processing on items with opacity < 1 as an - // optimization, causing the MultiEffect inverted mask to stop working and the - // Rectangle to render as a plain square at low opacity values. - color: Qt.rgba(SettingsData.effectiveFrameColor.r, - SettingsData.effectiveFrameColor.g, - SettingsData.effectiveFrameColor.b, - SettingsData.frameOpacity) + // Bake frameOpacity into the color alpha rather than using the `opacity` property + color: root.borderColor layer.enabled: true layer.effect: MultiEffect { - maskSource: cutoutMask - maskEnabled: true - maskInverted: true - maskThresholdMin: 0.5 - maskSpreadAtMin: 1 + maskSource: cutoutMask + maskEnabled: true + maskInverted: true + maskThresholdMin: 0.5 + maskSpreadAtMin: 1 } } @@ -47,11 +42,11 @@ Item { Rectangle { anchors { - fill: parent - topMargin: root.cutoutTopInset + fill: parent + topMargin: root.cutoutTopInset bottomMargin: root.cutoutBottomInset - leftMargin: root.cutoutLeftInset - rightMargin: root.cutoutRightInset + leftMargin: root.cutoutLeftInset + rightMargin: root.cutoutRightInset } radius: root.cutoutRadius } diff --git a/quickshell/Modules/Frame/FrameWindow.qml b/quickshell/Modules/Frame/FrameWindow.qml index 47b28b99..b21952f9 100644 --- a/quickshell/Modules/Frame/FrameWindow.qml +++ b/quickshell/Modules/Frame/FrameWindow.qml @@ -44,6 +44,7 @@ PanelWindow { "x": 0, "y": 0 }) + readonly property var _notifState: ConnectedModeState.notificationStates[win._screenName] || ConnectedModeState.emptyNotificationState // ─── Connected chrome convenience properties ────────────────────────────── readonly property bool _connectedActive: win._frameActive && SettingsData.connectedFrameModeActive @@ -218,6 +219,18 @@ PanelWindow { height: _active ? win._dockConnectorRadius() * 2 : 0 } + Item { + id: _notifBodyBlurAnchor + visible: false + + readonly property bool _active: win._frameActive && win._notifState.visible && win._notifState.bodyW > 0 && win._notifState.bodyH > 0 + + x: _active ? Theme.snap(win._notifState.bodyX, win._dpr) : 0 + y: _active ? Theme.snap(win._notifState.bodyY, win._dpr) : 0 + width: _active ? Theme.snap(win._notifState.bodyW, win._dpr) : 0 + height: _active ? Theme.snap(win._notifState.bodyH, win._dpr) : 0 + } + Region { id: _staticBlurRegion x: 0 @@ -267,6 +280,11 @@ PanelWindow { radius: win._dockConnectorRadius() } } + + Region { + item: _notifBodyBlurAnchor + radius: win._surfaceRadius + } } // ─── Connector position helpers (dock) ───────────────────────────────── @@ -528,7 +546,7 @@ PanelWindow { FrameBorder { anchors.fill: parent - visible: win._frameActive + visible: win._frameActive && !win._connectedActive cutoutTopInset: win.cutoutTopInset cutoutBottomInset: win.cutoutBottomInset cutoutLeftInset: win.cutoutLeftInset @@ -539,98 +557,141 @@ PanelWindow { // ─── Connected chrome fills ─────────────────────────────────────────────── Item { - id: _connectedChrome + id: _connectedSurfaceLayer anchors.fill: parent visible: win._connectedActive + opacity: win._surfaceOpacity + layer.enabled: opacity < 1 + layer.smooth: false + + FrameBorder { + anchors.fill: parent + borderColor: win._opaqueSurfaceColor + cutoutTopInset: win.cutoutTopInset + cutoutBottomInset: win.cutoutBottomInset + cutoutLeftInset: win.cutoutLeftInset + cutoutRightInset: win.cutoutRightInset + cutoutRadius: win.cutoutRadius + } Item { - id: _popoutChrome - visible: ConnectedModeState.popoutVisible && ConnectedModeState.popoutScreen === win._screenName - x: win._popoutChromeX() - y: win._popoutChromeY() - width: win._popoutChromeWidth() - height: win._popoutChromeHeight() - opacity: win._surfaceOpacity - layer.enabled: opacity < 1 - layer.smooth: false + id: _connectedChrome + anchors.fill: parent + visible: true Item { - id: _popoutClip - readonly property bool _barHoriz: ConnectedModeState.popoutBarSide === "top" || ConnectedModeState.popoutBarSide === "bottom" - // Expand clip by ccr on bar axis to include arc columns - x: win._popoutClipX() - (_barHoriz ? win._effectivePopoutCcr : 0) - y: win._popoutClipY() - (_barHoriz ? 0 : win._effectivePopoutCcr) - width: win._popoutClipWidth() + (_barHoriz ? win._effectivePopoutCcr * 2 : 0) - height: win._popoutClipHeight() + (_barHoriz ? 0 : win._effectivePopoutCcr * 2) - clip: true + id: _popoutChrome + visible: ConnectedModeState.popoutVisible && ConnectedModeState.popoutScreen === win._screenName + x: win._popoutChromeX() + y: win._popoutChromeY() + width: win._popoutChromeWidth() + height: win._popoutChromeHeight() - ConnectedShape { - id: _popoutShape - visible: _popoutBodyBlurAnchor._active && _popoutBodyBlurAnchor.width > 0 && _popoutBodyBlurAnchor.height > 0 - barSide: ConnectedModeState.popoutBarSide - bodyWidth: win._popoutClipWidth() - bodyHeight: win._popoutClipHeight() - connectorRadius: win._effectivePopoutCcr - surfaceRadius: win._surfaceRadius - fillColor: win._opaqueSurfaceColor - x: 0 - y: 0 + Item { + id: _popoutClip + readonly property bool _barHoriz: ConnectedModeState.popoutBarSide === "top" || ConnectedModeState.popoutBarSide === "bottom" + // Expand clip by ccr on bar axis to include arc columns + x: win._popoutClipX() - (_barHoriz ? win._effectivePopoutCcr : 0) + y: win._popoutClipY() - (_barHoriz ? 0 : win._effectivePopoutCcr) + width: win._popoutClipWidth() + (_barHoriz ? win._effectivePopoutCcr * 2 : 0) + height: win._popoutClipHeight() + (_barHoriz ? 0 : win._effectivePopoutCcr * 2) + clip: true + + ConnectedShape { + id: _popoutShape + visible: _popoutBodyBlurAnchor._active && _popoutBodyBlurAnchor.width > 0 && _popoutBodyBlurAnchor.height > 0 + barSide: ConnectedModeState.popoutBarSide + bodyWidth: win._popoutClipWidth() + bodyHeight: win._popoutClipHeight() + connectorRadius: win._effectivePopoutCcr + surfaceRadius: win._surfaceRadius + fillColor: win._opaqueSurfaceColor + x: 0 + y: 0 + } + } + } + + Item { + id: _dockChrome + visible: _dockBodyBlurAnchor._active + x: win._dockChromeX() + y: win._dockChromeY() + width: win._dockChromeWidth() + height: win._dockChromeHeight() + + Rectangle { + id: _dockFill + x: win._dockBodyXInChrome() + y: win._dockBodyYInChrome() + width: _dockBodyBlurAnchor.width + win._dockFillOverlapX() * 2 + height: _dockBodyBlurAnchor.height + win._dockFillOverlapY() * 2 + color: win._opaqueSurfaceColor + z: 1 + + readonly property string _dockSide: win._dockState.barSide + readonly property real _dockRadius: win._dockBodyBlurRadius() + topLeftRadius: (_dockSide === "top" || _dockSide === "left") ? 0 : _dockRadius + topRightRadius: (_dockSide === "top" || _dockSide === "right") ? 0 : _dockRadius + bottomLeftRadius: (_dockSide === "bottom" || _dockSide === "left") ? 0 : _dockRadius + bottomRightRadius: (_dockSide === "bottom" || _dockSide === "right") ? 0 : _dockRadius + } + + ConnectedCorner { + id: _connDockLeft + visible: _dockBodyBlurAnchor._active + barSide: win._dockState.barSide + placement: "left" + spacing: 0 + connectorRadius: win._dockConnectorRadius() + color: win._opaqueSurfaceColor + dpr: win._dpr + x: Theme.snap(win._dockConnectorX(_dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "left", 0) - _dockChrome.x, win._dpr) + y: Theme.snap(win._dockConnectorY(_dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "left", 0) - _dockChrome.y, win._dpr) + } + + ConnectedCorner { + id: _connDockRight + visible: _dockBodyBlurAnchor._active + barSide: win._dockState.barSide + placement: "right" + spacing: 0 + connectorRadius: win._dockConnectorRadius() + color: win._opaqueSurfaceColor + dpr: win._dpr + x: Theme.snap(win._dockConnectorX(_dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "right", 0) - _dockChrome.x, win._dpr) + y: Theme.snap(win._dockConnectorY(_dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "right", 0) - _dockChrome.y, win._dpr) } } } Item { - id: _dockChrome - visible: _dockBodyBlurAnchor._active - x: win._dockChromeX() - y: win._dockChromeY() - width: win._dockChromeWidth() - height: win._dockChromeHeight() - opacity: win._surfaceOpacity - layer.enabled: opacity < 1 - layer.smooth: false + id: _notifChrome + visible: _notifBodyBlurAnchor._active - Rectangle { - id: _dockFill - x: win._dockBodyXInChrome() - y: win._dockBodyYInChrome() - width: _dockBodyBlurAnchor.width + win._dockFillOverlapX() * 2 - height: _dockBodyBlurAnchor.height + win._dockFillOverlapY() * 2 - color: win._opaqueSurfaceColor - z: 1 + readonly property string _notifSide: win._notifState.barSide + readonly property bool _isHoriz: _notifSide === "top" || _notifSide === "bottom" + readonly property real _notifCcr: Theme.snap(Math.max(0, Math.min(win._ccr, win._surfaceRadius, (_isHoriz ? _notifBodyBlurAnchor.width : _notifBodyBlurAnchor.height) / 2)), win._dpr) + readonly property real _sideUnderlap: _isHoriz ? 0 : win._seamOverlap + readonly property real _bodyW: Theme.snap(_notifBodyBlurAnchor.width + _sideUnderlap, win._dpr) + readonly property real _bodyH: Theme.snap(_notifBodyBlurAnchor.height, win._dpr) - readonly property string _dockSide: win._dockState.barSide - readonly property real _dockRadius: win._dockBodyBlurRadius() - topLeftRadius: (_dockSide === "top" || _dockSide === "left") ? 0 : _dockRadius - topRightRadius: (_dockSide === "top" || _dockSide === "right") ? 0 : _dockRadius - bottomLeftRadius: (_dockSide === "bottom" || _dockSide === "left") ? 0 : _dockRadius - bottomRightRadius: (_dockSide === "bottom" || _dockSide === "right") ? 0 : _dockRadius - } + z: _isHoriz ? 0 : -1 + x: Theme.snap(_notifBodyBlurAnchor.x - (_isHoriz ? _notifCcr : (_notifSide === "left" ? _sideUnderlap : 0)), win._dpr) + y: Theme.snap(_notifBodyBlurAnchor.y - (_isHoriz ? 0 : _notifCcr), win._dpr) + width: _isHoriz ? Theme.snap(_bodyW + _notifCcr * 2, win._dpr) : _bodyW + height: Theme.snap(_bodyH + (_isHoriz ? 0 : _notifCcr * 2), win._dpr) - ConnectedCorner { - id: _connDockLeft - visible: _dockBodyBlurAnchor._active - barSide: win._dockState.barSide - placement: "left" - spacing: 0 - connectorRadius: win._dockConnectorRadius() - color: win._opaqueSurfaceColor - dpr: win._dpr - x: Theme.snap(win._dockConnectorX(_dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "left", 0) - _dockChrome.x, win._dpr) - y: Theme.snap(win._dockConnectorY(_dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "left", 0) - _dockChrome.y, win._dpr) - } - - ConnectedCorner { - id: _connDockRight - visible: _dockBodyBlurAnchor._active - barSide: win._dockState.barSide - placement: "right" - spacing: 0 - connectorRadius: win._dockConnectorRadius() - color: win._opaqueSurfaceColor - dpr: win._dpr - x: Theme.snap(win._dockConnectorX(_dockBodyBlurAnchor.x, _dockBodyBlurAnchor.width, "right", 0) - _dockChrome.x, win._dpr) - y: Theme.snap(win._dockConnectorY(_dockBodyBlurAnchor.y, _dockBodyBlurAnchor.height, "right", 0) - _dockChrome.y, win._dpr) + ConnectedShape { + visible: _notifBodyBlurAnchor._active && _notifBodyBlurAnchor.width > 0 && _notifBodyBlurAnchor.height > 0 + barSide: _notifChrome._notifSide + bodyWidth: _notifChrome._bodyW + bodyHeight: _notifChrome._bodyH + connectorRadius: _notifChrome._notifCcr + surfaceRadius: win._surfaceRadius + fillColor: win._opaqueSurfaceColor + x: 0 + y: 0 } } } diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index 83712555..979a5b50 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -10,13 +10,29 @@ import qs.Widgets PanelWindow { id: win + readonly property bool connectedFrameMode: SettingsData.frameEnabled + && Theme.isConnectedEffect + && SettingsData.isScreenInPreferences(win.screen, SettingsData.frameScreenPreferences) + readonly property string notifBarSide: { + const pos = SettingsData.notificationPopupPosition; + if (pos === -1) return "top"; + switch (pos) { + case SettingsData.Position.Top: return "right"; + case SettingsData.Position.Left: return "left"; + case SettingsData.Position.BottomCenter: return "bottom"; + case SettingsData.Position.Right: return "right"; + case SettingsData.Position.Bottom: return "left"; + default: return "top"; + } + } + 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: SettingsData.connectedFrameModeActive ? Theme.connectedSurfaceRadius : Theme.cornerRadius + blurWidth: !win._finalized && !win.connectedFrameMode ? Math.max(0, content.width - content.cardInset * 2) : 0 + blurHeight: !win._finalized && !win.connectedFrameMode ? Math.max(0, content.height - content.cardInset * 2) : 0 + blurRadius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius } WlrLayershell.namespace: "dms:notification-popup" @@ -84,6 +100,7 @@ PanelWindow { signal exitStarted signal exitFinished signal popupHeightChanged + signal popupChromeGeometryChanged function startExit() { if (exiting || _isDestroying) { @@ -91,6 +108,7 @@ PanelWindow { } exiting = true; exitStarted(); + popupChromeGeometryChanged(); exitAnim.restart(); exitWatchdog.restart(); if (NotificationService.removeFromVisibleNotifications) @@ -171,6 +189,7 @@ PanelWindow { duration: Theme.variantDuration(descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration, descriptionExpanded) easing.type: Easing.BezierSpline easing.bezierCurve: descriptionExpanded ? Theme.variantPopoutEnterCurve : Theme.variantPopoutExitCurve + onFinished: win.popupHeightChanged() } } @@ -263,12 +282,24 @@ PanelWindow { }); } + function _frameEdgeInset(side) { + if (!screen) + return 0; + const edges = SettingsData.getActiveBarEdgesForScreen(screen); + const raw = edges.includes(side) ? SettingsData.frameBarSize : SettingsData.frameThickness; + return Math.max(0, Math.round(Theme.px(raw, dpr))); + } + function getTopMargin() { const popupPos = SettingsData.notificationPopupPosition; const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left; if (!isTop) return 0; + if (connectedFrameMode) { + const cornerClear = isCenterPosition ? 0 : (Theme.px(SettingsData.frameRounding, dpr) + Theme.px(Theme.connectedCornerRadius, dpr)); + return _frameEdgeInset("top") + cornerClear + screenY; + } const barInfo = getBarInfo(); const base = barInfo.topBar > 0 ? barInfo.topBar : Theme.popupDistance; return base + screenY; @@ -280,6 +311,10 @@ PanelWindow { if (!isBottom) return 0; + if (connectedFrameMode) { + const cornerClear = isCenterPosition ? 0 : (Theme.px(SettingsData.frameRounding, dpr) + Theme.px(Theme.connectedCornerRadius, dpr)); + return _frameEdgeInset("bottom") + cornerClear + screenY; + } const barInfo = getBarInfo(); const base = barInfo.bottomBar > 0 ? barInfo.bottomBar : Theme.popupDistance; return base + screenY; @@ -294,6 +329,8 @@ PanelWindow { if (!isLeft) return 0; + if (connectedFrameMode) + return _frameEdgeInset("left"); const barInfo = getBarInfo(); return barInfo.leftBar > 0 ? barInfo.leftBar : Theme.popupDistance; } @@ -307,6 +344,8 @@ PanelWindow { if (!isRight) return 0; + if (connectedFrameMode) + return _frameEdgeInset("right"); const barInfo = getBarInfo(); return barInfo.rightBar > 0 ? barInfo.rightBar : Theme.popupDistance; } @@ -351,10 +390,64 @@ PanelWindow { return Theme.snap(getContentY() - windowShadowPad, dpr); } + function _swipeDismissTarget() { + return (content.swipeDismissDirection < 0 ? -1 : 1) * content.width; + } + + function _frameEdgeSwipeDirection() { + const popupPos = SettingsData.notificationPopupPosition; + return (popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom) ? -1 : 1; + } + + function _swipeDismissesTowardFrameEdge() { + return content.swipeDismissDirection === _frameEdgeSwipeDirection(); + } + + function popupChromeMotionActive() { + return exiting || content.swipeActive || content.swipeDismissing || Math.abs(content.swipeOffset) > 0.5; + } + + function popupLayoutReservesSlot() { + return !content.swipeDismissing; + } + + function popupChromeReservesSlot() { + return !content.swipeDismissing; + } + + function popupChromeReleaseProgress() { + if (content.swipeDismissing) + return Math.max(0, Math.min(1, Math.abs(content.swipeOffset) / Math.max(1, content.swipeTravelDistance))); + if (!exiting) + return 0; + const exitOffset = isCenterPosition ? tx.y : tx.x; + return Math.max(0, Math.min(1, Math.abs(exitOffset) / Math.max(1, exitTravel))); + } + + function popupChromeMotionX() { + if (!popupChromeMotionActive() || isCenterPosition) + return 0; + const motion = content.swipeOffset + tx.x; + if (content.swipeDismissing && !_swipeDismissesTowardFrameEdge()) + return exiting ? Theme.snap(tx.x, dpr) : 0; + if (content.swipeActive && motion * _frameEdgeSwipeDirection() < 0) + return 0; + return Theme.snap(motion, dpr); + } + + function popupChromeMotionY() { + return popupChromeMotionActive() ? Theme.snap(tx.y, dpr) : 0; + } + 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) + onScreenYChanged: popupChromeGeometryChanged() + onScreenChanged: popupChromeGeometryChanged() + onConnectedFrameModeChanged: popupChromeGeometryChanged() + onAlignedWidthChanged: popupChromeGeometryChanged() + onAlignedHeightChanged: popupChromeGeometryChanged() Item { id: content @@ -363,7 +456,7 @@ PanelWindow { y: Theme.snap(windowShadowPad, dpr) width: alignedWidth height: alignedHeight - visible: !win._finalized + visible: !win._finalized && !chromeOnlyExit scale: cardHoverHandler.hovered ? 1.01 : 1.0 transformOrigin: Item.Center @@ -375,13 +468,25 @@ PanelWindow { } property real swipeOffset: 0 - readonly property real dismissThreshold: isCenterPosition ? height * 0.4 : width * 0.35 + property real swipeDismissDirection: 1 + property bool chromeOnlyExit: false + readonly property real dismissThreshold: width * 0.35 readonly property real swipeFadeStartRatio: 0.75 - readonly property real swipeTravelDistance: isCenterPosition ? height : width + readonly property real swipeTravelDistance: 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 + onSwipeDismissingChanged: { + if (!win.connectedFrameMode) + return; + win.popupHeightChanged(); + win.popupChromeGeometryChanged(); + } + onSwipeOffsetChanged: { + if (win.connectedFrameMode) + win.popupChromeGeometryChanged(); + } readonly property bool shadowsAllowed: Theme.elevationEnabled && SettingsData.notificationPopupShadowEnabled readonly property var elevLevel: cardHoverHandler.hovered ? Theme.elevationLevel4 : Theme.elevationLevel3 @@ -422,7 +527,7 @@ PanelWindow { shadowOffsetX: content.shadowOffsetX shadowOffsetY: content.shadowOffsetY shadowColor: content.shadowsAllowed && content.elevLevel ? Theme.elevationShadowColor(content.elevLevel) : "transparent" - shadowEnabled: !win._isDestroying && win.screenValid && content.shadowsAllowed + shadowEnabled: !win._isDestroying && win.screenValid && content.shadowsAllowed && !win.connectedFrameMode layer.textureSize: Qt.size(Math.round(width * win.dpr), Math.round(height * win.dpr)) layer.textureMirroring: ShaderEffectSource.MirrorVertically @@ -431,8 +536,12 @@ PanelWindow { 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.radius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius + sourceRect.color: win.connectedFrameMode ? Theme.popupLayerColor(Theme.surfaceContainer) : Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + sourceRect.antialiasing: true + sourceRect.layer.enabled: win.connectedFrameMode + sourceRect.layer.smooth: true + sourceRect.layer.textureSize: win.connectedFrameMode && win.dpr > 1 ? Qt.size(Math.ceil(sourceRect.width * win.dpr), Math.ceil(sourceRect.height * win.dpr)) : Qt.size(0, 0) 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 @@ -470,10 +579,10 @@ PanelWindow { Rectangle { anchors.fill: parent anchors.margins: content.cardInset - radius: Theme.cornerRadius + radius: win.connectedFrameMode ? Theme.connectedSurfaceRadius : Theme.cornerRadius color: "transparent" - border.color: BlurService.borderColor - border.width: BlurService.borderWidth + border.color: win.connectedFrameMode ? "transparent" : BlurService.borderColor + border.width: win.connectedFrameMode ? 0 : BlurService.borderWidth z: 100 } @@ -873,14 +982,15 @@ PanelWindow { DragHandler { id: swipeDragHandler target: null - xAxis.enabled: !isCenterPosition - yAxis.enabled: isCenterPosition + xAxis.enabled: true + yAxis.enabled: false onActiveChanged: { if (active || win.exiting || content.swipeDismissing) return; if (Math.abs(content.swipeOffset) > content.dismissThreshold) { + content.swipeDismissDirection = content.swipeOffset < 0 ? -1 : 1; content.swipeDismissing = true; swipeDismissAnim.start(); } else { @@ -892,15 +1002,7 @@ PanelWindow { 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); - } + content.swipeOffset = translation.x; } } @@ -931,20 +1033,28 @@ PanelWindow { 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) + to: win._swipeDismissTarget() duration: Theme.notificationExitDuration easing.type: Easing.OutCubic onStopped: { - NotificationService.dismissNotification(notificationData); - win.forceExit(); + const inwardConnectedExit = win.connectedFrameMode && !win.isCenterPosition && !win._swipeDismissesTowardFrameEdge(); + if (inwardConnectedExit) + content.chromeOnlyExit = true; + if (win.connectedFrameMode && (win.isCenterPosition || inwardConnectedExit)) { + win.startExit(); + NotificationService.dismissNotification(notificationData); + } else { + NotificationService.dismissNotification(notificationData); + win.forceExit(); + } } } transform: [ Translate { id: swipeTx - x: isCenterPosition ? 0 : content.swipeOffset - y: isCenterPosition ? content.swipeOffset : 0 + x: content.swipeOffset + y: 0 }, Translate { id: tx @@ -955,6 +1065,14 @@ PanelWindow { return isLeft ? -entryTravel : entryTravel; } y: isTopCenter ? -entryTravel : isBottomCenter ? entryTravel : 0 + onXChanged: { + if (win.connectedFrameMode) + win.popupChromeGeometryChanged(); + } + onYChanged: { + if (win.connectedFrameMode) + win.popupChromeGeometryChanged(); + } } ] } diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml index 283f5edf..4a401bea 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml @@ -8,24 +8,40 @@ QtObject { property var modelData property int topMargin: 0 readonly property bool compactMode: SettingsData.notificationCompactMode - readonly property bool connectedFrameMode: SettingsData.connectedFrameModeActive + readonly property bool notificationConnectedMode: SettingsData.frameEnabled + && Theme.isConnectedEffect + && SettingsData.isScreenInPreferences(manager.modelData, SettingsData.frameScreenPreferences) + readonly property string notifBarSide: { + const pos = SettingsData.notificationPopupPosition; + if (pos === -1) return "top"; + switch (pos) { + case SettingsData.Position.Top: return "right"; + case SettingsData.Position.Left: return "left"; + case SettingsData.Position.BottomCenter: return "bottom"; + case SettingsData.Position.Right: return "right"; + case SettingsData.Position.Bottom: return "left"; + default: return "top"; + } + } 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: connectedFrameMode ? 0 : (compactMode ? 0 : Theme.spacingXS) + readonly property real popupSpacing: notificationConnectedMode ? 0 : (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 bool _chromeSyncPending: false property Component popupComponent popupComponent: Component { NotificationPopup { onExitFinished: manager._onPopupExitFinished(this) onPopupHeightChanged: manager._onPopupHeightChanged(this) + onPopupChromeGeometryChanged: manager._onPopupChromeGeometryChanged(this) } } @@ -109,6 +125,14 @@ QtObject { return p && p.status !== Component.Null && !p._isDestroying && p.hasValidData; } + function _layoutWindows() { + return popupWindows.filter(p => _isValidWindow(p) && p.notificationData?.popup && !p.exiting && (!p.popupLayoutReservesSlot || p.popupLayoutReservesSlot())); + } + + function _chromeWindows() { + return popupWindows.filter(p => p && p.status !== Component.Null && p.visible && !p._finalized && p.hasValidData && (p.notificationData?.popup || p.exiting)); + } + function _isFocusedScreen() { if (!SettingsData.notificationFocusedMonitor) return true; @@ -117,18 +141,24 @@ QtObject { } function _sync(newWrappers) { + let needsReposition = false; 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; + needsReposition = true; } } for (const w of newWrappers) { - if (w && !_hasWindowFor(w) && _isFocusedScreen()) + if (w && !_hasWindowFor(w) && _isFocusedScreen()) { _insertAtTop(w); + needsReposition = false; + } } + if (needsReposition) + _repositionAll(); } function _popupHeight(p) { @@ -158,7 +188,7 @@ QtObject { } function _repositionAll() { - const active = popupWindows.filter(p => _isValidWindow(p) && p.notificationData?.popup && !p.exiting); + const active = _layoutWindows(); const pinnedSlots = []; for (const p of active) { @@ -182,6 +212,168 @@ QtObject { win.screenY = currentY; currentY += _popupHeight(win); } + _scheduleNotificationChromeSync(); + } + + function _scheduleNotificationChromeSync() { + if (_chromeSyncPending) + return; + _chromeSyncPending = true; + Qt.callLater(() => { + _chromeSyncPending = false; + _syncNotificationChromeState(); + }); + } + + function _popupChromeRect(p, useMotionOffset) { + if (!p || !p.screen) + return null; + const motionX = useMotionOffset && p.popupChromeMotionX ? p.popupChromeMotionX() : 0; + const motionY = useMotionOffset && p.popupChromeMotionY ? p.popupChromeMotionY() : 0; + const x = (p.getContentX ? p.getContentX() : 0) + motionX; + const y = (p.getContentY ? p.getContentY() : 0) + motionY; + const w = p.alignedWidth || 0; + const h = Math.max(p.alignedHeight || 0, baseNotificationHeight); + if (w <= 0 || h <= 0) + return null; + return { + x: x, + y: y, + right: x + w, + bottom: y + h + }; + } + + function _popupChromeBoundsRect(p, trailing, useMotionOffset) { + const rect = _popupChromeRect(p, useMotionOffset); + if (!rect || p !== trailing || !p.popupChromeReleaseProgress) + return rect; + + const progress = Math.max(0, Math.min(1, p.popupChromeReleaseProgress())); + if (progress <= 0) + return rect; + + const anchorsTop = _stackAnchorsTop(); + const h = Math.max(0, rect.bottom - rect.y); + const shrink = h * progress; + if (anchorsTop) + rect.bottom = Math.max(rect.y, rect.bottom - shrink); + else + rect.y = Math.min(rect.bottom, rect.y + shrink); + return rect; + } + + function _stackAnchorsTop() { + const pos = SettingsData.notificationPopupPosition; + return pos === -1 || pos === SettingsData.Position.Top || pos === SettingsData.Position.Left; + } + + function _trailingChromeWindow(candidates) { + const anchorsTop = _stackAnchorsTop(); + let trailing = null; + let edge = anchorsTop ? -Infinity : Infinity; + for (const p of candidates) { + const rect = _popupChromeRect(p, false); + if (!rect) + continue; + const candidateEdge = anchorsTop ? rect.bottom : rect.y; + if ((anchorsTop && candidateEdge > edge) || (!anchorsTop && candidateEdge < edge)) { + edge = candidateEdge; + trailing = p; + } + } + return trailing; + } + + function _chromeWindowReservesSlot(p, trailing) { + if (p === trailing) + return true; + return !p.popupChromeReservesSlot || p.popupChromeReservesSlot(); + } + + function _stackAnchoredChromeEdge(candidates) { + const anchorsTop = _stackAnchorsTop(); + let edge = anchorsTop ? Infinity : -Infinity; + for (const p of candidates) { + const rect = _popupChromeRect(p, false); + if (!rect) + continue; + if (anchorsTop && rect.y < edge) + edge = rect.y; + if (!anchorsTop && rect.bottom > edge) + edge = rect.bottom; + } + if (edge === Infinity || edge === -Infinity) + return null; + return { + anchorsTop: anchorsTop, + edge: edge + }; + } + + function _syncNotificationChromeState() { + const screenName = manager.modelData?.name || ""; + if (!screenName) + return; + if (!notificationConnectedMode) { + ConnectedModeState.clearNotificationState(screenName); + return; + } + const chromeCandidates = _chromeWindows(); + if (chromeCandidates.length === 0) { + ConnectedModeState.clearNotificationState(screenName); + return; + } + const trailing = chromeCandidates.length > 1 ? _trailingChromeWindow(chromeCandidates) : null; + let active = chromeCandidates; + if (chromeCandidates.length > 1) { + const reserving = chromeCandidates.filter(p => _chromeWindowReservesSlot(p, trailing)); + if (reserving.length > 0) + active = reserving; + } + let minX = Infinity; + let minY = Infinity; + let maxXEnd = -Infinity; + let maxYEnd = -Infinity; + const useMotionOffset = active.length === 1 && active[0].popupChromeMotionActive && active[0].popupChromeMotionActive(); + for (const p of active) { + const rect = _popupChromeBoundsRect(p, trailing, useMotionOffset); + if (!rect) + continue; + if (rect.x < minX) + minX = rect.x; + if (rect.y < minY) + minY = rect.y; + if (rect.right > maxXEnd) + maxXEnd = rect.right; + if (rect.bottom > maxYEnd) + maxYEnd = rect.bottom; + } + const stackEdge = _stackAnchoredChromeEdge(chromeCandidates); + if (stackEdge !== null) { + if (stackEdge.anchorsTop && stackEdge.edge < minY) + minY = stackEdge.edge; + if (!stackEdge.anchorsTop && stackEdge.edge > maxYEnd) + maxYEnd = stackEdge.edge; + } + if (minX === Infinity || minY === Infinity || maxXEnd <= minX || maxYEnd <= minY) { + ConnectedModeState.clearNotificationState(screenName); + return; + } + ConnectedModeState.setNotificationState(screenName, { + visible: true, + barSide: notifBarSide, + bodyX: minX, + bodyY: minY, + bodyW: maxXEnd - minX, + bodyH: maxYEnd - minY + }); + } + + function _onPopupChromeGeometryChanged(p) { + if (!p || popupWindows.indexOf(p) === -1) + return; + _scheduleNotificationChromeSync(); } function _onPopupHeightChanged(p) { @@ -228,8 +420,15 @@ QtObject { } popupWindows = []; destroyingWindows.clear(); + _chromeSyncPending = false; + _syncNotificationChromeState(); } + onNotificationConnectedModeChanged: _scheduleNotificationChromeSync() + onNotifBarSideChanged: _scheduleNotificationChromeSync() + onModelDataChanged: _scheduleNotificationChromeSync() + onTopMarginChanged: _repositionAll() + onPopupWindowsChanged: { if (popupWindows.length > 0 && !sweeper.running) { sweeper.start(); diff --git a/quickshell/Widgets/ConnectedShape.qml b/quickshell/Widgets/ConnectedShape.qml index a9fb36d8..6a51ae4e 100644 --- a/quickshell/Widgets/ConnectedShape.qml +++ b/quickshell/Widgets/ConnectedShape.qml @@ -114,7 +114,7 @@ Item { } radiusX: root._cr radiusY: root._cr - direction: PathArc.Counterclockwise + direction: root.barSide === "bottom" ? PathArc.Clockwise : PathArc.Counterclockwise } // Body edge to first convex corner @@ -169,7 +169,7 @@ Item { } radiusX: root._sr radiusY: root._sr - direction: PathArc.Clockwise + direction: root.barSide === "bottom" ? PathArc.Counterclockwise : PathArc.Clockwise } // Far edge @@ -224,7 +224,7 @@ Item { } radiusX: root._sr radiusY: root._sr - direction: PathArc.Clockwise + direction: root.barSide === "bottom" ? PathArc.Counterclockwise : PathArc.Clockwise } // Body edge to second concave arc @@ -279,7 +279,7 @@ Item { } radiusX: root._cr radiusY: root._cr - direction: PathArc.Counterclockwise + direction: root.barSide === "bottom" ? PathArc.Clockwise : PathArc.Counterclockwise } } }