add squeekboard toggle button

This commit is contained in:
2026-08-02 12:16:23 +02:00
parent 18c00d40b4
commit 9eca88992e
8 changed files with 394 additions and 2 deletions
+41 -1
View File
@@ -7,7 +7,7 @@ A bottom desktop-environment panel for wlroots-based compositors, built on
| ------- | ----------------------------------------------------------------- | | ------- | ----------------------------------------------------------------- |
| Left | Application menu built from `.desktop` entries, with search | | Left | Application menu built from `.desktop` entries, with search |
| Middle | Open windows (task list), click to focus, middle-click to close | | 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 No clock, no tray, no status readouts — it is designed to sit alongside a
separate status bar. separate status bar.
@@ -25,6 +25,8 @@ separate status bar.
- NetworkManager for the wifi section, likewise optional. A NetworkManager - NetworkManager for the wifi section, likewise optional. A NetworkManager
configured with `wifi.backend=iwd` is fine — and is what this was built configured with `wifi.backend=iwd` is fine — and is what this was built
against — since the connection still goes through iwd underneath 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 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, 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 focused window. Only one menu is open at a time, so `close` closes whichever it
is. 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 ## Behaviour
**Placement.** The panel is a layer-shell surface anchored to the left, right and **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 `xdg-popup`, which is what makes click-away dismissal and keyboard focus work on
any wlroots compositor without compositor-specific focus grabs. 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 **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 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 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. small output still gets a usable menu.
- `terminal` — argv prefix for entries with `Terminal=true`. Honours - `terminal` — argv prefix for entries with `Terminal=true`. Honours
`$TERMINAL`; otherwise change the `["foot"]` fallback to your terminal. `$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. - `showWifi` — set `false` to drop the wifi button entirely.
- `wifiLabelMaxWidth` — cap on the SSID shown on the panel. - `wifiLabelMaxWidth` — cap on the SSID shown on the panel.
- `wifiMenuWidth` / `wifiMenuHeight` — also clamped to the screen. - `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/Session.qml Which menu is open, and on which screen
services/AppLauncher.qml Desktop-entry launching services/AppLauncher.qml Desktop-entry launching
services/Attwm.qml attwm's IPC socket, when there is one 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/Bar.qml The panel window and its three sections
modules/AppMenu.qml Left: launcher button modules/AppMenu.qml Left: launcher button
modules/Launcher.qml The application menu overlay modules/Launcher.qml The application menu overlay
modules/AppEntry.qml One row in the menu modules/AppEntry.qml One row in the menu
modules/WindowList.qml Middle: task list modules/WindowList.qml Middle: task list
modules/WindowButton.qml One task button modules/WindowButton.qml One task button
modules/OskMenu.qml Right: on-screen keyboard button
modules/WifiMenu.qml Right: wifi button modules/WifiMenu.qml Right: wifi button
modules/WifiPanel.qml The network list overlay modules/WifiPanel.qml The network list overlay
modules/WifiNetworkEntry.qml One network row 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/PanelButton.qml Shared hover/press surface
components/BluetoothGlyph.qml The bluetooth rune, drawn components/BluetoothGlyph.qml The bluetooth rune, drawn
components/WifiGlyph.qml The wifi fan, drawn components/WifiGlyph.qml The wifi fan, drawn
components/KeyboardGlyph.qml The keyboard, drawn
components/ToggleSwitch.qml On/off switch 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 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 — scope rather than a service, so the app inherits the environment it needs —
`WAYLAND_DISPLAY` above all. `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 - 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 of a reverse-DNS app id (`org.qutebrowser.qutebrowser``qutebrowser`), then
to a lettered tile. The packaged build adds Adwaita and hicolor to to a lettered tile. The packaged build adds Adwaita and hicolor to
+64
View File
@@ -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
}
}
}
}
+10
View File
@@ -47,6 +47,16 @@ Singleton {
return needsExecFlag.includes(env.split("/").pop()) ? [env, "-e"] : [env]; 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 ------------------------------------------------------------ // ---- Wifi ------------------------------------------------------------
// Set false to drop the wifi button. It also hides itself where there is no // Set false to drop the wifi button. It also hides itself where there is no
+2 -1
View File
@@ -41,7 +41,8 @@
--add-flags "--path $out/share/att_menu" \ --add-flags "--path $out/share/att_menu" \
--suffix PATH : ${ --suffix PATH : ${
pkgs.lib.makeBinPath [ 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 # session's own PATH is searched first, so a compositor's
# swaymsg/hyprctl/niri is still picked up for logging out. # swaymsg/hyprctl/niri is still picked up for logging out.
pkgs.systemd pkgs.systemd
+4
View File
@@ -59,6 +59,10 @@ PanelWindow {
Layout.fillHeight: true Layout.fillHeight: true
} }
OskMenu {
Layout.fillHeight: true
}
WifiMenu { WifiMenu {
bar: bar bar: bar
Layout.fillHeight: true Layout.fillHeight: true
+32
View File
@@ -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
}
}
}
+220
View File
@@ -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;
}
}
}
+21
View File
@@ -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 { IpcHandler {
target: "bluetooth" target: "bluetooth"