aboutsummaryrefslogtreecommitdiffstats
path: root/notifications/NotificationBalloon.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-06 10:02:41 +0200
committerDanilo M. <danix@danix.xyz>2026-10-06 10:02:41 +0200
commit595d42fbd4ee206d2b16d033e1be337f9c97eee7 (patch)
treee59353ad2d43392cd8e1cf9d212140479388a450 /notifications/NotificationBalloon.qml
parent87ed521bad8774899f5080f937665b110f44ac27 (diff)
downloadquickshell-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/NotificationBalloon.qml')
-rw-r--r--notifications/NotificationBalloon.qml61
1 files changed, 57 insertions, 4 deletions
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.