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

@ -9,6 +9,8 @@ Item {
required property string summary required property string summary
required property string body required property string body
required property string appIcon required property string appIcon
required property string image
required property string desktopEntry
required property int urgency required property int urgency
required property var timestamp required property var timestamp
@ -52,9 +54,14 @@ Item {
id: iconImg id: iconImg
anchors.fill: parent anchors.fill: parent
anchors.margins: 5 anchors.margins: 5
source: root.appIcon 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 fillMode: Image.PreserveAspectFit
visible: status === Image.Ready visible: status === Image.Ready && source !== ""
smooth: true smooth: true
} }
@ -98,6 +105,7 @@ Item {
Text { Text {
visible: root.body.length > 0 visible: root.body.length > 0
text: root.body text: root.body
textFormat: Text.StyledText
color: Theme.fg2 color: Theme.fg2
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.family: Theme.font font.family: Theme.font
@ -158,7 +166,7 @@ Item {
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
NotificationsState.invokeDefault(root.nid) NotificationsState.invokeDefault(root.nid, root.desktopEntry || root.appName)
root.dismissed() root.dismissed()
} }
} }

View file

@ -147,6 +147,8 @@ PanelWindow {
summary: model.summary summary: model.summary
body: model.body body: model.body
appIcon: model.appIcon appIcon: model.appIcon
image: model.image
desktopEntry: model.desktopEntry
urgency: model.urgency urgency: model.urgency
timestamp: model.timestamp timestamp: model.timestamp

View file

@ -60,9 +60,16 @@ PanelWindow {
id: tIcon id: tIcon
anchors.fill: parent anchors.fill: parent
anchors.margins: 5 anchors.margins: 5
source: model.appIcon // 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 fillMode: Image.PreserveAspectFit
visible: status === Image.Ready visible: status === Image.Ready && source !== ""
smooth: true smooth: true
} }
@ -106,6 +113,7 @@ PanelWindow {
Text { Text {
visible: model.body.length > 0 visible: model.body.length > 0
text: model.body text: model.body
textFormat: Text.StyledText
color: Theme.fg2 color: Theme.fg2
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
font.family: Theme.font font.family: Theme.font
@ -161,7 +169,7 @@ PanelWindow {
anchors.rightMargin: 34 anchors.rightMargin: 34
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: { onClicked: {
NotificationsState.invokeDefault(model.tid) NotificationsState.invokeDefault(model.tid, model.desktopEntry || model.appName)
NotificationsState.dismissToast(model.tid) NotificationsState.dismissToast(model.tid)
} }
} }

View file

@ -1,5 +1,6 @@
pragma Singleton pragma Singleton
import Quickshell import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications import Quickshell.Services.Notifications
import QtQuick import QtQuick
@ -10,7 +11,8 @@ Singleton {
property int unreadCount: 0 property int unreadCount: 0
property ListModel notifications: ListModel {} 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 {} 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 { NotificationServer {
keepOnReload: true keepOnReload: true
@ -49,6 +88,8 @@ Singleton {
summary: notif.summary || "", summary: notif.summary || "",
body: notif.body || "", body: notif.body || "",
appIcon: notif.appIcon || "", appIcon: notif.appIcon || "",
image: notif.image || "",
desktopEntry: notif.desktopEntry || "",
urgency: notif.urgency || 1, urgency: notif.urgency || 1,
timestamp: ts, timestamp: ts,
day: day day: day
@ -66,6 +107,8 @@ Singleton {
summary: notif.summary || "", summary: notif.summary || "",
body: notif.body || "", body: notif.body || "",
appIcon: notif.appIcon || "", appIcon: notif.appIcon || "",
image: notif.image || "",
desktopEntry: notif.desktopEntry || "",
urgency: notif.urgency || 1, urgency: notif.urgency || 1,
progress: 1.0 progress: 1.0
}) })
@ -82,6 +125,9 @@ Singleton {
delete r[entry.nid] delete r[entry.nid]
_refs = r _refs = r
} }
const p = _pids
delete p[entry.nid]
_pids = p
notifications.remove(index, 1) notifications.remove(index, 1)
} }
@ -92,22 +138,65 @@ Singleton {
} }
notifications.clear() notifications.clear()
_refs = {} _refs = {}
_pids = {}
unreadCount = 0 unreadCount = 0
} }
// Invokes the "default" action on a notification, falling back to the first action // Invokes the D-Bus action (tells the app which chat/content to open) and
function invokeDefault(nid) { // 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] const ref = _refs[nid]
if (!ref) return if (ref) {
const actions = ref.actions const actions = ref.actions ? Array.from(ref.actions) : []
if (!actions || actions.length === 0) return if (actions.length > 0) {
let found = false
for (let i = 0; i < actions.length; i++) { for (let i = 0; i < actions.length; i++) {
if (actions[i].identifier === "default") { if (actions[i].identifier === "default") {
try { actions[i].invoke() } catch (_) {} try { actions[i].invoke() } catch (_) {}
found = true
break
}
}
if (!found) {
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 return
} }
} _focusProc.running = false
try { actions[0].invoke() } catch (_) {} _focusProc.running = true
} }
function dismissToast(tid) { function dismissToast(tid) {

53
quickshell/notif-pid-monitor.sh Executable file
View file

@ -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:
# <notification-id>:<sender-pid>
# 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