The notification background was a 6% foreground tint over a transparent window, which meant it had nothing behind it at all: the card sat directly on whatever was on screen and the text competed with it. Notifications are the one surface someone reads without having chosen to look at it, so it now uses a real popover surface -- a little lighter than one you opened deliberately, because a toast arrives unbidden and full popover weight reads as a dialog demanding an answer. Windows that ask for attention are now switched to rather than merely highlighted, so a link opening on another workspace takes you there. That setting already existed and was simply off; it is a preference rather than a hardcoded behaviour, so it can be turned back off on the Desktop & Dock page. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
317 lines
10 KiB
QML
317 lines
10 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.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()
|
|
}
|
|
|
|
Column {
|
|
id: layout
|
|
anchors.left: appIcon.visible ? appIcon.right : parent.left
|
|
anchors.leftMargin: appIcon.visible ? 10 : 14
|
|
anchors.right: image.visible ? image.left : closeButton.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
|
|
}
|
|
}
|
|
}
|