From 9eca88992e38bfb226b95c69a613d1920fc5300a Mon Sep 17 00:00:00 2001 From: Asmir A Date: Sun, 2 Aug 2026 12:16:23 +0200 Subject: [PATCH] add squeekboard toggle button --- README.md | 42 ++++++- components/KeyboardGlyph.qml | 64 ++++++++++ config/Config.qml | 10 ++ flake.nix | 3 +- modules/Bar.qml | 4 + modules/OskMenu.qml | 32 +++++ services/Osk.qml | 220 +++++++++++++++++++++++++++++++++++ shell.qml | 21 ++++ 8 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 components/KeyboardGlyph.qml create mode 100644 modules/OskMenu.qml create mode 100644 services/Osk.qml diff --git a/README.md b/README.md index e90d885..d84f5cb 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A bottom desktop-environment panel for wlroots-based compositors, built on | ------- | ----------------------------------------------------------------- | | Left | Application menu built from `.desktop` entries, with search | | Middle | Open windows (task list), click to focus, middle-click to close | -| Right | Wifi, bluetooth, and a session menu: log out, suspend, restart, shut down | +| Right | On-screen keyboard, wifi, bluetooth, and a session menu: log out, suspend, restart, shut down | No clock, no tray, no status readouts — it is designed to sit alongside a separate status bar. @@ -25,6 +25,8 @@ separate status bar. - NetworkManager for the wifi section, likewise optional. A NetworkManager configured with `wifi.backend=iwd` is fine — and is what this was built against — since the connection still goes through iwd underneath +- squeekboard, as a systemd user unit, for the keyboard button — also optional; + where the unit is not installed the button is absent The window list uses the foreign-toplevel protocol rather than any single compositor's IPC, so it works the same everywhere — except under river 0.4, @@ -84,6 +86,13 @@ The `launcher`, `wifi`, `bluetooth` and `power` IPC targets each accept focused window. Only one menu is open at a time, so `close` closes whichever it is. +The `osk` target takes the same three and shows or hides the keyboard instead, +which is what to bind to a hardware key or a tablet-mode switch: + +```sh +att_menu ipc call osk toggle +``` + ## Behaviour **Placement.** The panel is a layer-shell surface anchored to the left, right and @@ -113,6 +122,17 @@ The launcher is a full-screen overlay layer surface rather than an `xdg-popup`, which is what makes click-away dismissal and keyboard focus work on any wlroots compositor without compositor-specific focus grabs. +**On-screen keyboard.** One button shows squeekboard, another click hides it, +and the button is accented while the keyboard is up. squeekboard runs as a +systemd user unit (`oskUnit`, `squeekboard.service` by default): showing starts +it if it is not already running, hiding leaves it running, since stopping it +would only make the next keypress wait for a fresh start. Whatever manages the +unit — a tablet-mode hook, say — stays in charge of when it exists at all. + +The keyboard also raises and lowers itself when a text field takes or loses +focus, so the button follows squeekboard's own `Visible` property rather than +remembering what it last asked for, and stays right either way. + **Wifi.** The button shows signal strength in the arcs and the SSID in use next to them, struck through when the radio is off. The menu lists the networks in range — connected first, then saved, then by strength — with the switch in the @@ -176,6 +196,8 @@ running with `qs --path .`). small output still gets a usable menu. - `terminal` — argv prefix for entries with `Terminal=true`. Honours `$TERMINAL`; otherwise change the `["foot"]` fallback to your terminal. + - `showOsk` — set `false` to drop the on-screen keyboard button entirely. + - `oskUnit` — the systemd user unit squeekboard runs as. - `showWifi` — set `false` to drop the wifi button entirely. - `wifiLabelMaxWidth` — cap on the SSID shown on the panel. - `wifiMenuWidth` / `wifiMenuHeight` — also clamped to the screen. @@ -196,12 +218,14 @@ config/Config.qml Behaviour, power actions, compositor detection services/Session.qml Which menu is open, and on which screen services/AppLauncher.qml Desktop-entry launching services/Attwm.qml attwm's IPC socket, when there is one +services/Osk.qml squeekboard: its unit, and its DBus state modules/Bar.qml The panel window and its three sections modules/AppMenu.qml Left: launcher button modules/Launcher.qml The application menu overlay modules/AppEntry.qml One row in the menu modules/WindowList.qml Middle: task list modules/WindowButton.qml One task button +modules/OskMenu.qml Right: on-screen keyboard button modules/WifiMenu.qml Right: wifi button modules/WifiPanel.qml The network list overlay modules/WifiNetworkEntry.qml One network row @@ -215,6 +239,7 @@ components/AppIcon.qml Icon resolution with fallbacks components/PanelButton.qml Shared hover/press surface components/BluetoothGlyph.qml The bluetooth rune, drawn components/WifiGlyph.qml The wifi fan, drawn +components/KeyboardGlyph.qml The keyboard, drawn components/ToggleSwitch.qml On/off switch ``` @@ -251,6 +276,21 @@ components/ToggleSwitch.qml On/off switch the compositor it shares the session's group, where wrapping buys nothing. A scope rather than a service, so the app inherits the environment it needs — `WAYLAND_DISPLAY` above all. +- **Why the keyboard button shells out.** Quickshell has no generic DBus + binding, and squeekboard is driven entirely over DBus, so `services/Osk.qml` + uses `busctl`: a call per request, and one long-lived `busctl --json=short + monitor` as the state feed, parsed a line at a time. A monitor connection + sees signals it is not the destination of, which is what lets the button + follow a keyboard raised by something else. The two match rules deliberately + do not name a `sender`: dbus-broker does not deliver the bus driver's own + signals to a monitor that asks for them that way, and `NameOwnerChanged` — + how the panel notices squeekboard going away, since it does not announce that + itself — would never arrive. Showing retries the call for a few seconds + because the unit goes active a moment before squeekboard claims its name. +- `qs ipc call` cannot reach a handler function named `show`: the name collides + with the `ipc show` subcommand and the call silently turns into a listing. The + `osk` target uses `open` and `close` instead, which matches the menu targets + anyway. - Icons are resolved through the Qt icon theme, falling back to the last segment of a reverse-DNS app id (`org.qutebrowser.qutebrowser` → `qutebrowser`), then to a lettered tile. The packaged build adds Adwaita and hicolor to diff --git a/components/KeyboardGlyph.qml b/components/KeyboardGlyph.qml new file mode 100644 index 0000000..247622b --- /dev/null +++ b/components/KeyboardGlyph.qml @@ -0,0 +1,64 @@ +import QtQuick +import qs.config + +// The keyboard rune, drawn. Icon themes ship it symbolic only — black on +// transparent, which Qt renders as-is and would be invisible on the dark +// panel — and drawing it makes it recolourable, which is how the on/off state +// is shown. +Item { + id: root + + property int size: Theme.iconSize + property color color: Theme.text + + implicitWidth: root.size + implicitHeight: root.size + + // One key, and the space between two of them. Five keys and four gaps are + // exactly the width inside the case. + readonly property real key: Math.max(1, Math.round(root.size * 0.09)) + readonly property real gap: Math.max(1, Math.round(root.size * 0.07)) + + Rectangle { + anchors.centerIn: parent + + width: root.size + height: Math.round(root.size * 0.72) + radius: Math.max(2, Math.round(root.size * 0.12)) + color: "transparent" + border.width: Math.max(1, Math.round(root.size / 16)) + border.color: root.color + + Column { + anchors.centerIn: parent + spacing: root.gap + + Repeater { + model: 2 + + Row { + spacing: root.gap + + Repeater { + model: 5 + + Rectangle { + width: root.key + height: root.key + color: root.color + } + } + } + } + + // The space bar, short of the full width so the two rows above + // read as keys rather than as a grid. + Rectangle { + anchors.horizontalCenter: parent.horizontalCenter + width: root.key * 3 + root.gap * 2 + height: root.key + color: root.color + } + } + } +} diff --git a/config/Config.qml b/config/Config.qml index 91b1e1c..3d95bba 100644 --- a/config/Config.qml +++ b/config/Config.qml @@ -47,6 +47,16 @@ Singleton { return needsExecFlag.includes(env.split("/").pop()) ? [env, "-e"] : [env]; } + // ---- On-screen keyboard ---------------------------------------------- + + // Set false to drop the keyboard button. It also hides itself where the + // unit below is not installed. + readonly property bool showOsk: true + + // The user unit squeekboard runs as. Showing the keyboard starts it if it + // is not already up; hiding it leaves the unit running. + readonly property string oskUnit: "squeekboard.service" + // ---- Wifi ------------------------------------------------------------ // Set false to drop the wifi button. It also hides itself where there is no diff --git a/flake.nix b/flake.nix index c8ac8f0..c352612 100644 --- a/flake.nix +++ b/flake.nix @@ -41,7 +41,8 @@ --add-flags "--path $out/share/att_menu" \ --suffix PATH : ${ pkgs.lib.makeBinPath [ - # loginctl and systemctl back the default power actions. The + # loginctl and systemctl back the default power actions, and + # systemctl and busctl the on-screen keyboard button. The # session's own PATH is searched first, so a compositor's # swaymsg/hyprctl/niri is still picked up for logging out. pkgs.systemd diff --git a/modules/Bar.qml b/modules/Bar.qml index d359228..9dce3bf 100644 --- a/modules/Bar.qml +++ b/modules/Bar.qml @@ -59,6 +59,10 @@ PanelWindow { Layout.fillHeight: true } + OskMenu { + Layout.fillHeight: true + } + WifiMenu { bar: bar Layout.fillHeight: true diff --git a/modules/OskMenu.qml b/modules/OskMenu.qml new file mode 100644 index 0000000..c212285 --- /dev/null +++ b/modules/OskMenu.qml @@ -0,0 +1,32 @@ +import QtQuick +import qs.config +import qs.components +import qs.services + +// Right section: the on-screen keyboard button. No menu behind it — a click +// shows squeekboard, another hides it. +Item { + id: root + + // Machines with no squeekboard unit get no button at all. + visible: Osk.available + + implicitWidth: button.implicitWidth + + PanelButton { + id: button + + anchors.verticalCenter: parent.verticalCenter + height: root.height - Theme.spacing + minContentWidth: Theme.iconSize + active: Osk.shown + activeColor: Theme.accent + + onClicked: Osk.toggle() + + KeyboardGlyph { + size: Theme.iconSize + color: button.active ? Theme.background : Theme.text + } + } +} diff --git a/services/Osk.qml b/services/Osk.qml new file mode 100644 index 0000000..64c04d0 --- /dev/null +++ b/services/Osk.qml @@ -0,0 +1,220 @@ +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; + } + } +} diff --git a/shell.qml b/shell.qml index b49c287..8a35183 100644 --- a/shell.qml +++ b/shell.qml @@ -63,6 +63,27 @@ ShellRoot { } } + // Not a menu: this one drives squeekboard, so it is worth binding to a + // hardware key or a tablet-mode switch as well as clicking. + IpcHandler { + target: "osk" + + function toggle(): void { + Osk.toggle(); + } + + // Named to match the menu targets above: `qs ipc call` swallows a + // function called `show` — it collides with the `ipc show` subcommand + // — and a lone `hide` next to `open` would read oddly anyway. + function open(): void { + Osk.show(); + } + + function close(): void { + Osk.hide(); + } + } + IpcHandler { target: "bluetooth"