~/souveraine-surfaces/quickshell on the phone is not a git repo and was the only copy of five files. Brought back verbatim: - Gestures.qml, SystemGestureRail.qml: TASK-38 osk-swap detent (DUMP §5, §6) - OnScreenKeyboard.qml: showOsk asks the bus instead of pgrep+sleep 1 - DockAppButton.qml, DockStack.qml: suffix-tolerant AppSearch.resolveEntry resolveEntry itself had been added to the phone's live ~/.config/quickshell/ii tree, which deploy.sh rsyncs from ii-base — the next deploy would have deleted it and left the two dock callers referring to nothing. It lands in ii-base here.
212 lines
8.9 KiB
QML
212 lines
8.9 KiB
QML
// Pixel3Arch replacement for ii's stock OnScreenKeyboard.qml (2026-07-07,
|
|
// reworked 2026-07-10: wvkbd -> squeekboard).
|
|
//
|
|
// ii's own on-screen keyboard is retired — squeekboard (3-finger swipe-up
|
|
// gesture, or the pill's long-hold) is the real keyboard now. It also
|
|
// auto-shows/hides itself on text-field focus via input-method-v2, which
|
|
// wvkbd never could. See docs/phone-shell-ux.md.
|
|
//
|
|
// This file keeps ii's OSK *plumbing* (GlobalStates.oskOpen, the "osk" IPC
|
|
// target, the oskToggle/oskOpen/oskClose global shortcuts — both the pill's
|
|
// long-hold and the 3-finger swipe-up gesture call `osk toggle`) all still
|
|
// working, but no longer renders a keyboard itself. It drives squeekboard
|
|
// over its D-Bus visibility interface (sm.puri.OSK0.SetVisible).
|
|
//
|
|
// Tapping the OSK while search/overview (or a sidebar) is open used to close
|
|
// it out from under you — that's `GlobalFocusGrab`'s HyprlandFocusGrab
|
|
// (a real Wayland protocol, hyprland_focus_grab_v1) clearing because the
|
|
// tap landed outside its whitelisted surfaces. Two "shield window" attempts
|
|
// to make wvkbd count as "inside" that whitelist both failed on real
|
|
// device testing:
|
|
// 1. mask: Region {} (empty, no item) — theory was this claims zero
|
|
// input area so taps pass through to wvkbd underneath while the
|
|
// window still counts toward the grab. Wrong in practice: search
|
|
// still closed on every tap, meaning an empty Region does NOT behave
|
|
// like zero input — it behaves like an unmasked/default full-window
|
|
// area, and the shield silently ate the taps itself.
|
|
// 2. mask: Region { item: <full-rect Item> } — mirrors the stock OSK's
|
|
// own working mask pattern exactly, but the stock OSK's mask matched
|
|
// ITS OWN visible key grid (so taps landed on real buttons). Our
|
|
// shield has no buttons — a full-covering mask would swallow every
|
|
// tap meant for wvkbd, making the keyboard untappable. Never shipped;
|
|
// caught before deploying back to the phone.
|
|
// There is no Quickshell or hyprctl API to add an arbitrary external
|
|
// process's Wayland surface (wvkbd is not a Quickshell QObject) to
|
|
// HyprlandFocusGrab's whitelist — confirmed via the actual
|
|
// hyprland-focus-grab-v1 protocol docs, which only expose whitelisting via
|
|
// the compositor-side protocol request, not anything scriptable from here
|
|
// without writing a standalone Wayland client. Not worth it for this.
|
|
//
|
|
// The actual fix lives in Overview.qml (and would need the same pattern in
|
|
// any other GlobalFocusGrab-dismissable surface if this bites there too):
|
|
// stop registering as dismissable at all while GlobalStates.oskOpen is
|
|
// true, so there's nothing for an outside tap to clear in the first place.
|
|
// See the comment there for details.
|
|
import qs
|
|
import qs.services
|
|
import qs.modules.common
|
|
import QtQuick
|
|
import Quickshell.Io
|
|
import Quickshell
|
|
import Quickshell.Hyprland
|
|
|
|
Scope {
|
|
id: root
|
|
|
|
// squeekboard exposes sm.puri.OSK0.SetVisible(b) on the session bus.
|
|
// Unlike wvkbd's signal dance this is an absolute set, so show/hide
|
|
// can't flip the wrong way. Known ceiling: squeekboard also shows and
|
|
// hides ITSELF on input-method focus, and oskOpen doesn't hear about
|
|
// that — the gesture toggle can need two swipes after an auto-show.
|
|
// One D-Bus call, no sleep. This used to read
|
|
// pgrep -x squeekboard >/dev/null || { squeekboard & sleep 1; }
|
|
// which was correct only while squeekboard was the sole keyboard. Once
|
|
// stevia became the default (2026-07-21) that pgrep missed on EVERY open:
|
|
// each one spawned a stray squeekboard to fight stevia for the
|
|
// sm.puri.OSK0 name, and — the visible damage — slept a full second before
|
|
// SetVisible. oskOpen flipped instantly, the keyboard arrived ~1s later,
|
|
// and the gesture rail spent that whole second lifted over nothing.
|
|
// Ask the bus first; whoever owns the name is the live keyboard. Only if
|
|
// nobody owns it do we start one, and osk-switch picks the right unit and
|
|
// re-asserts visibility itself.
|
|
function showOsk() {
|
|
Quickshell.execDetached(["sh", "-c",
|
|
"busctl --user call sm.puri.OSK0 /sm/puri/OSK0 sm.puri.OSK0 " +
|
|
"SetVisible b true 2>/dev/null || osk-switch stevia >/dev/null 2>&1"])
|
|
}
|
|
function hideOsk() {
|
|
Quickshell.execDetached(["busctl", "call", "--user",
|
|
"sm.puri.OSK0", "/sm/puri/OSK0", "sm.puri.OSK0", "SetVisible", "b", "false"])
|
|
}
|
|
|
|
Connections {
|
|
target: GlobalStates
|
|
function onOskOpenChanged() {
|
|
if (GlobalStates.oskOpen) {
|
|
root.showOsk();
|
|
} else {
|
|
root.hideOsk();
|
|
}
|
|
}
|
|
}
|
|
|
|
// squeekboard auto-shows/hides ITSELF on input-method focus (e.g. a
|
|
// sidebar taking or dropping a text field) without telling us. oskOpen
|
|
// then lies: the dock stays suppressed and the gesture rail floats at
|
|
// keyboard height over nothing until someone manually toggles. Mirror
|
|
// squeekboard's real Visible property back into oskOpen so there is
|
|
// exactly one truth. Setting oskOpen from here re-triggers SetVisible
|
|
// with the value squeekboard already has, which is a harmless no-op.
|
|
// A self-hide is squeekboard's opinion, not the user's. While a surface
|
|
// holds the keyboard (GlobalStates.oskHolds — polkit's password field is
|
|
// the first) that opinion is overridden and the keyboard comes straight
|
|
// back. Bounded: after this many re-asserts within one hold we stop and
|
|
// say so, rather than trading SetVisible calls with squeekboard forever.
|
|
property int maxReasserts: 5
|
|
property int reassertCount: 0
|
|
|
|
Connections {
|
|
target: GlobalStates
|
|
function onOskHoldsChanged() {
|
|
if (GlobalStates.oskHolds.length > 0) root.reassertCount = 0;
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: oskVisMonitor
|
|
running: true
|
|
command: ["gdbus", "monitor", "--session",
|
|
"--dest", "sm.puri.OSK0", "--object-path", "/sm/puri/OSK0"]
|
|
stdout: SplitParser {
|
|
onRead: line => {
|
|
if (!line.includes("'Visible'")) return;
|
|
const vis = line.includes("<true>");
|
|
if (!vis && GlobalStates.oskHolds.length > 0) {
|
|
if (root.reassertCount >= root.maxReasserts) {
|
|
console.log("[osk] squeekboard self-hid under hold ["
|
|
+ GlobalStates.oskHolds.join(",") + "] more than "
|
|
+ root.maxReasserts + " times; giving up the hold");
|
|
GlobalStates.oskDropHolds();
|
|
GlobalStates.oskOpen = false;
|
|
return;
|
|
}
|
|
root.reassertCount++;
|
|
console.log("[osk] squeekboard self-hid while held by ["
|
|
+ GlobalStates.oskHolds.join(",") + "]; re-asserting ("
|
|
+ root.reassertCount + "/" + root.maxReasserts + ")");
|
|
root.showOsk();
|
|
return;
|
|
}
|
|
if (GlobalStates.oskOpen !== vis) {
|
|
console.log("[osk] squeekboard self-" + (vis ? "showed" : "hid") + ", syncing oskOpen");
|
|
GlobalStates.oskOpen = vis;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every deliberate close — pill, gesture, shortcut, IPC — goes through
|
|
// here, because closing by hand is what drops a hold.
|
|
function userClose() {
|
|
GlobalStates.oskDropHolds();
|
|
GlobalStates.oskOpen = false;
|
|
}
|
|
|
|
IpcHandler {
|
|
target: "osk"
|
|
|
|
function toggle(): void {
|
|
if (GlobalStates.oskOpen) root.userClose();
|
|
else GlobalStates.oskOpen = true;
|
|
}
|
|
|
|
function close(): void {
|
|
root.userClose();
|
|
}
|
|
|
|
function open(): void {
|
|
GlobalStates.oskOpen = true;
|
|
}
|
|
|
|
// Which surfaces are holding the keyboard open, if any. The pill and
|
|
// the agent both ask "why won't this close"; this answers it.
|
|
function holds(): string {
|
|
return JSON.stringify(GlobalStates.oskHolds);
|
|
}
|
|
|
|
// Double-tapping the pill while the keyboard is open calls this —
|
|
// pops the (suppressed, see Dock.qml) dock back up for a few
|
|
// seconds without having to close the keyboard first.
|
|
function pulseDock(): void {
|
|
GlobalStates.pulseDockReveal();
|
|
}
|
|
}
|
|
|
|
GlobalShortcut {
|
|
name: "oskToggle"
|
|
description: "Toggles on screen keyboard on press"
|
|
|
|
onPressed: {
|
|
if (GlobalStates.oskOpen) root.userClose();
|
|
else GlobalStates.oskOpen = true;
|
|
}
|
|
}
|
|
|
|
GlobalShortcut {
|
|
name: "oskOpen"
|
|
description: "Opens on screen keyboard on press"
|
|
|
|
onPressed: {
|
|
GlobalStates.oskOpen = true;
|
|
}
|
|
}
|
|
|
|
GlobalShortcut {
|
|
name: "oskClose"
|
|
description: "Closes on screen keyboard on press"
|
|
|
|
onPressed: {
|
|
root.userClose();
|
|
}
|
|
}
|
|
}
|