From a502faef19959ee265d2d41cfb477f7ebe97747c Mon Sep 17 00:00:00 2001 From: Damocles Date: Fri, 17 Apr 2026 10:24:45 +0200 Subject: [PATCH] notifs: extract NotifCard shared component (background, progress, urgency bar, icon, text, dismiss button) --- modules/NotifCard.qml | 241 ++++++++++++++++++++++++++++++++++++++++ modules/NotifCenter.qml | 195 ++------------------------------ modules/NotifPopup.qml | 199 ++++----------------------------- 3 files changed, 271 insertions(+), 364 deletions(-) create mode 100644 modules/NotifCard.qml diff --git a/modules/NotifCard.qml b/modules/NotifCard.qml new file mode 100644 index 0000000..bd19ee0 --- /dev/null +++ b/modules/NotifCard.qml @@ -0,0 +1,241 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Notifications +import "." as M + +// Shared notification card: background, progress bar, urgency bar, icon, text, dismiss button. +// Does NOT include dismiss animation or dismiss logic — emits dismissRequested() instead. +Item { + id: root + + required property var notif // NotifItem (may be null — all accesses use ?.) + + property bool showAppName: true + property int iconSize: 32 + property int bodyMaxLines: 3 + property color accentColor: M.Theme.base0D + + signal dismissRequested + + // Tall enough for content including padding, or the icon if it's taller + implicitHeight: Math.max(_contentCol.implicitHeight, _icon.visible ? _icon.height : 0) + 16 + + HoverHandler { + id: _hover + } + + // Background: base01, base02 on hover + Rectangle { + anchors.fill: parent + color: _hover.hovered ? M.Theme.base02 : M.Theme.base01 + opacity: Math.max(M.Theme.barOpacity, 0.9) + radius: M.Theme.radius + + Behavior on color { + ColorAnimation { + duration: 100 + } + } + + // Progress bar fill (hint value) + Rectangle { + visible: (root.notif?.hints?.value ?? -1) >= 0 + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.left: parent.left + width: parent.width * Math.min(1, Math.max(0, (root.notif?.hints?.value ?? 0) / 100)) + color: M.Theme.base03 + radius: parent.radius + + Behavior on width { + NumberAnimation { + duration: 200 + } + } + } + } + + // Urgency accent bar + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: 3 + radius: 1 + color: { + const u = root.notif?.urgency ?? NotificationUrgency.Normal; + return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; + } + } + + // App icon / image + Image { + id: _icon + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.top: parent.top + anchors.topMargin: 8 + width: root.iconSize + height: root.iconSize + source: { + const img = root.notif?.image; + if (img) + return img; + const ic = root.notif?.appIcon; + if (!ic) + return ""; + return (ic.startsWith("/") || ic.startsWith("file://")) ? ic : Quickshell.iconPath(ic, "dialog-information"); + } + visible: status === Image.Ready + fillMode: Image.PreserveAspectFit + sourceSize: Qt.size(root.iconSize, root.iconSize) + asynchronous: true + } + + // Dismiss button — overlays top-right, visible only on hover (opacity keeps layout stable) + Text { + id: _dismissBtn + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.top: parent.top + anchors.topMargin: 8 + text: "\uF00D" + color: _dismissHover.hovered ? M.Theme.base08 : M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.iconFontFamily + opacity: _hover.hovered ? 1 : 0 + + HoverHandler { + id: _dismissHover + } + + TapHandler { + cursorShape: Qt.PointingHandCursor + onTapped: root.dismissRequested() + } + } + + Column { + id: _contentCol + anchors.left: _icon.visible ? _icon.right : parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.leftMargin: _icon.visible ? 8 : 14 + anchors.rightMargin: 12 + anchors.topMargin: 8 + spacing: 2 + + // App name + time row (optional) + Row { + visible: root.showAppName + width: parent.width + + Text { + text: root.notif?.appName ?? "Notification" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + width: parent.width - _timeText.implicitWidth - 4 + elide: Text.ElideRight + } + + Text { + id: _timeText + text: root.notif?.timeStr ?? "" + color: M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + } + + // Summary (with inline time when app name row is hidden) + Row { + visible: !root.showAppName + width: parent.width + + Text { + text: root.notif?.summary ?? "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + width: parent.width - _inlineTime.implicitWidth - 4 + } + + Text { + id: _inlineTime + text: root.notif?.timeStr ?? "" + color: M.Theme.base03 + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + anchors.verticalCenter: parent.verticalCenter + } + } + + Text { + visible: root.showAppName + width: parent.width + text: root.notif?.summary ?? "" + color: M.Theme.base05 + font.pixelSize: M.Theme.fontSize + font.family: M.Theme.fontFamily + font.bold: true + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: 2 + } + + Text { + width: parent.width + text: root.notif?.body ?? "" + color: M.Theme.base04 + font.pixelSize: M.Theme.fontSize - 1 + font.family: M.Theme.fontFamily + elide: Text.ElideRight + wrapMode: Text.WordWrap + maximumLineCount: root.bodyMaxLines + visible: text !== "" + } + + // Action buttons + Row { + spacing: 6 + visible: !!(root.notif?.actions?.length) + + Repeater { + model: root.notif?.actions ?? [] + delegate: Rectangle { + required property var modelData + width: _actText.implicitWidth + 12 + height: _actText.implicitHeight + 6 + radius: M.Theme.radius + color: _actHover.hovered ? M.Theme.base03 : "transparent" + border.color: M.Theme.base03 + border.width: 1 + + Text { + id: _actText + anchors.centerIn: parent + text: parent.modelData.text + color: root.accentColor + font.pixelSize: M.Theme.fontSize - 2 + font.family: M.Theme.fontFamily + } + + HoverHandler { + id: _actHover + } + + TapHandler { + cursorShape: Qt.PointingHandCursor + onTapped: { + parent.modelData.invoke(); + M.NotifService.dismiss(root.notif.id); + } + } + } + } + } + } +} diff --git a/modules/NotifCenter.qml b/modules/NotifCenter.qml index 0bc8549..ee07ebf 100644 --- a/modules/NotifCenter.qml +++ b/modules/NotifCenter.qml @@ -1,6 +1,5 @@ import QtQuick import Quickshell -import Quickshell.Services.Notifications import "." as M M.HoverPanel { @@ -223,7 +222,7 @@ M.HoverPanel { clip: true opacity: 0 - readonly property real _targetHeight: _type === "header" ? 28 : (notifContent.implicitHeight + 12) + readonly property real _targetHeight: _type === "header" ? 28 : _notifCard.implicitHeight property real _heightScale: 1 property bool _skipDismiss: false @@ -318,193 +317,15 @@ M.HoverPanel { } // ---- Individual notification ---- - Item { - id: _notifRow + M.NotifCard { + id: _notifCard visible: notifDelegate._type === "notif" anchors.fill: parent - - // Hover + progress bar background - Rectangle { - anchors.fill: parent - anchors.leftMargin: 4 - anchors.rightMargin: 4 - color: notifArea.containsMouse ? M.Theme.base02 : "transparent" - radius: M.Theme.radius - - Rectangle { - visible: (notifDelegate._notif?.hints?.value ?? -1) >= 0 - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.left: parent.left - width: parent.width * Math.min(1, Math.max(0, (notifDelegate._notif?.hints?.value ?? 0) / 100)) - color: M.Theme.base02 - radius: parent.radius - - Behavior on width { - NumberAnimation { - duration: 200 - } - } - } - } - - // Urgency accent bar - 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 = notifDelegate._notif?.urgency ?? NotificationUrgency.Normal; - return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; - } - } - - // App icon / image - Image { - id: ncIcon - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.top: parent.top - anchors.topMargin: 6 - width: 24 - height: 24 - source: { - if (notifDelegate._type !== "notif") - return ""; - const img = notifDelegate._notif?.image; - if (img) - return img; - const ic = notifDelegate._notif?.appIcon; - if (!ic) - return ""; - return (ic.startsWith("/") || ic.startsWith("file://")) ? ic : Quickshell.iconPath(ic, "dialog-information"); - } - visible: status === Image.Ready - fillMode: Image.PreserveAspectFit - sourceSize: Qt.size(24, 24) - asynchronous: true - } - - Column { - id: notifContent - anchors.left: ncIcon.visible ? ncIcon.right : parent.left - anchors.right: dismissBtn.left - anchors.top: parent.top - anchors.leftMargin: ncIcon.visible ? 6 : 14 - anchors.topMargin: 6 - spacing: 1 - - // Summary + time on the same row - Row { - width: parent.width - - Text { - text: notifDelegate._notif?.summary ?? "" - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - font.bold: true - elide: Text.ElideRight - width: parent.width - _notifTime.implicitWidth - 4 - } - - Text { - id: _notifTime - text: notifDelegate._notif?.timeStr ?? "" - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - } - - Text { - width: parent.width - text: notifDelegate._notif?.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: !!(notifDelegate._notif?.actions?.length) - - Repeater { - model: notifDelegate._notif?.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(notifDelegate._notif.id); - } - } - } - } - } - } - - // Per-notification 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: notifDelegate.dismiss() - } - } - - // Right-click to dismiss - MouseArea { - id: notifArea - anchors.fill: parent - z: -1 - hoverEnabled: true - acceptedButtons: Qt.RightButton - onClicked: notifDelegate.dismiss() - } + notif: notifDelegate._notif + showAppName: false + iconSize: 24 + bodyMaxLines: 2 + onDismissRequested: notifDelegate.dismiss() } SequentialAnimation { diff --git a/modules/NotifPopup.qml b/modules/NotifPopup.qml index 493975b..b7a2779 100644 --- a/modules/NotifPopup.qml +++ b/modules/NotifPopup.qml @@ -47,9 +47,29 @@ PanelWindow { x: 320 clip: true - readonly property real _targetHeight: contentCol.height + 16 + readonly property real _targetHeight: _card.implicitHeight property real _heightScale: 0 + // Glow on critical — layer effect composites everything including NotifCard + layer.enabled: popupItem.modelData.urgency === NotificationUrgency.Critical + layer.effect: MultiEffect { + shadowEnabled: true + shadowColor: M.Theme.base08 + shadowBlur: 0.6 + shadowVerticalOffset: 0 + shadowHorizontalOffset: 0 + } + + M.NotifCard { + id: _card + anchors.fill: parent + notif: popupItem.modelData + showAppName: true + iconSize: 36 + bodyMaxLines: 3 + onDismissRequested: popupItem.animateDismiss(true) + } + property bool _entered: false Component.onCompleted: { if (popupCol._knownIds[modelData.id]) { @@ -105,180 +125,6 @@ PanelWindow { } } - // Background - Rectangle { - anchors.fill: parent - color: M.Theme.base01 - opacity: Math.max(M.Theme.barOpacity, 0.9) - radius: M.Theme.radius - - // Progress fill as background - Rectangle { - visible: (popupItem.modelData.hints?.value ?? -1) >= 0 - anchors.top: parent.top - anchors.bottom: parent.bottom - anchors.left: parent.left - width: parent.width * Math.min(1, Math.max(0, (popupItem.modelData.hints?.value ?? 0) / 100)) - color: M.Theme.base02 - radius: parent.radius - - Behavior on width { - NumberAnimation { - duration: 200 - } - } - } - } - - // Urgency accent bar - Rectangle { - anchors.left: parent.left - anchors.top: parent.top - anchors.bottom: parent.bottom - width: 3 - radius: M.Theme.radius - color: { - const u = popupItem.modelData.urgency; - return u === NotificationUrgency.Critical ? M.Theme.base08 : u === NotificationUrgency.Low ? M.Theme.base04 : M.Theme.base0D; - } - } - - // Glow on critical - layer.enabled: popupItem.modelData.urgency === NotificationUrgency.Critical - layer.effect: MultiEffect { - shadowEnabled: true - shadowColor: M.Theme.base08 - shadowBlur: 0.6 - shadowVerticalOffset: 0 - shadowHorizontalOffset: 0 - } - - Image { - id: notifIcon - anchors.left: parent.left - anchors.leftMargin: 14 - anchors.top: parent.top - anchors.topMargin: 8 - width: 36 - height: 36 - source: { - const img = popupItem.modelData.image; - if (img) - return img; - const ic = popupItem.modelData.appIcon; - if (!ic) - return ""; - return (ic.startsWith("/") || ic.startsWith("file://")) ? ic : Quickshell.iconPath(ic, "dialog-information"); - } - visible: status === Image.Ready - fillMode: Image.PreserveAspectFit - sourceSize: Qt.size(36, 36) - asynchronous: true - } - - Column { - id: contentCol - anchors.left: notifIcon.visible ? notifIcon.right : parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - anchors.leftMargin: notifIcon.visible ? 8 : 14 - spacing: 2 - - // App name + time - Row { - width: parent.width - Text { - text: popupItem.modelData.appName || "Notification" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - width: parent.width - timeLabel.width - elide: Text.ElideRight - } - Text { - id: timeLabel - text: { - const diff = Math.floor((Date.now() - popupItem.modelData.time) / 1000); - if (diff < 5) - return "now"; - if (diff < 60) - return diff + "s"; - return Math.floor(diff / 60) + "m"; - } - color: M.Theme.base03 - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - } - - // Summary - Text { - width: parent.width - text: popupItem.modelData.summary || "" - color: M.Theme.base05 - font.pixelSize: M.Theme.fontSize - font.family: M.Theme.fontFamily - font.bold: true - elide: Text.ElideRight - wrapMode: Text.WordWrap - maximumLineCount: 2 - } - - // Body - Text { - width: parent.width - text: popupItem.modelData.body || "" - color: M.Theme.base04 - font.pixelSize: M.Theme.fontSize - 1 - font.family: M.Theme.fontFamily - elide: Text.ElideRight - wrapMode: Text.WordWrap - maximumLineCount: 3 - visible: text !== "" - } - - // Actions - Row { - spacing: 6 - visible: popupItem.modelData.actions.length > 0 - - Repeater { - model: popupItem.modelData.actions - - delegate: Rectangle { - required property var modelData - width: actionText.implicitWidth + 12 - height: actionText.implicitHeight + 6 - radius: M.Theme.radius - color: actionArea.containsMouse ? M.Theme.base02 : M.Theme.base01 - border.color: M.Theme.base03 - border.width: 1 - - Text { - id: actionText - anchors.centerIn: parent - text: parent.modelData.text - color: M.Theme.base0D - font.pixelSize: M.Theme.fontSize - 2 - font.family: M.Theme.fontFamily - } - - MouseArea { - id: actionArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - parent.modelData.invoke(); - M.NotifService.dismiss(popupItem.modelData.id); - } - } - } - } - } - } - property bool _fullDismiss: false function animateDismiss(full) { @@ -292,7 +138,6 @@ PanelWindow { SequentialAnimation { id: slideOut - // Swipe right ParallelAnimation { NumberAnimation { target: popupItem @@ -309,7 +154,6 @@ PanelWindow { easing.type: Easing.InCubic } } - // Collapse height so items below float up NumberAnimation { target: popupItem property: "_heightScale" @@ -322,6 +166,7 @@ PanelWindow { } } + // Click anywhere to dismiss (left = popup only, right = full dismiss) MouseArea { anchors.fill: parent z: -1