import QtQuick import QtQuick.Layouts import QtQuick.Controls import Quickshell import Quickshell.Wayland import Quickshell.Services.Notifications import "../../config" as Cfg import "../../components" ModuleChip { id: root property int unreadCount: 0 property bool doNotDisturb: false property bool historyOpen: historyPopup.popupOpen width: 35 height: Cfg.Config.moduleHeight radius: panelRadius chipColor: t.notifBg chipColorHovered: t.notifBgHover popupOpen: historyOpen border.color: root.doNotDisturb ? t.notifDnd : ((hovered || historyOpen) ? t.notifBorderActive : t.notifBorder) Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } component NotificationCard: Item { id: toastRoot property string toastId: "" property string appName: "" property string summary: "" property string body: "" property int urgency: NotificationUrgency.Normal property string timeStr: "" property int timeout: 5000 property var t property var urgencyColor property var urgencyIcon signal dismissed(string toastId) width: parent ? parent.width : 350 height: toastCard.height property real progressVal: 1.0 property bool slideFromRight: false property real slideX: slideFromRight ? (width + 20) : -(width + 20) transform: [ Translate { id: slideTranslate; x: toastRoot.slideX }, Scale { id: bubbleScale; yScale: 0; origin.y: toastLayer.atBottom ? toastRoot.height : 0 } ] Component.onCompleted: entranceAnim.start() ParallelAnimation { id: entranceAnim NumberAnimation { target: slideTranslate; property: "x" from: toastRoot.slideX; to: 0 duration: 320; easing.type: Easing.OutCubic } NumberAnimation { target: bubbleScale; property: "yScale" from: 0; to: 1 duration: 280; easing.type: Easing.OutBack } } function triggerDismiss() { if (exitAnim.running) return autoTimer.stop() exitAnim.start() } ParallelAnimation { id: exitAnim NumberAnimation { target: slideTranslate; property: "x" to: toastRoot.slideX duration: 250; easing.type: Easing.InCubic } NumberAnimation { target: bubbleScale; property: "yScale" from: 1; to: 0 duration: 200; easing.type: Easing.InBack } onStopped: toastRoot.dismissed(toastRoot.toastId) } Timer { id: autoTimer interval: toastRoot.timeout running: toastRoot.timeout > 0 repeat: false onTriggered: toastRoot.triggerDismiss() } NumberAnimation on progressVal { from: 1.0; to: 0.0 duration: toastRoot.timeout running: toastRoot.urgency === NotificationUrgency.Low && toastRoot.timeout > 0 } Rectangle { id: toastCard width: parent.width height: toastContent.implicitHeight + (toastRoot.urgency === NotificationUrgency.Low ? 30 : 24) radius: 16 color: cardHover.hovered ? toastRoot.t.notifToastBgHover : toastRoot.t.notifToastBg border.color: toastRoot.urgency === NotificationUrgency.Critical ? toastRoot.t.notifUrgencyCritical : (cardHover.hovered ? toastRoot.t.notifBorderActive : toastRoot.t.notifToastBorder) border.width: Cfg.Config.popupBorderWidth Behavior on color { ColorAnimation { duration: 150; easing.type: Easing.OutCubic } } Behavior on border.color { ColorAnimation { duration: 150 } } HoverHandler { id: cardHover } TapHandler { onTapped: toastRoot.triggerDismiss() } Rectangle { width: 4 height: Math.max(16, toastCard.height - 24) radius: 2 anchors { left: parent.left leftMargin: 10 verticalCenter: parent.verticalCenter } color: toastRoot.urgencyColor(toastRoot.urgency) } ColumnLayout { id: toastContent anchors { left: parent.left right: parent.right top: parent.top leftMargin: 24 rightMargin: 12 topMargin: 12 } spacing: 4 RowLayout { Layout.fillWidth: true spacing: 6 Text { text: toastRoot.urgencyIcon(toastRoot.urgency) color: toastRoot.urgencyColor(toastRoot.urgency) font.pixelSize: 12 font.bold: true Layout.alignment: Qt.AlignVCenter renderType: Text.NativeRendering } Text { text: toastRoot.appName color: toastRoot.t.notifToastAppName font.pixelSize: 12 font.bold: true font.capitalization: Font.AllUppercase font.letterSpacing: 0.4 Layout.fillWidth: true elide: Text.ElideRight verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering } Text { text: toastRoot.timeStr color: toastRoot.t.notifToastDim font.pixelSize: 11 font.bold: true verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering } Rectangle { Layout.alignment: Qt.AlignVCenter width: 20; height: 20; radius: 10 color: closeHover.hovered ? toastRoot.t.notifHistoryHover : "transparent" Behavior on color { ColorAnimation { duration: 100 } } Text { anchors.centerIn: parent text: "✕" color: closeHover.hovered ? toastRoot.t.notifToastText : toastRoot.t.notifToastDim font.pixelSize: 10 font.bold: true renderType: Text.NativeRendering } HoverHandler { id: closeHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: toastRoot.triggerDismiss() } } } Text { text: toastRoot.summary color: toastRoot.t.notifToastText font.pixelSize: 14 font.bold: true Layout.fillWidth: true elide: Text.ElideRight textFormat: Text.PlainText visible: toastRoot.summary !== "" renderType: Text.NativeRendering } Text { text: toastRoot.body color: toastRoot.t.notifToastDim font.pixelSize: 12 font.bold: true Layout.fillWidth: true wrapMode: Text.WordWrap maximumLineCount: 3 elide: Text.ElideRight textFormat: Text.PlainText visible: toastRoot.body !== "" Layout.bottomMargin: toastRoot.urgency === NotificationUrgency.Low ? 6 : 0 lineHeight: 1.2 renderType: Text.NativeRendering } } Item { visible: toastRoot.urgency === NotificationUrgency.Low && toastRoot.timeout > 0 anchors { left: parent.left; right: parent.right; bottom: parent.bottom margins: 10; bottomMargin: 10 } height: 4 Rectangle { anchors.fill: parent radius: 2 color: toastRoot.t.notifProgressBg } Rectangle { width: parent.width * toastRoot.progressVal height: parent.height radius: 2 color: toastRoot.t.notifProgressFill } } } } component HistoryCard: Rectangle { id: histRoot property int itemIndex: 0 property string appName: "" property string summary: "" property string body: "" property int urgency: 0 property string timeStr: "" property var t property var urgencyColor property var urgencyIcon signal dismissed(int itemIndex) height: histItemContent.implicitHeight + 16 radius: 10 clip: true color: histItemHover.hovered ? histRoot.t.notifHistoryHover : "transparent" Behavior on color { ColorAnimation { duration: 150; easing.type: Easing.OutCubic } } HoverHandler { id: histItemHover } SequentialAnimation { id: dismissAnim ParallelAnimation { NumberAnimation { target: histRoot; property: "opacity"; to: 0; duration: 150; easing.type: Easing.OutCubic } NumberAnimation { target: histRoot; property: "scale"; to: 0.95; duration: 150; easing.type: Easing.OutCubic } } NumberAnimation { target: histRoot; property: "height"; to: 0; duration: 150; easing.type: Easing.InOutQuad } ScriptAction { script: histRoot.dismissed(histRoot.itemIndex) } } Rectangle { width: 3 height: Math.max(10, histRoot.height - 16) radius: 1.5 anchors { left: parent.left; leftMargin: 8; verticalCenter: parent.verticalCenter } color: histRoot.urgencyColor(histRoot.urgency) } Rectangle { anchors { right: parent.right; top: parent.top; margins: 6 } width: 18; height: 18; radius: 9 color: histCloseHover.hovered ? histRoot.t.notifHistoryHover : "transparent" opacity: histItemHover.hovered ? 1 : 0 Behavior on opacity { NumberAnimation { duration: 150 } } Behavior on color { ColorAnimation { duration: 150 } } Text { anchors.centerIn: parent; text: "✕"; color: histRoot.t.notifToastText; font.pixelSize: 10; font.bold: true; renderType: Text.NativeRendering } HoverHandler { id: histCloseHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: dismissAnim.start() } } Text { anchors { right: parent.right; rightMargin: 6; top: parent.top; topMargin: 8 } text: histRoot.timeStr color: histRoot.t.notifToastDim font.pixelSize: 10 font.bold: true verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering opacity: histItemHover.hovered ? 0 : 1 Behavior on opacity { NumberAnimation { duration: 150 } } } ColumnLayout { id: histItemContent anchors { left: parent.left; right: parent.right; top: parent.top; leftMargin: 20; rightMargin: 40; topMargin: 8 } spacing: 2 RowLayout { Layout.fillWidth: true; spacing: 5 Text { text: histRoot.urgencyIcon(histRoot.urgency); color: histRoot.urgencyColor(histRoot.urgency); font.pixelSize: 10; font.bold: true; Layout.alignment: Qt.AlignVCenter; renderType: Text.NativeRendering } Text { text: histRoot.appName; color: histRoot.t.notifToastAppName; font.pixelSize: 11; font.bold: true font.capitalization: Font.AllUppercase; font.letterSpacing: 0.4; Layout.fillWidth: true; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter renderType: Text.NativeRendering } } Text { text: histRoot.summary; color: histRoot.t.notifToastText; font.pixelSize: 12; font.bold: true Layout.fillWidth: true; elide: Text.ElideRight; textFormat: Text.PlainText; visible: histRoot.summary !== "" renderType: Text.NativeRendering } Text { text: histRoot.body; color: histRoot.t.notifToastDim; font.pixelSize: 11; font.bold: true; Layout.fillWidth: true wrapMode: Text.WordWrap; maximumLineCount: 2; elide: Text.ElideRight; textFormat: Text.PlainText; visible: histRoot.body !== ""; lineHeight: 1.2 renderType: Text.NativeRendering } } } HoverHandler { cursorShape: Qt.PointingHandCursor; onHoveredChanged: if (hovered && Cfg.Config.notificationShakeEnabled) bellRingAnim.start() } TapHandler { onTapped: { if (historyPopup.popupOpen) { historyPopup.close() } else { root.unreadCount = 0 historyPopup.open() } } } Item { anchors.centerIn: parent width: Cfg.Config.notifIconSize + 10 height: Cfg.Config.notifIconSize + 10 Text { id: bellIcon anchors.centerIn: parent text: root.doNotDisturb ? "󰂛" : "󰂚" color: root.doNotDisturb ? t.notifDnd : (root.unreadCount > 0 ? t.notifIconActive : t.notifIcon) font.pixelSize: Cfg.Config.notifIconSize font.bold: true Behavior on color { ColorAnimation { duration: 150 } } rotation: 0 transformOrigin: Item.Top SequentialAnimation { id: bellRingAnim NumberAnimation { target: bellIcon; property: "rotation"; to: -18; duration: 80; easing.type: Easing.OutQuad } NumberAnimation { target: bellIcon; property: "rotation"; to: 20; duration: 100; easing.type: Easing.OutQuad } NumberAnimation { target: bellIcon; property: "rotation"; to: -10; duration: 80; easing.type: Easing.OutQuad } NumberAnimation { target: bellIcon; property: "rotation"; to: 10; duration: 70; easing.type: Easing.OutQuad } NumberAnimation { target: bellIcon; property: "rotation"; to: 0; duration: 80; easing.type: Easing.OutCubic } } } Rectangle { visible: root.unreadCount > 0 anchors.top: parent.top anchors.right: parent.right width: Math.max(14, badgeLabel.implicitWidth + 6) height: 14 radius: 7 color: t.notifBadgeBg Behavior on width { NumberAnimation { duration: 150; easing.type: Easing.OutCubic } } Text { id: badgeLabel anchors.centerIn: parent text: root.unreadCount > 99 ? "99+" : root.unreadCount.toString() color: t.notifBadgeText font.pixelSize: 8 font.bold: true horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter renderType: Text.QtRendering } } } ListModel { id: notifHistory } ListModel { id: activeToasts } function removeToast(toastId) { for (var i = 0; i < activeToasts.count; i++) { if (activeToasts.get(i).toastId === toastId) { activeToasts.remove(i) return } } } function urgencyColor(urgency) { if (urgency === NotificationUrgency.Critical) return t.notifUrgencyCritical if (urgency === NotificationUrgency.Normal) return t.notifUrgencyNormal return t.notifUrgencyLow } function urgencyIcon(urgency) { if (urgency === NotificationUrgency.Critical) return "󰀪" if (urgency === NotificationUrgency.Normal) return "󰋼" return "󰵙" } NotificationServer { id: notifServer keepOnReload: false actionIconsSupported: true bodyMarkupSupported: true bodySupported: true persistenceSupported: true imageSupported: true onNotification: function(notif) { notif.tracked = true if (Cfg.Config.notificationShakeEnabled) bellRingAnim.start() const timeStr = new Date().toLocaleTimeString(Qt.locale(), "HH:mm") const toastId = notif.id + "_" + Date.now() const appName = notif.appName || "Unknown" const summary = notif.summary || "" const body = notif.body || "" const urgency = notif.urgency notifHistory.insert(0, { nid: notif.id, appName, summary, body, urgency, timeStr }) while (notifHistory.count > Cfg.Config.notificationHistorySize) notifHistory.remove(notifHistory.count - 1) var cfgTimeout = urgency === NotificationUrgency.Critical ? Cfg.Config.notifTimeoutCritical : (urgency === NotificationUrgency.Low ? Cfg.Config.notifTimeoutLow : Cfg.Config.notifTimeoutNormal) var finalTimeout = (notif.expireTimeout > 0) ? notif.expireTimeout : cfgTimeout if (!root.doNotDisturb) { var toastEntry = { toastId, appName, summary, body, urgency, timeStr, timeout: finalTimeout } if (toastLayer.atBottom) activeToasts.append(toastEntry) else activeToasts.insert(0, toastEntry) if (!root.historyOpen) root.unreadCount++ } } } PanelWindow { id: toastLayer screen: root.targetScreen visible: activeToasts.count > 0 color: "transparent" readonly property string pos: Cfg.Config.notificationPosition readonly property bool atRight: pos === "topright" || pos === "bottomright" readonly property bool atBottom: pos === "bottomleft" || pos === "bottomright" anchors { top: true; bottom: true; right: atRight; left: !atRight } implicitWidth: 350 + root.barMargin + toastPad WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "main-shell-notifications-toasts" WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: WlrKeyboardFocus.None mask: Region { item: toastCol } readonly property bool shareEdge: (root.isTop && !atBottom) || (!root.isTop && atBottom) readonly property int edgeGap: shareEdge ? root.barMargin + root.barHeight + 10 : root.barMargin + 10 readonly property int toastPad: root.barMargin + Cfg.Config.frameBorderWidth + Cfg.Config.notifToastEdgePad Column { id: toastCol x: toastLayer.atRight ? root.barMargin : toastLayer.toastPad y: !toastLayer.atBottom ? toastLayer.edgeGap : parent.height - toastLayer.edgeGap - toastCol.height spacing: 8 width: 350 Repeater { model: activeToasts NotificationCard { width: toastCol.width toastId: model.toastId appName: model.appName summary: model.summary body: model.body urgency: model.urgency timeStr: model.timeStr timeout: model.timeout t: root.t urgencyColor: root.urgencyColor urgencyIcon: root.urgencyIcon slideFromRight: toastLayer.atRight onDismissed: (id) => root.removeToast(id) } } } } PanelWindow { id: historyPopup screen: root.targetScreen visible: false color: "transparent" property bool popupOpen: false function open() { hideTimer.stop(); visible = true; popupOpen = true } function close() { popupOpen = false; hideTimer.restart() } PopupHideTimer { id: hideTimer onTriggered: historyPopup.visible = false } anchors { top: true; bottom: true; left: true; right: true } WlrLayershell.layer: WlrLayer.Top WlrLayershell.namespace: "main-shell-notifications-history" WlrLayershell.exclusiveZone: -1 mask: popupOpen ? null : _noInput Region { id: _noInput } MouseArea { anchors.fill: parent onClicked: historyPopup.close() } Item { id: histClipContainer readonly property int cardW: 340 readonly property int screenPad: root.barMargin + 10 x: { var cx = root.mapToItem(null, 0, 0).x + root.width / 2 return Math.max(screenPad, Math.min(cx - cardW / 2, parent.width - cardW - screenPad)) } width: cardW anchors.top: root.isTop ? parent.top : undefined anchors.bottom: root.isTop ? undefined : parent.bottom anchors.topMargin: root.isTop ? (root.barHeight + 10) : 0 anchors.bottomMargin: root.isTop ? 0 : (root.barHeight + 10) height: parent.height - root.barHeight - 10 clip: true PopupCard { id: historyCard popupOpen: historyPopup.popupOpen isTop: root.isTop animEnabled: historyPopup.visible height: Math.min(histHeader.implicitHeight + 56 + (notifHistory.count > 0 ? histCol.implicitHeight : 90), 520) color: t.notifHistoryBg radius: Cfg.Config.popupRadius border.color: t.notifHistoryBorder border.width: Cfg.Config.popupBorderWidth RowLayout { id: histHeader anchors { left: parent.left; right: parent.right; top: parent.top } anchors.topMargin: 20; anchors.leftMargin: 20; anchors.rightMargin: 20 spacing: 10 Text { text: "󰂚"; color: t.notifToastAppName; font.pixelSize: 18; font.bold: true; Layout.alignment: Qt.AlignVCenter; renderType: Text.NativeRendering } Text { text: "Notifications"; color: t.notifToastAppName; font.pixelSize: 15; font.bold: true Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter; renderType: Text.NativeRendering } Rectangle { Layout.alignment: Qt.AlignVCenter width: dndRow.implicitWidth + 16 height: 24 radius: 12 color: root.doNotDisturb ? Qt.rgba(t.notifDnd.r, t.notifDnd.g, t.notifDnd.b, 0.18) : (dndHover.hovered ? t.notifHistoryHover : "transparent") border.color: root.doNotDisturb ? t.notifDnd : "transparent" border.width: Cfg.Config.popupBorderWidth Behavior on color { ColorAnimation { duration: 150 } } Behavior on border.color { ColorAnimation { duration: 150 } } Row { id: dndRow anchors.centerIn: parent spacing: 4 Text { text: "󰂛" color: root.doNotDisturb ? t.notifDnd : t.notifToastDim font.pixelSize: 12 font.bold: true anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 150 } } renderType: Text.NativeRendering } Text { text: root.doNotDisturb ? "On" : "Off" color: root.doNotDisturb ? t.notifDnd : t.notifToastDim font.pixelSize: 11 font.bold: true anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 150 } } renderType: Text.NativeRendering } } HoverHandler { id: dndHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: root.doNotDisturb = !root.doNotDisturb } } Rectangle { visible: notifHistory.count > 0 Layout.alignment: Qt.AlignVCenter width: clearText.implicitWidth + 16 height: 24 radius: 12 color: clearHover.hovered ? t.notifHistoryHover : "transparent" Behavior on color { ColorAnimation { duration: 150 } } Text { id: clearText; anchors.centerIn: parent; text: "Clear all"; color: t.notifToastDim; font.pixelSize: 11; font.bold: true; renderType: Text.NativeRendering } HoverHandler { id: clearHover; cursorShape: Qt.PointingHandCursor } TapHandler { onTapped: clearAllAnim.start() } } } Rectangle { id: histDivider anchors { left: parent.left; right: parent.right; top: histHeader.bottom } anchors.topMargin: 12; height: 1; color: t.notifSeparator } SequentialAnimation { id: clearAllAnim ParallelAnimation { NumberAnimation { target: histCol; property: "opacity" to: 0; duration: 220; easing.type: Easing.OutCubic } NumberAnimation { target: clearShift; property: "y" to: -16; duration: 220; easing.type: Easing.OutCubic } } ScriptAction { script: { notifHistory.clear(); root.unreadCount = 0 } } ParallelAnimation { NumberAnimation { target: histCol; property: "opacity"; to: 1; duration: 0 } NumberAnimation { target: clearShift; property: "y"; to: 0; duration: 0 } } } Flickable { id: histFlick anchors { left: parent.left; right: parent.right; top: histDivider.bottom; bottom: parent.bottom } anchors.leftMargin: 2; anchors.rightMargin: 8 anchors.topMargin: 6; anchors.bottomMargin: 8; clip: true contentHeight: histCol.implicitHeight flickableDirection: Flickable.VerticalFlick ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Column { id: histCol opacity: 1 transform: Translate { id: clearShift; y: 0 } width: histFlick.width; spacing: 2; topPadding: 4; bottomPadding: 4; leftPadding: 2; rightPadding: 8 Item { width: histCol.width - 16; height: 90; visible: notifHistory.count === 0 ColumnLayout { anchors.centerIn: parent; spacing: 8 Text { text: "󰂚"; color: t.notifHistoryEmpty; font.pixelSize: 30; font.bold: true; Layout.alignment: Qt.AlignHCenter; renderType: Text.NativeRendering } Text { text: "No notifications"; color: t.notifHistoryEmpty; font.pixelSize: 12; font.bold: true; Layout.alignment: Qt.AlignHCenter; renderType: Text.NativeRendering } } } Repeater { model: notifHistory HistoryCard { width: histCol.width - 10; x: 2; itemIndex: index appName: model.appName; summary: model.summary; body: model.body; urgency: model.urgency; timeStr: model.timeStr t: root.t; urgencyColor: root.urgencyColor; urgencyIcon: root.urgencyIcon onDismissed: (idx) => notifHistory.remove(idx) } } } } } } } }