Files
Panama/config/dot/quickshell/services/Capture.qml
T
Gabriel Brown 538c0a887c Save things where the rest of the software already saves them
Screenshots and recordings offered three folders to choose between, and three
guesses cannot include the folder somebody's other software already writes to --
which is the only folder that matters. This machine has had ~/Pictures/Screenshots
and ~/Videos/Screencasts since long before Panama, and Panama was writing
recordings to a Videos/Recordings it invented. Both are free text now, and the
recording default is the folder that was already there.

Wallpapers were swept from four directories at once, so the distribution's stock
images arrived mixed in with the user's own and there was no way to ask for just
one. Where wallpapers live is something somebody knows about their own machine.
It is a setting, not a search.

All three accept an absolute path as well as one relative to home, which meant
fixing Capture: it prefixed $HOME unconditionally, so naming /mnt/captures would
have written screenshots to ~/mnt/captures and left nobody able to find them.

The generator turned out to skip any entry whose comment sits inside the braces
rather than above them -- it looks for `key:` immediately after `{`. Three
settings were invisible in the reference because of it, one of them dockScreens,
which has never appeared there at all. The staleness contract could not see it
either: regenerating reproduced the same omission, so the copy was current and
incomplete at once. It now counts what was declared against what it could read
and refuses rather than quietly documenting less than exists.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 12:57:47 -04:00

478 lines
20 KiB
QML

