Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/ViewtopControl.qml

281 lines
11 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_()
// 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", {});
}
// Scale every window on the zone in front, live, as a drag progresses.
//
// This is the iOS/Android "the app shrinks into a card while your thumb
// climbs" feel, and it is the first thing in the tree to actually drive
// `pose`. The verb and its inverse input-mapping have been implemented and
// tested in the compositor for weeks with nothing calling them, which is
// the whole reason the behaviour has never been seen on the device.
//
// Throttled on the value, not on a timer: a drag emits a motion event per
// frame and each one would otherwise be a socket round-trip. Anything
// smaller than a percent of scale is invisible and not worth a write.
property real _lastPose: 1.0
function poseActiveZone(scale) {
if (Math.abs(scale - root._lastPose) < 0.01)
return;
root._lastPose = scale;
for (const w of root.windows) {
if (w.workspace === root.activeZone)
root.pose(w.id, scale, 0.0, 0.5, 0.5);
}
}
// Put them back. Called on every path out of a drag — commit, abandon and
// cancel — because a window left posed by a gesture that ended is a window
// the user cannot restore without knowing a verb exists.
function clearPose() {
root._lastPose = 1.0;
for (const w of root.windows)
root.pose(w.id, 1.0, 0.0, 0.5, 0.5);
}
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();
}
}
}
}