1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-03 20:32:07 -04:00
Files
DankMaterialShell/quickshell/Modules/Notifications/Popup/NotificationPopup.qml
2026-02-18 10:27:57 -05:00

1107 lines
44 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import Quickshell
import Quickshell.Wayland
import Quickshell.Services.Notifications
import qs.Common
import qs.Services
import qs.Widgets
PanelWindow {
id: win
WlrLayershell.namespace: "dms:notification-popup"
required property var notificationData
required property string notificationId
readonly property bool hasValidData: notificationData && notificationData.notification
readonly property alias hovered: cardHoverHandler.hovered
property int screenY: 0
property bool exiting: false
property bool _isDestroying: false
property bool _finalized: false
property real _lastReportedAlignedHeight: -1
property real _storedTopMargin: 0
property real _storedBottomMargin: 0
readonly property string clearText: I18n.tr("Dismiss")
property bool descriptionExpanded: false
readonly property bool hasExpandableBody: (notificationData?.htmlBody || "").replace(/<[^>]*>/g, "").trim().length > 0
onDescriptionExpandedChanged: {
popupHeightChanged();
}
onImplicitHeightChanged: {
const aligned = Theme.px(implicitHeight, dpr);
if (Math.abs(aligned - _lastReportedAlignedHeight) < 0.5)
return;
_lastReportedAlignedHeight = aligned;
popupHeightChanged();
}
readonly property bool compactMode: SettingsData.notificationCompactMode
readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding
readonly property real popupIconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal
readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS
readonly property real contentBottomClearance: 8
readonly property real actionButtonHeight: compactMode ? 20 : 24
readonly property real collapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) + contentBottomClearance
readonly property real privacyCollapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2) + contentBottomClearance
readonly property real basePopupHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing
readonly property real basePopupHeightPrivacy: cardPadding * 2 + privacyCollapsedContentHeight + actionButtonHeight + contentSpacing
signal entered
signal exitStarted
signal exitFinished
signal popupHeightChanged
function startExit() {
if (exiting || _isDestroying) {
return;
}
exiting = true;
exitStarted();
exitAnim.restart();
exitWatchdog.restart();
if (NotificationService.removeFromVisibleNotifications)
NotificationService.removeFromVisibleNotifications(win.notificationData);
}
function forceExit() {
if (_isDestroying) {
return;
}
_isDestroying = true;
exiting = true;
visible = false;
exitWatchdog.stop();
finalizeExit("forced");
}
function finalizeExit(reason) {
if (_finalized) {
return;
}
_finalized = true;
_isDestroying = true;
exitWatchdog.stop();
wrapperConn.enabled = false;
wrapperConn.target = null;
win.exitFinished();
}
visible: !_finalized
WlrLayershell.layer: {
const envLayer = Quickshell.env("DMS_NOTIFICATION_LAYER");
if (envLayer) {
switch (envLayer) {
case "bottom":
return WlrLayershell.Bottom;
case "overlay":
return WlrLayershell.Overlay;
case "background":
return WlrLayershell.Background;
case "top":
return WlrLayershell.Top;
}
}
if (!notificationData)
return WlrLayershell.Top;
SettingsData.notificationOverlayEnabled;
const shouldUseOverlay = (SettingsData.notificationOverlayEnabled) || (notificationData.urgency === NotificationUrgency.Critical);
return shouldUseOverlay ? WlrLayershell.Overlay : WlrLayershell.Top;
}
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
implicitWidth: screen ? Math.min(400, Math.max(320, screen.width * 0.23)) : 380
implicitHeight: {
if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded)
return basePopupHeightPrivacy;
if (!descriptionExpanded)
return basePopupHeight;
const bodyTextHeight = bodyText.contentHeight || 0;
const collapsedBodyHeight = Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2);
if (bodyTextHeight > collapsedBodyHeight + 2)
return basePopupHeight + bodyTextHeight - collapsedBodyHeight;
return basePopupHeight;
}
Behavior on implicitHeight {
enabled: !exiting && !_isDestroying
NumberAnimation {
id: implicitHeightAnim
duration: descriptionExpanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasized
}
}
onHasValidDataChanged: {
if (!hasValidData && !exiting && !_isDestroying) {
forceExit();
}
}
Component.onCompleted: {
_lastReportedAlignedHeight = Theme.px(implicitHeight, dpr);
_storedTopMargin = getTopMargin();
_storedBottomMargin = getBottomMargin();
if (SettingsData.notificationPopupPrivacyMode)
descriptionExpanded = false;
if (hasValidData) {
Qt.callLater(() => enterX.restart());
} else {
forceExit();
}
}
onNotificationDataChanged: {
if (!_isDestroying) {
if (SettingsData.notificationPopupPrivacyMode)
descriptionExpanded = false;
wrapperConn.target = win.notificationData || null;
notificationConn.target = (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null;
}
}
onEntered: {
if (!_isDestroying) {
enterDelay.start();
}
}
Component.onDestruction: {
_isDestroying = true;
exitWatchdog.stop();
if (notificationData && notificationData.timer) {
notificationData.timer.stop();
}
}
property bool isTopCenter: SettingsData.notificationPopupPosition === -1
property bool isBottomCenter: SettingsData.notificationPopupPosition === SettingsData.Position.BottomCenter
property bool isCenterPosition: isTopCenter || isBottomCenter
anchors.top: true
anchors.bottom: true
anchors.left: SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom
anchors.right: SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Right
mask: contentInputMask
Region {
id: contentInputMask
item: contentMaskRect
}
Item {
id: contentMaskRect
visible: false
x: content.x
y: content.y
width: alignedWidth
height: alignedHeight
}
margins {
top: _storedTopMargin
bottom: _storedBottomMargin
left: getLeftMargin()
right: getRightMargin()
}
function getBarInfo() {
if (!screen)
return {
topBar: 0,
bottomBar: 0,
leftBar: 0,
rightBar: 0
};
return SettingsData.getAdjacentBarInfo(screen, SettingsData.notificationPopupPosition, {
id: "notification-popup",
screenPreferences: [screen.name],
autoHide: false
});
}
function getTopMargin() {
const popupPos = SettingsData.notificationPopupPosition;
const isTop = isTopCenter || popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Left;
if (!isTop)
return 0;
const barInfo = getBarInfo();
const base = barInfo.topBar > 0 ? barInfo.topBar : Theme.popupDistance;
return base + screenY;
}
function getBottomMargin() {
const popupPos = SettingsData.notificationPopupPosition;
const isBottom = isBottomCenter || popupPos === SettingsData.Position.Bottom || popupPos === SettingsData.Position.Right;
if (!isBottom)
return 0;
const barInfo = getBarInfo();
const base = barInfo.bottomBar > 0 ? barInfo.bottomBar : Theme.popupDistance;
return base + screenY;
}
function getLeftMargin() {
if (isCenterPosition)
return screen ? (screen.width - implicitWidth) / 2 : 0;
const popupPos = SettingsData.notificationPopupPosition;
const isLeft = popupPos === SettingsData.Position.Left || popupPos === SettingsData.Position.Bottom;
if (!isLeft)
return 0;
const barInfo = getBarInfo();
return barInfo.leftBar > 0 ? barInfo.leftBar : Theme.popupDistance;
}
function getRightMargin() {
if (isCenterPosition)
return 0;
const popupPos = SettingsData.notificationPopupPosition;
const isRight = popupPos === SettingsData.Position.Top || popupPos === SettingsData.Position.Right;
if (!isRight)
return 0;
const barInfo = getBarInfo();
return barInfo.rightBar > 0 ? barInfo.rightBar : Theme.popupDistance;
}
readonly property bool screenValid: win.screen && !_isDestroying
readonly property real dpr: screenValid ? CompositorService.getScreenScale(win.screen) : 1
readonly property real alignedWidth: Theme.px(implicitWidth, dpr)
readonly property real alignedHeight: Theme.px(implicitHeight, dpr)
Item {
id: content
x: Theme.snap((win.width - alignedWidth) / 2, dpr)
y: {
const isTop = isTopCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left;
if (isTop) {
return Theme.snap(screenY, dpr);
} else {
return Theme.snap(win.height - alignedHeight - screenY, dpr);
}
}
width: alignedWidth
height: alignedHeight
visible: !win._finalized
scale: cardHoverHandler.hovered ? 1.01 : 1.0
transformOrigin: Item.Center
Behavior on scale {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
property real swipeOffset: 0
readonly property real dismissThreshold: isCenterPosition ? height * 0.4 : width * 0.35
readonly property real swipeFadeStartRatio: 0.75
readonly property real swipeTravelDistance: isCenterPosition ? height : width
readonly property real swipeFadeStartOffset: swipeTravelDistance * swipeFadeStartRatio
readonly property real swipeFadeDistance: Math.max(1, swipeTravelDistance - swipeFadeStartOffset)
readonly property bool swipeActive: swipeDragHandler.active
property bool swipeDismissing: false
readonly property real radiusForShadow: Theme.cornerRadius
property real shadowBlurPx: SettingsData.notificationPopupShadowEnabled ? ((2 + radiusForShadow * 0.2) * (cardHoverHandler.hovered ? 1.2 : 1)) : 0
property real shadowSpreadPx: SettingsData.notificationPopupShadowEnabled ? (radiusForShadow * (cardHoverHandler.hovered ? 0.06 : 0)) : 0
property real shadowBaseAlpha: 0.35
readonly property real popupSurfaceAlpha: SettingsData.popupTransparency
readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha))
Behavior on shadowBlurPx {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on shadowSpreadPx {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Item {
id: bgShadowLayer
anchors.fill: parent
anchors.margins: Theme.snap(4, win.dpr)
layer.enabled: !win._isDestroying && win.screenValid
layer.smooth: false
layer.textureSize: Qt.size(Math.round(width * win.dpr), Math.round(height * win.dpr))
layer.textureMirroring: ShaderEffectSource.MirrorVertically
readonly property int blurMax: 64
layer.effect: MultiEffect {
id: shadowFx
autoPaddingEnabled: true
shadowEnabled: SettingsData.notificationPopupShadowEnabled
blurEnabled: false
maskEnabled: false
shadowBlur: Math.max(0, Math.min(1, content.shadowBlurPx / bgShadowLayer.blurMax))
shadowScale: 1 + (2 * content.shadowSpreadPx) / Math.max(1, Math.min(bgShadowLayer.width, bgShadowLayer.height))
shadowColor: {
const baseColor = Theme.isLightMode ? Qt.rgba(0, 0, 0, 1) : Theme.surfaceContainerHighest;
return Theme.withAlpha(baseColor, content.effectiveShadowAlpha);
}
}
Rectangle {
id: shadowShapeSource
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
border.color: notificationData && notificationData.urgency === NotificationUrgency.Critical ? Theme.withAlpha(Theme.primary, 0.3) : Theme.withAlpha(Theme.outline, 0.08)
border.width: notificationData && notificationData.urgency === NotificationUrgency.Critical ? 2 : 0
}
Rectangle {
anchors.fill: parent
radius: shadowShapeSource.radius
visible: notificationData && notificationData.urgency === NotificationUrgency.Critical
opacity: 1
clip: true
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop {
position: 0
color: Theme.primary
}
GradientStop {
position: 0.02
color: Theme.primary
}
GradientStop {
position: 0.021
color: "transparent"
}
}
}
}
Item {
id: backgroundContainer
anchors.fill: parent
anchors.margins: Theme.snap(4, win.dpr)
clip: true
HoverHandler {
id: cardHoverHandler
}
Connections {
target: cardHoverHandler
function onHoveredChanged() {
if (!notificationData || win.exiting || win._isDestroying)
return;
if (cardHoverHandler.hovered) {
if (notificationData.timer)
notificationData.timer.stop();
} else if (notificationData.popup && notificationData.timer) {
notificationData.timer.restart();
}
}
}
LayoutMirroring.enabled: I18n.isRtl
LayoutMirroring.childrenInherit: true
Item {
id: notificationContent
readonly property real expandedTextHeight: bodyText.contentHeight || 0
readonly property real collapsedBodyHeight: Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)
readonly property real effectiveCollapsedHeight: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? win.privacyCollapsedContentHeight : win.collapsedContentHeight
readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > collapsedBodyHeight + 2) ? (expandedTextHeight - collapsedBodyHeight) : 0
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: cardPadding
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
height: effectiveCollapsedHeight + extraHeight
clip: SettingsData.notificationPopupPrivacyMode && !descriptionExpanded
DankCircularImage {
id: iconContainer
readonly property string rawImage: notificationData?.image || ""
readonly property string iconFromImage: {
if (rawImage.startsWith("image://icon/"))
return rawImage.substring(13);
return "";
}
readonly property bool imageHasSpecialPrefix: {
const icon = iconFromImage;
return icon.startsWith("material:") || icon.startsWith("svg:") || icon.startsWith("unicode:") || icon.startsWith("image:");
}
readonly property bool hasNotificationImage: rawImage !== "" && !rawImage.startsWith("image://icon/")
readonly property bool needsImagePersist: hasNotificationImage && rawImage.startsWith("image://qsimage/") && !notificationData.persistedImagePath
width: popupIconSize
height: popupIconSize
anchors.left: parent.left
anchors.top: parent.top
anchors.topMargin: {
if (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) {
const headerSummary = Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2;
return Math.max(0, headerSummary / 2 - popupIconSize / 2);
}
if (descriptionExpanded)
return Math.max(0, Theme.fontSizeSmall * 1.2 + (Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) / 2 - popupIconSize / 2);
return Math.max(0, Theme.fontSizeSmall * 1.2 + (textContainer.height - Theme.fontSizeSmall * 1.2) / 2 - popupIconSize / 2);
}
imageSource: {
if (!notificationData)
return "";
if (hasNotificationImage)
return notificationData.cleanImage || "";
if (imageHasSpecialPrefix)
return "";
const appIcon = notificationData.appIcon;
if (!appIcon)
return iconFromImage ? "image://icon/" + iconFromImage : "";
if (appIcon.startsWith("file://") || appIcon.startsWith("http://") || appIcon.startsWith("https://") || appIcon.includes("/"))
return appIcon;
if (appIcon.startsWith("material:") || appIcon.startsWith("svg:") || appIcon.startsWith("unicode:") || appIcon.startsWith("image:"))
return "";
return Quickshell.iconPath(appIcon, true);
}
hasImage: hasNotificationImage
fallbackIcon: {
if (imageHasSpecialPrefix)
return iconFromImage;
return notificationData?.appIcon || iconFromImage || "";
}
fallbackText: {
const appName = notificationData?.appName || "?";
return appName.charAt(0).toUpperCase();
}
onImageStatusChanged: {
if (imageStatus === Image.Ready && needsImagePersist) {
const cachePath = NotificationService.getImageCachePath(notificationData);
saveImageToFile(cachePath);
}
}
onImageSaved: filePath => {
if (!notificationData)
return;
notificationData.persistedImagePath = filePath;
const wrapperId = notificationData.notification?.id?.toString() || "";
if (wrapperId)
NotificationService.updateHistoryImage(wrapperId, filePath);
}
}
Column {
id: textContainer
anchors.left: iconContainer.right
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.top: parent.top
spacing: Theme.notificationContentSpacing
Row {
id: headerRow
width: parent.width
spacing: Theme.spacingXS
visible: headerAppNameText.text.length > 0 || headerTimeText.text.length > 0
StyledText {
id: headerAppNameText
text: notificationData ? (notificationData.appName || "") : ""
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
elide: Text.ElideRight
maximumLineCount: 1
width: Math.min(implicitWidth, parent.width - headerSeparator.implicitWidth - headerTimeText.implicitWidth - parent.spacing * 2)
}
StyledText {
id: headerSeparator
text: (headerAppNameText.text.length > 0 && headerTimeText.text.length > 0) ? " • " : ""
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
}
StyledText {
id: headerTimeText
text: notificationData ? (notificationData.timeStr || "") : ""
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
}
}
StyledText {
text: notificationData ? (notificationData.summary || "") : ""
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
maximumLineCount: 1
visible: text.length > 0
}
StyledText {
id: bodyText
property bool hasMoreText: truncated
text: notificationData ? (notificationData.htmlBody || "") : ""
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
width: parent.width
elide: descriptionExpanded ? Text.ElideNone : Text.ElideRight
horizontalAlignment: Text.AlignLeft
maximumLineCount: descriptionExpanded ? -1 : (compactMode ? 1 : 2)
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
visible: text.length > 0
opacity: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? 0 : 1
linkColor: Theme.primary
onLinkActivated: link => Qt.openUrlExternally(link)
MouseArea {
anchors.fill: parent
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (bodyText.hasMoreText || descriptionExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: mouse => {
if (!parent.hoveredLink && (bodyText.hasMoreText || descriptionExpanded))
win.descriptionExpanded = !win.descriptionExpanded;
}
propagateComposedEvents: true
onPressed: mouse => {
if (parent.hoveredLink)
mouse.accepted = false;
}
onReleased: mouse => {
if (parent.hoveredLink)
mouse.accepted = false;
}
}
}
StyledText {
text: I18n.tr("Message Content", "notification privacy mode placeholder")
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
width: parent.width
visible: SettingsData.notificationPopupPrivacyMode && !descriptionExpanded && win.hasExpandableBody
}
}
}
DankActionButton {
id: closeButton
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: cardPadding
anchors.rightMargin: Theme.spacingL
iconName: "close"
iconSize: compactMode ? 14 : 16
buttonSize: compactMode ? 20 : 24
z: 15
onClicked: {
if (notificationData && !win.exiting)
notificationData.popup = false;
}
}
DankActionButton {
id: expandButton
anchors.right: closeButton.left
anchors.rightMargin: Theme.spacingXS
anchors.top: parent.top
anchors.topMargin: cardPadding
iconName: descriptionExpanded ? "expand_less" : "expand_more"
iconSize: compactMode ? 14 : 16
buttonSize: compactMode ? 20 : 24
z: 15
visible: SettingsData.notificationPopupPrivacyMode && win.hasExpandableBody
onClicked: {
if (win.hasExpandableBody)
win.descriptionExpanded = !win.descriptionExpanded;
}
}
Row {
visible: cardHoverHandler.hovered
opacity: visible ? 1 : 0
anchors.right: clearButton.visible ? clearButton.left : parent.right
anchors.rightMargin: clearButton.visible ? contentSpacing : Theme.spacingL
anchors.top: notificationContent.bottom
anchors.topMargin: contentSpacing
spacing: contentSpacing
z: 20
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Repeater {
model: notificationData ? (notificationData.actions || []) : []
Rectangle {
property bool isHovered: false
width: Math.max(actionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
StyledText {
id: actionText
text: modelData.text || "Open"
color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
anchors.centerIn: parent
elide: Text.ElideRight
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onEntered: parent.isHovered = true
onExited: parent.isHovered = false
onClicked: {
if (modelData && modelData.invoke)
modelData.invoke();
if (notificationData && !win.exiting)
notificationData.popup = false;
}
}
}
}
}
Rectangle {
id: clearButton
property bool isHovered: false
readonly property int actionCount: notificationData ? (notificationData.actions || []).length : 0
visible: actionCount < 3 && cardHoverHandler.hovered
opacity: visible ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
anchors.right: parent.right
anchors.rightMargin: Theme.spacingL
anchors.top: notificationContent.bottom
anchors.topMargin: contentSpacing
width: Math.max(clearTextLabel.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
z: 20
StyledText {
id: clearTextLabel
text: win.clearText
color: clearButton.isHovered ? Theme.primary : Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
anchors.centerIn: parent
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton
onEntered: clearButton.isHovered = true
onExited: clearButton.isHovered = false
onClicked: {
if (notificationData && !win.exiting)
NotificationService.dismissNotification(notificationData);
}
}
}
MouseArea {
id: cardHoverArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
propagateComposedEvents: true
z: -1
onClicked: mouse => {
if (!notificationData || win.exiting)
return;
if (mouse.button === Qt.RightButton) {
popupContextMenu.popup();
} else if (mouse.button === Qt.LeftButton) {
const canExpand = bodyText.hasMoreText || win.descriptionExpanded || (SettingsData.notificationPopupPrivacyMode && win.hasExpandableBody);
if (canExpand) {
win.descriptionExpanded = !win.descriptionExpanded;
} else if (notificationData.actions && notificationData.actions.length > 0) {
notificationData.actions[0].invoke();
NotificationService.dismissNotification(notificationData);
} else {
notificationData.popup = false;
}
}
}
}
}
DragHandler {
id: swipeDragHandler
target: null
xAxis.enabled: !isCenterPosition
yAxis.enabled: isCenterPosition
onActiveChanged: {
if (active || win.exiting || content.swipeDismissing)
return;
if (Math.abs(content.swipeOffset) > content.dismissThreshold) {
content.swipeDismissing = true;
swipeDismissAnim.start();
} else {
content.swipeOffset = 0;
}
}
onTranslationChanged: {
if (win.exiting)
return;
const raw = isCenterPosition ? translation.y : translation.x;
if (isTopCenter) {
content.swipeOffset = Math.min(0, raw);
} else if (isBottomCenter) {
content.swipeOffset = Math.max(0, raw);
} else {
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
content.swipeOffset = isLeft ? Math.min(0, raw) : Math.max(0, raw);
}
}
}
opacity: {
const swipeAmount = Math.abs(content.swipeOffset);
if (swipeAmount <= content.swipeFadeStartOffset)
return 1;
const fadeProgress = (swipeAmount - content.swipeFadeStartOffset) / content.swipeFadeDistance;
return Math.max(0, 1 - fadeProgress);
}
Behavior on opacity {
enabled: !content.swipeActive
NumberAnimation {
duration: Theme.shortDuration
}
}
Behavior on swipeOffset {
enabled: !content.swipeActive && !content.swipeDismissing
NumberAnimation {
duration: Theme.notificationExitDuration
easing.type: Theme.standardEasing
}
}
NumberAnimation {
id: swipeDismissAnim
target: content
property: "swipeOffset"
to: isTopCenter ? -content.height : isBottomCenter ? content.height : (SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom ? -content.width : content.width)
duration: Theme.notificationExitDuration
easing.type: Easing.OutCubic
onStopped: {
NotificationService.dismissNotification(notificationData);
win.forceExit();
}
}
transform: [
Translate {
id: swipeTx
x: isCenterPosition ? 0 : content.swipeOffset
y: isCenterPosition ? content.swipeOffset : 0
},
Translate {
id: tx
x: {
if (isCenterPosition)
return 0;
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
return isLeft ? -Anims.slidePx : Anims.slidePx;
}
y: isTopCenter ? -Anims.slidePx : isBottomCenter ? Anims.slidePx : 0
}
]
}
NumberAnimation {
id: enterX
target: tx
property: isCenterPosition ? "y" : "x"
from: {
if (isTopCenter)
return -Anims.slidePx;
if (isBottomCenter)
return Anims.slidePx;
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
return isLeft ? -Anims.slidePx : Anims.slidePx;
}
to: 0
duration: Theme.notificationEnterDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: isCenterPosition ? Theme.expressiveCurves.standardDecel : Theme.expressiveCurves.emphasizedDecel
onStopped: {
if (!win.exiting && !win._isDestroying) {
if (isCenterPosition) {
if (Math.abs(tx.y) < 0.5)
win.entered();
} else {
if (Math.abs(tx.x) < 0.5)
win.entered();
}
}
}
}
ParallelAnimation {
id: exitAnim
onStopped: finalizeExit("animStopped")
PropertyAnimation {
target: tx
property: isCenterPosition ? "y" : "x"
from: 0
to: {
if (isTopCenter)
return -Anims.slidePx;
if (isBottomCenter)
return Anims.slidePx;
const isLeft = SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom;
return isLeft ? -Anims.slidePx : Anims.slidePx;
}
duration: Theme.notificationExitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel
}
NumberAnimation {
target: content
property: "opacity"
from: 1
to: 0
duration: Theme.notificationExitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standardAccel
}
NumberAnimation {
target: content
property: "scale"
from: 1
to: 0.98
duration: Theme.notificationExitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel
}
}
Connections {
id: wrapperConn
function onPopupChanged() {
if (!win.notificationData || win._isDestroying)
return;
if (!win.notificationData.popup && !win.exiting)
startExit();
}
target: win.notificationData || null
ignoreUnknownSignals: true
enabled: !win._isDestroying
}
Connections {
id: notificationConn
function onDropped() {
if (!win._isDestroying && !win.exiting)
forceExit();
}
target: (win.notificationData && win.notificationData.notification && win.notificationData.notification.Retainable) || null
ignoreUnknownSignals: true
enabled: !win._isDestroying
}
Timer {
id: enterDelay
interval: 160
repeat: false
onTriggered: {
if (notificationData && notificationData.timer && !exiting && !_isDestroying)
notificationData.timer.start();
}
}
Timer {
id: exitWatchdog
interval: 600
repeat: false
onTriggered: finalizeExit("watchdog")
}
Behavior on screenY {
id: screenYAnim
enabled: !exiting && !_isDestroying
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standardDecel
}
}
Menu {
id: popupContextMenu
width: 220
contentHeight: 130
margins: -1
popupType: Popup.Window
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
background: Rectangle {
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
radius: Theme.cornerRadius
border.width: 0
border.color: Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.12)
}
MenuItem {
id: setNotificationRulesItem
text: I18n.tr("Set notification rules")
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
radius: Theme.cornerRadius / 2
}
onTriggered: {
const appName = notificationData?.appName || "";
const desktopEntry = notificationData?.desktopEntry || "";
SettingsData.addNotificationRuleForNotification(appName, desktopEntry);
PopoutService.openSettingsWithTab("notifications");
}
}
MenuItem {
id: muteUnmuteItem
readonly property bool isMuted: SettingsData.isAppMuted(notificationData?.appName || "", notificationData?.desktopEntry || "")
text: isMuted ? I18n.tr("Unmute popups for %1").arg(notificationData?.appName || I18n.tr("this app")) : I18n.tr("Mute popups for %1").arg(notificationData?.appName || I18n.tr("this app"))
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
radius: Theme.cornerRadius / 2
}
onTriggered: {
const appName = notificationData?.appName || "";
const desktopEntry = notificationData?.desktopEntry || "";
if (isMuted) {
SettingsData.removeMuteRuleForApp(appName, desktopEntry);
} else {
SettingsData.addMuteRuleForApp(appName, desktopEntry);
if (notificationData && !exiting)
NotificationService.dismissNotification(notificationData);
}
}
}
MenuItem {
text: I18n.tr("Dismiss")
contentItem: StyledText {
text: parent.text
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
leftPadding: Theme.spacingS
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent"
radius: Theme.cornerRadius / 2
}
onTriggered: {
if (notificationData && !exiting)
NotificationService.dismissNotification(notificationData);
}
}
}
}