From 4b0974e74e8217028d1be351d8bdc81cb2f645de Mon Sep 17 00:00:00 2001 From: Zoty Date: Thu, 25 Jun 2026 08:26:57 -0300 Subject: [PATCH] experimental notification focus --- quickshell/NotificationItem.qml | 18 ++-- quickshell/NotificationPanel.qml | 18 ++-- quickshell/NotificationToast.qml | 18 ++-- quickshell/NotificationsState.qml | 141 ++++++++++++++++++++++++------ quickshell/notif-pid-monitor.sh | 53 +++++++++++ 5 files changed, 204 insertions(+), 44 deletions(-) create mode 100755 quickshell/notif-pid-monitor.sh diff --git a/quickshell/NotificationItem.qml b/quickshell/NotificationItem.qml index 082b87d..d06de83 100644 --- a/quickshell/NotificationItem.qml +++ b/quickshell/NotificationItem.qml @@ -9,6 +9,8 @@ Item { required property string summary required property string body required property string appIcon + required property string image + required property string desktopEntry required property int urgency required property var timestamp @@ -52,10 +54,15 @@ Item { id: iconImg anchors.fill: parent anchors.margins: 5 - source: root.appIcon - fillMode: Image.PreserveAspectFit - visible: status === Image.Ready - smooth: true + source: { + if (root.image) return root.image + if (!root.appIcon) return "" + if (root.appIcon.startsWith("/")) return root.appIcon + return Quickshell.iconPath(root.appIcon, true) || "" + } + fillMode: Image.PreserveAspectFit + visible: status === Image.Ready && source !== "" + smooth: true } Text { @@ -98,6 +105,7 @@ Item { Text { visible: root.body.length > 0 text: root.body + textFormat: Text.StyledText color: Theme.fg2 font.pixelSize: Theme.fontSizeSmall font.family: Theme.font @@ -158,7 +166,7 @@ Item { hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { - NotificationsState.invokeDefault(root.nid) + NotificationsState.invokeDefault(root.nid, root.desktopEntry || root.appName) root.dismissed() } } diff --git a/quickshell/NotificationPanel.qml b/quickshell/NotificationPanel.qml index 50dafb4..1977ac9 100644 --- a/quickshell/NotificationPanel.qml +++ b/quickshell/NotificationPanel.qml @@ -141,14 +141,16 @@ PanelWindow { required property var model required property int index - width: list.width - nid: model.nid - appName: model.appName - summary: model.summary - body: model.body - appIcon: model.appIcon - urgency: model.urgency - timestamp: model.timestamp + width: list.width + nid: model.nid + appName: model.appName + summary: model.summary + body: model.body + appIcon: model.appIcon + image: model.image + desktopEntry: model.desktopEntry + urgency: model.urgency + timestamp: model.timestamp onDismissed: NotificationsState.dismiss(index) } diff --git a/quickshell/NotificationToast.qml b/quickshell/NotificationToast.qml index 068a02d..6864c81 100644 --- a/quickshell/NotificationToast.qml +++ b/quickshell/NotificationToast.qml @@ -60,10 +60,17 @@ PanelWindow { id: tIcon anchors.fill: parent anchors.margins: 5 - source: model.appIcon - fillMode: Image.PreserveAspectFit - visible: status === Image.Ready - smooth: true + // Priority: notification image (avatar/screenshot) > app icon. + // appIcon can be a path or a theme name; iconPath() resolves theme names. + source: { + if (model.image) return model.image + if (!model.appIcon) return "" + if (model.appIcon.startsWith("/")) return model.appIcon + return Quickshell.iconPath(model.appIcon, true) || "" + } + fillMode: Image.PreserveAspectFit + visible: status === Image.Ready && source !== "" + smooth: true } Text { @@ -106,6 +113,7 @@ PanelWindow { Text { visible: model.body.length > 0 text: model.body + textFormat: Text.StyledText color: Theme.fg2 font.pixelSize: Theme.fontSizeSmall font.family: Theme.font @@ -161,7 +169,7 @@ PanelWindow { anchors.rightMargin: 34 cursorShape: Qt.PointingHandCursor onClicked: { - NotificationsState.invokeDefault(model.tid) + NotificationsState.invokeDefault(model.tid, model.desktopEntry || model.appName) NotificationsState.dismissToast(model.tid) } } diff --git a/quickshell/NotificationsState.qml b/quickshell/NotificationsState.qml index 2efa017..83516f8 100644 --- a/quickshell/NotificationsState.qml +++ b/quickshell/NotificationsState.qml @@ -1,5 +1,6 @@ pragma Singleton import Quickshell +import Quickshell.Io import Quickshell.Services.Notifications import QtQuick @@ -10,7 +11,8 @@ Singleton { property int unreadCount: 0 property ListModel notifications: ListModel {} - property var _refs: ({}) // maps nid to the live Notification object + property var _refs: ({}) // maps nid → live Notification object + property var _pids: ({}) // maps nid → sender process PID (from D-Bus monitor) property ListModel toasts: ListModel {} @@ -36,6 +38,43 @@ Singleton { } } + // Executes a niri focus-window command + Process { + id: _focusProc + } + + // Monitors D-Bus for Notify calls and outputs "nid:sender_pid" so we can + // walk /proc and focus the exact window that sent each notification. + Process { + id: _pidMonitor + command: ["bash", String(Qt.resolvedUrl("notif-pid-monitor.sh")).replace("file://", "")] + running: true + + stdout: SplitParser { + onRead: line => { + const parts = line.split(":") + if (parts.length !== 2) return + const nid = parseInt(parts[0]) + const pid = parts[1].trim() + if (!isNaN(nid) && pid) { + const p = root._pids + p[nid] = pid + root._pids = p + } + } + } + + onExited: (exitCode, exitStatus) => _pidRestartTimer.start() + } + + // Restarts the D-Bus monitor if dbus-monitor exits unexpectedly (e.g. session bus restart). + Timer { + id: _pidRestartTimer + interval: 1000 + repeat: false + onTriggered: { _pidMonitor.running = false; _pidMonitor.running = true } + } + NotificationServer { keepOnReload: true @@ -44,14 +83,16 @@ Singleton { const day = root._dayLabel(ts) root.notifications.insert(0, { - nid: notif.id, - appName: notif.appName || "Unknown", - summary: notif.summary || "", - body: notif.body || "", - appIcon: notif.appIcon || "", - urgency: notif.urgency || 1, - timestamp: ts, - day: day + nid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + appIcon: notif.appIcon || "", + image: notif.image || "", + desktopEntry: notif.desktopEntry || "", + urgency: notif.urgency || 1, + timestamp: ts, + day: day }) const r = root._refs @@ -61,13 +102,15 @@ Singleton { if (!root.panelOpen) root.unreadCount++ root.toasts.insert(0, { - tid: notif.id, - appName: notif.appName || "Unknown", - summary: notif.summary || "", - body: notif.body || "", - appIcon: notif.appIcon || "", - urgency: notif.urgency || 1, - progress: 1.0 + tid: notif.id, + appName: notif.appName || "Unknown", + summary: notif.summary || "", + body: notif.body || "", + appIcon: notif.appIcon || "", + image: notif.image || "", + desktopEntry: notif.desktopEntry || "", + urgency: notif.urgency || 1, + progress: 1.0 }) } } @@ -82,6 +125,9 @@ Singleton { delete r[entry.nid] _refs = r } + const p = _pids + delete p[entry.nid] + _pids = p notifications.remove(index, 1) } @@ -92,22 +138,65 @@ Singleton { } notifications.clear() _refs = {} + _pids = {} unreadCount = 0 } - // Invokes the "default" action on a notification, falling back to the first action - function invokeDefault(nid) { + // Invokes the D-Bus action (tells the app which chat/content to open) and + // always also focuses the window via niri IPC. + // invoke() alone cannot steal focus on Wayland without an XDG activation token; + // niri IPC bypasses that restriction because the compositor grants focus directly. + // entry = desktopEntry || appName, passed from the model so it works even after + // a hot-reload clears _refs. + function invokeDefault(nid, entry) { + // Send ActionInvoked to the app if we still have a live reference. const ref = _refs[nid] - if (!ref) return - const actions = ref.actions - if (!actions || actions.length === 0) return - for (let i = 0; i < actions.length; i++) { - if (actions[i].identifier === "default") { - try { actions[i].invoke() } catch (_) {} - return + if (ref) { + const actions = ref.actions ? Array.from(ref.actions) : [] + if (actions.length > 0) { + let found = false + for (let i = 0; i < actions.length; i++) { + if (actions[i].identifier === "default") { + try { actions[i].invoke() } catch (_) {} + found = true + break + } + } + if (!found) { + try { actions[0].invoke() } catch (_) {} + } } } - try { actions[0].invoke() } catch (_) {} + + // Bring the window to front via niri IPC. + const senderPid = _pids[nid] + if (senderPid) { + // Walk /proc from the sender PID upward until we find a PID that owns + // a niri window, then focus that window. This correctly handles multiple + // instances of the same app (e.g. several kitty terminals). + _focusProc.command = [ + "bash", "-c", + 'P=' + senderPid + '; ' + + 'while [ "$P" -gt 1 ]; do ' + + ' WIN=$(niri msg --json windows | jq -r --arg p "$P" \'.[] | select(.pid == ($p | tonumber)) | .id // empty\'); ' + + ' if [ -n "$WIN" ]; then niri msg action focus-window --id "$WIN"; exit 0; fi; ' + + ' P=$(awk \'/^PPid:/{print $2}\' /proc/"$P"/status 2>/dev/null); ' + + ' [ -z "$P" ] && exit 1; ' + + 'done' + ] + } else if (entry) { + // Fallback when no PID was captured: match by app_id, pick most recently focused. + _focusProc.command = [ + "bash", "-c", + 'WID=$(niri msg --json windows | jq -r --arg id "' + entry + '" ' + + '\'[.[] | select(.app_id == $id)] | sort_by(.focus_timestamp.secs, .focus_timestamp.nanos) | last | .id // empty\'); ' + + '[ -n "$WID" ] && niri msg action focus-window --id "$WID"' + ] + } else { + return + } + _focusProc.running = false + _focusProc.running = true } function dismissToast(tid) { diff --git a/quickshell/notif-pid-monitor.sh b/quickshell/notif-pid-monitor.sh new file mode 100755 index 0000000..3c287b1 --- /dev/null +++ b/quickshell/notif-pid-monitor.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Monitors D-Bus for org.freedesktop.Notifications.Notify method calls. +# For each notification, captures the sender process PID and outputs: +# : +# Quickshell reads this to know which process originated each notification, +# allowing it to walk the /proc tree and focus the exact window that sent it. + +get_pid() { + busctl call org.freedesktop.DBus /org/freedesktop/DBus \ + org.freedesktop.DBus GetConnectionUnixProcessID s "$1" 2>/dev/null \ + | awk '{print $2}' +} + +# Maps serial → pid so concurrent notifications don't clobber each other. +declare -A pending_pids + +awaiting_nid=false +pending_pid="" + +dbus-monitor --session "dest='org.freedesktop.Notifications'" 2>/dev/null | \ +while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" # trim leading whitespace + + if [[ "$line" == *"method call"* && "$line" == *"member=Notify"* ]]; then + if [[ "$line" =~ sender=([^[:space:],;]+) ]]; then sender="${BASH_REMATCH[1]}"; fi + if [[ "$line" =~ serial=([0-9]+) ]]; then serial="${BASH_REMATCH[1]}"; fi + if [[ "$sender" == :* && -n "$serial" ]]; then + pid=$(get_pid "$sender") + if [[ -n "$pid" && "$pid" != "0" ]]; then + pending_pids["$serial"]="$pid" + fi + fi + + elif [[ "$line" == *"method return"* ]]; then + if [[ "$line" =~ reply_serial=([0-9]+) ]]; then + reply="${BASH_REMATCH[1]}" + if [[ -n "${pending_pids[$reply]}" ]]; then + awaiting_nid=true + pending_pid="${pending_pids[$reply]}" + unset "pending_pids[$reply]" + fi + fi + + elif [[ "$awaiting_nid" == true ]]; then + if [[ "$line" =~ ^uint32[[:space:]]+([0-9]+) ]]; then + echo "${BASH_REMATCH[1]}:${pending_pid}" + awaiting_nid=false + pending_pid="" + elif [[ -n "$line" ]]; then + awaiting_nid=false + fi + fi +done