1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-04-04 12:52:06 -04:00

Squashed commit of the following:

commit 051b7576f7
Author: purian23 <purian23@gmail.com>
Date:   Sun Feb 15 16:38:45 2026 -0500

    Height for realz

commit 7784488a61
Author: purian23 <purian23@gmail.com>
Date:   Sun Feb 15 16:34:09 2026 -0500

    Fix height and truncate text/URLs

commit 31b328d428
Author: bbedward <bbedward@gmail.com>
Date:   Sun Feb 15 16:25:57 2026 -0500

    notifications: handle URL encoding in markdown2html

commit dbb04f74a2
Author: bbedward <bbedward@gmail.com>
Date:   Sun Feb 15 16:10:20 2026 -0500

    notifications: more comprehensive decoder

commit b29c7192c2
Author: bbedward <bbedward@gmail.com>
Date:   Sun Feb 15 15:51:37 2026 -0500

    notifications: html unescape

commit 8a48fa11ec
Author: purian23 <purian23@gmail.com>
Date:   Sun Feb 15 15:04:33 2026 -0500

    Add expressive curve on init toast

commit ee124f5e04
Author: purian23 <purian23@gmail.com>
Date:   Sun Feb 15 15:02:16 2026 -0500

    Expressive curves on swipe & btn height

commit 0fce904635
Author: purian23 <purian23@gmail.com>
Date:   Sun Feb 15 13:40:02 2026 -0500

    Provide bottom button clearance

commit 00d3829999
Author: bbedward <bbedward@gmail.com>
Date:   Sun Feb 15 13:24:31 2026 -0500

    notifications: cleanup popup display logic

commit fd05768059
Author: purian23 <purian23@gmail.com>
Date:   Sun Feb 15 01:00:55 2026 -0500

    Add Privacy Mode
    - Smoother notification expansions
    - Shadow & Privacy Toggles

commit 0dba11d845
Author: purian23 <purian23@gmail.com>
Date:   Sat Feb 14 22:48:46 2026 -0500

    Further M3 enhancements

commit 949c216964
Author: purian23 <purian23@gmail.com>
Date:   Sat Feb 14 19:59:38 2026 -0500

    Right-Click to set Rules on Notifications directly

commit 62bc25782c
Author: bbedward <bbedward@gmail.com>
Date:   Fri Feb 13 21:44:27 2026 -0500

    notifications: fix compact spacing, reveal header bar, add bottom center
    position, pointing hand cursor fix

commit ed495d4396
Author: purian23 <purian23@gmail.com>
Date:   Fri Feb 13 20:25:40 2026 -0500

    Tighten init toast

commit ebe38322a0
Author: purian23 <purian23@gmail.com>
Date:   Fri Feb 13 20:09:59 2026 -0500

    Update more m3 baselines & spacing

commit b1735bb701
Author: purian23 <purian23@gmail.com>
Date:   Fri Feb 13 14:10:05 2026 -0500

    Expand rules on-Click

commit 9f13546b4d
Author: purian23 <purian23@gmail.com>
Date:   Fri Feb 13 12:59:29 2026 -0500

    Add Notification Rules
    - Additional right-click ops
    - Allow for 3rd boy line on init notification popup

commit be133b73c7
Author: purian23 <purian23@gmail.com>
Date:   Fri Feb 13 10:10:03 2026 -0500

    Truncate long title in groups

commit 4fc275bead
Author: bbedward <bbedward@gmail.com>
Date:   Thu Feb 12 23:27:34 2026 -0500

    notification: expand/collapse animation adjustment

commit 00e6172a68
Author: purian23 <purian23@gmail.com>
Date:   Thu Feb 12 22:50:11 2026 -0500

    Fix global warnings

commit 0772f6deb7
Author: purian23 <purian23@gmail.com>
Date:   Thu Feb 12 22:46:40 2026 -0500

    Tweak expansion duration

commit 0ffeed3ff0
Author: purian23 <purian23@gmail.com>
Date:   Thu Feb 12 22:16:16 2026 -0500

    notifications: Update Material 3 baselines
    - New right-click to mute option
    - New independent Notification Animation settings
This commit is contained in:
bbedward
2026-02-16 17:57:13 -05:00
parent 8399d64c2d
commit 196c421b75
18 changed files with 1693 additions and 565 deletions

View File

