diff --git a/surfaces/quickshell/deploy.sh b/surfaces/quickshell/deploy.sh index 6928bed..1fc360d 100755 --- a/surfaces/quickshell/deploy.sh +++ b/surfaces/quickshell/deploy.sh @@ -38,6 +38,7 @@ services/Ai.qml souveraine/services/Ai.qml services/Cellular.qml souveraine/services/Cellular.qml services/ChargeRate.qml souveraine/services/ChargeRate.qml services/Haptics.qml souveraine/services/Haptics.qml +services/Gestures.qml souveraine/services/Gestures.qml modules/souveraine/dial/RadialDial.qml souveraine/modules/souveraine/dial/RadialDial.qml modules/souveraine/dial/DialHost.qml souveraine/modules/souveraine/dial/DialHost.qml modules/souveraine/dial/qmldir souveraine/modules/souveraine/dial/qmldir diff --git a/surfaces/quickshell/modules/souveraine/dial/DialHost.qml b/surfaces/quickshell/modules/souveraine/dial/DialHost.qml index b1bfc13..a46530c 100644 --- a/surfaces/quickshell/modules/souveraine/dial/DialHost.qml +++ b/surfaces/quickshell/modules/souveraine/dial/DialHost.qml @@ -122,6 +122,8 @@ Scope { function open(): void { scope.dialOpen = true; + console.log("[dial] open -> dialOpen=" + scope.dialOpen + + " screens=" + Quickshell.screens.length); } function close(): void { diff --git a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml index 7b8926e..326d37d 100644 --- a/surfaces/quickshell/panelFamilies/SouveraineFamily.qml +++ b/surfaces/quickshell/panelFamilies/SouveraineFamily.qml @@ -2,6 +2,7 @@ import QtQuick import Quickshell import qs.modules.common +import qs.services // AppInventory.qml root is Item (not QtObject) so it can own its scanner // Process child — the original QtObject root failed with "no default property." import qs.modules.ii.appInventory @@ -63,6 +64,12 @@ Scope { PanelLoader { component: Overview {} } PanelLoader { component: Polkit {} } PanelLoader { component: DialHost {} } + + // Gestures is a singleton and QML instantiates singletons lazily, so its + // IpcHandler never registered — `gesture` answered "Target not found" + // while the file was plainly deployed. Touching a property is what brings + // it into existence. Same reason AppInventoryScope is a plain child. + Component.onCompleted: Gestures.squeezeAction PanelLoader { component: RegionSelector {} } PanelLoader { component: ScreenCorners {} } PanelLoader { component: ScreenTranslator {} } diff --git a/surfaces/quickshell/services/Gestures.qml b/surfaces/quickshell/services/Gestures.qml new file mode 100644 index 0000000..eebdf02 --- /dev/null +++ b/surfaces/quickshell/services/Gestures.qml @@ -0,0 +1,140 @@ +// 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" + + // 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; + }, + "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 + }); + } + + // 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, + 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; + default: + return JSON.stringify({ ok: false, reason: "no such gesture: " + gesture }); + } + return JSON.stringify({ ok: true, gesture: gesture, action: action }); + } + } +} diff --git a/surfaces/quickshell/services/qmldir b/surfaces/quickshell/services/qmldir index 2ef9c48..6e42e48 100644 --- a/surfaces/quickshell/services/qmldir +++ b/surfaces/quickshell/services/qmldir @@ -17,6 +17,7 @@ singleton EasyEffects 1.0 EasyEffects.qml singleton Emojis 1.0 Emojis.qml singleton FileSearch 1.0 FileSearch.qml singleton FirstRunExperience 1.0 FirstRunExperience.qml +singleton Gestures 1.0 Gestures.qml singleton GlobalFocusGrab 1.0 GlobalFocusGrab.qml singleton GoogleCloud 1.0 GoogleCloud.qml singleton Haptics 1.0 Haptics.qml