Krolyxon
Commit 0b9794e

Branches

Refs →

aurora0b9794e

aurora: screenshot viewfinder and editor (glyph-rice port), ocr script

Replaces hyprshot and hypr_screenshot.sh. Print / SUPER+SHIFT+P freeze the
monitor (grim) under a viewfinder: region / window / screen / record,
dotted crosshair, loupe with the pixel colour, timer with a countdown.
Enter or Ctrl+C copies (the file stays in /tmp), Ctrl+S also saves to
~/pix/ss; vips crops the frozen frame. A card follows the shot: click to
edit (pen, arrow, rect, blur, text; Ctrl+C copy, Ctrl+S save), "save"
keeps it. Record mode hands the area to wf-recorder.

OCR is its own script: ocr (slurp + grim + tesseract), SHIFT+PRINT and
SUPER+SHIFT+O. packages: hyprshot out, libvips in.
Author
krolyxon
Date
2026-10-29 11:39 +0530
Parent
b101de2
Commit
0b9794e11c0f6243dd94d4516a28bdd511694292

+1157 −91

12 files changed

  1. M .config/hypr/source/keybinds.lua +4 −2
  2. M .config/quickshell/aurora/HomePage.qml +3 −45
  3. A .config/quickshell/aurora/Pill.qml +20 −0
  4. M .config/quickshell/aurora/Rec.qml +6 −0
  5. M .config/quickshell/aurora/Runner.qml +3 −3
  6. M .config/quickshell/aurora/Shot.qml +197 −20
  7. A .config/quickshell/aurora/ShotAim.qml +483 −0
  8. A .config/quickshell/aurora/ShotEditor.qml +399 −0
  9. M .config/quickshell/aurora/shell.qml +16 −2
  10. D .local/bin/hypr/hypr_screenshot.sh +0 −18
  11. A .local/bin/ocr +25 −0
  12. M packages/desktop.txt +1 −1
