import QtQuick import QtQuick.Layouts import QtQuick.Controls import Quickshell import Quickshell.Wayland import Quickshell.Services.Notifications import "../../config" as Cfg import "../../notifications" as Notif Rectangle { id: root property real panelRadius: Cfg.Config.radius / 2 property int borderWidth: Cfg.Config.panelBorderWidth property bool isTop: true property var targetScreen: null property int barHeight: Cfg.Config.barHeight property int barMargin: Cfg.Config.margin readonly property var t: Cfg.Config.theme property int unreadCount: 0 property bool doNotDisturb: false property bool historyOpen: historyPopup.popupOpen width: 35 height: 40 radius: panelRadius color: hoverHandler.hovered ? t.notifBgHover : t.notifBg border.color: root.doNotDisturb ? t.notifDnd : ((hoverHandler.hovered || historyOpen) ? t.notifBorderActive : t.notifBorder) border.width: borderWidth Behavior on color { ColorAnimation { duration: 150 } } Behavior on border.color { ColorAnimation { duration: 150 } } Behavior on width { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } HoverHandler { id: hoverHandler; cursorShape: Qt.PointingHandCursor; onHoveredChanged: if (hovered) 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 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 } } } 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 bellRingAnim.start() var timeStr = new Date().toLocaleTimeString(Qt.locale(), "HH:mm") var toastId = notif.id + "_" + Date.now() notifHistory.insert(0, { nid: notif.id, appName: notif.appName || "Unknown", summary: notif.summary || "", body: notif.body || "", urgency: notif.urgency, timeStr: timeStr }) while (notifHistory.count > Cfg.Config.notificationHistorySize) notifHistory.remove(notifHistory.count - 1) var cfgTimeout = notif.urgency === NotificationUrgency.Critical ? Cfg.Config.notifTimeoutCritical : (notif.urgency === NotificationUrgency.Low ? Cfg.Config.notifTimeoutLow : Cfg.Config.notifTimeoutNormal) var finalTimeout = (notif.expireTimeout > 0) ? notif.expireTimeout : cfgTimeout var toastEntry = { toastId: toastId, nid: notif.id, appName: notif.appName || "Unknown", summary: notif.summary || "", body: notif.body || "", urgency: notif.urgency, timeStr: timeStr, timeout: finalTimeout } if (!root.doNotDisturb) { 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 // Extra clearance so cards don't overlap the frame border stroke 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 Notif.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() } Timer { id: hideTimer interval: Cfg.Config.popupAnimDuration + 70 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 Rectangle { id: historyCard width: parent.width height: Math.min(histHeader.implicitHeight + 56 + (notifHistory.count > 0 ? histCol.implicitHeight : 90), 520) anchors.top: root.isTop ? parent.top : undefined anchors.bottom: root.isTop ? undefined : parent.bottom transform: Translate { y: historyPopup.popupOpen ? 0 : (root.isTop ? -historyCard.height : historyCard.height) Behavior on y { NumberAnimation { duration: Cfg.Config.popupAnimDuration; easing.type: Easing.OutExpo } enabled: historyPopup.visible } } opacity: historyPopup.popupOpen ? 1.0 : 0.0 Behavior on opacity { NumberAnimation { duration: 200 } } color: t.notifHistoryBg radius: Cfg.Config.popupRadius border.color: t.notifHistoryBorder border.width: Cfg.Config.popupBorderWidth layer.enabled: true MouseArea { anchors.fill: parent; propagateComposedEvents: false } 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; Layout.alignment: Qt.AlignVCenter } Text { text: "Notifications"; color: t.notifToastAppName; font.pixelSize: 15; font.bold: true Layout.fillWidth: true; Layout.alignment: Qt.AlignVCenter } // ── Do Not Disturb toggle ───────────────────────────── 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 anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 150 } } } Text { text: root.doNotDisturb ? "On" : "Off" color: root.doNotDisturb ? t.notifDnd : t.notifToastDim font.pixelSize: 11 font.bold: root.doNotDisturb anchors.verticalCenter: parent.verticalCenter Behavior on color { ColorAnimation { duration: 150 } } } } 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 } 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; Layout.alignment: Qt.AlignHCenter } Text { text: "No notifications"; color: t.notifHistoryEmpty; font.pixelSize: 12; Layout.alignment: Qt.AlignHCenter } } } Repeater { model: notifHistory Notif.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) } } } } } } } }