Watch
1
0
Fork
You've already forked souveraine
0

shell: the keyboard owns the bottom edge, and nothing measures it

Two bugs with one cause — the bottom edge was being reasoned about
twice, by hand, against a compositor that is gone.

The rail set ExclusionMode.Ignore (exclusive zone -1: ignore what
everyone else reserved, anchor to the whole screen) and then computed
its own lift from the keyboard's height, probed by shelling out to
`hyprctl -j layers`. Under viewtop there is no hyprctl, so every probe
threw a JSON.parse on empty output and logged it in a loop every second
or two, forever, while the margin stayed at a hardcoded fallback
measured against a keyboard that is not the one running. A 200px
fallback against some other height is exactly "the pill spawns in the
middle of the keyboard". Zone 0 with Normal — the idiom SidebarRight and
ReloadPopup already use — reserves nothing and respects what others
reserved, so the compositor anchors the rail to the top of the
keyboard's exclusive zone. Correct at any height, any rotation, across a
swap, with nothing to go stale. The probe, its retry timer and
probeOskHeight() are deleted rather than ported: a second measurement of
a number the protocol already carries is what this class of bug is made
of.

The dock consulted oskOpen only in its empty-desktop branch, below
effectivePinned, so a PINNED dock ignored the keyboard and sat under it.
DockManifest.dockState() has always reported "suppressed-by-osk" and
_blockReason() has always refused mutations with "osk-open" — the
manifest promised a suppression the dock never performed, so the agent's
view of the screen and the screen disagreed. The check moves above
pinning and applies in every state, staying below dockRevealPulse
because an explicit reveal is the one thing that should outrank the
keyboard.

Verified on the phone: the probe loop is gone from the journal. The
resulting pill and dock positions want eyes on the glass.
This commit is contained in:
Fimeg 2026-08-03 16:53:06 -04:00
commit ca44b0be2a
2 changed files with 64 additions and 83 deletions

View file

@ -79,6 +79,24 @@ Scope { // Scope
// glance at the dock while the keyboard is up. // glance at the dock while the keyboard is up.
if (GlobalStates.dockRevealPulse) if (GlobalStates.dockRevealPulse)
return Dock.DockState.Shown; return Dock.DockState.Shown;
// The keyboard owns the bottom edge, pinned or not.
//
// This check used to live below `effectivePinned` and inside the
// empty-desktop branch only, so a PINNED dock ignored the OSK entirely
// and sat under the keyboard — "the dock is still always showing with
// keyboard". Meanwhile DockManifest.dockState() has always reported
// "suppressed-by-osk" whenever oskOpen, and _blockReason() refuses
// mutations with "osk-open": the manifest promised a suppression the
// dock did not perform, so the agent's view of the screen and the
// screen disagreed. Same shape as the pill layerrule in
// INTERFACE-ARCHITECTURE — a documented guarantee the code does not
// enforce.
//
// Below the pulse deliberately: an explicit reveal is the one thing
// that outranks the keyboard, because glancing at the dock while
// typing is exactly what it is for.
if (GlobalStates.oskOpen)
return Dock.DockState.Hidden;
// Rail swipe-down dismissed a visible dock; swipe up brings it back. // Rail swipe-down dismissed a visible dock; swipe up brings it back.
if (GlobalStates.dockSuppressed) if (GlobalStates.dockSuppressed)
return Dock.DockState.Hidden; return Dock.DockState.Hidden;
@ -86,9 +104,9 @@ Scope { // Scope
return Dock.DockState.Pinned; return Dock.DockState.Pinned;
if (root.previewShowing) if (root.previewShowing)
return Dock.DockState.Shown; return Dock.DockState.Shown;
// Empty desktop (nothing focused) reveals the dock, unless the OSK // Empty desktop (nothing focused) reveals the dock. The OSK is handled
// took the bottom edge. // above now, for every state rather than only this one.
if (!GlobalStates.oskOpen && !ToplevelManager.activeToplevel?.activated) if (!ToplevelManager.activeToplevel?.activated)
return Dock.DockState.Shown; return Dock.DockState.Shown;
return Dock.DockState.Hidden; return Dock.DockState.Hidden;
} }

View file

