Watch
1
0
Fork
You've already forked souveraine
0

osk: retire the dead dual-keyboard path

This commit is contained in:
Fimeg 2026-08-11 19:21:21 -04:00
commit fb5e9bb80c
4 changed files with 36 additions and 179 deletions

View file

@ -80,7 +80,7 @@ RowLayout {
}
// Souveraine: drive the search from committed text PLUS live
// input-method composition. Stevia (sm.puri.OSK0) delivers typed
// input-method composition. Squeekboard (sm.puri.OSK0) delivers typed
// letters as pre-edit that it may never commit, so `text` alone stays
// empty while the letters are visibly in the field hardware
// keyboards and IPC setQuery worked, OSK typing searched nothing

View file

@ -58,21 +58,14 @@ Scope {
// 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 the sole intended keyboard through the
// transition helper; it also re-asserts visibility itself.
// Ask the bus first. If no owner exists, start the package-owned service;
// the owner monitor below pushes the pending visibility intent as soon as
// Squeekboard claims the name.
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 squeek >/dev/null 2>&1"])
"SetVisible b true 2>/dev/null || " +
"systemctl --user start squeekboard.service >/dev/null 2>&1"])
}
function hideOsk() {
Quickshell.execDetached(["busctl", "call", "--user",
@ -107,11 +100,8 @@ Scope {
// An owner change is not a visibility event.
//
// sm.puri.OSK0 is a *name*, not a process. Three different things put a
// fresh process behind it: boot, a crash-restart (the keyboards exit
// `Error reading events from display: Broken pipe` when their Wayland
// connection is perturbed, and both units are Restart=on-failure), and
// `osk-switch` swapping squeekboard for stevia at the user's gesture.
// sm.puri.OSK0 is a *name*, not a process. Boot and a crash-restart can
// both put a fresh Squeekboard process behind it.
// Each new process announces its own idea of Visible, and until now this
// monitor mirrored that into oskOpen indistinguishable from the
// keyboard deciding to show itself. Measured on 2026-08-05: shell up at
@ -142,14 +132,9 @@ Scope {
interval: root.ownerSettleMs
onTriggered: {
root.ownerSettling = false;
// Push once more on the way out. The first push (at adopt time)
// can lose a race: osk-switch sleeps 0.3s and then deliberately
// SetVisible(true), and a keyboard that self-shows on startup
// does so after it has finished coming up both land *after* we
// spoke. Measured 2026-08-05: adopt+hide at 07:37:51, stevia
// Visible=true at 07:37:52, leaving oskOpen=false while a
// keyboard sat on the screen. Whoever spoke last during the
// settle window does not get to be the winner; intent does.
// Push once more on the way out. The first push can lose a race to
// Squeekboard self-showing after startup. Whoever speaks last
// during the settle window does not get to win; intent does.
root.assertIntent();
}
}
@ -254,7 +239,7 @@ Scope {
// Unlocking must not leave a keyboard behind.
//
// Nothing asks for one the lock module never touches `oskOpen`, and only
// polkit takes a hold. squeekboard/stevia self-show whenever input-method
// polkit takes a hold. Squeekboard self-shows whenever input-method
// focus lands on them (GlobalStates' own note: "self-showed / self-hid
// every couple of seconds, ending in Visible=true with no keyboard in front
// of the user"), and the surfaces coming back at unlock are exactly such a

View file

@ -12,13 +12,10 @@
// by making it wait 350 ms first. Both are gone, along with the double tap's
// `hyprctl` fullscreen (START-HERE §3).
//
// One exception, and it is the keyboard's: with an OSK up, the climb means
// only the swap an upward pull past the swap detent swaps the keyboard
// (TASK-38), and the navigation stages (multitasking, home) do not exist for
// the duration. The keyboard owns the edge it rides on; a swipe that flips an
// app into multitasking while the user was mid-sentence was the complaint as
// it presented, and the swap detent has to cost a deliberate reach, not a
// near miss. Keyboard down, the two swipe modes are the whole of it.
// With an OSK up, upward navigation is gated off. The keyboard owns the edge
// it rides on; a swipe that flips an app into multitasking while the user was
// mid-sentence was the complaint as it presented. Layout selection now lives
// on Squeekboard's own mode key, so the rail has no keyboard-swap gesture.
//
// The upward gesture is progressive, not a single threshold, and the travel is
// *reported* to `ZoneTransition` rather than interpreted here: the compositor
@ -56,17 +53,6 @@ PanelWindow {
anchors.bottom: true
implicitWidth: 200
implicitHeight: Config.options?.dock.gestureRailHeight ?? 32
// Last measured squeekboard height; the probe below corrects it every
// time the keyboard opens (rotation changes it). 315 = portrait height
// on the Pixel's 540x1080 logical screen, the value measured live
// only a first-boot fallback until the first probe lands.
// Stevia's portrait height is 200px on the 540x1080 logical screen (the
// probe below corrects it live per rotation). Fallback WAS 315 a stale
// squeekboard value that lifted the pill WAY above stevia's top edge,
// landing it on the top key row and inside stevia's upward-opening layout
// menu ("keyboard selection not working", 2026-07-21). The pill must stay
// visible it is the dismiss handle just resting exactly at the
// keyboard's top edge, never over the keys.
// The compositor places this above the keyboard. Nothing here measures it.
//
// This used to be `ExclusionMode.Ignore` layer-shell's exclusive zone
@ -82,8 +68,7 @@ PanelWindow {
// Zone 0 with Normal is the shell's own idiom for this (SidebarRight,
// SidebarLeft unpinned, ReloadPopup): reserve nothing, but respect what
// others reserved. The keyboard already declares an exclusive zone, so the
// compositor anchors this to the top of it correct for any keyboard, any
// height, any rotation, and across a swap, with no measurement to go stale.
// compositor anchors this to its top correct at any height or rotation.
//
// START-HERE §3: a control that worked under Hyprland and does nothing now
// is almost always a hyprctl or a hyprland.lua binding, and the fix is
@ -100,11 +85,9 @@ PanelWindow {
exclusionMode: GlobalStates.oskOpen ? ExclusionMode.Normal : ExclusionMode.Ignore
exclusiveZone: 0
color: "transparent"
// Top, not Overlay: stevia's completion bar / layout menu are child
// surfaces of the OSK (which lives on Top). On Overlay the pill sat above
// them, so tapping word-completion opened a menu that rendered *behind*
// the pill and looked broken. On Top the pill shares the OSK's band and
// those popups draw over it. Tradeoff: dock and mission control are on
// Top, not Overlay: Squeekboard's layout chooser is a child surface of the
// OSK. On Overlay the pill could sit above that chooser and steal its taps;
// on Top the popup draws over the pill. Dock and mission control are on
// Overlay, so the pill now sits behind them when they're open fine,
// since those are summoned *from* the pill and occupy the screen anyway.
// The pill still clears fullscreen apps (below Top), which is its job.
@ -113,11 +96,8 @@ PanelWindow {
// The height probe that used to live here is gone, along with its retry
// timer and `probeOskHeight()`. It existed to answer "how tall is the
// keyboard right now", including across a swap where stevia (348px) and
// squeekboard (315px) differ and `oskOpen` never flips and getting that
// answer wrong put the pill 33px inside the new keyboard's top rows, where
// it reads as simply gone (observed 2026-07-29: rail y=733 against an osk
// top edge of 732).
// keyboard right now"; getting that answer wrong put the pill inside the
// keyboard's top rows, where it read as simply gone.
//
// The compositor answers it now, for free and always correctly, because the
// keyboard's exclusive zone is the same fact the probe was reconstructing.
@ -151,14 +131,6 @@ PanelWindow {
// idiom for this (`SelectionHost`, `FullscreenPolkitWindow`).
readonly property int missionAt: Math.max(revealAt + 60,
Math.round((rail.screen?.height ?? 1080) * 0.18))
// TASK-38: one more detent PAST the mission/max stage, armed only while an
// OSK is up, that swaps stevia <-> squeekboard. stevia dropped its terminal
// layout in 0.56.0-9, so this gesture is the only way to reach a terminal
// keyboard. It is the ONLY stage while a keyboard is up the nav stages
// are gated off (see `dragStage`), so the pull is a deliberate, exclusive
// reach rather than a near-miss of Mission Control.
readonly property int swapAt: 300
// Speed and length become one number: how far the thumb would have carried
// had it kept going. 130 ms is where the old rule sat "1.1 px/ms promotes
// one stage" over a 146 px stage gap so the feel is the same minus the
@ -280,31 +252,12 @@ PanelWindow {
}
// Stage the current travel has reached: 0 none, 1 reveal, 2 mission.
// With a keyboard up the navigation stages do not exist the climb means
// only the swap, and an app mid-sentence has no business shrinking under
// the thumb (the drag is the keyboard's then, not the canvas's).
// With a keyboard up the navigation stages do not exist; an app
// mid-sentence has no business shrinking under the thumb.
readonly property int dragStage: GlobalStates.oskOpen ? 0
: dragTravel >= missionAt ? 2
: dragTravel >= revealAt ? 1 : 0
// Stage 3, keyboard only. Kept out of dragStage so none of the existing
// stage arithmetic changes meaning.
readonly property bool swapArmed: GlobalStates.oskOpen && dragTravel >= swapAt
// A detent has to be feelable or the gesture can't be found without
// looking, which is the whole argument for having it. Fires once on the
// crossing, not per frame.
onSwapArmedChanged: if (rail.swapArmed) Haptics.tick()
// The swap stops one keyboard unit and starts another; in that gap the osk
// layer unmaps and margins.bottom would collapse to 0 and snap back, which
// reads as the pill falling off the screen. Hold the lift across the gap.
property bool swapping: false
Timer {
id: swapSettle
interval: 4000
onTriggered: rail.swapping = false
}
Rectangle {
id: handle
anchors.horizontalCenter: parent.horizontalCenter
@ -319,17 +272,12 @@ PanelWindow {
: rail.dragStage >= 1 ? 170 : 150)
+ Math.min(rail.dragTravel * 0.08, 24)
height: 7 + (rail.dragStage >= 2 ? 3 : rail.dragStage >= 1 ? 1 : 0)
// Square off at the swap detent. Stage 2 is already the widest and
// brightest the pill gets, so the extra step needs a change of SHAPE to
// read as a further stage rather than more of the same.
radius: rail.swapArmed ? 2 : height / 2
radius: height / 2
// Brighten and tint toward the accent as the drag escalates. Stage 2
// pulls the handle to the theme accent the "you've reached Mission
// Control" tell.
color: rail.swapArmed
? (Appearance?.colors?.colSecondary ?? "#ffd7a0")
: rail.dragStage >= 2
color: rail.dragStage >= 2
? (Appearance?.colors?.colPrimary ?? "#a0c8ff")
: "#e6ffffff"
// Invisible while the dock is up and nothing is being dragged the dock
@ -505,11 +453,9 @@ PanelWindow {
// the drag is abandoned there.
//
// The pose is a navigation gesture's transition. With a keyboard
// up the climb is the swap pull instead a scale that starts while
// the finger is still short of the swap detent is the app "going
// into multitasking" for a gesture that is not about multitasking,
// which is exactly the wrong read (observed 2026-08-06). The
// keyboard's climb touches nothing; only the swap commits.
// up the climb belongs to the keyboard instead. A scale under that
// thumb reads as the app entering multitasking, so the keyboard's
// climb deliberately touches nothing.
if (!GlobalStates.oskOpen) {
// Name the destination once, at the moment this becomes an
// upward pull quickstep's `getSwipeUpDestinationAndLength`
@ -526,10 +472,8 @@ PanelWindow {
// running long before anything could commit and the motion is
// still continuous from the start of the climb.
//
// Not with a keyboard up: there the climb is the swap pull and
// touches nothing. An app shrinking under a thumb that was
// reaching for a different keyboard is the wrong read, and was
// the keyboard-swap complaint as it presented (2026-08-06).
// Not with a keyboard up: the climb touches nothing. Layout
// choice is an explicit key on Squeekboard itself.
if (!ZoneTransition.inFlight && rail.dragTravel >= 8)
ZoneTransition.begin()
// The clock, and nothing derived from it. `pullTo` turns this
@ -579,38 +523,13 @@ PanelWindow {
const projected = travel + speed * rail.projectMs
rail.dragTravel = 0
// TASK-38: past the swap detent with a keyboard up, this gesture IS
// the keyboard swap it does not also commit Mission Control on
// the way through. No flick promotion either: the swap costs a
// deliberate pull, and this way the commit matches exactly what
// swapArmed lit up under the thumb.
//
// Routed through the gesture table rather than calling osk-switch
// here, per INTERFACE-ARCHITECTURE §4 the binding stays data, so
// it can be re-pointed without editing this file.
if (GlobalStates.oskOpen && travel >= rail.swapAt) {
rail.swapping = true
swapSettle.restart()
// No re-probe across the swap. The incoming keyboard is a
// different height and that used to strand the pill inside the
// new one's keys; the compositor re-anchors this surface when
// the new exclusive zone lands, so the height nobody measures
// is the height that cannot be wrong.
Gestures.deliver("osk-swap", Gestures.oskSwapAction)
rail.dragging = false
return
}
// Upward: pick the committed stage from travel, then let a fast
// flick promote it one step (a quick short throw still reaches
// Mission Control).
//
// Navigation is OFF while the keyboard is up: the climb means only
// the swap, and a pull that fell short of the swap detent is an
// abandoned pull, not a mission-to-home gesture. A short swipe that
// flipped the app into multitasking was the keyboard-swap complaint
// as it actually presented (observed 2026-08-06) the swap path
// above is the *only* thing a keyboard-up climb can commit.
// Navigation is OFF while the keyboard is up: the climb is an
// abandoned pull, not a mission-to-home gesture. Layout selection
// stays on Squeekboard's mode key.
// `travel` floors it: projection alone would turn a 10 px smudge
// flicked in 5 ms into a 270 px throw, i.e. Home. Bare `tapSlop`
// it is the MouseArea's. The old flick rule read `rail.tapSlop`,
@ -663,9 +582,8 @@ PanelWindow {
// competing for the one region a thumb rests in and one of them
// could only resolve by making the other wait 350 ms first.
//
// Not while the keyboard is up: a short climb there is a swap pull
// that fell short a failed attempt at a gesture that exists
// not a failed attempt at one that does not.
// Not while the keyboard is up: navigation discovery should not
// train against a gesture that is intentionally gated off.
if (travel > tapSlop && !GlobalStates.oskOpen)
maybeNudgeDiscovery()
}

View file

@ -35,12 +35,6 @@ Singleton {
property string squeezeAction: "dial"
property string squeezeHoldAction: "assistant"
property string edgeAction: "dial"
// The nav pill's extra detent, past swipe-to-maximum, while an OSK is up.
// Named here rather than wired into the pill for the same reason as the
// squeeze: TASK-38 says this registers as an action, not as a hardcoded
// pill binding (INTERFACE-ARCHITECTURE §4).
property string oskSwapAction: "oskSwap"
// Last gesture seen, so a surface can show that the hardware is alive even
// before anything is bound to it the difference between "grip does
// nothing" and "grip is not reaching us" is the whole debugging story.
@ -63,34 +57,6 @@ Singleton {
Haptics.tick();
GlobalStates.oskOpen = !GlobalStates.oskOpen;
},
// Swap which on-screen keyboard is running: stevia (daily word
// completion, dictation mic) <-> squeekboard (terminal arrows, fn,
// dense grid). stevia dropped its terminal layout and the
// POS_INPUT_METHOD_PURPOSE_TERMINAL auto-switch with it, so without
// this there is no way to reach a terminal layout by gesture at all.
//
// Called by name, not by path: osk-switch is /usr/bin/osk-switch, owned
// by souveraine-osk-switch (Pixel3Arch cd8b67e), which depends on both
// souveraine-stevia and souveraine-squeekboard. It was unowned at
// /usr/local/bin until 2026-07-29 a shipped gesture whose only effect
// was a file no package knew about and a reprovision would lose.
//
// confirm(), not tick(): the detent already ticked when the drag
// crossed it, and a swap that has actually been committed should not
// feel the same as crossing the line.
"oskSwap": () => {
Haptics.confirm();
// Declare the intent before the swap, not after it. osk-switch
// deliberately re-shows the keyboard on the new daemon ("so the
// swap is visible immediately"), and OnScreenKeyboard re-asserts
// oskOpen at whoever takes the sm.puri.OSK0 name. If the shell
// did not record that a swap means "keyboard up", those two would
// fight: osk-switch shows it, the shell pushes its stale false,
// and which one the user gets is a race. A swap is a request for
// a keyboard.
GlobalStates.oskOpen = true;
Quickshell.execDetached(["osk-switch"]);
},
"screenshot": () => {
Haptics.confirm();
Quickshell.execDetached(["sh", "-c",
@ -143,16 +109,6 @@ Singleton {
});
}
// The nav pill's past-maximum detent. Exposed here so the swap is
// testable without a touchscreen `qs -c souveraine ipc call gesture
// oskSwap` is the same path the pill takes.
function oskSwap(): string {
return JSON.stringify({
ok: root.deliver("osk-swap", root.oskSwapAction),
action: root.oskSwapAction
});
}
// What is bound to what, and what was last seen. An agent or a
// settings page reads this instead of guessing.
function state(): string {
@ -160,7 +116,6 @@ Singleton {
squeeze: root.squeezeAction,
squeezeHold: root.squeezeHoldAction,
edge: root.edgeAction,
oskSwap: root.oskSwapAction,
available: Object.keys(root.actions),
lastGesture: root.lastGesture,
lastGestureAt: root.lastGestureAt
@ -175,7 +130,6 @@ Singleton {
case "squeeze": root.squeezeAction = action; break;
case "squeeze-hold": root.squeezeHoldAction = action; break;
case "edge": root.edgeAction = action; break;
case "osk-swap": root.oskSwapAction = action; break;
default:
return JSON.stringify({ ok: false, reason: "no such gesture: " + gesture });
}