pragma ComponentBehavior: Bound import QtQuick import QtQuick.Shapes import Quickshell import Quickshell.Services.Notifications import Caelestia.Components import Caelestia.Config import qs.components import qs.components.controls import qs.components.effects import qs.services import qs.utils StyledRect { id: root required property NotifData modelData readonly property bool hasImage: modelData.image.length > 0 readonly property bool hasAppIcon: modelData.appIcon.length > 0 readonly property int bodyTextFormat: /[<*_`#\[\]]/.test(modelData.body) ? Text.MarkdownText : Text.PlainText readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 property bool expanded: Config.notifs.openExpanded color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondaryContainer : Colours.tPalette.m3surfaceContainer radius: Tokens.rounding.large implicitHeight: inner.implicitHeight x: implicitWidth Component.onCompleted: { x = 0; modelData.lock(this); } Component.onDestruction: modelData.unlock(this) Behavior on x { Anim { easing: Tokens.anim.emphasizedDecel } } MouseArea { property int startY anchors.fill: parent hoverEnabled: true cursorShape: root.expanded && body.hoveredLink ? Qt.PointingHandCursor : pressed ? Qt.ClosedHandCursor : undefined acceptedButtons: Qt.LeftButton | Qt.MiddleButton preventStealing: true onEntered: root.modelData.timer.stop() onExited: { if (!pressed) root.modelData.timer.start(); } drag.target: parent drag.axis: Drag.XAxis onPressed: event => { root.modelData.timer.stop(); startY = event.y; if (event.button === Qt.MiddleButton) root.modelData.close(); } onReleased: event => { if (!containsMouse) root.modelData.timer.start(); if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold) root.x = 0; else root.modelData.popup = false; } onPositionChanged: event => { if (pressed) { const diffY = event.y - startY; if (Math.abs(diffY) > Config.notifs.expandThreshold) root.expanded = diffY > 0; } } onClicked: event => { if (!GlobalConfig.notifs.actionOnClick || event.button !== Qt.LeftButton) return; const actions = root.modelData.actions; if (actions.length === 1) actions[0].invoke(); } Item { id: inner anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.margins: Tokens.padding.medium implicitHeight: root.nonAnimHeight Behavior on implicitHeight { Anim {} } Loader { id: image asynchronous: true active: root.hasImage anchors.left: parent.left anchors.top: parent.top width: TokenConfig.sizes.notifs.image height: TokenConfig.sizes.notifs.image visible: root.hasImage || root.hasAppIcon sourceComponent: StyledClippingRect { radius: Tokens.rounding.full color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) : Colours.palette.m3secondaryContainer implicitWidth: TokenConfig.sizes.notifs.image implicitHeight: TokenConfig.sizes.notifs.image Image { anchors.fill: parent source: Qt.resolvedUrl(root.modelData.image) fillMode: Image.PreserveAspectCrop sourceSize: { const size = TokenConfig.sizes.notifs.image * ((QsWindow.window as QsWindow)?.devicePixelRatio ?? 1); return Qt.size(size, size); } cache: false asynchronous: true } } } Loader { id: appIcon asynchronous: true active: root.hasAppIcon || !root.hasImage anchors.horizontalCenter: root.hasImage ? undefined : image.horizontalCenter anchors.verticalCenter: root.hasImage ? undefined : image.verticalCenter anchors.right: root.hasImage ? image.right : undefined anchors.bottom: root.hasImage ? image.bottom : undefined sourceComponent: StyledRect { radius: Tokens.rounding.full color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3error : root.modelData.urgency === NotificationUrgency.Low ? Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) : Colours.palette.m3secondaryContainer implicitWidth: root.hasImage ? Tokens.sizes.notifs.badge : TokenConfig.sizes.notifs.image implicitHeight: root.hasImage ? Tokens.sizes.notifs.badge : TokenConfig.sizes.notifs.image Loader { id: icon asynchronous: true active: root.hasAppIcon anchors.centerIn: parent width: Math.round(parent.width * 0.6) height: Math.round(parent.width * 0.6) sourceComponent: ColouredIcon { anchors.fill: parent source: Quickshell.iconPath(root.modelData.appIcon) colour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer layer.enabled: root.modelData.appIcon.endsWith("symbolic") } } Loader { asynchronous: true active: !root.hasAppIcon anchors.centerIn: parent anchors.verticalCenterOffset: 1 sourceComponent: MaterialIcon { text: Icons.getNotifIcon(root.modelData.summary, root.modelData.urgency) color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onError : root.modelData.urgency === NotificationUrgency.Low ? Colours.palette.m3onSurface : Colours.palette.m3onSecondaryContainer fontStyle: Tokens.font.icon.medium } } } } Shape { id: progressIndicator anchors.centerIn: appIcon width: appIcon.implicitWidth + progressShape.strokeWidth * 2 height: appIcon.implicitHeight + progressShape.strokeWidth * 2 preferredRendererType: Shape.CurveRenderer ShapePath { id: progressShape capStyle: ShapePath.RoundCap fillColor: "transparent" strokeWidth: 2 strokeColor: Colours.palette.m3primary PathAngleArc { id: progressArc radiusX: progressIndicator.width / 2 - root.Tokens.padding.extraSmall / 2 centerX: progressIndicator.width / 2 radiusY: progressIndicator.height / 2 - root.Tokens.padding.extraSmall / 2 centerY: progressIndicator.height / 2 startAngle: -90 sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360 Behavior on sweepAngle { Anim { easing: Tokens.anim.emphasizedDecel } } } } } StyledText { id: appName anchors.top: parent.top anchors.left: image.right anchors.leftMargin: Tokens.spacing.medium animate: true text: appNameMetrics.elidedText maximumLineCount: 1 color: Colours.palette.m3onSurfaceVariant font: Tokens.font.label.medium opacity: root.expanded ? 1 : 0 Behavior on opacity { Anim { type: Anim.DefaultEffects } } } TextMetrics { id: appNameMetrics text: root.modelData.appName font: appName.font elide: Text.ElideRight elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - root.Tokens.spacing.small * 3 } StyledText { id: summary anchors.top: parent.top anchors.left: image.right anchors.leftMargin: Tokens.spacing.medium animate: true text: summaryMetrics.elidedText maximumLineCount: 1 height: implicitHeight states: State { name: "expanded" when: root.expanded PropertyChanges { summary.maximumLineCount: undefined summary.anchors.topMargin: root.Tokens.spacing.extraSmall bodyPreview.anchors.topMargin: root.Tokens.spacing.extraSmall body.anchors.topMargin: root.Tokens.spacing.extraSmall } AnchorChanges { target: summary anchors.top: appName.bottom } } transitions: Transition { PropertyAction { target: summary property: "maximumLineCount" } Anim { property: "topMargin" } AnchorAnim {} } Behavior on height { Anim {} } } TextMetrics { id: summaryMetrics text: root.modelData.summary font: summary.font elide: Text.ElideRight elideWidth: expandBtn.x - time.width - timeSep.width - summary.x - root.Tokens.spacing.small * 3 } StyledText { id: timeSep anchors.top: parent.top anchors.left: summary.right anchors.leftMargin: Tokens.spacing.small text: "•" color: Colours.palette.m3onSurfaceVariant font: Tokens.font.body.small states: State { name: "expanded" when: root.expanded AnchorChanges { target: timeSep anchors.left: appName.right } } transitions: Transition { AnchorAnim {} } } StyledText { id: time anchors.top: parent.top anchors.left: timeSep.right anchors.leftMargin: Tokens.spacing.small animate: true horizontalAlignment: Text.AlignLeft text: root.modelData.timeStr color: Colours.palette.m3onSurfaceVariant font: Tokens.font.body.small } Item { id: expandBtn anchors.right: parent.right anchors.top: parent.top implicitWidth: expandIcon.implicitHeight implicitHeight: expandIcon.implicitHeight StateLayer { radius: Tokens.rounding.full color: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondaryContainer : Colours.palette.m3onSurface onClicked: root.expanded = !root.expanded } MaterialIcon { id: expandIcon anchors.centerIn: parent text: "expand_more" fontStyle: Tokens.font.icon.medium rotation: root.expanded ? 180 : 0 Behavior on rotation { Anim {} } } } StyledText { id: bodyPreview anchors.left: summary.left anchors.right: expandBtn.left anchors.top: summary.bottom anchors.rightMargin: Tokens.spacing.small animate: true textFormat: root.bodyTextFormat text: bodyPreviewMetrics.elidedText color: Colours.palette.m3onSurfaceVariant font: Tokens.font.body.small opacity: root.expanded ? 0 : 1 Behavior on opacity { Anim { type: Anim.DefaultEffects } } } TextMetrics { id: bodyPreviewMetrics text: root.modelData.body font: bodyPreview.font elide: Text.ElideRight elideWidth: bodyPreview.width } StyledText { id: body anchors.left: summary.left anchors.right: expandBtn.left anchors.top: summary.bottom anchors.rightMargin: Tokens.spacing.small animate: true textFormat: root.bodyTextFormat text: root.modelData.body color: Colours.palette.m3onSurfaceVariant font: Tokens.font.body.small wrapMode: Text.WrapAtWordBoundaryOrAnywhere height: text ? implicitHeight : 0 onLinkActivated: link => { if (!root.expanded) return; Quickshell.execDetached(["app2unit", "-O", "--", link]); root.modelData.popup = false; } opacity: root.expanded ? 1 : 0 Behavior on opacity { Anim { type: Anim.DefaultEffects } } } ButtonRow { id: actions anchors.left: body.left anchors.right: body.right anchors.top: body.bottom anchors.topMargin: Tokens.spacing.small spacing: Tokens.spacing.extraSmall opacity: root.expanded ? 1 : 0 Behavior on opacity { Anim { type: Anim.DefaultEffects } } IconButton { isRound: true shapeMorph: true fillWidth: root.modelData.actions.length === 0 inactiveColour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondary : Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) inactiveOnColour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurfaceVariant icon: "close" padding: Tokens.padding.extraSmall onClicked: root.modelData.close() } Repeater { model: root.modelData.actions TextButton { required property var modelData isRound: true shapeMorph: true fillWidth: true inactiveColour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondary : Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) inactiveOnColour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurfaceVariant text: modelData.text onClicked: modelData.invoke() label.horizontalAlignment: Text.AlignHCenter label.anchors.left: left label.anchors.right: right label.anchors.verticalCenter: verticalCenter label.anchors.centerIn: undefined label.anchors.margins: Tokens.padding.medium label.elide: Text.ElideRight } } IconButton { isRound: true shapeMorph: true fillWidth: root.modelData.actions.length === 0 inactiveColour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3secondary : Colours.layer(Colours.palette.m3surfaceContainerHighest, 2) inactiveOnColour: root.modelData.urgency === NotificationUrgency.Critical ? Colours.palette.m3onSecondary : Colours.palette.m3onSurfaceVariant icon: copyTimer.running ? "inventory" : "content_copy" padding: Tokens.padding.extraSmall onClicked: { Quickshell.clipboardText = root.modelData.body; copyTimer.restart(); } label.animate: true Timer { id: copyTimer interval: 3000 } } } } } }