Watch
1
0
Fork
You've already forked souveraine
0

dial: hold the camera to record the screen

Entries may carry a hold verb beside verb; the ring fills while it charges so
the commitment is visible while it is still cancellable, and sliding off
cancels. Holding the camera starts a recording, tapping or holding it again
stops it — the same seat, because the dial is muscle memory.

No wf-recorder or wl-screenrec on the device, so it is grim piped into
ffmpeg. Half scale: at 1080x2160 grim manages about 3.5 fps and a recording
that captures a third of its claimed frames plays back at triple speed.

Its own process group, and SIGINT to the group: only ffmpeg can finish the
file, so killing the shell alone would orphan a screen capture with no way to
stop it.

Reachable as a verb too, not only by thumb.
This commit is contained in:
Fimeg 2026-08-07 00:24:12 -04:00
commit ae432e7654
2 changed files with 165 additions and 1 deletions

View file

@ -70,6 +70,59 @@ Scope {
}
}
// ## Screen recording
//
// Hold the camera to start, tap or hold it again to stop. There is no
// wf-recorder or wl-screenrec on this device, so the capture is `grim` in
// a loop piped into ffmpeg measured on blueline 2026-08-06 at 1080x2160,
// 41 frames in 3.4s, about 12 fps. Coarse, and honest about it: the right
// tool is a compositor-side encoder fed by the capture protocol viewtop
// already serves, and this is what ships today with what is installed.
//
// **Its own process group, and SIGINT to the group.** The pipeline is two
// processes and only ffmpeg can finish the file it writes the mp4 index
// on the way out, so a killed ffmpeg leaves an unplayable recording.
// Interrupting the group stops the grim loop, ffmpeg sees its input end,
// and it closes the file properly. Killing the shell alone would orphan
// both halves and leave the phone quietly capturing the screen forever,
// which is the failure worth engineering against here.
property bool recording: false
readonly property string recPid: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-screenrec.pid"
function startRecording() {
if (scope.recording)
return;
Haptics.confirm();
scope.recording = true;
// `setsid` run directly as argv, not through an outer `sh -c`. Wrapped,
// the inner script needs a second level of shell quoting and the whole
// pipeline silently produced no file while every state flag said it was
// recording the failure looked like a bug in the verb rather than in
// the quoting. One shell, one level of quotes.
Quickshell.execDetached(["setsid", "sh", "-c",
'mkdir -p "$HOME/Videos"; echo $$ > "$1"; '
// Half scale: `grim` at the full 1080x2160 managed only ~3.5 fps
// on this device (measured 2026-08-06), and a recording that
// captures a third of the frames it claims plays back at triple
// speed. Halving the pixels roughly doubles the rate and the
// framerate below is set to match what it actually achieves.
+ 'while :; do grim -s 0.5 -t ppm - || break; done '
+ '| ffmpeg -y -loglevel error -f image2pipe -framerate 8 -i - '
+ '-c:v libx264 -preset ultrafast -pix_fmt yuv420p '
+ '"$HOME/Videos/screenrec-$(date +%Y%m%d-%H%M%S).mp4"',
"_", scope.recPid]);
}
function stopRecording() {
if (!scope.recording)
return;
Haptics.confirm();
scope.recording = false;
Quickshell.execDetached(["sh", "-c",
'G=$(cat "$1" 2>/dev/null); [ -n "$G" ] && kill -INT -- -"$G" 2>/dev/null; rm -f "$1"',
"_", scope.recPid]);
}
// 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: [
@ -80,11 +133,26 @@ Scope {
},
{
icon: "photo_camera",
label: "Screenshot",
label: scope.recording ? "Stop recording" : "Screenshot",
verb: () => {
// While recording, the same seat is the way out. A separate
// stop entry would mean the ring changes length mid-session,
// and the dial is muscle memory the thumb goes where the
// camera was, so that is where stopping lives.
if (scope.recording) {
scope.stopRecording();
return;
}
Haptics.confirm();
Quickshell.execDetached(["sh", "-c",
"grim ~/Pictures/screenshot-$(date +%Y%m%d-%H%M%S).png"]);
},
hold: () => {
if (scope.recording) {
scope.stopRecording();
return;
}
scope.startRecording();
}
},
{
@ -137,6 +205,22 @@ Scope {
scope.dialOpen = !scope.dialOpen;
}
// Recording, reachable without the ring. The hold gesture is the
// hand's way in; this is hers, and doctrine §13 is explicit that a
// verb only the thumb can reach is a defect. It is also the only way
// to test the pipeline without a finger on the glass.
function record(): void {
scope.startRecording();
}
function stopRecord(): void {
scope.stopRecording();
}
function recording(): string {
return scope.recording ? "recording" : "idle";
}
// 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.

View file

@ -145,6 +145,22 @@ Item {
scale: seat.isSelected ? 1.22 : 1.0
Behavior on scale { NumberAnimation { duration: 90; easing.type: Easing.OutCubic } }
// The charge. Drawn under the seat and growing past it, so a hold
// reads as the entry swelling toward committing rather than as a
// separate widget appearing next to it.
Rectangle {
anchors.centerIn: parent
visible: seat.isSelected && root.holdProgress > 0
&& typeof seat.modelData.hold === "function"
width: parent.width * (1 + 0.45 * root.holdProgress)
height: width
radius: width / 2
color: "transparent"
border.width: 3
border.color: Appearance.colors.colPrimary
opacity: 0.35 + 0.65 * root.holdProgress
}
Rectangle {
anchors.fill: parent
radius: width / 2
@ -191,6 +207,58 @@ Item {
text: root.entries[root.selected]?.reason ?? ""
}
// ## Holding an entry, for the second verb it carries
//
// An entry may declare `hold` beside `verb`: tap does one thing, holding
// does the heavier one. Screenshot/record is the case that asked for it
// Casey, 2026-08-06: "if I hold on the screenshot button... it'll start
// recording a video until I hit the stop button on the radial dial."
//
// The hold has to *show*, or it is indistinguishable from a tap that has
// not been let go of yet, and the user releases before it fires. The ring
// below fills as it charges, so the commitment is visible while it is
// still cancellable.
//
// Sliding to another entry cancels: the thumb is already the dial's
// selection mechanism, so a hold that survived moving off its entry would
// fire the wrong verb and this ring holds Lock and Kill window.
property real holdProgress: 0
property bool holdFired: false
readonly property int holdMs: 550
Timer {
id: holdCharge
interval: 16
repeat: true
onTriggered: {
root.holdProgress += interval / root.holdMs;
if (root.holdProgress < 1)
return;
stop();
root.holdProgress = 0;
root.holdFired = true;
const entry = root.entries[root.selected];
if (!entry || entry.enabled === false || typeof entry.hold !== "function")
return;
// Closes on firing, like a release would: the verb has been
// committed, and leaving the ring up over a recording that has
// already started reads as "it did not take".
root.close();
root.invoked(entry);
entry.hold();
}
}
function beginHold() {
root.holdProgress = 0;
root.holdFired = false;
const entry = root.entries[root.selected];
if (entry && entry.enabled !== false && typeof entry.hold === "function")
holdCharge.restart();
else
holdCharge.stop();
}
MouseArea {
anchors.fill: parent
enabled: root.open
@ -198,12 +266,24 @@ Item {
preventStealing: true
onPositionChanged: mouse => {
const before = root.selected;
root.selected = root.entryAt(mouse.x, mouse.y);
if (root.selected !== before)
root.beginHold();
}
onPressed: mouse => {
root.selected = root.entryAt(mouse.x, mouse.y);
root.beginHold();
}
onReleased: mouse => {
holdCharge.stop();
root.holdProgress = 0;
// The hold already ran and already closed the ring. Firing the tap
// verb now would take a screenshot every time a recording starts.
if (root.holdFired) {
root.holdFired = false;
return;
}
const i = root.entryAt(mouse.x, mouse.y);
root.close();
if (i < 0 || i >= root.entries.length)