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:
parent
21b602a7d2
commit
5965c64781
6 changed files with 184 additions and 17 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue