// 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. 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: root.notification.urgency === 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 } } }