pragma Singleton
// ─────────────────────────────────────────────────────────────────────────────
// Screenshot / screen-recording engine.
//
// This is the whole non-visual half of the GNOME 42 screenshot experience:
// freeze the screen, hand the frozen frame to the picker UI, then run grim or
// wf-recorder against whatever region the user chose.
//
// The UI (modules/capture) only ever reads state from here and calls the
// shoot*/record* functions -- it never spawns a process itself.
//
// Verified against the installed tools on 2026-08-17:
// grim 1.x -g "<X>,<Y> <W>x<H>" <- SPACE before WxH, not a comma.
// "0,0,100x100" is rejected with
// "invalid geometry".
// -s <factor> output image scale
// -c include the cursor
// wf-recorder -g "<X>,<Y> <W>x<H>" same format as grim
// no cursor toggle in this build; the pointer is always
// recorded, so "Show Pointer" only affects screenshots.
// notify-send -A NAME=Label implies --wait and prints NAME on stdout,
// which is how the notification
// actions below are dispatched.
// satty 0.22 --filename / --output-filename / --early-exit
// ─────────────────────────────────────────────────────────────────────────────
import Quickshell
import Quickshell.Io
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.services
Singleton {
id: root
// ── Picker state (bound by modules/capture) ─────────────────────────────
// "screen" | "window" | "selection"
property string mode: "screen"
// Capture action. The booleans stay explicit because the existing picker
// reads them directly, while the setters guarantee only one special mode
// can be active at a time.
property bool recordMode: false
property bool intelligenceMode: false
// GNOME's "Show Pointer" checkbox. Screenshots only -- see the note above.
property bool showPointer: false
// file:// URL of the frozen frame the overlay paints as its backdrop, or
// "" while the grab is still in flight / after it failed.
readonly property string freezeUrl: root._freezePath === "" ? "" : "file://" + root._freezePath
// Top-left of the captured layout in Hyprland's global coordinate space.
// Everything the picker reports back is layout-global; the overlay
// subtracts these to get window-local pixels.
property real originX: 0
property real originY: 0
// Windows on the focused workspace, newest-focused first, as
// { address, class, title, x, y, w, h } in layout-global logical pixels.
property var windows: []
// ── Recording state (read by the bar / recording indicator) ─────────────
readonly property bool recording: recProc.running
property int recordingSeconds: 0
property string recordingPath: ""
// ── Coordinate space ────────────────────────────────────────────────────
// `hyprctl -j clients` reports `at`/`size` in Hyprland's *logical* layout
// coordinates (the 3000x2000 space on this 1.5x display), and grim's and
// wf-recorder's -g flags consume the same logical space -- so no scaling is
// needed anywhere and this stays 1.0. It exists because that is the one
// thing here that could not be verified without a live Hyprland session: if
// window outlines land at 2/3 size, set it to 1.5 and the whole pipeline
// (outlines and grim geometry alike) corrects together.
property real coordinateScale: 1.0
// ── Paths ───────────────────────────────────────────────────────────────
readonly property string _home: Quickshell.env("HOME") || "/home/gib"
// Both folders are settings now rather than a choice of three, so both can
// name somewhere outside home -- a second drive, a network mount. Prefixing
// $HOME unconditionally, as this did, would have turned /mnt/captures into
// ~/mnt/captures and written screenshots somewhere nobody looks.
function _resolve(folder: string): string {
const trimmed = String(folder ?? "").trim();
if (trimmed === "")
return root._home;
if (trimmed.startsWith("/"))
return trimmed;
if (trimmed.startsWith("~/"))
return root._home + trimmed.slice(1);
return root._home + "/" + trimmed;
}
readonly property string shotDir: root._resolve(Settings.screenshotDir)
readonly property string recDir: root._resolve(Settings.recordingDir)
property string _freezePath: ""
property string _prevFreezePath: ""
function _stamp(): string {
return Qt.formatDateTime(new Date(), "yyyy-MM-dd HH-mm-ss");
}
// grim/wf-recorder geometry string, or "" for "the whole thing".
function _geom(x: real, y: real, w: real, h: real): string {
const s = root.coordinateScale;
return Math.round(x * s) + "," + Math.round(y * s) + " " + Math.round(w * s) + "x" + Math.round(h * s);
}
// ── Public API (driven by shell.qml's IpcHandler) ───────────────────────
// Open the GNOME-style picker. The frozen frame is grabbed *before* the
// overlay is shown, otherwise the overlay would appear in its own backdrop.
function open(): void {
if (root.recording) {
// Print while recording = stop, matching the way GNOME's indicator
// behaves. The lead binds this to the same key.
root.stopRecording();
return;
}
if (ShellState.captureOpen) {
// Print again while the picker is up dismisses it. Re-grabbing here
// would freeze a screen with the picker already in it.
root.close();
return;
}
if (ScreenIntelligence.visible)
ScreenIntelligence.close();
root.refreshWindows();
root._grabFreeze();
}
function openIntelligence(): void {
root.mode = "selection";
root.selectIntelligence();
root.open();
}
function selectScreenshot(): void {
root.recordMode = false;
root.intelligenceMode = false;
}
function selectRecording(): void {
root.recordMode = true;
root.intelligenceMode = false;
}
function selectIntelligence(): void {
root.recordMode = false;
root.intelligenceMode = true;
}
function close(): void {
ShellState.close();
root._dropFreeze();
}
// Immediate whole-screen screenshot, no UI. (Shift+Print)
function screenNow(): void {
root.shootRegion("");
}
// Immediate active-window screenshot, no UI. (Alt+Print)
function windowNow(): void {
activeWindowProc.running = false;
activeWindowProc.running = true;
}
function stopRecording(): void {
if (!recProc.running)
return;
// SIGINT, never SIGKILL: wf-recorder needs to flush and write the
// container trailer or the file is unplayable.
recProc.signal(2);
}
// ── Execution ───────────────────────────────────────────────────────────
// geom: "" for the full output, otherwise a grim geometry string.
function shootRegion(geom: string): void {
// Capital "From" matches the files GNOME already left in
// ~/Pictures/Screenshots, so old and new shots sort together.
const name = "Screenshot From " + root._stamp() + ".png";
const args = ["sh", "-c", root._shotScript, "qs-capture", root.shotDir, name];
if (root.showPointer)
args.push("-c");
if (geom !== "")
args.push("-g", geom);
else if (root._outputName !== "")
args.push("-o", root._outputName);
Quickshell.execDetached(args);
}
function recordRegion(geom: string): void {
if (recProc.running)
return;
// .mp4 rather than GNOME's .webm because Settings.recorderArgs encodes
// h264 on the AMD VAAPI device.
const name = "Screencast From " + root._stamp() + ".mp4";
root.recordingPath = root.recDir + "/" + name;
// mkdir + exec so the PID we later SIGINT is wf-recorder itself and not
// the shell wrapping it.
let cmd = ["sh", "-c", 'mkdir -p "$1" && shift && exec "$@"', "qs-capture", root.recDir, "wf-recorder", "-y"];
cmd = cmd.concat(Settings.recorderArgs.split(" ").filter(a => a !== ""));
if (geom !== "")
cmd.push("-g", geom);
else if (root._outputName !== "")
cmd.push("-o", root._outputName);
cmd.push("-f", root.recordingPath);
root.recordingSeconds = 0;
recProc.command = cmd;
recProc.running = true;
}
// Convenience wrappers used by the overlay, in layout-global logical px.
function shootRect(x: real, y: real, w: real, h: real): void {
root.shootRegion(root._geom(x, y, w, h));
}
function recordRect(x: real, y: real, w: real, h: real): void {
root.recordRegion(root._geom(x, y, w, h));
}
// Fire whatever the picker currently has selected, then dismiss it.
// rect is null for "the whole output".
function commit(rect: var): void {
ShellState.close();
root._dropFreeze();
// Let the overlay actually unmap before anything reads the screen,
// otherwise the dimming layer ends up in the picture.
commitDelay.rect = rect;
commitDelay.record = root.recordMode;
commitDelay.intelligence = root.intelligenceMode;
commitDelay.restart();
}
// ── Window enumeration ──────────────────────────────────────────────────
// Quickshell's HyprlandToplevel exposes no geometry, so this shells out.
function refreshWindows(): void {
clientsProc.running = false;
clientsProc.running = true;
}
readonly property string _outputName: {
const m = Hyprland.focusedMonitor;
return m ? m.name : "";
}
// ── Internals ───────────────────────────────────────────────────────────
// Shot pipeline: capture, copy to the clipboard, then notify with the
// GNOME-style follow-up actions. Runs detached so a lingering
// `notify-send --wait` never blocks the shell.
// $1 = directory, $2 = filename, $3.. = extra grim args
readonly property string _shotScript: 'd=$1; n=$2; shift 2
mkdir -p "$d" || exit 1
p="$d/$n"
if ! grim "$@" "$p"; then
notify-send -a Screenshot -u critical "Screenshot failed" "grim could not capture the screen"
exit 1
fi
wl-copy --type image/png < "$p"
qs ipc call status-events screenshot "$p" >/dev/null 2>&1 || true
act=$(notify-send -a Screenshot -i "$p" -A open=Open -A annotate=Annotate -A folder=Folder "Screenshot captured" "$n")
case "$act" in
open) xdg-open "$p" ;;
annotate) satty --filename "$p" --output-filename "$p" --copy-command wl-copy --early-exit all ;;
folder) xdg-open "$d" ;;
esac'
readonly property string _recDoneScript: 'p=$1
d=$(dirname "$p")
act=$(notify-send -a Screencast -A open=Open -A folder=Folder "Screen recording saved" "$(basename "$p")")
case "$act" in
open) xdg-open "$p" ;;
folder) xdg-open "$d" ;;
esac'
function _grabFreeze(): void {
root._prevFreezePath = root._freezePath;
root._freezePath = "";
// Unique name per open: QML caches Image sources by URL, so reusing one
// path would repaint the previous frame.
const path = Quickshell.cachePath("capture-freeze-" + Date.now() + ".ppm");
// PPM, not PNG: this is a 4500x3000 grab and libpng would spend the
// better part of a second compressing an image we throw away seconds
// later. PPM is a raw dump, so the freeze appears immediately.
// -o pins the grab to the focused output so the picker's coordinate
// space is exactly this window's.
const cmd = ["sh", "-c", 'mkdir -p "$(dirname "$1")" && d=$1 && shift && exec grim -t ppm "$@" "$d"', "qs-capture", path];
if (root._outputName !== "")
cmd.push("-o", root._outputName);
freezeProc.pendingPath = path;
freezeProc.command = cmd;
freezeProc.running = false;
freezeProc.running = true;
}
function _dropFreeze(): void {
const stale = root._freezePath;
root._freezePath = "";
if (stale !== "")
Quickshell.execDetached(["rm", "-f", stale]);
}
Timer {
id: commitDelay
property var rect: null
property bool record: false
property bool intelligence: false
// A handful of frames at 60Hz, enough for the compositor to recomposite
// the output without the overlay on it.
interval: 90
onTriggered: {
const r = commitDelay.rect;
const geom = r ? root._geom(r.x, r.y, r.width, r.height) : "";
if (commitDelay.intelligence)
ScreenIntelligence.analyzeRegion(geom, root._outputName);
else if (commitDelay.record)
root.recordRegion(geom);
else
root.shootRegion(geom);
commitDelay.rect = null;
}
}
Process {
id: freezeProc
property string pendingPath: ""
onExited: (code, status) => {
if (code === 0) {
root._freezePath = freezeProc.pendingPath;
} else {
// No wlr-screencopy (or no compositor at all): still open the
// picker so Selection/Window mode remain usable, just without
// the frozen backdrop.
root._freezePath = "";
console.warn("Capture: grim freeze failed with exit code", code);
}
if (root._prevFreezePath !== "") {
Quickshell.execDetached(["rm", "-f", root._prevFreezePath]);
root._prevFreezePath = "";
}
ShellState.open("capture");
}
}
Process {
id: clientsProc
command: ["hyprctl", "-j", "clients"]
stdout: StdioCollector {
onStreamFinished: {
const focused = Hyprland.focusedWorkspace;
let out = [];
try {
const raw = JSON.parse(this.text);
for (const c of raw) {
if (!c.mapped || c.hidden)
continue;
if (!c.size || c.size[0] <= 0 || c.size[1] <= 0)
continue;
// Only the workspace the user is looking at. If
// Hyprland hasn't reported a focused workspace yet,
// show everything rather than nothing.
if (focused && c.workspace && c.workspace.id !== focused.id)
continue;
out.push({
address: c.address || "",
appId: c.class || "",
title: c.title || "",
order: c.focusHistoryID === undefined ? 999 : c.focusHistoryID,
x: c.at[0],
y: c.at[1],
w: c.size[0],
h: c.size[1]
});
}
// Ascending focusHistoryID == front to back, so a hit test
// walking this list picks the topmost window first.
out.sort((a, b) => a.order - b.order);
} catch (e) {
// Not running under Hyprland, or hyprctl printed its
// "HYPRLAND_INSTANCE_SIGNATURE not set!" error. Window mode
// simply shows nothing instead of throwing.
out = [];
}
root.windows = out;
}
}
}
Process {
id: activeWindowProc
command: ["hyprctl", "-j", "activewindow"]
stdout: StdioCollector {
onStreamFinished: {
try {
const c = JSON.parse(this.text);
if (c && c.at && c.size && c.size[0] > 0)
root.shootRect(c.at[0], c.at[1], c.size[0], c.size[1]);
else
root.shootRegion("");
} catch (e) {
// No Hyprland / no focused window: fall back to the whole
// screen rather than doing nothing.
root.shootRegion("");
}
}
}
}
Process {
id: recProc
onExited: (code, status) => {
recTimer.stop();
const path = root.recordingPath;
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
// user pressed stop and the file was finalised normally. Any other
// code means wf-recorder died or errored before finalising, so the
// file may be missing or truncated -- never report success then.
const clean = code === 2 || code === 130;
if (path !== "") {
if (clean) {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording saved",
detail: path.split("/").pop(),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-path",
actionData: path
});
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", path]);
} else {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording failed",
detail: "wf-recorder exited unexpectedly (code " + code + ")",
tone: "warn",
priority: StatusEvents.importantPriority
});
}
}
root.recordingPath = "";
root.recordingSeconds = 0;
}
onStarted: {
root.recordingSeconds = 0;
recTimer.start();
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording started",
detail: "Panama is capturing the selected area",
tone: "danger",
priority: StatusEvents.criticalPriority,
actionId: "open-activity"
});
}
}
// Only ticks while a recording is live -- nothing in this shell repaints
// when idle.
Timer {
id: recTimer
interval: 1000
repeat: true
onTriggered: root.recordingSeconds += 1
}
}