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
|
// 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 Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import "NotifyConfig.js" as NC
// The balloon stack, in the corner and on the screen notifyd's config.json
// names (bottom-right of DP-1 by default), over conky.
Scope {
id: root
// A tick drives both expiry and the end of a snooze without waiting for a
// file change. 250ms keeps ronema's -t 1 near-instant.
property double now: Date.now()
Timer {
interval: 250
running: true
repeat: true
onTriggered: root.now = Date.now()
}
// The Repeater model is Notify.queue itself, so its identity only changes
// when the daemon rewrites the file, not on the 250ms tick. Expiry and
// snooze are the balloon's own business: the tick reassigns `now`, each
// balloon re-evaluates its visible binding and leaves the column at
// expiry. Rebuilding the model on the tick would recreate every delegate
// (reloading icons, resetting hover) four times a second.
readonly property var live: Notify.queue || []
// A sound per new or replaced notification (replace keeps the id and
// changes created), played only when its balloon would show now: the
// same drawer, snooze, mute and DND checks as NotificationBalloon. The
// first read after a start only records what is there, so a restart is
// silent. seen is rebuilt from the queue each time, so it never grows.
property var seen: ({})
property bool primed: false
Connections {
target: Notify
function onQueueChanged() {
const next = {};
let file = "";
for (const n of Notify.queue || []) {
const key = n.id + ":" + n.created;
next[key] = true;
if (!root.primed || root.seen[key] || file !== "") continue;
if (Notify.drawerOpen || Notify.snoozeUntil > Date.now()) continue;
if (NC.withheld(Notify.config, n, Notify.dnd)) continue;
file = NC.soundFor(Notify.config, n);
}
root.seen = next;
root.primed = true;
if (file !== "") {
// ponytail: one player, a new sound cuts the last; a queue of
// sounds if bursts ever need every one heard.
player.command = ["pw-play", file];
player.running = false;
player.running = true;
}
}
}
Process { id: player }
// The window border gradient, from the same file Hyprland reads, so the
// balloons wear the windows' border. udt-accent writes it as Hyprland
// colours, `rgb(rrggbb)` or `rgba(rrggbbaa)`; Qt wants `#aarrggbb`. The
// fallback is the triple look_and_feel.lua falls back to.
property var borderColors: ["#c6a0f6", "#b7bdf8", "#8aadf4"]
FileView {
path: `${Quickshell.env("HOME")}/.cache/udt/udt-border.lua`
watchChanges: true
onFileChanged: reload()
onLoaded: {
const re = /rgba?\(([0-9a-fA-F]{6})([0-9a-fA-F]{2})?\)/g;
const out = [];
let m;
while ((m = re.exec(text())) !== null) out.push("#" + (m[2] ?? "") + m[1]);
if (out.length > 0) root.borderColors = out;
}
}
// Hyprland's borderangle is speed 30, linear, loop, which reads as 3s a
// turn, but on a balloon that size looked slow next to the windows, so
// the turn is tuned by eye rather than taken from the config. One angle for every balloon, so they turn together like the
// windows do, and only while the stack is mapped.
property real borderAngle: 0
NumberAnimation on borderAngle {
from: 0
to: 2 * Math.PI
duration: 1500
loops: Animation.Infinite
running: win.visible
}
PanelWindow {
id: win
// From the queue, never from layout: a hidden window stops polishing,
// and Column computes implicitHeight during polish, so deriving
// visibility from it would never turn true again after the first
// hide. Queue membership is a plain property the binding re-reads
// whether or not the window is mapped, so a later notification
// re-maps it. Expired entries that stay in the queue keep a
// zero-height window mapped, which is harmless.
visible: root.live.length > 0
readonly property var place: Notify.config.balloons
readonly property bool atTop: place.corner.startsWith("top")
readonly property bool atLeft: place.corner.endsWith("left")
screen: Quickshell.screens.find(s => s.name === place.screen) ?? Quickshell.screens[0]
anchors { top: atTop; bottom: !atTop; left: atLeft; right: !atLeft }
margins { top: 12; bottom: 12; left: 12; right: 12 }
implicitWidth: 340
implicitHeight: column.implicitHeight
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "keystone-notifications"
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
Column {
id: column
width: parent.width
spacing: 8
Repeater {
model: root.live
NotificationBalloon {
notification: modelData
now: root.now
borderColors: root.borderColors
borderAngle: root.borderAngle
}
}
}
}
}
|