import QtQuick import Quickshell import Quickshell.Wayland import Quickshell.Hyprland import Quickshell.Services.Notifications import QtQuick.Layouts import QtQuick.Controls import "../../config" import "./notificationServer" PanelWindow { id: root required property var screen anchors { top: true left: true right: true bottom: true } exclusiveZone: 0 color: "transparent" WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive // state for expanded groups: appName -> bool property var expandedGroups: ({}) // reactive grouped model: depends on trackedNotifications count property var grouped: { // establish binding dependency on trackedNotifications const vals = NotificationS.trackedNotifications.values // touch length to trigger re-evaluation const len = vals.length if (len < 0) return [] let map = {} for (let i = 0; i < vals.length; ++i) { const n = vals[i] const key = (n && n.appName && n.appName !== "") ? n.appName : "Unknown" if (!map[key]) map[key] = [] map[key].push(n) } let result = [] for (const k in map) { result.push({ appName: k, notes: map[k] }) } // sort alphabetically for stable order; could sort by most recent instead result.sort((a, b) => a.appName.localeCompare(b.appName)) return result } function toggleGroup(appName) { let copy = Object.assign({}, expandedGroups) copy[appName] = !(copy[appName] === true) expandedGroups = copy } function clearGroup(appName) { const vals = NotificationS.trackedNotifications.values.slice() for (let i = 0; i < vals.length; ++i) { const n = vals[i] const key = (n && n.appName && n.appName !== "") ? n.appName : "Unknown" if (key === appName) { // dismiss tracked notification if (n.tracked) n.dismiss() else if (n.close) n.close() } } } function clearAll() { const vals = NotificationS.trackedNotifications.values.slice() for (let i = 0; i < vals.length; ++i) { const n = vals[i] if (n.tracked) n.dismiss() } // also collapse all expandedGroups = ({}) } // close handling function closeOverview() { NotificationS.overviewVisible = false } // keyboard handling via contentItem contentItem { focus: true Keys.onPressed: event => { if (event.key === Qt.Key_Escape) { closeOverview() event.accepted = true } } } // background dim + click to close MouseArea { id: bgMouse anchors.fill: parent onClicked: function(mouse) { root.closeOverview() } // eat wheel to prevent flickable behind? } // main panel container Rectangle { id: panel // panel size: constrained by screen width: Math.min(520, Math.max(380, root.screen.width * 0.32)) property real maxPanelHeight: root.screen.height * 0.72 height: maxPanelHeight anchors.top: parent.top anchors.right: parent.right anchors.topMargin: Config.barHeight(root.screen.height) + Config.spacing anchors.rightMargin: Config.spacing radius: Config.radius color: Qt.alpha(Colors.surfaceContainer, 0.96) border.width: Config.border_width border.color: Colors.outlineVariant clip: true // prevent bg click when clicking panel MouseArea { anchors.fill: parent hoverEnabled: false onClicked: function(mouse) { mouse.accepted = true } } Column { id: panelColumn anchors.fill: parent anchors.margins: Config.spacing spacing: Config.spacing // ───── Header ───── Rectangle { id: header width: parent.width implicitHeight: headerRow.implicitHeight + Config.spacing*2 radius: Config.small_radius color: Colors.surfaceContainerHigh border.width: Config.border_width border.color: Colors.outlineVariant RowLayout { id: headerRow anchors.fill: parent anchors.margins: Config.spacing spacing: Config.spacing // title + count Text { id: titleText Layout.alignment: Qt.AlignVCenter text: "Notifications" font.family: Config.font font.pixelSize: Config.screen_height_to_font_small(root.screen.height) font.bold: true color: Colors.primary } Rectangle { id: countBadge Layout.alignment: Qt.AlignVCenter visible: NotificationS.trackedNotifications.values.length > 0 Layout.preferredWidth: countText.implicitWidth + Config.spacing Layout.preferredHeight: countText.implicitHeight + Config.small_spacing radius: height/2 color: Colors.primaryContainer border.width: Config.border_width border.color: Colors.primary Text { id: countText anchors.centerIn: parent text: NotificationS.trackedNotifications.values.length font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) font.bold: true color: Colors.primaryContainer_fg } } Item { Layout.preferredWidth: Config.spacing; Layout.preferredHeight: 1; Layout.alignment: Qt.AlignVCenter } // mute toggle (moved from NotificationButton) Rectangle { id: muteBtn Layout.preferredWidth: muteText.implicitWidth + Config.spacing*2 Layout.preferredHeight: muteText.implicitHeight + Config.spacing Layout.alignment: Qt.AlignVCenter radius: Config.radius_fun(height)*2 color: muteArea.containsMouse ? Colors.tertiaryContainer : (NotificationS.soundEnabled ? Colors.secondaryContainer : Colors.errorContainer) border.width: Config.border_width border.color: muteArea.containsMouse ? Colors.tertiary : (NotificationS.soundEnabled ? Colors.secondary : Colors.error) Text { id: muteText anchors.centerIn: parent text: NotificationS.soundEnabled ? "󰂚" : "󰂛" font.family: Config.font font.pixelSize: Config.screen_height_to_font_small(root.screen.height) color: muteArea.containsMouse ? Colors.tertiary_fg : (NotificationS.soundEnabled ? Colors.secondary : Colors.error_fg) } MouseArea { id: muteArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: function(mouse) { NotificationS.soundEnabled = !NotificationS.soundEnabled mouse.accepted = true } } } // clear all Rectangle { id: clearAllBtn Layout.preferredWidth: clearAllText.implicitWidth + Config.spacing*2 Layout.preferredHeight: muteText.implicitHeight + Config.spacing Layout.alignment: Qt.AlignVCenter radius: Config.radius_fun(height)*2 visible: NotificationS.trackedNotifications.values.length > 0 color: clearAllArea.containsMouse ? Colors.errorContainer : Colors.surfaceContainerHighest border.width: Config.border_width border.color: clearAllArea.containsMouse ? Colors.error : Colors.outline Text { id: clearAllText anchors.centerIn: parent text: "Clear all" font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: clearAllArea.containsMouse ? Colors.error_fg : Colors.primary } MouseArea { id: clearAllArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: function(mouse) { root.clearAll() mouse.accepted = true } } } Item { Layout.fillWidth: true; Layout.preferredWidth: 8; Layout.preferredHeight: 1 } // close X Rectangle { id: closeBtn Layout.preferredWidth: closeText.implicitHeight + Config.spacing Layout.preferredHeight: closeText.implicitHeight + Config.spacing Layout.alignment: Qt.AlignVCenter radius: Config.radius color: closeArea.containsMouse ? Colors.errorContainer : "transparent" border.width: Config.border_width border.color: closeArea.containsMouse ? Colors.error : Colors.outlineVariant Text { id: closeText anchors.centerIn: parent text: "✕" font.family: Config.font font.pixelSize: Config.screen_height_to_font_small(root.screen.height) color: closeArea.containsMouse ? Colors.error_fg : Colors.primary } MouseArea { id: closeArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: function(mouse) { root.closeOverview() mouse.accepted = true } } } } } // ───── Scrollable grouped list ───── Flickable { id: flick width: parent.width height: panel.height - header.height - panelColumn.spacing - Config.spacing*2 contentWidth: width contentHeight: contentColumn.implicitHeight clip: true boundsBehavior: Flickable.StopAtBounds flickableDirection: Flickable.VerticalFlick ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } Column { id: contentColumn width: flick.width spacing: Config.spacing // empty placeholder Rectangle { width: parent.width height: 80 radius: Config.small_radius color: Qt.alpha(Colors.surfaceContainerLow, 0.6) border.width: Config.border_width border.color: Colors.outlineVariant visible: NotificationS.trackedNotifications.values.length === 0 Text { anchors.centerIn: parent text: "No notifications" font.family: Config.font font.pixelSize: Config.screen_height_to_font_small(root.screen.height) color: Colors.outline } } Repeater { id: groupRepeater model: root.grouped delegate: Column { id: groupColumn required property var modelData required property int index property string appName: modelData.appName property var notes: modelData.notes property bool isExpanded: root.expandedGroups[appName] === true property int count: notes.length // pick representative icon from first notification property var repNote: notes.length > 0 ? notes[0] : null width: contentColumn.width spacing: Config.spacing / 2 // ── Group header ── Rectangle { id: groupHeader width: parent.width height: Math.max(headerContent.implicitHeight + Config.spacing*2, 48) radius: Config.small_radius color: groupHeaderArea.containsMouse ? Colors.secondaryContainer : Colors.surfaceContainerHigh border.width: Config.border_width border.color: groupColumn.isExpanded ? Colors.primary : Colors.outlineVariant Row { id: headerContent anchors.fill: parent anchors.margins: Config.spacing spacing: Config.spacing anchors.verticalCenter: parent.verticalCenter // app icon Image { id: groupIcon width: 28 height: 28 anchors.verticalCenter: parent.verticalCenter asynchronous: true fillMode: Image.PreserveAspectFit source: { if (!groupColumn.repNote) return Quickshell.iconPath("notifications") if (groupColumn.repNote.image) return groupColumn.repNote.image if (groupColumn.repNote.appIcon) return Quickshell.iconPath(groupColumn.repNote.appIcon) const entry = groupColumn.repNote.desktopEntry ? DesktopEntries.byId(groupColumn.repNote.desktopEntry) : null if (entry && entry.icon) { const p = Quickshell.iconPath(entry.icon) if (p && p !== "") return p } return Quickshell.iconPath("notifications") } } Column { anchors.verticalCenter: parent.verticalCenter spacing: 2 width: groupHeader.width - groupIcon.width - expandArrow.width - clearGroupBtn.width - badge.width - headerContent.spacing*4 - Config.spacing*2 Text { text: groupColumn.appName width: parent.width elide: Text.ElideRight font.family: Config.font font.pixelSize: Config.screen_height_to_font_small(root.screen.height) font.bold: true color: Colors.primary } Text { text: groupColumn.count + (groupColumn.count === 1 ? " notification" : " notifications") font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: Colors.outline } } Rectangle { id: badge width: badgeText.implicitWidth + Config.spacing height: badgeText.implicitHeight + 4 anchors.verticalCenter: parent.verticalCenter radius: height/2 color: groupColumn.isExpanded ? Colors.primary : Colors.secondaryContainer border.width: Config.border_width border.color: groupColumn.isExpanded ? Colors.primary_fg : Colors.outlineVariant Text { id: badgeText anchors.centerIn: parent text: groupColumn.count font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) font.bold: true color: groupColumn.isExpanded ? Colors.primary_fg : Colors.secondary } } Text { id: expandArrow anchors.verticalCenter: parent.verticalCenter text: groupColumn.isExpanded ? "▼" : "▶" font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: Colors.primary } // clear group button Rectangle { id: clearGroupBtn width: clearGroupText.implicitWidth + Config.spacing height: clearGroupText.implicitHeight + Config.spacing/2 anchors.verticalCenter: parent.verticalCenter radius: Config.radius_fun(height) color: clearGroupArea.containsMouse ? Colors.errorContainer : Qt.alpha(Colors.surfaceContainerHighest, 0.8) border.width: Config.border_width border.color: clearGroupArea.containsMouse ? Colors.error : Colors.outlineVariant Text { id: clearGroupText anchors.centerIn: parent text: "✕" font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: clearGroupArea.containsMouse ? Colors.error_fg : Colors.outline } MouseArea { id: clearGroupArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: function(mouse) { mouse.accepted = true root.clearGroup(groupColumn.appName) } } } } MouseArea { id: groupHeaderArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor // don't steal click when hitting clear button onClicked: function(mouse) { // if clear button contains mouse, ignore if (clearGroupArea.containsMouse) return mouse.accepted = true root.toggleGroup(groupColumn.appName) } } } // ── Group notifications (collapsed by default) ── Column { id: groupNotesColumn width: parent.width spacing: Config.spacing/2 visible: groupColumn.isExpanded // keep clipped and animate? simple visible Repeater { id: notesRepeater model: groupColumn.notes delegate: Rectangle { id: histCard required property var modelData required property int index property var notification: modelData property int urgency: (notification.urgency ?? notification.hints["urgency"]) != null ? (notification.urgency ?? notification.hints["urgency"]) + 1 : 0 property color textColor: urgency !== 3 ? Colors.primary : Colors.tertiary property color textBGColor: urgency !== 3 ? Qt.alpha(Colors.primaryContainer, 0.92) : Qt.alpha(Colors.tertiaryContainer, 0.92) width: groupNotesColumn.width // height determined by content implicitHeight: cardInnerColumn.implicitHeight + Config.spacing*2 radius: Config.small_radius color: textBGColor border.width: Config.border_width border.color: textColor clip: true Column { id: cardInnerColumn width: parent.width spacing: 0 // content row Rectangle { id: cardContentRect width: parent.width // height based on max of icon vs text implicitHeight: Math.max(iconCol.implicitHeight, bodyCol.implicitHeight) + Config.spacing*2 color: "transparent" // close per-notification MouseArea { id: cardCloseArea anchors.top: parent.top anchors.right: parent.right anchors.margins: Config.spacing/2 width: cardCloseText.implicitHeight + Config.spacing height: width hoverEnabled: true cursorShape: Qt.PointingHandCursor Rectangle { anchors.fill: parent radius: Config.small_radius color: cardCloseArea.containsMouse ? Colors.errorContainer : "transparent" border.width: Config.border_width border.color: cardCloseArea.containsMouse ? Colors.error : histCard.textColor Text { id: cardCloseText anchors.centerIn: parent text: "✕" font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: cardCloseArea.containsMouse ? Colors.error_fg : histCard.textColor } } onClicked: function(mouse) { mouse.accepted = true if (histCard.notification.tracked) histCard.notification.dismiss() else histCard.notification.close() } } Row { id: cardRow anchors.fill: parent anchors.margins: Config.spacing anchors.rightMargin: cardCloseArea.width + Config.spacing spacing: Config.spacing // icon side Rectangle { id: iconColWrapper width: 1.25 * iconImg.width height: Math.max(iconCol.implicitHeight, bodyCol.implicitHeight) color: "transparent" Column { id: iconCol width: parent.width spacing: Config.spacing/2 Image { id: iconImg width: root.screen.width / 32 height: width anchors.horizontalCenter: parent.horizontalCenter asynchronous: true fillMode: Image.PreserveAspectFit source: { if (histCard.notification.image) return histCard.notification.image if (histCard.notification.appIcon) return Quickshell.iconPath(histCard.notification.appIcon) const entry = histCard.notification.desktopEntry ? DesktopEntries.byId(histCard.notification.desktopEntry) : null if (entry && entry.icon) { const p = Quickshell.iconPath(entry.icon) if (p && p !== "") return p } return Quickshell.iconPath("notifications") } } Text { id: histSummary text: histCard.notification.summary visible: histCard.notification.summary ? true : false width: parent.width font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) font.bold: true color: histCard.textColor wrapMode: Text.Wrap horizontalAlignment: Text.AlignHCenter } } } // body side Rectangle { id: bodyWrapper width: parent.width - iconColWrapper.width - cardRow.spacing height: Math.max(iconCol.implicitHeight, bodyCol.implicitHeight) color: "transparent" Column { id: bodyCol width: parent.width spacing: Config.spacing/2 Text { id: bodyTitle text: { if (histCard.notification.image) return histCard.notification.appName.charAt(0).toUpperCase() + histCard.notification.appName.slice(1) return histCard.notification.appName ? histCard.notification.appName : "Message:" } font.family: Config.font font.bold: true font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: histCard.textColor elide: Text.ElideRight width: parent.width } Text { id: bodyText text: histCard.notification.body visible: histCard.notification.body ? true : false width: parent.width font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: histCard.textColor wrapMode: Text.Wrap textFormat: Text.RichText } Text { text: { // time hint if available, else empty if (histCard.notification.hints["x-dunst-stack-tag"]) return "" return "" } visible: false font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: Colors.outline } } } } } // actions row Rectangle { id: histActionRowRect width: parent.width height: Config.screen_height_to_font_tiny(root.screen.height)+2*Config.spacing color: "transparent" border.width: Config.border_width border.color: histCard.textColor visible: histCard.notification.actions.length > 0 Row { id: histActionsRow spacing: Config.spacing anchors.fill: parent anchors.margins: Config.spacing property var cardRoot: histCard Repeater { id: histActionRepeater model: histCard.notification.actions delegate: MouseArea { required property var modelData id: histActionArea width: histActionsRow.width / histActionsRow.cardRoot.notification.actions.length height: histActionRect.height anchors.verticalCenter: parent.verticalCenter hoverEnabled: true cursorShape: Qt.PointingHandCursor Rectangle { id: histActionRect width: parent.width height: histActionRow.height + Config.spacing color: histActionArea.containsMouse ? Colors.secondaryContainer_fg : Colors.secondaryContainer border.color: histActionArea.containsMouse ? Colors.secondary_fg : Colors.secondary border.width: Config.border_width radius: Config.radius_fun(histActionRect.height)*2 Row { id: histActionRow anchors.centerIn: parent spacing: Config.spacing/2 Text { text: modelData.text width: Math.min(histActionRect.width - Config.spacing*2, implicitWidth) elide: Text.ElideRight font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: histActionArea.containsMouse ? Colors.secondary_fg : Colors.secondary } } } onClicked: function(mouse) { mouse.accepted = true modelData.invoke() // after invoke, dismiss if not resident handled by server if (histCard.notification.tracked && !histCard.notification.resident) { // optionally keep in history; don't auto dismiss } } } } } } // inline reply Rectangle { id: histInlineRect width: parent.width height: Config.screen_height_to_font_small(root.screen.height)+2*Config.spacing color: "transparent" border.width: Config.border_width border.color: histCard.textColor visible: histCard.notification.hasInlineReply function sendReply(message) { histCard.notification.sendInlineReply(message) inlineField.focus = false // keep notification in history; optionally dismiss? } Row { id: histInlineRow spacing: Config.spacing anchors.fill: parent anchors.margins: Config.spacing TextField { id: inlineField width: parent.width - inlineSendArea.width - Config.spacing height: inlineSendText.height + Config.spacing anchors.verticalCenter: parent.verticalCenter hoverEnabled: true activeFocusOnTab: true placeholderText: histCard.notification.hints["x-kde-reply-placeholder-text"] != null ? histCard.notification.hints["x-kde-reply-placeholder-text"] : histCard.notification.inlineReplyPlaceholder placeholderTextColor: inlineField.activeFocus ? Colors.secondary_fg : Colors.secondary font.family: Config.font leftPadding: Config.spacing background: Rectangle { radius: Config.radius color: inlineField.activeFocus ? Colors.tertiary_fg : Colors.primary_fg border.color: inlineField.activeFocus ? Colors.tertiary : Colors.primary border.width: Config.border_width } onAccepted: { histInlineRect.sendReply(text) } } MouseArea { id: inlineSendArea width: inlineSendText.width + 2*Config.spacing height: inlineSendText.height + Config.spacing anchors.verticalCenter: parent.verticalCenter hoverEnabled: true cursorShape: Qt.PointingHandCursor Rectangle { anchors.fill: parent radius: height*0.5 color: inlineSendArea.containsMouse ? Colors.secondaryContainer_fg : Colors.secondaryContainer border.color: inlineSendArea.containsMouse ? Colors.secondary_fg : Colors.secondary border.width: Config.border_width Text { id: inlineSendText text: "SEND" anchors.centerIn: parent font.family: Config.font font.pixelSize: Config.screen_height_to_font_tiny(root.screen.height) color: inlineSendArea.containsMouse ? Colors.secondary_fg : Colors.secondary } } onClicked: function(mouse) { mouse.accepted = true histInlineRect.sendReply(inlineField.text) } } } } } } } } } } } } } } // debug Component.onCompleted: { Config.debug == "Notification" && console.log("[NotificationOverview " + Hyprland.monitorFor(screen).activeWorkspace.id + "] completed") } }