M .config/hypr/source/keybinds.lua View
@@ -42,8 +42,10 @@ hl.bind(mainMod .. " + I", hl.dsp.exec_cmd("qs -c aurora ipc call shell settings
4242
4343 -- Applications
4444 hl.bind(mainMod .. " + W", hl.dsp.exec_cmd(browser))
45−hl.bind("PRINT", hl.dsp.exec_cmd("hypr_screenshot.sh"))
46−hl.bind(mainMod .. " + SHIFT + P", hl.dsp.exec_cmd("hypr_screenshot.sh"))
45+hl.bind("PRINT", hl.dsp.exec_cmd("qs -c aurora ipc call shell shot region"), { description = "screenshot" })
46+hl.bind(mainMod .. " + SHIFT + P", hl.dsp.exec_cmd("qs -c aurora ipc call shell shot region"), { description = "screenshot" })
47+hl.bind("SHIFT + PRINT", hl.dsp.exec_cmd("ocr"), { description = "ocr a region" })
48+hl.bind(mainMod .. " + SHIFT + O", hl.dsp.exec_cmd("ocr"), { description = "ocr a region" })
4749
4850 -- Music Controls
4951 hl.bind(mainMod .. " + m", hl.dsp.exec_cmd(terminal .. " -e zsh -i -c \"rmptui\""))
M .config/quickshell/aurora/HomePage.qml View
@@ -19,8 +19,6 @@ Flickable {
1919 implicitHeight: col.implicitHeight
2020 boundsBehavior: Flickable.StopAtBounds
2121
22− property bool shotOpen: false // the capture options under the SHOT tile
23− onVisibleChanged: if (!visible) shotOpen = false
2422
2523 readonly property var sink: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.audio : null
2624 readonly property var source: Pipewire.defaultAudioSource ? Pipewire.defaultAudioSource.audio : null
@@ -138,52 +136,12 @@ Flickable {
138136 name: "colour"
139137 onClicked: { page.closeRequested(); Toggles.pickColour() }
140138 }
141− Tile {
139+ Tile { // click: the screenshot viewfinder (after the panel is gone), right click: ocr
142140 width: tiles.cw
143141 icon: Theme.iShot
144142 name: "shot"
145− on: page.shotOpen
146− onClicked: page.shotOpen = !page.shotOpen
147− onMenu: page.shotOpen = !page.shotOpen
148− }
149− }
150−
151− // ---- capture (under SHOT): clipboard or ~/pix/ss, then the kind of shot ----
152− Rectangle {
153− visible: page.shotOpen
154− Layout.fillWidth: true
155− implicitHeight: cap.implicitHeight + 24
156− radius: 30
157− color: Theme.s2
158− ColumnLayout {
159− id: cap
160− x: 12
161− y: 12
162− width: parent.width - 24
163− spacing: 10
164− Segmented {
165− Layout.fillWidth: true
166− implicitHeight: 44
167− color: Theme.s3
168− options: [{ value: false, label: "copy" }, { value: true, label: "save" }]
169− value: Shot.save
170− onPicked: v => Shot.save = v
171− }
172− RowLayout {
173− Layout.fillWidth: true
174− spacing: 6
175− Repeater {
176− model: [{ mode: "region", label: "area" }, { mode: "window", label: "window" },
177− { mode: "output", label: "screen" }, { mode: "ocr", label: "ocr" }]
178− delegate: Chip {
179− required property var modelData
180− Layout.fillWidth: true
181− text: modelData.label
182− filled: true
183− onClicked: { page.closeRequested(); Shot.take(modelData.mode) }
184− }
185− }
186− }
143+ onClicked: { page.closeRequested(); Shot.open("region", Theme.t + 60) }
144+ onMenu: { page.closeRequested(); Shot.ocr(Theme.t + 60) }
187145 }
188146 }
189147
A .config/quickshell/aurora/Pill.qml View
@@ -0,0 +1,20 @@
1+import QtQuick
2+
3+// A pill floating over a full-screen view (screenshot viewfinder, editor): the content in a centred row.
4+// Clicks on it don't fall through to what's under it.
5+// Ported from glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), shot/Pill.qml.
6+Rectangle {
7+ default property alias content: row.data
8+ property alias spacing: row.spacing
9+ property real pad: 10
10+
11+ width: row.width + pad * 2
12+ height: 56
13+ radius: height / 2
14+ color: Theme.s1
15+ border.width: 1
16+ border.color: Theme.s2
17+
18+ MouseArea { anchors.fill: parent }
19+ Row { id: row; anchors.centerIn: parent }
20+}
M .config/quickshell/aurora/Rec.qml View
@@ -32,6 +32,12 @@ Singleton {
3232 if (recProc.running) recProc.signal(2) // SIGINT: wf-recorder finalises the file
3333 }
3434 function toggle(m) { if (busy) stop(); else start(m) }
35+ // an area already picked (Shot's record mode): "x,y wxh", "" = the focused monitor
36+ function begin(geometry) {
37+ if (!available || busy) return
38+ mode = geometry !== "" ? "region" : "screen"
39+ record(geometry)
40+ }
3541
3642 function record(geometry) {
3743 file = dir + "/" + Qt.formatDateTime(new Date(), "yyyy-MM-dd_HH-mm-ss") + ".mp4"
M .config/quickshell/aurora/Runner.qml View
@@ -198,7 +198,7 @@ PanelWindow {
198198 { id: "bar", glyph: Theme.iMonitor, name: "toggle bar" },
199199 { id: "picker", glyph: Theme.iPicker, name: "colour picker" },
200200 { id: "shot", glyph: Theme.iCrop, name: "screenshot region" },
201− { id: "ocr", glyph: Theme.iCrop, name: "screenshot ocr" }
201+ { id: "ocr", glyph: Theme.iOcr, name: "ocr (text from screen)" }
202202 ]
203203 if (Rec.available) c.push({ id: "rec", glyph: Theme.iRec, name: Rec.busy ? "stop recording" : "record screen" })
204204 var out = []
@@ -364,8 +364,8 @@ PanelWindow {
364364 case "dnd": Notifs.dnd = !Notifs.dnd; break
365365 case "bar": shell.barShown = !shell.barShown; break
366366 case "picker": Toggles.pickColour(); break
367− case "shot": Shot.take("region"); break
368− case "ocr": Shot.take("ocr"); break
367+ case "shot": shell.shot("region"); break
368+ case "ocr": shell.shot("ocr"); break
369369 case "rec": Rec.toggle("screen"); break
370370 }
371371 }
M .config/quickshell/aurora/Shot.qml View
@@ -1,33 +1,210 @@
11 pragma Singleton
22 import QtQuick
33 import Quickshell
4+import Quickshell.Io
5+import Quickshell.Hyprland
46
5−// Screenshots, ported from your fuzzel script. Needs: hyprshot (+ grim, slurp), and for
6−// OCR: tesseract + wl-clipboard. Used by the Home tab and by `ipc call shell shot <mode>`.
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.
716 Singleton {
817 id: root
918
10− property bool save: false // false = clipboard, true = file
11− property string dir: Quickshell.env("HOME") + "/pix/ss" // where "save" puts files
12− property string ocrLang: "eng"
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"
1324
14− readonly property var names: ({ region: "selected", window: "window", output: "full" })
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
1534
16− // mode: "region" | "window" | "output" | "ocr"
17− function take(mode) {
18− var script
19− if (mode === "ocr") {
20− script = 'slurp ' + Theme.slurpArgs.join(" ") + ' | grim -g - - | tesseract stdin stdout -l "$2" | wl-copy'
21− + ' && notify-send -a screenshot "OCR" "text copied to clipboard"'
22− } else if (names[mode] === undefined) {
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() })
23160 return
24− } else if (!save) {
25− script = 'hyprshot -m ' + mode + ' --clipboard-only'
26− } else {
27− script = 'mkdir -p "$1"; hyprshot -m ' + mode + ' -o "$1"'
28− + ' -f "pic-' + names[mode] + '-$(date +%y-%m-%d_%H%M%S).png"'
29161 }
30− // short delay so the panel is gone before the capture starts
31− Quickshell.execDetached(["sh", "-c", "sleep 0.35; " + script, "sh", dir, ocrLang])
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()
32182 }
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+
33210 }
A .config/quickshell/aurora/ShotAim.qml View
@@ -0,0 +1,483 @@
1+import QtQuick
2+import QtQuick.Effects
3+import Quickshell
4+import Quickshell.Wayland
5+
6+// The screenshot viewfinder over the frozen frame (Shot.open), and what follows a shot.
7+// Viewfinder: everything outside the selection dims, a dotted crosshair across the screen, the frame is a 1px line with
8+// dotted thirds and corner dots, mode + size above it; a loupe at the cursor (pixels as dots in a circle, coordinates and
9+// the colour under it). Drag = region, click = window / screen, Space = window mode, Tab = next mode, T = timer,
10+// Enter / Ctrl+C = copy (a file in /tmp only), Ctrl+S = save to ~/pix/ss + copy (whole screen without a selection),
11+// Esc = cancel. Modes and timer are chips in a pill at the bottom.
12+// After the shot: the area flashes (recording: a live-coloured frame around it) and a card slides in at the bottom
13+// right for a few seconds (longer while hovered): click the shot to edit it (ShotEditor), "save" keeps it.
14+// After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), shot/Shot.qml.
15+Scope {
16+ id: root
17+
18+ // ui state, logical px relative to the screen
19+ property rect drag: Qt.rect(0, 0, 0, 0) // width 0 = nothing selected
20+ property real mx: 0
21+ property real my: 0
22+ readonly property var hover: {
23+ var l = Shot.wins
24+ for (var i = 0; i < l.length; i++)
25+ if (mx >= l[i].x && mx < l[i].x + l[i].w && my >= l[i].y && my < l[i].y + l[i].h) return l[i]
26+ return null
27+ }
28+ readonly property rect sel: Shot.mode === 2 ? Qt.rect(0, 0, stage.width, stage.height)
29+ : Shot.mode === 1 ? (hover ? Qt.rect(hover.x, hover.y, hover.w, hover.h) : Qt.rect(0, 0, 0, 0))
30+ : drag
31+ readonly property real k: frozenImg.sourceSize.width / Math.max(1, stage.width) // frame px per logical px
32+
33+ function fire(keep) { Shot.fire(sel, frozenImg.sourceSize, keep) }
34+ function cycle(n) {
35+ var c = Rec.available ? 4 : 3
36+ Shot.mode = (Shot.mode + n + c) % c
37+ }
38+
39+ Connections {
40+ target: Shot
41+ function onAimingChanged() {
42+ if (!Shot.aiming) return
43+ root.drag = Qt.rect(0, 0, 0, 0)
44+ if (Shot.cursor.x >= 0) { root.mx = Shot.cursor.x; root.my = Shot.cursor.y }
45+ stage.forceActiveFocus()
46+ }
47+ function onFired() { out.stop(); stay.stop(); fx.restart() }
48+ }
49+
50+ // ============ viewfinder ============
51+ PanelWindow {
52+ id: aim
53+
54+ // opens at once (the frame already matches the screen), fades out; Shot.snap = gone without the fade
55+ property real t: Shot.aiming ? 1 : 0
56+ Behavior on t {
57+ enabled: !Shot.snap
58+ NumberAnimation { duration: Theme.t; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide }
59+ }
60+
61+ screen: Shot.scr || Quickshell.screens[0]
62+ visible: t > 0
63+ anchors { left: true; right: true; top: true; bottom: true }
64+ color: "transparent"
65+ exclusionMode: ExclusionMode.Ignore
66+ WlrLayershell.layer: WlrLayer.Overlay
67+ WlrLayershell.keyboardFocus: Shot.aiming ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
68+ WlrLayershell.namespace: "aurora-shot"
69+
70+ Item {
71+ id: stage
72+ anchors.fill: parent
73+ opacity: Shot.aiming ? 1 : aim.t
74+ focus: true
75+ Keys.onPressed: e => {
76+ var ctrl = (e.modifiers & Qt.ControlModifier) !== 0
77+ if (e.key === Qt.Key_Escape) Shot.close()
78+ else if (e.key === Qt.Key_Return || e.key === Qt.Key_Enter || (ctrl && e.key === Qt.Key_C)) root.fire(false)
79+ else if (ctrl && e.key === Qt.Key_S) root.fire(true)
80+ else if (e.key === Qt.Key_Space) Shot.mode = 1
81+ else if (e.key === Qt.Key_Tab) root.cycle(1)
82+ else if (e.key === Qt.Key_Backtab) root.cycle(-1)
83+ else if (e.key === Qt.Key_T) Shot.delay = (Shot.delay + 1) % Shot.delays.length
84+ else return
85+ e.accepted = true
86+ }
87+
88+ Image {
89+ id: frozenImg
90+ anchors.fill: parent
91+ source: Shot.frozen !== "" ? "file://" + Shot.frozen : "" // shared cache with the loupe and the picker
92+ }
93+
94+ // outside the frame dims; slightly while nothing is selected
95+ Item {
96+ anchors.fill: parent
97+ opacity: aim.t
98+ readonly property color c: Theme.fade(Theme.bg, root.sel.width > 0 ? 0.74 : 0.35)
99+ readonly property rect r: root.sel.width > 0 ? root.sel : Qt.rect(0, 0, 0, 0)
100+ Rectangle { width: parent.width; height: parent.r.y; color: parent.c }
101+ Rectangle { y: parent.r.y + parent.r.height; width: parent.width; height: parent.height - y; color: parent.c }
102+ Rectangle { y: parent.r.y; width: parent.r.x; height: parent.r.height; color: parent.c }
103+ Rectangle { x: parent.r.x + parent.r.width; y: parent.r.y; width: parent.width - x; height: parent.r.height; color: parent.c }
104+ }
105+
106+ // crosshair: dotted lines across the screen
107+ Dots { y: Math.round(root.my) - 4; width: parent.width; height: 8; opacity: 0.5 * aim.t }
108+ Dots { x: Math.round(root.mx) - 4; width: 8; height: parent.height; opacity: 0.5 * aim.t }
109+
110+ // frame: 1px line, thirds as dots, dots on the corners; mode and size above it
111+ Item {
112+ id: vf
113+ visible: root.sel.width > 0
114+ opacity: aim.t
115+ x: root.sel.x
116+ y: root.sel.y
117+ width: root.sel.width
118+ height: root.sel.height
119+
120+ Rectangle { anchors.fill: parent; color: "transparent"; border.width: 1; border.color: Theme.fg }
121+ Repeater {
122+ model: 2
123+ Item {
124+ required property int index
125+ anchors.fill: parent
126+ Dots { x: parent.width * (index + 1) / 3 - 4; width: 8; height: parent.height; opacity: 0.3 }
127+ Dots { y: parent.height * (index + 1) / 3 - 4; width: parent.width; height: 8; opacity: 0.3 }
128+ }
129+ }
130+ Repeater {
131+ model: 4
132+ Rectangle {
133+ required property int index
134+ x: (index % 2 ? vf.width : 0) - 6
135+ y: (index > 1 ? vf.height : 0) - 6
136+ width: 12
137+ height: 12
138+ radius: 6
139+ color: Theme.fill
140+ }
141+ }
142+
143+ // above the frame; inside it near the top edge
144+ Item {
145+ y: vf.y > 70 ? -height - 18 : 18
146+ width: parent.width
147+ height: dims.height
148+ Row {
149+ x: 2
150+ anchors.bottom: parent.bottom
151+ spacing: 10
152+ // recording: the live dot, the one live thing on screen
153+ Rectangle {
154+ visible: Shot.mode === 3
155+ anchors.verticalCenter: parent.verticalCenter
156+ width: 10
157+ height: 10
158+ radius: 5
159+ color: Theme.live
160+ }
161+ Lb { text: Shot.modes[Shot.mode]; color: Theme.fg }
162+ }
163+ Row {
164+ anchors.right: parent.right
165+ anchors.rightMargin: 2
166+ spacing: 14
167+ Lb { anchors.bottom: parent.bottom; text: Math.round(root.sel.width * root.k) + "×" + Math.round(root.sel.height * root.k) + " px" }
168+ Num { id: dims; text: Math.round(root.sel.width) + "×" + Math.round(root.sel.height); font.pixelSize: 44 }
169+ }
170+ }
171+ }
172+
173+ // loupe: the pixels under the cursor x12, each a dot (DotMask with a pixel's step); coordinates and colour below
174+ Item {
175+ id: loupe
176+ readonly property int px: Math.floor(root.mx * root.k)
177+ readonly property int py: Math.floor(root.my * root.k)
178+ readonly property int zoom: 12
179+ opacity: aim.t
180+ x: root.mx + 24 + width > stage.width ? root.mx - 24 - width : root.mx + 24
181+ y: root.my + 24 + height > stage.height ? root.my - 24 - height : root.my + 24
182+ width: 180
183+ height: 240
184+
185+ // the pixel's colour: drawn into a 1x1 canvas hidden under the loupe
186+ Canvas {
187+ id: picker
188+ property string hex: "#000000"
189+ x: 90
190+ y: 90
191+ width: 1
192+ height: 1
193+ onImageLoaded: requestPaint()
194+ onPaint: {
195+ var g = getContext("2d"), src = frozenImg.source.toString()
196+ if (src === "") return
197+ if (!isImageLoaded(src)) { loadImage(src); return }
198+ g.drawImage(src, loupe.px, loupe.py, 1, 1, 0, 0, 1, 1)
199+ var d = g.getImageData(0, 0, 1, 1).data, h = "#"
200+ for (var i = 0; i < 3; i++) h += (d[i] < 16 ? "0" : "") + d[i].toString(16)
201+ hex = h.toUpperCase()
202+ }
203+ Connections {
204+ target: loupe
205+ function onPxChanged() { picker.requestPaint() }
206+ function onPyChanged() { picker.requestPaint() }
207+ }
208+ }
209+
210+ Rectangle { x: 6; y: 6; width: 168; height: 168; radius: 84; color: Theme.bg }
211+ // 13 x 13 pixels; the dot grid lines up with them: the centre is the middle of the 7th cell
212+ Item {
213+ x: 12
214+ y: 12
215+ width: 13 * loupe.zoom
216+ height: 13 * loupe.zoom
217+ layer.enabled: aim.visible
218+ layer.effect: DotMask { step: loupe.zoom; circle: true }
219+ Image {
220+ source: frozenImg.source
221+ smooth: false
222+ width: frozenImg.sourceSize.width * loupe.zoom
223+ height: frozenImg.sourceSize.height * loupe.zoom
224+ x: (6 - loupe.px) * loupe.zoom
225+ y: (6 - loupe.py) * loupe.zoom
226+ }
227+ }
228+ Rectangle { x: 80; y: 80; width: 20; height: 20; radius: 10; color: "transparent"; border.width: 1.5; border.color: Theme.fg }
229+ Ring { width: 180; height: 180; n: 48; dot: 3 }
230+
231+ Pill {
232+ anchors.horizontalCenter: parent.horizontalCenter
233+ anchors.bottom: parent.bottom
234+ height: 44
235+ pad: 18
236+ spacing: 12
237+ Num { anchors.verticalCenter: parent.verticalCenter; text: Math.round(root.mx) + " · " + Math.round(root.my); font.pixelSize: 26 }
238+ Rectangle {
239+ anchors.verticalCenter: parent.verticalCenter
240+ width: 12
241+ height: 12
242+ radius: 6
243+ color: picker.hex
244+ border.width: 1
245+ border.color: Theme.s3
246+ }
247+ Lb { anchors.verticalCenter: parent.verticalCenter; text: picker.hex; px: 11 }
248+ }
249+ }
250+
251+ MouseArea {
252+ anchors.fill: parent
253+ hoverEnabled: true
254+ cursorShape: Qt.CrossCursor
255+ property point from
256+ onPositionChanged: e => {
257+ root.mx = e.x
258+ root.my = e.y
259+ if (pressed && (Shot.mode === 0 || Shot.mode === 3))
260+ root.drag = Qt.rect(Math.min(from.x, e.x), Math.min(from.y, e.y), Math.abs(e.x - from.x), Math.abs(e.y - from.y))
261+ }
262+ onPressed: e => {
263+ if (Shot.mode === 1 || Shot.mode === 2) { root.fire(false); return } // window / screen: a click copies
264+ from = Qt.point(e.x, e.y)
265+ root.drag = Qt.rect(e.x, e.y, 0, 0)
266+ }
267+ }
268+
269+ // modes and timer as chips in a pill, the keys under it; they slide up from the bottom
270+ Column {
271+ anchors.horizontalCenter: parent.horizontalCenter
272+ anchors.bottom: parent.bottom
273+ anchors.bottomMargin: 44 - 24 * (1 - aim.t)
274+ opacity: aim.t
275+ spacing: 12
276+ Pill {
277+ anchors.horizontalCenter: parent.horizontalCenter
278+ spacing: 6
279+ Repeater {
280+ model: Shot.modes
281+ Chip {
282+ required property string modelData
283+ required property int index
284+ anchors.verticalCenter: parent.verticalCenter
285+ visible: index !== 3 || Rec.available
286+ text: modelData
287+ selected: index === Shot.mode
288+ onClicked: Shot.mode = index
289+ }
290+ }
291+ Item {
292+ width: 18
293+ height: 36
294+ Rectangle { anchors.centerIn: parent; width: 4; height: 4; radius: 2; color: Theme.mute }
295+ }
296+ Chip {
297+ anchors.verticalCenter: parent.verticalCenter
298+ text: Shot.delay > 0 ? Shot.delays[Shot.delay] + "s" : "timer"
299+ selected: Shot.delay > 0
300+ onClicked: Shot.delay = (Shot.delay + 1) % Shot.delays.length
301+ }
302+ }
303+ Pill {
304+ anchors.horizontalCenter: parent.horizontalCenter
305+ height: 36
306+ pad: 22
307+ spacing: 28
308+ Repeater {
309+ model: ["drag select", "space window", "tab mode", "t timer", "ctrl+c copy", "ctrl+s save", "esc cancel"]
310+ Lb { required property string modelData; anchors.verticalCenter: parent.verticalCenter; text: modelData; px: 10 }
311+ }
312+ }
313+ }
314+ }
315+ }
316+
317+ // ============ after the shot: the area flashes, the shot slides in as a card at the bottom right ============
318+ PanelWindow {
319+ id: fired
320+ screen: Shot.scr || Quickshell.screens[0]
321+ visible: false
322+ anchors { left: true; right: true; top: true; bottom: true }
323+ color: "transparent"
324+ exclusionMode: ExclusionMode.Ignore
325+ mask: Region { item: Shot.lastRec ? null : card } // clicks pass through everywhere but the card
326+ WlrLayershell.layer: WlrLayer.Overlay
327+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
328+ WlrLayershell.namespace: "aurora-shot-done"
329+
330+ // flash: the shot area lights up and fades. Recording: a live frame just outside it, no fill and no glow
331+ // (the recorder starts while it fades, it must stay out of the video)
332+ Rectangle {
333+ id: flash
334+ readonly property real o: Shot.lastRec ? 4 : 0
335+ x: Shot.lastRect.x - o
336+ y: Shot.lastRect.y - o
337+ width: Shot.lastRect.width + o * 2
338+ height: Shot.lastRect.height + o * 2
339+ color: Shot.lastRec ? "transparent" : Theme.fade(Theme.fg, 0.12)
340+ border.width: 2
341+ border.color: Shot.lastRec ? Theme.live : Theme.fill
342+ layer.enabled: fired.visible && !Shot.lastRec
343+ layer.effect: MultiEffect {
344+ shadowEnabled: true
345+ shadowColor: Theme.glow
346+ shadowBlur: 0.7
347+ blurMax: 20
348+ shadowHorizontalOffset: 0
349+ shadowVerticalOffset: 0
350+ }
351+ }
352+
353+ // card: rounded 40, the shot rounded 24 inside it (concentric)
354+ Rectangle {
355+ id: card
356+ property real t: 0
357+ visible: !Shot.lastRec
358+ x: fired.width - 28 - width + 48 * (1 - t)
359+ y: fired.height - 28 - height
360+ opacity: t
361+ width: 360
362+ height: col.height + 32
363+ radius: 40
364+ color: Theme.s1
365+ border.width: 1
366+ border.color: Theme.s2
367+
368+ Column {
369+ id: col
370+ x: 16
371+ y: 16
372+ width: parent.width - 32
373+ spacing: 16
374+ Item {
375+ width: parent.width
376+ height: 184
377+ TapHandler { onTapped: { fx.stop(); stay.stop(); out.stop(); fired.visible = false; Shot.edit() } }
378+ HoverHandler { cursorShape: Qt.PointingHandCursor }
379+ layer.enabled: fired.visible
380+ layer.effect: MultiEffect { maskEnabled: true; maskSource: thumbMask; maskThresholdMin: 0.5; maskSpreadAtMin: 1 }
381+ Rectangle { anchors.fill: parent; color: Theme.s2 }
382+ Image {
383+ anchors.fill: parent
384+ source: Shot.last !== "" ? "file://" + Shot.last : ""
385+ sourceSize: Qt.size(328, 184)
386+ fillMode: Image.PreserveAspectCrop
387+ cache: false // the editor saves over the same path
388+ }
389+ }
390+ Item {
391+ width: parent.width
392+ height: cdims.height
393+ Num { id: cdims; x: 8; text: Shot.lastDims; font.pixelSize: 30 }
394+ Lb { anchors.right: parent.right; anchors.rightMargin: 8; anchors.bottom: parent.bottom; text: Shot.lastWhat; px: 11 }
395+ }
396+ Item {
397+ width: parent.width
398+ height: keepChip.implicitHeight
399+ Lb {
400+ x: 8
401+ anchors.verticalCenter: parent.verticalCenter
402+ text: Shot.kept ? "saved · ~/pix/ss · click to edit" : "copied · click to edit"
403+ px: 10
404+ color: Theme.mute
405+ }
406+ Chip {
407+ id: keepChip
408+ anchors.right: parent.right
409+ visible: !Shot.kept
410+ text: "save"
411+ filled: true
412+ px: 10
413+ onClicked: { Shot.keep(); stay.restart() }
414+ }
415+ }
416+ }
417+ Rectangle { id: thumbMask; width: 328; height: 184; radius: 24; visible: false; layer.enabled: true }
418+ HoverHandler { id: cardHover }
419+ }
420+
421+ // the flash fades in 0.9s, the card slides in from the right, stays a moment (while hovered too) and leaves the same way
422+ SequentialAnimation {
423+ id: fx
424+ PropertyAction { target: card; property: "t"; value: 0 }
425+ PropertyAction { target: fired; property: "visible"; value: true }
426+ ParallelAnimation {
427+ NumberAnimation { target: flash; property: "opacity"; from: 1; to: 0; duration: 900; easing.type: Easing.OutQuad }
428+ NumberAnimation { target: card; property: "t"; from: 0; to: 1; duration: Math.max(1, Theme.t); easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide }
429+ }
430+ ScriptAction { script: stay.restart() }
431+ }
432+ Timer {
433+ id: stay
434+ interval: 3000
435+ onTriggered: { if (cardHover.hovered) restart(); else out.restart() }
436+ }
437+ SequentialAnimation {
438+ id: out
439+ NumberAnimation { target: card; property: "t"; to: 0; duration: Math.max(1, Theme.t); easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide }
440+ PropertyAction { target: fired; property: "visible"; value: false }
441+ }
442+ }
443+
444+ // ============ timer countdown: where the mode pill was; gone 150ms before the live capture (Shot.after) ============
445+ PanelWindow {
446+ screen: Shot.scr || Quickshell.screens[0]
447+ visible: Shot.countdown > 0
448+ anchors.bottom: true
449+ margins.bottom: 44
450+ implicitWidth: cd.width
451+ implicitHeight: cd.height
452+ color: "transparent"
453+ exclusionMode: ExclusionMode.Ignore
454+ mask: Region {} // click-through
455+ WlrLayershell.layer: WlrLayer.Overlay
456+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
457+ WlrLayershell.namespace: "aurora-shot-timer"
458+
459+ Pill {
460+ id: cd
461+ pad: 22
462+ spacing: 14
463+ Rectangle {
464+ anchors.verticalCenter: parent.verticalCenter
465+ width: 10
466+ height: 10
467+ radius: 5
468+ color: Theme.live
469+ }
470+ Lb { anchors.verticalCenter: parent.verticalCenter; text: Shot.countWhat + " in"; color: Theme.fg }
471+ Num { anchors.verticalCenter: parent.verticalCenter; text: Shot.countdown; font.pixelSize: 36 }
472+ Lb { anchors.verticalCenter: parent.verticalCenter; text: "print to cancel"; px: 10 }
473+ }
474+ }
475+
476+ // dotted line: a row or column of dots, 8px apart; an 8px thick line is exactly one row
477+ component Dots: Image {
478+ fillMode: Image.Tile
479+ horizontalAlignment: Image.AlignLeft
480+ verticalAlignment: Image.AlignTop
481+ source: "data:image/svg+xml," + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"><circle cx="4" cy="4" r="1.2" fill="#' + Theme.hex(Theme.fg) + '"/></svg>')
482+ }
483+}
A .config/quickshell/aurora/ShotEditor.qml View
@@ -0,0 +1,399 @@
1+import QtQuick
2+import QtQuick.Shapes
3+import Quickshell
4+import Quickshell.Wayland
5+
6+// Screenshot editor (click the card after a shot): the screen almost dark, the shot in a frame with corner dots like the
7+// viewfinder; tools as chips in a pill, inks as circles, sizes as dots. Marks live in the shot's pixels and are baked in
8+// with grabToImage at the shot's own size: Ctrl+C / Enter = copy (a /tmp file: the shot's own if it was one, a new one
9+// if it was kept, so a saved shot is never changed), Ctrl+S = also save to ~/pix/ss. P A R B T tool, 1-4 ink, [ ] size,
10+// Ctrl+Z undo, Esc discards. The inks are content baked into the image, not UI: the one place with fixed colours (+ the accent).
11+// After glyph-rice (github.com/Nebelung-cloud/glyph-rice, GPL-3.0), shot/Editor.qml.
12+Scope {
13+ id: root
14+
15+ readonly property var tools: ["pen", "arrow", "rect", "blur", "text"]
16+ readonly property var hotkeys: [Qt.Key_P, Qt.Key_A, Qt.Key_R, Qt.Key_B, Qt.Key_T]
17+ readonly property var inks: ["#FF3B30", "#FFFFFF", "#000000", Theme.fill]
18+ readonly property var sizes: [3, 6, 12] // stroke in logical px; in the shot x dpr
19+
20+ readonly property string shot: Shot.editing
21+ property int tool: 0
22+ property int ink: 0
23+ property int size: 1
24+ property var marks: [] // {k: "pen", c, w, pts} · {k: "arrow" | "rect" | "blur", c, w, x1, y1, x2, y2} · {k: "text", c, s, x, y, t}
25+ property var live: null // the mark being dragged right now
26+ readonly property var scr: Shot.scr || Quickshell.screens[0]
27+ readonly property real dpr: scr ? scr.devicePixelRatio : 1
28+ readonly property real w: sizes[size] * dpr
29+
30+ onShotChanged: {
31+ if (shot === "") return
32+ marks = []
33+ live = null
34+ typing.visible = false
35+ st.forceActiveFocus()
36+ }
37+ function close() { Shot.editing = "" }
38+
39+ function commitWord() {
40+ if (!typing.visible) return
41+ if (typing.text.trim() !== "")
42+ marks = marks.concat([{ k: "text", c: inks[ink], s: typing.font.pixelSize, x: typing.x, y: typing.y, t: typing.text }])
43+ typing.visible = false
44+ st.forceActiveFocus()
45+ }
46+
47+ // keep: also into ~/pix/ss (Shot.done copies it there and to the clipboard)
48+ function finish(keep) {
49+ commitWord()
50+ var path = Shot.isTemp(shot) ? shot : Shot.tempPath()
51+ canvas.grabToImage(function (r) {
52+ if (!r.saveToFile(path)) {
53+ Quickshell.execDetached(["notify-send", "-a", "aurora", "-u", "critical", "screenshot", "couldn't write " + path])
54+ return
55+ }
56+ Shot.done(path, keep, false)
57+ root.close()
58+ }, Qt.size(img.implicitWidth, img.implicitHeight))
59+ }
60+
61+ PanelWindow {
62+ id: win
63+
64+ property real t: root.shot !== "" ? 1 : 0
65+ Behavior on t { NumberAnimation { duration: Theme.t; easing.type: Easing.BezierSpline; easing.bezierCurve: Theme.glide } }
66+
67+ screen: root.scr
68+ visible: t > 0
69+ anchors { left: true; right: true; top: true; bottom: true }
70+ color: "transparent"
71+ exclusionMode: ExclusionMode.Ignore
72+ WlrLayershell.layer: WlrLayer.Overlay
73+ WlrLayershell.keyboardFocus: root.shot !== "" ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
74+ WlrLayershell.namespace: "aurora-shot-edit"
75+
76+ Rectangle { anchors.fill: parent; color: Theme.fade(Theme.bg, 0.96); opacity: win.t }
77+
78+ Item {
79+ id: st
80+ anchors.fill: parent
81+ focus: true
82+ Keys.onPressed: e => {
83+ var t = root.hotkeys.indexOf(e.key), ctrl = (e.modifiers & Qt.ControlModifier) !== 0
84+ if (e.key === Qt.Key_Escape) root.close()
85+ else if (e.key === Qt.Key_Return || e.key === Qt.Key_Enter || (ctrl && e.key === Qt.Key_C)) root.finish(false)
86+ else if (ctrl && e.key === Qt.Key_S) root.finish(true)
87+ else if (ctrl && e.key === Qt.Key_Z) root.marks = root.marks.slice(0, -1)
88+ else if (ctrl) return
89+ else if (t >= 0) root.tool = t
90+ else if (e.key >= Qt.Key_1 && e.key <= Qt.Key_4) root.ink = e.key - Qt.Key_1
91+ else if (e.key === Qt.Key_BracketLeft) root.size = Math.max(0, root.size - 1)
92+ else if (e.key === Qt.Key_BracketRight) root.size = Math.min(2, root.size + 1)
93+ else return
94+ e.accepted = true
95+ }
96+
97+ // the shot fits between the labels and the pill, never above 1:1; grows in from 96%
98+ Item {
99+ id: frame
100+ readonly property real fit: Math.min(1 / root.dpr, (st.width - 240) / Math.max(1, img.implicitWidth),
101+ (st.height - 140 - 220) / Math.max(1, img.implicitHeight))
102+ width: img.implicitWidth * fit
103+ height: img.implicitHeight * fit
104+ x: (st.width - width) / 2
105+ y: 140 + (st.height - 140 - 220 - height) / 2
106+ opacity: win.t
107+ scale: 0.96 + 0.04 * win.t
108+
109+ Item {
110+ id: canvas
111+ width: img.implicitWidth
112+ height: img.implicitHeight
113+ scale: frame.fit
114+ transformOrigin: Item.TopLeft
115+
116+ Image {
117+ id: img
118+ source: root.shot !== "" ? "file://" + root.shot : ""
119+ cache: false // saved over the same path: read it again
120+ smooth: true
121+ mipmap: true
122+ }
123+ Repeater {
124+ // the live mark is separate: only its delegate is rebuilt on every mouse step
125+ model: root.marks
126+ Mark { required property var modelData; m: modelData; src: img }
127+ }
128+ Repeater {
129+ model: root.live ? [root.live] : []
130+ Mark { required property var modelData; m: modelData; src: img }
131+ }
132+
133+ TextInput {
134+ id: typing
135+ visible: false
136+ font.family: Theme.ui
137+ font.pixelSize: root.w * 6
138+ font.weight: Font.DemiBold
139+ color: root.inks[root.ink]
140+ selectionColor: Theme.s3
141+ cursorDelegate: Rectangle { width: 2 * root.dpr; color: Theme.live } // the caret is live
142+ onAccepted: root.commitWord()
143+ Keys.onEscapePressed: { visible = false; st.forceActiveFocus() }
144+ }
145+
146+ MouseArea {
147+ anchors.fill: parent
148+ cursorShape: root.tool === 4 ? Qt.IBeamCursor : Qt.CrossCursor
149+ onPressed: e => {
150+ root.commitWord()
151+ var c = root.inks[root.ink]
152+ if (root.tool === 0) root.live = { k: "pen", c: c, w: root.w, pts: [Qt.point(e.x, e.y)] }
153+ else if (root.tool === 4) {
154+ typing.text = ""
155+ typing.x = e.x
156+ typing.y = e.y - typing.height / 2
157+ typing.visible = true
158+ typing.forceActiveFocus()
159+ } else root.live = { k: root.tools[root.tool], c: c, w: root.w, x1: e.x, y1: e.y, x2: e.x, y2: e.y }
160+ }
161+ onPositionChanged: e => {
162+ var l = root.live
163+ if (!l) return
164+ // a stroke is copied on every step (O(n^2)): fine for strokes of thousands of points
165+ if (l.k === "pen") root.live = Object.assign({}, l, { pts: l.pts.concat([Qt.point(e.x, e.y)]) })
166+ else root.live = Object.assign({}, l, { x2: e.x, y2: e.y })
167+ }
168+ onReleased: {
169+ var l = root.live
170+ root.live = null
171+ if (l && (l.k === "pen" ? l.pts.length > 1 : Math.hypot(l.x2 - l.x1, l.y2 - l.y1) > 4 * root.dpr))
172+ root.marks = root.marks.concat([l])
173+ }
174+ }
175+ }
176+
177+ // frame and corner dots, like the viewfinder
178+ Rectangle { anchors.fill: parent; anchors.margins: -1; color: "transparent"; border.width: 1; border.color: Theme.s3 }
179+ Repeater {
180+ model: 4
181+ Rectangle {
182+ required property int index
183+ x: (index % 2 ? frame.width : 0) - 6
184+ y: (index > 1 ? frame.height : 0) - 6
185+ width: 12
186+ height: 12
187+ radius: 6
188+ color: Theme.fill
189+ }
190+ }
191+ Item {
192+ y: -height - 18
193+ width: parent.width
194+ height: dims.height
195+ Lb { x: 2; anchors.bottom: parent.bottom; text: "edit · " + root.tools[root.tool]; color: Theme.fg }
196+ Row {
197+ anchors.right: parent.right
198+ anchors.rightMargin: 2
199+ spacing: 14
200+ Lb { anchors.bottom: parent.bottom; text: root.marks.length + (root.marks.length === 1 ? " mark" : " marks") }
201+ Num { id: dims; text: img.implicitWidth + "×" + img.implicitHeight; font.pixelSize: 44 }
202+ }
203+ }
204+ }
205+
206+ // tools as chips; then inks as circles and sizes as dots. They slide up from the bottom
207+ Column {
208+ anchors.horizontalCenter: parent.horizontalCenter
209+ anchors.bottom: parent.bottom
210+ anchors.bottomMargin: 44 - 24 * (1 - win.t)
211+ opacity: win.t
212+ spacing: 12
213+ Pill {
214+ anchors.horizontalCenter: parent.horizontalCenter
215+ spacing: 6
216+ Repeater {
217+ model: root.tools
218+ Chip {
219+ required property string modelData
220+ required property int index
221+ anchors.verticalCenter: parent.verticalCenter
222+ text: modelData
223+ selected: index === root.tool
224+ onClicked: { root.commitWord(); root.tool = index }
225+ }
226+ }
227+ Sep {}
228+ Repeater {
229+ model: root.inks
230+ // the chosen ink in a ring
231+ Item {
232+ required property color modelData
233+ required property int index
234+ width: 30
235+ height: 36
236+ Rectangle {
237+ anchors.centerIn: parent
238+ width: 26
239+ height: 26
240+ radius: 13
241+ color: "transparent"
242+ border.width: 1.5
243+ border.color: index === root.ink ? Theme.fg : "transparent"
244+ }
245+ Rectangle {
246+ anchors.centerIn: parent
247+ width: 16
248+ height: 16
249+ radius: 8
250+ color: modelData
251+ border.width: 1
252+ border.color: Theme.s3
253+ }
254+ TapHandler { onTapped: root.ink = index }
255+ HoverHandler { cursorShape: Qt.PointingHandCursor }
256+ }
257+ }
258+ Sep {}
259+ Repeater {
260+ model: root.sizes
261+ Item {
262+ required property int modelData
263+ required property int index
264+ width: 26
265+ height: 36
266+ Rectangle {
267+ anchors.centerIn: parent
268+ width: modelData + 4
269+ height: width
270+ radius: width / 2
271+ color: index === root.size ? Theme.fill : Theme.mute
272+ Behavior on color { enabled: Theme.motion; ColorAnimation { duration: Theme.t } }
273+ }
274+ TapHandler { onTapped: root.size = index }
275+ HoverHandler { cursorShape: Qt.PointingHandCursor }
276+ }
277+ }
278+ Item { width: 8; height: 36 }
279+ }
280+ Pill {
281+ anchors.horizontalCenter: parent.horizontalCenter
282+ height: 36
283+ pad: 22
284+ spacing: 28
285+ Repeater {
286+ model: ["p a r b t tool", "1-4 ink", "[ ] size", "ctrl+z undo", "ctrl+c copy", "ctrl+s save", "esc discard"]
287+ Lb { required property string modelData; anchors.verticalCenter: parent.verticalCenter; text: modelData; px: 10 }
288+ }
289+ }
290+ }
291+ }
292+ }
293+
294+ // separator in the pill: a mute dot
295+ component Sep: Item {
296+ width: 18
297+ height: 36
298+ Rectangle { anchors.centerIn: parent; width: 4; height: 4; radius: 2; color: Theme.mute }
299+ }
300+
301+ // one mark, in the shot's pixels
302+ component Mark: Item {
303+ id: mk
304+ property var m
305+ property Item src // the shot, for blur (the file's ids aren't visible in an inline component)
306+ readonly property real x0: Math.min(m.x1, m.x2)
307+ readonly property real y0: Math.min(m.y1, m.y2)
308+ readonly property real w0: Math.abs(m.x2 - m.x1)
309+ readonly property real h0: Math.abs(m.y2 - m.y1)
310+
311+ Loader {
312+ sourceComponent: ({ pen: pen, arrow: arrow, rect: rectC, blur: blur, text: word })[mk.m.k]
313+ }
314+
315+ Component {
316+ id: pen
317+ Shape {
318+ preferredRendererType: Shape.CurveRenderer
319+ ShapePath {
320+ strokeColor: mk.m.c
321+ strokeWidth: mk.m.w
322+ fillColor: "transparent"
323+ capStyle: ShapePath.RoundCap
324+ joinStyle: ShapePath.RoundJoin
325+ PathPolyline { path: mk.m.pts }
326+ }
327+ }
328+ }
329+ Component {
330+ id: arrow
331+ Shape {
332+ id: ar
333+ // the head is a triangle 4 strokes long, the shaft ends at its base
334+ readonly property real a: Math.atan2(mk.m.y2 - mk.m.y1, mk.m.x2 - mk.m.x1)
335+ readonly property real hl: mk.m.w * 4
336+ readonly property point base: Qt.point(mk.m.x2 - Math.cos(a) * hl * 0.8, mk.m.y2 - Math.sin(a) * hl * 0.8)
337+ preferredRendererType: Shape.CurveRenderer
338+ ShapePath {
339+ strokeColor: mk.m.c
340+ strokeWidth: mk.m.w
341+ fillColor: "transparent"
342+ capStyle: ShapePath.RoundCap
343+ PathPolyline { path: [Qt.point(mk.m.x1, mk.m.y1), ar.base] }
344+ }
345+ ShapePath {
346+ strokeColor: "transparent"
347+ fillColor: mk.m.c
348+ PathPolyline {
349+ path: [Qt.point(mk.m.x2, mk.m.y2),
350+ Qt.point(mk.m.x2 - Math.cos(ar.a - 0.45) * ar.hl, mk.m.y2 - Math.sin(ar.a - 0.45) * ar.hl),
351+ Qt.point(mk.m.x2 - Math.cos(ar.a + 0.45) * ar.hl, mk.m.y2 - Math.sin(ar.a + 0.45) * ar.hl),
352+ Qt.point(mk.m.x2, mk.m.y2)]
353+ }
354+ }
355+ }
356+ }
357+ Component {
358+ id: rectC
359+ Rectangle {
360+ x: mk.x0 - mk.m.w / 2
361+ y: mk.y0 - mk.m.w / 2
362+ width: mk.w0 + mk.m.w
363+ height: mk.h0 + mk.m.w
364+ color: "transparent"
365+ border.width: mk.m.w
366+ border.color: mk.m.c
367+ }
368+ }
369+ // blur: that part of the shot in big pixels (passwords, faces, addresses)
370+ Component {
371+ id: blur
372+ ShaderEffectSource {
373+ readonly property real px: mk.m.w * 3
374+ x: mk.x0
375+ y: mk.y0
376+ width: mk.w0
377+ height: mk.h0
378+ sourceItem: mk.src
379+ sourceRect: Qt.rect(mk.x0, mk.y0, mk.w0, mk.h0)
380+ textureSize: Qt.size(Math.max(1, Math.ceil(mk.w0 / px)), Math.max(1, Math.ceil(mk.h0 / px)))
381+ smooth: false
382+ }
383+ }
384+ Component {
385+ id: word
386+ Text {
387+ x: mk.m.x
388+ y: mk.m.y
389+ text: mk.m.t
390+ font.family: Theme.ui
391+ font.pixelSize: mk.m.s
392+ font.weight: Font.DemiBold
393+ color: mk.m.c
394+ style: Text.Outline
395+ styleColor: Qt.colorEqual(mk.m.c, "#000000") ? "#FFFFFF" : "#000000"
396+ }
397+ }
398+ }
399+}
M .config/quickshell/aurora/shell.qml View
@@ -62,6 +62,17 @@ ShellRoot {
6262 overlayScreen = scr ? scr : focusedScreen()
6363 overlay = name
6464 }
65+ // screenshots: whatever aurora shows must slide away before grim freezes the screen
66+ function shot(mode) {
67+ if (mode === "edit") { Shot.edit(); return } // the last shot in the editor
68+ // also when one closed a moment ago (the runner closes itself before running "screenshot")
69+ var gone = Math.max(closedAt, overlayClosedAt)
70+ var wait = opened || overlay !== "" ? Theme.t + 60 : Math.max(0, Theme.t + 60 - (Date.now() - gone))
71+ close()
72+ closeOverlay()
73+ if (mode === "ocr") Shot.ocr(wait)
74+ else Shot.open(mode, wait)
75+ }
6576 // section "" = toggle the window on its last section; a name opens (or switches) to it
6677 function settings(section) {
6778 if (section === "" || section === undefined) { settingsOpen = !settingsOpen; return }
@@ -159,6 +170,9 @@ ShellRoot {
159170 // polkit password prompt (Agents), on the focused monitor
160171 AuthPrompt { screen: root.focusedScr }
161172
173+ ShotAim {}
174+ ShotEditor {}
175+
162176 PowerMenu {
163177 screen: root.overlayScreen
164178 opened: root.overlay === "power"
@@ -209,8 +223,8 @@ ShellRoot {
209223 // `qs ipc call shell show x` is swallowed by the qs CLI (it has its own `show`), so use this
210224 function tab(name: string): void { root.openTab(name) }
211225 function dnd(): void { Notifs.dnd = !Notifs.dnd }
212− function shot(mode: string): void { root.close(); Shot.take(mode) } // region | window | output | ocr
213− function shotsave(): void { Shot.save = !Shot.save }
226+ // screenshot viewfinder in that mode (region | window | screen | record), ocr (the `ocr` script), edit (the last shot)
227+ function shot(mode: string): void { root.shot(mode) }
214228 function overview(): void { root.toggleOverlay("overview") }
215229 function cal(): void { root.toggleOverlay("cal") }
216230 function clip(): void { root.toggleOverlay("clip") }
D .local/bin/hypr/hypr_screenshot.sh View
@@ -1,18 +0,0 @@
1−#!/bin/sh
2−
3−ocr_cmd="wl-copy"
4−
5−case "$(printf "a selected area (copy)\ncurrent window (copy)\nfull screen (copy)\na selected area\na selected area (tmp dir)\ncurrent window\nfull screen\na selected area (OCR)" | aurora-run -d -l 8 -p "Screenshot which area?")" in
6− "a selected area (copy)") hyprshot -m region --clipboard-only ;;
7− "current window (copy)") hyprshot -m window --clipboard-only ;;
8− "full screen (copy)") hyprshot -m output --clipboard-only ;;
9−
10− "a selected area") hyprshot -m region -o ~/pix/ss -f "pic-selected-$(uuidgen | awk -F- '{printf $2}')-$(date '+%y-%m-%d').png" ;;
11− "a selected area (tmp dir)") hyprshot -m region -o /tmp -f "pic-selected-$(uuidgen | awk -F- '{printf $2}')-$(date '+%y-%m-%d').png" ;;
12− "current window") hyprshot -m window -o ~/pix/ss -f "pic-window-$(uuidgen | awk -F- '{printf $2}')-$(date '+%y-%m-%d').png" ;;
13− "full screen") hyprshot -m output -o ~/pix/ss -f "pic-full-$(uuidgen | awk -F- '{printf $2}')-$(date '+%y-%m-%d').png" ;;
14−
15− "a selected area (OCR)") tmpfile=$(mktemp /tmp/ocr-XXXXXX.png) && \
16− slurp | grim -g - - | tesseract stdin stdout -l eng | wl-copy
17−esac
18−
A .local/bin/ocr View
@@ -0,0 +1,25 @@
1+#!/bin/sh
2+# OCR a screen region: slurp -> grim -> tesseract -> clipboard, with a notification showing what was read.
3+# usage: ocr [lang] (tesseract language, default eng; e.g. `ocr eng+hin`)
4+# slurp takes aurora's matugen colours when they're there.
5+
6+lang="${1:-eng}"
7+c="${XDG_CACHE_HOME:-$HOME/.cache}/matugen/aurora.json"
8+
9+if command -v jq >/dev/null && [ -r "$c" ]; then
10+ bg=$(jq -r '.surfaceLowest // empty' "$c")
11+ fg=$(jq -r '.primary // empty' "$c")
12+fi
13+if [ -n "$bg" ] && [ -n "$fg" ]; then
14+ g=$(slurp -b "${bg}88" -c "${fg}ff" -s "#00000000" -w 1) || exit 0
15+else
16+ g=$(slurp) || exit 0
17+fi
18+
19+text=$(grim -g "$g" - | tesseract stdin stdout -l "$lang" 2>/dev/null | sed -e 's/[[:space:]]*$//' -e '/./,$!d')
20+if [ -z "$text" ]; then
21+ notify-send -a ocr "OCR" "no text found"
22+ exit 1
23+fi
24+printf '%s' "$text" | wl-copy
25+notify-send -a ocr "OCR · copied" "$(printf '%s' "$text" | head -c 200)"
M packages/desktop.txt View
@@ -6,7 +6,6 @@ hypridle
66 hyprlock
77 hyprpicker
88 hyprpolkitagent
9−hyprshot
109 xdg-desktop-portal-hyprland
1110 xdg-desktop-portal-gtk
1211 qt6-wayland
@@ -26,6 +25,7 @@ cava
2625 wf-recorder
2726 grim
2827 slurp
28+libvips
2929 wl-clipboard
3030 cliphist
3131 playerctl