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

@ -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

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

View file

@ -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 {} }

View 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");
}
}

View file

@ -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