Build the Panama Hyprland desktop
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user