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.
+1157 −91
12 files changed
- M .config/hypr/source/keybinds.lua +4 −2
- M .config/quickshell/aurora/HomePage.qml +3 −45
- A .config/quickshell/aurora/Pill.qml +20 −0
- M .config/quickshell/aurora/Rec.qml +6 −0
- M .config/quickshell/aurora/Runner.qml +3 −3
- M .config/quickshell/aurora/Shot.qml +197 −20
- A .config/quickshell/aurora/ShotAim.qml +483 −0
- A .config/quickshell/aurora/ShotEditor.qml +399 −0
- M .config/quickshell/aurora/shell.qml +16 −2
- D .local/bin/hypr/hypr_screenshot.sh +0 −18
- A .local/bin/ocr +25 −0
- M packages/desktop.txt +1 −1
| @@ -42,8 +42,10 @@ hl.bind(mainMod .. " + I", hl.dsp.exec_cmd("qs -c aurora ipc call shell settings | |||
| 42 | 42 | ||
| 43 | 43 | -- Applications | |
| 44 | 44 | 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" }) | |
| 47 | 49 | ||
| 48 | 50 | -- Music Controls | |
| 49 | 51 | hl.bind(mainMod .. " + m", hl.dsp.exec_cmd(terminal .. " -e zsh -i -c \"rmptui\"")) | |
| @@ -19,8 +19,6 @@ Flickable { | |||
| 19 | 19 | implicitHeight: col.implicitHeight | |
| 20 | 20 | boundsBehavior: Flickable.StopAtBounds | |
| 21 | 21 | ||
| 22 | − | property bool shotOpen: false // the capture options under the SHOT tile | |
| 23 | − | onVisibleChanged: if (!visible) shotOpen = false | |
| 24 | 22 | ||
| 25 | 23 | readonly property var sink: Pipewire.defaultAudioSink ? Pipewire.defaultAudioSink.audio : null | |
| 26 | 24 | readonly property var source: Pipewire.defaultAudioSource ? Pipewire.defaultAudioSource.audio : null | |
| @@ -138,52 +136,12 @@ Flickable { | |||
| 138 | 136 | name: "colour" | |
| 139 | 137 | onClicked: { page.closeRequested(); Toggles.pickColour() } | |
| 140 | 138 | } | |
| 141 | − | Tile { | |
| 139 | + | Tile { // click: the screenshot viewfinder (after the panel is gone), right click: ocr | |
| 142 | 140 | width: tiles.cw | |
| 143 | 141 | icon: Theme.iShot | |
| 144 | 142 | 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) } | |
| 187 | 145 | } | |
| 188 | 146 | } | |
| 189 | 147 | ||
| @@ -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 | + | } | |
| @@ -32,6 +32,12 @@ Singleton { | |||
| 32 | 32 | if (recProc.running) recProc.signal(2) // SIGINT: wf-recorder finalises the file | |
| 33 | 33 | } | |
| 34 | 34 | 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 | + | } | |
| 35 | 41 | ||
| 36 | 42 | function record(geometry) { | |
| 37 | 43 | file = dir + "/" + Qt.formatDateTime(new Date(), "yyyy-MM-dd_HH-mm-ss") + ".mp4" | |
| @@ -198,7 +198,7 @@ PanelWindow { | |||
| 198 | 198 | { id: "bar", glyph: Theme.iMonitor, name: "toggle bar" }, | |
| 199 | 199 | { id: "picker", glyph: Theme.iPicker, name: "colour picker" }, | |
| 200 | 200 | { 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)" } | |
| 202 | 202 | ] | |
| 203 | 203 | if (Rec.available) c.push({ id: "rec", glyph: Theme.iRec, name: Rec.busy ? "stop recording" : "record screen" }) | |
| 204 | 204 | var out = [] | |
| @@ -364,8 +364,8 @@ PanelWindow { | |||
| 364 | 364 | case "dnd": Notifs.dnd = !Notifs.dnd; break | |
| 365 | 365 | case "bar": shell.barShown = !shell.barShown; break | |
| 366 | 366 | 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 | |
| 369 | 369 | case "rec": Rec.toggle("screen"); break | |
| 370 | 370 | } | |
| 371 | 371 | } | |
| @@ -1,33 +1,210 @@ | |||
| 1 | 1 | pragma Singleton | |
| 2 | 2 | import QtQuick | |
| 3 | 3 | import Quickshell | |
| 4 | + | import Quickshell.Io | |
| 5 | + | import Quickshell.Hyprland | |
| 4 | 6 | ||
| 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. | |
| 7 | 16 | Singleton { | |
| 8 | 17 | id: root | |
| 9 | 18 | ||
| 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" | |
| 13 | 24 | ||
| 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 | |
| 15 | 34 | ||
| 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() }) | |
| 23 | 160 | 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"' | |
| 29 | 161 | } | |
| 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() | |
| 32 | 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 | + | ||
| 33 | 210 | } | |
| @@ -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 | + | } | |
| @@ -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 | + | } | |
| @@ -62,6 +62,17 @@ ShellRoot { | |||
| 62 | 62 | overlayScreen = scr ? scr : focusedScreen() | |
| 63 | 63 | overlay = name | |
| 64 | 64 | } | |
| 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 | + | } | |
| 65 | 76 | // section "" = toggle the window on its last section; a name opens (or switches) to it | |
| 66 | 77 | function settings(section) { | |
| 67 | 78 | if (section === "" || section === undefined) { settingsOpen = !settingsOpen; return } | |
| @@ -159,6 +170,9 @@ ShellRoot { | |||
| 159 | 170 | // polkit password prompt (Agents), on the focused monitor | |
| 160 | 171 | AuthPrompt { screen: root.focusedScr } | |
| 161 | 172 | ||
| 173 | + | ShotAim {} | |
| 174 | + | ShotEditor {} | |
| 175 | + | ||
| 162 | 176 | PowerMenu { | |
| 163 | 177 | screen: root.overlayScreen | |
| 164 | 178 | opened: root.overlay === "power" | |
| @@ -209,8 +223,8 @@ ShellRoot { | |||
| 209 | 223 | // `qs ipc call shell show x` is swallowed by the qs CLI (it has its own `show`), so use this | |
| 210 | 224 | function tab(name: string): void { root.openTab(name) } | |
| 211 | 225 | 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) } | |
| 214 | 228 | function overview(): void { root.toggleOverlay("overview") } | |
| 215 | 229 | function cal(): void { root.toggleOverlay("cal") } | |
| 216 | 230 | function clip(): void { root.toggleOverlay("clip") } | |
| @@ -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 | − | ||
| @@ -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)" | |
| @@ -6,7 +6,6 @@ hypridle | |||
| 6 | 6 | hyprlock | |
| 7 | 7 | hyprpicker | |
| 8 | 8 | hyprpolkitagent | |
| 9 | − | hyprshot | |
| 10 | 9 | xdg-desktop-portal-hyprland | |
| 11 | 10 | xdg-desktop-portal-gtk | |
| 12 | 11 | qt6-wayland | |
| @@ -26,6 +25,7 @@ cava | |||
| 26 | 25 | wf-recorder | |
| 27 | 26 | grim | |
| 28 | 27 | slurp | |
| 28 | + | libvips | |
| 29 | 29 | wl-clipboard | |
| 30 | 30 | cliphist | |
| 31 | 31 | playerctl | |