| 1 | pragma Singleton |
| 2 | import QtQuick |
| 3 | import Quickshell |
| 4 | import Quickshell.Io |
| 5 | import Quickshell.Hyprland |
| 6 | |
| 7 | // Screenshots: open() freezes the focused monitor with grim (raw ppm in $XDG_RUNTIME_DIR: ~90 ms, png ~450) and ShotAim |
| 8 | // shows that frame under the viewfinder (region / window / screen / record, timer). fire(rect): without a timer vips crops |
| 9 | // the frozen frame (what you saw is what you get), with one grim takes the live screen after a countdown (ShotAim shows it; |
| 10 | // the timer is off again on every open: left on, it silently delayed every later shot). Every shot is |
| 11 | // copied; it's only a file in /tmp (tmpfs: gone on reboot, but there to paste a path) unless kept: Ctrl+S in the |
| 12 | // viewfinder or editor, or "save" on the card, puts it in ~/pix/ss. After a shot ShotAim flashes the area and shows a |
| 13 | // card, a click on it opens ShotEditor (`editing`). |
| 14 | // Record mode hands the area to Rec (wf-recorder). OCR is the separate `ocr` script (slurp + grim + tesseract). |
| 15 | // After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), shot/Shot.qml. |
| 16 | Singleton { |
| 17 | id: root |
| 18 | |
| 19 | readonly property var modes: ["region", "window", "screen", "record"] |
| 20 | readonly property var delays: [0, 3, 10] |
| 21 | readonly property string dir: Quickshell.env("HOME") + "/pix/ss" // kept shots |
| 22 | readonly property string tmp: "/tmp" // the rest |
| 23 | readonly property string run: Quickshell.env("XDG_RUNTIME_DIR") || "/tmp" |
| 24 | |
| 25 | property bool aiming: false // the viewfinder is up |
| 26 | property bool snap: false // hide it without the fade (the live screen is captured right after) |
| 27 | property int mode: 0 // index into modes |
| 28 | property int delay: 0 // index into delays |
| 29 | property var scr: null // ShellScreen of the frozen monitor |
| 30 | property var mon: null // its HyprlandMonitor (x/y in layout coordinates) |
| 31 | property string frozen: "" |
| 32 | property var wins: [] // windows on its workspace, logical px relative to it: {x, y, w, h}, topmost first |
| 33 | property point cursor // the cursor when it froze, relative to the screen; -1 = unknown |
| 34 | |
| 35 | // the last shot (flash + card) |
| 36 | property string last: "" // its file |
| 37 | readonly property bool kept: last !== "" && last.indexOf(dir + "/") === 0 |
| 38 | property string lastWhat: "" |
| 39 | property string lastDims: "" |
| 40 | property rect lastRect |
| 41 | property bool lastRec: false |
| 42 | signal fired() |
| 43 | |
| 44 | property string editing: "" // file open in ShotEditor |
| 45 | property int countdown: 0 // seconds left before a timed shot / recording |
| 46 | property string countWhat: "" // "shot" | "rec" |
| 47 | |
| 48 | // m: "region" | "window" | "screen" | "record" (anything else = region); wait: ms to let a panel/overlay leave first |
| 49 | function open(m, wait) { |
| 50 | if (aiming) { close(); return } // Print again = cancel |
| 51 | if (countdown > 0) { tick.stop(); countdown = 0; return } // ... also a running countdown |
| 52 | if (freeze.running) return |
| 53 | var i = modes.indexOf(m) |
| 54 | mode = i >= 0 && (i !== 3 || Rec.available) ? i : 0 |
| 55 | delay = 0 |
| 56 | if (wait > 0) { later.interval = wait; later.restart() } |
| 57 | else start() |
| 58 | } |
| 59 | function close() { snap = false; aiming = false } |
| 60 | function ocr(wait) { Quickshell.execDetached(["sh", "-c", 'sleep "$1"; exec ocr', "sh", String((wait || 0) / 1000)]) } |
| 61 | function edit() { editing = editing !== "" ? "" : last } // toggle: `shot edit` again discards |
| 62 | function isTemp(f) { return f.indexOf(tmp + "/") === 0 } |
| 63 | function tempPath() { return tmp + "/shot-" + Qt.formatDateTime(new Date(), "yyyy-MM-dd_HH-mm-ss") + ".png" } |
| 64 | |
| 65 | // file -> clipboard; keep = also into ~/pix/ss (same name). Becomes `last` (the card shows it). Used by the card's |
| 66 | // save chip (keep()) and the editor; quiet = no "saved" notification (the card already says so) |
| 67 | function done(file, keep, quiet) { |
| 68 | doneProc.quiet = !!quiet |
| 69 | doneProc.command = ["sh", "-c", 'f="$1"; if [ "$2" = 1 ]; then mkdir -p "$3" && cp -f "$f" "$3/${f##*/}" && f="$3/${f##*/}" || exit 1; fi\n' |
| 70 | + 'wl-copy -t image/png < "$f" && printf %s "$f"', "sh", file, keep ? "1" : "0", dir] |
| 71 | doneProc.running = true |
| 72 | } |
| 73 | function keep() { if (last !== "" && !kept) done(last, true, true) } |
| 74 | Process { |
| 75 | id: doneProc |
| 76 | property bool quiet |
| 77 | stdout: StdioCollector { |
| 78 | onStreamFinished: { |
| 79 | if (text === "") return |
| 80 | root.last = text |
| 81 | if (root.kept && !doneProc.quiet) |
| 82 | Quickshell.execDetached(["notify-send", "-a", "aurora", "-i", text, "screenshot saved", text]) |
| 83 | } |
| 84 | } |
| 85 | onExited: code => { |
| 86 | if (code !== 0) Quickshell.execDetached(["notify-send", "-a", "aurora", "-u", "critical", "screenshot", "couldn't copy / save it"]) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | Timer { id: later; onTriggered: root.start() } |
| 91 | |
| 92 | function start() { |
| 93 | var m = Hyprland.focusedMonitor, l = Quickshell.screens, s = l[0] |
| 94 | for (var i = 0; i < l.length; i++) if (m && l[i].name === m.name) s = l[i] |
| 95 | scr = s |
| 96 | mon = m |
| 97 | Hyprland.refreshToplevels() // async: read back once grim is done |
| 98 | freeze.next = run + "/aurora-frozen-" + Date.now() + ".ppm" |
| 99 | // + where the cursor is (layout coordinates), so the crosshair, loupe and window mode work before the mouse moves |
| 100 | freeze.command = ["sh", "-c", 'rm -f "$3"/aurora-frozen-*.ppm; grim -t ppm -o "$1" "$2" && echo ok && hyprctl cursorpos', |
| 101 | "sh", s.name, freeze.next, run] |
| 102 | freeze.running = true |
| 103 | } |
| 104 | Process { |
| 105 | id: freeze |
| 106 | property string next |
| 107 | stdout: StdioCollector { |
| 108 | onStreamFinished: { |
| 109 | var l = text.trim().split("\n") |
| 110 | if (l[0] !== "ok") return |
| 111 | var c = (l[1] || "").split(",") |
| 112 | root.cursor = c.length === 2 ? Qt.point(parseFloat(c[0]) - (root.mon ? root.mon.x : 0), parseFloat(c[1]) - (root.mon ? root.mon.y : 0)) |
| 113 | : Qt.point(-1, -1) |
| 114 | root.frozen = freeze.next // only now: the file is complete |
| 115 | root.wins = root.windows() |
| 116 | root.snap = false |
| 117 | root.aiming = true |
| 118 | } |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // windows of the frozen monitor's workspace (+ its open special workspace on top), floating ones above tiled |
| 123 | function windows() { |
| 124 | var m = mon, ipc = m ? m.lastIpcObject : null |
| 125 | var ws = m && m.activeWorkspace ? m.activeWorkspace.id : -1 |
| 126 | var sp = ipc && ipc.specialWorkspace ? ipc.specialWorkspace.id : 0 |
| 127 | var l = Hyprland.toplevels.values, out = [] |
| 128 | for (var i = 0; i < l.length; i++) { |
| 129 | var o = l[i].lastIpcObject, w = l[i].workspace |
| 130 | if (!w || !o || !o.size || !o.at || (w.id !== ws && (sp === 0 || w.id !== sp))) continue |
| 131 | out.push({ x: o.at[0] - m.x, y: o.at[1] - m.y, w: o.size[0], h: o.size[1], |
| 132 | z: (w.id === sp ? 2 : 0) + (o.floating ? 1 : 0) }) |
| 133 | } |
| 134 | out.sort(function (a, b) { return b.z - a.z }) |
| 135 | return out |
| 136 | } |
| 137 | |
| 138 | // r: logical rect relative to the screen; fs: the frozen frame's size in pixels; keep: into ~/pix/ss, not /tmp |
| 139 | function fire(r, fs, keep) { |
| 140 | var W = scr.width, H = scr.height, k = fs.width / Math.max(1, W) |
| 141 | var x0 = Math.max(0, r.x), y0 = Math.max(0, r.y) |
| 142 | var x1 = Math.min(W, r.x + r.width), y1 = Math.min(H, r.y + r.height) |
| 143 | if (x1 - x0 <= 2 || y1 - y0 <= 2) { x0 = 0; y0 = 0; x1 = W; y1 = H } |
| 144 | r = Qt.rect(x0, y0, x1 - x0, y1 - y0) |
| 145 | var full = r.width >= W && r.height >= H |
| 146 | var w = Math.round(r.width), h = Math.round(r.height) |
| 147 | var gx = Math.round((mon ? mon.x : 0) + r.x), gy = Math.round((mon ? mon.y : 0) + r.y) |
| 148 | lastRect = r |
| 149 | lastRec = mode === 3 |
| 150 | lastWhat = modes[mode] + " · " + Theme.hh(new Date()) + ":" + Qt.formatTime(new Date(), "mm") |
| 151 | // the viewfinder goes at once, no fade: recordings and timed shots capture the live screen |
| 152 | snap = true |
| 153 | aiming = false |
| 154 | |
| 155 | var live = delays[delay] > 0 |
| 156 | if (lastRec) { |
| 157 | lastDims = w + "×" + h |
| 158 | var g = full ? "" : gx + "," + gy + " " + w + "x" + h |
| 159 | after(live, "rec", function () { Rec.begin(g); root.fired() }) |
| 160 | return |
| 161 | } |
| 162 | // vips (unlike magick) fails on a crop a pixel past the frame: fractional scales, so clamp in pixels |
| 163 | var cx = Math.min(fs.width - 1, Math.round(r.x * k)), cy = Math.min(fs.height - 1, Math.round(r.y * k)) |
| 164 | var cw = Math.max(1, Math.min(Math.round(r.width * k), fs.width - cx)) |
| 165 | var ch = Math.max(1, Math.min(Math.round(r.height * k), fs.height - cy)) |
| 166 | lastDims = cw + "×" + ch |
| 167 | take.command = ["sh", "-c", 'mkdir -p "$5"; f="$5/shot-$(date +%F_%H-%M-%S).png"\n' |
| 168 | + 'if [ "$1" = 0 ]; then vips crop "$4" "$f[compression=1]" $2 2>/dev/null; else grim -g "$3" "$f"; fi' |
| 169 | + ' && wl-copy -t image/png < "$f" && printf %s "$f"', |
| 170 | "sh", live ? "1" : "0", cx + " " + cy + " " + cw + " " + ch, gx + "," + gy + " " + w + "x" + h, frozen, keep ? dir : tmp] |
| 171 | after(live, "shot", function () { take.running = true }) |
| 172 | } |
| 173 | |
| 174 | // now, or after the timer's countdown; the countdown pill gets 150ms to leave the screen before the live capture |
| 175 | property var pending: null |
| 176 | function after(timed, what, fn) { |
| 177 | if (!timed) { fn(); return } |
| 178 | pending = fn |
| 179 | countWhat = what |
| 180 | countdown = delays[delay] |
| 181 | tick.restart() |
| 182 | } |
| 183 | Timer { |
| 184 | id: tick |
| 185 | interval: 1000 |
| 186 | repeat: true |
| 187 | onTriggered: { |
| 188 | root.countdown-- |
| 189 | if (root.countdown > 0) return |
| 190 | stop() |
| 191 | go.restart() |
| 192 | } |
| 193 | } |
| 194 | Timer { id: go; interval: 150; onTriggered: if (root.pending) root.pending() } |
| 195 | Process { |
| 196 | id: take |
| 197 | stdout: StdioCollector { |
| 198 | onStreamFinished: { |
| 199 | if (text === "") return |
| 200 | root.last = text |
| 201 | root.fired() |
| 202 | } |
| 203 | } |
| 204 | onExited: code => { |
| 205 | if (code !== 0) Quickshell.execDetached(["notify-send", "-a", "aurora", "-u", "critical", "screenshot failed", |
| 206 | "grim / vips exited with " + code]) |
| 207 | } |
| 208 | } |
| 209 | |
| 210 | } |