217 lines
8.4 KiB
QML
217 lines
8.4 KiB
QML
// 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_()
|
|
|
|
// Ask what is open. Answers into `windows`.
|
|
function refreshWindows() {
|
|
root._send({ op: "workspaces" });
|
|
}
|
|
|
|
// 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_();
|
|
}
|
|
root.succeeded(intent);
|
|
}
|
|
root._drain();
|
|
}
|
|
}
|
|
}
|
|
}
|