diff --git a/quickshell/Modules/Notifications/NotificationMetrics.qml b/quickshell/Modules/Notifications/NotificationMetrics.qml index 4e82594c8..e1a8d73a3 100644 --- a/quickshell/Modules/Notifications/NotificationMetrics.qml +++ b/quickshell/Modules/Notifications/NotificationMetrics.qml @@ -44,6 +44,9 @@ Singleton { 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 real swipeWheelEngageDistance: 12 + readonly property int swipeWheelSettleMs: 500 + readonly property int swipeFlingProjectionMs: 200 readonly property int expandedLimit: 10 readonly property int collapsedLines: compact ? 1 : 2 readonly property real summarySize: SettingsData.notificationSummaryFontSize || Theme.fontSizeMedium diff --git a/quickshell/Modules/Notifications/NotificationSwipeRow.qml b/quickshell/Modules/Notifications/NotificationSwipeRow.qml index f93a10cf0..02dec597d 100644 --- a/quickshell/Modules/Notifications/NotificationSwipeRow.qml +++ b/quickshell/Modules/Notifications/NotificationSwipeRow.qml @@ -55,13 +55,14 @@ Item { motion.retarget(target); } - function release() { + function release(velocity = 0) { if (group?.row !== root) return; - if (Math.abs(group.translation) > width * NotificationMetrics.swipeThreshold) { + const projected = group.translation + velocity * NotificationMetrics.swipeFlingProjectionMs / 1000; + if (Math.abs(projected) > width * NotificationMetrics.swipeThreshold) { dismissing = true; motion.snapTo(motion.value); - dismissAnimation.to = (group.translation > 0 ? 1 : -1) * (width + bleed); + dismissAnimation.to = (projected > 0 ? 1 : -1) * (width + bleed); dismissAnimation.start(); } group.end(root); @@ -142,4 +143,11 @@ Item { } onTranslationChanged: root.group?.drag(root, translation.x) } + + NotificationSwipeWheel { + enabled: root.group !== null && !root.dismissing + onBegan: root.group.begin(root) + onMoved: travel => root.group?.drag(root, travel) + onEnded: velocity => root.release(velocity) + } } diff --git a/quickshell/Modules/Notifications/NotificationSwipeWheel.qml b/quickshell/Modules/Notifications/NotificationSwipeWheel.qml new file mode 100644 index 000000000..7624db3f7 --- /dev/null +++ b/quickshell/Modules/Notifications/NotificationSwipeWheel.qml @@ -0,0 +1,77 @@ +import QtQuick +import "../../DCommon/Common/WheelInput.js" as WheelInput +import "../../DCommon/Widgets/ScrollConstants.js" as Scroll + +WheelHandler { + id: handler + + property real travel: 0 + property real drift: 0 + property bool engaged: false + property real lastTime: 0 + property var samples: [] + readonly property Timer settle: Timer { + interval: NotificationMetrics.swipeWheelSettleMs + onTriggered: handler.finish() + } + + signal began + signal moved(real travel) + signal ended(real velocity) + + acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad + orientation: Qt.Horizontal + blocking: false + target: null + + function velocity() { + if (samples.length < 2) + return 0; + const span = samples[samples.length - 1].time - samples[0].time; + if (span <= 0) + return 0; + const total = samples.reduce((sum, s) => sum + s.delta, 0); + return Math.max(-Scroll.maxMomentumVelocity, Math.min(Scroll.maxMomentumVelocity, total / span * 1000)); + } + + function finish() { + settle.stop(); + const lifted = Date.now() - lastTime < Scroll.velocitySampleWindowMs; + const fling = lifted ? velocity() : 0; + travel = 0; + drift = 0; + samples = []; + if (!engaged) + return; + engaged = false; + ended(fling); + } + + onWheel: event => { + if (!WheelInput.isTouchpad(event)) + return; + const now = Date.now(); + // Qt Wayland negates finger deltas; natural scrolling flips them back and reports inverted + const delta = (event.inverted ? event.pixelDelta.x : -event.pixelDelta.x) * Scroll.touchpadSpeed; + travel += delta; + drift += event.pixelDelta.y * Scroll.touchpadSpeed; + samples = samples.filter(s => now - s.time < Scroll.velocitySampleWindowMs).concat({ + "delta": delta, + "time": now + }); + lastTime = now; + settle.restart(); + if (!engaged) { + if (Math.abs(travel) < NotificationMetrics.swipeWheelEngageDistance || Math.abs(travel) <= Math.abs(drift)) + return; + engaged = true; + began(); + } + moved(travel); + } + + onActiveChanged: { + if (!active) + finish(); + } +} diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index 8b4b3ffb4..d82a061f3 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -589,7 +589,8 @@ PanelWindow { 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 swipeWheelActive: false + readonly property bool swipeActive: swipeDragHandler.active || swipeWheelActive property bool swipeDismissing: false onSwipeDismissingChanged: { if (!win.connectedFrameMode) @@ -602,6 +603,19 @@ PanelWindow { win.popupChromeGeometryChanged(); } + function releaseSwipe(velocity = 0) { + if (win.exiting || swipeDismissing) + return; + const projected = swipeOffset + velocity * NotificationMetrics.swipeFlingProjectionMs / 1000; + if (Math.abs(projected) <= dismissThreshold) { + swipeOffset = 0; + return; + } + swipeDismissDirection = projected < 0 ? -1 : 1; + swipeDismissing = true; + swipeDismissAnim.start(); + } + readonly property bool shadowsAllowed: win.popupWindowShadowActive readonly property var elevLevel: Theme.elevationLevel2 readonly property real cardInset: Theme.snap(Theme.spacingXS, win.dpr) @@ -817,16 +831,9 @@ PanelWindow { yAxis.enabled: false onActiveChanged: { - if (active || win.exiting || content.swipeDismissing) + if (active) return; - - if (Math.abs(content.swipeOffset) > content.dismissThreshold) { - content.swipeDismissDirection = content.swipeOffset < 0 ? -1 : 1; - content.swipeDismissing = true; - swipeDismissAnim.start(); - } else { - content.swipeOffset = 0; - } + content.releaseSwipe(); } onTranslationChanged: { @@ -893,6 +900,27 @@ PanelWindow { ] } + // Sits on the untranslated card footprint: inside content the handler would lose the pointer once the card slides past it, + // and above content so it sees the gesture before DFlickable's blocking vertical handler + Item { + parent: slideClip + x: content.x + y: content.y + width: content.width + height: content.height + visible: content.visible + + NotificationSwipeWheel { + enabled: !win.exiting && !content.swipeDismissing + onBegan: content.swipeWheelActive = true + onMoved: travel => content.swipeOffset = travel + onEnded: velocity => { + content.swipeWheelActive = false; + content.releaseSwipe(velocity); + } + } + } + DCommon.DAnim { id: enterAnimation target: win