1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
|
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
import QtQuick
// One balloon: icon at the left, app, summary and body, an X, and the click
// targets. The body is markup, which is why the daemon advertises body-markup.
Rectangle {
id: b
// Neither property is `required` on purpose. A required property makes
// QML create the delegate in its own context, where `modelData` and
// `index` are not defined, so the Repeater's `notification: modelData`
// silently arrives undefined and every binding on it fails.
property var notification
property double now: 0
// Driven by the shell's tick. The balloon drops itself once its expiry
// passes, or while a suppression says so: the drawer and snooze withhold
// everything, DND withholds low and normal but still shows critical. The
// drawer lists a notification DND chose not to pop, because a list the
// user opened is not an interruption.
visible: {
if (Notify.drawerOpen) return false;
if (Notify.snoozeUntil > b.now) return false;
if (Notify.dnd && b.notification.urgency !== "critical") return false;
if (b.notification.expires !== 0 && b.now >= b.notification.expires) return false;
return true;
}
width: parent ? parent.width : 340
implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0)
radius: 10
color: Qt.alpha(Theme.base, 0.82)
border.width: 1
border.color: Qt.alpha(Theme.text, 0.12)
// The background click target is declared first so the X, declared later,
// sits above it and wins its corner.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton
cursorShape: Qt.PointingHandCursor
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
Notify.closeAll();
return;
}
const acts = b.notification.actions || [];
const inert = b.notification.expires !== 0 && Date.now() >= b.notification.expires;
if (acts.length > 0 && !inert) Notify.actions(b.notification.id, acts);
else Notify.close(b.notification.id);
}
}
Image {
id: icon
visible: b.notification.icon !== "" && b.notification.icon !== undefined
anchors { left: parent.left; top: parent.top; margins: 10 }
width: 32
height: 32
source: visible ? "file://" + b.notification.icon : ""
sourceSize { width: 64; height: 64 }
}
Text {
id: close
anchors { right: parent.right; top: parent.top; margins: 6 }
width: 20
height: 20
text: "\uf00d"
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
font { family: Theme.iconFamily; pixelSize: 13 }
color: closeArea.containsMouse ? Theme.red : Theme.subtext
MouseArea {
id: closeArea
anchors.fill: parent
anchors.margins: -6
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Notify.close(b.notification.id)
}
}
Column {
id: texts
anchors {
left: icon.visible ? icon.right : parent.left
leftMargin: 10
right: parent.right
rightMargin: 10
top: parent.top
topMargin: 10
}
spacing: 2
Text {
width: parent.width
text: b.notification.app || ""
elide: Text.ElideRight
font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
color: Theme.subtext
}
Text {
width: parent.width
text: b.notification.summary || ""
elide: Text.ElideRight
font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2 }
color: Theme.text
}
Text {
width: parent.width
visible: text !== ""
text: Notify.sanitize(b.notification.body)
textFormat: Text.RichText
wrapMode: Text.WordWrap
maximumLineCount: 3
elide: Text.ElideRight
font { family: Theme.fontFamily; pixelSize: Theme.fontSize }
color: Theme.subtext
}
}
// The content image (a screenshot or an app-provided image), below the
// text. The daemon writes the path; an older daemon without the field
// leaves this hidden. The height matches the scaled width so
// PreserveAspectFit does not letterbox, and a tall screenshot is capped at
// 240px. Asynchronous so a large screenshot does not stall the shell.
Image {
id: preview
visible: b.notification.image !== "" && b.notification.image !== undefined
anchors {
left: parent.left
right: parent.right
top: texts.bottom
leftMargin: 10
rightMargin: 10
topMargin: 8
}
height: visible && implicitWidth > 0
? Math.min(width * implicitHeight / implicitWidth, 240)
: 0
source: visible ? "file://" + b.notification.image : ""
fillMode: Image.PreserveAspectFit
asynchronous: true
cache: false
}
}
|