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 '
';
});
- // 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(/([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",