diff --git a/quickshell/Modals/NotificationModal.qml b/quickshell/Modals/NotificationModal.qml index 496c331fd..c9fdc64d2 100644 --- a/quickshell/Modals/NotificationModal.qml +++ b/quickshell/Modals/NotificationModal.qml @@ -224,7 +224,9 @@ DankModal { KeyboardNavigatedNotificationList { id: notificationList - width: parent.width + x: -swipeBleed + width: parent.width + swipeBleed * 2 + swipeBleed: Theme.spacingL height: parent.height - y visible: notificationHeader.currentTab === 0 keyboardController: modalKeyboardController @@ -243,6 +245,7 @@ DankModal { width: parent.width height: parent.height - y visible: notificationHeader.currentTab === 1 + swipeBleed: Theme.spacingL Component.onCompleted: notificationModal.historyListRef = historyList } } diff --git a/quickshell/Modules/DankDash/NotificationsTab.qml b/quickshell/Modules/DankDash/NotificationsTab.qml index 24811a0f7..af0afe2ec 100644 --- a/quickshell/Modules/DankDash/NotificationsTab.qml +++ b/quickshell/Modules/DankDash/NotificationsTab.qml @@ -14,6 +14,7 @@ FocusScope { property string entryId: "notifications" property int currentTab: 0 property var transientSurfaceTracker: null + property real swipeBleed: 0 readonly property var surfaces: transientSurfaceTracker ?? localSurfaces readonly property bool hasNotifications: list.count > 0 readonly property Item focusTarget: root @@ -85,7 +86,6 @@ FocusScope { implicitHeight: DashMetrics.tabMinHeight enabled: interactive - clip: true LayoutMirroring.enabled: I18n.isRtl LayoutMirroring.childrenInherit: true @@ -128,8 +128,11 @@ FocusScope { KeyboardNavigatedNotificationList { id: list anchors.fill: parent + anchors.leftMargin: -root.swipeBleed + anchors.rightMargin: -root.swipeBleed nested: root.nested anchors.bottomMargin: footer.height + Theme.spacingM + swipeBleed: root.swipeBleed visible: root.currentTab === 0 showScrollBar: false keyboardController: keyboard @@ -140,13 +143,15 @@ FocusScope { Loader { id: historyLoader - anchors.fill: list + anchors.fill: parent + anchors.bottomMargin: footer.height + Theme.spacingM active: root.currentTab === 1 visible: active sourceComponent: HistoryNotificationList { focusAllowed: root.activeFocus showScrollBar: false nested: root.nested + swipeBleed: root.swipeBleed } } diff --git a/quickshell/Modules/DankDash/Overview/NotificationsOverviewCard.qml b/quickshell/Modules/DankDash/Overview/NotificationsOverviewCard.qml index 44e6cf4e7..d8276a727 100644 --- a/quickshell/Modules/DankDash/Overview/NotificationsOverviewCard.qml +++ b/quickshell/Modules/DankDash/Overview/NotificationsOverviewCard.qml @@ -24,6 +24,7 @@ Card { live: root.live interactive: root.interactive nested: true + swipeBleed: root.pad transientSurfaceTracker: root.transientSurfaceTracker } } diff --git a/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml b/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml index 5e1699406..6d71fe8e4 100644 --- a/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml +++ b/quickshell/Modules/Notifications/Center/HistoryNotificationList.qml @@ -16,6 +16,7 @@ Item { property int selectedIndex: -1 property bool showKeyboardHints: false property bool nested: false + property real swipeBleed: 0 property alias showScrollBar: historyListView.showScrollBar function getStartOfDay(date) { @@ -237,9 +238,12 @@ Item { DankListView { id: historyListView - width: parent.width + x: -root.swipeBleed + width: parent.width + root.swipeBleed * 2 height: parent.height - filterChips.height - Theme.spacingS clip: true + leftMargin: root.swipeBleed + rightMargin: root.swipeBleed spacing: Theme.groupedListGap add: NotificationMetrics.animationsEnabled ? DC.ListViewTransitions.add : null @@ -257,90 +261,33 @@ Item { visible: historyListView.count === 0 } - delegate: Item { + NotificationSwipeGroup { + id: historySwipe + } + + delegate: NotificationSwipeRow { id: delegateRoot required property var modelData - required property int index - property real swipeOffset: 0 - property bool isDismissing: false - readonly property real dismissThreshold: width * NotificationMetrics.swipeThreshold - property bool __delegateInitialized: false - - Timer { - interval: 0 - running: true - onTriggered: delegateRoot.__delegateInitialized = true - } - - width: ListView.view.width + group: historySwipe + bleed: root.swipeBleed + width: ListView.view.width - root.swipeBleed * 2 height: historyCard.height - clip: true + onDismissed: root.removeWithScrollPreserve(modelData?.id || "") HistoryNotificationCard { id: historyCard width: parent.width - x: delegateRoot.swipeOffset + x: delegateRoot.offset historyItem: modelData nested: root.nested firstInGroup: index === 0 lastInGroup: index === historyListView.count - 1 + topRoundness: delegateRoot.topRoundness + bottomRoundness: delegateRoot.bottomRoundness + contentOpacity: delegateRoot.contentOpacity isSelected: root.keyboardActive && root.focusAllowed && root.selectedIndex === index keyboardNavigationActive: root.keyboardActive && root.focusAllowed - opacity: Math.max(0, 1 - Math.abs(delegateRoot.swipeOffset) / delegateRoot.width) - - Behavior on x { - enabled: !swipeDragHandler.active && !delegateRoot.isDismissing && delegateRoot.__delegateInitialized && NotificationMetrics.animationsEnabled - NumberAnimation { - duration: Theme.notificationExitDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: NotificationMetrics.dismissCurve - } - } - - Behavior on opacity { - enabled: delegateRoot.__delegateInitialized && NotificationMetrics.animationsEnabled - NumberAnimation { - duration: delegateRoot.__delegateInitialized ? Theme.notificationExitDuration : 0 - easing.type: Easing.BezierSpline - easing.bezierCurve: Theme.expressiveCurves.expressiveEffects - } - } - } - - NumberAnimation { - id: swipeDismissAnimation - target: delegateRoot - property: "swipeOffset" - to: delegateRoot.swipeOffset > 0 ? delegateRoot.width : -delegateRoot.width - duration: NotificationMetrics.animationsEnabled ? Theme.notificationExitDuration : 0 - easing.type: Easing.BezierSpline - easing.bezierCurve: NotificationMetrics.dismissCurve - onFinished: root.removeWithScrollPreserve(delegateRoot.modelData?.id || "") - } - - DragHandler { - id: swipeDragHandler - target: null - yAxis.enabled: false - xAxis.enabled: true - - onActiveChanged: { - if (active || delegateRoot.isDismissing) - return; - if (Math.abs(delegateRoot.swipeOffset) <= delegateRoot.dismissThreshold) { - delegateRoot.swipeOffset = 0; - return; - } - delegateRoot.isDismissing = true; - swipeDismissAnimation.restart(); - } - - onTranslationChanged: { - if (delegateRoot.isDismissing) - return; - delegateRoot.swipeOffset = translation.x; - } } } } diff --git a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml index 352ff3d9b..838c651b9 100644 --- a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml +++ b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml @@ -21,8 +21,7 @@ DankListView { property bool trackStableContentHeight: true property bool trackSessionContentHeight: false property bool listInitialized: false - property int swipingCardIndex: -1 - property real swipingCardOffset: 0 + property real swipeBleed: 0 property real sessionContentHeight: 0 property var transientSurfaceTracker: null property bool nested: false @@ -171,6 +170,8 @@ DankListView { } clip: true + leftMargin: swipeBleed + rightMargin: swipeBleed model: ScriptModel { values: NotificationService.groupedNotifications.map(group => group.key) } @@ -206,6 +207,10 @@ DankListView { visible: listView.count === 0 } + NotificationSwipeGroup { + id: swipeGroup + } + onCountChanged: listView.queueSessionContentHeightUpdate() onModelChanged: { @@ -216,21 +221,12 @@ DankListView { ensureVisibleTimer.restart(); } - delegate: Item { + delegate: NotificationSwipeRow { id: delegateRoot required property string modelData - required property int index readonly property var notificationGroup: NotificationService.groupedNotifications.find(group => group.key === modelData) readonly property bool isExpanded: NotificationService.expandedGroups[modelData] || false - property real swipeOffset: 0 - property bool isDismissing: false - readonly property real dismissThreshold: width * NotificationMetrics.swipeThreshold - - readonly property bool isAdjacentToSwipe: listView.count >= 2 && listView.swipingCardIndex !== -1 && (index === listView.swipingCardIndex - 1 || index === listView.swipingCardIndex + 1) - readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? listView.swipingCardOffset * NotificationMetrics.adjacentSwipeInfluence : 0 - readonly property real swipeFadeStartOffset: width * NotificationMetrics.swipeFadeStart - readonly property real swipeFadeDistance: Math.max(1, width - swipeFadeStartOffset) readonly property real nonAnimHeight: notificationCard.targetHeight readonly property bool isCardAnimating: notificationCard.isAnimating @@ -244,14 +240,20 @@ DankListView { } } - width: ListView.view.width + group: swipeGroup + bleed: listView.swipeBleed + width: ListView.view.width - listView.swipeBleed * 2 height: notificationCard.height - clip: true + onDismissed: NotificationService.dismissGroup(modelData) Center.NotificationCard { id: notificationCard width: parent.width - x: delegateRoot.swipeOffset + delegateRoot.adjacentSwipeInfluence + x: delegateRoot.offset + topRoundness: delegateRoot.topRoundness + bottomRoundness: delegateRoot.bottomRoundness + contentOpacity: delegateRoot.contentOpacity + swipeBleed: listView.swipeBleed notificationGroup: delegateRoot.notificationGroup nested: listView.nested firstInList: index === 0 @@ -259,13 +261,6 @@ DankListView { keyboardNavigationActive: listView.keyboardActive && listView.focusAllowed animateExpansion: listView.cardAnimateExpansion && listView.listInitialized transientSurfaceTracker: listView.transientSurfaceTracker - opacity: { - const swipeAmount = Math.abs(delegateRoot.swipeOffset); - if (swipeAmount <= delegateRoot.swipeFadeStartOffset) - return 1; - const fadeProgress = (swipeAmount - delegateRoot.swipeFadeStartOffset) / delegateRoot.swipeFadeDistance; - return Math.max(0, 1 - fadeProgress); - } onIsAnimatingChanged: { if (!listView.trackStableContentHeight) return; @@ -302,65 +297,6 @@ DankListView { const selection = keyboardController.getCurrentSelection(); return (selection.type === "notification" && selection.groupIndex === index) ? selection.notificationIndex : -1; } - - Behavior on x { - enabled: NotificationMetrics.animationsEnabled && !swipeDragHandler.active && !delegateRoot.isDismissing && (listView.swipingCardIndex === -1 || !delegateRoot.isAdjacentToSwipe) && listView.listInitialized - NumberAnimation { - duration: Theme.notificationExitDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: NotificationMetrics.heightCurve - } - } - - Behavior on opacity { - enabled: NotificationMetrics.animationsEnabled && listView.listInitialized - NumberAnimation { - duration: listView.listInitialized ? Theme.notificationExitDuration : 0 - } - } - } - - DragHandler { - id: swipeDragHandler - target: null - yAxis.enabled: false - xAxis.enabled: true - - onActiveChanged: { - if (active) { - listView.swipingCardIndex = index; - return; - } - listView.swipingCardIndex = -1; - listView.swipingCardOffset = 0; - if (delegateRoot.isDismissing) - return; - if (Math.abs(delegateRoot.swipeOffset) > delegateRoot.dismissThreshold) { - delegateRoot.isDismissing = true; - swipeDismissAnim.to = delegateRoot.swipeOffset > 0 ? delegateRoot.width : -delegateRoot.width; - swipeDismissAnim.start(); - } else { - delegateRoot.swipeOffset = 0; - } - } - - onTranslationChanged: { - if (delegateRoot.isDismissing) - return; - delegateRoot.swipeOffset = translation.x; - listView.swipingCardOffset = translation.x; - } - } - - NumberAnimation { - id: swipeDismissAnim - target: delegateRoot - property: "swipeOffset" - to: 0 - duration: NotificationMetrics.animationsEnabled ? Theme.notificationExitDuration : 0 - easing.type: Easing.BezierSpline - easing.bezierCurve: NotificationMetrics.dismissCurve - onFinished: NotificationService.dismissGroup(delegateRoot.modelData) } } diff --git a/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index 1b56baa2b..8657d1822 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCard.qml @@ -19,6 +19,10 @@ Item { property bool firstInList: true property bool lastInList: true property bool nested: false + property real topRoundness: 0 + property real bottomRoundness: 0 + property real contentOpacity: 1 + property real swipeBleed: 0 readonly property color cardSurfaceColor: Theme.foregroundColor(nested ? Theme.chipSurface : Theme.cardSurface, Theme.isFloatingWindow(root)) readonly property color cardChipColor: nested ? Theme.withAlpha(Theme.onSurface, Theme.stateLayerFocus) : Theme.chipSurface readonly property real expandedTargetHeight: { @@ -35,7 +39,7 @@ Item { width: parent ? parent.width : NotificationMetrics.popupWidth height: targetHeight - clip: true + clip: isAnimating function toggleGroup() { NotificationService.toggleGroupExpansion(notificationGroup?.key || ""); @@ -55,6 +59,10 @@ Item { Component.onDestruction: transientSurfaceTracker?.unregister(root) + NotificationSwipeGroup { + id: rowSwipe + } + Connections { target: root.transientSurfaceTracker ignoreUnknownSignals: true @@ -84,6 +92,9 @@ Item { descriptionExpanded: NotificationService.expandedMessages[(notificationData?.notification?.id || "") + "_desc"] || false firstInGroup: root.firstInList lastInGroup: root.lastInList + topRoundness: root.topRoundness + bottomRoundness: root.bottomRoundness + contentOpacity: root.contentOpacity keyboardSelected: root.keyboardNavigationActive && root.isGroupSelected keyboardHints: keyboardSelected animateHeight: false @@ -104,10 +115,9 @@ Item { Column { id: expandedContent objectName: "expandedContent" - property int swipingIndex: -1 - property real swipingOffset: 0 width: parent.width visible: root.expanded + opacity: root.contentOpacity spacing: Theme.groupedListGap Rectangle { @@ -165,30 +175,34 @@ Item { values: root.expanded ? (root.notificationGroup?.notifications?.slice(0, NotificationMetrics.expandedLimit) || []) : [] } - Item { + NotificationSwipeRow { id: row required property var modelData - required property int index - property real swipeOffset: 0 - property bool dismissing: false property bool collapsing: false property var notificationToDismiss: null readonly property real targetHeight: collapsing ? 0 : message.targetHeight - readonly property bool adjacentToSwipe: expandedContent.swipingIndex !== -1 && Math.abs(index - expandedContent.swipingIndex) === 1 - readonly property real adjacentSwipeInfluence: adjacentToSwipe ? expandedContent.swipingOffset * NotificationMetrics.adjacentSwipeInfluence : 0 + group: rowSwipe + bleed: root.swipeBleed width: expandedContent.width height: message.height - clip: true + clip: collapsing + onDismissed: { + notificationToDismiss = modelData; + collapseAnimation.start(); + } Notifications.NotificationCard { id: message surfaceColor: root.cardSurfaceColor chipColor: root.cardChipColor - x: row.swipeOffset + row.adjacentSwipeInfluence + x: row.offset width: parent.width notificationData: row.modelData firstInGroup: row.index === 0 lastInGroup: row.index === notificationRepeater.count - 1 + topRoundness: row.topRoundness + bottomRoundness: row.bottomRoundness + contentOpacity: row.contentOpacity keyboardSelected: root.keyboardNavigationActive && root.selectedNotificationIndex === row.index keyboardHints: keyboardSelected descriptionExpanded: NotificationService.expandedMessages[(notificationData?.notification?.id || "") + "_desc"] || false @@ -197,56 +211,11 @@ Item { onActionRequested: action => root.invokeAction(action) onBodyClicked: root.invokeAction(contextActions.defaultAction(notificationData)) onContextMenuRequested: (x, y) => root.openContextMenu(message, x, y) - Behavior on x { - enabled: !swipe.active && !row.dismissing && !row.adjacentToSwipe && NotificationMetrics.animationsEnabled - NumberAnimation { - duration: Theme.notificationExitDuration - easing.type: Easing.BezierSpline - easing.bezierCurve: NotificationMetrics.dismissCurve - } - } } - DragHandler { - id: swipe - target: null - yAxis.enabled: false - grabPermissions: PointerHandler.CanTakeOverFromItems | PointerHandler.CanTakeOverFromHandlersOfDifferentType - onTranslationChanged: { - if (row.dismissing) - return; - row.swipeOffset = translation.x; - expandedContent.swipingOffset = translation.x; - } - onActiveChanged: { - if (active) { - expandedContent.swipingIndex = row.index; - return; - } - expandedContent.swipingIndex = -1; - expandedContent.swipingOffset = 0; - if (row.dismissing) - return; - if (Math.abs(row.swipeOffset) <= row.width * NotificationMetrics.swipeThreshold) { - row.swipeOffset = 0; - return; - } - row.notificationToDismiss = row.modelData; - row.dismissing = true; - dismissAnimation.start(); - } - } SequentialAnimation { - id: dismissAnimation + id: collapseAnimation - NumberAnimation { - target: row - property: "swipeOffset" - to: row.swipeOffset > 0 ? row.width : -row.width - duration: NotificationMetrics.animationsEnabled ? Theme.notificationExitDuration : 0 - easing.type: Easing.BezierSpline - easing.bezierCurve: NotificationMetrics.dismissCurve - } PropertyAction { target: row property: "collapsing" diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterContent.qml b/quickshell/Modules/Notifications/Center/NotificationCenterContent.qml index 9f8581a72..d4d76b2a9 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterContent.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterContent.qml @@ -122,6 +122,9 @@ Item { objectName: "notificationList" anchors.fill: parent + anchors.leftMargin: -PopoutMetrics.contentPadding + anchors.rightMargin: -PopoutMetrics.contentPadding + swipeBleed: PopoutMetrics.contentPadding cardAnimateExpansion: root.host.animateCardExpansion ?? true trackStableContentHeight: !root.hostOwnsHeight trackSessionContentHeight: root.hostOwnsHeight @@ -135,6 +138,7 @@ Item { visible: notificationHeader.currentTab === 1 width: parent.width height: parent.height - root.cachedHeaderHeight - contentColumnInner.spacing + swipeBleed: PopoutMetrics.contentPadding } } } diff --git a/quickshell/Modules/Notifications/NotificationCard.qml b/quickshell/Modules/Notifications/NotificationCard.qml index eeda92732..be7906182 100644 --- a/quickshell/Modules/Notifications/NotificationCard.qml +++ b/quickshell/Modules/Notifications/NotificationCard.qml @@ -30,6 +30,9 @@ Rectangle { property real outerRadius: Theme.groupedListOuterRadius property bool firstInGroup: true property bool lastInGroup: true + property real topRoundness: 0 + property real bottomRoundness: 0 + property real contentOpacity: 1 readonly property bool hasMoreText: bodyText.truncated || summaryText.truncated readonly property bool hasBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0 readonly property string appIcon: NotificationService.notificationAppIcon(notificationData?.appIcon || "", notificationData?.desktopEntry || "") @@ -69,9 +72,9 @@ Rectangle { implicitHeight: targetHeight height: targetHeight radius: Theme.groupedListInnerRadius - topLeftRadius: firstInGroup ? outerRadius : radius + topLeftRadius: radius + (outerRadius - radius) * (firstInGroup ? 1 : topRoundness) topRightRadius: topLeftRadius - bottomLeftRadius: lastInGroup ? outerRadius : radius + bottomLeftRadius: radius + (outerRadius - radius) * (lastInGroup ? 1 : bottomRoundness) bottomRightRadius: bottomLeftRadius color: keyboardSelected ? Theme.selectedContainer : surfaceColor border.width: Theme.layerOutlineWidth @@ -127,6 +130,7 @@ Rectangle { height: parent.height - NotificationMetrics.cardPadding * 2 radius: Theme.fullRadius(width, height) color: Theme.primary + opacity: root.contentOpacity visible: root.notificationData?.urgency === NotificationUrgency.Critical } @@ -151,6 +155,7 @@ Rectangle { anchors.left: parent.left anchors.leftMargin: NotificationMetrics.cardPadding y: NotificationMetrics.cardPadding + opacity: root.contentOpacity Image { id: appImage @@ -184,6 +189,7 @@ Rectangle { anchors.rightMargin: NotificationMetrics.cardPadding y: NotificationMetrics.cardPadding spacing: NotificationMetrics.contentSpacing + opacity: root.contentOpacity Item { width: parent.width diff --git a/quickshell/Modules/Notifications/NotificationMetrics.qml b/quickshell/Modules/Notifications/NotificationMetrics.qml index d1933dc9f..4e82594c8 100644 --- a/quickshell/Modules/Notifications/NotificationMetrics.qml +++ b/quickshell/Modules/Notifications/NotificationMetrics.qml @@ -35,7 +35,15 @@ Singleton { readonly property real unreadDotSize: Theme.spacingXS + Theme.spacingXXS readonly property real swipeThreshold: 0.35 readonly property real swipeFadeStart: 0.75 - readonly property real adjacentSwipeInfluence: 0.1 + readonly property real swipeContentFadeEnd: 0.6 + readonly property real swipeDetachDistance: 72 + readonly property real swipeAttachDistance: 56 + readonly property var swipePull: [0.04, 0.12, 0.5, 0.12, 0.04] + readonly property var swipeRoundness: [0.5, 0.7, 0.9, 1, 0.9, 0.7, 0.5] + readonly property real swipePullRoundnessMax: 0.8 + readonly property var swipeDetachSpring: swipeSpring(800, 0.95) + readonly property var swipeSnapSpring: swipeSpring(550, 0.6) + readonly property var swipeAttachSpring: swipeSpring(850, 0.95) readonly property int expandedLimit: 10 readonly property int collapsedLines: compact ? 1 : 2 readonly property real summarySize: SettingsData.notificationSummaryFontSize || Theme.fontSizeMedium @@ -49,4 +57,11 @@ Singleton { readonly property var stackSpring: Theme.springPreset("default", Theme.notificationStackShiftDuration) readonly property var expandCurve: Theme.expressiveCurves.expressiveDefaultSpatial readonly property var dismissCurve: Theme.expressiveCurves.expressiveFastSpatial + + function swipeSpring(stiffness, dampingRatio) { + return { + "stiffness": stiffness, + "damping": 2 * dampingRatio * Math.sqrt(stiffness) + }; + } } diff --git a/quickshell/Modules/Notifications/NotificationSwipeGroup.qml b/quickshell/Modules/Notifications/NotificationSwipeGroup.qml new file mode 100644 index 000000000..d1237ed98 --- /dev/null +++ b/quickshell/Modules/Notifications/NotificationSwipeGroup.qml @@ -0,0 +1,34 @@ +import QtQuick + +QtObject { + property Item row: null + property real translation: 0 + property bool detached: false + + function begin(item) { + if (row) + return; + translation = 0; + detached = false; + row = item; + } + + function drag(item, value) { + if (row !== item) + return; + const distance = Math.abs(value); + if (!detached && distance >= NotificationMetrics.swipeDetachDistance) + detached = true; + else if (detached && distance <= NotificationMetrics.swipeAttachDistance) + detached = false; + translation = value; + } + + function end(item) { + if (row !== item) + return; + row = null; + detached = false; + translation = 0; + } +} diff --git a/quickshell/Modules/Notifications/NotificationSwipeRow.qml b/quickshell/Modules/Notifications/NotificationSwipeRow.qml new file mode 100644 index 000000000..f93a10cf0 --- /dev/null +++ b/quickshell/Modules/Notifications/NotificationSwipeRow.qml @@ -0,0 +1,145 @@ +import QtQuick +import qs.Common + +Item { + id: root + + required property int index + property NotificationSwipeGroup group: null + property real bleed: 0 + property bool dismissing: false + property real topRoundness: roundness(true) + property real bottomRoundness: roundness(false) + readonly property real offset: motion.value + readonly property real contentOpacity: 1 - Math.min(1, Math.abs(motion.value) / Math.max(1, width * NotificationMetrics.swipeContentFadeEnd)) + readonly property bool magnetActive: group !== null && group.row !== null + readonly property bool magnetDetached: group !== null && group.detached + readonly property real magnetTranslation: group ? group.translation : 0 + + signal dismissed + + function slot(values, distance) { + const i = distance + (values.length - 1) / 2; + return i >= 0 && i < values.length ? values[i] : 0; + } + + function roundness(top) { + if (dismissing) + return 1; + if (!group?.row) + return 0; + const distance = index - group.row.index; + if (group.detached) + return distance === 0 || distance === (top ? 1 : -1) ? 1 : 0; + const reach = (NotificationMetrics.swipeRoundness.length - 1) / 2; + if (distance === (top ? -reach : reach)) + return 0; + const pull = Math.abs(slot(NotificationMetrics.swipePull, 0) * group.translation) / NotificationMetrics.swipeDetachDistance; + return slot(NotificationMetrics.swipeRoundness, distance) * Math.min(NotificationMetrics.swipePullRoundnessMax, pull); + } + + function magnetTarget() { + if (!group?.row) + return 0; + const distance = index - group.row.index; + if (group.detached) + return distance === 0 ? group.translation : 0; + return slot(NotificationMetrics.swipePull, distance) * group.translation; + } + + function springTo(spring, target) { + if (dismissing) + return; + motion.stiffness = spring.stiffness; + motion.damping = spring.damping; + motion.retarget(target); + } + + function release() { + if (group?.row !== root) + return; + if (Math.abs(group.translation) > width * NotificationMetrics.swipeThreshold) { + dismissing = true; + motion.snapTo(motion.value); + dismissAnimation.to = (group.translation > 0 ? 1 : -1) * (width + bleed); + dismissAnimation.start(); + } + group.end(root); + } + + onMagnetTranslationChanged: { + if (dismissing) + return; + if (motion.running) { + motion.retarget(magnetTarget()); + return; + } + motion.snapTo(magnetTarget()); + } + + onMagnetDetachedChanged: { + if (!group?.row) + return; + if (!group.detached) { + springTo(NotificationMetrics.swipeAttachSpring, magnetTarget()); + return; + } + springTo(group.row === root ? NotificationMetrics.swipeDetachSpring : NotificationMetrics.swipeSnapSpring, magnetTarget()); + } + + onMagnetActiveChanged: { + if (magnetActive) + return; + springTo(NotificationMetrics.swipeSnapSpring, 0); + } + + Component.onDestruction: group?.end(root) + + Behavior on topRoundness { + enabled: NotificationMetrics.animationsEnabled + NumberAnimation { + duration: Theme.expressiveDurations.expressiveEffects + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + + Behavior on bottomRoundness { + enabled: NotificationMetrics.animationsEnabled + NumberAnimation { + duration: Theme.expressiveDurations.expressiveEffects + easing.type: Easing.BezierSpline + easing.bezierCurve: Theme.expressiveCurves.expressiveEffects + } + } + + SpringMotion { + id: motion + reducedMotion: !NotificationMetrics.animationsEnabled + } + + NumberAnimation { + id: dismissAnimation + target: motion + property: "value" + duration: NotificationMetrics.animationsEnabled ? Theme.notificationExitDuration : 0 + easing.type: Easing.BezierSpline + easing.bezierCurve: NotificationMetrics.dismissCurve + onFinished: root.dismissed() + } + + DragHandler { + target: null + yAxis.enabled: false + enabled: root.group !== null && !root.dismissing + grabPermissions: PointerHandler.CanTakeOverFromItems | PointerHandler.CanTakeOverFromHandlersOfDifferentType + onActiveChanged: { + if (active) { + root.group.begin(root); + return; + } + root.release(); + } + onTranslationChanged: root.group?.drag(root, translation.x) + } +}