449 lines
19 KiB
QML
449 lines
19 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_()
|
|
|
|
// The serialised form of the last canvas we published, so an unchanged
|
|
// answer is not republished.
|
|
//
|
|
// This is not a micro-optimisation, it is a correctness fix. `windows` is a
|
|
// `var` holding a fresh array on every reply, so assigning it fires
|
|
// `windowsChanged` whether or not anything changed. A surface that binds a
|
|
// ListView model to it therefore had its entire delegate tree — and every
|
|
// `ScreencopyView` inside it — destroyed and rebuilt on the poll interval,
|
|
// which is a view that flickers and loses its place while you are reading
|
|
// it. Republish on *difference*, the same level-not-edge discipline
|
|
// `lockhint.rs` applies to `LockedHint`.
|
|
property string _lastCanvas: ""
|
|
|
|
// 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" });
|
|
}
|
|
|
|
// True once the compositor has accepted a `subscribe` and is pushing canvas
|
|
// changes down the second socket below.
|
|
property bool subscribed: false
|
|
// A compositor that does not serve `subscribe` will not start serving it
|
|
// while it is running. Latch the refusal, or the disconnect that follows it
|
|
// re-arms the retry that the refusal just stopped — which is a reconnect
|
|
// loop at timer speed against a socket that will keep saying no. Cleared by
|
|
// the shell restarting, which is also when the compositor has changed.
|
|
property bool _pushRefused: false
|
|
|
|
// The poll, which now exists only as the fallback for a compositor too old
|
|
// to push.
|
|
//
|
|
// TASK-60 Q4: *"Either the compositor pushes zone/window changes, or this
|
|
// surface asks synchronously when it opens and stops guessing in between."*
|
|
// Polling was the proximate cause of the multitasking view scaling the
|
|
// wrong windows — a two-second answer is wrong for the whole of every
|
|
// gesture, and a gesture is exactly when something asks. The push channel
|
|
// is the answer; this stays because the phone can be running a compositor
|
|
// that predates it, and a shell that hard-depends on an op the running
|
|
// compositor does not serve is a shell that breaks on the deploy ordering
|
|
// TASK-28 is made of.
|
|
Timer {
|
|
interval: 2000
|
|
running: !root.subscribed
|
|
repeat: true
|
|
triggeredOnStart: true
|
|
onTriggered: root.refreshWindows()
|
|
}
|
|
|
|
// Take one reply's canvas facts, whether it arrived as an answer or as a
|
|
// push. Both carry the same shape by construction — the compositor
|
|
// serialises the `workspaces` payload once and uses it for both — so there
|
|
// is one reader here rather than two that can drift.
|
|
function _ingest(reply) {
|
|
if (reply.windows !== undefined) {
|
|
// Compare before publishing. See `_lastCanvas`.
|
|
const encoded = JSON.stringify(reply.windows);
|
|
if (encoded !== root._lastCanvas) {
|
|
root._lastCanvas = encoded;
|
|
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;
|
|
}
|
|
|
|
// The push channel: a second, long-lived connection that carries canvas
|
|
// changes as they happen.
|
|
//
|
|
// Separate from the request socket on purpose. viewtop answers one request
|
|
// per connection and then closes; a subscription is the opposite shape — it
|
|
// is written to, never read from, and outlives every request. Multiplexing
|
|
// both onto one socket would mean interleaving a push into the middle of
|
|
// somebody's reply, which is how a request/response client learns to
|
|
// distrust its own parser.
|
|
Socket {
|
|
id: feed
|
|
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock"
|
|
connected: true
|
|
|
|
onConnectionStateChanged: {
|
|
if (connected) {
|
|
feed.write(JSON.stringify({ op: "subscribe" }) + "\n");
|
|
} else {
|
|
// Either the compositor went away or it never served the op.
|
|
// Both mean the poll is the truth again until we get back in.
|
|
root.subscribed = false;
|
|
if (!root._pushRefused)
|
|
resubscribe.restart();
|
|
}
|
|
}
|
|
|
|
parser: SplitParser {
|
|
splitMarker: "\n"
|
|
onRead: message => {
|
|
let reply;
|
|
try {
|
|
reply = JSON.parse(message);
|
|
} catch (e) {
|
|
console.error("[viewtop-control] unparseable push: " + message);
|
|
return;
|
|
}
|
|
if (reply.ok === false) {
|
|
// A compositor that does not serve `subscribe` says so with
|
|
// a code, which is the whole point of the codes. Stop
|
|
// asking, say it once, and let the poll carry it — this is
|
|
// the ordinary state of a phone between a shell update and
|
|
// the compositor package that follows it.
|
|
console.log("[viewtop-control] no push channel ("
|
|
+ (reply.code || "refused")
|
|
+ "); falling back to the 2 s poll");
|
|
root.subscribed = false;
|
|
root._pushRefused = true;
|
|
resubscribe.stop();
|
|
feed.connected = false;
|
|
return;
|
|
}
|
|
root.subscribed = true;
|
|
root._ingest(reply);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Reconnect the feed after the compositor restarts. A session restart is
|
|
// the ordinary case: the shell outlives individual compositor runs, and a
|
|
// subscription that never came back would leave every surface reading a
|
|
// canvas frozen at the moment of the crash.
|
|
Timer {
|
|
id: resubscribe
|
|
interval: 5000
|
|
repeat: false
|
|
onTriggered: if (!root.subscribed && !root._pushRefused) feed.connected = true
|
|
}
|
|
|
|
// 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
|
|
// Whether the in-flight request was answered before the socket closed.
|
|
// The compositor serves exactly one request per connection and then hangs
|
|
// up, so a disconnect is the *normal* end of every exchange — and telling
|
|
// that apart from a compositor that died mid-request is the difference
|
|
// between a silent success and a spurious "socket unavailable".
|
|
property bool _replied: false
|
|
|
|
function _send(msg) {
|
|
root._queue.push(msg);
|
|
root._pump();
|
|
}
|
|
|
|
// One request per connection, because that is what the other end serves.
|
|
//
|
|
// This used to write the whole queue down a single socket, which worked for
|
|
// exactly one request: `handle()` in `control.rs` reads one line, answers,
|
|
// and drops the stream. The second verb of any pair — `kill` after a
|
|
// refused `close`, the one case the queue exists for — was written into a
|
|
// socket that had already been closed, and surfaced as a refusal of a verb
|
|
// that was never delivered. So the connection is re-established per
|
|
// request, and a close with nothing in flight is silence rather than an
|
|
// error.
|
|
function _pump() {
|
|
if (root._inflight !== null || root._queue.length === 0)
|
|
return;
|
|
if (!sock.connected) {
|
|
sock.connected = true;
|
|
return;
|
|
}
|
|
root._inflight = root._queue.shift();
|
|
root._replied = false;
|
|
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 });
|
|
}
|
|
|
|
// Let a window out of its zone.
|
|
//
|
|
// TASK-60 Q6. A zone tiles what is on it, which is right for the thing you
|
|
// are doing and wrong for the thing you are keeping — a video that should
|
|
// survive going somewhere else, a call, anything picture-in-picture. Those
|
|
// want to leave the zone's confinement rather than take a half of it.
|
|
//
|
|
// Distinct from `place`: a placed window is still the zone's, put somewhere
|
|
// specific in it. A floated one has stopped being the zone's business, so
|
|
// it is not counted when the strip decides whether a zone still has
|
|
// anything on it. That is also why it must be visible on a card — a float
|
|
// is a window you can no longer find by remembering which zone you left it
|
|
// on.
|
|
function float(id) {
|
|
root.scene("float", { id: id });
|
|
}
|
|
|
|
function unfloat(id) {
|
|
root.scene("unfloat", { 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 });
|
|
}
|
|
|
|
// Move a window to a zone without going there. Same op, its other half —
|
|
// `workspace` takes an optional surface and moves it before it looks.
|
|
function moveToZone(id, to) {
|
|
root._send({ op: "workspace", to: to, surface: id });
|
|
}
|
|
|
|
function focus(id) {
|
|
root.scene("focus", { id: id });
|
|
}
|
|
|
|
Socket {
|
|
id: sock
|
|
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock"
|
|
|
|
onConnectionStateChanged: {
|
|
if (connected) {
|
|
root._pump();
|
|
return;
|
|
}
|
|
// Answered, then hung up: the exchange completed. Carry on with
|
|
// whatever is behind it.
|
|
if (root._inflight === null && root._replied) {
|
|
root._pump();
|
|
return;
|
|
}
|
|
// Nothing was in flight and nothing is waiting — an idle socket
|
|
// closing is not news.
|
|
if (root._inflight === null && root._queue.length === 0)
|
|
return;
|
|
|
|
const intent = (root._inflight && root._inflight.intent)
|
|
|| (root._inflight && root._inflight.op) || "?";
|
|
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) || (sent && sent.op) || "?";
|
|
root._inflight = null;
|
|
root._replied = true;
|
|
|
|
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._pump();
|
|
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.
|
|
root._ingest(reply);
|
|
root.succeeded(intent);
|
|
}
|
|
root._pump();
|
|
}
|
|
}
|
|
}
|
|
}
|