Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

quickshell/nuguland/NotificationCard.qml

Raw
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
    }
}