fancy notifs

This commit is contained in:
2026-03-02 19:28:15 +02:00
parent 8fa89bb99c
commit aa8c07b136
8 changed files with 811 additions and 360 deletions

View File

@@ -45,4 +45,47 @@ top bar:
y: 767 y: 767
without: without:
x: 1154 x: 1154
y: 504 y: 504
notifications:
panel:
margin top: 24
margin right: 24
topbar reserved height: 182
card:
width: 420
padding: 14
border width: 3
icon box: 28
app icon size: 24
content gap: 10
line gap: 2
action gap: 8
action height: 34
action border width: 2
collapsed body lines: 2
expanded body lines: 8
stack peek count: 2
stack peek height: 36
stack peek step: 18
drag dismiss threshold: 140
colors:
background: #000000
border: #ffffff
text: #ffffff
hover invert background: #ffffff
hover invert text: #000000
timeouts:
fallback default ms: 5000
fallback low ms: 4500
fallback normal ms: 5000
fallback critical ms: 8000
animation:
enter ms: 170
exit ms: 140
fade ms: 120
expand ms: 120

View File

@@ -1,392 +1,396 @@
import QtQuick import QtQuick
import Qt5Compat.GraphicalEffects
import Quickshell import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Widgets import Quickshell.Widgets
Item { Item {
id: root id: root
property NotificationTheme theme
property int notificationId: -1 property int notificationId: -1
property string notifTitle: "" property var notifObject: null
property string notifBody: "" property int notifVersion: 0
property string notifUrgency: "Normal" property real createdAtMs: Date.now()
property string notifAppName: ""
property string notifAppIcon: "" property bool interactive: true
property string notifImage: "" property bool forceExpanded: false
property int notifTimeoutMs: 5000 property bool peekMode: false
property bool notifCritical: false
property var notifHints: ({})
signal closeFinished(int notificationId, string reason) signal closeFinished(int notificationId, string reason)
property bool highlighted: hoverHandler.hovered
property bool closing: false property bool closing: false
property string closeReason: "" property bool contextOpen: false
property real borderAlpha: 1
property real textAlpha: 1
property real collapseFactor: 1 property real collapseFactor: 1
readonly property color normalInk: "#ffffff" readonly property string notifTitle: notifObject ? String(notifObject.summary || "") : ""
readonly property color highlightInk: "#ffc90e" readonly property string notifBody: notifObject ? String(notifObject.body || "") : ""
readonly property color activeInk: highlighted ? highlightInk : normalInk readonly property string notifAppName: notifObject ? String(notifObject.appName || "") : ""
readonly property int padding: 14 readonly property string notifAppIcon: notifObject ? String(notifObject.appIcon || "") : ""
readonly property int iconBox: 28 readonly property string notifImage: notifObject ? String(notifObject.image || "") : ""
readonly property int appIconSize: 24 readonly property bool isExpanded: forceExpanded || contextOpen
readonly property string appIconSource: {
if (notifImage && notifImage.length > 0)
return notifImage;
readonly property string appIconSource: {
if (!notifAppIcon || notifAppIcon.length === 0) if (!notifAppIcon || notifAppIcon.length === 0)
return ""; return "";
if (notifAppIcon.indexOf("/") >= 0 || notifAppIcon.indexOf("file://") === 0) if (notifAppIcon.indexOf("/") >= 0 || notifAppIcon.indexOf("file://") === 0)
return notifAppIcon; return notifAppIcon;
return Quickshell.iconPath(notifAppIcon); return Quickshell.iconPath(notifAppIcon);
} }
function hintString(name) { readonly property string customImageSource: notifImage
if (!notifHints || typeof notifHints !== "object") readonly property string primaryIconSource: customImageSource.length > 0 ? customImageSource : appIconSource
return ""; readonly property bool showBadgeIcon: customImageSource.length > 0 && appIconSource.length > 0
const value = notifHints[name]; readonly property int iconBoxSize: (theme ? theme.iconBox : 28) + 12
return value === undefined || value === null ? "" : String(value);
readonly property var notifActions: {
if (!notifObject || !notifObject.actions)
return [];
const normalized = [];
for (let i = 0; i < notifObject.actions.length; i++) {
const actionObject = notifObject.actions[i];
if (!actionObject)
continue;
const actionText = String(actionObject.text || "").trim();
if (actionText.length === 0)
continue;
normalized.push({
actionObject: actionObject,
actionText: actionText
});
}
return normalized;
} }
function textBlob() { readonly property int actionCount: notifActions.length
return (notifAppName + " " + notifTitle + " " + notifBody + " " + hintString("category")).toLowerCase(); readonly property var contextItems: {
const items = [];
for (let i = 0; i < notifActions.length; i++)
items.push(notifActions[i]);
items.push({
actionObject: null,
actionText: "Dismiss"
});
return items;
} }
function parseVolumePercent() { function urgencyTimeoutMs() {
const source = (notifTitle + " " + notifBody).toUpperCase(); if (!notifObject)
const match = source.match(/VOLUME\s*(\d{1,3})%/); return theme.fallbackNormalTimeoutMs;
if (!match || match.length < 2)
return -1;
const parsed = Number(match[1]); const rawSeconds = Number(notifObject.expireTimeout);
if (!Number.isFinite(parsed)) if (Number.isFinite(rawSeconds) && rawSeconds > 0)
return -1; return Math.max(1000, Math.round(rawSeconds * 1000));
return Math.max(0, Math.min(100, parsed)); const urgency = notifObject.urgency;
if (urgency === NotificationUrgency.Critical)
return theme.fallbackCriticalTimeoutMs;
if (urgency === NotificationUrgency.Low)
return theme.fallbackLowTimeoutMs;
return theme.fallbackNormalTimeoutMs;
} }
readonly property int volumePercent: parseVolumePercent() function relativeTime() {
readonly property bool isVolumeLayout: volumePercent >= 0 const elapsedSeconds = Math.max(0, Math.floor((Date.now() - createdAtMs) / 1000));
if (elapsedSeconds < 60)
function soulColor() { return "now";
const full = textBlob(); if (elapsedSeconds < 3600)
const urgencyText = String(notifUrgency || "").toLowerCase(); return Math.floor(elapsedSeconds / 60) + "m";
return Math.floor(elapsedSeconds / 3600) + "h";
if (urgencyText === "critical")
return "#fff27a";
if (full.indexOf("network") >= 0 || full.indexOf("wifi") >= 0 || full.indexOf("ethernet") >= 0 || full.indexOf("bluetooth") >= 0)
return "#4ca4ff";
if (full.indexOf("success") >= 0 || full.indexOf("completed") >= 0 || full.indexOf("saved") >= 0 || full.indexOf("done") >= 0)
return "#47d66b";
return "#ff2a2a";
} }
readonly property color soulInk: soulColor()
function beginClose(reason) { function beginClose(reason) {
if (closing) if (closing)
return; return;
closing = true; closing = true;
closeReason = reason;
timeoutTimer.stop(); timeoutTimer.stop();
closeReason = reason;
if (reason === "click") { closeAnimation.start();
heartFlash.restart();
clickClose.start();
} else {
timeoutClose.start();
}
} }
implicitWidth: 420 function openContextPanel() {
implicitHeight: Math.max(1, contentColumn.implicitHeight * collapseFactor + padding * 2) if (!interactive || closing || peekMode)
return;
contextOpen = !contextOpen;
restartTimeout();
}
function invokeAction(actionObject) {
if (closing)
return;
if (actionObject)
actionObject.invoke();
beginClose(actionObject ? "click" : "dismiss");
}
function restartTimeout() {
if (closing || isExpanded || peekMode)
return;
timeoutTimer.interval = urgencyTimeoutMs();
timeoutTimer.restart();
}
property string closeReason: ""
implicitWidth: theme ? theme.stackWidth : 420
implicitHeight: peekMode ? (theme ? theme.stackPeekHeight : 36) : Math.max(1, Math.round((contentColumn.implicitHeight + ((theme ? theme.cardPadding : 14) * 2)) * collapseFactor))
width: implicitWidth width: implicitWidth
height: implicitHeight height: implicitHeight
transformOrigin: Item.TopRight opacity: 1
SequentialAnimation { SequentialAnimation {
id: entryAnimation id: enterAnimation
running: true running: !peekMode
PropertyAction {
target: root
property: "opacity"
value: 0
}
PropertyAction {
target: root
property: "scale"
value: 0.95
}
PropertyAction { target: root; property: "opacity"; value: 0 }
PropertyAction { target: root; property: "x"; value: 20 }
ParallelAnimation { ParallelAnimation {
NumberAnimation { NumberAnimation { target: root; property: "opacity"; to: 1; duration: theme ? theme.enterMs : 170; easing.type: Easing.OutCubic }
target: root NumberAnimation { target: root; property: "x"; to: 0; duration: theme ? theme.enterMs : 170; easing.type: Easing.OutCubic }
property: "opacity"
to: 0.45
duration: 55
}
NumberAnimation {
target: root
property: "scale"
to: 0.97
duration: 55
}
}
ParallelAnimation {
NumberAnimation {
target: root
property: "opacity"
to: 0.75
duration: 55
}
NumberAnimation {
target: root
property: "scale"
to: 0.99
duration: 55
}
}
ParallelAnimation {
NumberAnimation {
target: root
property: "opacity"
to: 1
duration: 70
}
NumberAnimation {
target: root
property: "scale"
to: 1
duration: 70
}
} }
} }
SequentialAnimation { ParallelAnimation {
id: heartFlash id: closeAnimation
NumberAnimation { NumberAnimation { target: root; property: "opacity"; to: 0; duration: theme ? theme.fadeMs : 120; easing.type: Easing.InCubic }
target: soulFlashOverlay NumberAnimation { target: root; property: "x"; to: width; duration: theme ? theme.exitMs : 140; easing.type: Easing.InCubic }
property: "opacity" onFinished: root.closeFinished(root.notificationId, root.closeReason)
to: 1
duration: 35
}
NumberAnimation {
target: soulFlashOverlay
property: "opacity"
to: 0
duration: 75
}
}
SequentialAnimation {
id: clickClose
ParallelAnimation {
NumberAnimation {
target: root
property: "y"
to: -8
duration: 160
easing.type: Easing.InCubic
}
NumberAnimation {
target: root
property: "opacity"
to: 0
duration: 160
}
}
ScriptAction {
script: root.closeFinished(root.notificationId, "click")
}
}
SequentialAnimation {
id: timeoutClose
NumberAnimation {
target: root
property: "borderAlpha"
to: 0.35
duration: 90
}
NumberAnimation {
target: root
property: "textAlpha"
to: 0
duration: 110
}
ParallelAnimation {
NumberAnimation {
target: root
property: "collapseFactor"
to: 0
duration: 150
easing.type: Easing.InCubic
}
NumberAnimation {
target: root
property: "opacity"
to: 0
duration: 150
}
}
ScriptAction {
script: root.closeFinished(root.notificationId, "timeout")
}
} }
Timer { Timer {
id: timeoutTimer id: timeoutTimer
interval: root.notifTimeoutMs interval: root.urgencyTimeoutMs()
repeat: false repeat: false
running: !root.notifCritical && root.notifTimeoutMs > 0 running: !root.peekMode
onTriggered: root.beginClose("timeout") onTriggered: root.beginClose("timeout")
} }
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: "#000000" color: theme ? theme.panelBackground : "#000000"
border.width: 3 border.width: theme ? theme.borderWidth : 3
border.color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.borderAlpha) border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0 radius: 0
antialiasing: false antialiasing: false
visible: !peekMode
} }
Item { Rectangle {
id: soulContainer anchors.fill: parent
x: { color: theme ? theme.panelBackground : "#000000"
if (!root.isVolumeLayout) border.width: theme ? theme.borderWidth : 3
return root.padding; border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0
const left = root.padding; antialiasing: false
const right = Math.max(left, root.width - root.padding - root.iconBox); visible: peekMode
return left + (right - left) * (root.volumePercent / 100); opacity: 0.95
}
y: root.padding + 2
width: root.iconBox
height: root.iconBox
scale: 1
Behavior on x {
NumberAnimation {
duration: 150
easing.type: Easing.OutCubic
}
}
IconImage {
visible: root.appIconSource.length > 0
anchors.centerIn: parent
implicitSize: root.appIconSize
source: root.appIconSource
asynchronous: true
}
Item {
visible: root.appIconSource.length === 0
anchors.fill: parent
Image {
id: soulImage
anchors.fill: parent
source: "../Topbar/topbar/soul_small.png"
smooth: false
antialiasing: false
}
ColorOverlay {
anchors.fill: soulImage
source: soulImage
color: root.soulInk
}
}
Rectangle {
id: soulFlashOverlay
anchors.fill: parent
color: "#ffffff"
opacity: 0
radius: 0
antialiasing: false
}
} }
Column { Column {
id: contentColumn id: contentColumn
x: root.padding + root.iconBox + 10 visible: !peekMode
y: root.padding - 1 x: (theme ? theme.cardPadding : 14)
width: root.width - x - root.padding y: (theme ? theme.cardPadding : 14)
spacing: 2 width: root.width - ((theme ? theme.cardPadding : 14) * 2)
spacing: theme ? theme.lineGap : 2
Text { Row {
visible: !root.isVolumeLayout id: mainRow
text: String(root.notifTitle || "")
color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha)
font.family: "8bitoperator JVE"
font.pixelSize: 28
font.letterSpacing: 1
wrapMode: Text.NoWrap
elide: Text.ElideRight
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
width: parent.width width: parent.width
spacing: theme ? theme.contentGap : 10
anchors.left: parent.left
anchors.right: parent.right
Item {
width: root.iconBoxSize
height: root.iconBoxSize
IconImage {
anchors.fill: parent
source: root.primaryIconSource
visible: root.primaryIconSource.length > 0
asynchronous: true
}
Rectangle {
anchors.fill: parent
visible: root.primaryIconSource.length === 0
color: "#000000"
border.width: 1
border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0
antialiasing: false
}
Image {
anchors.centerIn: parent
width: parent.width - 2
height: parent.height - 2
source: "../Topbar/topbar/soul_small.png"
visible: root.primaryIconSource.length === 0
smooth: false
antialiasing: false
fillMode: Image.PreserveAspectFit
}
IconImage {
visible: root.showBadgeIcon
width: Math.max(12, Math.round(parent.width * 0.42))
height: Math.max(12, Math.round(parent.height * 0.42))
source: root.appIconSource
x: parent.width - width
y: parent.height - height
asynchronous: true
}
}
Column {
id: textColumn
width: parent.width - root.iconBoxSize - (theme ? theme.contentGap : 10)
spacing: theme ? theme.lineGap : 2
Row {
width: parent.width
Text {
width: parent.width - timeLabel.implicitWidth - 6
text: root.notifTitle
color: theme ? theme.panelText : "#ffffff"
elide: Text.ElideRight
maximumLineCount: 1
font.family: theme ? theme.fontFamily : "8bitoperator JVE"
font.pixelSize: 24
font.letterSpacing: theme ? theme.fontLetterSpacing : 1
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
}
Text {
id: timeLabel
text: root.relativeTime()
color: theme ? theme.panelText : "#ffffff"
font.family: theme ? theme.fontFamily : "8bitoperator JVE"
font.pixelSize: 16
font.letterSpacing: theme ? theme.fontLetterSpacing : 1
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
}
}
Text {
width: parent.width
text: root.notifBody
color: theme ? theme.panelText : "#ffffff"
wrapMode: Text.Wrap
maximumLineCount: root.isExpanded ? (theme ? theme.expandedBodyLines : 8) : (theme ? theme.collapsedBodyLines : 2)
elide: Text.ElideRight
font.family: theme ? theme.fontFamily : "8bitoperator JVE"
font.pixelSize: 22
font.letterSpacing: theme ? theme.fontLetterSpacing : 1
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
}
}
} }
Text { Column {
visible: !root.isVolumeLayout visible: root.contextOpen
text: String(root.notifBody || "")
color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha)
font.family: "8bitoperator JVE"
font.pixelSize: 24
font.letterSpacing: 1
wrapMode: Text.Wrap
maximumLineCount: 4
elide: Text.ElideRight
textFormat: Text.PlainText
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
width: parent.width width: parent.width
} spacing: theme ? theme.actionGap : 8
Text { Repeater {
visible: root.isVolumeLayout model: root.contextItems
text: "VOLUME " + root.volumePercent + "%"
color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.textAlpha)
font.family: "8bitoperator JVE"
font.pixelSize: 30
font.letterSpacing: 1
wrapMode: Text.NoWrap
elide: Text.ElideRight
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
width: parent.width
}
Rectangle {
width: parent.width
height: theme ? theme.actionHeight : 34
color: actionHover.hovered ? (theme ? theme.hoverInvertBackground : "#ffffff") : (theme ? theme.panelBackground : "#000000")
border.width: theme ? theme.actionBorderWidth : 2
border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0
antialiasing: false
HoverHandler { id: actionHover }
Text {
anchors.centerIn: parent
text: modelData.actionText
color: actionHover.hovered ? (theme ? theme.hoverInvertText : "#000000") : (theme ? theme.panelText : "#ffffff")
font.family: theme ? theme.fontFamily : "8bitoperator JVE"
font.pixelSize: 18
font.letterSpacing: theme ? theme.fontLetterSpacing : 1
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
}
TapHandler {
acceptedButtons: Qt.LeftButton
enabled: root.interactive && !root.closing
onTapped: root.invokeAction(modelData.actionObject)
}
}
}
}
} }
HoverHandler { DragHandler {
id: hoverHandler id: swipeHandler
enabled: root.interactive && !root.peekMode && !root.closing
xAxis.enabled: true
yAxis.enabled: false
onTranslationChanged: {
root.x = Math.max(0, translation.x);
}
onActiveChanged: {
if (active)
return;
const threshold = theme ? theme.dragDismissThreshold : 140;
if (root.x >= threshold) {
root.beginClose("drag");
return;
}
snapBackAnimation.restart();
}
}
NumberAnimation {
id: snapBackAnimation
target: root
property: "x"
to: 0
duration: theme ? theme.expandMs : 120
easing.type: Easing.OutCubic
} }
TapHandler { TapHandler {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
enabled: root.highlighted && !root.closing enabled: root.interactive && !root.peekMode && !root.closing
onTapped: root.beginClose("click") onTapped: root.openContextPanel()
} }
TapHandler {
acceptedButtons: Qt.RightButton
enabled: root.interactive && !root.peekMode && !root.closing
onTapped: root.beginClose("dismiss")
}
onNotifObjectChanged: restartTimeout()
onNotifVersionChanged: restartTimeout()
onContextOpenChanged: {
if (!contextOpen)
restartTimeout();
else
timeoutTimer.stop();
}
Component.onCompleted: restartTimeout()
} }

