Files
moonarch/defaults/xdg/quickshell/moonarch/Niri.qml
T
nevaforget cf1db166e9 feat(quickshell): popouts close when the focus leaves them
An open popout only closed on a backdrop click, Escape, a toggle, or
another screen claiming the slot. Focus moving away without a click —
Alt+Tab, a focus keybind, a workspace switch, a newly mapped window —
left it standing over the desktop.

Three signals, all needed:

- the host surface losing the keyboard, read from Qt's Window.active
  (PanelWindow exports no focus state); the only one that catches a
  focus grab by another layer surface
- niri focusing an actual window, filtered on id >= 0: niri drops window
  focus to none while the popout holds the keyboard, so reacting to every
  change would make each open close itself
- the focused workspace changing, via the new Niri.focusedWorkspaceId;
  workspace ids are unique across outputs, so a monitor switch counts too

No exception for pkexec/keyring prompts raised by popout actions: they
take the focus, so the popout closes and the new state shows on the next
open. Rationale and measurements in DECISIONS.md.
2026-08-18 15:30:06 +02:00

135 lines
4.9 KiB
QML

// ABOUTME: Niri compositor IPC service — consumes the NIRI_SOCKET event stream.
// ABOUTME: Exposes reactive workspaces and the focused window for the bar widgets.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
readonly property string socketPath: Quickshell.env("NIRI_SOCKET") || ""
// Reactive state consumed by widgets.
property var workspaces: []
property var windowsById: ({})
property int focusedWindowId: -1
property var activeWindow: null
// Connector name of the monitor holding the focused workspace ("" until known). Window-independent:
// an empty focused workspace still carries is_focused, so this resolves even with nothing on screen.
// Used to route keybind-triggered popouts to the active monitor (matches Quickshell screen.name).
readonly property string activeOutput: {
const w = root.workspaces.find(x => x && x.is_focused);
return w ? w.output : "";
}
// Id of the focused workspace (-1 until known). Ids are unique across outputs, so a change here
// covers both a workspace switch and a monitor switch — which is what popouts close on.
readonly property int focusedWorkspaceId: {
const w = root.workspaces.find(x => x && x.is_focused);
return w ? w.id : -1;
}
function recomputeActive() {
root.activeWindow = (root.focusedWindowId >= 0 && root.windowsById[root.focusedWindowId])
? root.windowsById[root.focusedWindowId]
: null;
}
// Event stream: niri sends the full current state up-front, then incremental updates.
Socket {
id: eventSock
path: root.socketPath
connected: root.socketPath.length > 0
parser: SplitParser {
splitMarker: "\n"
onRead: line => root.handleEvent(line)
}
onConnectedChanged: {
if (connected)
write("\"EventStream\"\n");
}
}
function handleEvent(line) {
if (!line || line.length === 0)
return;
var ev;
try {
ev = JSON.parse(line);
} catch (e) {
return;
}
if (ev.WorkspacesChanged) {
root.workspaces = ev.WorkspacesChanged.workspaces;
} else if (ev.WorkspaceActivated) {
var a = ev.WorkspaceActivated;
var target = root.workspaces.find(w => w.id === a.id);
if (target) {
var out = target.output;
root.workspaces = root.workspaces.map(function (w) {
var n = Object.assign({}, w);
if (w.output === out)
n.is_active = (w.id === a.id);
if (a.focused)
n.is_focused = (w.id === a.id);
return n;
});
}
} else if (ev.WorkspaceUrgencyChanged) {
var u = ev.WorkspaceUrgencyChanged;
root.workspaces = root.workspaces.map(w => w.id === u.id ? Object.assign({}, w, {
is_urgent: u.urgent
}) : w);
} else if (ev.WorkspaceActiveWindowChanged) {
var c = ev.WorkspaceActiveWindowChanged;
root.workspaces = root.workspaces.map(w => w.id === c.workspace_id ? Object.assign({}, w, {
active_window_id: c.active_window_id
}) : w);
} else if (ev.WindowsChanged) {
var map = {};
for (var i = 0; i < ev.WindowsChanged.windows.length; i++) {
var win = ev.WindowsChanged.windows[i];
map[win.id] = win;
}
root.windowsById = map;
var focused = ev.WindowsChanged.windows.find(w => w.is_focused);
root.focusedWindowId = focused ? focused.id : -1;
root.recomputeActive();
} else if (ev.WindowOpenedOrChanged) {
var w2 = ev.WindowOpenedOrChanged.window;
var m2 = Object.assign({}, root.windowsById);
m2[w2.id] = w2;
root.windowsById = m2;
if (w2.is_focused)
root.focusedWindowId = w2.id;
root.recomputeActive();
} else if (ev.WindowClosed) {
var m3 = Object.assign({}, root.windowsById);
delete m3[ev.WindowClosed.id];
root.windowsById = m3;
if (root.focusedWindowId === ev.WindowClosed.id)
root.focusedWindowId = -1;
root.recomputeActive();
} else if (ev.WindowFocusChanged) {
var fid = ev.WindowFocusChanged.id;
root.focusedWindowId = (fid === null || fid === undefined) ? -1 : fid;
root.recomputeActive();
}
}
// Focus a workspace by its niri per-output index. Best-effort for the pilot.
function focusWorkspace(idx) {
focusProc.command = ["niri", "msg", "action", "focus-workspace", String(idx)];
focusProc.running = true;
}
Process {
id: focusProc
}
}