diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-06 10:02:41 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-06 10:02:41 +0200 |
| commit | 595d42fbd4ee206d2b16d033e1be337f9c97eee7 (patch) | |
| tree | e59353ad2d43392cd8e1cf9d212140479388a450 /notifications | |
| parent | 87ed521bad8774899f5080f937665b110f44ac27 (diff) | |
| download | quickshell-595d42fbd4ee206d2b16d033e1be337f9c97eee7.tar.gz quickshell-595d42fbd4ee206d2b16d033e1be337f9c97eee7.zip | |
feat(notifications): window border on the balloons
The balloons now wear the Hyprland window border: 2px, the udt gradient
read from ~/.cache/udt/udt-border.lua (the file look_and_feel.lua reads,
watched so a wallpaper change follows), rotating, with the windows'
corners.
A Rectangle border takes one colour and its radius is circular, while the
windows use rounding 15 at rounding_power 4, a superellipse. So the body
and the border are both drawn by a Shape from an SVG path whose corners
are the cubic fit to |x|^4 + |y|^4 = 1, and the border is a ring filled
odd-even, which keeps the translucent body from showing the gradient
through.
One angle drives every balloon, so they turn together, and the
animation runs only while the stack is mapped. Hyprland's borderangle
(speed 30) reads as 3s a turn, which looked slow on a balloon that size,
so the turn is 1.5s, tuned by eye.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'notifications')
| -rw-r--r-- | notifications/Balloons.qml | 39 | ||||
| -rw-r--r-- | notifications/NotificationBalloon.qml | 61 |
2 files changed, 95 insertions, 5 deletions
diff --git a/notifications/Balloons.qml b/notifications/Balloons.qml index 3676e67..d22e4a1 100644 --- a/notifications/Balloons.qml +++ b/notifications/Balloons.qml @@ -10,6 +10,7 @@ // GNU General Public License for more details. import Quickshell +import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -35,6 +36,37 @@ Scope { // (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 @@ -65,7 +97,12 @@ Scope { Repeater { model: root.live - NotificationBalloon { notification: modelData; now: root.now } + NotificationBalloon { + notification: modelData + now: root.now + borderColors: root.borderColors + borderAngle: root.borderAngle + } } } } diff --git a/notifications/NotificationBalloon.qml b/notifications/NotificationBalloon.qml index b01a848..a9f9459 100644 --- a/notifications/NotificationBalloon.qml +++ b/notifications/NotificationBalloon.qml @@ -10,6 +10,7 @@ // GNU General Public License for more details. import QtQuick +import QtQuick.Shapes // 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. @@ -22,6 +23,8 @@ Rectangle { // silently arrives undefined and every binding on it fails. property var notification property double now: 0 + property var borderColors: [] + property real borderAngle: 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 @@ -38,10 +41,60 @@ Rectangle { 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 body is drawn by the Shape below, not by the Rectangle, so its + // corners can be Hyprland's: rounding 15 at rounding_power 4, a + // superellipse a Rectangle radius cannot draw. + color: "transparent" + + // A rounded rectangle as an SVG path, each corner the cubic that best + // fits |x|^4 + |y|^4 = 1. A circle's control points sit 0.552 of the + // radius from each end; matching the superellipse at 45 degrees, + // (4 + 3k) / 8 = 2^-1/4, puts them at k = 0.909. + function squircle(x, y, w, h, r) { + const c = r * (1 - 0.909); + const R = x + w, B = y + h; + return `M ${x + r} ${y} L ${R - r} ${y} C ${R - c} ${y} ${R} ${y + c} ${R} ${y + r}` + + ` L ${R} ${B - r} C ${R} ${B - c} ${R - c} ${B} ${R - r} ${B}` + + ` L ${x + r} ${B} C ${x + c} ${B} ${x} ${B - c} ${x} ${B - r}` + + ` L ${x} ${y + r} C ${x} ${y + c} ${x + c} ${y} ${x + r} ${y} Z`; + } + + // Hyprland's window border: 2px, the udt gradient, rotating. A Rectangle + // border takes one colour, so this is a ring, two squircles + // filled odd-even, which keeps the translucent body from showing the + // gradient through. The gradient line runs through the centre at the + // current angle and is just long enough to span the box. + // ponytail: three stops, which is what udt-accent writes; GradientStops + // cannot come from a Repeater, so a longer list would need more stops. + Shape { + id: ring + anchors.fill: parent + preferredRendererType: Shape.CurveRenderer + readonly property real dx: Math.cos(b.borderAngle) + readonly property real dy: Math.sin(b.borderAngle) + readonly property real reach: Math.abs(width / 2 * dx) + Math.abs(height / 2 * dy) + + ShapePath { + strokeWidth: -1 + fillColor: Qt.alpha(Theme.base, 0.82) + PathSvg { path: b.squircle(0, 0, ring.width, ring.height, 15) } + } + + ShapePath { + strokeWidth: -1 + fillRule: ShapePath.OddEvenFill + fillGradient: LinearGradient { + x1: ring.width / 2 - ring.dx * ring.reach + y1: ring.height / 2 - ring.dy * ring.reach + x2: ring.width / 2 + ring.dx * ring.reach + y2: ring.height / 2 + ring.dy * ring.reach + GradientStop { position: 0.0; color: b.borderColors[0] ?? Theme.accent } + GradientStop { position: 0.5; color: b.borderColors[1] ?? Theme.accent } + GradientStop { position: 1.0; color: b.borderColors[2] ?? Theme.accent } + } + PathSvg { path: b.squircle(0, 0, ring.width, ring.height, 15) + " " + b.squircle(2, 2, ring.width - 4, ring.height - 4, 13) } + } + } // The background click target is declared first so the X, declared later, // sits above it and wins its corner. |
