Krolyxon
Commit 0b9794e

Branches

Refs →

aurora.configquickshellauroraShot.qml

210 lines · 10.3 KB · QML

1pragma Singleton
2import QtQuick
3import Quickshell
4import Quickshell.Io
5import 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.
16Singleton {
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}