Watch
1
0
Fork
You've already forked souveraine
0

dial: radial picker, haptic detents, dial IPC

This commit is contained in:
Fimeg 2026-07-26 20:36:07 -04:00
commit bef6e6a653
7 changed files with 416 additions and 0 deletions

View 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
}
}

View 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()
}

View file

@ -0,0 +1,2 @@
RadialDial 1.0 RadialDial.qml
DialHost 1.0 DialHost.qml