aboutsummaryrefslogtreecommitdiffstats
path: root/notifications/NotificationBalloon.qml
blob: a9f94592a5f7eeaebd3fc44b3421c28983ae7eb9 (plain)
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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
// 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 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.
Rectangle {
    id: b

    // Neither property is `required` on purpose. A required property makes
    // QML create the delegate in its own context, where `modelData` and
    // `index` are not defined, so the Repeater's `notification: modelData`
    // 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
    // everything, DND withholds low and normal but still shows critical. The
    // drawer lists a notification DND chose not to pop, because a list the
    // user opened is not an interruption.
    visible: {
        if (Notify.drawerOpen) return false;
        if (Notify.snoozeUntil > b.now) return false;
        if (Notify.dnd && b.notification.urgency !== "critical") return false;
        if (b.notification.expires !== 0 && b.now >= b.notification.expires) return false;
        return true;
    }

    width: parent ? parent.width : 340
    implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0)
    // 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.
    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.LeftButton | Qt.RightButton
        cursorShape: Qt.PointingHandCursor
        onClicked: mouse => {
            if (mouse.button === Qt.RightButton) {
                Notify.closeAll();
                return;
            }
            const acts = b.notification.actions || [];
            const inert = b.notification.expires !== 0 && Date.now() >= b.notification.expires;
            if (acts.length > 0 && !inert) Notify.actions(b.notification.id, acts);
            else Notify.close(b.notification.id);
        }
    }

    Image {
        id: icon
        visible: b.notification.icon !== "" && b.notification.icon !== undefined
        anchors { left: parent.left; top: parent.top; margins: 10 }
        width: 32
        height: 32
        source: visible ? "file://" + b.notification.icon : ""
        sourceSize { width: 64; height: 64 }
    }

    Text {
        id: close
        anchors { right: parent.right; top: parent.top; margins: 6 }
        width: 20
        height: 20
        text: "\uf00d"
        horizontalAlignment: Text.AlignHCenter
        verticalAlignment: Text.AlignVCenter
        font { family: Theme.iconFamily; pixelSize: 13 }
        color: closeArea.containsMouse ? Theme.red : Theme.subtext

        MouseArea {
            id: closeArea
            anchors.fill: parent
            anchors.margins: -6
            hoverEnabled: true
            cursorShape: Qt.PointingHandCursor
            onClicked: Notify.close(b.notification.id)
        }
    }

    Column {
        id: texts
        anchors {
            left: icon.visible ? icon.right : parent.left
            leftMargin: 10
            right: parent.right
            rightMargin: 10
            top: parent.top
            topMargin: 10
        }
        spacing: 2

        Text {
            width: parent.width
            text: b.notification.app || ""
            elide: Text.ElideRight
            font { family: Theme.fontFamily; pixelSize: Theme.fontSize; bold: true }
            color: Theme.subtext
        }

        Text {
            width: parent.width
            text: b.notification.summary || ""
            elide: Text.ElideRight
            font { family: Theme.fontFamily; pixelSize: Theme.fontSize + 2 }
            color: Theme.text
        }

        Text {
            width: parent.width
            visible: text !== ""
            text: Notify.sanitize(b.notification.body)
            textFormat: Text.MarkdownText
            wrapMode: Text.WordWrap
            maximumLineCount: 3
            elide: Text.ElideRight
            font { family: Theme.fontFamily; pixelSize: Theme.fontSize }
            color: Theme.subtext
        }
    }

    // The content image (a screenshot or an app-provided image), below the
    // text. The daemon writes the path; an older daemon without the field
    // leaves this hidden. The height matches the scaled width so
    // PreserveAspectFit does not letterbox, and a tall screenshot is capped at
    // 240px. Asynchronous so a large screenshot does not stall the shell.
    Image {
        id: preview
        visible: b.notification.image !== "" && b.notification.image !== undefined
        anchors {
            left: parent.left
            right: parent.right
            top: texts.bottom
            leftMargin: 10
            rightMargin: 10
            topMargin: 8
        }
        height: visible && implicitWidth > 0
            ? Math.min(width * implicitHeight / implicitWidth, 240)
            : 0
        source: visible ? "file://" + b.notification.image : ""
        fillMode: Image.PreserveAspectFit
        asynchronous: true
        cache: false
    }
}