Files
Gabriel Brown 54dfa977ee The notification menu learns what every other popover already knew
Popover's container is a plain Item and never measures its children, so
the three-dots menu on a notification card opened as a 240x28 sliver
with its one row clipped out of existence. TrayMenu and AgentUsagePanel
both carry the antidote — implicit sizes on the Popover itself, taken
from the content column — and now the notification settings menu does
too, with rows filling the window width so the hover reaches the edges.

Claude-Session: https://claude.ai/code/session_01W8icivxZcmFTR2g6DkYhjT
2026-08-26 19:50:31 -04:00

390 lines
13 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;
}
// 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
// Popover's container is a plain Item and does not size itself from its
// children, so the window dimensions come from the column's implicit
// size — the same pattern TrayMenu uses. Rows take their *actual* width
// from the window in the other direction; the two chains are
// independent, so there is no binding loop.
implicitWidth: Math.max(menuBody.implicitWidth + contentPadding * 2, 200)
implicitHeight: menuBody.implicitHeight + contentPadding * 2
Column {
id: menuBody
width: parent.width
TrayMenuRow {
width: parent.width
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
}
}
}