@@ -21,8 +21,8 @@ Rectangle {
}
readonly property bool compactMode: SettingsData.notificationCompactMode
readonly property real cardPadding: compactMode ? Theme.spacingS : Theme.spacingM
readonly property real iconSize: compactMode ? 48 : 63
readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding
readonly property real iconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal
readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS
readonly property real collapsedContentHeight: iconSize + cardPadding
readonly property real baseCardHeight: cardPadding * 2 + collapsedContentHeight
@@ -93,7 +93,7 @@ Rectangle {
anchors.right: parent.right
anchors.topMargin: cardPadding
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL + (compactMode ? 32 : 40)
anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
height: collapsedContentHeight + extraHeight
DankCircularImage {
@@ -165,32 +165,47 @@ Rectangle {
Column {
width: parent.width
anchors.top: parent.top
spacing: compactMode ? 1 : 2
spacing: Theme.notificationContentSpacing
StyledText {
Row {
width: parent.width
text: {
const timeStr = NotificationService.formatHistoryTime(historyItem.timestamp);
const appName = historyItem.appName || "";
return timeStr.length > 0 ? `${appName} ${timeStr}` : appName;
spacing: Theme.spacingXS
readonly property real reservedTrailingWidth: historySeparator.implicitWidth + Math.max(historyTimeText.implicitWidth, 72) + spacing
StyledText {
id: historyTitleText
width: Math.min(implicitWidth, Math.max(0, parent.width - parent.reservedTrailingWidth))
text: {
let title = historyItem.summary || "";
const appName = historyItem.appName || "";
const prefix = appName + " • ";
if (appName && title.toLowerCase().startsWith(prefix.toLowerCase())) {
title = title.substring(prefix.length);
}
return title;
}
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
StyledText {
id: historySeparator
text: (historyTitleText.text.length > 0 && historyTimeText.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: historyTimeText
text: NotificationService.formatHistoryTime(historyItem.timestamp)
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
visible: text.length > 0
}
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
StyledText {
text: historyItem.summary || ""
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
StyledText {

View File

@@ -11,15 +11,63 @@ DankListView {
property bool autoScrollDisabled: false
property bool isAnimatingExpansion: false
property alias listContentHeight: listView.contentHeight
property real stableContentHeight: 0
property bool cardAnimateExpansion: true
property bool listInitialized: false
property int swipingCardIndex: -1
property real swipingCardOffset: 0
property real __pendingStableHeight: 0
property real __heightUpdateThreshold: 20
Component.onCompleted: {
Qt.callLater(() => {
listInitialized = true;
if (listView) {
listView.listInitialized = true;
listView.stableContentHeight = listView.contentHeight;
}
});
}
Timer {
id: heightUpdateDebounce
interval: Theme.mediumDuration + 20
repeat: false
onTriggered: {
if (!listView.isAnimatingExpansion && Math.abs(listView.__pendingStableHeight - listView.stableContentHeight) > listView.__heightUpdateThreshold) {
listView.stableContentHeight = listView.__pendingStableHeight;
}
}
}
onContentHeightChanged: {
if (!isAnimatingExpansion) {
__pendingStableHeight = contentHeight;
if (Math.abs(contentHeight - stableContentHeight) > __heightUpdateThreshold) {
heightUpdateDebounce.restart();
} else {
stableContentHeight = contentHeight;
}
}
}
onIsAnimatingExpansionChanged: {
if (isAnimatingExpansion) {
heightUpdateDebounce.stop();
let delta = 0;
for (let i = 0; i < count; i++) {
const item = itemAtIndex(i);
if (item && item.children[0] && item.children[0].isAnimating)
delta += item.children[0].targetHeight - item.height;
}
const targetHeight = contentHeight + delta;
// During expansion, always update immediately without threshold check
stableContentHeight = targetHeight;
} else {
__pendingStableHeight = contentHeight;
heightUpdateDebounce.restart();
}
}
clip: true
model: NotificationService.groupedNotifications
spacing: Theme.spacingL
@@ -86,29 +134,47 @@ DankListView {
readonly property real dismissThreshold: width * 0.35
property bool __delegateInitialized: false
readonly property bool isAdjacentToSwipe: listView.count >= 2 && listView.swipingCardIndex !== -1 &&
(index === listView.swipingCardIndex - 1 || index === listView.swipingCardIndex + 1)
readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? listView.swipingCardOffset * 0.10 : 0
readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(listView.swipingCardOffset) / width * 0.02 : 1.0
readonly property real swipeFadeStartOffset: width * 0.75
readonly property real swipeFadeDistance: Math.max(1, width - swipeFadeStartOffset)
Component.onCompleted: {
Qt.callLater(() => {
__delegateInitialized = true;
if (delegateRoot)
delegateRoot.__delegateInitialized = true;
});
}
width: ListView.view.width
height: isDismissing ? 0 : notificationCard.targetHeight
clip: isDismissing || notificationCard.isAnimating
height: notificationCard.height
clip: notificationCard.isAnimating
NotificationCard {
id: notificationCard
width: parent.width
x: delegateRoot.swipeOffset
x: delegateRoot.swipeOffset + delegateRoot.adjacentSwipeInfluence
listLevelAdjacentScaleInfluence: delegateRoot.adjacentScaleInfluence
listLevelScaleAnimationsEnabled: listView.swipingCardIndex === -1 || !delegateRoot.isAdjacentToSwipe
notificationGroup: modelData
keyboardNavigationActive: listView.keyboardActive
animateExpansion: listView.cardAnimateExpansion && listView.listInitialized
opacity: 1 - Math.abs(delegateRoot.swipeOffset) / (delegateRoot.width * 0.5)
opacity: {
const swipeAmount = Math.abs(delegateRoot.swipeOffset);
if (swipeAmount <= delegateRoot.swipeFadeStartOffset)
return 1;
const fadeProgress = (swipeAmount - delegateRoot.swipeFadeStartOffset) / delegateRoot.swipeFadeDistance;
return Math.max(0, 1 - fadeProgress);
}
onIsAnimatingChanged: {
if (isAnimating) {
listView.isAnimatingExpansion = true;
} else {
Qt.callLater(() => {
if (!notificationCard || !listView)
return;
let anyAnimating = false;
for (let i = 0; i < listView.count; i++) {
const item = listView.itemAtIndex(i);
@@ -139,7 +205,7 @@ DankListView {
}
Behavior on x {
enabled: !swipeDragHandler.active && listView.listInitialized
enabled: !swipeDragHandler.active && !delegateRoot.isDismissing && (listView.swipingCardIndex === -1 || !delegateRoot.isAdjacentToSwipe) && listView.listInitialized
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
@@ -161,12 +227,18 @@ DankListView {
xAxis.enabled: true
onActiveChanged: {
if (active || delegateRoot.isDismissing)
if (active) {
listView.swipingCardIndex = index;
return;
}
listView.swipingCardIndex = -1;
listView.swipingCardOffset = 0;
if (delegateRoot.isDismissing)
return;
if (Math.abs(delegateRoot.swipeOffset) > delegateRoot.dismissThreshold) {
delegateRoot.isDismissing = true;
delegateRoot.swipeOffset = delegateRoot.swipeOffset > 0 ? delegateRoot.width : -delegateRoot.width;
dismissTimer.start();
swipeDismissAnim.to = delegateRoot.swipeOffset > 0 ? delegateRoot.width : -delegateRoot.width;
swipeDismissAnim.start();
} else {
delegateRoot.swipeOffset = 0;
}
@@ -176,13 +248,18 @@ DankListView {
if (delegateRoot.isDismissing)
return;
delegateRoot.swipeOffset = translation.x;
listView.swipingCardOffset = translation.x;
}
}
Timer {
id: dismissTimer
interval: Theme.shortDuration
onTriggered: NotificationService.dismissGroup(delegateRoot.modelData?.key || "")
NumberAnimation {
id: swipeDismissAnim
target: delegateRoot
property: "swipeOffset"
to: 0
duration: Theme.shortDuration
easing.type: Easing.OutCubic
onStopped: NotificationService.dismissGroup(delegateRoot.modelData?.key || "")
}
}

View File

@@ -1,4 +1,5 @@
import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Services.Notifications
import qs.Common
@@ -18,28 +19,43 @@ Rectangle {
property bool isGroupSelected: false
property int selectedNotificationIndex: -1
property bool keyboardNavigationActive: false
property int swipingNotificationIndex: -1
property real swipingNotificationOffset: 0
property real listLevelAdjacentScaleInfluence: 1.0
property bool listLevelScaleAnimationsEnabled: true
readonly property bool compactMode: SettingsData.notificationCompactMode
readonly property real cardPadding: compactMode ? Theme.spacingS : Theme.spacingM
readonly property real iconSize: compactMode ? 48 : 63
readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding
readonly property real iconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal
readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS
readonly property real collapsedDismissOffset: 5
readonly property real badgeSize: compactMode ? 16 : 18
readonly property real actionButtonHeight: compactMode ? 20 : 24
readonly property real collapsedContentHeight: iconSize
readonly property real collapsedContentHeight: Math.max(iconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2))
readonly property real baseCardHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing
width: parent ? parent.width : 400
height: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight)
readonly property real targetHeight: expanded ? (expandedContent.height + cardPadding * 2) : (baseCardHeight + collapsedContent.extraHeight)
radius: Theme.cornerRadius
scale: (cardHoverHandler.hovered ? 1.01 : 1.0) * listLevelAdjacentScaleInfluence
property bool __initialized: false
Component.onCompleted: {
Qt.callLater(() => {
__initialized = true;
if (root)
root.__initialized = true;
});
}
Behavior on scale {
enabled: listLevelScaleAnimationsEnabled
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Behavior on border.color {
enabled: root.__initialized
ColorAnimation {
@@ -83,6 +99,10 @@ Rectangle {
}
clip: true
HoverHandler {
id: cardHoverHandler
}
Rectangle {
anchors.fill: parent
radius: parent.radius
@@ -109,15 +129,16 @@ Rectangle {
id: collapsedContent
readonly property real expandedTextHeight: descriptionText.contentHeight
readonly property real twoLineHeight: descriptionText.font.pixelSize * 1.2 * 2
readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > twoLineHeight + 2) ? (expandedTextHeight - twoLineHeight) : 0
readonly property real collapsedLineCount: compactMode ? 1 : 2
readonly property real collapsedLineHeight: Theme.fontSizeSmall * 1.2 * collapsedLineCount
readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > collapsedLineHeight + 2) ? (expandedTextHeight - collapsedLineHeight) : 0
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: cardPadding
anchors.leftMargin: Theme.spacingL
anchors.rightMargin: Theme.spacingL + (compactMode ? 32 : 40)
anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
height: collapsedContentHeight + extraHeight
visible: !expanded
@@ -139,6 +160,7 @@ Rectangle {
height: iconSize
anchors.left: parent.left
anchors.top: parent.top
anchors.topMargin: descriptionExpanded ? Math.max(0, Theme.fontSizeSmall * 1.2 + (Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2)) / 2 - iconSize / 2) : Math.max(0, Theme.fontSizeSmall * 1.2 + (textContainer.height - Theme.fontSizeSmall * 1.2) / 2 - iconSize / 2)
imageSource: {
if (hasNotificationImage)
@@ -212,29 +234,49 @@ Rectangle {
Column {
width: parent.width
anchors.top: parent.top
spacing: compactMode ? 1 : 2
spacing: Theme.notificationContentSpacing
StyledText {
Row {
id: collapsedHeaderRow
width: parent.width
text: {
const timeStr = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.timeStr) || "";
const appName = (notificationGroup && notificationGroup.appName) || "";
return timeStr.length > 0 ? `${appName} ${timeStr}` : appName;
spacing: Theme.spacingXS
visible: (collapsedHeaderAppNameText.text.length > 0 || collapsedHeaderTimeText.text.length > 0)
StyledText {
id: collapsedHeaderAppNameText
text: notificationGroup?.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 - collapsedHeaderSeparator.implicitWidth - collapsedHeaderTimeText.implicitWidth - parent.spacing * 2)
}
StyledText {
id: collapsedHeaderSeparator
text: (collapsedHeaderAppNameText.text.length > 0 && collapsedHeaderTimeText.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: collapsedHeaderTimeText
text: notificationGroup?.latestNotification?.timeStr || ""
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
}
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
StyledText {
text: (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.summary) || ""
id: collapsedTitleText
width: parent.width
text: notificationGroup?.latestNotification?.summary || ""
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
width: parent.width
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
@@ -301,7 +343,7 @@ Rectangle {
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: Theme.spacingL + (compactMode ? 32 : 40)
anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingS
@@ -343,214 +385,331 @@ Rectangle {
objectName: "notificationRepeater"
model: notificationGroup?.notifications?.slice(0, 10) || []
delegate: Rectangle {
delegate: Item {
id: expandedDelegateWrapper
required property var modelData
required property int index
readonly property bool messageExpanded: NotificationService.expandedMessages[modelData?.notification?.id] || false
readonly property bool isSelected: root.selectedNotificationIndex === index
readonly property real expandedIconSize: compactMode ? 40 : 48
readonly property bool actionsVisible: true
readonly property real expandedIconSize: compactMode ? Theme.notificationExpandedIconSizeCompact : Theme.notificationExpandedIconSizeNormal
HoverHandler {
id: expandedDelegateHoverHandler
}
readonly property real expandedItemPadding: compactMode ? Theme.spacingS : Theme.spacingM
readonly property real expandedBaseHeight: expandedItemPadding * 2 + expandedIconSize + actionButtonHeight + contentSpacing * 2
readonly property real expandedBaseHeight: expandedItemPadding * 2 + Math.max(expandedIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * 2) + actionButtonHeight + contentSpacing * 2
property bool __delegateInitialized: false
property real swipeOffset: 0
property bool isDismissing: false
readonly property real dismissThreshold: width * 0.35
Component.onCompleted: {
Qt.callLater(() => {
__delegateInitialized = true;
if (expandedDelegateWrapper)
expandedDelegateWrapper.__delegateInitialized = true;
});
}
width: parent.width
height: {
if (!messageExpanded)
return expandedBaseHeight;
const twoLineHeight = bodyText.font.pixelSize * 1.2 * 2;
if (bodyText.implicitHeight > twoLineHeight + 2)
return expandedBaseHeight + bodyText.implicitHeight - twoLineHeight;
return expandedBaseHeight;
}
radius: Theme.cornerRadius
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.color: isSelected ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
border.width: 1
height: delegateRect.height
clip: true
Behavior on border.color {
enabled: __delegateInitialized
ColorAnimation {
duration: __delegateInitialized ? Theme.shortDuration : 0
easing.type: Theme.standardEasing
Rectangle {
id: delegateRect
width: parent.width
readonly property bool isAdjacentToSwipe: root.swipingNotificationIndex !== -1 &&
(expandedDelegateWrapper.index === root.swipingNotificationIndex - 1 ||
expandedDelegateWrapper.index === root.swipingNotificationIndex + 1)
readonly property real adjacentSwipeInfluence: isAdjacentToSwipe ? root.swipingNotificationOffset * 0.10 : 0
readonly property real adjacentScaleInfluence: isAdjacentToSwipe ? 1.0 - Math.abs(root.swipingNotificationOffset) / width * 0.02 : 1.0
x: expandedDelegateWrapper.swipeOffset + adjacentSwipeInfluence
scale: adjacentScaleInfluence
transformOrigin: Item.Center
Behavior on x {
enabled: !expandedSwipeHandler.active && !expandedDelegateWrapper.isDismissing
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
}
Behavior on height {
enabled: false
}
Item {
anchors.fill: parent
anchors.margins: compactMode ? Theme.spacingS : Theme.spacingM
anchors.bottomMargin: contentSpacing
DankCircularImage {
id: messageIcon
readonly property string rawImage: modelData?.image || ""
readonly property string iconFromImage: {
if (rawImage.startsWith("image://icon/"))
return rawImage.substring(13);
return "";
Behavior on scale {
enabled: !expandedSwipeHandler.active
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
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/")
}
width: expandedIconSize
height: expandedIconSize
anchors.left: parent.left
anchors.top: parent.top
anchors.topMargin: compactMode ? Theme.spacingM : Theme.spacingXL
height: {
if (!messageExpanded)
return expandedBaseHeight;
const twoLineHeight = bodyText.font.pixelSize * 1.2 * 2;
if (bodyText.implicitHeight > twoLineHeight + 2)
return expandedBaseHeight + bodyText.implicitHeight - twoLineHeight;
return expandedBaseHeight;
}
radius: Theme.cornerRadius
color: isSelected ? Theme.primaryPressed : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
border.color: isSelected ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.4) : Qt.rgba(Theme.outline.r, Theme.outline.g, Theme.outline.b, 0.05)
border.width: 1
imageSource: {
if (hasNotificationImage)
return modelData.cleanImage;
if (imageHasSpecialPrefix)
return "";
const appIcon = modelData?.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);
Behavior on border.color {
enabled: __delegateInitialized
ColorAnimation {
duration: __delegateInitialized ? Theme.shortDuration : 0
easing.type: Theme.standardEasing
}
}
fallbackIcon: {
if (imageHasSpecialPrefix)
return iconFromImage;
return modelData?.appIcon || iconFromImage || "";
}
fallbackText: {
const appName = modelData?.appName || "?";
return appName.charAt(0).toUpperCase();
}
Behavior on height {
enabled: false
}
Item {
anchors.left: messageIcon.right
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.fill: parent
anchors.margins: compactMode ? Theme.spacingS : Theme.spacingM
anchors.bottomMargin: contentSpacing
Column {
DankCircularImage {
id: messageIcon
readonly property string rawImage: modelData?.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/")
width: expandedIconSize
height: expandedIconSize
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: buttonArea.top
anchors.bottomMargin: contentSpacing
spacing: compactMode ? 1 : 2
anchors.topMargin: Theme.fontSizeSmall * 1.2 + (compactMode ? Theme.spacingXS : Theme.spacingS)
StyledText {
width: parent.width
text: modelData?.timeStr || ""
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
imageSource: {
if (hasNotificationImage)
return modelData.cleanImage;
if (imageHasSpecialPrefix)
return "";
const appIcon = modelData?.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);
}
StyledText {
width: parent.width
text: modelData?.summary || ""
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
fallbackIcon: {
if (imageHasSpecialPrefix)
return iconFromImage;
return modelData?.appIcon || iconFromImage || "";
}
StyledText {
id: bodyText
property bool hasMoreText: truncated
text: modelData?.htmlBody || ""
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
width: parent.width
elide: messageExpanded ? Text.ElideNone : Text.ElideRight
maximumLineCount: messageExpanded ? -1 : 2
wrapMode: Text.WordWrap
visible: text.length > 0
linkColor: Theme.primary
onLinkActivated: link => Qt.openUrlExternally(link)
MouseArea {
anchors.fill: parent
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (bodyText.hasMoreText || messageExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: mouse => {
if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) {
NotificationService.toggleMessageExpansion(modelData?.notification?.id || "");
}
}
propagateComposedEvents: true
onPressed: mouse => {
if (parent.hoveredLink) {
mouse.accepted = false;
}
}
onReleased: mouse => {
if (parent.hoveredLink) {
mouse.accepted = false;
}
}
}
fallbackText: {
const appName = modelData?.appName || "?";
return appName.charAt(0).toUpperCase();
}
}
Item {
id: buttonArea
anchors.left: parent.left
anchors.left: messageIcon.right
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.top: parent.top
anchors.bottom: parent.bottom
height: actionButtonHeight + contentSpacing
Row {
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: buttonArea.top
anchors.bottomMargin: contentSpacing
spacing: Theme.notificationContentSpacing
Row {
id: expandedDelegateHeaderRow
width: parent.width
spacing: Theme.spacingXS
visible: (expandedDelegateHeaderAppNameText.text.length > 0 || expandedDelegateHeaderTimeText.text.length > 0)
StyledText {
id: expandedDelegateHeaderAppNameText
text: modelData?.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 - expandedDelegateHeaderSeparator.implicitWidth - expandedDelegateHeaderTimeText.implicitWidth - parent.spacing * 2)
}
StyledText {
id: expandedDelegateHeaderSeparator
text: (expandedDelegateHeaderAppNameText.text.length > 0 && expandedDelegateHeaderTimeText.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: expandedDelegateHeaderTimeText
text: modelData?.timeStr || ""
color: Qt.rgba(Theme.surfaceText.r, Theme.surfaceText.g, Theme.surfaceText.b, 0.7)
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Normal
}
}
StyledText {
id: expandedDelegateTitleText
width: parent.width
text: modelData?.summary || ""
color: Theme.surfaceText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
elide: Text.ElideRight
maximumLineCount: 1
visible: text.length > 0
}
StyledText {
id: bodyText
property bool hasMoreText: truncated
text: modelData?.htmlBody || ""
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
width: parent.width
elide: messageExpanded ? Text.ElideNone : Text.ElideRight
maximumLineCount: messageExpanded ? -1 : 2
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
visible: text.length > 0
linkColor: Theme.primary
onLinkActivated: link => Qt.openUrlExternally(link)
MouseArea {
anchors.fill: parent
cursorShape: parent.hoveredLink ? Qt.PointingHandCursor : (bodyText.hasMoreText || messageExpanded) ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: mouse => {
if (!parent.hoveredLink && (bodyText.hasMoreText || messageExpanded)) {
NotificationService.toggleMessageExpansion(modelData?.notification?.id || "");
}
}
propagateComposedEvents: true
onPressed: mouse => {
if (parent.hoveredLink) {
mouse.accepted = false;
}
}
onReleased: mouse => {
if (parent.hoveredLink) {
mouse.accepted = false;
}
}
}
}
}
Item {
id: buttonArea
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
spacing: contentSpacing
height: actionButtonHeight + contentSpacing
Repeater {
model: modelData?.actions || []
Row {
visible: expandedDelegateWrapper.actionsVisible
opacity: visible ? 1 : 0
anchors.right: parent.right
anchors.bottom: parent.bottom
spacing: contentSpacing
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
Repeater {
model: modelData?.actions || []
Rectangle {
property bool isHovered: false
width: Math.max(expandedActionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
StyledText {
id: expandedActionText
text: {
const baseText = modelData.text || "Open";
if (keyboardNavigationActive && (isGroupSelected || selectedNotificationIndex >= 0))
return `${baseText} (${index + 1})`;
return baseText;
}
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
onEntered: parent.isHovered = true
onExited: parent.isHovered = false
onClicked: {
if (modelData && modelData.invoke)
modelData.invoke();
}
}
}
}
Rectangle {
id: expandedDelegateDismissBtn
property bool isHovered: false
width: Math.max(expandedActionText.implicitWidth + Theme.spacingM, compactMode ? 40 : 50)
visible: expandedDelegateWrapper.actionsVisible
opacity: visible ? 1 : 0
width: Math.max(expandedClearText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.spacingXS
color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
Behavior on opacity {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Theme.standardEasing
}
}
StyledText {
id: expandedActionText
text: {
const baseText = modelData.text || "View";
if (keyboardNavigationActive && (isGroupSelected || selectedNotificationIndex >= 0))
return `${baseText} (${index + 1})`;
return baseText;
}
id: expandedClearText
text: I18n.tr("Dismiss")
color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
anchors.centerIn: parent
elide: Text.ElideRight
}
MouseArea {
@@ -559,44 +718,56 @@ Rectangle {
cursorShape: Qt.PointingHandCursor
onEntered: parent.isHovered = true
onExited: parent.isHovered = false
onClicked: {
if (modelData && modelData.invoke)
modelData.invoke();
}
onClicked: NotificationService.dismissNotification(modelData)
}
}
}
Rectangle {
property bool isHovered: false
width: Math.max(expandedClearText.implicitWidth + Theme.spacingM, compactMode ? 40 : 50)
height: actionButtonHeight
radius: Theme.spacingXS
color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
StyledText {
id: expandedClearText
text: I18n.tr("Dismiss")
color: parent.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
onEntered: parent.isHovered = true
onExited: parent.isHovered = false
onClicked: NotificationService.dismissNotification(modelData)
}
}
}
}
}
}
DragHandler {
id: expandedSwipeHandler
target: null
xAxis.enabled: true
yAxis.enabled: false
grabPermissions: PointerHandler.CanTakeOverFromItems | PointerHandler.CanTakeOverFromHandlersOfDifferentType
onActiveChanged: {
if (active) {
root.swipingNotificationIndex = expandedDelegateWrapper.index;
} else {
root.swipingNotificationIndex = -1;
root.swipingNotificationOffset = 0;
}
if (active || expandedDelegateWrapper.isDismissing)
return;
if (Math.abs(expandedDelegateWrapper.swipeOffset) > expandedDelegateWrapper.dismissThreshold) {
expandedDelegateWrapper.isDismissing = true;
expandedSwipeDismissAnim.start();
} else {
expandedDelegateWrapper.swipeOffset = 0;
}
}
onTranslationChanged: {
if (expandedDelegateWrapper.isDismissing)
return;
expandedDelegateWrapper.swipeOffset = translation.x;
root.swipingNotificationOffset = translation.x;
}
}
NumberAnimation {
id: expandedSwipeDismissAnim
target: expandedDelegateWrapper
property: "swipeOffset"
to: expandedDelegateWrapper.swipeOffset > 0 ? expandedDelegateWrapper.width : -expandedDelegateWrapper.width
duration: Theme.shortDuration
easing.type: Easing.OutCubic
onStopped: NotificationService.dismissNotification(modelData)
}
}
}
}
@@ -607,7 +778,7 @@ Rectangle {
anchors.right: clearButton.visible ? clearButton.left : parent.right
anchors.rightMargin: clearButton.visible ? contentSpacing : Theme.spacingL
anchors.top: collapsedContent.bottom
anchors.topMargin: contentSpacing
anchors.topMargin: contentSpacing + collapsedDismissOffset
spacing: contentSpacing
Repeater {
@@ -616,15 +787,15 @@ Rectangle {
Rectangle {
property bool isHovered: false
width: Math.max(collapsedActionText.implicitWidth + Theme.spacingM, compactMode ? 40 : 50)
width: Math.max(collapsedActionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.spacingXS
color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
StyledText {
id: collapsedActionText
text: {
const baseText = modelData.text || "View";
const baseText = modelData.text || "Open";
if (keyboardNavigationActive && isGroupSelected) {
return `${baseText} (${index + 1})`;
}
@@ -663,11 +834,11 @@ Rectangle {
anchors.right: parent.right
anchors.rightMargin: Theme.spacingL
anchors.top: collapsedContent.bottom
anchors.topMargin: contentSpacing
width: Math.max(collapsedClearText.implicitWidth + Theme.spacingM, compactMode ? 40 : 50)
anchors.topMargin: contentSpacing + collapsedDismissOffset
width: Math.max(collapsedClearText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.spacingXS
color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
StyledText {
id: collapsedClearText
@@ -691,6 +862,7 @@ Rectangle {
MouseArea {
anchors.fill: parent
visible: !expanded && (notificationGroup?.count || 0) > 1 && !descriptionExpanded
cursorShape: Qt.PointingHandCursor
onClicked: {
root.userInitiatedExpansion = true;
NotificationService.toggleGroupExpansion(notificationGroup?.key || "");
@@ -731,11 +903,11 @@ Rectangle {
}
Behavior on height {
enabled: root.userInitiatedExpansion && root.animateExpansion
enabled: root.__initialized && root.userInitiatedExpansion && root.animateExpansion
NumberAnimation {
duration: Theme.expressiveDurations.normal
duration: root.expanded ? Theme.notificationExpandDuration : Theme.notificationCollapseDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standard
easing.bezierCurve: Theme.expressiveCurves.emphasized
onRunningChanged: {
if (running) {
root.isAnimating = true;
@@ -746,4 +918,102 @@ Rectangle {
}
}
}
Menu {
id: notificationCardContextMenu
width: 220
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 = notificationGroup?.appName || "";
const desktopEntry = notificationGroup?.latestNotification?.desktopEntry || "";
SettingsData.addNotificationRuleForNotification(appName, desktopEntry);
PopoutService.openSettingsWithTab("notifications");
}
}
MenuItem {
id: muteUnmuteItem
readonly property bool isMuted: SettingsData.isAppMuted(notificationGroup?.appName || "", notificationGroup?.latestNotification?.desktopEntry || "")
text: isMuted ? I18n.tr("Unmute popups for %1").arg(notificationGroup?.appName || I18n.tr("this app")) : I18n.tr("Mute popups for %1").arg(notificationGroup?.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 = notificationGroup?.appName || "";
const desktopEntry = notificationGroup?.latestNotification?.desktopEntry || "";
if (isMuted) {
SettingsData.removeMuteRuleForApp(appName, desktopEntry);
} else {
SettingsData.addMuteRuleForApp(appName, desktopEntry);
NotificationService.dismissGroup(notificationGroup?.key || "");
}
}
}
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: NotificationService.dismissGroup(notificationGroup?.key || "")
}
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.RightButton
z: -2
onClicked: mouse => {
if (mouse.button === Qt.RightButton && notificationGroup) {
notificationCardContextMenu.popup();
}
}
}
}

View File

@@ -10,6 +10,17 @@ DankPopout {
property bool notificationHistoryVisible: false
property var triggerScreen: null
property real stablePopupHeight: 400
property real _lastAlignedContentHeight: -1
function updateStablePopupHeight() {
const item = contentLoader.item;
const target = item ? Theme.px(item.implicitHeight, dpr) : 400;
if (Math.abs(target - _lastAlignedContentHeight) < 0.5)
return;
_lastAlignedContentHeight = target;
stablePopupHeight = target;
}
NotificationKeyboardController {
id: keyboardController
@@ -20,11 +31,12 @@ DankPopout {
}
}
popupWidth: 400
popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 400
popupWidth: triggerScreen ? Math.min(500, Math.max(380, triggerScreen.width - 48)) : 400
popupHeight: stablePopupHeight
positioning: ""
animationScaleCollapsed: 1.0
animationOffset: 0
suspendShadowWhileResizing: true
screen: triggerScreen
shouldBeVisible: notificationHistoryVisible
@@ -68,19 +80,32 @@ DankPopout {
Connections {
target: contentLoader
function onLoaded() {
root.updateStablePopupHeight();
if (root.shouldBeVisible)
Qt.callLater(root.setupKeyboardNavigation);
}
}
Connections {
target: contentLoader.item
function onImplicitHeightChanged() {
root.updateStablePopupHeight();
}
}
onDprChanged: updateStablePopupHeight()
onShouldBeVisibleChanged: {
if (shouldBeVisible) {
NotificationService.onOverlayOpen();
updateStablePopupHeight();
if (contentLoader.item)
Qt.callLater(setupKeyboardNavigation);
} else {
NotificationService.onOverlayClose();
keyboardController.keyboardNavigationActive = false;
NotificationService.expandedGroups = {};
NotificationService.expandedMessages = {};
}
}
@@ -113,7 +138,7 @@ DankPopout {
baseHeight += Theme.spacingM * 2;
const settingsHeight = notificationSettings.expanded ? notificationSettings.contentHeight : 0;
let listHeight = notificationHeader.currentTab === 0 ? notificationList.listContentHeight : Math.max(200, NotificationService.historyList.length * 80);
let listHeight = notificationHeader.currentTab === 0 ? notificationList.stableContentHeight : Math.max(200, NotificationService.historyList.length * 80);
if (notificationHeader.currentTab === 0 && NotificationService.groupedNotifications.length === 0) {
listHeight = 200;
}

View File

@@ -262,6 +262,50 @@ Rectangle {
}
}
Item {
width: parent.width
height: Math.max(privacyRow.implicitHeight, privacyToggle.implicitHeight) + Theme.spacingS
Row {
id: privacyRow
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingM
DankIcon {
name: "privacy_tip"
size: Theme.iconSizeSmall
color: SettingsData.notificationPopupPrivacyMode ? Theme.primary : Theme.surfaceText
anchors.verticalCenter: parent.verticalCenter
}
Column {
spacing: 2
anchors.verticalCenter: parent.verticalCenter
StyledText {
text: I18n.tr("Privacy Mode")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceText
}
StyledText {
text: I18n.tr("Hide notification content until expanded")
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
}
}
}
DankToggle {
id: privacyToggle
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
checked: SettingsData.notificationPopupPrivacyMode
onToggled: toggled => SettingsData.set("notificationPopupPrivacyMode", toggled)
}
}
Rectangle {
width: parent.width
height: 1

View File

@@ -16,24 +16,41 @@ PanelWindow {
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
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.spacingS : Theme.spacingM
readonly property real popupIconSize: compactMode ? 48 : 63
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: 5
readonly property real actionButtonHeight: compactMode ? 20 : 24
readonly property real collapsedContentHeight: popupIconSize
readonly property real basePopupHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + Theme.spacingS
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) {
@@ -99,22 +116,38 @@ PanelWindow {
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
color: "transparent"
implicitWidth: 400
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 twoLineHeight = Theme.fontSizeSmall * 1.2 * 2;
if (bodyTextHeight > twoLineHeight + 2)
return basePopupHeight + bodyTextHeight - twoLineHeight;
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);
if (SettingsData.notificationPopupPrivacyMode)
descriptionExpanded = false;
if (hasValidData) {
Qt.callLater(() => enterX.restart());
} else {
@@ -123,6 +156,8 @@ PanelWindow {
}
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;
}
@@ -141,9 +176,11 @@ PanelWindow {
}
property bool isTopCenter: SettingsData.notificationPopupPosition === -1
property bool isBottomCenter: SettingsData.notificationPopupPosition === SettingsData.Position.BottomCenter
property bool isCenterPosition: isTopCenter || isBottomCenter
anchors.top: isTopCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Left
anchors.bottom: SettingsData.notificationPopupPosition === SettingsData.Position.Bottom || SettingsData.notificationPopupPosition === SettingsData.Position.Right
anchors.bottom: isBottomCenter || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom || SettingsData.notificationPopupPosition === SettingsData.Position.Right
anchors.left: SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom
anchors.right: SettingsData.notificationPopupPosition === SettingsData.Position.Top || SettingsData.notificationPopupPosition === SettingsData.Position.Right
@@ -182,7 +219,7 @@ PanelWindow {
function getBottomMargin() {
const popupPos = SettingsData.notificationPopupPosition;
const isBottom = popupPos === SettingsData.Position.Bottom || popupPos === SettingsData.Position.Right;
const isBottom = isBottomCenter || popupPos === SettingsData.Position.Bottom || popupPos === SettingsData.Position.Right;
if (!isBottom)
return 0;
@@ -192,7 +229,7 @@ PanelWindow {
}
function getLeftMargin() {
if (isTopCenter)
if (isCenterPosition)
return screen ? (screen.width - implicitWidth) / 2 : 0;
const popupPos = SettingsData.notificationPopupPosition;
@@ -205,7 +242,7 @@ PanelWindow {
}
function getRightMargin() {
if (isTopCenter)
if (isCenterPosition)
return 0;
const popupPos = SettingsData.notificationPopupPosition;
@@ -230,23 +267,51 @@ PanelWindow {
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: isTopCenter ? height * 0.4 : width * 0.35
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
property real shadowBlurPx: 10
property real shadowSpreadPx: 0
property real shadowBaseAlpha: 0.60
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.enabled: !win._isDestroying && win.screenValid && !implicitHeightAnim.running
layer.smooth: false
layer.textureSize: Qt.size(Math.round(width * win.dpr), Math.round(height * win.dpr))
layer.textureMirroring: ShaderEffectSource.MirrorVertically
@@ -256,7 +321,7 @@ PanelWindow {
layer.effect: MultiEffect {
id: shadowFx
autoPaddingEnabled: true
shadowEnabled: true
shadowEnabled: SettingsData.notificationPopupShadowEnabled
blurEnabled: false
maskEnabled: false
shadowBlur: Math.max(0, Math.min(1, content.shadowBlurPx / bgShadowLayer.blurMax))
@@ -268,7 +333,7 @@ PanelWindow {
}
Rectangle {
id: backgroundShape
id: shadowShapeSource
anchors.fill: parent
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
@@ -278,7 +343,7 @@ PanelWindow {
Rectangle {
anchors.fill: parent
radius: backgroundShape.radius
radius: shadowShapeSource.radius
visible: notificationData && notificationData.urgency === NotificationUrgency.Critical
opacity: 1
clip: true
@@ -310,6 +375,24 @@ PanelWindow {
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
@@ -317,16 +400,18 @@ PanelWindow {
id: notificationContent
readonly property real expandedTextHeight: bodyText.contentHeight || 0
readonly property real twoLineHeight: Theme.fontSizeSmall * 1.2 * 2
readonly property real extraHeight: (descriptionExpanded && expandedTextHeight > twoLineHeight + 2) ? (expandedTextHeight - twoLineHeight) : 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 + (compactMode ? 32 : 40)
height: collapsedContentHeight + extraHeight
anchors.rightMargin: Theme.spacingL + Theme.notificationHoverRevealMargin
height: effectiveCollapsedHeight + extraHeight
clip: SettingsData.notificationPopupPrivacyMode && !descriptionExpanded
DankCircularImage {
id: iconContainer
@@ -348,6 +433,15 @@ PanelWindow {
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)
@@ -401,24 +495,40 @@ PanelWindow {
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.top: parent.top
spacing: compactMode ? 1 : 2
spacing: Theme.notificationContentSpacing
StyledText {
Row {
id: headerRow
width: parent.width
text: {
if (!notificationData)
return "";
const appName = notificationData.appName || "";
const timeStr = notificationData.timeStr || "";
return timeStr.length > 0 ? appName + " • " + timeStr : appName;
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
}
color: Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
maximumLineCount: 1
visible: text.length > 0
}
StyledText {
@@ -444,8 +554,9 @@ PanelWindow {
elide: descriptionExpanded ? Text.ElideNone : Text.ElideRight
horizontalAlignment: Text.AlignLeft
maximumLineCount: descriptionExpanded ? -1 : (compactMode ? 1 : 2)
wrapMode: Text.WordWrap
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
visible: text.length > 0
opacity: (SettingsData.notificationPopupPrivacyMode && !descriptionExpanded) ? 0 : 1
linkColor: Theme.primary
onLinkActivated: link => Qt.openUrlExternally(link)
@@ -469,6 +580,14 @@ PanelWindow {
}
}
}
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
}
}
}
@@ -480,16 +599,38 @@ PanelWindow {
anchors.topMargin: cardPadding
anchors.rightMargin: Theme.spacingL
iconName: "close"
iconSize: compactMode ? 16 : 18
buttonSize: compactMode ? 24 : 28
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
@@ -497,21 +638,28 @@ PanelWindow {
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, compactMode ? 40 : 50)
width: Math.max(actionText.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.spacingXS
color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
StyledText {
id: actionText
text: modelData.text || "View"
text: modelData.text || "Open"
color: parent.isHovered ? Theme.primary : Theme.surfaceVariantText
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
@@ -543,15 +691,22 @@ PanelWindow {
property bool isHovered: false
readonly property int actionCount: notificationData ? (notificationData.actions || []).length : 0
visible: actionCount < 3
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, compactMode ? 40 : 50)
width: Math.max(clearTextLabel.implicitWidth + Theme.spacingM, Theme.notificationActionMinWidth)
height: actionButtonHeight
radius: Theme.spacingXS
color: isHovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.1) : "transparent"
radius: Theme.notificationButtonCornerRadius
color: isHovered ? Theme.withAlpha(Theme.primary, Theme.stateLayerHover) : "transparent"
z: 20
StyledText {
@@ -584,23 +739,19 @@ PanelWindow {
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
propagateComposedEvents: true
z: -1
onEntered: {
if (notificationData && notificationData.timer)
notificationData.timer.stop();
}
onExited: {
if (notificationData && notificationData.popup && notificationData.timer)
notificationData.timer.restart();
}
onClicked: mouse => {
if (!notificationData || win.exiting)
return;
if (mouse.button === Qt.RightButton) {
NotificationService.dismissNotification(notificationData);
popupContextMenu.popup();
} else if (mouse.button === Qt.LeftButton) {
if (notificationData.actions && notificationData.actions.length > 0) {
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 {
@@ -614,8 +765,8 @@ PanelWindow {
DragHandler {
id: swipeDragHandler
target: null
xAxis.enabled: !isTopCenter
yAxis.enabled: isTopCenter
xAxis.enabled: !isCenterPosition
yAxis.enabled: isCenterPosition
onActiveChanged: {
if (active || win.exiting || content.swipeDismissing)
@@ -633,9 +784,11 @@ PanelWindow {
if (win.exiting)
return;
const raw = isTopCenter ? translation.y : translation.x;
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);
@@ -643,7 +796,13 @@ PanelWindow {
}
}
opacity: 1 - Math.abs(content.swipeOffset) / (isTopCenter ? content.height : content.width * 0.6)
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
@@ -664,7 +823,7 @@ PanelWindow {
id: swipeDismissAnim
target: content
property: "swipeOffset"
to: isTopCenter ? -content.height : (SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom ? -content.width : content.width)
to: isTopCenter ? -content.height : isBottomCenter ? content.height : (SettingsData.notificationPopupPosition === SettingsData.Position.Left || SettingsData.notificationPopupPosition === SettingsData.Position.Bottom ? -content.width : content.width)
duration: Theme.shortDuration
easing.type: Easing.OutCubic
onStopped: {
@@ -676,18 +835,18 @@ PanelWindow {
transform: [
Translate {
id: swipeTx
x: isTopCenter ? 0 : content.swipeOffset
y: isTopCenter ? content.swipeOffset : 0
x: isCenterPosition ? 0 : content.swipeOffset
y: isCenterPosition ? content.swipeOffset : 0
},
Translate {
id: tx
x: {
if (isTopCenter)
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 : 0
y: isTopCenter ? -Anims.slidePx : isBottomCenter ? Anims.slidePx : 0
}
]
}
@@ -696,20 +855,22 @@ PanelWindow {
id: enterX
target: tx
property: isTopCenter ? "y" : "x"
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.mediumDuration
duration: Theme.notificationEnterDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: isTopCenter ? Theme.expressiveCurves.standardDecel : Theme.expressiveCurves.emphasizedDecel
easing.bezierCurve: isCenterPosition ? Theme.expressiveCurves.standardDecel : Theme.expressiveCurves.emphasizedDecel
onStopped: {
if (!win.exiting && !win._isDestroying) {
if (isTopCenter) {
if (isCenterPosition) {
if (Math.abs(tx.y) < 0.5)
win.entered();
} else {
@@ -727,15 +888,17 @@ PanelWindow {
PropertyAnimation {
target: tx
property: isTopCenter ? "y" : "x"
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.shortDuration
duration: Theme.notificationExitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel
}
@@ -745,7 +908,7 @@ PanelWindow {
property: "opacity"
from: 1
to: 0
duration: Theme.shortDuration
duration: Theme.notificationExitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.standardAccel
}
@@ -755,7 +918,7 @@ PanelWindow {
property: "scale"
from: 1
to: 0.98
duration: Theme.shortDuration
duration: Theme.notificationExitDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: Theme.expressiveCurves.emphasizedAccel
}
@@ -819,4 +982,98 @@ PanelWindow {
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);
}
}
}
}

View File

@@ -8,26 +8,23 @@ QtObject {
property var modelData
property int topMargin: 0
readonly property bool compactMode: SettingsData.notificationCompactMode
readonly property real cardPadding: compactMode ? Theme.spacingS : Theme.spacingM
readonly property real popupIconSize: compactMode ? 48 : 63
readonly property real cardPadding: compactMode ? Theme.notificationCardPaddingCompact : Theme.notificationCardPadding
readonly property real popupIconSize: compactMode ? Theme.notificationIconSizeCompact : Theme.notificationIconSizeNormal
readonly property real actionButtonHeight: compactMode ? 20 : 24
readonly property real popupSpacing: 4
readonly property int baseNotificationHeight: cardPadding * 2 + popupIconSize + actionButtonHeight + Theme.spacingS + popupSpacing
property int maxTargetNotifications: 4
property var popupWindows: [] // strong refs to windows (live until exitFinished)
readonly property real contentSpacing: compactMode ? Theme.spacingXS : Theme.spacingS
readonly property real popupSpacing: compactMode ? 0 : Theme.spacingXS
readonly property real collapsedContentHeight: Math.max(popupIconSize, Theme.fontSizeSmall * 1.2 + Theme.fontSizeMedium * 1.2 + Theme.fontSizeSmall * 1.2 * (compactMode ? 1 : 2))
readonly property int baseNotificationHeight: cardPadding * 2 + collapsedContentHeight + actionButtonHeight + contentSpacing + popupSpacing
property var popupWindows: []
property var destroyingWindows: new Set()
property var pendingDestroys: []
property int destroyDelayMs: 100
property var pendingCreates: []
property int createDelayMs: 50
property bool createBusy: false
property Component popupComponent
popupComponent: Component {
NotificationPopup {
onEntered: manager._onPopupEntered(this)
onExitStarted: manager._onPopupExitStarted(this)
onExitFinished: manager._onPopupExitFinished(this)
onPopupHeightChanged: manager._onPopupHeightChanged(this)
}
}
@@ -71,36 +68,6 @@ QtObject {
destroyTimer.restart();
}
property Timer createTimer: Timer {
interval: createDelayMs
running: false
repeat: false
onTriggered: manager._processCreateQueue()
}
function _processCreateQueue() {
createBusy = false;
if (pendingCreates.length === 0)
return;
const wrapper = pendingCreates.shift();
if (wrapper)
_doInsertNewestAtTop(wrapper);
if (pendingCreates.length > 0) {
createBusy = true;
createTimer.restart();
}
}
function _scheduleCreate(wrapper) {
if (!wrapper)
return;
pendingCreates.push(wrapper);
if (!createBusy) {
createBusy = true;
createTimer.restart();
}
}
sweeper: Timer {
interval: 500
running: false
@@ -126,14 +93,10 @@ QtObject {
}
if (toRemove.length) {
popupWindows = popupWindows.filter(p => toRemove.indexOf(p) === -1);
const survivors = _active().sort((a, b) => a.screenY - b.screenY);
for (let k = 0; k < survivors.length; ++k) {
survivors[k].screenY = topMargin + k * baseNotificationHeight;
}
_repositionAll();
}
if (popupWindows.length === 0) {
if (popupWindows.length === 0)
sweeper.stop();
}
}
}
@@ -145,105 +108,29 @@ QtObject {
return p && p.status !== Component.Null && !p._isDestroying && p.hasValidData;
}
function _canMakeRoomFor(wrapper) {
const activeWindows = _active();
if (activeWindows.length < maxTargetNotifications) {
return true;
}
if (!wrapper || !wrapper.notification) {
return false;
}
const incomingUrgency = wrapper.urgency || 0;
for (const p of activeWindows) {
if (!p.notificationData || !p.notificationData.notification) {
continue;
}
const existingUrgency = p.notificationData.urgency || 0;
if (existingUrgency < incomingUrgency) {
return true;
}
if (existingUrgency === incomingUrgency) {
const timer = p.notificationData.timer;
if (timer && !timer.running) {
return true;
}
}
}
return false;
}
function _makeRoomForNew(wrapper) {
const activeWindows = _active();
if (activeWindows.length < maxTargetNotifications) {
return;
}
const toRemove = _selectPopupToRemove(activeWindows, wrapper);
if (toRemove && !toRemove.exiting) {
toRemove.notificationData.removedByLimit = true;
toRemove.notificationData.popup = false;
if (toRemove.notificationData.timer) {
toRemove.notificationData.timer.stop();
}
}
}
function _selectPopupToRemove(activeWindows, incomingWrapper) {
const sortedWindows = activeWindows.slice().sort((a, b) => {
const aUrgency = (a.notificationData) ? a.notificationData.urgency || 0 : 0;
const bUrgency = (b.notificationData) ? b.notificationData.urgency || 0 : 0;
if (aUrgency !== bUrgency) {
return aUrgency - bUrgency;
}
const aTimer = a.notificationData && a.notificationData.timer;
const bTimer = b.notificationData && b.notificationData.timer;
const aRunning = aTimer && aTimer.running;
const bRunning = bTimer && bTimer.running;
if (aRunning !== bRunning) {
return aRunning ? 1 : -1;
}
return b.screenY - a.screenY;
});
return sortedWindows[0];
}
function _sync(newWrappers) {
for (const w of newWrappers) {
if (w && !_hasWindowFor(w)) {
insertNewestAtTop(w);
}
}
for (const p of popupWindows.slice()) {
if (!_isValidWindow(p)) {
if (!_isValidWindow(p) || p.exiting)
continue;
}
if (p.notificationData && newWrappers.indexOf(p.notificationData) === -1 && !p.exiting) {
if (p.notificationData && newWrappers.indexOf(p.notificationData) === -1) {
p.notificationData.removedByLimit = true;
p.notificationData.popup = false;
}
}
for (const w of newWrappers) {
if (w && !_hasWindowFor(w))
_insertAtTop(w);
}
}
function insertNewestAtTop(wrapper) {
if (!wrapper)
return;
if (createBusy || pendingCreates.length > 0) {
_scheduleCreate(wrapper);
return;
}
_doInsertNewestAtTop(wrapper);
function _popupHeight(p) {
return (p.alignedHeight || p.implicitHeight || (baseNotificationHeight - popupSpacing)) + popupSpacing;
}
function _doInsertNewestAtTop(wrapper) {
function _insertAtTop(wrapper) {
if (!wrapper)
return;
for (const p of popupWindows) {
if (!_isValidWindow(p))
continue;
if (p.exiting)
continue;
p.screenY = p.screenY + baseNotificationHeight;
}
const notificationId = wrapper && wrapper.notification ? wrapper.notification.id : "";
const notificationId = wrapper?.notification ? wrapper.notification.id : "";
const win = popupComponent.createObject(null, {
"notificationData": wrapper,
"notificationId": notificationId,
@@ -256,72 +143,70 @@ QtObject {
win.destroy();
return;
}
popupWindows.push(win);
createBusy = true;
createTimer.restart();
popupWindows.unshift(win);
_repositionAll();
if (!sweeper.running)
sweeper.start();
}
function _active() {
return popupWindows.filter(p => _isValidWindow(p) && p.notificationData && p.notificationData.popup && !p.exiting);
}
function _repositionAll() {
const active = popupWindows.filter(p => _isValidWindow(p) && p.notificationData?.popup && !p.exiting);
function _bottom() {
let b = null;
let maxY = -1;
for (const p of _active()) {
if (p.screenY > maxY) {
maxY = p.screenY;
b = p;
}
const pinnedSlots = [];
for (const p of active) {
if (!p.hovered)
continue;
pinnedSlots.push({
y: p.screenY,
end: p.screenY + _popupHeight(p)
});
}
pinnedSlots.sort((a, b) => a.y - b.y);
let currentY = topMargin;
for (const win of active) {
if (win.hovered)
continue;
for (const slot of pinnedSlots) {
if (currentY >= slot.y - 1 && currentY < slot.end)
currentY = slot.end;
}
win.screenY = currentY;
currentY += _popupHeight(win);
}
return b;
}
function _onPopupEntered(p) {
}
function _onPopupExitStarted(p) {
if (!p)
function _onPopupHeightChanged(p) {
if (!p || p.exiting || p._isDestroying)
return;
const survivors = _active().sort((a, b) => a.screenY - b.screenY);
for (let k = 0; k < survivors.length; ++k)
survivors[k].screenY = topMargin + k * baseNotificationHeight;
if (popupWindows.indexOf(p) === -1)
return;
_repositionAll();
}
function _onPopupExitFinished(p) {
if (!p) {
if (!p)
return;
}
const windowId = p.toString();
if (destroyingWindows.has(windowId)) {
if (destroyingWindows.has(windowId))
return;
}
destroyingWindows.add(windowId);
const i = popupWindows.indexOf(p);
if (i !== -1) {
popupWindows.splice(i, 1);
popupWindows = popupWindows.slice();
}
if (NotificationService.releaseWrapper && p.notificationData) {
if (NotificationService.releaseWrapper && p.notificationData)
NotificationService.releaseWrapper(p.notificationData);
}
_scheduleDestroy(p);
Qt.callLater(() => destroyingWindows.delete(windowId));
const survivors = _active().sort((a, b) => a.screenY - b.screenY);
for (let k = 0; k < survivors.length; ++k) {
survivors[k].screenY = topMargin + k * baseNotificationHeight;
}
_repositionAll();
}
function cleanupAllWindows() {
sweeper.stop();
destroyTimer.stop();
createTimer.stop();
pendingDestroys = [];
pendingCreates = [];
createBusy = false;
for (const p of popupWindows.slice()) {
if (p) {
try {