// 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 // The balloon stack, bottom-right of DP-1, 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 || [] // 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 screen: Quickshell.screens.find(s => s.name === "DP-1") ?? Quickshell.screens[0] anchors { bottom: true; right: true } margins { bottom: 12; right: 12 } implicitWidth: 340 implicitHeight: column.implicitHeight color: "transparent" exclusionMode: ExclusionMode.Ignore WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.namespace: "quickshell-notifications" WlrLayershell.keyboardFocus: WlrKeyboardFocus.None Column { id: column width: parent.width anchors { bottom: parent.bottom; right: parent.right } spacing: 8 Repeater { model: root.live NotificationBalloon { notification: modelData now: root.now borderColors: root.borderColors borderAngle: root.borderAngle } } } } }