From 196c421b754e62926814ef972bde39003f2ce3c6 Mon Sep 17 00:00:00 2001 From: bbedward Date: Mon, 16 Feb 2026 17:57:13 -0500 Subject: [PATCH] Squashed commit of the following: commit 051b7576f7ed31bbfbc58620e2eadfee41dbbac3 Author: purian23 Date: Sun Feb 15 16:38:45 2026 -0500 Height for realz commit 7784488a61a3a66e03ea2b387ca1e9da2f5e02fd Author: purian23 Date: Sun Feb 15 16:34:09 2026 -0500 Fix height and truncate text/URLs commit 31b328d428b6cb1266aeb890eef052f003379a32 Author: bbedward Date: Sun Feb 15 16:25:57 2026 -0500 notifications: handle URL encoding in markdown2html commit dbb04f74a2ab52751f48bd851e74a79a3549c68a Author: bbedward Date: Sun Feb 15 16:10:20 2026 -0500 notifications: more comprehensive decoder commit b29c7192c2b27d8b3e01a08dbad7656029abf673 Author: bbedward Date: Sun Feb 15 15:51:37 2026 -0500 notifications: html unescape commit 8a48fa11ecfffa6588807a736c897d247167b3cc Author: purian23 Date: Sun Feb 15 15:04:33 2026 -0500 Add expressive curve on init toast commit ee124f5e04f39435b7c8cbaabc82f89a689a3847 Author: purian23 Date: Sun Feb 15 15:02:16 2026 -0500 Expressive curves on swipe & btn height commit 0fce90463567be6b01c4a316bc489baffdaa4602 Author: purian23 Date: Sun Feb 15 13:40:02 2026 -0500 Provide bottom button clearance commit 00d3829999492ab1b698a0d6c3079e53f510f725 Author: bbedward Date: Sun Feb 15 13:24:31 2026 -0500 notifications: cleanup popup display logic commit fd05768059db8abda8a33ae8f47caf962c6aefaa Author: purian23 Date: Sun Feb 15 01:00:55 2026 -0500 Add Privacy Mode - Smoother notification expansions - Shadow & Privacy Toggles commit 0dba11d845871eeaa969571e6bede56f984655c7 Author: purian23 Date: Sat Feb 14 22:48:46 2026 -0500 Further M3 enhancements commit 949c216964b87c1ae265854ef1886b5b4990fd0d Author: purian23 Date: Sat Feb 14 19:59:38 2026 -0500 Right-Click to set Rules on Notifications directly commit 62bc25782cc1dcc5f71e3c3718d7eefbcc88029e Author: bbedward 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 ed495d4396b52df2b44eb96bbfd3dea1565f8458 Author: purian23 Date: Fri Feb 13 20:25:40 2026 -0500 Tighten init toast commit ebe38322a03956b864171a4fdf6986c97befbd34 Author: purian23 Date: Fri Feb 13 20:09:59 2026 -0500 Update more m3 baselines & spacing commit b1735bb7013a7c96917597e83d7f5433e528a735 Author: purian23 Date: Fri Feb 13 14:10:05 2026 -0500 Expand rules on-Click commit 9f13546b4d9df991da91ed0f3eedccd32747530e Author: purian23 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 be133b73c7c0781cde74258da2a455d14bc9feea Author: purian23 Date: Fri Feb 13 10:10:03 2026 -0500 Truncate long title in groups commit 4fc275bead670d5485a7cfb0402e38057efe6443 Author: bbedward Date: Thu Feb 12 23:27:34 2026 -0500 notification: expand/collapse animation adjustment commit 00e6172a6888cd08781c2b321ded2d3c359dbf18 Author: purian23 Date: Thu Feb 12 22:50:11 2026 -0500 Fix global warnings commit 0772f6deb7aa19bad3ee88a769e7b75d36d7f775 Author: purian23 Date: Thu Feb 12 22:46:40 2026 -0500 Tweak expansion duration commit 0ffeed3ff0b25d54eca00166a3f77db6c4dc4c49 Author: purian23 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 --- core/internal/notify/notify.go | 10 + quickshell/Common/SettingsData.qml | 91 +++ quickshell/Common/Theme.qml | 47 ++ quickshell/Common/markdown2html.js | 19 +- quickshell/Common/settings/SettingsSpec.js | 4 + quickshell/Modals/NotificationModal.qml | 4 +- .../Center/HistoryNotificationCard.qml | 67 +- .../KeyboardNavigatedNotificationList.qml | 105 ++- .../Notifications/Center/NotificationCard.qml | 710 ++++++++++++------ .../Center/NotificationCenterPopout.qml | 31 +- .../Center/NotificationSettings.qml | 44 ++ .../Notifications/Popup/NotificationPopup.qml | 411 ++++++++-- .../Popup/NotificationPopupManager.qml | 223 ++---- .../Modules/Settings/NotificationsTab.qml | 232 +++++- quickshell/Services/NotificationService.qml | 186 ++++- quickshell/Widgets/DankDropdown.qml | 6 +- quickshell/Widgets/DankPopout.qml | 24 +- .../translations/settings_search_index.json | 44 ++ 18 files changed, 1693 insertions(+), 565 deletions(-) diff --git a/core/internal/notify/notify.go b/core/internal/notify/notify.go index 0867d823..a194e257 100644 --- a/core/internal/notify/notify.go +++ b/core/internal/notify/notify.go @@ -15,6 +15,9 @@ const ( notifyDest = "org.freedesktop.Notifications" notifyPath = "/org/freedesktop/Notifications" notifyInterface = "org.freedesktop.Notifications" + + maxSummaryLen = 29 + maxBodyLen = 80 ) type Notification struct { @@ -39,6 +42,13 @@ func Send(n Notification) error { n.Timeout = 5000 } + if len(n.Summary) > maxSummaryLen { + n.Summary = n.Summary[:maxSummaryLen-3] + "..." + } + if len(n.Body) > maxBodyLen { + n.Body = n.Body[:maxBodyLen-3] + "..." + } + var actions []string if n.FilePath != "" { actions = []string{ diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index f78c29f7..2ffa6552 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -472,6 +472,8 @@ Singleton { property bool dockShowOverflowBadge: true property bool notificationOverlayEnabled: false + property bool notificationPopupShadowEnabled: true + property bool notificationPopupPrivacyMode: false property int overviewRows: 2 property int overviewColumns: 5 property real overviewScale: 0.16 @@ -501,6 +503,8 @@ Singleton { property int notificationTimeoutCritical: 0 property bool notificationCompactMode: false property int notificationPopupPosition: SettingsData.Position.Top + property int notificationAnimationSpeed: SettingsData.AnimationSpeed.Short + property int notificationCustomAnimationDuration: 400 property bool notificationHistoryEnabled: true property int notificationHistoryMaxCount: 50 property int notificationHistoryMaxAgeDays: 7 @@ -2138,6 +2142,9 @@ Singleton { saveSettings(); } + property bool _pendingExpandNotificationRules: false + property int _pendingNotificationRuleIndex: -1 + function addNotificationRule() { var rules = JSON.parse(JSON.stringify(notificationRules || [])); rules.push({ @@ -2145,6 +2152,45 @@ Singleton { field: "appName", pattern: "", matchType: "contains", + action: "default", + urgency: "default" + }); + notificationRules = rules; + saveSettings(); + } + + function addNotificationRuleForNotification(appName, desktopEntry) { + var rules = JSON.parse(JSON.stringify(notificationRules || [])); + var pattern = (desktopEntry && desktopEntry !== "") ? desktopEntry : (appName || ""); + var field = (desktopEntry && desktopEntry !== "") ? "desktopEntry" : "appName"; + var rule = { + enabled: true, + field: pattern ? field : "appName", + pattern: pattern || "", + matchType: pattern ? "exact" : "contains", + action: "default", + urgency: "default" + }; + rules.push(rule); + notificationRules = rules; + saveSettings(); + var index = rules.length - 1; + _pendingExpandNotificationRules = true; + _pendingNotificationRuleIndex = index; + return index; + } + + function addMuteRuleForApp(appName, desktopEntry) { + var rules = JSON.parse(JSON.stringify(notificationRules || [])); + var pattern = (desktopEntry && desktopEntry !== "") ? desktopEntry : (appName || ""); + var field = (desktopEntry && desktopEntry !== "") ? "desktopEntry" : "appName"; + if (pattern === "") + return; + rules.push({ + enabled: true, + field: field, + pattern: pattern, + matchType: "exact", action: "mute", urgency: "default" }); @@ -2152,6 +2198,51 @@ Singleton { saveSettings(); } + function isAppMuted(appName, desktopEntry) { + const rules = notificationRules || []; + const pat = (desktopEntry && desktopEntry !== "" ? desktopEntry : appName || "").toString().toLowerCase(); + if (!pat) + return false; + for (let i = 0; i < rules.length; i++) { + const r = rules[i]; + if ((r.action || "").toString().toLowerCase() !== "mute" || r.enabled === false) + continue; + const field = (r.field || "appName").toString().toLowerCase(); + const rulePat = (r.pattern || "").toString().toLowerCase(); + if (!rulePat) + continue; + const useDesktop = field === "desktopentry"; + const matches = (useDesktop && desktopEntry) ? (desktopEntry.toString().toLowerCase() === rulePat) : (appName && appName.toString().toLowerCase() === rulePat); + if (matches) + return true; + if (rulePat === pat) + return true; + } + return false; + } + + function removeMuteRuleForApp(appName, desktopEntry) { + var rules = JSON.parse(JSON.stringify(notificationRules || [])); + const app = (appName || "").toString().toLowerCase(); + const desktop = (desktopEntry || "").toString().toLowerCase(); + if (!app && !desktop) + return; + for (let i = rules.length - 1; i >= 0; i--) { + const r = rules[i]; + if ((r.action || "").toString().toLowerCase() !== "mute") + continue; + const rulePat = (r.pattern || "").toString().toLowerCase(); + if (!rulePat) + continue; + if (rulePat === app || rulePat === desktop) { + rules.splice(i, 1); + notificationRules = rules; + saveSettings(); + return; + } + } + } + function updateNotificationRule(index, ruleData) { var rules = JSON.parse(JSON.stringify(notificationRules || [])); if (index < 0 || index >= rules.length) diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index e8316be8..d474417a 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -776,6 +776,53 @@ Singleton { }; } + readonly property int notificationAnimationBaseDuration: { + if (typeof SettingsData === "undefined") + return 200; + if (SettingsData.notificationAnimationSpeed === SettingsData.AnimationSpeed.None) + return 0; + if (SettingsData.notificationAnimationSpeed === SettingsData.AnimationSpeed.Custom) + return SettingsData.notificationCustomAnimationDuration; + const presetMap = [0, 200, 400, 600]; + return presetMap[SettingsData.notificationAnimationSpeed] ?? 200; + } + + readonly property int notificationEnterDuration: { + const base = notificationAnimationBaseDuration; + return base === 0 ? 0 : Math.round(base * 0.875); + } + + readonly property int notificationExitDuration: { + const base = notificationAnimationBaseDuration; + return base === 0 ? 0 : Math.round(base * 0.75); + } + + readonly property int notificationExpandDuration: { + const base = notificationAnimationBaseDuration; + return base === 0 ? 0 : Math.round(base * 1.0); + } + + readonly property int notificationCollapseDuration: { + const base = notificationAnimationBaseDuration; + return base === 0 ? 0 : Math.round(base * 0.85); + } + + readonly property real notificationIconSizeNormal: 56 + readonly property real notificationIconSizeCompact: 48 + readonly property real notificationExpandedIconSizeNormal: 48 + readonly property real notificationExpandedIconSizeCompact: 40 + readonly property real notificationActionMinWidth: 48 + readonly property real notificationButtonCornerRadius: cornerRadius / 2 + readonly property real notificationHoverRevealMargin: spacingXL + readonly property real notificationContentSpacing: spacingXS + readonly property real notificationCardPadding: spacingM + readonly property real notificationCardPaddingCompact: spacingS + + readonly property real stateLayerHover: 0.08 + readonly property real stateLayerFocus: 0.12 + readonly property real stateLayerPressed: 0.12 + readonly property real stateLayerDrag: 0.16 + readonly property int popoutAnimationDuration: { if (typeof SettingsData === "undefined") return 150; diff --git a/quickshell/Common/markdown2html.js b/quickshell/Common/markdown2html.js index 23e7567b..04a9b52e 100644 --- a/quickshell/Common/markdown2html.js +++ b/quickshell/Common/markdown2html.js @@ -32,8 +32,15 @@ function markdownToHtml(text) { return `\x00INLINECODE${inlineIndex++}\x00`; }); - // Now process everything else - // Escape HTML entities (but not in code blocks) + // Extract plain URLs before escaping so & in query strings is preserved + const urls = []; + let urlIndex = 0; + html = html.replace(/(^|[\s])((?:https?|file):\/\/[^\s]+)/gm, (match, prefix, url) => { + urls.push(url); + return prefix + `\x00URL${urlIndex++}\x00`; + }); + + // Escape HTML entities (but not in code blocks or URLs) html = html.replace(/&/g, '&') .replace(//g, '>'); @@ -64,8 +71,12 @@ function markdownToHtml(text) { return '
    ' + match + '
'; }); - // Detect plain URLs and wrap them in anchor tags (but not inside existing or markdown links) - html = html.replace(/(^|[^"'>])((https?|file):\/\/[^\s<]+)/g, '$1$2'); + // Restore extracted URLs as anchor tags (preserves raw & in href) + html = html.replace(/\x00URL(\d+)\x00/g, (_, index) => { + const url = urls[parseInt(index)]; + const display = url.replace(/&/g, '&').replace(//g, '>'); + return `${display}`; + }); // Restore code blocks and inline code BEFORE line break processing html = html.replace(/\x00CODEBLOCK(\d+)\x00/g, (match, index) => { diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 9feccf61..24e83e39 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -297,6 +297,8 @@ var SPEC = { dockShowOverflowBadge: { def: true }, notificationOverlayEnabled: { def: false }, + notificationPopupShadowEnabled: { def: true }, + notificationPopupPrivacyMode: { def: false }, overviewRows: { def: 2, persist: false }, overviewColumns: { def: 5, persist: false }, overviewScale: { def: 0.16, persist: false }, @@ -325,6 +327,8 @@ var SPEC = { notificationTimeoutCritical: { def: 0 }, notificationCompactMode: { def: false }, notificationPopupPosition: { def: 0 }, + notificationAnimationSpeed: { def: 1 }, + notificationCustomAnimationDuration: { def: 400 }, notificationHistoryEnabled: { def: true }, notificationHistoryMaxCount: { def: 50 }, notificationHistoryMaxAgeDays: { def: 7 }, diff --git a/quickshell/Modals/NotificationModal.qml b/quickshell/Modals/NotificationModal.qml index bbee0ab2..86533444 100644 --- a/quickshell/Modals/NotificationModal.qml +++ b/quickshell/Modals/NotificationModal.qml @@ -70,8 +70,8 @@ DankModal { NotificationService.dismissAllPopups(); } - modalWidth: 500 - modalHeight: 700 + modalWidth: Math.min(500, screenWidth - 48) + modalHeight: Math.min(700, screenHeight * 0.85) backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) visible: false onBackgroundClicked: hide() diff --git a/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml b/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml index 9a23a831..7e9e8bcb 100644 --- a/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/HistoryNotificationCard.qml @@ -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 { diff --git a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml index 247156ba..6214608f 100644 --- a/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml +++ b/quickshell/Modules/Notifications/Center/KeyboardNavigatedNotificationList.qml @@ -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 || "") } } diff --git a/quickshell/Modules/Notifications/Center/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index a9b4019e..e2e1a407 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCard.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCard.qml @@ -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(); + } + } + } } diff --git a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml index 3459211e..134a4fd1 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml @@ -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; } diff --git a/quickshell/Modules/Notifications/Center/NotificationSettings.qml b/quickshell/Modules/Notifications/Center/NotificationSettings.qml index 38e40341..cffa29c1 100644 --- a/quickshell/Modules/Notifications/Center/NotificationSettings.qml +++ b/quickshell/Modules/Notifications/Center/NotificationSettings.qml @@ -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 diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index 5fac19cd..c7812b94 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -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); + } + } + } } diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml index 39144a95..df0dc3a1 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopupManager.qml @@ -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 { diff --git a/quickshell/Modules/Settings/NotificationsTab.qml b/quickshell/Modules/Settings/NotificationsTab.qml index e8c7d55a..2c3f9e75 100644 --- a/quickshell/Modules/Settings/NotificationsTab.qml +++ b/quickshell/Modules/Settings/NotificationsTab.qml @@ -6,6 +6,25 @@ import qs.Modules.Settings.Widgets Item { id: root + Component.onCompleted: { + if (SettingsData._pendingExpandNotificationRules) { + SettingsData._pendingExpandNotificationRules = false; + notificationRulesCard.userToggledCollapse = true; + notificationRulesCard.expanded = true; + SettingsData._pendingNotificationRuleIndex = -1; + } + } + + readonly property var mutedRules: { + var rules = SettingsData.notificationRules || []; + var out = []; + for (var i = 0; i < rules.length; i++) { + if ((rules[i].action || "").toString().toLowerCase() === "mute") + out.push({ rule: rules[i], index: i }); + } + return out; + } + readonly property var timeoutOptions: [ { text: I18n.tr("Never"), @@ -201,22 +220,33 @@ Item { return I18n.tr("Top Left", "screen position option"); case SettingsData.Position.Right: return I18n.tr("Bottom Right", "screen position option"); + case SettingsData.Position.BottomCenter: + return I18n.tr("Bottom Center", "screen position option"); default: return I18n.tr("Top Right", "screen position option"); } } - options: [I18n.tr("Top Right", "screen position option"), I18n.tr("Top Left", "screen position option"), I18n.tr("Top Center", "screen position option"), I18n.tr("Bottom Right", "screen position option"), I18n.tr("Bottom Left", "screen position option")] + options: [I18n.tr("Top Right", "screen position option"), I18n.tr("Top Left", "screen position option"), I18n.tr("Top Center", "screen position option"), I18n.tr("Bottom Center", "screen position option"), I18n.tr("Bottom Right", "screen position option"), I18n.tr("Bottom Left", "screen position option")] onValueChanged: value => { - if (value === I18n.tr("Top Right", "screen position option")) { + switch (value) { + case I18n.tr("Top Right", "screen position option"): SettingsData.set("notificationPopupPosition", SettingsData.Position.Top); - } else if (value === I18n.tr("Top Left", "screen position option")) { + break; + case I18n.tr("Top Left", "screen position option"): SettingsData.set("notificationPopupPosition", SettingsData.Position.Left); - } else if (value === I18n.tr("Top Center", "screen position option")) { + break; + case I18n.tr("Top Center", "screen position option"): SettingsData.set("notificationPopupPosition", -1); - } else if (value === I18n.tr("Bottom Right", "screen position option")) { + break; + case I18n.tr("Bottom Center", "screen position option"): + SettingsData.set("notificationPopupPosition", SettingsData.Position.BottomCenter); + break; + case I18n.tr("Bottom Right", "screen position option"): SettingsData.set("notificationPopupPosition", SettingsData.Position.Right); - } else if (value === I18n.tr("Bottom Left", "screen position option")) { + break; + case I18n.tr("Bottom Left", "screen position option"): SettingsData.set("notificationPopupPosition", SettingsData.Position.Bottom); + break; } SettingsData.sendTestNotifications(); } @@ -239,6 +269,95 @@ Item { checked: SettingsData.notificationCompactMode onToggled: checked => SettingsData.set("notificationCompactMode", checked) } + + SettingsToggleRow { + settingKey: "notificationPopupShadowEnabled" + tags: ["notification", "popup", "shadow", "radius", "rounded"] + text: I18n.tr("Popup Shadow") + description: I18n.tr("Show drop shadow on notification popups") + checked: SettingsData.notificationPopupShadowEnabled + onToggled: checked => SettingsData.set("notificationPopupShadowEnabled", checked) + } + + SettingsToggleRow { + settingKey: "notificationPopupPrivacyMode" + tags: ["notification", "popup", "privacy", "body", "content", "hide"] + text: I18n.tr("Privacy Mode") + description: I18n.tr("Hide notification content until expanded; popups show collapsed by default") + checked: SettingsData.notificationPopupPrivacyMode + onToggled: checked => SettingsData.set("notificationPopupPrivacyMode", checked) + } + + Item { + width: parent.width + height: notificationAnimationColumn.implicitHeight + Theme.spacingM * 2 + + Column { + id: notificationAnimationColumn + width: parent.width - Theme.spacingM * 2 + x: Theme.spacingM + anchors.top: parent.top + anchors.topMargin: Theme.spacingM + spacing: Theme.spacingS + + StyledText { + text: I18n.tr("Animation Speed") + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + width: parent.width + } + + StyledText { + text: I18n.tr("Control animation duration for notification popups and history") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + } + + DankButtonGroup { + id: notificationSpeedGroup + anchors.horizontalCenter: parent.horizontalCenter + buttonPadding: parent.width < 480 ? Theme.spacingS : Theme.spacingM + minButtonWidth: parent.width < 480 ? 44 : 56 + textSize: parent.width < 480 ? Theme.fontSizeSmall : Theme.fontSizeMedium + model: [I18n.tr("None"), I18n.tr("Short"), I18n.tr("Medium"), I18n.tr("Long"), I18n.tr("Custom")] + selectionMode: "single" + currentIndex: SettingsData.notificationAnimationSpeed + onSelectionChanged: (index, selected) => { + if (!selected) + return; + SettingsData.set("notificationAnimationSpeed", index); + } + + Connections { + target: SettingsData + function onNotificationAnimationSpeedChanged() { + notificationSpeedGroup.currentIndex = SettingsData.notificationAnimationSpeed; + } + } + } + + SettingsSliderRow { + settingKey: "notificationCustomAnimationDuration" + tags: ["notification", "animation", "duration", "custom", "speed"] + text: I18n.tr("Duration") + description: I18n.tr("Base duration for animations (drag to use Custom)") + minimum: 100 + maximum: 800 + value: Theme.notificationAnimationBaseDuration + unit: "ms" + defaultValue: 400 + onSliderValueChanged: newValue => { + if (SettingsData.notificationAnimationSpeed !== SettingsData.AnimationSpeed.Custom) { + SettingsData.set("notificationAnimationSpeed", SettingsData.AnimationSpeed.Custom); + } + SettingsData.set("notificationCustomAnimationDuration", newValue); + } + } + } + } } SettingsCard { @@ -258,6 +377,7 @@ Item { } SettingsCard { + id: notificationRulesCard width: parent.width iconName: "rule_settings" title: I18n.tr("Notification Rules") @@ -282,7 +402,11 @@ Item { iconSize: 20 backgroundColor: Theme.surfaceContainer iconColor: Theme.primary - onClicked: SettingsData.addNotificationRule() + onClicked: { + SettingsData.addNotificationRule(); + notificationRulesCard.userToggledCollapse = true; + notificationRulesCard.expanded = true; + } } ] @@ -291,7 +415,7 @@ Item { spacing: Theme.spacingS StyledText { - text: I18n.tr("Create rules to mute, ignore, hide from history, or override notification priority.") + text: I18n.tr("Create rules to mute, ignore, hide from history, or override notification priority. Default only overrides priority; notifications still show normally.") font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -407,6 +531,7 @@ Item { width: parent.width compactMode: true dropdownWidth: parent.width + popupWidth: 165 currentValue: root.getRuleOptionLabel(root.notificationRuleFieldOptions, modelData.field, root.notificationRuleFieldOptions[0].label) options: root.notificationRuleFieldOptions.map(o => o.label) onValueChanged: value => SettingsData.updateNotificationRuleField(index, "field", root.getRuleOptionValue(root.notificationRuleFieldOptions, value, "appName")) @@ -447,6 +572,7 @@ Item { width: parent.width compactMode: true dropdownWidth: parent.width + popupWidth: 170 currentValue: root.getRuleOptionLabel(root.notificationRuleActionOptions, modelData.action, root.notificationRuleActionOptions[0].label) options: root.notificationRuleActionOptions.map(o => o.label) onValueChanged: value => SettingsData.updateNotificationRuleField(index, "action", root.getRuleOptionValue(root.notificationRuleActionOptions, value, "default")) @@ -467,6 +593,7 @@ Item { width: parent.width compactMode: true dropdownWidth: parent.width + popupWidth: 165 currentValue: root.getRuleOptionLabel(root.notificationRuleUrgencyOptions, modelData.urgency, root.notificationRuleUrgencyOptions[0].label) options: root.notificationRuleUrgencyOptions.map(o => o.label) onValueChanged: value => SettingsData.updateNotificationRuleField(index, "urgency", root.getRuleOptionValue(root.notificationRuleUrgencyOptions, value, "default")) @@ -479,6 +606,95 @@ Item { } } + SettingsCard { + width: parent.width + iconName: "volume_off" + title: I18n.tr("Muted Apps") + settingKey: "mutedApps" + tags: ["notification", "mute", "unmute", "popup"] + + Column { + width: parent.width + spacing: Theme.spacingS + + StyledText { + text: mutedRules.length > 0 ? I18n.tr("Apps with notification popups muted. Unmute or delete to remove.") : I18n.tr("No apps muted. Right-click a notification and choose \"Mute popups\" to add one here.") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + width: parent.width + bottomPadding: Theme.spacingS + } + + Repeater { + model: mutedRules + + delegate: Rectangle { + width: parent.width + height: mutedRow.implicitHeight + Theme.spacingS * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainer, 0.5) + + Row { + id: mutedRow + anchors.fill: parent + anchors.margins: Theme.spacingS + spacing: Theme.spacingM + + StyledText { + id: mutedAppLabel + text: (modelData.rule && modelData.rule.pattern) ? modelData.rule.pattern : I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Item { + width: Math.max(0, parent.width - parent.spacing - mutedAppLabel.width - unmuteBtn.width - deleteBtn.width - Theme.spacingS * 5) + height: 1 + } + + DankButton { + id: unmuteBtn + text: I18n.tr("Unmute") + backgroundColor: Theme.surfaceContainer + textColor: Theme.primary + onClicked: SettingsData.removeNotificationRule(modelData.index) + } + + Item { + id: deleteBtn + width: 28 + height: 28 + anchors.verticalCenter: parent.verticalCenter + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: deleteArea.containsMouse ? Theme.withAlpha(Theme.error, 0.2) : "transparent" + } + + DankIcon { + anchors.centerIn: parent + name: "delete" + size: 18 + color: deleteArea.containsMouse ? Theme.error : Theme.surfaceVariantText + } + + MouseArea { + id: deleteArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: SettingsData.removeNotificationRule(modelData.index) + } + } + } + } + } + } + } + SettingsCard { width: parent.width iconName: "lock" diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index 21c4c08d..d4beff7c 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -22,7 +22,7 @@ Singleton { property list notificationQueue: [] property list visibleNotifications: [] - property int maxVisibleNotifications: 3 + property int maxVisibleNotifications: 4 property bool addGateBusy: false property int enterAnimMs: 400 property int seqCounter: 0 @@ -158,10 +158,7 @@ Singleton { continue; const urg = typeof item.urgency === "number" ? item.urgency : 1; const body = item.body || ""; - let htmlBody = item.htmlBody || ""; - if (!htmlBody && body) { - htmlBody = (body.includes('<') && body.includes('>')) ? body : Markdown2Html.markdownToHtml(body); - } + const htmlBody = item.htmlBody || _resolveHtmlBody(body); loaded.push({ id: item.id || "", summary: item.summary || "", @@ -251,9 +248,15 @@ Singleton { const timeStr = SettingsData.use24HourClock ? date.toLocaleTimeString(Qt.locale(), "HH:mm") : date.toLocaleTimeString(Qt.locale(), "h:mm AP"); if (daysDiff === 0) return timeStr; - if (daysDiff === 1) - return I18n.tr("yesterday") + ", " + timeStr; - return I18n.tr("%1 days ago").arg(daysDiff); + try { + const localeName = (typeof Qt !== "undefined" && Qt.locale) ? Qt.locale().name : "en-US"; + const weekday = date.toLocaleDateString(localeName, { + weekday: "long" + }); + return weekday + ", " + timeStr; + } catch (e) { + return timeStr; + } } function _nowSec() { @@ -484,7 +487,7 @@ Singleton { Timer { id: addGate - interval: enterAnimMs + 50 + interval: 80 running: false repeat: false onTriggered: { @@ -688,11 +691,15 @@ Singleton { return formatTime(time); } - if (daysDiff === 1) { - return `yesterday, ${formatTime(time)}`; + try { + const localeName = (typeof Qt !== "undefined" && Qt.locale) ? Qt.locale().name : "en-US"; + const weekday = time.toLocaleDateString(localeName, { + weekday: "long" + }); + return `${weekday}, ${formatTime(time)}`; + } catch (e) { + return formatTime(time); } - - return `${daysDiff} days ago`; } function formatTime(date) { @@ -715,13 +722,7 @@ Singleton { required property Notification notification readonly property string summary: notification?.summary ?? "" readonly property string body: notification?.body ?? "" - readonly property string htmlBody: { - if (!body) - return ""; - if (body.includes('<') && body.includes('>')) - return body; - return Markdown2Html.markdownToHtml(body); - } + readonly property string htmlBody: root._resolveHtmlBody(body) readonly property string appIcon: notification?.appIcon ?? "" readonly property string appName: { if (!notification) @@ -837,39 +838,54 @@ Singleton { } } - function processQueue() { - if (addGateBusy) { - return; - } - if (popupsDisabled) { - return; - } - if (SessionData.doNotDisturb) { - return; - } - if (notificationQueue.length === 0) { - return; - } + property bool _processingQueue: false - const activePopupCount = visibleNotifications.filter(n => n && n.popup).length; - if (activePopupCount >= 4) { + function processQueue() { + if (addGateBusy || _processingQueue) return; - } + if (popupsDisabled) + return; + if (SessionData.doNotDisturb) + return; + if (notificationQueue.length === 0) + return; + + _processingQueue = true; const next = notificationQueue.shift(); - if (!next) + if (!next) { + _processingQueue = false; return; + } next.seq = ++seqCounter; - visibleNotifications = [...visibleNotifications, next]; + + const activePopups = visibleNotifications.filter(n => n && n.popup); + let evicted = null; + if (activePopups.length >= maxVisibleNotifications) { + const unhovered = activePopups.filter(n => n.timer?.running); + const pool = unhovered.length > 0 ? unhovered : activePopups; + evicted = pool.reduce((min, n) => (n.seq < min.seq) ? n : min, pool[0]); + if (evicted) + evicted.removedByLimit = true; + } + + if (evicted) { + visibleNotifications = [...visibleNotifications.filter(n => n !== evicted), next]; + } else { + visibleNotifications = [...visibleNotifications, next]; + } + + if (evicted) + evicted.popup = false; next.popup = true; - if (next.timer.interval > 0) { + if (next.timer.interval > 0) next.timer.start(); - } addGateBusy = true; addGate.restart(); + _processingQueue = false; } function removeFromVisibleNotifications(wrapper) { @@ -890,6 +906,96 @@ Singleton { } } + function _decodeEntities(s) { + s = s.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(parseInt(n, 10))); + s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16))); + return s.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name) => { + switch (name) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return "\""; + case "apos": + return "'"; + case "nbsp": + return "\u00A0"; + case "ndash": + return "\u2013"; + case "mdash": + return "\u2014"; + case "lsquo": + return "\u2018"; + case "rsquo": + return "\u2019"; + case "ldquo": + return "\u201C"; + case "rdquo": + return "\u201D"; + case "bull": + return "\u2022"; + case "hellip": + return "\u2026"; + case "trade": + return "\u2122"; + case "copy": + return "\u00A9"; + case "reg": + return "\u00AE"; + case "deg": + return "\u00B0"; + case "plusmn": + return "\u00B1"; + case "times": + return "\u00D7"; + case "divide": + return "\u00F7"; + case "micro": + return "\u00B5"; + case "middot": + return "\u00B7"; + case "laquo": + return "\u00AB"; + case "raquo": + return "\u00BB"; + case "larr": + return "\u2190"; + case "rarr": + return "\u2192"; + case "uarr": + return "\u2191"; + case "darr": + return "\u2193"; + default: + return match; + } + }); + } + + function _resolveHtmlBody(body) { + if (!body) + return ""; + if (/<\/?[a-z][\s\S]*>/i.test(body)) + return body; + + // Decode percent-encoded URLs (e.g. https%3A%2F%2F → https://) + body = body.replace(/\bhttps?%3A%2F%2F[^\s]+/gi, match => { + try { return decodeURIComponent(match); } + catch (e) { return match; } + }); + + if (/&(#\d+|#x[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/.test(body)) { + const decoded = _decodeEntities(body); + if (/<\/?[a-z][\s\S]*>/i.test(decoded)) + return decoded; + return Markdown2Html.markdownToHtml(decoded); + } + return Markdown2Html.markdownToHtml(body); + } + function getGroupKey(wrapper) { if (wrapper.desktopEntry && wrapper.desktopEntry !== "") { return wrapper.desktopEntry.toLowerCase(); diff --git a/quickshell/Widgets/DankDropdown.qml b/quickshell/Widgets/DankDropdown.qml index 496d3fb6..c100c0ac 100644 --- a/quickshell/Widgets/DankDropdown.qml +++ b/quickshell/Widgets/DankDropdown.qml @@ -55,6 +55,10 @@ Item { signal valueChanged(string value) + function closeDropdownMenu() { + dropdownMenu.close(); + } + width: compactMode ? dropdownWidth : parent.width implicitHeight: compactMode ? 40 : Math.max(60, labelColumn.implicitHeight + Theme.spacingM) @@ -409,7 +413,7 @@ Item { onClicked: { root.currentValue = delegateRoot.modelData; root.valueChanged(delegateRoot.modelData); - dropdownMenu.close(); + root.closeDropdownMenu(); } } } diff --git a/quickshell/Widgets/DankPopout.qml b/quickshell/Widgets/DankPopout.qml index d3f0798b..3dbc8205 100644 --- a/quickshell/Widgets/DankPopout.qml +++ b/quickshell/Widgets/DankPopout.qml @@ -25,10 +25,12 @@ Item { property real animationOffset: Theme.spacingL property list animationEnterCurve: Theme.expressiveCurves.expressiveDefaultSpatial property list animationExitCurve: Theme.expressiveCurves.emphasized + property bool suspendShadowWhileResizing: false property bool shouldBeVisible: false property var customKeyboardFocus: null property bool backgroundInteractive: true property bool contentHandlesKeys: false + property bool _resizeActive: false property real storedBarThickness: Theme.barHeight - 4 property real storedBarSpacing: 4 @@ -185,6 +187,26 @@ Item { readonly property real alignedWidth: Theme.px(popupWidth, dpr) readonly property real alignedHeight: Theme.px(popupHeight, dpr) + onAlignedHeightChanged: { + if (!suspendShadowWhileResizing || !shouldBeVisible) + return; + _resizeActive = true; + resizeSettleTimer.restart(); + } + onShouldBeVisibleChanged: { + if (!shouldBeVisible) { + _resizeActive = false; + resizeSettleTimer.stop(); + } + } + + Timer { + id: resizeSettleTimer + interval: 80 + repeat: false + onTriggered: root._resizeActive = false + } + readonly property real alignedX: Theme.snap((() => { const useAutoGaps = storedBarConfig?.popupGapsAuto !== undefined ? storedBarConfig.popupGapsAuto : true; const manualGapValue = storedBarConfig?.popupGapsManual !== undefined ? storedBarConfig.popupGapsManual : 4; @@ -440,7 +462,7 @@ Item { readonly property real effectiveShadowAlpha: Math.max(0, Math.min(1, shadowBaseAlpha * popupSurfaceAlpha)) readonly property int blurMax: 64 - layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" + layer.enabled: Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1" && !(root.suspendShadowWhileResizing && root._resizeActive) layer.smooth: false layer.textureSize: root.dpr > 1 ? Qt.size(Math.ceil(width * root.dpr), Math.ceil(height * root.dpr)) : Qt.size(0, 0) diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 3c507523..ea7a51e7 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -4677,6 +4677,50 @@ ], "description": "Use smaller notification cards" }, + { + "section": "notificationAnimationSpeed", + "label": "Animation Speed", + "tabIndex": 17, + "category": "Notifications", + "keywords": [ + "alert", + "animate", + "animation", + "duration", + "fast", + "messages", + "motion", + "notif", + "notification", + "notifications", + "popup", + "speed", + "toast" + ], + "description": "Control animation duration for notification popups and history" + }, + { + "section": "notificationCustomAnimationDuration", + "label": "Animation Duration", + "tabIndex": 17, + "category": "Notifications", + "keywords": [ + "alert", + "animate", + "animation", + "custom", + "duration", + "messages", + "ms", + "notif", + "notification", + "notifications", + "popup", + "speed", + "toast" + ], + "description": "Base duration for notification animations" + }, { "section": "notificationHistorySaveCritical", "label": "Critical Priority",