import QtQuick import Quickshell.Services.Notifications import "." as M M.PopupPanel { id: menuWindow panelWidth: 350 // Header: title + clear all + DND toggle Item { width: menuWindow.panelWidth height: 32 Text { anchors.left: parent.left anchors.leftMargin: 12 anchors.verticalCenter: parent.verticalCenter text: "Notifications" color: M.Theme.base05 font.pixelSize: M.Theme.fontSize + 1 font.family: M.Theme.fontFamily font.bold: true } Row { anchors.right: parent.right anchors.rightMargin: 8 anchors.verticalCenter: parent.verticalCenter spacing: 8 // DND toggle Text { text: M.NotifService.dnd ? "\uDB82\uDE93" : "\uDB80\uDC9C" color: M.NotifService.dnd ? M.Theme.base09 : M.Theme.base04 font.pixelSize: M.Theme.fontSize font.family: M.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: M.NotifService.toggleDnd() } } // Clear all Text { text: "\uF1F8" color: clearArea.containsMouse ? M.Theme.base08 : M.Theme.base04 font.pixelSize: M.Theme.fontSize font.family: M.Theme.iconFontFamily anchors.verticalCenter: parent.verticalCenter visible: M.NotifService.count > 0 MouseArea { id: clearArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: menuWindow._cascadeDismiss() } } } } property var _delegates: [] property var _pendingDismissIds: [] function _cascadeDismiss() { const dels = _delegates.filter(d => d && d.modelData && !d.modelData.closed); if (dels.length === 0) return; _pendingDismissIds = dels.map(d => d.modelData.id); for (let i = 0; i < dels.length; i++) { const d = dels[i]; const isLast = (i === dels.length - 1); _cascadeTimer.createObject(menuWindow, { _target: d, _delay: i * 60, _isLast: isLast }); } } function _finishCascade() { const ids = _pendingDismissIds; _pendingDismissIds = []; for (const id of ids) M.NotifService.dismiss(id); } property Component _cascadeTimer: Component { Timer { property var _target property int _delay property bool _isLast interval: _delay running: true onTriggered: { if (_target && _target.dismissVisualOnly) _target.dismissVisualOnly(); if (_isLast) { // Wait for the last animation to finish, then bulk dismiss _bulkTimer.createObject(menuWindow, {}); } destroy(); } } } property Component _bulkTimer: Component { Timer { interval: 400 // swipe (200) + collapse (150) + margin running: true onTriggered: { menuWindow._finishCascade(); destroy(); } } } // Separator Rectangle { width: menuWindow.panelWidth - 16 height: 1 anchors.horizontalCenter: parent.horizontalCenter color: M.Theme.base03 } // Notification list (scrollable) Item { width: menuWindow.panelWidth height: Math.min(notifFlick.contentHeight, _maxHeight) readonly property real _itemHeight: 60 readonly property real _maxHeight: _itemHeight * (M.Modules.notifications.maxVisible || 10) Flickable { id: notifFlick anchors.fill: parent contentWidth: width contentHeight: notifCol.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds Column { id: notifCol width: parent.width Repeater { model: M.NotifService.list delegate: Item { id: notifItem required property var modelData required property int index width: menuWindow.panelWidth height: _targetHeight * _heightScale opacity: 0 clip: true readonly property real _targetHeight: notifContent.height + 12 property real _heightScale: 1 property bool _skipDismiss: false function dismiss() { _dismissAnim.start(); } function dismissVisualOnly() { _skipDismiss = true; _dismissAnim.start(); } Component.onCompleted: { menuWindow._delegates.push(notifItem); fadeIn.start(); } Component.onDestruction: { const idx = menuWindow._delegates.indexOf(notifItem); if (idx >= 0) menuWindow._delegates.splice(idx, 1); } NumberAnimation { id: fadeIn target: notifItem property: "opacity" to: 1 duration: 150 easing.type: Easing.OutCubic } Rectangle { anchors.fill: parent anchors.leftMargin: 4 anchors.rightMargin: 4 color: notifArea.containsMouse ? M.Theme.base02 : "transparent" radius: M.Theme.radius } // Urgency accent Rectangle { anchors.left: parent.left anchors.leftMargin: 4 anchors.top: parent.top anchors.topMargin: 4 anchors.bottom: parent.bottom anchors.bottomMargin: 4 width: 2 radius: 1 color: { const u = notifItem.modelData.urgency; return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; } } Column { id: notifContent anchors.left: parent.left anchors.right: dismissBtn.left anchors.top: parent.top anchors.leftMargin: 14 anchors.topMargin: 6 spacing: 1 // App + time Row { width: parent.width Text { text: notifItem.modelData.appName || "Notification" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily elide: Text.ElideRight width: parent.width - ageText.width - 4 } Text { id: ageText text: { const diff = Math.floor((Date.now() - notifItem.modelData.time) / 60000); if (diff < 1) return "now"; if (diff < 60) return diff + "m"; if (diff < 1440) return Math.floor(diff / 60) + "h"; return Math.floor(diff / 1440) + "d"; } color: M.Theme.base03 font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } } Text { width: parent.width text: notifItem.modelData.summary || "" color: M.Theme.base05 font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily font.bold: true elide: Text.ElideRight } Text { width: parent.width text: notifItem.modelData.body || "" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.fontFamily wrapMode: Text.WordWrap maximumLineCount: 2 elide: Text.ElideRight visible: text !== "" } // Actions Row { spacing: 4 visible: notifItem.modelData.actions && notifItem.modelData.actions.length > 0 Repeater { model: notifItem.modelData.actions || [] delegate: Rectangle { required property var modelData width: actText.implicitWidth + 10 height: actText.implicitHeight + 4 radius: M.Theme.radius color: actArea.containsMouse ? M.Theme.base02 : "transparent" border.color: M.Theme.base03 border.width: 1 Text { id: actText anchors.centerIn: parent text: parent.modelData.text color: M.Theme.base0D font.pixelSize: M.Theme.fontSize - 2 font.family: M.Theme.fontFamily } MouseArea { id: actArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { parent.modelData.invoke(); M.NotifService.dismiss(notifItem.modelData.id); } } } } } } // Dismiss button Text { id: dismissBtn anchors.right: parent.right anchors.rightMargin: 10 anchors.top: parent.top anchors.topMargin: 8 text: "\uF00D" color: dismissArea.containsMouse ? M.Theme.base08 : M.Theme.base03 font.pixelSize: M.Theme.fontSize - 1 font.family: M.Theme.iconFontFamily MouseArea { id: dismissArea anchors.fill: parent anchors.margins: -4 hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: _dismissAnim.start() } } SequentialAnimation { id: _dismissAnim ParallelAnimation { NumberAnimation { target: notifItem property: "x" to: menuWindow.panelWidth duration: 200 easing.type: Easing.InCubic } NumberAnimation { target: notifItem property: "opacity" to: 0 duration: 200 easing.type: Easing.InCubic } } NumberAnimation { target: notifItem property: "_heightScale" to: 0 duration: 150 easing.type: Easing.OutCubic } ScriptAction { script: { if (!notifItem._skipDismiss) M.NotifService.dismiss(notifItem.modelData.id); } } } MouseArea { id: notifArea anchors.fill: parent z: -1 hoverEnabled: true acceptedButtons: Qt.RightButton onClicked: _dismissAnim.start() } } } // Repeater } // Column } // Flickable } // Item // Empty state Text { visible: M.NotifService.count === 0 width: menuWindow.panelWidth height: 48 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter text: "No notifications" color: M.Theme.base04 font.pixelSize: M.Theme.fontSize font.family: M.Theme.fontFamily } }