From 0ffeed3ff0b25d54eca00166a3f77db6c4dc4c49 Mon Sep 17 00:00:00 2001 From: purian23 Date: Thu, 12 Feb 2026 22:16:16 -0500 Subject: [PATCH] notifications: Update Material 3 baselines - New right-click to mute option - New independent Notification Animation settings --- core/internal/notify/notify.go | 10 ++ quickshell/Common/SettingsData.qml | 20 ++++ quickshell/Common/Theme.qml | 31 ++++++ quickshell/Common/settings/SettingsSpec.js | 2 + quickshell/Modals/NotificationModal.qml | 4 +- .../Notifications/Center/NotificationCard.qml | 96 +++++++++++++++---- .../Center/NotificationCenterPopout.qml | 2 +- .../Notifications/Popup/NotificationPopup.qml | 96 +++++++++++++++---- .../Modules/Settings/NotificationsTab.qml | 71 ++++++++++++++ quickshell/Services/NotificationService.qml | 22 +++-- .../translations/settings_search_index.json | 44 +++++++++ 11 files changed, 350 insertions(+), 48 deletions(-) diff --git a/core/internal/notify/notify.go b/core/internal/notify/notify.go index 0867d823..a194e257 100644 --- a/core/internal/notify/notify.go +++ b/core/internal/notify/notify.go @@ -15,6 +15,9 @@ const ( notifyDest = "org.freedesktop.Notifications" notifyPath = "/org/freedesktop/Notifications" notifyInterface = "org.freedesktop.Notifications" + + maxSummaryLen = 29 + maxBodyLen = 80 ) type Notification struct { @@ -39,6 +42,13 @@ func Send(n Notification) error { n.Timeout = 5000 } + if len(n.Summary) > maxSummaryLen { + n.Summary = n.Summary[:maxSummaryLen-3] + "..." + } + if len(n.Body) > maxBodyLen { + n.Body = n.Body[:maxBodyLen-3] + "..." + } + var actions []string if n.FilePath != "" { actions = []string{ diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index f78c29f7..5b91a794 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -501,6 +501,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 @@ -2152,6 +2154,24 @@ Singleton { saveSettings(); } + 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" + }); + notificationRules = rules; + saveSettings(); + } + 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..13db0126 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -776,6 +776,37 @@ 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.4); + } + + readonly property int notificationCollapseDuration: { + const base = notificationAnimationBaseDuration; + return base === 0 ? 0 : Math.round(base * 1.1); + } + readonly property int popoutAnimationDuration: { if (typeof SettingsData === "undefined") return 150; diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 9feccf61..91a69e63 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -325,6 +325,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/NotificationCard.qml b/quickshell/Modules/Notifications/Center/NotificationCard.qml index a9b4019e..a9a58f16 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 @@ -215,26 +216,22 @@ Rectangle { spacing: compactMode ? 1 : 2 StyledText { - width: parent.width - text: { - const timeStr = (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.timeStr) || ""; - const appName = (notificationGroup && notificationGroup.appName) || ""; - return timeStr.length > 0 ? `${appName} • ${timeStr}` : appName; - } - color: Theme.surfaceVariantText - font.pixelSize: Theme.fontSizeSmall + text: (notificationGroup && notificationGroup.latestNotification && 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 } StyledText { - text: (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.summary) || "" - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium width: parent.width + text: (notificationGroup && notificationGroup.latestNotification && notificationGroup.latestNotification.timeStr) || "" + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium elide: Text.ElideRight maximumLineCount: 1 visible: text.length > 0 @@ -541,7 +538,7 @@ Rectangle { StyledText { id: expandedActionText text: { - const baseText = modelData.text || "View"; + const baseText = modelData.text || "Open"; if (keyboardNavigationActive && (isGroupSelected || selectedNotificationIndex >= 0)) return `${baseText} (${index + 1})`; return baseText; @@ -624,7 +621,7 @@ Rectangle { StyledText { id: collapsedActionText text: { - const baseText = modelData.text || "View"; + const baseText = modelData.text || "Open"; if (keyboardNavigationActive && isGroupSelected) { return `${baseText} (${index + 1})`; } @@ -733,9 +730,9 @@ Rectangle { Behavior on height { enabled: 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: root.expanded ? Theme.expressiveCurves.emphasizedDecel : Theme.expressiveCurves.emphasizedAccel onRunningChanged: { if (running) { root.isAnimating = true; @@ -746,4 +743,71 @@ Rectangle { } } } + + Menu { + id: notificationCardContextMenu + width: 220 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + + 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 { + text: 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 || ""; + 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: 10 + 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..0a32baf6 100644 --- a/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml +++ b/quickshell/Modules/Notifications/Center/NotificationCenterPopout.qml @@ -20,7 +20,7 @@ DankPopout { } } - popupWidth: 400 + popupWidth: triggerScreen ? Math.min(500, Math.max(380, triggerScreen.width - 48)) : 400 popupHeight: contentLoader.item ? contentLoader.item.implicitHeight : 400 positioning: "" animationScaleCollapsed: 1.0 diff --git a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml index 5fac19cd..87a8a14d 100644 --- a/quickshell/Modules/Notifications/Popup/NotificationPopup.qml +++ b/quickshell/Modules/Notifications/Popup/NotificationPopup.qml @@ -99,7 +99,7 @@ PanelWindow { WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: WlrKeyboardFocus.None color: "transparent" - implicitWidth: 400 + implicitWidth: screen ? Math.min(420, Math.max(320, screen.width * 0.25)) : 400 implicitHeight: { if (!descriptionExpanded) return basePopupHeight; @@ -404,17 +404,11 @@ PanelWindow { spacing: compactMode ? 1 : 2 StyledText { - width: parent.width - text: { - if (!notificationData) - return ""; - const appName = notificationData.appName || ""; - const timeStr = notificationData.timeStr || ""; - return timeStr.length > 0 ? appName + " • " + timeStr : appName; - } - color: Theme.surfaceVariantText - font.pixelSize: Theme.fontSizeSmall + text: notificationData ? (notificationData.summary || "") : "" + color: Theme.surfaceText + font.pixelSize: Theme.fontSizeMedium font.weight: Font.Medium + width: parent.width elide: Text.ElideRight horizontalAlignment: Text.AlignLeft maximumLineCount: 1 @@ -422,11 +416,11 @@ PanelWindow { } StyledText { - text: notificationData ? (notificationData.summary || "") : "" - color: Theme.surfaceText - font.pixelSize: Theme.fontSizeMedium - font.weight: Font.Medium width: parent.width + text: notificationData ? (notificationData.timeStr || "") : "" + color: Theme.surfaceVariantText + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.Medium elide: Text.ElideRight horizontalAlignment: Text.AlignLeft maximumLineCount: 1 @@ -511,7 +505,7 @@ PanelWindow { 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 @@ -598,7 +592,7 @@ PanelWindow { 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) { notificationData.actions[0].invoke(); @@ -704,7 +698,7 @@ PanelWindow { 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 onStopped: { @@ -735,7 +729,7 @@ PanelWindow { 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 +739,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 +749,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 +813,64 @@ PanelWindow { easing.bezierCurve: Theme.expressiveCurves.standardDecel } } + + Menu { + id: popupContextMenu + width: 220 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + + 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 { + text: 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 || ""; + 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/Settings/NotificationsTab.qml b/quickshell/Modules/Settings/NotificationsTab.qml index e8c7d55a..d84cb6be 100644 --- a/quickshell/Modules/Settings/NotificationsTab.qml +++ b/quickshell/Modules/Settings/NotificationsTab.qml @@ -239,6 +239,77 @@ Item { checked: SettingsData.notificationCompactMode onToggled: checked => SettingsData.set("notificationCompactMode", 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 { diff --git a/quickshell/Services/NotificationService.qml b/quickshell/Services/NotificationService.qml index 21c4c08d..cdd773a7 100644 --- a/quickshell/Services/NotificationService.qml +++ b/quickshell/Services/NotificationService.qml @@ -251,9 +251,13 @@ 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() { @@ -688,11 +692,13 @@ 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) { @@ -852,7 +858,7 @@ Singleton { } const activePopupCount = visibleNotifications.filter(n => n && n.popup).length; - if (activePopupCount >= 4) { + if (activePopupCount >= maxVisibleNotifications) { return; } 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",