View File

@@ -0,0 +1,179 @@
import QtQuick
Item {
id: root
property NotificationTheme theme
property var groupData: null
property bool expanded: false
property bool peekEnabled: false
signal closeFinished(int notificationId, string reason)
signal expandRequested(string groupKey, bool expanded)
readonly property var entries: groupData && groupData.notifications ? groupData.notifications : []
readonly property int entryCount: entries.length
readonly property bool showCompressedStack: !expanded && entryCount > 1
readonly property int visiblePeekCount: showCompressedStack && peekEnabled ? Math.min(theme ? theme.stackPeekCount : 2, Math.max(0, entryCount - 1)) : 0
width: theme ? theme.stackWidth : 420
implicitHeight: {
if (expanded)
return expandedColumn.implicitHeight;
const base = newestCard.implicitHeight;
const step = theme ? theme.stackPeekStep : 18;
return base + visiblePeekCount * step;
}
height: implicitHeight
Item {
id: compressedStack
visible: !root.expanded
width: parent.width
height: parent.height
Repeater {
model: root.visiblePeekCount
NotificationCard {
property int peekIndex: index
theme: root.theme
notificationId: root.entries[peekIndex + 1].notificationId
notifObject: root.entries[peekIndex + 1].notifObject
notifVersion: root.entries[peekIndex + 1].notifVersion
createdAtMs: root.entries[peekIndex + 1].createdAt
peekMode: true
interactive: false
width: root.width
y: Math.max(0, newestCard.implicitHeight - (theme ? theme.stackPeekHeight : 36) + (theme ? theme.stackPeekStep : 18) * (peekIndex + 1))
z: 10 - peekIndex
onCloseFinished: function (closedNotificationId, reason) {
root.closeFinished(closedNotificationId, reason);
}
}
}
NotificationCard {
id: newestCard
theme: root.theme
notificationId: root.entryCount > 0 ? root.entries[0].notificationId : -1
notifObject: root.entryCount > 0 ? root.entries[0].notifObject : null
notifVersion: root.entryCount > 0 ? root.entries[0].notifVersion : 0
createdAtMs: root.entryCount > 0 ? root.entries[0].createdAt : Date.now()
interactive: root.entryCount <= 1
width: root.width
y: 0
z: 20
onCloseFinished: function (closedNotificationId, reason) {
root.closeFinished(closedNotificationId, reason);
}
}
Rectangle {
visible: root.entryCount > 1 && root.peekEnabled
width: 82
height: 18
x: root.width - width - 18
y: newestCard.implicitHeight + 1
color: theme ? theme.panelBackground : "#000000"
border.width: 2
border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0
antialiasing: false
z: 25
Text {
anchors.centerIn: parent
text: "+" + String(Math.max(0, root.entryCount - 1))
color: theme ? theme.panelText : "#ffffff"
font.family: theme ? theme.fontFamily : "8bitoperator JVE"
font.pixelSize: 14
font.letterSpacing: theme ? theme.fontLetterSpacing : 1
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
}
}
TapHandler {
enabled: root.entryCount > 1
acceptedButtons: Qt.LeftButton
onTapped: root.expandRequested(root.groupData.groupKey, true)
}
TapHandler {
enabled: root.entryCount > 1
acceptedButtons: Qt.RightButton
onTapped: {
if (root.entryCount > 0)
root.closeFinished(root.entries[0].notificationId, "dismiss");
}
}
}
Column {
id: expandedColumn
visible: root.expanded
width: parent.width
spacing: 0
Repeater {
model: root.entries
NotificationCard {
theme: root.theme
notificationId: modelData.notificationId
notifObject: modelData.notifObject
notifVersion: modelData.notifVersion
createdAtMs: modelData.createdAt
width: root.width
forceExpanded: true
onCloseFinished: function (closedNotificationId, reason) {
root.closeFinished(closedNotificationId, reason);
}
}
}
Rectangle {
visible: root.entryCount > 1
width: parent.width
height: theme ? theme.actionHeight : 34
color: theme ? theme.panelBackground : "#000000"
border.width: theme ? theme.actionBorderWidth : 2
border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0
antialiasing: false
Text {
anchors.centerIn: parent
text: "Collapse"
color: theme ? theme.panelText : "#ffffff"
font.family: theme ? theme.fontFamily : "8bitoperator JVE"
font.pixelSize: 18
font.letterSpacing: theme ? theme.fontLetterSpacing : 1
renderType: Text.NativeRendering
font.hintingPreference: Font.PreferNoHinting
smooth: false
antialiasing: false
}
TapHandler {
acceptedButtons: Qt.LeftButton
onTapped: root.expandRequested(root.groupData.groupKey, false)
}
}
}
Behavior on implicitHeight {
NumberAnimation {
duration: theme ? theme.expandMs : 120
easing.type: Easing.InOutCubic
}
}
}