@ -22,9 +22,9 @@
// it off for good once the gesture is found. This is the Souveraine analogue // it off for good once the gesture is found. This is the Souveraine analogue
// of Launcher3's AllAppsEduView triple-swipe hint, minus the full overlay. // of Launcher3's AllAppsEduView triple-swipe hint, minus the full overlay.
// //
// When the on-screen keyboard is summoned the rail rides ATOP it (bottom // When the on-screen keyboard is summoned the rail rides ATOP it — the
// margin = the keyboard's measured layer height, probed from // compositor anchors it above the keyboard's exclusive zone, so no height is
// `hyprctl -j layers`, namespace "osk"), and swipe down dismisses the // measured here at all — and swipe down dismisses the
// keyboard first; with no keyboard up, swipe down dismisses a visible dock // keyboard first; with no keyboard up, swipe down dismisses a visible dock
// in any state (GlobalStates.dockSuppressed — pinned included). // in any state (GlobalStates.dockSuppressed — pinned included).
import QtQuick import QtQuick
@ -53,9 +53,29 @@ PanelWindow {
// menu ("keyboard selection not working", 2026-07-21). The pill must stay // menu ("keyboard selection not working", 2026-07-21). The pill must stay
// visible — it is the dismiss handle — just resting exactly at the // visible — it is the dismiss handle — just resting exactly at the
// keyboard's top edge, never over the keys. // keyboard's top edge, never over the keys.
property int oskLift: 200 // The compositor places this above the keyboard. Nothing here measures it.
margins.bottom: (GlobalStates.oskOpen || rail.swapping) ? rail.oskLift : 0 //
exclusionMode: ExclusionMode.Ignore // This used to be `ExclusionMode.Ignore` — layer-shell's exclusive zone
// -1, "ignore what everyone else reserved and anchor to the whole screen" —
// and then hand-computed a bottom margin from the keyboard's height, probed
// by shelling out to `hyprctl -j layers`. Under viewtop there is no
// hyprctl, so the probe threw on every attempt (a JSON.parse of an empty
// string, logged in a loop every second or two forever) and the margin
// stayed at a hardcoded fallback measured against a keyboard that is no
// longer the one running. A fallback that is 200 while the keyboard is some
// other height is exactly "the pill spawns in the middle of the keyboard".
//
// 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.
//
// 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
// never to re-add the binding.
exclusionMode: ExclusionMode.Normal
exclusiveZone: 0
color: "transparent" color: "transparent"
// Top, not Overlay: stevia's completion bar / layout menu are child // 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 // surfaces of the OSK (which lives on Top). On Overlay the pill sat above
@ -68,76 +88,19 @@ PanelWindow {
WlrLayershell.layer: WlrLayer.Top WlrLayershell.layer: WlrLayer.Top
WlrLayershell.namespace: "souveraine:navigation-rail" WlrLayershell.namespace: "souveraine:navigation-rail"
// Measure the keyboard's real layer-surface height. squeekboard maps up // The height probe that used to live here is gone, along with its retry
// to ~1s after SetVisible on a cold start (OnScreenKeyboard.qml sleeps 1 // timer and `probeOskHeight()`. It existed to answer "how tall is the
// before the D-Bus call), so a miss retries once. // keyboard right now", including across a swap where stevia (348px) and
property int oskProbeAttempts: 0 // squeekboard (315px) differ and `oskOpen` never flips — and getting that
property int oskProbeMax: 2 // 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
// Re-measure and re-place the pill. // top edge of 732).
// //
// This used to be triggered by oskOpen going true and nothing else, which // The compositor answers it now, for free and always correctly, because the
// is not enough once the keyboard can be SWAPPED: stevia is 348px tall and // keyboard's exclusive zone is the same fact the probe was reconstructing.
// squeekboard is 315px, and oskOpen does not flip across a swap — the // Deleted rather than ported to a viewtop equivalent: a second measurement
// keyboard stays "open" the whole time, only the daemon changes. So the // of a number the protocol already carries is the drift this class of bug
// lift kept the old keyboard's height and the pill landed 33px inside the // is made of.
// new one's top rows, behind the keys, where it reads as simply gone.
// (Observed 2026-07-29: rail y=733 against an osk top edge of 732.)
// Any path that can change the keyboard's height must call this.
function probeOskHeight(): void {
rail.oskProbeAttempts = 0;
oskProbeRetry.stop();
oskProbe.running = true;
}
Connections {
target: GlobalStates
function onOskOpenChanged() {
if (GlobalStates.oskOpen) {
rail.oskProbeMax = 2;
rail.probeOskHeight();
}
}
}
Process {
id: oskProbe
command: ["hyprctl", "-j", "layers"]
stdout: StdioCollector {
onStreamFinished: {
try {
const monitors = JSON.parse(text);
for (const mon of Object.values(monitors)) {
for (const surfaces of Object.values(mon.levels)) {
for (const s of surfaces) {
if (s.namespace === "osk" && s.h > 0) {
rail.oskLift = s.h;
return;
}
}
}
}
} catch (e) {
console.log("[rail] osk layer probe parse failed:", e);
}
// Keyboard not mapped yet (cold start takes ~1s). Retry a
// couple of times, then keep the last known height.
if (GlobalStates.oskOpen && rail.oskProbeAttempts < rail.oskProbeMax)
oskProbeRetry.restart();
}
}
}
Timer {
id: oskProbeRetry
// Cold start is slow, but a swap has a real gap where NO osk layer is
// mapped at all (one unit stopped, the next not up yet), so poll harder
// while swapping — otherwise the pill wears the old height for over a
// second after every swap.
interval: rail.swapping ? 400 : 1200
onTriggered: {
rail.oskProbeAttempts += 1;
if (GlobalStates.oskOpen) oskProbe.running = true;
}
}
// --- Gesture stages ----------------------------------------------------- // --- Gesture stages -----------------------------------------------------
// Upward travel, in px, at which each stage arms. The drag must climb // Upward travel, in px, at which each stage arms. The drag must climb
@ -327,11 +290,11 @@ PanelWindow {
lastTapAt = -1 lastTapAt = -1
rail.swapping = true rail.swapping = true
swapSettle.restart() swapSettle.restart()
// The incoming keyboard is a different height. Keep probing // No re-probe across the swap. The incoming keyboard is a
// across the whole stop/start gap or the pill keeps the old // different height and that used to strand the pill inside the
// one's lift and disappears into the new one's keys. // new one's keys; the compositor re-anchors this surface when
rail.oskProbeMax = 12 // the new exclusive zone lands, so the height nobody measures
rail.probeOskHeight() // is the height that cannot be wrong.
Gestures.deliver("osk-swap", Gestures.oskSwapAction) Gestures.deliver("osk-swap", Gestures.oskSwapAction)
return return
} }