repositories / dotfiles
dotfiles
bugabingas dorkfiles
owned by admin
quickshell/nuguland/NotificationCard.qml
Rawpragma 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
}
}