Watch
1
0
Fork
You've already forked souveraine
0
souveraine/surfaces/quickshell/services/Gestures.qml

176 lines
7.3 KiB
QML

// Gesture consumer — the userspace side of squeeze, and the seam every other
// physical gesture arrives through.
//
// This is deliberately written before the producer exists. TASK-13 has the
// Active Edge rail powered (PM8998 GPIO 2, held from boot) and the SSC
// registry admitting `sns_touch_gesture`, but nothing had anywhere to deliver
// a squeeze TO — and a bring-up with no consumer is a sensor that fires into
// nothing, which is exactly why grip stalled. The contract goes first now, so
// the producer has a defined target and can be tested the moment it works.
//
// The producer is not specified here on purpose. Anything that can reach the
// shell's IPC socket can deliver a gesture: a libssc client, a udev-spawned
// helper, an evdev reader, or a human running `qs ipc call gesture squeeze`
// to test the routing without any of that existing. That is the same posture
// the sensor reporters take — the source is replaceable, the contract is not.
//
// Routing is NOT hardcoded per gesture. `squeezeAction` names an action, and
// the action table below is the one place a gesture's meaning is decided, so
// a squeeze can be re-pointed without editing call sites. See the Keyboard
// System note in TASK-13 step 4: a squeeze must register as an input, not as
// a hardcoded binding, or it collides with everything else that wants it.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common
Singleton {
id: root
// What each gesture does. Names, not closures, so the choice is data a
// settings page or an agent can read and change.
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.
property string lastGesture: ""
property double lastGestureAt: 0
signal gestureReceived(string name)
readonly property var actions: ({
"dial": () => {
Haptics.tick();
Quickshell.execDetached(["qs", "-c", "souveraine", "ipc",
"--any-display", "call", "dial", "toggle"]);
},
"assistant": () => {
Haptics.tick();
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen;
},
"keyboard": () => {
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();
Quickshell.execDetached(["osk-switch"]);
},
"screenshot": () => {
Haptics.confirm();
Quickshell.execDetached(["sh", "-c",
"grim ~/Pictures/screenshot-$(date +%Y%m%d-%H%M%S).png"]);
},
"none": () => {}
})
// One entry point for every gesture, so the trail of "what arrived" is in
// one place and a refusal is legible rather than a silent no-op.
function deliver(name, action) {
root.lastGesture = name;
root.lastGestureAt = Date.now();
console.log("[gesture] " + name + " -> " + action);
root.gestureReceived(name);
const fn = root.actions[action];
if (!fn) {
console.log("[gesture] no action named '" + action + "'");
Haptics.refuse();
return false;
}
fn();
return true;
}
IpcHandler {
target: "gesture"
// The squeeze. Called by whatever ends up producing it.
function squeeze(): string {
return JSON.stringify({
ok: root.deliver("squeeze", root.squeezeAction),
action: root.squeezeAction
});
}
// A held squeeze is a different gesture, not a longer one.
function squeezeHold(): string {
return JSON.stringify({
ok: root.deliver("squeeze-hold", root.squeezeHoldAction),
action: root.squeezeHoldAction
});
}
function edge(): string {
return JSON.stringify({
ok: root.deliver("edge", root.edgeAction),
action: root.edgeAction
});
}
// 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 {
return JSON.stringify({
squeeze: root.squeezeAction,
squeezeHold: root.squeezeHoldAction,
edge: root.edgeAction,
oskSwap: root.oskSwapAction,
available: Object.keys(root.actions),
lastGesture: root.lastGesture,
lastGestureAt: root.lastGestureAt
});
}
// Re-point a gesture without editing code.
function bind(gesture: string, action: string): string {
if (!root.actions[action])
return JSON.stringify({ ok: false, reason: "no such action: " + action });
switch (gesture) {
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 });
}
return JSON.stringify({ ok: true, gesture: gesture, action: action });
}
}
}