experimental notification focus

This commit is contained in:
Zoty 2026-06-25 08:26:57 -03:00
parent 38db696ee8
commit 4b0974e74e
Signed by: Zoty
SSH key fingerprint: SHA256:WsGEGivgl37t3Mth6uugXMOgMCTR7QolIepNbC+j/tA
5 changed files with 204 additions and 44 deletions

View file

@ -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) {