Files
Panama/config/dot/quickshell/services/Capture.qml
T

447 lines
18 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"
readonly property string shotDir: root._home + "/" + Settings.screenshotDir
readonly property string recDir: root._home + "/" + 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();
// SIGINT gives exit code 2 (or 130 through a shell); both mean the
// user pressed stop and the file was finalised normally.
if (root.recordingPath !== "") {
StatusEvents.publish({
key: "capture-recording",
glyph: "\u{F044A}",
title: "Screen recording saved",
detail: root.recordingPath.split("/").pop(),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: "open-path",
actionData: root.recordingPath
});
Quickshell.execDetached(["sh", "-c", root._recDoneScript, "qs-capture", root.recordingPath]);
}
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
}
}