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" <- SPACE before WxH, not a comma. // "0,0,100x100" is rejected with // "invalid geometry". // -s output image scale // -c include the cursor // wf-recorder -g ", x" 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") || "" // 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); } // The render node "auto" in recorderArgs resolves to. Enumerated once at // startup, which is as often as it changes; empty until the scan lands, // and the map below falls back to the conventional first node then. property string renderNode: "" Process { id: renderNodeScan command: ["sh", "-c", "ls /dev/dri/renderD* 2>/dev/null | head -1"] running: true stdout: StdioCollector { onStreamFinished: root.renderNode = this.text.trim() } } 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"]; // "auto" becomes a real render node here rather than in the schema: // the stored option must not carry one machine's device path to the // next machine. First node wins; a hybrid machine that needs the // other one can store explicit args. const args = Settings.recorderArgs.split(" ").filter(a => a !== "") .map(a => a === "auto" ? (root.renderNode || "/dev/dri/renderD128") : a); cmd = cmd.concat(args); 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 } }