// Copyright (C) 2026 Danilo M. // // 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 } } } } }