// 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; } // A command the notification carried as data, in the `panama-exec` hint. // Panama's escalation ladder rides this: a crash watcher that has already // exited, or an install that failed in a terminal, still gets a clickable // "diagnose this with your agent" -- the command IS the notification, so // nothing has to stay alive to service an action and the click survives a // shell restart. Read once at delivery; see services/Notifs.qml for why // that is safe and what it deliberately does not promise. readonly property string execCommand: Notifs.execCommand(root.notification) readonly property bool bodyActivates: root.defaultAction !== null || root.execCommand !== "" // Clicking the body runs the notification's default action, which is what // GNOME does. The sender's own action wins when a notification carries // both: an application that registered one is asking for ITS handler, and // the hint exists for senders that cannot stay alive to serve one. // // The command runs through `sh -c` because it arrives as a single string // rather than an argv -- that is the shape the hint can carry. It runs // detached, so a notification click never blocks or outlives the shell. function activateBody(): void { if (root.defaultAction) { root.defaultAction.invoke(); return; } if (root.execCommand === "") return; Quickshell.execDetached(["sh", "-c", root.execCommand]); root.dismissed(); } // Everything except the buttons. MouseArea { id: hover anchors.fill: parent hoverEnabled: true cursorShape: root.bodyActivates ? Qt.PointingHandCursor : Qt.ArrowCursor onClicked: root.activateBody() } 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 } } }