View File

@@ -13,8 +13,8 @@ PanelWindow {
} }
margins { margins {
top: 24 top: theme.panelMarginTop
right: 24 right: theme.panelMarginRight
} }
// Top layer keeps notifications above normal windows while still usually // Top layer keeps notifications above normal windows while still usually
@@ -30,13 +30,18 @@ PanelWindow {
visible: true visible: true
color: "#00000000" color: "#00000000"
property int stackWidth: 420 NotificationTheme {
property int menuReservedHeight: 182 id: theme
}
property int stackWidth: theme.stackWidth
property int menuReservedHeight: theme.topbarReservedHeight
readonly property bool stackPreviewEnabled: Boolean(ShellStateManager.global("notifications.stackPreviewEnabled", true))
readonly property int screenHeight: screen ? screen.height : 1080 readonly property int screenHeight: screen ? screen.height : 1080
readonly property bool topbarOpen: ShellStateManager.shellOpen readonly property bool topbarOpen: ShellStateManager.shellOpen
readonly property int stackOffsetY: topbarOpen ? menuReservedHeight + 24 : 0 readonly property int stackOffsetY: topbarOpen ? menuReservedHeight + theme.panelMarginTop : 0
readonly property int maxStackHeight: Math.max(120, screenHeight - 24 - stackOffsetY - 24) readonly property int maxStackHeight: Math.max(120, screenHeight - theme.panelMarginTop - stackOffsetY - theme.panelMarginTop)
implicitWidth: stackWidth implicitWidth: stackWidth
implicitHeight: stackOffsetY + notificationViewport.height implicitHeight: stackOffsetY + notificationViewport.height
@@ -87,22 +92,20 @@ PanelWindow {
spacing: 0 spacing: 0
Repeater { Repeater {
model: notificationModel.notifications model: notificationModel.groupedNotifications
NotificationCard { NotificationGroup {
notificationId: rowNotificationId theme: theme
notifTitle: rowTitle groupData: modelData
notifBody: rowBody expanded: notificationModel.groupIsExpanded(modelData.groupKey)
notifUrgency: rowUrgency peekEnabled: notificationLayer.stackPreviewEnabled
notifAppName: rowAppName
notifAppIcon: rowAppIcon
notifImage: rowImage
notifTimeoutMs: rowTimeoutMs
notifCritical: rowCritical
notifHints: rowHints
width: notificationLayer.stackWidth width: notificationLayer.stackWidth
onExpandRequested: function (groupKey, shouldExpand) {
notificationModel.setGroupExpanded(groupKey, shouldExpand);
}
onCloseFinished: function (closedNotificationId, reason) { onCloseFinished: function (closedNotificationId, reason) {
notificationModel.closeById(closedNotificationId, reason); notificationModel.closeById(closedNotificationId, reason);
} }

View File

@@ -5,6 +5,14 @@ QtObject {
id: root id: root
property ListModel notifications: ListModel {} property ListModel notifications: ListModel {}
property var groupedNotifications: []
property var groupExpansion: ({})
property int versionCounter: 0
function nextVersion() {
versionCounter += 1;
return versionCounter;
}
function indexOfId(notificationId) { function indexOfId(notificationId) {
for (let i = 0; i < notifications.count; i++) { for (let i = 0; i < notifications.count; i++) {
@@ -15,16 +23,104 @@ QtObject {
return -1; return -1;
} }
function timeoutMsFor(notificationObject) { function normalizedString(value) {
const isCritical = notificationObject.urgency === NotificationUrgency.Critical; if (value === undefined || value === null)
if (isCritical) return "";
return -1; return String(value);
}
const rawSeconds = Number(notificationObject.expireTimeout); function hintString(notificationObject, name) {
if (!Number.isFinite(rawSeconds) || rawSeconds <= 0) if (!notificationObject || !notificationObject.hints)
return 5000; return "";
const value = notificationObject.hints[name];
return value === undefined || value === null ? "" : String(value);
}
return Math.max(1000, Math.round(rawSeconds * 1000)); function groupKeyFor(notificationObject) {
const desktopEntry = normalizedString(hintString(notificationObject, "desktop-entry")).trim().toLowerCase();
if (desktopEntry.length > 0)
return "desktop:" + desktopEntry;
const appName = normalizedString(notificationObject ? notificationObject.appName : "").trim().toLowerCase();
if (appName.length > 0)
return "app:" + appName;
const appIcon = normalizedString(notificationObject ? notificationObject.appIcon : "").trim().toLowerCase();
if (appIcon.length > 0)
return "icon:" + appIcon;
const image = normalizedString(notificationObject ? notificationObject.image : "").trim().toLowerCase();
if (image.length > 0)
return "image:" + image;
return "id:" + String(notificationObject ? notificationObject.id : Date.now());
}
function sortRowsNewestFirst(left, right) {
return (right.createdAt || 0) - (left.createdAt || 0);
}
function groupSortNewestFirst(left, right) {
return (right.newestAt || 0) - (left.newestAt || 0);
}
function rebuildGroups() {
const grouped = ({});
for (let i = 0; i < notifications.count; i++) {
const row = notifications.get(i);
const key = row.rowGroupKey;
if (!grouped[key]) {
grouped[key] = {
groupKey: key,
appName: row.rowAppName,
appIcon: row.rowAppIcon,
desktopEntry: row.rowDesktopEntry,
newestAt: row.rowCreatedAt,
notifications: []
};
}
grouped[key].notifications.push({
notificationId: row.rowNotificationId,
notifObject: row.rowObject,
notifVersion: row.rowVersion,
createdAt: row.rowCreatedAt,
appName: row.rowAppName,
appIcon: row.rowAppIcon,
image: row.rowImage
});
if (row.rowCreatedAt > grouped[key].newestAt)
grouped[key].newestAt = row.rowCreatedAt;
}
const groups = [];
for (const groupKey in grouped) {
if (!grouped.hasOwnProperty(groupKey))
continue;
const group = grouped[groupKey];
group.notifications.sort(sortRowsNewestFirst);
groups.push(group);
}
groups.sort(groupSortNewestFirst);
groupedNotifications = groups;
}
function groupIsExpanded(groupKey) {
return Boolean(groupExpansion[groupKey]);
}
function setGroupExpanded(groupKey, expanded) {
const next = Object.assign({}, groupExpansion);
next[groupKey] = Boolean(expanded);
groupExpansion = next;
}
function toggleGroupExpanded(groupKey) {
setGroupExpanded(groupKey, !groupIsExpanded(groupKey));
} }
function addNotification(notificationObject) { function addNotification(notificationObject) {
@@ -33,32 +129,50 @@ QtObject {
return; return;
} }
const existingIndex = indexOfId(notificationObject.id);
if (existingIndex >= 0)
notifications.remove(existingIndex);
notificationObject.tracked = true; notificationObject.tracked = true;
const idCopy = notificationObject.id; const idCopy = notificationObject.id;
const objectCopy = notificationObject;
notificationObject.closed.connect(function () { notificationObject.closed.connect(function () {
const rowIndex = indexOfId(idCopy); const rowIndex = indexOfId(idCopy);
if (rowIndex >= 0) if (rowIndex >= 0 && notifications.get(rowIndex).rowObject === objectCopy) {
notifications.remove(rowIndex); notifications.remove(rowIndex);
rebuildGroups();
}
}); });
const nowMs = Date.now();
const groupKey = groupKeyFor(notificationObject);
const existingIndex = indexOfId(notificationObject.id);
if (existingIndex >= 0) {
notifications.set(existingIndex, {
rowNotificationId: notificationObject.id,
rowObject: notificationObject,
rowVersion: nextVersion(),
rowCreatedAt: nowMs,
rowGroupKey: groupKey,
rowAppName: normalizedString(notificationObject.appName),
rowAppIcon: normalizedString(notificationObject.appIcon),
rowImage: normalizedString(notificationObject.image),
rowDesktopEntry: hintString(notificationObject, "desktop-entry")
});
rebuildGroups();
return;
}
notifications.append({ notifications.append({
rowNotificationId: notificationObject.id, rowNotificationId: notificationObject.id,
rowTitle: String(notificationObject.summary || ""), rowObject: notificationObject,
rowBody: String(notificationObject.body || ""), rowVersion: nextVersion(),
rowUrgency: NotificationUrgency.toString(notificationObject.urgency), rowCreatedAt: nowMs,
rowAppName: String(notificationObject.appName || ""), rowGroupKey: groupKey,
rowAppIcon: String(notificationObject.appIcon || ""), rowAppName: normalizedString(notificationObject.appName),
rowImage: String(notificationObject.image || ""), rowAppIcon: normalizedString(notificationObject.appIcon),
rowTimeoutMs: timeoutMsFor(notificationObject), rowImage: normalizedString(notificationObject.image),
rowCritical: notificationObject.urgency === NotificationUrgency.Critical, rowDesktopEntry: hintString(notificationObject, "desktop-entry")
rowHints: notificationObject.hints || ({}),
rowObject: notificationObject
}); });
rebuildGroups();
} }
function closeById(notificationId, reason) { function closeById(notificationId, reason) {
@@ -70,13 +184,15 @@ QtObject {
const notifObject = row.rowObject; const notifObject = row.rowObject;
if (notifObject) { if (notifObject) {
if (reason === "timeout") if (reason === "timeout") {
notifObject.expire(); notifObject.expire();
else } else if (reason === "click" || reason === "dismiss" || reason === "drag") {
notifObject.dismiss(); notifObject.dismiss();
}
} }
notifications.remove(rowIndex); notifications.remove(rowIndex);
rebuildGroups();
} }
property NotificationServer server: NotificationServer { property NotificationServer server: NotificationServer {
@@ -84,6 +200,7 @@ QtObject {
bodySupported: true bodySupported: true
bodyMarkupSupported: false bodyMarkupSupported: false
bodyHyperlinksSupported: false bodyHyperlinksSupported: false
actionsSupported: true
// NotificationServer is Quickshell's DBus implementation for // NotificationServer is Quickshell's DBus implementation for
// org.freedesktop.Notifications, so this is the notification source. // org.freedesktop.Notifications, so this is the notification source.

View File

@@ -0,0 +1,46 @@
import QtQuick
QtObject {
id: theme
readonly property string fontFamily: "8bitoperator JVE"
readonly property int fontLetterSpacing: 1
readonly property color panelBackground: "#000000"
readonly property color panelBorder: "#ffffff"
readonly property color panelText: "#ffffff"
readonly property color hoverInvertBackground: "#ffffff"
readonly property color hoverInvertText: "#000000"
readonly property int stackWidth: 420
readonly property int panelMarginTop: 24
readonly property int panelMarginRight: 24
readonly property int topbarReservedHeight: 182
readonly property int cardPadding: 14
readonly property int borderWidth: 3
readonly property int iconBox: 28
readonly property int appIconSize: 24
readonly property int contentGap: 10
readonly property int lineGap: 2
readonly property int actionGap: 8
readonly property int actionHeight: 34
readonly property int actionBorderWidth: 2
readonly property int collapsedBodyLines: 2
readonly property int expandedBodyLines: 8
readonly property int stackPeekCount: 2
readonly property int stackPeekHeight: 36
readonly property int stackPeekStep: 18
readonly property int dragDismissThreshold: 140
readonly property int fallbackLowTimeoutMs: 4500
readonly property int fallbackNormalTimeoutMs: 5000
readonly property int fallbackCriticalTimeoutMs: 8000
readonly property int enterMs: 170
readonly property int exitMs: 140
readonly property int fadeMs: 120
readonly property int expandMs: 120
}

View File

@@ -19,7 +19,9 @@ QtObject {
property bool appUsageLoaded: false property bool appUsageLoaded: false
property var windowRequests: ({}) property var windowRequests: ({})
property var quickSettingsPayload: ({}) property var quickSettingsPayload: ({})
property var globals: ({}) property var globals: ({
"notifications.stackPreviewEnabled": true
})
signal shellOpened signal shellOpened
signal shellClosed signal shellClosed
@@ -198,10 +200,24 @@ QtObject {
} }
function setGlobal(key, value) { function setGlobal(key, value) {
globals[key] = value; var updated = Object.assign({}, globals);
updated[key] = value;
globals = updated;
} }
function global(key, defaultValue) { function global(key, defaultValue) {
return globals.hasOwnProperty(key) ? globals[key] : defaultValue; return globals.hasOwnProperty(key) ? globals[key] : defaultValue;
} }
function notificationStackPreviewEnabled() {
return Boolean(global("notifications.stackPreviewEnabled", true));
}
function setNotificationStackPreviewEnabled(enabled) {
setGlobal("notifications.stackPreviewEnabled", Boolean(enabled));
}
function toggleNotificationStackPreviewEnabled() {
setNotificationStackPreviewEnabled(!notificationStackPreviewEnabled());
}
} }

View File

@@ -36,6 +36,7 @@ Item {
property int activeSelection: 0 property int activeSelection: 0
property bool inBluetoothMenu: false property bool inBluetoothMenu: false
property bool inWallpaperMenu: false property bool inWallpaperMenu: false
property bool inNotificationsMenu: false
property bool isSelected: false property bool isSelected: false
@@ -81,7 +82,7 @@ Item {
} }
function currentAction() { function currentAction() {
if (root.inBluetoothMenu || root.inWallpaperMenu) if (root.inBluetoothMenu || root.inWallpaperMenu || root.inNotificationsMenu)
return null; return null;
return menuLength() > 0 ? menuAt(activeSelection) : null; return menuLength() > 0 ? menuAt(activeSelection) : null;
} }
@@ -195,6 +196,7 @@ Item {
property int bluetoothActionIndex: 1 property int bluetoothActionIndex: 1
property int wallpaperActionIndex: 2 property int wallpaperActionIndex: 2
property int notificationsActionIndex: 3
property var actions: [ property var actions: [
{ {
name: "Master Volume", name: "Master Volume",
@@ -218,6 +220,7 @@ Item {
ent: function () { ent: function () {
root.inBluetoothMenu = true; root.inBluetoothMenu = true;
root.inWallpaperMenu = false; root.inWallpaperMenu = false;
root.inNotificationsMenu = false;
root.isSelected = false; root.isSelected = false;
root.activeSelection = 0; root.activeSelection = 0;
root.clampSelection(); root.clampSelection();
@@ -231,6 +234,7 @@ Item {
ent: function () { ent: function () {
root.inWallpaperMenu = true; root.inWallpaperMenu = true;
root.inBluetoothMenu = false; root.inBluetoothMenu = false;
root.inNotificationsMenu = false;
root.isSelected = false; root.isSelected = false;
var currentIndex = root.currentWallpaperIndex(); var currentIndex = root.currentWallpaperIndex();
root.activeSelection = currentIndex >= 0 ? currentIndex : 0; root.activeSelection = currentIndex >= 0 ? currentIndex : 0;
@@ -241,10 +245,39 @@ Item {
getState: function () { getState: function () {
return ""; return "";
} }
},
{
name: "Notifications",
ent: function () {
root.inNotificationsMenu = true;
root.inBluetoothMenu = false;
root.inWallpaperMenu = false;
root.isSelected = false;
root.activeSelection = 0;
root.clampSelection();
},
getState: function () {
return "";
}
} }
] ]
property var menuModel: root.inBluetoothMenu ? Bluetooth.devices : (root.inWallpaperMenu ? wallpaperFolderModel : actions) property var notificationActions: [
{
name: "Stack Preview",
ent: function () {
if (manager && manager.toggleNotificationStackPreviewEnabled)
manager.toggleNotificationStackPreviewEnabled();
},
getState: function () {
if (!manager || !manager.notificationStackPreviewEnabled)
return "ON";
return manager.notificationStackPreviewEnabled() ? "ON" : "OFF";
}
}
]
property var menuModel: root.inBluetoothMenu ? Bluetooth.devices : (root.inWallpaperMenu ? wallpaperFolderModel : (root.inNotificationsMenu ? notificationActions : actions))
FolderListModel { FolderListModel {
id: wallpaperFolderModel id: wallpaperFolderModel
@@ -320,7 +353,7 @@ Item {
} }
Text { Text {
text: root.inBluetoothMenu ? "BLUETOOTH" : (root.inWallpaperMenu ? "WALLPAPER" : "CONFIG") text: root.inBluetoothMenu ? "BLUETOOTH" : (root.inWallpaperMenu ? "WALLPAPER" : (root.inNotificationsMenu ? "NOTIFICATIONS" : "CONFIG"))
font.family: "8bitoperator JVE" font.family: "8bitoperator JVE"
font.pixelSize: 71 font.pixelSize: 71
renderType: Text.NativeRendering renderType: Text.NativeRendering
@@ -440,6 +473,10 @@ Item {
const wallpaperPath = wallpaperPathAt(activeSelection); const wallpaperPath = wallpaperPathAt(activeSelection);
if (wallpaperPath && wallpaperPath.length > 0) if (wallpaperPath && wallpaperPath.length > 0)
root.applyWallpaper(wallpaperPath); root.applyWallpaper(wallpaperPath);
} else if (root.inNotificationsMenu) {
const notificationAction = menuAt(activeSelection);
if (notificationAction && notificationAction.ent)
notificationAction.ent();
} else { } else {
const a = currentAction(); const a = currentAction();
if (a && a.ent) { if (a && a.ent) {
@@ -459,6 +496,11 @@ Item {
root.scrollOffset = 0; root.scrollOffset = 0;
root.activeSelection = root.wallpaperActionIndex; root.activeSelection = root.wallpaperActionIndex;
root.clampSelection(); root.clampSelection();
} else if (root.inNotificationsMenu) {
root.inNotificationsMenu = false;
root.isSelected = false;
root.activeSelection = root.notificationsActionIndex;
root.clampSelection();
} else if (root.inBluetoothMenu) { } else if (root.inBluetoothMenu) {
root.inBluetoothMenu = false; root.inBluetoothMenu = false;
root.isSelected = false; root.isSelected = false;
@@ -481,6 +523,7 @@ Item {
root.isSelected = false; root.isSelected = false;
root.inBluetoothMenu = false; root.inBluetoothMenu = false;
root.inWallpaperMenu = false; root.inWallpaperMenu = false;
root.inNotificationsMenu = false;
root.scrollOffset = 0; root.scrollOffset = 0;
root.refreshCurrentWallpaper(); root.refreshCurrentWallpaper();
ShellInputManager.registerHandler("quickSettings", handleKey); ShellInputManager.registerHandler("quickSettings", handleKey);