Krolyxon
Commit b101de2

Branches

Refs →

aurora.configquickshellauroraNotifPopups.qml

252 lines · 11.6 KB · QML

1import QtQuick
2import QtQuick.Effects
3import Quickshell
4import Quickshell.Wayland
5import Quickshell.Services.Notifications
6
7// Toasts as an island at the top centre, under the bar: up to 4 cards, newest on top. Each drops in as a small pill and
8// grows into its card; leaving, it shrinks back and the ones below slide up. Hovering holds them all, click = the app's
9// default action, the × on hover only drops the toast (the notification stays in the history), other actions are pills.
10// After glyph-rice's island (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), glyph/Glyph.qml.
11PanelWindow {
12 id: win
13 property bool suppressed: false // hidden while the control panel is open
14 readonly property int cardW: 520
15
16 visible: stack.count > 0 && !suppressed
17 anchors.top: true
18 margins.top: 6
19 implicitWidth: cardW
20 implicitHeight: 4 * 160 + 20 // fixed; only the cards take input (mask)
21 exclusiveZone: 0
22 color: "transparent"
23 mask: Region { item: col }
24 WlrLayershell.layer: WlrLayer.Overlay
25 WlrLayershell.namespace: "aurora-notifs"
26 WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
27
28 // The island's own model: Notifs.popups is a new array on every change, which would restart every card's animation.
29 // Entries are snapshots (a notification can close while its card animates out); refs keeps the live object.
30 ListModel { id: stack }
31 property var refs: ({})
32
33 function imgOf(n) {
34 if (n.image && n.image !== "") return n.image
35 if (n.appIcon && n.appIcon !== "") return Quickshell.iconPath(n.appIcon, true)
36 return ""
37 }
38 function sync() {
39 var want = {}, l = Notifs.popups
40 for (var i = 0; i < l.length; i++) want[String(l[i].n.id)] = l[i].n
41 for (var j = 0; j < stack.count; j++) {
42 var e = stack.get(j)
43 if (!e.leaving && !want[e.key]) stack.setProperty(j, "leaving", true)
44 }
45 for (var k in want) {
46 var known = false
47 for (var m = 0; m < stack.count; m++) if (stack.get(m).key === k) { known = true; break }
48 if (known) continue
49 var n = want[k]
50 refs[k] = n
51 stack.insert(0, { key: k, app: n.appName || "app", summary: n.summary || "", body: n.body || "",
52 icon: imgOf(n), photo: !!n.image && n.image !== "",
53 crit: n.urgency === NotificationUrgency.Critical, leaving: false })
54 }
55 }
56 function forget(key) {
57 delete refs[key]
58 for (var i = 0; i < stack.count; i++) if (stack.get(i).key === key) { stack.remove(i); return }
59 }
60 Connections {
61 target: Notifs
62 function onPopupsChanged() { win.sync() }
63 }
64 Component.onCompleted: sync()
65
66 // hovering the island holds every toast
67 Binding { target: Notifs; property: "hold"; value: hov.hovered }
68
69 Column {
70 id: col
71 width: win.cardW
72 HoverHandler { id: hov }
73
74 Repeater {
75 model: stack
76 delegate: Item {
77 id: c
78 required property int index
79 required property string key
80 required property string app
81 required property string summary
82 required property string body
83 required property string icon
84 required property bool photo
85 required property bool crit
86 required property bool leaving
87
88 readonly property var n: win.refs[key] || null
89 // other actions as pills (the default one is the click); read once, the notification may close later
90 readonly property var buttons: {
91 var out = [], a = n ? n.actions : []
92 for (var i = 0; i < a.length; i++) if (a[i].identifier !== "default" && a[i].text !== "") out.push(a[i])
93 return out
94 }
95 readonly property var defaultAction: {
96 var a = n ? n.actions : []
97 for (var i = 0; i < a.length; i++) if (a[i].identifier === "default") return a[i]
98 return null
99 }
100 // invoke() can close the notification: drop by key afterwards, never touch c.n again
101 function run(action) {
102 var k = key
103 try { if (action) action.invoke() } catch (e) {}
104 var nn = win.refs[k]
105 if (nn) Notifs.dropPopup(nn)
106 }
107
108 // p 0..1 linear; each phase gets its own ease-out-quart (like glide)
109 property real p: 0
110 Component.onCompleted: p = Qt.binding(() => leaving ? 0 : 1)
111 Behavior on p { enabled: Theme.motion; NumberAnimation { duration: c.leaving ? 450 : 700 } }
112 onPChanged: if (leaving && p <= 0) Qt.callLater(win.forget, key)
113 onLeavingChanged: if (leaving && !Theme.motion) Qt.callLater(win.forget, key)
114 function ph(a, b) { var x = Math.max(0, Math.min(1, (p - a) / (b - a))); return 1 - Math.pow(1 - x, 4) }
115 readonly property real drop: ph(0, 0.3) // the pill drops in
116 readonly property real grow: ph(0.25, 0.8) // and spreads into the card
117 readonly property real full: Math.max(86, txt.implicitHeight + 32)
118
119 width: win.cardW
120 height: (40 + (full - 40) * grow + 10) * drop // its slot grows and collapses with it
121
122 Rectangle {
123 id: card
124 width: 140 + (win.cardW - 140) * c.grow
125 height: 40 + (c.full - 40) * c.grow
126 x: (win.cardW - width) / 2
127 y: -24 * (1 - c.drop)
128 opacity: c.drop
129 radius: Math.min(34, height / 2)
130 clip: true
131 color: Theme.s1
132 border.width: 1
133 border.color: c.crit ? Theme.err : Theme.grey
134
135 MouseArea {
136 id: ma
137 anchors.fill: parent
138 hoverEnabled: true
139 cursorShape: c.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
140 onClicked: c.run(c.defaultAction)
141 }
142
143 // the content at full size in the middle: text doesn't reflow while the card grows
144 Item {
145 anchors.centerIn: parent
146 width: win.cardW
147 height: c.full
148 opacity: c.ph(0.7, 1)
149
150 Rectangle {
151 id: ic
152 x: 16
153 anchors.verticalCenter: parent.verticalCenter
154 width: 54
155 height: 54
156 radius: 27
157 color: Theme.s2
158 Icon { anchors.centerIn: parent; visible: !img.visible; text: Theme.iBell; font.pixelSize: 22 }
159 Image {
160 id: img
161 anchors.centerIn: parent
162 width: c.photo && Settings.appIcons === "dots" ? 54 : 30
163 height: width
164 visible: status === Image.Ready
165 source: c.icon
166 sourceSize: Qt.size(width * 2, height * 2)
167 fillMode: Image.PreserveAspectCrop
168 asynchronous: true
169 layer.enabled: Settings.appIcons !== "colour"
170 layer.effect: Settings.appIcons === "mono" ? monoFx : dotFx
171 }
172 Component { id: dotFx; DotMask { step: c.photo ? 3 : 2; circle: c.photo } }
173 Component { id: monoFx; MultiEffect { saturation: -1 } }
174 }
175 Column {
176 id: txt
177 anchors.left: ic.right
178 anchors.leftMargin: 16
179 anchors.right: parent.right
180 anchors.rightMargin: 48
181 anchors.verticalCenter: parent.verticalCenter
182 spacing: 2
183 Lb { width: parent.width; elide: Text.ElideRight; px: 10; text: c.app + " · now" }
184 Text {
185 width: parent.width
186 elide: Text.ElideRight
187 text: c.summary
188 font.family: Theme.ui
189 font.pixelSize: 17
190 font.weight: Font.DemiBold
191 color: Theme.fg
192 }
193 Text {
194 width: parent.width
195 visible: c.body !== ""
196 text: c.body
197 textFormat: Text.StyledText
198 wrapMode: Text.Wrap
199 maximumLineCount: 2
200 elide: Text.ElideRight
201 font.family: Theme.ui
202 font.pixelSize: 14
203 color: Theme.fgDim
204 linkColor: Theme.fill
205 }
206 Flow {
207 visible: c.buttons.length > 0
208 width: parent.width
209 topPadding: 8
210 spacing: 6
211 Repeater {
212 model: c.buttons
213 delegate: Chip {
214 required property var modelData
215 text: modelData.text
216 filled: true
217 px: 10
218 onClicked: c.run(modelData)
219 }
220 }
221 }
222 }
223 // the newest one carries the live dot; on hover the dot makes room for ×
224 Rectangle {
225 visible: c.index === 0 && !ma.containsMouse && !closeBtn.hovered
226 anchors.right: parent.right
227 anchors.rightMargin: 22
228 anchors.verticalCenter: parent.verticalCenter
229 width: 10
230 height: 10
231 radius: 5
232 color: Theme.live
233 }
234 IconBtn {
235 id: closeBtn
236 readonly property bool hovered: hh.hovered
237 visible: ma.containsMouse || hovered
238 anchors.right: parent.right
239 anchors.rightMargin: 12
240 anchors.verticalCenter: parent.verticalCenter
241 implicitHeight: 30
242 icon: Theme.iClose
243 iconSize: 13
244 onClicked: c.run(null)
245 HoverHandler { id: hh }
246 }
247 }
248 }
249 }
250 }
251 }
252}