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

@@ -1,392 +1,396 @@
import QtQuick
import Qt5Compat.GraphicalEffects
import Quickshell
import Quickshell.Services.Notifications
import Quickshell.Widgets
Item {
id: root
property NotificationTheme theme
property int notificationId: -1
property string notifTitle: ""
property string notifBody: ""
property string notifUrgency: "Normal"
property string notifAppName: ""
property string notifAppIcon: ""
property string notifImage: ""
property int notifTimeoutMs: 5000
property bool notifCritical: false
property var notifHints: ({})
property var notifObject: null
property int notifVersion: 0
property real createdAtMs: Date.now()
property bool interactive: true
property bool forceExpanded: false
property bool peekMode: false
signal closeFinished(int notificationId, string reason)
property bool highlighted: hoverHandler.hovered
property bool closing: false
property string closeReason: ""
property real borderAlpha: 1
property real textAlpha: 1
property bool contextOpen: false
property real collapseFactor: 1
readonly property color normalInk: "#ffffff"
readonly property color highlightInk: "#ffc90e"
readonly property color activeInk: highlighted ? highlightInk : normalInk
readonly property int padding: 14
readonly property int iconBox: 28
readonly property int appIconSize: 24
readonly property string appIconSource: {
if (notifImage && notifImage.length > 0)
return notifImage;
readonly property string notifTitle: notifObject ? String(notifObject.summary || "") : ""
readonly property string notifBody: notifObject ? String(notifObject.body || "") : ""
readonly property string notifAppName: notifObject ? String(notifObject.appName || "") : ""
readonly property string notifAppIcon: notifObject ? String(notifObject.appIcon || "") : ""
readonly property string notifImage: notifObject ? String(notifObject.image || "") : ""
readonly property bool isExpanded: forceExpanded || contextOpen
readonly property string appIconSource: {
if (!notifAppIcon || notifAppIcon.length === 0)
return "";
if (notifAppIcon.indexOf("/") >= 0 || notifAppIcon.indexOf("file://") === 0)
return notifAppIcon;
return Quickshell.iconPath(notifAppIcon);
}
function hintString(name) {
if (!notifHints || typeof notifHints !== "object")
return "";
const value = notifHints[name];
return value === undefined || value === null ? "" : String(value);
readonly property string customImageSource: notifImage
readonly property string primaryIconSource: customImageSource.length > 0 ? customImageSource : appIconSource
readonly property bool showBadgeIcon: customImageSource.length > 0 && appIconSource.length > 0
readonly property int iconBoxSize: (theme ? theme.iconBox : 28) + 12
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() {
return (notifAppName + " " + notifTitle + " " + notifBody + " " + hintString("category")).toLowerCase();
readonly property int actionCount: notifActions.length
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() {
const source = (notifTitle + " " + notifBody).toUpperCase();
const match = source.match(/VOLUME\s*(\d{1,3})%/);
if (!match || match.length < 2)
return -1;
function urgencyTimeoutMs() {
if (!notifObject)
return theme.fallbackNormalTimeoutMs;
const parsed = Number(match[1]);
if (!Number.isFinite(parsed))
return -1;
const rawSeconds = Number(notifObject.expireTimeout);
if (Number.isFinite(rawSeconds) && rawSeconds > 0)
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()
readonly property bool isVolumeLayout: volumePercent >= 0
function soulColor() {
const full = textBlob();
const urgencyText = String(notifUrgency || "").toLowerCase();
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";
function relativeTime() {
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - createdAtMs) / 1000));
if (elapsedSeconds < 60)
return "now";
if (elapsedSeconds < 3600)
return Math.floor(elapsedSeconds / 60) + "m";
return Math.floor(elapsedSeconds / 3600) + "h";
}
readonly property color soulInk: soulColor()
function beginClose(reason) {
if (closing)
return;
closing = true;
closeReason = reason;
timeoutTimer.stop();
if (reason === "click") {
heartFlash.restart();
clickClose.start();
} else {
timeoutClose.start();
}
closeReason = reason;
closeAnimation.start();
}
implicitWidth: 420
implicitHeight: Math.max(1, contentColumn.implicitHeight * collapseFactor + padding * 2)
function openContextPanel() {
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
height: implicitHeight
transformOrigin: Item.TopRight
opacity: 1
SequentialAnimation {
id: entryAnimation
running: true
PropertyAction {
target: root
property: "opacity"
value: 0
}
PropertyAction {
target: root
property: "scale"
value: 0.95
}
id: enterAnimation
running: !peekMode
PropertyAction { target: root; property: "opacity"; value: 0 }
PropertyAction { target: root; property: "x"; value: 20 }
ParallelAnimation {
NumberAnimation {
target: root
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
}
NumberAnimation { target: root; property: "opacity"; to: 1; duration: theme ? theme.enterMs : 170; easing.type: Easing.OutCubic }
NumberAnimation { target: root; property: "x"; to: 0; duration: theme ? theme.enterMs : 170; easing.type: Easing.OutCubic }
}
}
SequentialAnimation {
id: heartFlash
NumberAnimation {
target: soulFlashOverlay
property: "opacity"
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")
}
ParallelAnimation {
id: closeAnimation
NumberAnimation { target: root; property: "opacity"; to: 0; duration: theme ? theme.fadeMs : 120; easing.type: Easing.InCubic }
NumberAnimation { target: root; property: "x"; to: width; duration: theme ? theme.exitMs : 140; easing.type: Easing.InCubic }
onFinished: root.closeFinished(root.notificationId, root.closeReason)
}
Timer {
id: timeoutTimer
interval: root.notifTimeoutMs
interval: root.urgencyTimeoutMs()
repeat: false
running: !root.notifCritical && root.notifTimeoutMs > 0
running: !root.peekMode
onTriggered: root.beginClose("timeout")
}
Rectangle {
anchors.fill: parent
color: "#000000"
border.width: 3
border.color: Qt.rgba(root.activeInk.r, root.activeInk.g, root.activeInk.b, root.borderAlpha)
color: theme ? theme.panelBackground : "#000000"
border.width: theme ? theme.borderWidth : 3
border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0
antialiasing: false
visible: !peekMode
}
Item {
id: soulContainer
x: {
if (!root.isVolumeLayout)
return root.padding;
const left = root.padding;
const right = Math.max(left, root.width - root.padding - root.iconBox);
return left + (right - left) * (root.volumePercent / 100);
}
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
}
Rectangle {
anchors.fill: parent
color: theme ? theme.panelBackground : "#000000"
border.width: theme ? theme.borderWidth : 3
border.color: theme ? theme.panelBorder : "#ffffff"
radius: 0
antialiasing: false
visible: peekMode
opacity: 0.95
}
Column {
id: contentColumn
x: root.padding + root.iconBox + 10
y: root.padding - 1
width: root.width - x - root.padding
spacing: 2
visible: !peekMode
x: (theme ? theme.cardPadding : 14)
y: (theme ? theme.cardPadding : 14)
width: root.width - ((theme ? theme.cardPadding : 14) * 2)
spacing: theme ? theme.lineGap : 2
Text {
visible: !root.isVolumeLayout
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
Row {
id: mainRow
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 {
visible: !root.isVolumeLayout
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
Column {
visible: root.contextOpen
width: parent.width
}
spacing: theme ? theme.actionGap : 8
Text {
visible: root.isVolumeLayout
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
}
Repeater {
model: root.contextItems
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 {
id: hoverHandler
DragHandler {
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 {
acceptedButtons: Qt.LeftButton
enabled: root.highlighted && !root.closing
onTapped: root.beginClose("click")
enabled: root.interactive && !root.peekMode && !root.closing
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()
}