489 lines
16 KiB
QML
489 lines
16 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Services.Notifications
|
|
import Quickshell.Widgets
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property NotificationTheme theme
|
|
property int notificationId: -1
|
|
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 closing: false
|
|
property bool contextOpen: false
|
|
property real collapseFactor: 1
|
|
property bool hovered: hoverHandler.hovered
|
|
|
|
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 notifDesktopEntry: {
|
|
if (!notifObject || !notifObject.hints)
|
|
return "";
|
|
const value = notifObject.hints["desktop-entry"];
|
|
return value === undefined || value === null ? "" : String(value);
|
|
}
|
|
readonly property string notifImage: notifObject ? String(notifObject.image || "") : ""
|
|
readonly property bool isExpanded: forceExpanded || contextOpen
|
|
|
|
function resolveIconSource(iconName) {
|
|
const normalized = String(iconName || "").trim();
|
|
if (normalized.length === 0)
|
|
return "";
|
|
if (normalized.indexOf("/") >= 0 || normalized.indexOf("file://") === 0)
|
|
return normalized;
|
|
return String(Quickshell.iconPath(normalized) || "");
|
|
}
|
|
|
|
readonly property string appIconSource: {
|
|
const candidates = [];
|
|
const desktopEntry = notifDesktopEntry.trim();
|
|
const appName = notifAppName.trim();
|
|
const appIcon = notifAppIcon.trim();
|
|
|
|
if (desktopEntry.length > 0) {
|
|
candidates.push(desktopEntry);
|
|
if (desktopEntry.endsWith(".desktop"))
|
|
candidates.push(desktopEntry.slice(0, desktopEntry.length - 8));
|
|
}
|
|
|
|
if (appName.length > 0) {
|
|
candidates.push(appName);
|
|
const normalizedAppName = appName.toLowerCase();
|
|
candidates.push(normalizedAppName);
|
|
candidates.push(normalizedAppName.replace(/\s+/g, "-"));
|
|
candidates.push(normalizedAppName.replace(/\s+/g, ""));
|
|
}
|
|
|
|
if (appIcon.length > 0)
|
|
candidates.push(appIcon);
|
|
|
|
for (let i = 0; i < candidates.length; i++) {
|
|
const resolved = resolveIconSource(candidates[i]);
|
|
if (resolved.length > 0)
|
|
return resolved;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 urgencyTimeoutMs() {
|
|
if (!notifObject)
|
|
return theme.fallbackNormalTimeoutMs;
|
|
|
|
const rawTimeout = Number(notifObject.expireTimeout);
|
|
if (Number.isFinite(rawTimeout) && rawTimeout > 0) {
|
|
const normalizedTimeout = rawTimeout > 100 ? Math.round(rawTimeout) : Math.round(rawTimeout * 1000);
|
|
return Math.max(theme ? theme.minimumTimeoutMs : 2000, normalizedTimeout);
|
|
}
|
|
|
|
const urgency = notifObject.urgency;
|
|
if (urgency === NotificationUrgency.Critical)
|
|
return theme.fallbackCriticalTimeoutMs;
|
|
if (urgency === NotificationUrgency.Low)
|
|
return theme.fallbackLowTimeoutMs;
|
|
return theme.fallbackNormalTimeoutMs;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function beginClose(reason) {
|
|
if (closing)
|
|
return;
|
|
closing = true;
|
|
timeoutTimer.stop();
|
|
closeReason = reason;
|
|
closeAnimation.start();
|
|
}
|
|
|
|
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 || hovered)
|
|
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
|
|
opacity: 1
|
|
|
|
SequentialAnimation {
|
|
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: 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
|
|
}
|
|
}
|
|
}
|
|
|
|
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.urgencyTimeoutMs()
|
|
repeat: false
|
|
running: false
|
|
onTriggered: root.beginClose("timeout")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
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
|
|
|
|
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: 0
|
|
border.color: "transparent"
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
visible: root.contextOpen
|
|
width: parent.width
|
|
spacing: theme ? theme.actionGap : 8
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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.interactive && !root.peekMode && !root.closing
|
|
onTapped: root.openContextPanel()
|
|
}
|
|
|
|
TapHandler {
|
|
acceptedButtons: Qt.RightButton
|
|
enabled: root.interactive && !root.peekMode && !root.closing
|
|
onTapped: root.beginClose("dismiss")
|
|
}
|
|
|
|
HoverHandler {
|
|
id: hoverHandler
|
|
}
|
|
|
|
onNotifObjectChanged: restartTimeout()
|
|
onNotifVersionChanged: restartTimeout()
|
|
onContextOpenChanged: {
|
|
if (!contextOpen)
|
|
restartTimeout();
|
|
else
|
|
timeoutTimer.stop();
|
|
}
|
|
onHoveredChanged: {
|
|
if (hovered)
|
|
timeoutTimer.stop();
|
|
else
|
|
restartTimeout();
|
|
}
|
|
onForceExpandedChanged: {
|
|
if (forceExpanded)
|
|
timeoutTimer.stop();
|
|
else
|
|
restartTimeout();
|
|
}
|
|
Component.onCompleted: restartTimeout()
|
|
}
|