356 lines
12 KiB
QML
356 lines
12 KiB
QML
// One notification, rendered GNOME-style: app icon, app name, summary, body,
|
|
// optional image, action buttons and an inline reply box.
|
|
//
|
|
// Shared by the toasts and the notification center so a notification looks the
|
|
// same wherever you meet it.
|
|
//
|
|
// IconButton/ThemedIcon come from the quicksettings module — they belong in
|
|
// widgets/, but that directory is owned elsewhere.
|
|
|
|
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Widgets
|
|
import Quickshell.Services.Notifications
|
|
import qs.config
|
|
import qs.services
|
|
import qs.modules.quicksettings
|
|
import qs.modules.bar
|
|
import qs.widgets
|
|
|
|
Rectangle {
|
|
id: root
|
|
|
|
required property var notification
|
|
|
|
// The center renders a denser card than a toast banner.
|
|
property bool compact: false
|
|
|
|
signal dismissed
|
|
readonly property alias hovered: hover.containsMouse
|
|
// Raised when the reply field takes focus: the toast window has to ask the
|
|
// compositor for keyboard focus before anything can be typed.
|
|
signal replyFocusChanged(bool focused)
|
|
|
|
// The image sits outside `layout`, so the card has to be tall enough for
|
|
// whichever of the two is bigger.
|
|
implicitHeight: Math.max(layout.implicitHeight + 24, image.visible ? 96 : 0)
|
|
radius: Theme.cardRadius
|
|
border.width: 0
|
|
// A real popover surface, the same one the date menu and clipboard panel
|
|
// use, rather than a 6% foreground tint.
|
|
//
|
|
// A tint that faint has nothing behind it: the toast window is transparent,
|
|
// so the card was sitting directly on whatever happened to be on screen and
|
|
// the text competed with it. Notifications are the one surface someone reads
|
|
// without having chosen to look at it, so it has to be legible over a
|
|
// bright photo and a white document alike.
|
|
color: hover.containsMouse
|
|
? Theme.alpha(Theme.mix(Theme.bgPopover, Theme.fg, 0.06), Theme.toastAlpha)
|
|
: Theme.alpha(Theme.bgPopover, Theme.toastAlpha)
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: Theme.durFast
|
|
}
|
|
}
|
|
|
|
// Critical notifications get a red edge rather than a color wash, so the
|
|
// text contrast never changes.
|
|
//
|
|
// Read through Notifs.effectiveUrgency rather than off the notification, so
|
|
// an application the rules treat as critical is marked here too -- and one
|
|
// demoted to low is not. The bell, the popup timeout and the Do Not Disturb
|
|
// breakthrough all ask the same question the same way.
|
|
Rectangle {
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
anchors.bottom: parent.bottom
|
|
anchors.margins: 6
|
|
width: 3
|
|
radius: 1.5
|
|
border.width: 0
|
|
color: Theme.urgent
|
|
visible: Notifs.effectiveUrgency(root.notification) === NotificationUrgency.Critical
|
|
}
|
|
|
|
readonly property var defaultAction: {
|
|
for (const action of root.notification.actions) {
|
|
if (action.identifier === "default")
|
|
return action;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// "default" is the card click, so it never becomes a button.
|
|
readonly property bool hasButtons: {
|
|
for (const action of root.notification.actions) {
|
|
if (action.identifier !== "default")
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Everything except the buttons: clicking the body runs the notification's
|
|
// default action, which is what GNOME does.
|
|
MouseArea {
|
|
id: hover
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: root.defaultAction ? Qt.PointingHandCursor : Qt.ArrowCursor
|
|
onClicked: {
|
|
if (root.defaultAction)
|
|
root.defaultAction.invoke();
|
|
}
|
|
}
|
|
|
|
IconImage {
|
|
id: appIcon
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 14
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 13
|
|
implicitSize: 22
|
|
asynchronous: true
|
|
source: {
|
|
if (root.notification.appIcon !== "")
|
|
return Quickshell.iconPath(root.notification.appIcon, true);
|
|
const entry = DesktopEntries.byId(root.notification.desktopEntry);
|
|
return entry ? Quickshell.iconPath(entry.icon, true) : "";
|
|
}
|
|
visible: source !== ""
|
|
}
|
|
|
|
IconButton {
|
|
id: closeButton
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 6
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 6
|
|
size: 24
|
|
iconSize: 12
|
|
tint: Theme.fgDim
|
|
icon: "window-close-symbolic"
|
|
onClicked: root.dismissed()
|
|
}
|
|
|
|
IconButton {
|
|
id: settingsMenuButton
|
|
anchors.right: closeButton.left
|
|
anchors.rightMargin: 2
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 6
|
|
size: 24
|
|
iconSize: 14
|
|
tint: Theme.fgDim
|
|
icon: "view-more-symbolic"
|
|
iconFallback: "open-menu-symbolic"
|
|
onClicked: settingsMenu.visible = !settingsMenu.visible
|
|
}
|
|
|
|
Popover {
|
|
id: settingsMenu
|
|
anchorItem: settingsMenuButton
|
|
|
|
// A Column already measures itself from its children, and in Qt 6 both
|
|
// implicit sizes are read-only on a positioner -- assigning them makes
|
|
// the whole shell fail to load rather than just this menu.
|
|
Column {
|
|
TrayMenuRow {
|
|
id: notificationSettings
|
|
label: "Notification settings"
|
|
onActivated: {
|
|
ShellState.openSettings("notifications");
|
|
settingsMenu.visible = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
id: layout
|
|
anchors.left: appIcon.visible ? appIcon.right : parent.left
|
|
anchors.leftMargin: appIcon.visible ? 10 : 14
|
|
anchors.right: image.visible ? image.left : settingsMenuButton.left
|
|
anchors.rightMargin: 8
|
|
anchors.top: parent.top
|
|
anchors.topMargin: 12
|
|
spacing: 3
|
|
|
|
Text {
|
|
width: parent.width
|
|
text: {
|
|
const app = root.notification.appName || "Notification";
|
|
const time = Notifs.timeText(root.notification.id);
|
|
return time === "" ? app : app + " · " + time;
|
|
}
|
|
color: Theme.fgMuted
|
|
elide: Text.ElideRight
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: text !== ""
|
|
text: root.notification.summary
|
|
color: Theme.fg
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 2
|
|
wrapMode: Text.Wrap
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
font.weight: Font.DemiBold
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: text !== ""
|
|
text: root.notification.body
|
|
color: Theme.fgDim
|
|
// The server advertises bodyMarkupSupported, so bodies arrive with
|
|
// the freedesktop subset of HTML in them.
|
|
textFormat: Text.StyledText
|
|
linkColor: Theme.accent
|
|
elide: Text.ElideRight
|
|
wrapMode: Text.Wrap
|
|
maximumLineCount: root.compact ? 6 : 3
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
|
|
Item {
|
|
width: 1
|
|
height: 4
|
|
visible: actionRow.visible || replyBox.visible
|
|
}
|
|
|
|
Flow {
|
|
id: actionRow
|
|
width: parent.width
|
|
spacing: 6
|
|
visible: root.hasButtons
|
|
|
|
Repeater {
|
|
model: root.notification.actions
|
|
|
|
Rectangle {
|
|
id: actionButton
|
|
|
|
required property var modelData
|
|
|
|
visible: actionButton.modelData.identifier !== "default"
|
|
width: visible ? actionLabel.implicitWidth + 22 : 0
|
|
height: visible ? 28 : 0
|
|
radius: Theme.pillRadius
|
|
border.width: 0
|
|
color: actionMouse.containsMouse ? Theme.alpha(Theme.accent, 0.28) : Theme.alpha(Theme.fg, 0.1)
|
|
|
|
Behavior on color {
|
|
ColorAnimation {
|
|
duration: Theme.durFast
|
|
}
|
|
}
|
|
|
|
Text {
|
|
id: actionLabel
|
|
anchors.centerIn: parent
|
|
text: actionButton.modelData.text
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
font.weight: Font.Medium
|
|
}
|
|
|
|
MouseArea {
|
|
id: actionMouse
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: actionButton.modelData.invoke()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
id: replyBox
|
|
width: parent.width
|
|
height: 36
|
|
visible: root.notification.hasInlineReply
|
|
radius: Theme.cardRadius - 2
|
|
border.width: 1
|
|
border.color: replyInput.activeFocus ? Theme.alpha(Theme.accent, 0.5) : Theme.alpha(Theme.fg, 0.12)
|
|
color: Theme.alpha(Theme.fg, 0.08)
|
|
|
|
TextInput {
|
|
id: replyInput
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: 10
|
|
anchors.right: replySend.left
|
|
anchors.rightMargin: 4
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: Theme.fg
|
|
selectionColor: Theme.alpha(Theme.accent, 0.5)
|
|
selectedTextColor: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
clip: true
|
|
|
|
onActiveFocusChanged: root.replyFocusChanged(replyInput.activeFocus)
|
|
|
|
onAccepted: {
|
|
if (replyInput.text.length === 0)
|
|
return;
|
|
root.notification.sendInlineReply(replyInput.text);
|
|
replyInput.text = "";
|
|
root.dismissed();
|
|
}
|
|
|
|
Text {
|
|
anchors.fill: parent
|
|
verticalAlignment: Text.AlignVCenter
|
|
visible: replyInput.text === ""
|
|
text: root.notification.inlineReplyPlaceholder || "Reply"
|
|
color: Theme.fgMuted
|
|
font: replyInput.font
|
|
}
|
|
}
|
|
|
|
IconButton {
|
|
id: replySend
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 3
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
size: 28
|
|
iconSize: 14
|
|
icon: "go-next-symbolic"
|
|
onClicked: replyInput.accepted()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notification images (album art, avatars, screenshots) sit on the right.
|
|
ClippingRectangle {
|
|
id: image
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 14
|
|
anchors.top: closeButton.bottom
|
|
anchors.topMargin: 4
|
|
implicitWidth: 52
|
|
implicitHeight: 52
|
|
radius: 8
|
|
border.width: 0
|
|
color: "transparent"
|
|
visible: root.notification.image !== ""
|
|
|
|
Image {
|
|
anchors.fill: parent
|
|
source: root.notification.image
|
|
fillMode: Image.PreserveAspectCrop
|
|
asynchronous: true
|
|
sourceSize.width: 104
|
|
sourceSize.height: 104
|
|
}
|
|
}
|
|
}
|