aboutsummaryrefslogtreecommitdiffstats
path: root/notifications/NotificationBalloon.qml
blob: bfccda3fe457aea861c92b8f9e3caaedee56b43f (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
// 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

// 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

    // 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)
    radius: 10
    color: Qt.alpha(Theme.base, 0.82)
    border.width: 1
    border.color: Qt.alpha(Theme.text, 0.12)

    // 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.RichText
            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
    }
}