Quickshell segfaulted mid-session -- a Qt image-teardown bug, three cores in the journal -- and the desktop stayed bar-less until a person noticed and knew what to type, because the shell ran as a bare compositor child and panama-crash-watch's report had no notification server left to arrive on. The shell is now panama-quickshell.service, started per-session by autostart.lua like every other Panama unit and never enabled globally: Restart=on-failure turns the same crash into a two-second flicker, verified by sending the running shell a real SIGSEGV and watching it return, and the crash report now lands because the restarted shell is serving the bus by the time the watcher looks. The two contracts that restart the shell learned to do it through the unit, or the unit's own restart races them with a second shell. Recordings can hear: a recorderAudio preference -- none by default, GNOME's default too, because a screencast that silently captured the microphone is an incident -- adds system audio or the microphone through PulseAudio's @DEFAULT_*@ aliases, so the capture follows whatever device Sound settings has chosen. The bar shows the active keyboard layout whenever more than one is configured, mapped from xkb's own registry (evdev.lst) because deriving a code from a description guesses wrong immediately -- "German" is de, not ge -- and updated live from Hyprland's activelayout event. One layout, no indicator, which is GNOME's behavior too. And presentation mode: Caffeine plus Do Not Disturb as one quick-settings tile, restoring both exactly as found -- the half you forget to arm before plugging into a projector is the one that fires a message preview onto the big screen. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
505 lines
21 KiB
QML
505 lines
21 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") || ""
|
|
|
|
// 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);
|
|
// Audio rides along per the recorderAudio preference. The tokens are
|
|
// resolved by the pulse layer at record time, so the capture follows
|
|
// whatever device is currently the default.
|
|
if (Settings.recorderAudio === "system")
|
|
cmd.push("--audio=@DEFAULT_MONITOR@");
|
|
else if (Settings.recorderAudio === "microphone")
|
|
cmd.push("--audio=@DEFAULT_SOURCE@");
|
|
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
|
|
}
|
|
}
|