pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Widgets PopupCard { id: card required property var modelData property bool compact: false signal dismiss(string id) signal invokeAction(string id, string actionId) signal replyEdited(string id, string text) signal sendReply(string id, string text) property string replyDraft: "" property string replyError: "" readonly property bool canReply: !compact && !!modelData.hasInlineReply onReplyDraftChanged: if (replyInput.text !== replyDraft) replyInput.text = replyDraft // notify-send funnels BOTH named freedesktop icons and attached photos into // `image` as an image://icon/ provider URL (appIcon stays empty). Split them: // a real picture (absolute path = double slash, or raster extension) renders // as the large preview; a named icon renders as the small identity glyph. readonly property string photoCandidate: String(modelData.image || "") readonly property bool imageIsPhoto: photoCandidate.indexOf("image://icon//") === 0 || /\.(png|jpe?g|gif|webp|bmp)(\?|#|$)/i.test(photoCandidate) readonly property string notificationIconSource: modelData.appIcon || (imageIsPhoto ? "" : photoCandidate) // image://icon//abs/path -> file:///abs/path so plain Image keeps true aspect readonly property string photoSource: !imageIsPhoto ? "" : (photoCandidate.indexOf("image://icon//") === 0 ? "file://" + photoCandidate.substring(13) : photoCandidate) // KDE/Plasma progress hint (0-100); -1 when the notification has no value. readonly property int progress: modelData.value !== undefined ? Number(modelData.value) : -1 readonly property bool hasProgress: progress >= 0 // local text styling, mirrors PopoutHost NuguText variants component CardText: Text { required property Theme theme property string variant: "body" color: !theme ? "transparent" : (variant === "title" ? theme.ui.important_global : (variant === "meta" ? theme.ui.normal : theme.content.normal)) font.family: variant === "mono" ? "IBM Plex Mono" : "IBM Plex Sans" font.pixelSize: variant === "title" ? 15 : (variant === "meta" ? 13 : 14) font.bold: variant === "title" elide: Text.ElideRight } function relTime(ms) { const now = Date.now(); const diff = Math.max(0, now - Number(ms || now)); const m = Math.floor(diff / 60000); if (m < 1) return "now"; if (m < 60) return `${m}m`; const h = Math.floor(m / 60); if (h < 24) return `${h}h`; return `${Math.floor(h / 24)}d`; } padding: 0 interactive: compact || hasDefault hoverable: true accessibleName: compact ? "dismiss notification" : (hasDefault ? "open notification" : "notification") tooltip: interactive ? accessibleName : "" active: String(modelData.urgency || "normal") === "normal" destructive: String(modelData.urgency || "normal") === "critical" radius: compact ? 12 : 10 implicitHeight: cardBody.implicitHeight + (compact ? 16 : 20) // freedesktop "default" action: invoked by activating the notification body. readonly property var defaultAction: (modelData.actionRows || []).find(a => a.id === "default") || null readonly property bool hasDefault: !!defaultAction onClicked: { if (card.hasDefault) card.invokeAction(String(card.modelData.id), "default"); if (card.compact) card.dismiss(String(card.modelData.id)); } Row { id: cardBody anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: 12 anchors.rightMargin: 10 spacing: 10 IconImage { anchors.top: card.compact ? undefined : parent.top anchors.verticalCenter: card.compact ? parent.verticalCenter : undefined width: card.compact ? 20 : 36 height: width source: card.notificationIconSource visible: source.toString().length > 0 } Column { // size off the card's own (externally-set) width, NOT parent.width: // cardBody is a Row whose width derives from content, so reading it // here collapses the column to 0 (a binding loop). 22 = left+right margins. width: card.width - 22 - (card.compact ? 30 : 56) - closeCol.width spacing: card.compact ? 0 : 3 Row { width: parent.width spacing: 6 visible: !card.compact CardText { theme: card.theme variant: "meta" text: card.modelData.appName || "unknown" width: parent.width - timeText.width - copyBtn.width - parent.spacing * 2 elide: Text.ElideRight } PopupButton { id: copyBtn visible: (card.modelData.body || "").length > 0 theme: card.theme width: visible ? 26 : 0 height: 22 role: "ghost" showText: false icon: "󰆏" accessibleName: "copy notification body" tooltip: accessibleName onClicked: card.invokeAction(String(card.modelData.id), "copy-body") } CardText { id: timeText theme: card.theme variant: "meta" text: card.relTime(card.modelData.time) } } CardText { theme: card.theme variant: card.compact ? "body" : "title" text: card.modelData.summary || "notification" width: parent.width elide: Text.ElideRight } CardText { theme: card.theme variant: "meta" text: card.modelData.body || "" width: parent.width visible: !card.compact && text.length > 0 wrapMode: Text.WordWrap maximumLineCount: 3 elide: Text.ElideRight } Image { source: card.photoSource visible: !card.compact && status === Image.Ready width: Math.min(parent.width, 220) fillMode: Image.PreserveAspectFit sourceSize.width: 220 } // content progress (KDE int:value); distinct from the toast's // auto-dismiss drain. Full card only — the toast shows it inline. Item { width: parent.width height: card.hasProgress && !card.compact ? 8 : 0 visible: card.hasProgress && !card.compact Rectangle { anchors.verticalCenter: parent.verticalCenter width: parent.width height: 4 radius: 2 color: card.theme ? card.theme.withAlpha(card.theme.ui.minor, 0.25) : "transparent" Rectangle { width: parent.width * Math.max(0, Math.min(100, card.progress)) / 100 height: parent.height radius: 2 color: card.theme ? card.theme.ui.accent : "transparent" Behavior on width { NumberAnimation { duration: card.theme && card.theme.lowPowerMode ? 0 : 200 } } } } } Flow { width: parent.width spacing: 6 visible: !card.compact && actionModel.length > 0 property var actionModel: (card.modelData.actionRows || []).filter(a => a.kind !== "close" && a.kind !== "copy-body" && a.id !== "default") Repeater { model: parent.actionModel delegate: PopupButton { id: actionBtn required property var modelData readonly property string actIcon: modelData.icon ? Quickshell.iconPath(String(modelData.icon), true) : "" theme: card.theme height: 26 text: modelData.label || "act" iconSource: actionBtn.actIcon onClicked: card.invokeAction(String(card.modelData.id), String(actionBtn.modelData.id)) } } } PopupTextField { id: replyInput width: parent.width height: card.canReply ? 30 : 0 visible: card.canReply theme: card.theme placeholderText: card.modelData.inlineReplyPlaceholder || "reply…" Component.onCompleted: text = card.replyDraft onEdited: text => card.replyEdited(String(card.modelData.id), text) onAccepted: if (text.length > 0) card.sendReply(String(card.modelData.id), text) } CardText { theme: card.theme variant: "meta" width: parent.width visible: card.replyError.length > 0 text: card.replyError color: card.theme ? card.theme.error : "transparent" } } PopupButton { id: closeCol theme: card.theme width: card.compact ? 0 : 24 height: card.compact ? 0 : 24 visible: !card.compact role: "ghost" showText: false icon: "×" accessibleName: "close notification" tooltip: accessibleName opacity: cardHover.hovered || activeFocus ? 1 : 0 onClicked: card.dismiss(String(card.modelData.id)) } } HoverHandler { id: cardHover } }