// The shell's door into viewtop's control socket. // // The compositor serves one JSON object per line — `{"op":…}` in, // `{"ok":true,…}` or `{"ok":false,"code":…,"reason":…}` back — the same house // grammar sessiond speaks, so this is shaped like SessiondPolicy rather than // inventing a second idea of what talking to a daemon looks like. // // **This is the only place the shell addresses the scene.** Every window verb // the hand can reach — close, kill, place, pose, raise, focus — goes through // `scene()`. The alternative is what the dial does today: `hyprctl dispatch` // baked into a surface, which stopped existing under viewtop and took the // dial's "Kill window" with it. A verb spelled out inside a widget is a verb // that dies when the compositor changes. // // A short-lived connection per request, deliberately. viewtop's socket is // request/response and holds no session; there is no heartbeat to protect here // the way SessiondBridge's EOF is load-bearing, so nothing is gained by // keeping it open and a dropped long-lived socket would need reconnect logic // to be correct. // // Failures are LOUD. A window verb that silently did nothing is exactly the // bug this exists to close: `overview toggle` shelled out to a handler that // did not exist and the tap did nothing, silently, for weeks. pragma Singleton import QtQuick import Quickshell import Quickshell.Io Singleton { id: root // The last refusal, for a surface that wants to show one. `close` is a // request the client may refuse; the hand deserves to see that rather than // watch nothing happen. property string lastError: "" signal refused(string intent, string reason) signal succeeded(string intent) // What the compositor last said is on the canvas: `[{id, workspace}, …]`. // Queried, never cached across opens — a chooser showing a window that // closed a minute ago is worse than one that takes a moment to fill. property var windows: [] signal windowsChanged_() // Which zone is in front, as the compositor last reported it. -1 is // "not asked yet" and is deliberately not 0: home is 0, so defaulting to it // would make every surface believe it was on home before the first reply. property int activeZone: -1 property int zoneCount: 0 // Home is zone 0, matching `workspace::HOME_ZONE` in the compositor. Named // here rather than written as a literal at each call site so the two ends // of the wire have one place to disagree if it ever moves. readonly property int homeZone: 0 // Ask what is open, and where we are. Answers into `windows`/`activeZone`. function refreshWindows() { root._send({ op: "workspaces" }); } // The zone is polled rather than pushed: the compositor has no subscription // for it yet, and a surface that reads a stale zone shows the wrong // furniture. Two seconds is slow enough to be free and fast enough that the // dock does not visibly lag a zone change; a push channel would replace // this and should. Timer { interval: 2000 running: true repeat: true triggeredOnStart: true onTriggered: root.refreshWindows() } // Requests waiting on a connection. A queue rather than SessiondPolicy's // single slot: the sheet can fire two verbs in a row (kill after a close // that was refused), and dropping the second would be silent. property var _queue: [] property var _inflight: null function _send(msg) { root._queue.push(msg); if (sock.connected) root._drain(); else sock.connected = true; } function _drain() { if (root._inflight !== null || root._queue.length === 0) return; root._inflight = root._queue.shift(); sock.write(JSON.stringify(root._inflight) + "\n"); } // Address the scene. `intent` is one of the compositor's own — the table it // returns from `{"op":"describe"}` is authoritative, and this deliberately // does not keep a second copy of it to validate against. function scene(intent, args) { const msg = { op: "scene", intent: intent }; for (const k in args) msg[k] = args[k]; root._send(msg); } // Ask the client to close. It may refuse or prompt; that is the protocol, // not a bug, and `refused` carries it. function close(id) { root.scene("close", { id: id }); } // End it regardless. The floor under close(), for a client that is hung or // says no — "two apps and no way to turn them off" is what this answers. // Unsaved work is lost, so a surface offering this should mean it. function kill(id) { root.scene("kill", { id: id }); } // Position and size a window. The shell owns layout; the compositor owns // whether a placement is legal and will refuse one that is not. function place(id, x, y, width, height) { root.scene("place", { id: id, at: { x: x, y: y }, size: { width: width, height: height } }); } // Give a window back to the layout. function unplace(id) { root.scene("unplace", { id: id }); } // Compose a window's visual geometry — the verb that makes a live app a // scaled, floating, still-touchable thing rather than a picture of one. // The compositor maps input back through the inverse, so a shrunken window // still receives touch where it is drawn. function pose(id, scale, rotation, anchorX, anchorY) { root.scene("pose", { id: id, scale: scale, rotation: rotation ?? 0.0, anchor: { x: anchorX ?? 0.5, y: anchorY ?? 0.5 } }); } // Hand a window to the finger. While grabbed, one-finger drags move it; // `drop` ends the mode. This is Move as a *mode* rather than a computed // geometry — the three-finger carry that used to do it lost a race with // the three-finger tap every time, so it is entered on purpose now. function grab(id) { root.scene("grab", { id: id }); } function drop() { root.scene("drop", {}); } function raise(id) { root.scene("raise", { id: id }); } // Go to a zone. Not a `scene` intent — zones are the canvas, not a surface // on it, so the compositor serves this as its own op. // // "Zone", not "workspace", in everything we name: viewtop's canvas is a // large scalable space of states rather than Hyprland's numbered desks, and // the vocabulary is being moved off Hyprland's deliberately. The wire op is // still spelled `workspace` — renaming that is a separate sweep, and doing // it halfway would leave the shell calling an op the compositor does not // serve. function zone(to) { root._send({ op: "workspace", to: to }); } function focus(id) { root.scene("focus", { id: id }); } Socket { id: sock path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock" onConnectionStateChanged: { if (connected) { root._drain(); } else if (root._inflight !== null || root._queue.length > 0) { const intent = (root._inflight && root._inflight.intent) || "?"; root.lastError = "viewtop socket unavailable"; console.error("[viewtop-control] could not reach the compositor at " + sock.path + " — the scene was NOT changed"); root._inflight = null; root._queue = []; root.refused(intent, root.lastError); } } parser: SplitParser { splitMarker: "\n" onRead: message => { const sent = root._inflight; const intent = (sent && sent.intent) || "?"; root._inflight = null; let reply; try { reply = JSON.parse(message); } catch (e) { console.error("[viewtop-control] unparseable reply: " + message); root.lastError = "unparseable reply"; root.refused(intent, root.lastError); root._drain(); return; } if (reply.ok !== true) { // `gone` is the ordinary one: the window closed between the // sheet opening and the button being pressed. Still a // refusal, still surfaced, because a sheet acting on a dead // id should say so rather than appear to work. const why = reply.reason || reply.code || "refused without a reason"; console.log("[viewtop-control] " + intent + " refused: " + why); root.lastError = why; root.refused(intent, why); } else { root.lastError = ""; // A `workspaces` reply carries the canvas rather than a // verb's outcome. Captured here so a chooser has something // real to list instead of a guess at what is open. if (reply.windows !== undefined) { root.windows = reply.windows; root.windowsChanged_(); } // `active` is an index and 0 is a real, meaningful value — // it is home — so this must test for presence, not // truthiness. `if (reply.active)` would silently ignore // every report that we are on home, which is the one zone // anything here cares about. if (reply.active !== undefined) root.activeZone = reply.active; if (reply.count !== undefined) root.zoneCount = reply.count; root.succeeded(intent); } root._drain(); } } } }