diff --git a/Modules/Notification/Notification.qml b/Modules/Notification/Notification.qml index 1cff5ab3c..7c35b10af 100644 --- a/Modules/Notification/Notification.qml +++ b/Modules/Notification/Notification.qml @@ -211,16 +211,31 @@ Variants { property real scaleValue: 0.8 property real opacityValue: 0.0 property real slideOffset: 0 + property real swipeOffset: 0 + property real pressGlobalX: 0 + property bool isSwiping: false + property bool suppressClick: false + readonly property real swipeStartThreshold: Math.round(18 * Style.uiScaleRatio) + readonly property real swipeDismissThreshold: Math.max(110, cardBackground.width * 0.32) scale: scaleValue opacity: opacityValue transform: Translate { + x: card.swipeOffset y: card.slideOffset } readonly property real slideInOffset: notifWindow.isTop ? -slideDistance : slideDistance readonly property real slideOutOffset: slideInOffset + function clampSwipeDelta(deltaX) { + if (notifWindow.isRight) + return Math.max(0, deltaX); + if (notifWindow.isLeft) + return Math.min(0, deltaX); + return deltaX; + } + // Background with border Rectangle { id: cardBackground @@ -301,26 +316,69 @@ Variants { // Right-click to dismiss MouseArea { + id: cardDragArea anchors.fill: cardBackground acceptedButtons: Qt.LeftButton | Qt.RightButton hoverEnabled: true onEntered: card.hoverCount++ onExited: card.hoverCount-- - onClicked: mouse => { - if (mouse.button === Qt.RightButton) { - card.animateOut(); - } else if (mouse.button === Qt.LeftButton) { - var actions = model.actionsJson ? JSON.parse(model.actionsJson) : []; - var hasDefault = actions.some(function (a) { - return a.identifier === "default"; - }); - if (hasDefault) { - card.animateOut(); - deferredActionTimer.actionId = "default"; - deferredActionTimer.start(); - } + onPressed: mouse => { + if (mouse.button === Qt.LeftButton) { + const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y); + card.pressGlobalX = globalPoint.x; + card.isSwiping = false; + card.suppressClick = false; } } + onPositionChanged: mouse => { + if (!(mouse.buttons & Qt.LeftButton) || card.isRemoving) + return; + const globalPoint = cardDragArea.mapToGlobal(mouse.x, mouse.y); + const deltaX = card.clampSwipeDelta(globalPoint.x - card.pressGlobalX); + if (!card.isSwiping) { + if (Math.abs(deltaX) < card.swipeStartThreshold) + return; + card.isSwiping = true; + } + card.swipeOffset = deltaX; + } + onReleased: mouse => { + if (mouse.button === Qt.RightButton) { + card.animateOut(); + return; + } + + if (mouse.button !== Qt.LeftButton) + return; + + if (card.isSwiping) { + if (Math.abs(card.swipeOffset) >= card.swipeDismissThreshold) { + card.dismissBySwipe(); + } else { + card.swipeOffset = 0; + } + card.suppressClick = true; + card.isSwiping = false; + return; + } + + if (card.suppressClick) + return; + + var actions = model.actionsJson ? JSON.parse(model.actionsJson) : []; + var hasDefault = actions.some(function (a) { + return a.identifier === "default"; + }); + if (hasDefault) { + card.animateOut(); + deferredActionTimer.actionId = "default"; + deferredActionTimer.start(); + } + } + onCanceled: { + card.isSwiping = false; + card.swipeOffset = 0; + } } // Animation setup function triggerEntryAnimation() { @@ -329,6 +387,8 @@ Variants { resumeTimer.stop(); isRemoving = false; hoverCount = 0; + isSwiping = false; + swipeOffset = 0; if (Settings.data.general.animationDisabled) { slideOffset = 0; scaleValue = 1.0; @@ -366,6 +426,8 @@ Variants { animInDelayTimer.stop(); resumeTimer.stop(); isRemoving = true; + isSwiping = false; + swipeOffset = 0; if (!Settings.data.general.animationDisabled) { slideOffset = slideOutOffset; scaleValue = 0.8; @@ -373,6 +435,22 @@ Variants { } } + function dismissBySwipe() { + if (isRemoving) + return; + animInDelayTimer.stop(); + resumeTimer.stop(); + isRemoving = true; + isSwiping = false; + if (!Settings.data.general.animationDisabled) { + swipeOffset = swipeOffset >= 0 ? cardBackground.width + Style.marginXL : -cardBackground.width - Style.marginXL; + scaleValue = 0.8; + opacityValue = 0.0; + } else { + swipeOffset = 0; + } + } + Timer { id: removalTimer interval: Style.animationSlow @@ -430,6 +508,14 @@ Variants { } } + Behavior on swipeOffset { + enabled: !Settings.data.general.animationDisabled && !card.isSwiping + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + // Content ColumnLayout { id: notificationContent diff --git a/Modules/Panels/NotificationHistory/NotificationHistoryPanel.qml b/Modules/Panels/NotificationHistory/NotificationHistoryPanel.qml index 454219c03..2771ddd53 100644 --- a/Modules/Panels/NotificationHistory/NotificationHistoryPanel.qml +++ b/Modules/Panels/NotificationHistory/NotificationHistoryPanel.qml @@ -331,11 +331,80 @@ SmartPanel { id: notificationDelegate width: parent.width visible: panelContent.isInCurrentRange(model.timestamp) - height: visible ? contentColumn.height + Style.marginXL : 0 + height: visible && !isRemoving ? contentColumn.height + Style.marginXL : 0 property string notificationId: model.id property bool isExpanded: scrollView.expandedId === notificationId property bool canExpand: summaryText.truncated || bodyText.truncated + property real swipeOffset: 0 + property real pressGlobalX: 0 + property real pressGlobalY: 0 + property bool isSwiping: false + property bool suppressClick: false + property bool isRemoving: false + readonly property real swipeStartThreshold: Math.round(16 * Style.uiScaleRatio) + readonly property real swipeDismissThreshold: Math.max(110, width * 0.3) + readonly property int removeAnimationDuration: Style.animationNormal + + transform: Translate { + x: notificationDelegate.swipeOffset + } + + function dismissBySwipe() { + if (isRemoving) + return; + isRemoving = true; + isSwiping = false; + suppressClick = true; + + if (Settings.data.general.animationDisabled) { + NotificationService.removeFromHistory(notificationId); + return; + } + + swipeOffset = swipeOffset >= 0 ? width + Style.marginL : -width - Style.marginL; + opacity = 0; + removeTimer.restart(); + } + + Timer { + id: removeTimer + interval: notificationDelegate.removeAnimationDuration + repeat: false + onTriggered: NotificationService.removeFromHistory(notificationId) + } + + Behavior on swipeOffset { + enabled: !Settings.data.general.animationDisabled && !notificationDelegate.isSwiping + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + } + + Behavior on opacity { + enabled: !Settings.data.general.animationDisabled + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + } + + Behavior on height { + enabled: !Settings.data.general.animationDisabled + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + } + + Behavior on y { + enabled: !Settings.data.general.animationDisabled + NumberAnimation { + duration: notificationDelegate.removeAnimationDuration + easing.type: Easing.OutCubic + } + } // Parse actions safely property var actionsList: { @@ -363,19 +432,86 @@ SmartPanel { // Click to expand/collapse MouseArea { + id: historyInteractionArea anchors.fill: parent anchors.rightMargin: Style.baseWidgetSize - enabled: notificationDelegate.canExpand - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - if (scrollView.expandedId === notificationId) { - scrollView.expandedId = ""; - } else { - scrollView.expandedId = notificationId; - } + enabled: !notificationDelegate.isRemoving + cursorShape: notificationDelegate.canExpand ? Qt.PointingHandCursor : Qt.ArrowCursor + onPressed: mouse => { + if (mouse.button !== Qt.LeftButton) + return; + const globalPoint = historyInteractionArea.mapToGlobal(mouse.x, mouse.y); + notificationDelegate.pressGlobalX = globalPoint.x; + notificationDelegate.pressGlobalY = globalPoint.y; + notificationDelegate.isSwiping = false; + notificationDelegate.suppressClick = false; + } + onPositionChanged: mouse => { + if (!(mouse.buttons & Qt.LeftButton) || notificationDelegate.isRemoving) + return; + + const globalPoint = historyInteractionArea.mapToGlobal(mouse.x, mouse.y); + const deltaX = globalPoint.x - notificationDelegate.pressGlobalX; + const deltaY = globalPoint.y - notificationDelegate.pressGlobalY; + + if (!notificationDelegate.isSwiping) { + if (Math.abs(deltaX) < notificationDelegate.swipeStartThreshold) + return; + + // Only start a swipe-dismiss when horizontal movement is dominant. + if (Math.abs(deltaX) <= Math.abs(deltaY) * 1.15) { + notificationDelegate.suppressClick = true; + return; + } + notificationDelegate.isSwiping = true; + } + + notificationDelegate.swipeOffset = deltaX; + } + onReleased: mouse => { + if (mouse.button !== Qt.LeftButton) + return; + + if (notificationDelegate.isSwiping) { + if (Math.abs(notificationDelegate.swipeOffset) >= notificationDelegate.swipeDismissThreshold) { + notificationDelegate.dismissBySwipe(); + } else { + notificationDelegate.swipeOffset = 0; + } + notificationDelegate.suppressClick = true; + notificationDelegate.isSwiping = false; + return; + } + + if (!notificationDelegate.canExpand || notificationDelegate.suppressClick) + return; + + if (scrollView.expandedId === notificationId) { + scrollView.expandedId = ""; + } else { + scrollView.expandedId = notificationId; + } + } + onCanceled: { + notificationDelegate.isSwiping = false; + notificationDelegate.swipeOffset = 0; + notificationDelegate.suppressClick = true; } } + onVisibleChanged: { + if (!visible) { + notificationDelegate.isSwiping = false; + notificationDelegate.suppressClick = false; + notificationDelegate.swipeOffset = 0; + notificationDelegate.opacity = 1; + notificationDelegate.isRemoving = false; + removeTimer.stop(); + } + } + + Component.onDestruction: removeTimer.stop() + Column { id: contentColumn anchors.left: parent.left diff --git a/Modules/Toast/Toast.qml b/Modules/Toast/Toast.qml index a18c3dd41..375e9fc7d 100644 --- a/Modules/Toast/Toast.qml +++ b/Modules/Toast/Toast.qml @@ -30,6 +30,26 @@ Item { property real progress: 1.0 property int hoverCount: 0 + property real swipeOffset: 0 + property real pressGlobalX: 0 + property bool isSwiping: false + readonly property string location: Settings.data.notifications?.location || "top_right" + readonly property bool isLeft: location.endsWith("_left") + readonly property bool isRight: location.endsWith("_right") + readonly property real swipeStartThreshold: Math.round(18 * Style.uiScaleRatio) + readonly property real swipeDismissThreshold: Math.max(110, background.width * 0.32) + + transform: Translate { + x: root.swipeOffset + } + + function clampSwipeDelta(deltaX) { + if (isRight) + return Math.max(0, deltaX); + if (isLeft) + return Math.min(0, deltaX); + return deltaX; + } onHoverCountChanged: { if (hoverCount > 0) { @@ -148,6 +168,14 @@ Item { } } + Behavior on swipeOffset { + enabled: !root.isSwiping + NumberAnimation { + duration: Style.animationFast + easing.type: Easing.OutCubic + } + } + Timer { id: hideAnimation interval: Style.animationFast @@ -165,6 +193,7 @@ Item { // Click anywhere dismiss the toast (must be before content so action link can override) MouseArea { + id: toastDragArea anchors.fill: background acceptedButtons: Qt.LeftButton hoverEnabled: true @@ -174,7 +203,41 @@ Item { onExited: { root.hoverCount--; } - onClicked: root.hide() + onPressed: mouse => { + const globalPoint = toastDragArea.mapToGlobal(mouse.x, mouse.y); + root.pressGlobalX = globalPoint.x; + root.isSwiping = false; + } + onPositionChanged: mouse => { + if (!(mouse.buttons & Qt.LeftButton)) + return; + const globalPoint = toastDragArea.mapToGlobal(mouse.x, mouse.y); + const deltaX = root.clampSwipeDelta(globalPoint.x - root.pressGlobalX); + if (!root.isSwiping) { + if (Math.abs(deltaX) < root.swipeStartThreshold) + return; + root.isSwiping = true; + } + root.swipeOffset = deltaX; + } + onReleased: mouse => { + if (mouse.button !== Qt.LeftButton) + return; + if (root.isSwiping) { + root.isSwiping = false; + if (Math.abs(root.swipeOffset) >= root.swipeDismissThreshold) { + root.hide(); + } else { + root.swipeOffset = 0; + } + return; + } + root.hide(); + } + onCanceled: { + root.isSwiping = false; + root.swipeOffset = 0; + } cursorShape: Qt.PointingHandCursor } @@ -280,6 +343,8 @@ Item { scale = 1.0; progress = 1.0; hoverCount = 0; + isSwiping = false; + swipeOffset = 0; // Configure and start animation progressAnimation.duration = duration; @@ -290,6 +355,8 @@ Item { function hide() { progressAnimation.stop(); + isSwiping = false; + swipeOffset = 0; opacity = 0; scale = initialScale; hideAnimation.restart(); @@ -298,6 +365,8 @@ Item { function hideImmediately() { hideAnimation.stop(); progressAnimation.stop(); + isSwiping = false; + swipeOffset = 0; opacity = 0; scale = initialScale; root.hidden();