dial: radial picker, haptic detents, dial IPC
This commit is contained in:
parent
2c1ee8bc00
commit
bef6e6a653
7 changed files with 416 additions and 0 deletions
|
|
@ -37,6 +37,10 @@ services/Souveraine.qml souveraine/services/Souveraine.qml
|
|||
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
|
||||
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
|
||||
services/Network.qml souveraine/services/Network.qml
|
||||
services/TaskbarApps.qml souveraine/services/TaskbarApps.qml
|
||||
services/GlobalFocusGrab.qml souveraine/services/GlobalFocusGrab.qml
|
||||
|
|
|
|||
153
surfaces/quickshell/modules/souveraine/dial/DialHost.qml
Normal file
153
surfaces/quickshell/modules/souveraine/dial/DialHost.qml
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// The dial's surface and its entries.
|
||||
//
|
||||
// RadialDial.qml is the component; this is the panel it lives on and the list
|
||||
// it renders. Split so the entries can move to the verb tables (TASK-30)
|
||||
// without touching the ring, and so the ring can be reused by anything else
|
||||
// that wants a thumb-picker.
|
||||
//
|
||||
// Entries are the ones Casey named: kill the current window, screenshot,
|
||||
// system health, the agent — plus lock, which is the one every phone needs
|
||||
// within a thumb's reach.
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import "." as DialLocal
|
||||
|
||||
Scope {
|
||||
id: scope
|
||||
|
||||
property bool dialOpen: false
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
PanelWindow {
|
||||
id: win
|
||||
required property var modelData
|
||||
screen: win.modelData
|
||||
|
||||
anchors { top: true; left: true; right: true; bottom: true }
|
||||
color: "transparent"
|
||||
visible: scope.dialOpen
|
||||
WlrLayershell.namespace: "souveraine:dial"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
// OnDemand, not Exclusive: an exclusive grab was tried on the
|
||||
// polkit surface 2026-07-26 and stopped touch reaching the layer
|
||||
// below it entirely.
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
// `open` is assigned, never bound: openAt() writes it, and a
|
||||
// binding here would be broken by that write on the first call.
|
||||
// The host drives it in one direction and listens for the close.
|
||||
DialLocal.RadialDial {
|
||||
id: dial
|
||||
entries: scope.entries
|
||||
onOpenChanged: {
|
||||
if (!dial.open)
|
||||
scope.dialOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: scope
|
||||
function onDialOpenChanged() {
|
||||
if (scope.dialOpen)
|
||||
dial.openAt(win.width / 2, win.height * 0.68);
|
||||
else
|
||||
dial.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The list. `enabled: false` carries a `reason` so a refusal explains
|
||||
// itself in the middle of the ring rather than greying out silently.
|
||||
readonly property var entries: [
|
||||
{
|
||||
icon: "lock",
|
||||
label: "Lock",
|
||||
verb: () => { Haptics.confirm(); Session.lock(); }
|
||||
},
|
||||
{
|
||||
icon: "photo_camera",
|
||||
label: "Screenshot",
|
||||
verb: () => {
|
||||
Haptics.confirm();
|
||||
Quickshell.execDetached(["sh", "-c",
|
||||
"grim ~/Pictures/screenshot-$(date +%Y%m%d-%H%M%S).png"]);
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "cancel",
|
||||
label: "Kill window",
|
||||
enabled: !!Hyprland.activeToplevel,
|
||||
reason: "no focused window to close",
|
||||
verb: () => {
|
||||
const addr = Hyprland.activeToplevel?.address;
|
||||
if (!addr) { Haptics.refuse(); return; }
|
||||
Haptics.confirm();
|
||||
const a = addr.startsWith("0x") ? addr : "0x" + addr;
|
||||
Quickshell.execDetached(["hyprctl", "dispatch",
|
||||
`hl.dsp.window.close({ window = "address:${a}" })`]);
|
||||
}
|
||||
},
|
||||
{
|
||||
icon: "monitor_heart",
|
||||
label: "Health",
|
||||
verb: () => { Haptics.confirm(); GlobalStates.sidebarRightOpen = true; }
|
||||
},
|
||||
{
|
||||
icon: "smart_toy",
|
||||
label: "Agent",
|
||||
verb: () => { Haptics.confirm(); GlobalStates.sidebarLeftOpen = true; }
|
||||
},
|
||||
{
|
||||
icon: "keyboard",
|
||||
label: "Keyboard",
|
||||
verb: () => { Haptics.confirm(); GlobalStates.oskOpen = !GlobalStates.oskOpen; }
|
||||
}
|
||||
]
|
||||
|
||||
// Anything that can reach this can raise the dial: the pill's long-hold,
|
||||
// an edge gesture, a squeeze once grip produces events, or an agent.
|
||||
IpcHandler {
|
||||
target: "dial"
|
||||
|
||||
function open(): void {
|
||||
scope.dialOpen = true;
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
scope.dialOpen = false;
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
scope.dialOpen = !scope.dialOpen;
|
||||
}
|
||||
|
||||
// What the ring currently offers, so a caller can see the entries
|
||||
// without opening it. JSON-over-string: quickshell marshals a `var`
|
||||
// return as VOID.
|
||||
function entries(): string {
|
||||
return JSON.stringify(scope.entries.map(e => ({
|
||||
label: e.label,
|
||||
icon: e.icon,
|
||||
enabled: e.enabled !== false,
|
||||
reason: e.reason ?? ""
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "dialToggle"
|
||||
description: "Toggle the radial dial"
|
||||
onPressed: scope.dialOpen = !scope.dialOpen
|
||||
}
|
||||
}
|
||||
204
surfaces/quickshell/modules/souveraine/dial/RadialDial.qml
Normal file
204
surfaces/quickshell/modules/souveraine/dial/RadialDial.qml
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// The radial dial — a thumb-reachable ring of verbs.
|
||||
//
|
||||
// Component before contents (TASK-31). The ring, the hit-testing, the detents
|
||||
// and the dismissal are the hard part and they are here; the entries are a
|
||||
// list this file consumes, not a list it owns. When the verb tables land
|
||||
// (TASK-30) `entries` gets fed from `describe` instead of from the default
|
||||
// below, and nothing else in this file changes. That is the whole reason the
|
||||
// entry shape is {icon, label, verb, enabled, reason} — `reason` exists so a
|
||||
// refused verb can show the refusal's own words rather than going quietly grey.
|
||||
//
|
||||
// Invocation is deliberately not owned here either. Anything that can set
|
||||
// `open` can raise it: the pill's long-hold, an edge gesture, a squeeze once
|
||||
// grip produces events, or `dial open` over IPC.
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import qs.modules.common.widgets
|
||||
|
||||
Item {
|
||||
id: root
|
||||
anchors.fill: parent
|
||||
visible: opacity > 0.01
|
||||
opacity: root.open ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
|
||||
|
||||
property bool open: false
|
||||
|
||||
// Where the ring is centred. Set by whatever opened it — a long-press
|
||||
// hands over the touch point so the ring appears under the thumb rather
|
||||
// than in the middle of a phone you are holding by one edge.
|
||||
property real originX: width / 2
|
||||
property real originY: height * 0.68
|
||||
|
||||
readonly property real ringRadius: Math.min(width, height) * 0.30
|
||||
readonly property real deadZone: ringRadius * 0.42
|
||||
|
||||
// One entry per verb. `verb` is a callable; nothing here knows what it
|
||||
// does, which is what lets the same component serve system actions, agent
|
||||
// actions and app actions without growing a switch statement.
|
||||
property var entries: []
|
||||
|
||||
// -1 = nothing selected (thumb inside the dead zone or dial closed).
|
||||
property int selected: -1
|
||||
property int lastDetent: -1
|
||||
|
||||
signal invoked(var entry)
|
||||
|
||||
function openAt(x, y) {
|
||||
if (root.entries.length === 0)
|
||||
return;
|
||||
root.originX = Math.max(root.ringRadius * 1.3,
|
||||
Math.min(root.width - root.ringRadius * 1.3, x));
|
||||
root.originY = Math.max(root.ringRadius * 1.3,
|
||||
Math.min(root.height - root.ringRadius * 1.3, y));
|
||||
root.selected = -1;
|
||||
root.lastDetent = -1;
|
||||
root.open = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
root.open = false;
|
||||
root.selected = -1;
|
||||
}
|
||||
|
||||
// Angle of entry i, measured so the first entry sits at the top and the
|
||||
// ring fills clockwise.
|
||||
function angleFor(i) {
|
||||
return (i / Math.max(1, root.entries.length)) * 2 * Math.PI - Math.PI / 2;
|
||||
}
|
||||
|
||||
// Which entry a point selects. Inside the dead zone selects nothing, which
|
||||
// is what makes "open it and let go without choosing" a first-class
|
||||
// outcome instead of an accident.
|
||||
function entryAt(x, y) {
|
||||
const dx = x - root.originX;
|
||||
const dy = y - root.originY;
|
||||
if (Math.sqrt(dx * dx + dy * dy) < root.deadZone)
|
||||
return -1;
|
||||
const n = root.entries.length;
|
||||
if (n === 0)
|
||||
return -1;
|
||||
let a = Math.atan2(dy, dx) + Math.PI / 2;
|
||||
while (a < 0) a += 2 * Math.PI;
|
||||
while (a >= 2 * Math.PI) a -= 2 * Math.PI;
|
||||
return Math.round(a / (2 * Math.PI) * n) % n;
|
||||
}
|
||||
|
||||
// A detent every time the selection changes under the thumb. This is the
|
||||
// dial's whole tactile argument: you can pick an entry without looking,
|
||||
// because the ring answers your thumb. Requires the haptic device
|
||||
// (pmi8998_haptics, enabled 2026-07-26) and feedbackd's `quiet` profile,
|
||||
// where button-pressed maps to VibraPattern.
|
||||
onSelectedChanged: {
|
||||
if (!root.open || root.selected === root.lastDetent)
|
||||
return;
|
||||
root.lastDetent = root.selected;
|
||||
if (root.selected >= 0)
|
||||
Haptics.tick();
|
||||
}
|
||||
|
||||
// Scrim. Tapping it dismisses without choosing.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: "#99000000"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.entries
|
||||
|
||||
delegate: Item {
|
||||
id: seat
|
||||
required property int index
|
||||
required property var modelData
|
||||
|
||||
readonly property bool isSelected: root.selected === seat.index
|
||||
readonly property real a: root.angleFor(seat.index)
|
||||
readonly property bool usable: seat.modelData.enabled !== false
|
||||
|
||||
x: root.originX + Math.cos(seat.a) * root.ringRadius - width / 2
|
||||
y: root.originY + Math.sin(seat.a) * root.ringRadius - height / 2
|
||||
width: 64
|
||||
height: 64
|
||||
|
||||
// Grows toward the thumb rather than jumping — the selection
|
||||
// should feel like the ring leaning, not like a menu re-rendering.
|
||||
scale: seat.isSelected ? 1.22 : 1.0
|
||||
Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } }
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: width / 2
|
||||
color: seat.isSelected
|
||||
? Appearance.colors.colPrimary
|
||||
: Appearance.colors.colLayer2
|
||||
opacity: seat.usable ? 1.0 : 0.4
|
||||
|
||||
MaterialSymbol {
|
||||
anchors.centerIn: parent
|
||||
text: seat.modelData.icon ?? "radio_button_unchecked"
|
||||
iconSize: 28
|
||||
color: seat.isSelected
|
||||
? Appearance.colors.colOnPrimary
|
||||
: Appearance.colors.colOnLayer2
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.bottom
|
||||
anchors.topMargin: 6
|
||||
text: seat.modelData.label ?? ""
|
||||
color: "white"
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
opacity: seat.isSelected ? 1 : 0
|
||||
Behavior on opacity { NumberAnimation { duration: 90 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The refusal, shown in the middle where the thumb is not. A disabled
|
||||
// entry that just greys out teaches nothing; this says why.
|
||||
StyledText {
|
||||
x: root.originX - width / 2
|
||||
y: root.originY - height / 2
|
||||
width: root.deadZone * 1.8
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
color: "#d9ffffff"
|
||||
font.pixelSize: Appearance.font.pixelSize.smaller
|
||||
visible: root.selected >= 0
|
||||
&& root.entries[root.selected]?.enabled === false
|
||||
text: root.entries[root.selected]?.reason ?? ""
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: root.open
|
||||
hoverEnabled: false
|
||||
preventStealing: true
|
||||
|
||||
onPositionChanged: mouse => {
|
||||
root.selected = root.entryAt(mouse.x, mouse.y);
|
||||
}
|
||||
onPressed: mouse => {
|
||||
root.selected = root.entryAt(mouse.x, mouse.y);
|
||||
}
|
||||
onReleased: mouse => {
|
||||
const i = root.entryAt(mouse.x, mouse.y);
|
||||
root.close();
|
||||
if (i < 0 || i >= root.entries.length)
|
||||
return;
|
||||
const entry = root.entries[i];
|
||||
if (entry.enabled === false)
|
||||
return;
|
||||
root.invoked(entry);
|
||||
if (typeof entry.verb === "function")
|
||||
entry.verb();
|
||||
}
|
||||
}
|
||||
|
||||
Keys.onEscapePressed: root.close()
|
||||
}
|
||||
2
surfaces/quickshell/modules/souveraine/dial/qmldir
Normal file
2
surfaces/quickshell/modules/souveraine/dial/qmldir
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
RadialDial 1.0 RadialDial.qml
|
||||
DialHost 1.0 DialHost.qml
|
||||
|
|
@ -25,6 +25,7 @@ import qs.modules.ii.sidebarRight
|
|||
import qs.modules.ii.overlay
|
||||
import qs.modules.ii.verticalBar
|
||||
import qs.modules.ii.wallpaperSelector
|
||||
import qs.modules.souveraine.dial
|
||||
import qs.modules.souveraine.navigation
|
||||
|
||||
// The Souveraine panel family — one family for both modes.
|
||||
|
|
@ -61,6 +62,7 @@ Scope {
|
|||
PanelLoader { component: Overlay {} }
|
||||
PanelLoader { component: Overview {} }
|
||||
PanelLoader { component: Polkit {} }
|
||||
PanelLoader { component: DialHost {} }
|
||||
PanelLoader { component: RegionSelector {} }
|
||||
PanelLoader { component: ScreenCorners {} }
|
||||
PanelLoader { component: ScreenTranslator {} }
|
||||
|
|
|
|||
50
surfaces/quickshell/services/Haptics.qml
Normal file
50
surfaces/quickshell/services/Haptics.qml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// Haptic feedback, through feedbackd.
|
||||
//
|
||||
// Not a direct write to /dev/input/eventN. feedbackd already owns the force
|
||||
// feedback device (it claimed event4 at boot the moment the kernel exposed
|
||||
// pmi8998_haptics), it already arbitrates between callers, and it already
|
||||
// carries the user's profile — full / quiet / silent — which is where "do not
|
||||
// buzz right now" is supposed to be decided. A second writer to the same
|
||||
// device would be the competing-writer mistake again, in a new subsystem.
|
||||
//
|
||||
// The event names are feedbackd's own vocabulary, so the theme decides what
|
||||
// each one feels like. `button-pressed` maps to VibraPattern in the `quiet`
|
||||
// profile, which is the same event squeekboard fires per key.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Off switch that does not depend on reaching feedbackd to honour it.
|
||||
property bool enabled: true
|
||||
|
||||
function trigger(event) {
|
||||
if (!root.enabled)
|
||||
return;
|
||||
Quickshell.execDetached(["busctl", "call", "--user",
|
||||
"org.sigxcpu.Feedback", "/org/sigxcpu/Feedback",
|
||||
"org.sigxcpu.Feedback", "TriggerFeedback",
|
||||
"ssa{sv}i", "souveraine", event, "0", "-1"]);
|
||||
}
|
||||
|
||||
// A detent. Short and light: this fires once per entry the thumb crosses
|
||||
// on the dial, so anything longer would smear into the next one.
|
||||
function tick() {
|
||||
root.trigger("button-pressed");
|
||||
}
|
||||
|
||||
// Something completed.
|
||||
function confirm() {
|
||||
root.trigger("button-released");
|
||||
}
|
||||
|
||||
// Something was refused. Distinct on purpose — a refusal that feels like a
|
||||
// success is worse than no feedback at all.
|
||||
function refuse() {
|
||||
root.trigger("bell-terminal");
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ singleton FileSearch 1.0 FileSearch.qml
|
|||
singleton FirstRunExperience 1.0 FirstRunExperience.qml
|
||||
singleton GlobalFocusGrab 1.0 GlobalFocusGrab.qml
|
||||
singleton GoogleCloud 1.0 GoogleCloud.qml
|
||||
singleton Haptics 1.0 Haptics.qml
|
||||
singleton HyprlandAntiFlashbangShader 1.0 HyprlandAntiFlashbangShader.qml
|
||||
singleton HyprlandConfig 1.0 HyprlandConfig.qml
|
||||
singleton HyprlandData 1.0 HyprlandData.qml
|
||||
|
|
|
|||
Loading…
Reference in a new issue