221 lines
7.5 KiB
QML
221 lines
7.5 KiB
QML
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;
|
|
}
|
|
}
|
|
}
|