pragma Singleton import Quickshell import Quickshell.Io import QtQuick import qs.config // The on-screen keyboard: squeekboard, started as a systemd user unit and // shown or hidden over DBus (`sm.puri.OSK0`). // // Quickshell has no generic DBus binding, so busctl does the talking. One // long-lived `busctl monitor` carries the state — squeekboard raises and // lowers itself on text-input focus as well, so the panel button has to follow // the keyboard rather than remember what it last asked for — and a short-lived // `busctl call` carries each request the other way. Singleton { id: root // squeekboard's well-known name doubles as its interface name. readonly property string busName: "sm.puri.OSK0" readonly property string objectPath: "/sm/puri/OSK0" // Whether the keyboard is on screen. property bool shown: false // Whether squeekboard is on the bus at all. Starting the unit is part of // showing, so this is state to report, not a precondition to check. property bool running: false // Whether the unit exists on this machine, asked once at startup. Without // it there is no button, the same way a machine with no bluetooth adapter // gets no bluetooth button. property bool unitPresent: false readonly property bool available: Config.showOsk && root.unitPresent // What was last asked for, and whether a click landed while the previous // request was still in flight — a show can take a moment when the unit has // to start, and the second click must not be dropped. property bool desired: false property bool queued: false function show(): void { root.request(true); } function hide(): void { root.request(false); } function toggle(): void { root.request(!root.shown); } function request(visible: bool): void { root.desired = visible; // Optimistic: squeekboard announces the change over the bus a moment // later and the monitor below overwrites this either way, including // when the request fails. It only keeps the button from sitting dead // while the unit starts. root.shown = visible; if (call.running) root.queued = true; else root.dispatch(); } function dispatch(): void { call.command = root.desired ? root.showCommand : root.hideCommand; call.running = true; } // Starting a unit that is already running is a no-op, so this one command // covers both "start the keyboard" and "show the keyboard again". The // retry is for the gap between the unit going active and squeekboard // claiming its name: until it does, the call has nowhere to land. // // $1 is the unit, $2 the bus name — which is also the interface name — and // $3 the object path. readonly property var showCommand: ["sh", "-c", ` systemctl --user start "$1" || exit 1 n=0 while [ "$n" -lt 30 ]; do busctl --user call "$2" "$3" "$2" SetVisible b true >/dev/null 2>&1 && exit 0 n=$((n + 1)) sleep 0.1 done exit 1 `, "att_menu", Config.oskUnit, root.busName, root.objectPath] // Hiding leaves the unit running: squeekboard idles cheaply, and stopping // it would only make the next keypress wait for a fresh start. readonly property var hideCommand: ["busctl", "--user", "call", root.busName, root.objectPath, root.busName, "SetVisible", "b", "false"] function handleMessage(line: string): void { let message; try { message = JSON.parse(line); } catch (e) { return; } const data = message.payload ? message.payload.data : null; if (!data) return; if (message.member === "NameOwnerChanged") { // data is [name, oldOwner, newOwner]. root.running = data[2] !== ""; // Nothing owns the name, so nothing is on screen. squeekboard does // not get to announce that on the way out. if (!root.running) root.shown = false; } else if (message.member === "PropertiesChanged") { // data is [interface, changed, invalidated]. const changed = data[1]; if (changed && changed.Visible !== undefined) { root.running = true; root.shown = changed.Visible.data === true; } } } Process { id: call onExited: (exitCode, exitStatus) => { if (root.queued) { root.queued = false; root.dispatch(); return; } // The unit would not start, or squeekboard is no longer there to // answer. Either way nothing is on screen. if (exitCode !== 0) { root.shown = false; root.running = false; } } } Process { id: monitor // A monitor connection sees the signals without being their // destination, so this follows squeekboard however it was told to // show — by this panel, or by a text field taking focus. // // Matching on `sender` is deliberately left out: dbus-broker does not // deliver the bus driver's own signals to a monitor that asks for them // by sender, so NameOwnerChanged would never arrive. command: ["busctl", "--user", "--json=short", "monitor", "--match", `type='signal',path='${root.objectPath}',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'`, "--match", `type='signal',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='${root.busName}'`] stdout: SplitParser { onRead: line => root.handleMessage(line) } } Process { id: probe command: ["busctl", "--user", "--json=short", "get-property", root.busName, root.objectPath, root.busName, "Visible"] stdout: StdioCollector { onStreamFinished: { // A request made since the probe started is the newer truth. if (call.running) return; try { root.shown = JSON.parse(this.text).data === true; root.running = true; } catch (e) { // No output to parse: squeekboard is not running, and the // name is not activatable, so nothing started it either. root.shown = false; root.running = false; } } } } Process { running: Config.showOsk command: ["systemctl", "--user", "show", "-p", "LoadState", "--value", Config.oskUnit] stdout: StdioCollector { onStreamFinished: root.unitPresent = this.text.trim() === "loaded" } } // The monitor is the whole state feed, so it has to outlive anything that // can kill it — a bus restart above all. Setting `running` on a process // that is already running does nothing, so a tick normally costs a // comparison. Timer { interval: 2000 repeat: true triggeredOnStart: true running: root.available onTriggered: { if (monitor.running) return; monitor.running = true; // A monitor only ever reports changes, so ask separately for the // state it started from. probe.running = true; } } }