From c1510b547645de5e8f70f6be99a0ba894b797241 Mon Sep 17 00:00:00 2001 From: ATMDA Date: Wed, 12 Nov 2025 14:51:22 -0500 Subject: notifs/toasts: reworked notifications and toasts and how they display and work together. see pull request comment. --- modules/notifications/Content.qml | 58 ++++++++- modules/notifications/Notification.qml | 67 +++++++--- modules/notifications/NotificationToast.qml | 154 ++++++++++++++++++++++ modules/notifications/NotificationToasts.qml | 186 +++++++++++++++++++++++++++ modules/notifications/Wrapper.qml | 2 + 5 files changed, 449 insertions(+), 18 deletions(-) create mode 100644 modules/notifications/NotificationToast.qml create mode 100644 modules/notifications/NotificationToasts.qml (limited to 'modules/notifications') diff --git a/modules/notifications/Content.qml b/modules/notifications/Content.qml index 2d4590e..035a228 100644 --- a/modules/notifications/Content.qml +++ b/modules/notifications/Content.qml @@ -13,6 +13,8 @@ Item { required property Item panels readonly property int padding: Appearance.padding.large + property bool shouldShow: false + anchors.top: parent.top anchors.bottom: parent.bottom anchors.right: parent.right @@ -20,13 +22,16 @@ Item { implicitWidth: Config.notifs.sizes.width + padding * 2 implicitHeight: { const count = list.count; - if (count === 0) + if (count === 0 || !shouldShow) return 0; let height = (count - 1) * Appearance.spacing.smaller; for (let i = 0; i < count; i++) height += list.itemAtIndex(i)?.nonAnimHeight ?? 0; + const screenHeight = QsWindow.window?.screen?.height ?? 0; + const maxHeight = Math.floor(screenHeight * 0.45); + if (visibilities && panels) { if (visibilities.osd) { const h = panels.osd.y - Config.border.rounding * 2 - padding * 2; @@ -41,7 +46,8 @@ Item { } } - return Math.min((QsWindow.window?.screen?.height ?? 0) - Config.border.thickness * 2, height + padding * 2); + const availableHeight = Math.min(maxHeight, screenHeight - Config.border.thickness * 2); + return Math.min(availableHeight, height + padding * 2); } ClippingWrapperRectangle { @@ -55,7 +61,7 @@ Item { id: list model: ScriptModel { - values: Notifs.popups.filter(n => !n.closed) + values: [...Notifs.notClosed] } anchors.fill: parent @@ -192,6 +198,52 @@ Item { } } + Timer { + id: hideTimer + + interval: 5000 + onTriggered: { + if (list.count > 0) + root.shouldShow = false; + } + } + + function show(): void { + if (list.count > 0) { + shouldShow = true; + hideTimer.restart(); + } + } + + Connections { + target: list + + function onCountChanged(): void { + if (list.count === 0) { + root.shouldShow = false; + hideTimer.stop(); + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + onEntered: { + if (list.count > 0) { + root.shouldShow = true; + hideTimer.restart(); + } + } + onExited: { + if (list.count > 0) { + root.shouldShow = false; + hideTimer.stop(); + } + } + } + Behavior on implicitHeight { Anim {} } diff --git a/modules/notifications/Notification.qml b/modules/notifications/Notification.qml index 95507fc..091da2c 100644 --- a/modules/notifications/Notification.qml +++ b/modules/notifications/Notification.qml @@ -17,22 +17,35 @@ StyledRect { required property Notifs.Notif modelData readonly property bool hasImage: modelData.image.length > 0 readonly property bool hasAppIcon: modelData.appIcon.length > 0 - readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 + readonly property bool isCritical: modelData.urgency === NotificationUrgency.Critical + readonly property bool isLow: modelData.urgency === NotificationUrgency.Low + readonly property int nonAnimHeight: { + const baseHeight = summary.implicitHeight + inner.anchors.margins * 2; + return root.expanded + ? baseHeight + appName.height + body.height + actions.height + actions.anchors.topMargin + : baseHeight + bodyPreview.height; + } property bool expanded + property bool disableSlideIn: false - color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainer + color: root.isCritical + ? Colours.palette.m3secondaryContainer + : Colours.tPalette.m3surfaceContainer radius: Appearance.rounding.normal implicitWidth: Config.notifs.sizes.width implicitHeight: inner.implicitHeight - x: Config.notifs.sizes.width + x: disableSlideIn ? 0 : Config.notifs.sizes.width Component.onCompleted: { - x = 0; + if (!root.disableSlideIn) { + x = 0; + } modelData.lock(this); } Component.onDestruction: modelData.unlock(this) Behavior on x { + enabled: !disableSlideIn Anim { easing.bezierCurve: Appearance.anim.curves.emphasizedDecel } @@ -134,8 +147,8 @@ StyledRect { Loader { id: appIcon - active: root.hasAppIcon || !root.hasImage - asynchronous: true + active: !root.hasImage || root.hasAppIcon + asynchronous: false anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter @@ -144,7 +157,11 @@ StyledRect { sourceComponent: StyledRect { radius: Appearance.rounding.full - color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) : Colours.palette.m3secondaryContainer + color: { + if (root.isCritical) return Colours.palette.m3error; + if (root.isLow) return Colours.layer(Colours.palette.m3surfaceContainerHighest, 2); + return Colours.palette.m3secondaryContainer; + } implicitWidth: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image implicitHeight: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image @@ -152,7 +169,8 @@ StyledRect { id: icon active: root.hasAppIcon - asynchronous: true + asynchronous: false + visible: active anchors.centerIn: parent @@ -162,14 +180,19 @@ StyledRect { sourceComponent: ColouredIcon { anchors.fill: parent source: Quickshell.iconPath(root.modelData.appIcon) - colour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + colour: { + if (root.isCritical) return Colours.palette.m3onError; + if (root.isLow) return Colours.palette.m3onSurface; + return Colours.palette.m3onSecondaryContainer; + } layer.enabled: root.modelData.appIcon.endsWith("symbolic") } } Loader { active: !root.hasAppIcon - asynchronous: true + asynchronous: false + visible: active anchors.centerIn: parent anchors.horizontalCenterOffset: -Appearance.font.size.large * 0.02 anchors.verticalCenterOffset: Appearance.font.size.large * 0.02 @@ -177,7 +200,11 @@ StyledRect { sourceComponent: MaterialIcon { text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) - color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + color: { + if (root.isCritical) return Colours.palette.m3onError; + if (root.isLow) return Colours.palette.m3onSurface; + return Colours.palette.m3onSecondaryContainer; + } font.pointSize: Appearance.font.size.large } } @@ -322,7 +349,9 @@ StyledRect { StateLayer { radius: Appearance.rounding.full - color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface + color: root.isCritical + ? Colours.palette.m3onSecondaryContainer + : Colours.palette.m3onSurface function onClicked() { root.expanded = !root.expanded; @@ -442,8 +471,12 @@ StyledRect { required property var modelData + readonly property bool isCritical: root.isCritical + radius: Appearance.rounding.full - color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondary : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) + color: isCritical + ? Colours.palette.m3secondary + : Colours.layer(Colours.palette.m3surfaceContainerHigh, 2) Layout.preferredWidth: actionText.width + Appearance.padding.normal * 2 Layout.preferredHeight: actionText.height + Appearance.padding.small * 2 @@ -452,7 +485,9 @@ StyledRect { StateLayer { radius: Appearance.rounding.full - color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface + color: isCritical + ? Colours.palette.m3onSecondary + : Colours.palette.m3onSurface function onClicked(): void { action.modelData.invoke(); @@ -464,7 +499,9 @@ StyledRect { anchors.centerIn: parent text: actionTextMetrics.elidedText - color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurfaceVariant + color: isCritical + ? Colours.palette.m3onSecondary + : Colours.palette.m3onSurfaceVariant font.pointSize: Appearance.font.size.small } diff --git a/modules/notifications/NotificationToast.qml b/modules/notifications/NotificationToast.qml new file mode 100644 index 0000000..1ce334b --- /dev/null +++ b/modules/notifications/NotificationToast.qml @@ -0,0 +1,154 @@ +import qs.components +import qs.components.effects +import qs.services +import qs.config +import qs.utils +import Quickshell +import Quickshell.Widgets +import Quickshell.Services.Notifications +import QtQuick +import QtQuick.Layouts + +StyledRect { + id: root + + required property Notifs.Notif modelData + + readonly property bool hasImage: modelData.image.length > 0 + readonly property bool hasAppIcon: modelData.appIcon.length > 0 + + anchors.left: parent.left + anchors.right: parent.right + implicitHeight: layout.implicitHeight + Appearance.padding.smaller * 2 + + radius: Appearance.rounding.normal + color: Colours.palette.m3surface + + border.width: 1 + border.color: Colours.palette.m3outlineVariant + + Elevation { + anchors.fill: parent + radius: parent.radius + opacity: parent.opacity + z: -1 + level: 3 + } + + RowLayout { + id: layout + + anchors.fill: parent + anchors.margins: Appearance.padding.smaller + anchors.leftMargin: Appearance.padding.normal + anchors.rightMargin: Appearance.padding.normal + spacing: Appearance.spacing.normal + + Item { + Layout.preferredWidth: Config.notifs.sizes.image + Layout.preferredHeight: Config.notifs.sizes.image + + Loader { + id: imageLoader + + active: root.hasImage + asynchronous: true + anchors.fill: parent + + sourceComponent: ClippingRectangle { + radius: Appearance.rounding.full + implicitWidth: Config.notifs.sizes.image + implicitHeight: Config.notifs.sizes.image + + Image { + anchors.fill: parent + source: Qt.resolvedUrl(root.modelData.image) + fillMode: Image.PreserveAspectCrop + cache: false + asynchronous: true + } + } + } + + Loader { + id: appIconLoader + + active: root.hasAppIcon || !root.hasImage + asynchronous: true + + anchors.horizontalCenter: root.hasImage ? undefined : parent.horizontalCenter + anchors.verticalCenter: root.hasImage ? undefined : parent.verticalCenter + anchors.right: root.hasImage ? parent.right : undefined + anchors.bottom: root.hasImage ? parent.bottom : undefined + + sourceComponent: StyledRect { + radius: Appearance.rounding.full + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) : Colours.palette.m3secondaryContainer + implicitWidth: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image + implicitHeight: root.hasImage ? Config.notifs.sizes.badge : Config.notifs.sizes.image + + Loader { + id: appIcon + + active: root.hasAppIcon + asynchronous: true + + anchors.centerIn: parent + + width: Math.round(parent.width * 0.6) + height: Math.round(parent.width * 0.6) + + sourceComponent: ColouredIcon { + anchors.fill: parent + source: Quickshell.iconPath(root.modelData.appIcon) + colour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + layer.enabled: root.modelData.appIcon.endsWith("symbolic") + } + } + + Loader { + active: !root.hasAppIcon + asynchronous: true + anchors.centerIn: parent + anchors.horizontalCenterOffset: -Appearance.font.size.large * 0.02 + anchors.verticalCenterOffset: Appearance.font.size.large * 0.02 + + sourceComponent: MaterialIcon { + text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) + color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer + font.pointSize: Appearance.font.size.large + } + } + } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 0 + + StyledText { + id: title + + Layout.fillWidth: true + text: root.modelData.summary + color: Colours.palette.m3onSurface + font.pointSize: Appearance.font.size.normal + elide: Text.ElideRight + } + + StyledText { + Layout.fillWidth: true + textFormat: Text.StyledText + text: root.modelData.body + color: Colours.palette.m3onSurface + opacity: 0.8 + elide: Text.ElideRight + } + } + } + + Behavior on border.color { + CAnim {} + } +} diff --git a/modules/notifications/NotificationToasts.qml b/modules/notifications/NotificationToasts.qml new file mode 100644 index 0000000..96fe817 --- /dev/null +++ b/modules/notifications/NotificationToasts.qml @@ -0,0 +1,186 @@ +pragma ComponentBehavior: Bound + +import qs.components +import qs.config +import qs.services +import Quickshell +import Quickshell.Widgets +import QtQuick + +Item { + id: root + + required property Item panels + + readonly property int spacing: Appearance.spacing.small + readonly property int maxToasts: 5 + readonly property bool listVisible: panels.notifications.content.shouldShow + + property bool flag + property var activeToasts: new Set() + + anchors.top: parent.top + anchors.right: parent.right + anchors.margins: Appearance.padding.normal + + implicitWidth: Config.notifs.sizes.width + implicitHeight: { + if (listVisible) + return 0; + + let height = -spacing; + for (let i = 0; i < repeater.count; i++) { + const item = repeater.itemAt(i) as ToastWrapper; + if (item && !item.modelData.closed && !item.previewHidden) + height += item.implicitHeight + spacing; + } + return height; + } + + opacity: listVisible ? 0 : 1 + visible: opacity > 0 + + Behavior on opacity { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + } + } + + Repeater { + id: repeater + + model: ScriptModel { + values: { + const toasts = []; + let visibleCount = 0; + + for (const notif of Notifs.list) { + if (notif.showAsToast) { + root.activeToasts.add(notif); + } + if (notif.closed) { + root.activeToasts.delete(notif); + } + } + + for (const notif of Notifs.list) { + if (root.activeToasts.has(notif)) { + toasts.push(notif); + if (notif.showAsToast && !notif.closed) { + visibleCount++; + if (visibleCount > root.maxToasts) + break; + } + } + } + return toasts; + } + onValuesChanged: root.flagChanged() + } + + ToastWrapper {} + } + + component ToastWrapper: MouseArea { + id: toast + + required property int index + required property Notifs.Notif modelData + + readonly property bool previewHidden: { + let extraHidden = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i); + if (item && item.modelData.closed) + extraHidden++; + } + return index >= root.maxToasts + extraHidden; + } + + opacity: modelData.closed || previewHidden || !modelData.showAsToast ? 0 : 1 + scale: modelData.closed || previewHidden || !modelData.showAsToast ? 0.7 : 1 + + anchors.topMargin: { + root.flag; + let margin = 0; + for (let i = 0; i < index; i++) { + const item = repeater.itemAt(i) as ToastWrapper; + if (item && !item.modelData.closed && !item.previewHidden) + margin += item.implicitHeight + root.spacing; + } + return margin; + } + + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + implicitHeight: toastInner.implicitHeight + + acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton + onClicked: { + modelData.showAsToast = false; + modelData.close(); + } + + Component.onCompleted: modelData.lock(this) + + onPreviewHiddenChanged: { + if (initAnim.running && previewHidden) + initAnim.stop(); + } + + Anim { + id: initAnim + + Component.onCompleted: running = !toast.previewHidden + + target: toast + properties: "opacity,scale" + from: 0 + to: 1 + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + + ParallelAnimation { + running: toast.modelData.closed || (!toast.modelData.showAsToast && !toast.modelData.closed) + onStarted: toast.anchors.topMargin = toast.anchors.topMargin + onFinished: { + if (toast.modelData.closed) + toast.modelData.unlock(toast); + } + + Anim { + target: toast + property: "opacity" + to: 0 + } + Anim { + target: toast + property: "scale" + to: 0.7 + } + } + + NotificationToast { + id: toastInner + + modelData: toast.modelData + } + + Behavior on opacity { + Anim {} + } + + Behavior on scale { + Anim {} + } + + Behavior on anchors.topMargin { + Anim { + duration: Appearance.anim.durations.expressiveDefaultSpatial + easing.bezierCurve: Appearance.anim.curves.expressiveDefaultSpatial + } + } + } +} diff --git a/modules/notifications/Wrapper.qml b/modules/notifications/Wrapper.qml index 61acc56..4b54883 100644 --- a/modules/notifications/Wrapper.qml +++ b/modules/notifications/Wrapper.qml @@ -8,6 +8,8 @@ Item { required property var visibilities required property Item panels + readonly property alias content: content + visible: height > 0 implicitWidth: Math.max(panels.sidebar.width, content.implicitWidth) implicitHeight: content.implicitHeight -- cgit v1.2.3-freya