experimental notification focus
This commit is contained in:
parent
38db696ee8
commit
4b0974e74e
5 changed files with 204 additions and 44 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
53
quickshell/notif-pid-monitor.sh
Executable file
53
quickshell/notif-pid-monitor.sh
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue