Files
DankMaterialShell/quickshell/Modules/Notifications/NotificationCard.qml
T
bbedward 4eb163538c media: change player style
theme: restore layer outline option
launcher/theme: tweaks and fix icon service
2026-09-22 09:00:34 -04:00

411 lines
18 KiB
QML

import QtQuick
import Quickshell.Widgets
import Quickshell.Services.Notifications
import qs.Common
import qs.Services
import qs.Widgets
Rectangle {
id: root
property color surfaceColor: Theme.foregroundColor(Theme.cardSurface, Theme.isFloatingWindow(root))
property color chipColor: Theme.chipSurface
property var notificationData: null
property bool interactive: true
property bool headerOnly: false
property bool descriptionExpanded: false
property bool privacyMode: false
property bool showActions: true
property bool showDismiss: true
property bool showClose: false
property string dismissText: I18n.tr("Dismiss")
property bool showTime: true
property bool bodyInvokesAction: false
property bool persistImage: false
property bool keyboardSelected: false
property bool keyboardHints: false
property bool animateHeight: true
property bool groupExpanded: false
property int groupCount: 1
property real outerRadius: Theme.groupedListOuterRadius
property bool firstInGroup: true
property bool lastInGroup: true
readonly property bool hasMoreText: bodyText.truncated || summaryText.truncated
readonly property bool hasBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0
readonly property string appIcon: NotificationService.notificationAppIcon(notificationData?.appIcon || "", notificationData?.desktopEntry || "")
readonly property string contentImageSource: notificationData?.hasDisplayImage ? notificationData?.displayImage || "" : ""
readonly property bool hasContentImage: contentImageSource.length > 0 && contentImage.status !== Image.Error
readonly property bool contentVisible: !headerOnly && (!privacyMode || descriptionExpanded)
readonly property bool canExpand: !headerOnly && (hasMoreText || hasContentImage || descriptionExpanded || (hasBody && privacyMode))
readonly property real targetHeight: Math.max(NotificationMetrics.appIconSize, content.implicitHeight) + NotificationMetrics.cardPadding * 2
readonly property bool isAnimating: heightAnimation.running
signal closeRequested
signal dismissRequested
signal actionRequested(var action)
signal expandRequested
signal groupToggleRequested
signal bodyClicked
signal contextMenuRequested(real x, real y)
function saveDisplayImage() {
const data = root.notificationData;
if (!root.persistImage || !data?.hasDisplayImage || data.persistedImagePath)
return;
const image = data.image || "";
if (!image.startsWith("image://qsimage/") && !NotificationService.notificationIconFromImage(image).startsWith("/"))
return;
const path = NotificationService.getImageCachePath(data);
contentImage.grabToImage(result => {
if (root.notificationData !== data || data.image !== image || !result.saveToFile(path))
return;
data.persistedImagePath = path;
const id = data.notification?.id?.toString() || "";
if (id)
NotificationService.updateHistoryImage(id, path);
}, Qt.size(Math.max(1, contentImage.implicitWidth), Math.max(1, contentImage.implicitHeight)));
}
implicitHeight: targetHeight
height: targetHeight
radius: Theme.groupedListInnerRadius
topLeftRadius: firstInGroup ? outerRadius : radius
topRightRadius: topLeftRadius
bottomLeftRadius: lastInGroup ? outerRadius : radius
bottomRightRadius: bottomLeftRadius
color: surfaceColor
border.width: Theme.layerOutlineWidth
border.color: Theme.outlineMedium
clip: true
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
Accessible.role: Accessible.ListItem
Accessible.name: headerOnly ? notificationData?.appName || "" : [notificationData?.appName, notificationData?.summary].filter(Boolean).join(". ")
Accessible.description: headerOnly ? "" : privacyMode && !descriptionExpanded ? I18n.tr("Message Content", "notification privacy mode placeholder") : (notificationData?.body || "")
Accessible.onPressAction: {
if (interactive)
root.bodyClicked();
}
Behavior on height {
enabled: root.animateHeight && NotificationMetrics.animationsEnabled
NumberAnimation {
id: heightAnimation
duration: root.descriptionExpanded ? Theme.notificationInlineExpandDuration : Theme.notificationInlineCollapseDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: NotificationMetrics.expandCurve
}
}
Behavior on color {
enabled: NotificationMetrics.animationsEnabled
ColorAnimation {
duration: Theme.expressiveDurations.expressiveEffects
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.expressiveEffects
}
}
Rectangle {
anchors.fill: parent
radius: root.radius
topLeftRadius: root.topLeftRadius
topRightRadius: root.topRightRadius
bottomLeftRadius: root.bottomLeftRadius
bottomRightRadius: root.bottomRightRadius
color: "transparent"
border.width: Theme.focusRingWidth
border.color: Theme.focusRingColor
visible: root.keyboardSelected
z: 1
}
Rectangle {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Theme.outlineWidthFocused
height: parent.height - NotificationMetrics.cardPadding * 2
radius: Theme.fullRadius(width, height)
color: Theme.primary
visible: root.notificationData?.urgency === NotificationUrgency.Critical
}
MouseArea {
anchors.fill: parent
enabled: root.interactive
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
root.contextMenuRequested(mouse.x, mouse.y);
return;
}
root.bodyClicked();
}
}
Item {
id: iconBox
width: NotificationMetrics.appIconSize
height: width
anchors.left: parent.left
anchors.leftMargin: NotificationMetrics.cardPadding
y: NotificationMetrics.cardPadding
Image {
id: appImage
anchors.fill: parent
asynchronous: true
smooth: true
fillMode: Image.PreserveAspectFit
sourceSize: Qt.size(NotificationMetrics.appIconSize * 2, NotificationMetrics.appIconSize * 2)
source: NotificationService.notificationImageSource("", root.appIcon)
visible: status === Image.Ready
}
AppIconRenderer {
anchors.fill: parent
visible: !appImage.visible
iconValue: NotificationService.notificationFallbackIcon(root.headerOnly ? "" : root.notificationData?.image || "", root.appIcon)
iconSize: NotificationMetrics.appIconSize
iconColor: Theme.onSurfaceVariant
fallbackText: (root.notificationData?.appName || "?").charAt(0).toUpperCase()
fallbackRadius: Theme.fullRadius(width, height)
fallbackBackgroundColor: Theme.secondaryContainer
fallbackTextColor: Theme.onSecondaryContainer
}
}
Column {
id: content
anchors.left: iconBox.right
anchors.leftMargin: NotificationMetrics.iconSpacing
anchors.right: parent.right
anchors.rightMargin: NotificationMetrics.cardPadding
y: NotificationMetrics.cardPadding
spacing: NotificationMetrics.contentSpacing
Item {
width: parent.width
height: NotificationMetrics.controlSize
StyledText {
anchors.left: parent.left
anchors.right: controls.left
anchors.rightMargin: Theme.spacingXS
anchors.verticalCenter: parent.verticalCenter
text: (root.notificationData?.appName || "") + (root.showTime && root.notificationData?.timeStr ? " · " + root.notificationData.timeStr : "")
horizontalAlignment: Text.AlignLeft
font.pixelSize: Theme.fontSizeSmall
color: Theme.onSurfaceVariant
elide: Text.ElideRight
}
Row {
id: controls
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXXS
Rectangle {
visible: !root.interactive && root.groupCount > 1
width: notificationCount.implicitWidth + Theme.spacingS * 2
height: Theme.iconSize
radius: Theme.fullRadius(width, height)
color: root.chipColor
StyledText {
id: notificationCount
anchors.centerIn: parent
text: root.groupCount.toString()
font.pixelSize: Theme.fontSizeSmall
color: Theme.onSurfaceVariant
}
}
DankButton {
visible: root.interactive && root.groupCount > 1
text: root.groupCount.toString()
iconName: root.groupExpanded ? "expand_less" : "expand_more"
buttonHeight: NotificationMetrics.controlSize
horizontalPadding: Theme.spacingS
backgroundColor: root.chipColor
textColor: Theme.onSurfaceVariant
Accessible.name: root.groupExpanded ? I18n.tr("Collapse") : I18n.tr("Expand")
onClicked: root.groupToggleRequested()
}
DankActionButton {
visible: root.interactive && root.canExpand && root.groupCount <= 1
iconName: root.descriptionExpanded ? "expand_less" : "expand_more"
backgroundColor: root.chipColor
width: NotificationMetrics.controlSize + Theme.spacingS
buttonSize: NotificationMetrics.controlSize
iconSize: Theme.iconSizeSmall
Accessible.name: root.descriptionExpanded ? I18n.tr("Collapse") : I18n.tr("Expand")
onClicked: root.expandRequested()
}
DankActionButton {
visible: root.interactive && root.showClose
iconName: "close"
buttonSize: NotificationMetrics.controlSize
iconSize: Theme.iconSizeSmall
Accessible.name: I18n.tr("Dismiss")
onClicked: root.closeRequested()
}
}
}
Item {
id: messageContent
width: parent.width
visible: !root.headerOnly
height: root.descriptionExpanded ? messageText.implicitHeight + (imagePreview.visible ? NotificationMetrics.contentSpacing + imagePreview.height : 0) : Math.max(messageText.implicitHeight, imagePreview.visible ? imagePreview.height : 0)
Column {
id: messageText
anchors.left: parent.left
width: Math.max(0, parent.width - (!root.descriptionExpanded && imagePreview.visible ? imagePreview.width + Theme.spacingS : 0))
spacing: NotificationMetrics.contentSpacing
StyledText {
id: summaryText
width: parent.width
visible: !root.headerOnly && text.length > 0
text: root.notificationData?.summary || ""
horizontalAlignment: Text.AlignLeft
font.pixelSize: NotificationMetrics.summarySize
font.weight: Theme.fontWeightMedium
color: Theme.onSurface
elide: root.descriptionExpanded ? Text.ElideNone : Text.ElideRight
maximumLineCount: root.descriptionExpanded ? -1 : 1
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
}
StyledText {
id: bodyText
width: parent.width
visible: !root.headerOnly && text.length > 0 && (!root.privacyMode || root.descriptionExpanded)
text: root.interactive ? root.notificationData?.htmlBody || "" : (root.notificationData?.body || "").replace(/<[^>]*>/g, '')
textFormat: root.interactive ? Text.StyledText : Text.PlainText
horizontalAlignment: Text.AlignLeft
font.pixelSize: NotificationMetrics.bodySize
color: Theme.onSurfaceVariant
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
maximumLineCount: root.descriptionExpanded ? -1 : NotificationMetrics.collapsedLines
elide: root.descriptionExpanded ? Text.ElideNone : Text.ElideRight
linkColor: Theme.primary
onLinkActivated: link => {
if (root.interactive)
Qt.openUrlExternally(link);
}
MouseArea {
anchors.fill: parent
enabled: root.interactive
cursorShape: Qt.PointingHandCursor
onPressed: mouse => {
if (bodyText.hoveredLink)
mouse.accepted = false;
}
onClicked: mouse => {
if (bodyText.hoveredLink)
return;
if (root.bodyInvokesAction) {
root.bodyClicked();
return;
}
if (root.canExpand)
root.expandRequested();
}
}
}
StyledText {
width: parent.width
visible: !root.headerOnly && root.privacyMode && !root.descriptionExpanded && root.hasBody
text: I18n.tr("Message Content", "notification privacy mode placeholder")
horizontalAlignment: Text.AlignLeft
font.pixelSize: NotificationMetrics.bodySize
color: Theme.onSurfaceVariant
}
}
ClippingRectangle {
id: imagePreview
readonly property bool alignRight: root.descriptionExpanded ? I18n.isRtl : !I18n.isRtl
x: alignRight ? parent.width - width : 0
y: root.descriptionExpanded ? messageText.implicitHeight + NotificationMetrics.contentSpacing : 0
width: root.descriptionExpanded ? Math.min(parent.width, NotificationMetrics.imageMaxHeight * contentImage.aspectRatio) : Math.min(NotificationMetrics.thumbnailSize, NotificationMetrics.thumbnailSize * contentImage.aspectRatio)
height: width / contentImage.aspectRatio
radius: Theme.cornerRadiusM
color: "transparent"
visible: root.contentVisible && contentImage.implicitWidth > 0 && contentImage.status !== Image.Error
Image {
id: contentImage
readonly property real aspectRatio: implicitWidth > 0 && implicitHeight > 0 ? implicitWidth / implicitHeight : 1
readonly property real decodeSize: root.persistImage || root.descriptionExpanded ? NotificationMetrics.imageDecodeSize : NotificationMetrics.thumbnailSize * 2
anchors.fill: parent
source: root.contentVisible || root.persistImage ? root.contentImageSource : ""
sourceSize: Qt.size(decodeSize, decodeSize)
asynchronous: true
retainWhileLoading: true
cache: !root.persistImage
smooth: true
fillMode: Image.Stretch
onStatusChanged: {
if (status === Image.Ready)
root.saveDisplayImage();
}
}
}
}
Item {
width: parent.width
height: actions.implicitHeight
visible: root.interactive && !root.headerOnly && (root.showActions || root.showDismiss)
Flow {
id: actions
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: -NotificationMetrics.actionPadding
spacing: Theme.spacingXS
Repeater {
model: root.showActions ? (root.notificationData?.actions || []) : []
DankButton {
required property var modelData
required property int index
text: (modelData.text || I18n.tr("Open")) + (root.keyboardHints && index < 9 ? " (" + (index + 1) + ")" : "")
maximumWidth: actions.width
wrapText: true
buttonHeight: NotificationMetrics.actionHeight
horizontalPadding: NotificationMetrics.actionPadding
minimumWidth: 0
backgroundColor: "transparent"
textColor: Theme.primary
onClicked: root.actionRequested(modelData)
}
}
DankButton {
visible: root.showDismiss
text: root.dismissText
maximumWidth: actions.width
wrapText: true
buttonHeight: NotificationMetrics.actionHeight
horizontalPadding: NotificationMetrics.actionPadding
minimumWidth: 0
backgroundColor: "transparent"
textColor: Theme.primary
onClicked: root.dismissRequested()
}
}
}
}
}