diff --git a/.config/quickshell/modules/common/functions/color_utils.js b/.config/quickshell/modules/common/functions/color_utils.js index 652b09ebb..c0ccfda9d 100644 --- a/.config/quickshell/modules/common/functions/color_utils.js +++ b/.config/quickshell/modules/common/functions/color_utils.js @@ -79,7 +79,7 @@ function mix(color1, color2, percentage) { * @param {number} percentage - The amount to transparentize (0-1). * @returns {Qt.rgba} The resulting color. */ -function transparentize(color, percentage) { +function transparentize(color, percentage = 1) { var c = Qt.color(color); return Qt.rgba(c.r, c.g, c.b, c.a * (1 - percentage)); } diff --git a/.config/quickshell/modules/common/widgets/DragManager.qml b/.config/quickshell/modules/common/widgets/DragManager.qml index 109f18254..8876c18f2 100644 --- a/.config/quickshell/modules/common/widgets/DragManager.qml +++ b/.config/quickshell/modules/common/widgets/DragManager.qml @@ -30,7 +30,9 @@ MouseArea { // Flick to dismiss onPressed: (mouse) => { if (!root.interactive) { - mouse.accepted = false; + if (mouse.button === Qt.LeftButton) { + mouse.accepted = false; + } return; } if (mouse.button === Qt.LeftButton) { @@ -40,7 +42,6 @@ MouseArea { // Flick to dismiss } onReleased: (mouse) => { if (!root.interactive) { - mouse.accepted = false; return; } dragging = false @@ -51,7 +52,6 @@ MouseArea { // Flick to dismiss } onPositionChanged: (mouse) => { if (!root.interactive) { - mouse.accepted = false; return; } if (mouse.buttons & Qt.LeftButton) { @@ -64,7 +64,6 @@ MouseArea { // Flick to dismiss } onCanceled: (mouse) => { if (!root.interactive) { - mouse.accepted = false; return; } released(mouse); diff --git a/.config/quickshell/modules/common/widgets/NotificationAppIcon.qml b/.config/quickshell/modules/common/widgets/NotificationAppIcon.qml new file mode 100644 index 000000000..5a218d8f9 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/NotificationAppIcon.qml @@ -0,0 +1,104 @@ +import "root:/modules/common" +import "./notification_utils.js" as NotificationUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications + +Rectangle { // App icon + id: root + property var appIcon: "" + property var summary: "" + property var urgency: NotificationUrgency.Normal + property var image: "" + property real size: 45 + property real materialIconScale: 0.57 + property real appIconScale: 0.7 + property real smallAppIconScale: 0.49 + property real materialIconSize: size * materialIconScale + property real appIconSize: size * appIconScale + property real smallAppIconSize: size * smallAppIconScale + + implicitWidth: size + implicitHeight: size + radius: Appearance.rounding.full + color: Appearance.m3colors.m3secondaryContainer + Loader { + id: materialSymbolLoader + active: root.appIcon == "" + anchors.fill: parent + sourceComponent: MaterialSymbol { + text: { + const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("") + const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(root.summary) + return (root.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ? + "release_alert" : guessedIcon + } + anchors.fill: parent + color: (root.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) : + Appearance.m3colors.m3onSecondaryContainer + iconSize: root.materialIconSize + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + Loader { + id: appIconLoader + active: root.image == "" && root.appIcon != "" + anchors.centerIn: parent + sourceComponent: IconImage { + id: appIconImage + implicitSize: root.appIconSize + asynchronous: true + source: Quickshell.iconPath(root.appIcon, "image-missing") + } + } + Loader { + id: notifImageLoader + active: root.image != "" + anchors.fill: parent + sourceComponent: Item { + anchors.fill: parent + Image { + id: notifImage + anchors.fill: parent + readonly property int size: parent.width + + source: root.image + fillMode: Image.PreserveAspectCrop + cache: false + antialiasing: true + asynchronous: true + + width: size + height: size + sourceSize.width: size + sourceSize.height: size + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: notifImage.size + height: notifImage.size + radius: Appearance.rounding.full + } + } + } + Loader { + id: notifImageAppIconLoader + active: root.appIcon != "" + anchors.bottom: parent.bottom + anchors.right: parent.right + sourceComponent: IconImage { + implicitSize: root.smallAppIconSize + asynchronous: true + source: Quickshell.iconPath(root.appIcon, "image-missing") + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/NotificationGroup.qml b/.config/quickshell/modules/common/widgets/NotificationGroup.qml new file mode 100644 index 000000000..7b1381dd3 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/NotificationGroup.qml @@ -0,0 +1,232 @@ +import "root:/modules/common" +import "root:/services" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import "./notification_utils.js" as NotificationUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland +import Quickshell.Services.Notifications + +/** + * A group of notifications from the same app. + * Similar to Android's notifications + */ +Item { // Notification group area + id: root + property var notificationGroup + property var notifications: notificationGroup.notifications + property int notificationCount: notifications.length + property bool multipleNotifications: notificationCount > 1 + property bool expanded: false + property bool popup: false + property real padding: 10 + implicitHeight: background.implicitHeight + + property real dragConfirmThreshold: 70 // Drag further to discard notification + property real dismissOvershoot: 20 // Account for gaps and bouncy animations + property var qmlParent: root.parent.parent // There's something between this and the parent ListView + property var parentDragIndex: qmlParent.dragIndex + property var parentDragDistance: qmlParent.dragDistance + property var dragIndexDiff: Math.abs(parentDragIndex - index) + property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0 + + function destroyWithAnimation() { + root.qmlParent.resetDrag() + background.anchors.leftMargin = background.anchors.leftMargin; // Break binding + destroyAnimation.running = true; + } + + SequentialAnimation { // Drag finish animation + id: destroyAnimation + running: false + + NumberAnimation { + target: background.anchors + property: "leftMargin" + to: root.width + root.dismissOvershoot + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + onFinished: () => { + root.notifications.forEach((notif) => { + Notifications.discardNotification(notif.id); + }); + } + } + + function toggleExpanded() { + if (expanded) implicitHeightAnim.enabled = true; + else implicitHeightAnim.enabled = false; + root.expanded = !root.expanded; + } + + DragManager { // Drag manager + id: dragManager + anchors.fill: parent + interactive: !expanded + automaticallyReset: false + acceptedButtons: Qt.LeftButton | Qt.RightButton + + onClicked: (mouse) => { + if (mouse.button === Qt.RightButton) { + root.toggleExpanded(); + } + } + + onDraggingChanged: () => { + if (dragging) { + root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root); + } + } + + onDragDiffXChanged: () => { + root.qmlParent.dragDistance = dragDiffX; + } + + onDragReleased: (diffX, diffY) => { + if (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + else + dragManager.resetDrag(); + } + } + + + + Rectangle { // Background of the notification + id: background + anchors.left: parent.left + width: parent.width + color: Appearance.m3colors.m3surfaceContainer + radius: Appearance.rounding.normal + anchors.leftMargin: root.xOffset + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + + clip: true + implicitHeight: expanded ? + row.implicitHeight + padding * 2 : + Math.min(80, row.implicitHeight + padding * 2) + + Behavior on implicitHeight { + id: implicitHeightAnim + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + layer.enabled: true + layer.effect: MultiEffect { + source: background + anchors.fill: background + shadowEnabled: popup + shadowColor: Appearance.colors.colShadow + shadowVerticalOffset: 1 + shadowBlur: 0.5 + } + + RowLayout { // Left column for icon, right column for content + id: row + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: root.padding + spacing: 10 + + NotificationAppIcon { // Icons + Layout.alignment: Qt.AlignTop + Layout.fillWidth: false + + appIcon: notificationGroup.appIcon + summary: notificationGroup.notifications[root.notificationCount - 1].summary + } + + ColumnLayout { // Content + Layout.fillWidth: true + spacing: expanded ? 5 : 0 + Behavior on spacing { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { // App name (or summary when there's only 1 notif) and time + id: topRow + spacing: 0 + property real fontSize: Appearance.font.pixelSize.smaller + property bool showAppName: root.multipleNotifications + + StyledText { + id: appName + text: topRow.showAppName ? + notificationGroup.appName : + notificationGroup.notifications[0].summary + font.pixelSize: topRow.showAppName ? + topRow.fontSize : + Appearance.font.pixelSize.small + color: topRow.showAppName ? + Appearance.colors.colSubtext : + Appearance.colors.colOnLayer2 + } + StyledText { + id: timeText + text: " • " + NotificationUtils.getFriendlyNotifTimeString(notificationGroup.time) + font.pixelSize: topRow.fontSize + color: Appearance.colors.colSubtext + Layout.alignment: Qt.AlignRight + Layout.fillWidth: true + } + Item { Layout.fillWidth: true } + NotificationGroupExpandButton { + count: root.notificationCount + expanded: root.expanded + fontSize: topRow.fontSize + onClicked: { root.toggleExpanded() } + } + } + + StyledListView { // Notification body (expanded) + id: notificationsColumn + implicitHeight: contentHeight + Layout.fillWidth: true + spacing: expanded ? 5 : 3 + // clip: true + interactive: false + Behavior on spacing { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + model: ScriptModel { + // values: root.expanded ? root.notifications : root.notifications.slice(0, 2) + values: root.notifications.slice().reverse() + } + delegate: NotificationItem { + required property int index + required property var modelData + notificationObject: modelData + expanded: root.expanded + onlyNotification: (root.notificationCount === 1) + opacity: (!root.expanded && index == 1 && root.notificationCount > 2) ? 0.5 : 1 + visible: root.expanded || (index < 2) + anchors.left: parent?.left + anchors.right: parent?.right + } + } + + } + } + } +} diff --git a/.config/quickshell/modules/common/widgets/NotificationGroupExpandButton.qml b/.config/quickshell/modules/common/widgets/NotificationGroupExpandButton.qml new file mode 100644 index 000000000..5de02a98e --- /dev/null +++ b/.config/quickshell/modules/common/widgets/NotificationGroupExpandButton.qml @@ -0,0 +1,51 @@ +import "root:/modules/common" +import "root:/services" +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Services.Notifications + +RippleButton { // Expand button + id: root + required property int count + required property bool expanded + property real fontSize: Appearance.font.pixelSize.small + implicitHeight: fontSize + 4 * 2 + implicitWidth: Math.max(contentItem.implicitWidth + 5 * 2, 30) + Layout.alignment: Qt.AlignVCenter + Layout.fillHeight: false + + buttonRadius: Appearance.rounding.full + colBackground: ColorUtils.mix(Appearance.colors.colLayer2, Appearance.colors.colLayer2Hover, 0.5) + colBackgroundHover: Appearance.colors.colLayer2Hover + colRipple: Appearance.colors.colLayer2Active + + contentItem: Item { + anchors.centerIn: parent + implicitWidth: contentRow.implicitWidth + RowLayout { + id: contentRow + anchors.centerIn: parent + spacing: 3 + StyledText { + Layout.leftMargin: 4 + visible: root.count > 1 + text: root.count + font.pixelSize: root.fontSize + } + MaterialSymbol { + text: "keyboard_arrow_down" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.colors.colOnLayer2 + rotation: expanded ? 180 : 0 + Behavior on rotation { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + } + } + } + +} diff --git a/.config/quickshell/modules/common/widgets/NotificationItem.qml b/.config/quickshell/modules/common/widgets/NotificationItem.qml new file mode 100644 index 000000000..ada5a95ff --- /dev/null +++ b/.config/quickshell/modules/common/widgets/NotificationItem.qml @@ -0,0 +1,268 @@ +import "root:/modules/common" +import "root:/services" +import "root:/modules/common/functions/string_utils.js" as StringUtils +import "root:/modules/common/functions/color_utils.js" as ColorUtils +import "./notification_utils.js" as NotificationUtils +import Qt5Compat.GraphicalEffects +import QtQuick +import QtQuick.Controls +import QtQuick.Effects +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Widgets +import Quickshell.Hyprland +import Quickshell.Services.Notifications + +Item { // Notification item area + id: root + property var notificationObject + property bool expanded: false + property bool onlyNotification: false + property real fontSize: Appearance.font.pixelSize.small + property real padding: 8 + + property real dragConfirmThreshold: 70 // Drag further to discard notification + property real dismissOvershoot: 20 // Account for gaps and bouncy animations + property var qmlParent: root?.parent?.parent // There's something between this and the parent ListView + property var parentDragIndex: qmlParent?.dragIndex ?? -1 + property var parentDragDistance: qmlParent?.dragDistance ?? 0 + property var dragIndexDiff: Math.abs(parentDragIndex - index) + property real xOffset: dragIndexDiff == 0 ? Math.max(0, parentDragDistance) : + parentDragDistance > dragConfirmThreshold ? 0 : + dragIndexDiff == 1 ? Math.max(0, parentDragDistance * 0.3) : + dragIndexDiff == 2 ? Math.max(0, parentDragDistance * 0.1) : 0 + + implicitHeight: background.implicitHeight + + function destroyWithAnimation() { + root.qmlParent.resetDrag() + background.anchors.leftMargin = background.anchors.leftMargin; // Break binding + destroyAnimation.running = true; + } + + SequentialAnimation { // Drag finish animation + id: destroyAnimation + running: false + + NumberAnimation { + target: background.anchors + property: "leftMargin" + to: root.width + root.dismissOvershoot + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animation.elementMove.bezierCurve + } + onFinished: () => { + Notifications.discardNotification(notificationObject.id); + } + } + + DragManager { // Drag manager + id: dragManager + anchors.fill: parent + interactive: expanded + automaticallyReset: false + acceptedButtons: Qt.LeftButton + + onPressAndHold: (mouse) => { + if (mouse.button === Qt.LeftButton) { + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`) + notificationSummaryText.text = String.format(qsTr("{0} (copied)"), notificationObject.summary) + } + } + onDraggingChanged: () => { + if (dragging) { + root.qmlParent.dragIndex = root.index ?? root.parent.children.indexOf(root); + } + } + + onDragDiffXChanged: () => { + root.qmlParent.dragDistance = dragDiffX; + } + + onDragReleased: (diffX, diffY) => { + if (diffX > root.dragConfirmThreshold) + root.destroyWithAnimation(); + else + dragManager.resetDrag(); + } + } + + Rectangle { // Background of notification item + id: background + width: parent.width + anchors.left: parent.left + radius: Appearance.rounding.small + anchors.leftMargin: root.xOffset + + Behavior on anchors.leftMargin { + enabled: !dragManager.dragging + NumberAnimation { + duration: Appearance.animation.elementMove.duration + easing.type: Appearance.animation.elementMove.type + easing.bezierCurve: Appearance.animationCurves.expressiveFastSpatial + } + } + + color: expanded ? + (notificationObject.urgency == NotificationUrgency.Critical) ? + ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, Appearance.colors.colLayer2, 0.35) : + (Appearance.m3colors.m3surfaceContainerHigh) : + ColorUtils.transparentize(Appearance.m3colors.m3surfaceContainerHighest) + + implicitHeight: expanded ? (contentColumn.implicitHeight + padding * 2) : summaryRow.implicitHeight + Behavior on implicitHeight { + animation: Appearance.animation.elementMove.numberAnimation.createObject(this) + } + + ColumnLayout { // Content column + id: contentColumn + anchors.fill: parent + anchors.margins: expanded ? root.padding : 0 + spacing: 3 + + Behavior on anchors.margins { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { // Summary row + id: summaryRow + visible: !root.onlyNotification || !root.expanded + Layout.fillWidth: true + implicitHeight: summaryText.implicitHeight + // Layout.fillWidth: true + StyledText { + id: summaryText + visible: !root.onlyNotification + font.pixelSize: root.fontSize + color: Appearance.colors.colOnLayer2 + elide: Text.ElideRight + text: root.notificationObject.summary || "" + } + StyledText { + opacity: !root.expanded ? 1 : 0 + visible: opacity > 0 + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Layout.fillWidth: true + font.pixelSize: root.fontSize + color: Appearance.colors.colSubtext + elide: Text.ElideRight + textFormat: Text.StyledText + text: notificationObject.body.replace(/ 0 + + StyledText { // Notification body (expanded) + id: notificationBodyText + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Layout.fillWidth: true + font.pixelSize: root.fontSize + color: Appearance.colors.colSubtext + wrapMode: Text.Wrap + elide: Text.ElideRight + textFormat: Text.RichText + text: `` + + `${notificationObject.body.replace(/\n/g, "
")}` + } + + Flickable { // Notification actions + id: actionsFlickable + Layout.fillWidth: true + implicitHeight: actionRowLayout.implicitHeight + contentWidth: actionRowLayout.implicitWidth + clip: true + + Behavior on opacity { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on height { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + Behavior on implicitHeight { + animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this) + } + + RowLayout { + id: actionRowLayout + Layout.alignment: Qt.AlignBottom + + Repeater { + id: actionRepeater + model: notificationObject.actions + NotificationActionButton { + Layout.fillWidth: true + buttonText: modelData.text + urgency: notificationObject.urgency + onClicked: { + Notifications.attemptInvokeAction(notificationObject.id, modelData.identifier); + } + } + } + + NotificationActionButton { + Layout.fillWidth: true + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`) + copyIcon.text = "inventory" + copyIconTimer.restart() + } + + Timer { + id: copyIconTimer + interval: 1500 + repeat: false + onTriggered: { + copyIcon.text = "content_copy" + } + } + + contentItem: MaterialSymbol { + id: copyIcon + iconSize: Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "content_copy" + } + } + + NotificationActionButton { + Layout.fillWidth: true + buttonText: qsTr("Close") + urgency: notificationObject.urgency + implicitWidth: (notificationObject.actions.length == 0) ? ((actionsFlickable.width - actionRowLayout.spacing) / 2) : + (contentItem.implicitWidth + leftPadding + rightPadding) + + onClicked: { + root.destroyWithAnimation() + } + + contentItem: MaterialSymbol { + iconSize: Appearance.font.pixelSize.large + horizontalAlignment: Text.AlignHCenter + color: (notificationObject.urgency == NotificationUrgency.Critical) ? + Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface + text: "close" + } + } + + } + } + } + } + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/NotificationListView.qml b/.config/quickshell/modules/common/widgets/NotificationListView.qml new file mode 100644 index 000000000..087e4a403 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/NotificationListView.qml @@ -0,0 +1,31 @@ +import "root:/" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +StyledListView { // Scrollable window + id: root + property bool popup: false + + spacing: 3 + + model: ScriptModel { + values: root.popup ? Notifications.popupAppNameList : Notifications.appNameList + } + delegate: NotificationGroup { + required property int index + required property var modelData + popup: root.popup + anchors.left: parent?.left + anchors.right: parent?.right + notificationGroup: popup ? + Notifications.popupGroupsByAppName[modelData] : + Notifications.groupsByAppName[modelData] + } +} \ No newline at end of file diff --git a/.config/quickshell/modules/common/widgets/NotificationWidget.qml b/.config/quickshell/modules/common/widgets/NotificationWidget.qml deleted file mode 100644 index c970fd235..000000000 --- a/.config/quickshell/modules/common/widgets/NotificationWidget.qml +++ /dev/null @@ -1,577 +0,0 @@ -import "root:/modules/common" -import "root:/services" -import "root:/modules/common/functions/string_utils.js" as StringUtils -import "root:/modules/common/functions/color_utils.js" as ColorUtils -import Qt5Compat.GraphicalEffects -import QtQuick -import QtQuick.Controls -import QtQuick.Effects -import QtQuick.Layouts -import Quickshell -import Quickshell.Io -import Quickshell.Widgets -import Quickshell.Hyprland -import Quickshell.Services.Notifications -import "./notification_utils.js" as NotificationUtils - -Item { - id: root - property var notificationObject - property bool popup: false - property bool expanded: false - property bool enableAnimation: true - property int notificationListSpacing: 5 - property int defaultTimeoutValue: 5000 - - property var notificationXAnimation: Appearance.animation.elementMoveEnter - - Layout.fillWidth: true - clip: !popup - - implicitHeight: notificationColumnLayout.implicitHeight + notificationListSpacing - - Component.onCompleted: { - if (popup) timeoutTimer.start() - } - - Timer { - id: timeoutTimer - interval: notificationObject.expireTimeout ?? root.defaultTimeoutValue - repeat: false - onTriggered: { - root.notificationXAnimation = Appearance.animation.elementMoveExit - Notifications.timeoutNotification(notificationObject.id); - } - } - - function destroyWithAnimation(delay = 0) { - destroyTimer0.interval = delay - destroyTimer0.start() - } - - function toggleExpanded() { - root.enableAnimation = true - notificationRowWrapper.anchors.bottom = undefined - root.expanded = !root.expanded - } - - Timer { - id: destroyTimer0 - interval: 0 - repeat: false - onTriggered: { - notificationRowWrapper.anchors.left = undefined - notificationRowWrapper.anchors.right = undefined - notificationRowWrapper.anchors.fill = undefined - notificationBackground.anchors.left = undefined - notificationBackground.anchors.right = undefined - notificationBackground.anchors.fill = undefined - notificationRowWrapper.x = width + (Appearance.sizes.hyprlandGapsOut + Appearance.sizes.elevationMargin) * 2 // Account for shadow - notificationBackground.x = width + (Appearance.sizes.hyprlandGapsOut + Appearance.sizes.elevationMargin) * 2 // Account for shadow - destroyTimer1.start() - } - } - - Timer { - id: destroyTimer1 - interval: notificationXAnimation.duration - repeat: false - onTriggered: { - notificationRowWrapper.anchors.top = undefined - notificationRowWrapper.anchors.bottom = root.bottom - Notifications.discardNotification(notificationObject.id); - } - } - - MouseArea { - // Middle click to close - anchors.fill: parent - acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton - onClicked: (mouse) => { - if (mouse.button == Qt.MiddleButton) - root.destroyWithAnimation() - else if (mouse.button == Qt.RightButton) - root.toggleExpanded() - } - - // Flick right to dismiss/discard - property real startX: 0 - property real dragStartThreshold: 10 - property real dragConfirmThreshold: 70 - property bool dragStarted: false - - onPressed: (mouse) => { - if (mouse.button === Qt.LeftButton) { - startX = mouse.x - } - } - onPressAndHold: (mouse) => { - if (mouse.button === Qt.LeftButton) { - Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`) - notificationSummaryText.text = String.format(qsTr("{0} (copied)"), notificationObject.summary) - } - } - onDragStartedChanged: () => { - // Prevent drag focus being shifted to parent flickable - if (root.parent.parent.parent.interactive !== undefined) root.parent.parent.parent.interactive = !dragStarted - root.enableAnimation = !dragStarted - } - onReleased: (mouse) => { - dragStarted = false - if (mouse.button === Qt.LeftButton) { - if (notificationRowWrapper.x > dragConfirmThreshold) { - root.notificationXAnimation = Appearance.animation.elementMoveEnter - root.destroyWithAnimation() - } else { - // Animate back if not far enough - root.notificationXAnimation = Appearance.animation.elementMoveFast - notificationRowWrapper.x = 0 - notificationBackground.x = 0 - } - } - } - onCanceled: (mouse) => { - dragStarted = false - if (notificationRowWrapper.x > dragConfirmThreshold) { - root.notificationXAnimation = Appearance.animation.elementMoveEnter - root.destroyWithAnimation() - } else { - // Animate back if not far enough - root.notificationXAnimation = Appearance.animation.elementMoveFast - notificationRowWrapper.x = 0 - notificationBackground.x = 0 - } - } - - onPositionChanged: (mouse) => { - if (mouse.buttons & Qt.LeftButton) { - let dx = mouse.x - startX - if (dragStarted || dx > dragStartThreshold) { - dragStarted = true - notificationRowWrapper.anchors.left = undefined - notificationRowWrapper.anchors.right = undefined - notificationRowWrapper.anchors.fill = undefined - notificationBackground.anchors.left = undefined - notificationBackground.anchors.right = undefined - notificationBackground.anchors.fill = undefined - notificationRowWrapper.x = Math.max(0, dx) - notificationBackground.x = Math.max(0, dx) - } - } - } - } - - // Background - Item { - id: notificationBackgroundWrapper - - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - anchors.topMargin: notificationListSpacing - implicitHeight: notificationColumnLayout.implicitHeight + notificationListSpacing - - Rectangle { - id: notificationBackground - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - // anchors.top: parent.top - implicitHeight: notificationColumnLayout.implicitHeight - - color: (notificationObject.urgency == NotificationUrgency.Critical) ? - ColorUtils.mix(Appearance.m3colors.m3secondaryContainer, Appearance.colors.colLayer2, 0.35) : Appearance.colors.colLayer2 - radius: Appearance.rounding.normal - - layer.enabled: true - layer.effect: MultiEffect { - source: notificationBackground - anchors.fill: notificationBackground - shadowEnabled: popup - shadowColor: Appearance.colors.colShadow - shadowVerticalOffset: 1 - shadowBlur: 0.5 - } - - Behavior on x { - enabled: enableAnimation - NumberAnimation { - duration: root.notificationXAnimation.duration - easing.type: root.notificationXAnimation.type - easing.bezierCurve: root.notificationXAnimation.bezierCurve - } - } - } - } - - - Item { - id: notificationRowWrapper - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - // anchors.top: parent.top - implicitHeight: notificationColumnLayout.implicitHeight + notificationListSpacing - - Behavior on x { - enabled: enableAnimation - NumberAnimation { - duration: root.notificationXAnimation.duration - easing.type: root.notificationXAnimation.type - easing.bezierCurve: root.notificationXAnimation.bezierCurve - } - } - - ColumnLayout { - id: notificationColumnLayout - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - spacing: 0 - Item { - Layout.fillWidth: true - implicitHeight: notificationRowLayout.implicitHeight - Behavior on implicitHeight { - enabled: enableAnimation - NumberAnimation { - duration: Appearance.animation.elementMoveFast.duration - easing.type: Appearance.animation.elementMoveFast.type - easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve - } - } - - RowLayout { - id: notificationRowLayout - anchors.top: parent.top - anchors.left: parent.left - anchors.right: parent.right - - Rectangle { // App icon - id: iconRectangle - implicitWidth: 47 - implicitHeight: 47 - Layout.leftMargin: 10 - Layout.topMargin: 10 - Layout.bottomMargin: 10 - Layout.alignment: Qt.AlignTop - Layout.fillWidth: false - radius: Appearance.rounding.full - color: Appearance.m3colors.m3secondaryContainer - Loader { - id: materialSymbolLoader - active: notificationObject.appIcon == "" - anchors.fill: parent - sourceComponent: MaterialSymbol { - text: { - const defaultIcon = NotificationUtils.findSuitableMaterialSymbol("") - const guessedIcon = NotificationUtils.findSuitableMaterialSymbol(notificationObject.summary) - return (notificationObject.urgency == NotificationUrgency.Critical && guessedIcon === defaultIcon) ? - "release_alert" : guessedIcon - } - anchors.fill: parent - color: (notificationObject.urgency == NotificationUrgency.Critical) ? - ColorUtils.mix(Appearance.m3colors.m3onSecondary, Appearance.m3colors.m3onSecondaryContainer, 0.1) : - Appearance.m3colors.m3onSecondaryContainer - iconSize: 27 - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - } - } - Loader { - id: appIconLoader - active: notificationObject.image == "" && notificationObject.appIcon != "" - anchors.centerIn: parent - sourceComponent: IconImage { - implicitSize: 33 - asynchronous: true - source: Quickshell.iconPath(notificationObject.appIcon, "image-missing") - } - } - Loader { - id: notifImageLoader - active: notificationObject.image != "" - anchors.fill: parent - sourceComponent: Item { - anchors.fill: parent - Image { - id: notifImage - anchors.fill: parent - readonly property int size: parent.width - - source: notificationObject?.image - fillMode: Image.PreserveAspectCrop - cache: false - antialiasing: true - asynchronous: true - - width: size - height: size - sourceSize.width: size - sourceSize.height: size - - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Rectangle { - width: notifImage.size - height: notifImage.size - radius: Appearance.rounding.full - } - } - } - Loader { - id: notifImageAppIconLoader - active: notificationObject.appIcon != "" - anchors.bottom: parent.bottom - anchors.right: parent.right - sourceComponent: IconImage { - implicitSize: 23 - asynchronous: true - source: Quickshell.iconPath(notificationObject.appIcon, "image-missing") - } - } - } - } - } - - ColumnLayout { // Notification content - spacing: 0 - Layout.fillWidth: true - - RowLayout { // Row of summary, time and expand button - Layout.topMargin: 10 - Layout.leftMargin: 10 - Layout.rightMargin: 10 - Layout.fillWidth: true - - StyledText { // Summary - id: notificationSummaryText - Layout.fillWidth: true - horizontalAlignment: Text.AlignLeft - verticalAlignment: Text.AlignBottom - font.pixelSize: Appearance.font.pixelSize.normal - color: Appearance.colors.colOnLayer2 - text: notificationObject.summary - wrapMode: expanded ? Text.Wrap : Text.NoWrap - elide: Text.ElideRight - } - - CircularProgress { - id: notificationProgress - visible: popup - Layout.alignment: Qt.AlignVCenter - lineWidth: 2 - value: popup ? 1 : 0 - size: 20 - animationDuration: notificationObject.expireTimeout ?? root.defaultTimeoutValue - easingType: Easing.Linear - - Component.onCompleted: { - value = 0 - } - } - - StyledText { // Time - id: notificationTimeText - Layout.fillWidth: false - Layout.alignment: Qt.AlignTop - Layout.topMargin: 3 - wrapMode: Text.Wrap - horizontalAlignment: Text.AlignLeft - font.pixelSize: Appearance.font.pixelSize.smaller - color: Appearance.m3colors.m3outline - text: NotificationUtils.getFriendlyNotifTimeString(notificationObject.time) - - Connections { - target: DateTime - function onTimeChanged() { - notificationTimeText.text = NotificationUtils.getFriendlyNotifTimeString(notificationObject.time) - } - } - } - - RippleButton { // Expand button - Layout.alignment: Qt.AlignTop - id: expandButton - implicitWidth: 22 - implicitHeight: 22 - - buttonRadius: Appearance.rounding.full - colBackgroundHover: Appearance.colors.colLayer2Hover - colRipple: Appearance.colors.colLayer2Active - - onClicked: { - root.toggleExpanded() - } - - contentItem: MaterialSymbol { - anchors.centerIn: parent - text: "keyboard_arrow_down" - horizontalAlignment: Text.AlignHCenter - verticalAlignment: Text.AlignVCenter - iconSize: Appearance.font.pixelSize.normal - color: Appearance.colors.colOnLayer2 - rotation: expanded ? 180 : 0 - Behavior on rotation { - NumberAnimation { - duration: Appearance.animation.elementMoveFast.duration - easing.type: Appearance.animation.elementMoveFast.type - easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve - } - } - } - - } - } - - StyledText { // Notification body - id: notificationBodyText - Layout.fillWidth: true - Layout.leftMargin: 10 - Layout.rightMargin: 10 - Layout.bottomMargin: 10 - clip: true - - wrapMode: expanded ? Text.Wrap : Text.NoWrap - elide: Text.ElideRight - font.pixelSize: Appearance.font.pixelSize.small - horizontalAlignment: Text.AlignLeft - color: Appearance.m3colors.m3outline - textFormat: expanded ? Text.RichText : Text.StyledText - text: expanded - ? `` + - `${notificationObject.body.replace(/\n/g, "
")}` - : notificationObject.body.replace(/ { - Qt.openUrlExternally(link) - Hyprland.dispatch("global quickshell:sidebarRightClose") - } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.NoButton // Only for hover - hoverEnabled: true - cursorShape: parent.hoveredLink !== "" ? Qt.PointingHandCursor : Qt.ArrowCursor - } - } - } - } - } - - // Actions - Flickable { - id: actionsFlickable - Layout.fillWidth: true - // Layout.topMargin: -5 - Layout.leftMargin: 10 - Layout.rightMargin: 10 - Layout.bottomMargin: expanded ? 10 : 0 - implicitHeight: expanded ? actionRowLayout.implicitHeight : 0 - height: expanded ? actionRowLayout.implicitHeight : 0 - contentWidth: actionRowLayout.implicitWidth - - clip: true - layer.enabled: true - layer.effect: OpacityMask { - maskSource: Rectangle { - width: actionsFlickable.width - height: actionsFlickable.height - radius: Appearance.rounding.small - } - } - - opacity: expanded ? 1 : 0 - visible: opacity > 0 - Behavior on opacity { - NumberAnimation { - duration: Appearance.animation.elementMoveFast.duration - easing.type: Appearance.animation.elementMoveFast.type - easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve - } - } - Behavior on height { - NumberAnimation { - duration: Appearance.animation.elementMoveFast.duration - easing.type: Appearance.animation.elementMoveFast.type - easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve - } - } - Behavior on implicitHeight { - NumberAnimation { - duration: Appearance.animation.elementMoveFast.duration - easing.type: Appearance.animation.elementMoveFast.type - easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve - } - } - - RowLayout { - id: actionRowLayout - Layout.alignment: Qt.AlignBottom - - Repeater { - id: actionRepeater - model: notificationObject.actions - NotificationActionButton { - Layout.fillWidth: true - buttonText: modelData.text - urgency: notificationObject.urgency - onClicked: { - Notifications.attemptInvokeAction(notificationObject.id, modelData.identifier); - } - } - } - - NotificationActionButton { - Layout.fillWidth: true - urgency: notificationObject.urgency - implicitWidth: (notificationObject.actions.length == 0) ? (actionsFlickable.width / 2) : - (contentItem.implicitWidth + leftPadding + rightPadding) - - onClicked: { - Hyprland.dispatch(`exec wl-copy '${StringUtils.shellSingleQuoteEscape(notificationObject.body)}'`) - copyIcon.text = "inventory" - copyIconTimer.restart() - } - - Timer { - id: copyIconTimer - interval: 1500 - repeat: false - onTriggered: { - copyIcon.text = "content_copy" - } - } - - contentItem: MaterialSymbol { - id: copyIcon - iconSize: Appearance.font.pixelSize.large - horizontalAlignment: Text.AlignHCenter - color: (notificationObject.urgency == NotificationUrgency.Critical) ? - Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface - text: "content_copy" - } - } - - NotificationActionButton { - Layout.fillWidth: true - buttonText: qsTr("Close") - urgency: notificationObject.urgency - implicitWidth: (notificationObject.actions.length == 0) ? (actionsFlickable.width / 2) : - (contentItem.implicitWidth + leftPadding + rightPadding) - - onClicked: { - root.destroyWithAnimation() - } - - contentItem: MaterialSymbol { - iconSize: Appearance.font.pixelSize.large - horizontalAlignment: Text.AlignHCenter - color: (notificationObject.urgency == NotificationUrgency.Critical) ? - Appearance.m3colors.m3onSurfaceVariant : Appearance.m3colors.m3onSurface - text: "close" - } - } - - } - } - } - } -} diff --git a/.config/quickshell/modules/common/widgets/StyledListView.qml b/.config/quickshell/modules/common/widgets/StyledListView.qml new file mode 100644 index 000000000..ed6aa9162 --- /dev/null +++ b/.config/quickshell/modules/common/widgets/StyledListView.qml @@ -0,0 +1,106 @@ +import "root:/" +import "root:/modules/common/" +import "root:/modules/common/widgets" +import "root:/services" +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Wayland +import Quickshell.Hyprland + +ListView { // Scrollable window + id: root + spacing: 5 + property real removeOvershoot: 20 // Account for gaps and bouncy animations + property int dragIndex: -1 + property real dragDistance: 0 + + function resetDrag() { + root.dragIndex = -1 + root.dragDistance = 0 + } + + add: Transition { + animations: [ + Appearance.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + from: 0, + to: 1, + }), + ] + } + + addDisplaced: Transition { + animations: [ + Appearance.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } + + displaced: Transition { + animations: [ + Appearance.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } + + move: Transition { + animations: [ + Appearance.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } + moveDisplaced: Transition { + animations: [ + Appearance.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } + + remove: Transition { + animations: [ + Appearance.animation.elementMove.numberAnimation.createObject(this, { + property: "x", + to: root.width + root.removeOvershoot, + }), + Appearance.animation.elementMove.numberAnimation.createObject(this, { + property: "opacity", + to: 0, + }) + ] + } + + // This is movement when something is removed, not removing animation! + removeDisplaced: Transition { + animations: [ + Appearance.animation.elementMove.numberAnimation.createObject(this, { + property: "y", + }), + Appearance.animation.elementMove.numberAnimation.createObject(this, { + properties: "opacity,scale", + to: 1, + }), + ] + } +} diff --git a/.config/quickshell/modules/notificationPopup/NotificationPopup.qml b/.config/quickshell/modules/notificationPopup/NotificationPopup.qml index e526a2769..122489d88 100644 --- a/.config/quickshell/modules/notificationPopup/NotificationPopup.qml +++ b/.config/quickshell/modules/notificationPopup/NotificationPopup.qml @@ -34,74 +34,14 @@ Scope { color: "transparent" implicitWidth: Appearance.sizes.notificationPopupWidth - ListView { // Scrollable window + NotificationListView { id: listview anchors.top: parent.top anchors.bottom: parent.bottom anchors.horizontalCenter: parent.horizontalCenter + anchors.topMargin: 5 implicitWidth: parent.width - Appearance.sizes.elevationMargin * 2 - - add: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - properties: "opacity,scale", - from: 0, - to: 1, - }), - ] - } - - addDisplaced: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "y", - }), - Appearance.animation.elementMove.numberAnimation.createObject(this, { - properties: "opacity,scale", - to: 1, - }), - ] - } - - displaced: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "y", - }), - ] - } - move: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "y", - }), - ] - } - - remove: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "x", - to: listview.width, - }), - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "opacity", - to: 0, - }) - ] - } - - model: ScriptModel { - values: Notifications.popupList.slice().reverse() - } - delegate: NotificationWidget { - required property var modelData - id: notificationWidget - popup: true - anchors.left: parent?.left - anchors.right: parent?.right - notificationObject: modelData - } + popup: true } } } diff --git a/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml b/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml index 0c6e7314b..7dd4e28c6 100644 --- a/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml +++ b/.config/quickshell/modules/sidebarRight/notifications/NotificationList.qml @@ -11,7 +11,7 @@ import Quickshell.Widgets Item { id: root - ListView { // Scrollable window + NotificationListView { // Scrollable window id: listview anchors.left: parent.left anchors.right: parent.right @@ -28,68 +28,7 @@ Item { } } - add: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - properties: "opacity,scale", - from: 0, - to: 1, - }), - ] - } - - addDisplaced: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "y", - }), - Appearance.animation.elementMove.numberAnimation.createObject(this, { - properties: "opacity,scale", - to: 1, - }), - ] - } - - displaced: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "y", - }), - ] - } - move: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "y", - }), - ] - } - - remove: Transition { - animations: [ - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "x", - to: listview.width, - }), - Appearance.animation.elementMove.numberAnimation.createObject(this, { - property: "opacity", - to: 0, - }) - ] - } - - model: ScriptModel { - values: Notifications.list.slice().reverse() - } - delegate: NotificationWidget { - required property var modelData - id: notificationWidget - // anchors.horizontalCenter: parent.horizontalCenter - anchors.left: parent?.left - anchors.right: parent?.right - Layout.fillWidth: true - notificationObject: modelData - } + popup: false } // Placeholder when list is empty