Watch
1
0
Fork
You've already forked souveraine
0

shell: recover the phone-only edits into the repo

~/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.
This commit is contained in:
Fimeg 2026-07-29 08:27:27 -04:00
commit 5965c64781
6 changed files with 184 additions and 17 deletions

View file

@ -94,6 +94,47 @@ Singleton {
return str.toLowerCase().replace(/_/g, "-");
}
/**
* Resolve a window app_id / pinned appId to its DesktopEntry.
*
* DesktopEntries.heuristicLookup() matches the entry id and
* StartupWMClass, which covers most apps but NOT the ones that append an
* instance suffix to their Wayland app_id. Firefox is the reference case:
* it derives its remoting name from the profile, so the window reports
* "firefox-default" while the entry is "firefox" with
* StartupWMClass=firefox. heuristicLookup returns null, and a dock tap on
* a pinned-but-not-running app then calls execute() on null a silent
* dead tap that reports no error anywhere.
*
* So: fall back to trimming trailing "-segment" pieces, longest match
* first, then to a StartupWMClass prefix scan. Returns null only when
* nothing in the entry set plausibly owns the id.
*/
function resolveEntry(appId) {
if (!appId || appId.length == 0) return null;
const direct = DesktopEntries.heuristicLookup(appId);
if (direct) return direct;
// "firefox-default" -> "firefox"; "signal-desktop-beta" -> "signal-desktop"
let candidate = appId;
while (candidate.includes("-")) {
candidate = candidate.slice(0, candidate.lastIndexOf("-"));
const trimmed = DesktopEntries.heuristicLookup(candidate);
if (trimmed) return trimmed;
}
// Last resort: an entry whose StartupWMClass prefixes the app_id.
const lowered = appId.toLowerCase();
for (const entry of root.list) {
const wmClass = entry.startupClass;
if (!wmClass || wmClass.length == 0) continue;
if (lowered.startsWith(wmClass.toLowerCase())) return entry;
}
return null;
}
function guessIcon(str) {
if (!str || str.length == 0) return "image-missing";
@ -153,8 +194,9 @@ Singleton {
if (iconExists(guess)) return guess;
}
// Quickshell's desktop entry lookup
const heuristicEntry = DesktopEntries.heuristicLookup(str);
// Desktop entry lookup, suffix-tolerant so "firefox-default" and
// friends land on their real entry's icon instead of falling through.
const heuristicEntry = root.resolveEntry(str);
if (heuristicEntry) return heuristicEntry.icon;
// Give up

View file

@ -21,7 +21,10 @@ DockButton {
property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined
readonly property bool isSeparator: appToplevel.appId === "SEPARATOR"
property var desktopEntry: DesktopEntries.heuristicLookup(appToplevel.appId)
// Suffix-tolerant resolve: a bare heuristicLookup returns null for app_ids
// that carry an instance suffix (Firefox reports "firefox-default"), and a
// null entry makes tapping a pinned-but-not-running icon a silent no-op.
property var desktopEntry: AppSearch.resolveEntry(appToplevel.appId)
enabled: !isSeparator
implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset
@ -29,7 +32,7 @@ DockButton {
target: DesktopEntries
function onApplicationsChanged() {
root.desktopEntry = DesktopEntries.heuristicLookup(appToplevel.appId);
root.desktopEntry = AppSearch.resolveEntry(appToplevel.appId);
}
}

View file

@ -85,7 +85,8 @@ DockButton {
running.activate();
return;
}
const entry = DesktopEntries.heuristicLookup(members[i]);
// Suffix-tolerant see DockAppButton.desktopEntry.
const entry = AppSearch.resolveEntry(members[i]);
entry?.execute();
}

View file

@ -58,10 +58,21 @@ 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 one, and osk-switch picks the right unit and
// re-asserts visibility itself.
function showOsk() {
Quickshell.execDetached(["sh", "-c",
"pgrep -x squeekboard >/dev/null || { squeekboard & sleep 1; }; " +
"busctl call --user sm.puri.OSK0 /sm/puri/OSK0 sm.puri.OSK0 SetVisible b true"])
"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",

View file

@ -34,6 +34,7 @@ import Quickshell.Hyprland
import Quickshell.Wayland
import qs
import qs.modules.common
import qs.services
PanelWindow {
id: rail
@ -53,7 +54,7 @@ PanelWindow {
// visible it is the dismiss handle just resting exactly at the
// keyboard's top edge, never over the keys.
property int oskLift: 200
margins.bottom: GlobalStates.oskOpen ? rail.oskLift : 0
margins.bottom: (GlobalStates.oskOpen || rail.swapping) ? rail.oskLift : 0
exclusionMode: ExclusionMode.Ignore
color: "transparent"
// Top, not Overlay: stevia's completion bar / layout menu are child
@ -71,13 +72,30 @@ PanelWindow {
// to ~1s after SetVisible on a cold start (OnScreenKeyboard.qml sleeps 1
// before the D-Bus call), so a miss retries once.
property int oskProbeAttempts: 0
property int oskProbeMax: 2
// Re-measure and re-place the pill.
//
// This used to be triggered by oskOpen going true and nothing else, which
// is not enough once the keyboard can be SWAPPED: stevia is 348px tall and
// squeekboard is 315px, and oskOpen does not flip across a swap the
// keyboard stays "open" the whole time, only the daemon changes. So the
// lift kept the old keyboard's height and the pill landed 33px inside the
// 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.oskProbeAttempts = 0;
oskProbeRetry.stop();
oskProbe.running = true;
rail.oskProbeMax = 2;
rail.probeOskHeight();
}
}
}
@ -103,14 +121,18 @@ PanelWindow {
}
// Keyboard not mapped yet (cold start takes ~1s). Retry a
// couple of times, then keep the last known height.
if (GlobalStates.oskOpen && rail.oskProbeAttempts < 2)
if (GlobalStates.oskOpen && rail.oskProbeAttempts < rail.oskProbeMax)
oskProbeRetry.restart();
}
}
}
Timer {
id: oskProbeRetry
interval: 1200
// 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;
@ -123,6 +145,13 @@ PanelWindow {
// Control; releasing between the two commits the lower stage.
readonly property int revealAt: 48
readonly property int missionAt: 190
// 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. Deliberately beyond missionAt: a swap mid-sentence is
// disruptive, so it has to be an obviously further pull, not a near-miss of
// Mission Control.
readonly property int swapAt: 300
// Live upward travel of the in-flight drag (0 while idle, grows as the
// finger climbs). Drives the handle's appearance so the pill tracks the
@ -134,6 +163,24 @@ PanelWindow {
readonly property int dragStage: 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
@ -148,14 +195,19 @@ PanelWindow {
: rail.dragStage >= 1 ? 170 : 150)
+ Math.min(rail.dragTravel * 0.08, 24)
height: 7 + (rail.dragStage >= 2 ? 3 : rail.dragStage >= 1 ? 1 : 0)
radius: height / 2
// 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
// 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.dragStage >= 2
? (Appearance?.colors?.colPrimary ?? "#a0c8ff")
: "#e6ffffff"
color: rail.swapArmed
? (Appearance?.colors?.colSecondary ?? "#ffd7a0")
: rail.dragStage >= 2
? (Appearance?.colors?.colPrimary ?? "#a0c8ff")
: "#e6ffffff"
opacity: rail.dragging ? 1 : 0.9
// Idle nudge for the discovery hint: a brief scale pulse.
@ -261,6 +313,29 @@ PanelWindow {
const speed = travel / elapsed // px/ms, upward only
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) {
singleTapTimer.stop()
lastTapAt = -1
rail.swapping = true
swapSettle.restart()
// The incoming keyboard is a different height. Keep probing
// across the whole stop/start gap or the pill keeps the old
// one's lift and disappears into the new one's keys.
rail.oskProbeMax = 12
rail.probeOskHeight()
Gestures.deliver("osk-swap", Gestures.oskSwapAction)
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).

View file

@ -35,6 +35,11 @@ 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
@ -58,6 +63,24 @@ 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 0.56.0-9 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.
//
// DEBT: /usr/local/bin/osk-switch is owned by no package exactly
// what TASK-25 exists to stop, and TASK-38 flags it as a precondition
// rather than a detail. This action is the dependency that makes it
// load-bearing; it wants packaging into the rootfs overlay.
//
// 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();
Quickshell.execDetached(["osk-switch"]);
},
"screenshot": () => {
Haptics.confirm();
Quickshell.execDetached(["sh", "-c",
@ -110,6 +133,16 @@ 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 {
@ -117,6 +150,7 @@ Singleton {
squeeze: root.squeezeAction,
squeezeHold: root.squeezeHoldAction,
edge: root.edgeAction,
oskSwap: root.oskSwapAction,
available: Object.keys(root.actions),
lastGesture: root.lastGesture,
lastGestureAt: root.lastGestureAt
@ -131,6 +165,7 @@ 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 });
}