From 036e501e589ecaccc6922ff3f517d70b4212a5c5 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 17:35:03 +0200 Subject: [PATCH] Show explicit choices on notification cards --- .../notifications/NotificationLogic.js | 11 ++++++++- shell/plugins/notifications/Service.qml | 22 +++++++++++++++++- .../components/NotificationCard.qml | 23 +++++++++++++++++++ test/shell.d/notifications-test.sh | 4 ++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/shell/plugins/notifications/NotificationLogic.js b/shell/plugins/notifications/NotificationLogic.js index e4d69a53..e69f1e06 100644 --- a/shell/plugins/notifications/NotificationLogic.js +++ b/shell/plugins/notifications/NotificationLogic.js @@ -185,6 +185,12 @@ function snapshotOf(notification, timestamp) { var id = n.id || 0 var expireTimeout = Number(n.expireTimeout || 0) if (!isFinite(expireTimeout) || expireTimeout < 0) expireTimeout = 0 + var actions = [] + for (var i = 0; n.actions && i < n.actions.length; i++) { + var action = n.actions[i] + if (action && action.identifier && action.identifier !== "default") + actions.push({ identifier: String(action.identifier), text: String(action.text || action.identifier) }) + } return { id: id, originalId: id, @@ -195,6 +201,7 @@ function snapshotOf(notification, timestamp) { image: n.image || "", glyph: glyphFromHints(n.hints), execArgv: execArgvFromHints(n.hints), + actionsJson: JSON.stringify(actions), // Pinyin's first-use cloud prediction question needs an explicit answer. // Its normal urgency otherwise dismisses it before users can read it. urgency: n.appName === "Pinyin" && n.appIcon === "fcitx-pinyin" ? 2 : n.urgency, @@ -205,7 +212,7 @@ function snapshotOf(notification, timestamp) { // Everything the popup card draws, and therefore everything an in-place // update has to write through to the row and its file. -var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"] +var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "actionsJson", "urgency", "expireTimeout"] function popupRoles() { return POPUP_ROLES @@ -265,6 +272,8 @@ function historyEntry(value, normalUrgency) { image: e.image || "", glyph: e.glyph || "", execArgv: e.execArgv || "", + // Libnotify actions belong to a live sender; never replay stale buttons. + actionsJson: "[]", urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency, expireTimeout: 0, timestamp: e.timestamp || 0 diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index cba13cbe..72706f77 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -235,7 +235,7 @@ Item { // about after the popup exists. readonly property var updateSignals: [ "summaryChanged", "bodyChanged", "appNameChanged", "appIconChanged", - "imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged" + "imageChanged", "urgencyChanged", "expireTimeoutChanged", "hintsChanged", "actionsChanged" ] // A client that updates a notification through replaces_id does not produce @@ -427,10 +427,26 @@ Item { // libnotify action — they just expect clicking the notification to // focus their window. Fall back to focusing the sending app by class so // that click-to-jump actually works. + if (!invoked && entry.actionsJson && entry.actionsJson !== "[]") return if (!invoked) focusApp(entry) dismissPopup(index) } + function invokePopupAction(index, identifier) { + if (index < 0 || index >= popupModel.count) return + var entry = popupModel.get(index) + var ref = entry && !isRestoredRow(entry) ? liveRefs[entry.originalId] : null + if (!ref || !ref.actions) return + for (var i = 0; i < ref.actions.length; i++) { + var action = ref.actions[i] + if (action && action.identifier === identifier) { + action.invoke() + dismissPopup(index) + return + } + } + } + // Try to focus an existing Hyprland window matching the notification's // sender. The helper handles case-insensitive class matching. function focusApp(entry) { @@ -726,6 +742,7 @@ Item { image: "", glyph: "󰂚", execArgv: "", + actionsJson: "[]", urgency: NotificationUrgency.Low, expireTimeout: 0, timestamp: Date.now() @@ -1044,6 +1061,7 @@ Item { required property string body required property string image required property string glyph + required property string actionsJson required property int urgency required property double expireTimeout required property double timestamp @@ -1095,9 +1113,11 @@ Item { cornerRadius: service.cornerRadius fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : "" glyph: cardSlot.glyph + actions: JSON.parse(cardSlot.actionsJson || "[]") onCloseRequested: service.dismissPopup(cardSlot.index) onCardClicked: service.invokePopupDefault(cardSlot.index) + onActionClicked: identifier => service.invokePopupAction(cardSlot.index, identifier) } } } diff --git a/shell/plugins/notifications/components/NotificationCard.qml b/shell/plugins/notifications/components/NotificationCard.qml index d7df8d83..6b3a916d 100644 --- a/shell/plugins/notifications/components/NotificationCard.qml +++ b/shell/plugins/notifications/components/NotificationCard.qml @@ -26,6 +26,7 @@ BorderSurface { property int urgency: 1 property double timestamp: 0 property int cornerRadius: 0 + property var actions: [] // System monospace font injected by the container. property string fontFamily: "" @@ -34,6 +35,7 @@ BorderSurface { signal closeRequested() signal cardClicked() + signal actionClicked(string identifier) // Prefer per-notification media/avatar data, then fall back to the app icon. // The `check` flag avoids Qt's missing-texture placeholder for unknown names. readonly property string smallIconSource: image.length > 0 ? image : iconSource(appIcon) @@ -193,6 +195,27 @@ BorderSurface { } } } + + Flow { + Layout.fillWidth: true + Layout.leftMargin: Style.space(12) + Layout.rightMargin: Style.space(12) + Layout.bottomMargin: visible ? Style.space(10) : 0 + visible: root.actions.length > 0 + spacing: Style.space(8) + + Repeater { + model: root.actions + delegate: Button { + required property var modelData + text: modelData.text + bordered: true + foreground: Color.notifications.text + fontFamily: root.fontFamily + onClicked: root.actionClicked(modelData.identifier) + } + } + } } // Hover-revealed close. Stacked after mainColumn so its MouseArea sits diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 52665b0a..ec3ad253 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -271,6 +271,10 @@ const notification = { expireTimeout: 1.5 } const snapshot = notifications.snapshotOf(notification, 12345) +assertDeepEqual(JSON.parse(notifications.snapshotOf({ actions: [ + { identifier: 'default', text: 'Open' }, { identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' } +]}).actionsJson), [{ identifier: 'yes', text: 'Yes' }, { identifier: 'no', text: 'No' }], 'explicit choices appear independently of the default card action') +assertEqual(notifications.popupEntry({ actionsJson: '[{"identifier":"yes","text":"Yes"}]' }, 1).actionsJson, '[]', 'restored notifications do not replay actions from a dead sender') assertEqual(notifications.snapshotOf({ appName: 'Pinyin', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 2, 'Pinyin prompts stay visible until dismissed') assertEqual(notifications.snapshotOf({ appName: 'Other', appIcon: 'fcitx-pinyin', urgency: 1 }).urgency, 1, 'other notification senders retain their urgency') assertDeepEqual(