import QtQuick import Quickshell import Quickshell.Io import "niri.js" as NiriState Item { id: root readonly property bool available: String(Quickshell.env("NIRI_SOCKET") || "").length > 0 property bool ready: false property bool windowsInitialized: false property bool workspacesInitialized: false property bool streamFailed: false property int retryDelay: 250 property string lastError: "" property string outputError: "" property bool outputsPending: false property bool outputsFinished: false signal screenshotCaptured(var path) property var workspaces: [] readonly property var visibleWorkspaces: displayWorkspaces(workspaces) property var windows: [] property var outputs: ({}) property var focusedWindow: null property string overviewState: "unknown" property string lastPickedColor: "" property var lastWindowAction: ({}) property var actionQueue: [] readonly property var focusedWindowRecord: windowById(focusedWindow) readonly property string focusedTitle: focusedWindowRecord ? (focusedWindowRecord.title || "") : "" readonly property string focusedAppId: focusedWindowRecord ? (focusedWindowRecord.app_id || focusedWindowRecord.appId || "") : "" readonly property string focusedOutputName: focusedOutputNameFromWorkspaces(workspaces) readonly property string keybindConfigPath: { const xdg = String(Quickshell.env("XDG_CONFIG_HOME") || ""); const home = String(Quickshell.env("HOME") || ""); return (xdg.length > 0 ? xdg : home + "/.config") + "/niri/cfg/keybinds.kdl"; } property var workspaceShortcuts: ({}) function refresh() { if (!available || query.running) return; reconnect.stop(); resetStream(); streamFailed = false; lastError = ""; query.exec(["niri", "msg", "--json", "event-stream"]); } function resetStream() { ready = false; windowsInitialized = false; workspacesInitialized = false; windows = []; workspaces = []; focusedWindow = null; overviewState = "unknown"; } function streamDisconnected() { resetStream(); if (lastError.length === 0) lastError = "event stream disconnected"; if (!available || reconnect.running) return; reconnect.interval = retryDelay; retryDelay = Math.min(retryDelay * 2, 8000); reconnect.start(); } function failStream(message) { lastError = message; ready = false; streamFailed = true; query.signal(9); } function refreshOutputs() { if (!available) return; if (outputQuery.running) { outputsPending = true; return; } outputsPending = false; outputsFinished = false; outputQuery.exec(["niri", "msg", "--json", "outputs"]); } function applyOutputs(text, code, status) { try { const inventory = NiriState.parseOutputs(text, code, status); if (JSON.stringify(outputs) !== JSON.stringify(inventory)) outputs = inventory; outputError = ""; } catch (error) { outputError = String(error); } } function action(args) { const list = Array.isArray(args) ? args : [String(args)]; actionQueue = actionQueue.concat([["niri", "msg", "action"].concat(list.map(String))]); dispatchNextAction(); } function dispatchNextAction() { if (actionProc.running || actionQueue.length === 0) return; const command = actionQueue[0]; actionQueue = actionQueue.slice(1); // Process.exec terminates an already-running command; preserve rapid requests. actionProc.exec(command); } function screenshot(path) { action(["screenshot", "--path", path]); } function screenshotScreen(path) { action(["screenshot-screen", "--path", path, "--write-to-disk", "true"]); } function screenshotWindow(path) { action(["screenshot-window", "--path", path, "--write-to-disk", "true"]); } function pickColor() { lastPickedColor = ""; colorProc.exec(["niri", "msg", "pick-color"]); } function focusWorkspace(ref) { action(["focus-workspace", String(ref)]); } function displayWorkspaces(items) { return (items || []).filter(workspace => { return workspace.name || workspace.is_focused || workspace.is_active || workspace.is_urgent || workspace.active_window_id !== null && workspace.active_window_id !== undefined; }); } function focusedOutputNameFromWorkspaces(items) { const list = items || []; for (let i = 0; i < list.length; i++) if (list[i].is_focused) return String(list[i].output || ""); return ""; } function shortcutKey(sequence) { const parts = String(sequence || "").split("+"); return parts.length > 0 ? parts[parts.length - 1] : ""; } function parseWorkspaceShortcuts(text) { const map = ({}); const re = /(?:^|\n)\s*([^\s{]+)[^{]*\{([^{}]*)\}/g; let match; while ((match = re.exec(String(text || ""))) !== null) { const focus = /\bfocus-workspace\s+(?:"([^"]+)"|([^\s}]+))/.exec(match[2]); if (!focus) continue; const name = String(focus[1] || focus[2] || ""); if (name.length > 0 && !map[name]) map[name] = shortcutKey(match[1]); } return map; } function reloadWorkspaceShortcuts() { workspaceShortcuts = parseWorkspaceShortcuts(keybindFile.text()); } function workspaceShortcut(name) { return workspaceShortcuts[String(name || "")] || ""; } // Group visible workspaces by output, each output's list idx-sorted, so the // single primary bar renders every monitor's workspaces in its own tray. // Pure: pass `items` for testing; defaults to the live workspace array. function groupWorkspaces(items) { const vis = displayWorkspaces(items === undefined ? workspaces : items); const byOutput = ({}); const order = []; for (let i = 0; i < vis.length; i++) { const name = String(vis[i].output || ""); if (!byOutput[name]) { byOutput[name] = []; order.push(name); } byOutput[name].push(Object.assign({}, vis[i], { "shortcut": workspaceShortcut(vis[i].name) })); } return order.map(name => { return { "output": name, "workspaces": byOutput[name].slice().sort((a, b) => Number(a.idx || 0) - Number(b.idx || 0)) }; }); } // Installed niri's outputs request returns a name-keyed object. function outputList() { return Object.keys(outputs).map(name => outputs[name]); } // physical left-to-right order key for an output, from niri's logical x; // large fallback so unknown outputs sort last (stable, not hash order). function outputX(name) { const list = outputList(); const want = String(name || ""); for (let i = 0; i < list.length; i++) { if (String(list[i].name || "") === want) { const logical = list[i].logical || ({}); return typeof logical.x === "number" ? logical.x : 1000000; } } return 1000000; } // live grouping for the bar, groups ordered by physical output position. function workspaceGroups() { return groupWorkspaces(workspaces).slice().sort((a, b) => outputX(a.output) - outputX(b.output)); } // Local gate for the grouping logic (untestable on a single monitor): feed // a synthetic two-output array and assert grouping, per-tray idx sort, and // empty-trailing-workspace filtering. Drive via the niriSelfTest IPC. function selfTest() { const failures = []; const sample = [ { "idx": 4, "output": "DP-2", "name": "browser" }, { "idx": 5, "output": "DP-2", "name": "vault" }, { "idx": 3, "output": "DP-1", "name": "games" }, { "idx": 6, "output": "DP-1", "name": null }, { "idx": 2, "output": "DP-1", "name": "media" }, { "idx": 1, "output": "DP-1", "name": "code", "is_focused": true } ]; const groups = groupWorkspaces(sample); if (groups.length !== 2) failures.push(`expected 2 groups, got ${groups.length}`); const dp1 = groups.filter(g => g.output === "DP-1")[0] || null; const dp2 = groups.filter(g => g.output === "DP-2")[0] || null; if (!dp1) failures.push("missing DP-1 group"); if (!dp2) failures.push("missing DP-2 group"); if (dp1) { const idxs = JSON.stringify(dp1.workspaces.map(w => w.idx)); if (idxs !== "[1,2,3]") failures.push(`DP-1 idx order ${idxs} != [1,2,3]`); } if (dp2) { const idxs = JSON.stringify(dp2.workspaces.map(w => w.idx)); if (idxs !== "[4,5]") failures.push(`DP-2 idx order ${idxs} != [4,5]`); } const total = groups.reduce((sum, g) => sum + g.workspaces.length, 0); if (total !== 5) failures.push(`expected 5 pills after empty filter, got ${total}`); try { const focused = focusedOutputNameFromWorkspaces(sample); if (focused !== "DP-1") failures.push(`focused output ${focused} != DP-1`); } catch (error) { failures.push(`focused output helper missing: ${error}`); } try { const shortcuts = parseWorkspaceShortcuts('binds {\n Mod+1 { focus-workspace "code" }\n Mod+4 { focus-workspace "browser" }\n Mod+Shift+4 { move-column-to-workspace "browser" }\n}'); if (shortcuts.code !== "1" || shortcuts.browser !== "4") failures.push(`workspace shortcuts ${JSON.stringify(shortcuts)} != code/browser 1/4`); } catch (error) { failures.push(`workspace shortcut parser missing: ${error}`); } return { "pass": failures.length === 0, "failures": failures }; } function parsePickedColor(text) { const value = String(text || ""); const match = value.match(/#[0-9a-fA-F]{6}/); lastPickedColor = match ? match[0].toLowerCase() : ""; lastError = value.trim(); } function windowById(id) { if (id === null || id === undefined) return null; for (let i = 0; i < windows.length; i++) { if (windows[i].id === id) return windows[i]; } return null; } function windowActions(id) { return [ { "id": "focus", "label": "focus", "route": "Niri.qml", "niriAction": "focus-window", "destructive": false, "keyboard": "Enter" }, { "id": "toggle-fullscreen", "label": "fullscreen", "route": "Niri.qml", "niriAction": "fullscreen-window", "destructive": false, "keyboard": "F" }, { "id": "move-workspace", "label": "move workspace", "route": "Niri.qml", "niriAction": "move-window-to-workspace", "destructive": false, "keyboard": "M" }, { "id": "close-window", "label": "close", "route": "Niri.qml", "niriAction": "close-window", "destructive": true, "keyboard": "Delete" } ].map(action => { return Object.assign({}, action, { "windowId": id }); }); } function windowPanelContract() { return { "source": "Nuguland WindowInfo.qml Details.qml Buttons.qml plus Nuguland TaskViewContent.qml", "ownedBy": "PopoutHost.qml inline WindowPanel", "niriWindowCardPanel": true, "previewSlotCard": true, "detailsRows": true, "ruleHintRows": true, "windowActionRail": true, "workspaceMoveAffordance": true, "serviceActionsOnly": true, "route": "Niri.qml", "realNiriWindowState": true, "cardParts": ["preview-slot", "details", "rule-hints", "actions", "workspace-move"], "noCompositorOverviewClone": true }; } function windowCardObject() { const win = focusedWindowRecord || ({}); const appId = win.app_id || win.appId || ""; const title = win.title || ""; const workspace = win.workspace_id ?? win.workspaceId ?? null; const id = win.id ?? focusedWindow ?? null; const hints = []; if (appId.length > 0) hints.push(`match app-id=${JSON.stringify(appId)}`); if (title.length > 0) hints.push(`match title=${JSON.stringify(title)}`); if (workspace !== null) hints.push(`workspace id ${workspace}`); const actions = windowActions(id); return { "id": id, "appId": appId, "title": title, "workspace": workspace, "focused": focusedWindowRecord !== null, "ruleHints": hints, "previewSlot": { "source": "niri-toplevel", "available": focusedWindowRecord !== null, "live": focusedWindowRecord !== null, "title": title, "appId": appId, "role": "content.normal" }, "actionRow": { "route": "Niri.qml", "keyboardFirst": true, "pointerParity": true, "actions": actions }, "windowPanelContract": windowPanelContract(), "detailsRows": [ { "id": "app-id", "label": "app-id", "value": appId, "role": "content.normal" }, { "id": "title", "label": "title", "value": title, "role": "content.normal" }, { "id": "workspace", "label": "workspace", "value": workspace === null ? "unknown" : String(workspace), "role": "ui.accent" }, { "id": "focused", "label": "focused", "value": focusedWindowRecord !== null ? "yes" : "no", "role": focusedWindowRecord !== null ? "ui.accent" : "ui.minor" } ], "actions": actions, "lastWindowAction": lastWindowAction }; } function windowRuleMetadata() { return JSON.stringify(windowCardObject()); } function windowCardAction(kind) { const name = String(kind); const card = windowCardObject(); const actionRows = card.actions || []; let selected = null; for (let i = 0; i < actionRows.length; i++) if (actionRows[i].id === name) { selected = actionRows[i]; } lastWindowAction = { "kind": name, "route": "Niri.qml", "accepted": selected !== null, "niriAction": selected ? selected.niriAction : "", "windowId": card.id, "executed": false }; return JSON.stringify(windowCardObject()); } function stateJson() { return JSON.stringify({ "available": available, "ready": ready, "workspaceCount": workspaces.length, "windowCount": windows.length, "windows": windows, "outputCount": Object.keys(outputs).length, "outputError": outputError, "outputs": outputs, "workspaces": workspaces, "workspaceShortcuts": workspaceShortcuts, "overviewState": overviewState, "focusedWindow": focusedWindow, "focusedOutputName": focusedOutputName, "focusedAppId": focusedAppId, "focusedTitle": focusedTitle, "lastError": lastError }); } function applyEvent(line) { if (streamFailed || String(line).trim().length === 0) return; try { const patch = NiriState.reduce(root, JSON.parse(line)); if (patch === null) return; if (Object.prototype.hasOwnProperty.call(patch, "screenshotPath")) { screenshotCaptured(patch.screenshotPath); return; } for (const key of Object.keys(patch)) root[key] = patch[key]; ready = windowsInitialized && workspacesInitialized && overviewState !== "unknown"; if (ready) retryDelay = 250; } catch (error) { failStream(String(error)); } } onReadyChanged: if (ready) refreshOutputs() Component.onCompleted: { refreshOutputs(); refresh(); } Connections { target: Quickshell function onScreensChanged() { Qt.callLater(root.refreshOutputs); } } // Niri has no OutputsChanged event. Qt screen notifications cover hotplug; // polling also catches compositor-only geometry changes. One query at a time. Timer { interval: 5000 running: root.available repeat: true onTriggered: root.refreshOutputs() } Timer { id: reconnect onTriggered: root.refresh() } Timer { interval: 5000 running: query.running && !root.ready && !root.streamFailed onTriggered: root.failStream("event stream initialization timed out") } Timer { interval: 3000 running: outputQuery.running onTriggered: outputQuery.signal(9) } FileView { id: keybindFile path: root.keybindConfigPath preload: true printErrors: false onLoaded: root.reloadWorkspaceShortcuts() onLoadFailed: root.workspaceShortcuts = ({}) } Process { id: outputQuery onRunningChanged: { if (!running) { if (!root.outputsFinished) root.outputError = "outputs query failed to start"; if (root.outputsPending) Qt.callLater(root.refreshOutputs); } } stdout: StdioCollector { id: outputStdout waitForEnd: true } } Process { id: query running: false onRunningChanged: if (!running) root.streamDisconnected() stdout: SplitParser { onRead: data => { return root.applyEvent(data); } } stderr: StdioCollector { onStreamFinished: { if (this.text.trim().length > 0 && !root.streamFailed) root.lastError = this.text.trim(); } } } Connections { target: outputQuery function onExited(code, status) { root.outputsFinished = true; root.applyOutputs(outputStdout.text, code, status); } } Connections { target: query function onExited(code) { if (!root.streamFailed) root.lastError = `event stream exited ${code}`; } } Process { id: actionProc onRunningChanged: if (!running) root.dispatchNextAction() stderr: StdioCollector { onStreamFinished: root.lastError = this.text } } Process { id: colorProc stdout: StdioCollector { onStreamFinished: root.parsePickedColor(this.text) } stderr: StdioCollector { onStreamFinished: root.lastError = this.text } } }