diff --git a/home/isabel/quickshell/modules/ScreenshotOverlay.qml b/home/isabel/quickshell/modules/ScreenshotOverlay.qml index 1c3e3bce..3a37c57b 100644 --- a/home/isabel/quickshell/modules/ScreenshotOverlay.qml +++ b/home/isabel/quickshell/modules/ScreenshotOverlay.qml @@ -1,14 +1,9 @@ import QtQuick import Quickshell -import Quickshell.Io import Quickshell.Wayland import "root:/data" import "root:/services" -// Per-output fullscreen overlay used for the frozen region select and the -// macOS-style mode picker. Only mapped while Screenshot.active. On activation -// each window snapshots its own output with grim (the freeze); region selection -// then crops that frozen image natively via grabToImage. Variants { model: Quickshell.screens @@ -22,8 +17,8 @@ Variants { && modelData.name === Quickshell.screens[0].name readonly property string freezePath: "/tmp/qs-screenshot-freeze-" + (modelData ? modelData.name : "x") + ".png" - // Selection rect in this window's logical coordinates. - property bool frozenReady: false + readonly property bool frozenReady: Screenshot.active && frozen.status === Image.Ready + property bool selecting: false property real selX: 0 property real selY: 0 @@ -63,37 +58,33 @@ Variants { }, Qt.size(w, h)); } - onVisibleChanged: { - if (visible) { - frozenReady = false; - resetSelection(); - // Capture while the surface is still fully transparent, so the overlay - // itself never lands in the frozen image. - freezeProc.command = ["grim", "-o", modelData.name, freezePath]; - freezeProc.running = true; - } else { - frozenReady = false; - } - } + onVisibleChanged: if (visible) resetSelection() - Process { - id: freezeProc - onExited: (code, status) => { if (code === 0) win.frozenReady = true; } + // menu -> window: grab this output's freeze cropped to the window's rect. + Connections { + target: Screenshot + function onAutoGrabChanged() { + const g = Screenshot.autoGrab; + if (!g || g.output !== win.modelData.name) return; + win.selX = g.x; + win.selY = g.y; + win.selW = g.w; + win.selH = g.h; + Qt.callLater(win.confirmRegion); + } } - // Frozen screen. Image { id: frozen anchors.fill: parent visible: win.frozenReady cache: false - source: win.frozenReady ? ("file://" + win.freezePath) : "" + source: Screenshot.active ? ("file://" + win.freezePath) : "" sourceSize.width: win.width * win.dpr sourceSize.height: win.height * win.dpr fillMode: Image.Stretch } - // Dimming scrim. Rectangle { anchors.fill: parent visible: win.frozenReady @@ -101,11 +92,11 @@ Variants { opacity: (Screenshot.mode === "region" && win.hasSelection) ? 0.45 : 0.30 } - // Bright selection cutout. This item is the grabToImage source, so it must - // contain ONLY the frozen image (border/label are siblings below). + // grabToImage source: must contain ONLY the frozen image (border/label are + // siblings below so they stay out of the capture). Item { id: cutout - visible: win.frozenReady && Screenshot.mode === "region" && win.hasSelection + visible: win.frozenReady && win.hasSelection x: win.selX y: win.selY width: win.selW @@ -136,7 +127,6 @@ Variants { border.width: 1 } - // Live W x H readout (native pixels). Rectangle { visible: cutout.visible color: Settings.colors.background @@ -157,7 +147,6 @@ Variants { } } - // Region drag handling. MouseArea { anchors.fill: parent visible: win.frozenReady && Screenshot.mode === "region" @@ -193,7 +182,6 @@ Variants { } } - // macOS-style mode picker toolbar (primary output only). Rectangle { id: toolbar visible: win.frozenReady && Screenshot.mode === "picker" && win.isPrimary @@ -259,8 +247,6 @@ Variants { } } - // Keyboard: Esc cancels everywhere, Enter confirms a region, R/W/O/A pick - // a mode while the picker is open. Item { anchors.fill: parent focus: win.frozenReady diff --git a/home/isabel/quickshell/services/Screenshot.qml b/home/isabel/quickshell/services/Screenshot.qml index 2560555f..b3a526ee 100644 --- a/home/isabel/quickshell/services/Screenshot.qml +++ b/home/isabel/quickshell/services/Screenshot.qml @@ -4,32 +4,22 @@ import Quickshell import Quickshell.Io import QtQuick -// Screenshot tool with screen freezing during region selection. -// -// Entry points (also exposed over IPC as `qs ipc call screenshot `): -// menu() - freeze + macOS-style mode picker toolbar -// region() - freeze + drag-select a region -// window() - active window (instant grim) -// output() - focused monitor (instant grim) -// all() - whole layout (instant grim) -// -// The ScreenshotOverlay module renders the frozen overlay/picker and performs -// the region crop, then calls back into regionCaptured(). Window/output/all are -// instant single-snapshot grim calls (no freeze needed - a snapshot is already -// a frozen instant). Singleton { id: root - // Overlay state, read by ScreenshotOverlay. - property bool active: false // overlay shown - property string mode: "" // "picker" | "region" | "" - property string pendingPath: "" // target file for the current capture - property string pendingKind: "" // "window" | "output" | "all" for instant modes + property bool active: false + property string mode: "" + property string pendingMode: "" + property string pendingPath: "" + property string pendingKind: "" + property var autoGrab: null - // Screenshots directory. XDG_SCREENSHOTS_DIR is not in quickshell's launch - // environment (it's only exported into interactive shells that source - // ~/.config/user-dirs.dirs), so Quickshell.env() can't see it. Resolve it at - // startup by sourcing user-dirs.dirs directly. Falls back to ~/Pictures/Screenshots. + readonly property string freezeAll: "/tmp/qs-screenshot-freeze-all.png" + function freezeOut(name: string): string { return "/tmp/qs-screenshot-freeze-" + name + ".png"; } + + // XDG_SCREENSHOTS_DIR is not in quickshell's launch environment (only + // interactive shells that source ~/.config/user-dirs.dirs export it), so + // Quickshell.env() can't see it; resolve it by sourcing that file at startup. property string dir: Quickshell.env("HOME") + "/Pictures/Screenshots" function newPath(): string { @@ -40,26 +30,32 @@ Singleton { return "'" + ("" + s).replace(/'/g, "'\\''") + "'"; } - // --- Entry points ------------------------------------------------------- + function menu(): void { root.openOverlay("picker"); } + function region(): void { root.openOverlay("region"); } - function menu(): void { + function openOverlay(m: string): void { + // Re-triggering while up would rewrite the freeze temps under the live Image. + if (root.active || freezeProc.running) + return; root.pendingPath = root.newPath(); - root.mode = "picker"; - root.active = true; + root.pendingMode = m; + // Freeze before the overlay is shown so the overlay can never be in a capture. + let parts = []; + for (const s of Quickshell.screens) + parts.push("grim -o " + root.shq(s.name) + " " + root.shq(root.freezeOut(s.name))); + parts.push("grim " + root.shq(root.freezeAll)); + freezeProc.command = ["bash", "-c", parts.join(" & ") + " & wait"]; + freezeProc.running = true; } - function region(): void { - root.pendingPath = root.newPath(); - root.mode = "region"; - root.active = true; + Process { + id: freezeProc + onExited: (code, status) => { + root.mode = root.pendingMode; + root.active = true; + } } - function window(): void { root.startInstant("window"); } - function output(): void { root.startInstant("output"); } - function all(): void { root.startInstant("all"); } - - // --- Region (called back from the overlay after grabToImage) ----------- - function regionCaptured(): void { const p = root.pendingPath; root.active = false; @@ -72,118 +68,135 @@ Singleton { root.mode = ""; root.pendingPath = ""; root.pendingKind = ""; + root.autoGrab = null; } - // --- Instant grim modes ------------------------------------------------ + function window(): void { root.startInstant("window"); } + function output(): void { root.startInstant("output"); } + function all(): void { root.startInstant("all"); } function startInstant(kind: string): void { root.pendingKind = kind; if (root.pendingPath.length === 0) root.pendingPath = root.newPath(); - if (root.active) { - // Came from the picker: drop the overlay first so it isn't in the shot, - // then capture once it has cleared. - root.active = false; - root.mode = ""; - teardownTimer.restart(); - } else { - root.runInstant(); - } + if (root.active) + root.captureFromFreeze(kind); + else + root.runLive(); } - function runInstant(): void { - if (root.pendingKind === "all") { - grimProc.command = ["grim", root.pendingPath]; - grimProc.running = true; - } else if (root.pendingKind === "output") { - monitorsProc.running = true; - } else if (root.pendingKind === "window") { - activeWinProc.running = true; + // From the menu: crop/copy the clean freeze instead of grabbing live, so the + // overlay can't appear in the shot (no teardown race with a live grim). + function captureFromFreeze(kind: string): void { + if (kind === "all") { + root.active = false; + root.mode = ""; + root.copyThenFinalize(root.freezeAll); + } else if (kind === "output") { + outputResolveProc.running = true; + } else if (kind === "window") { + windowResolveProc.running = true; } } - Timer { - id: teardownTimer - interval: 90 - onTriggered: root.runInstant() + function copyThenFinalize(src: string): void { + cpProc.command = ["cp", src, root.pendingPath]; + cpProc.running = true; } - // --- Shared finish: save notification + swappy annotate -> clipboard ---- - - function finalize(path: string): void { - notifyProc.command = ["notify-send", "Screenshot saved", path, "-i", path]; - notifyProc.running = true; - - // swappy reads the raw capture, writes its annotated result to stdout - // (-> wl-copy), and its own Save button writes to swappy's configured dir. - swappyProc.command = ["bash", "-c", "swappy -f " + root.shq(path) + " -o - | wl-copy"]; - swappyProc.running = true; - - root.pendingKind = ""; - root.pendingPath = ""; + Process { + id: cpProc + onExited: (code, status) => { + if (code === 0) root.finalize(root.pendingPath); + else root.fail("Could not copy capture"); + } } - function fail(msg: string): void { - root.active = false; - root.mode = ""; - root.pendingPath = ""; - root.pendingKind = ""; - notifyProc.command = ["notify-send", "Screenshot failed", msg, "-u", "critical"]; - notifyProc.running = true; + Process { + id: outputResolveProc + command: ["hyprctl", "monitors", "-j"] + stdout: StdioCollector { + onStreamFinished: { + try { + const mons = JSON.parse(text); + const f = mons.find(m => m.focused) ?? mons[0]; + root.active = false; + root.mode = ""; + root.copyThenFinalize(root.freezeOut(f.name)); + } catch (e) { + root.fail("Could not determine monitor"); + } + } + } } - // --- Processes ---------------------------------------------------------- - - Component.onCompleted: dirProc.running = true - Process { - id: dirProc - command: ["bash", "-c", 'd="${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs"; [ -f "$d" ] && . "$d"; echo -n "${XDG_SCREENSHOTS_DIR:-$HOME/Pictures/Screenshots}"'] + id: windowResolveProc + command: ["hyprctl", "activewindow", "-j"] stdout: StdioCollector { onStreamFinished: { - const d = text.trim(); - if (d.length > 0) - root.dir = d; - mkdirProc.command = ["mkdir", "-p", root.dir]; - mkdirProc.running = true; + try { + const w = JSON.parse(text); + if (!w || !w.at || !w.size || w.size[0] <= 0) { root.fail("No active window"); return; } + const cx = w.at[0] + w.size[0] / 2; + const cy = w.at[1] + w.size[1] / 2; + let target = Quickshell.screens[0]; + for (const s of Quickshell.screens) + if (cx >= s.x && cx < s.x + s.width && cy >= s.y && cy < s.y + s.height) { target = s; break; } + const lx = Math.max(0, w.at[0] - target.x); + const ly = Math.max(0, w.at[1] - target.y); + root.autoGrab = { + output: target.name, + x: lx, + y: ly, + w: Math.min(w.size[0], target.width - lx), + h: Math.min(w.size[1], target.height - ly) + }; + } catch (e) { + root.fail("No active window"); + } } } } - Process { id: mkdirProc } - Process { id: notifyProc } - Process { id: swappyProc } + function runLive(): void { + if (root.pendingKind === "all") { + grimProc.command = ["grim", root.pendingPath]; + grimProc.running = true; + } else if (root.pendingKind === "output") { + liveMonitorsProc.running = true; + } else if (root.pendingKind === "window") { + liveWindowProc.running = true; + } + } Process { id: grimProc onExited: (code, status) => { - if (code === 0) - root.finalize(root.pendingPath); - else - root.fail("grim exited with code " + code); + if (code === 0) root.finalize(root.pendingPath); + else root.fail("grim exited with code " + code); } } Process { - id: monitorsProc + id: liveMonitorsProc command: ["hyprctl", "monitors", "-j"] stdout: StdioCollector { onStreamFinished: { try { const mons = JSON.parse(text); - const focused = mons.find(m => m.focused) ?? mons[0]; - if (!focused) { root.fail("No monitor found"); return; } - grimProc.command = ["grim", "-o", focused.name, root.pendingPath]; + const f = mons.find(m => m.focused) ?? mons[0]; + grimProc.command = ["grim", "-o", f.name, root.pendingPath]; grimProc.running = true; } catch (e) { - root.fail("Could not parse monitors"); + root.fail("Could not determine monitor"); } } } } Process { - id: activeWinProc + id: liveWindowProc command: ["hyprctl", "activewindow", "-j"] stdout: StdioCollector { onStreamFinished: { @@ -200,6 +213,46 @@ Singleton { } } + function finalize(path: string): void { + // swappy's -o - writes its annotated result to stdout (-> wl-copy); its own + // Save button additionally writes to swappy's configured dir. + swappyProc.command = ["bash", "-c", "swappy -f " + root.shq(path) + " -o - | wl-copy"]; + swappyProc.running = true; + root.pendingKind = ""; + root.pendingPath = ""; + root.autoGrab = null; + } + + function fail(msg: string): void { + root.active = false; + root.mode = ""; + root.pendingPath = ""; + root.pendingKind = ""; + root.autoGrab = null; + notifyProc.command = ["notify-send", "Screenshot failed", msg, "-u", "critical"]; + notifyProc.running = true; + } + + Component.onCompleted: dirProc.running = true + + Process { + id: dirProc + command: ["bash", "-c", 'd="${XDG_CONFIG_HOME:-$HOME/.config}/user-dirs.dirs"; [ -f "$d" ] && . "$d"; echo -n "${XDG_SCREENSHOTS_DIR:-$HOME/Pictures/Screenshots}"'] + stdout: StdioCollector { + onStreamFinished: { + const d = text.trim(); + if (d.length > 0) + root.dir = d; + mkdirProc.command = ["mkdir", "-p", root.dir]; + mkdirProc.running = true; + } + } + } + + Process { id: mkdirProc } + Process { id: notifyProc } + Process { id: swappyProc } + IpcHandler { target: "screenshot" function menu(): void { root.menu(); }