From c48c10b3ec2603b86ca493e04015ba1e25ae26ce Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Sat, 5 Sep 2026 23:16:20 +0100 Subject: [PATCH] install.sh: rebind ALT+SHIFT+V in bindings.lua (the live Quattro config), not just legacy bindings.conf --- Clipboard.qml | 18 +++++++++++++- PreviewPane.qml | 56 ++++++++++++++++++++++++++++++++++++++++++++ Store.js | 17 ++++++++++++-- capture.py | 29 +++++++++++++++++++++++ install.sh | 28 +++++++++++++++++----- tests/test-store.mjs | 17 ++++++++++++++ 6 files changed, 156 insertions(+), 9 deletions(-) diff --git a/Clipboard.qml b/Clipboard.qml index e39a982..fb5d763 100644 --- a/Clipboard.qml +++ b/Clipboard.qml @@ -30,6 +30,8 @@ Item { property int displayLimit: 200 property int maxAgeDays: 0 // 0 = keep forever property bool qrDecode: true + property bool ocr: true + property string ocrLang: "eng" property bool paused: false property var typeCache: ({}) // id → derived type, memoized @@ -330,10 +332,20 @@ Item { function applySettings(raw) { var s = Store.parseSettings(raw, "tank.clipboard") + var needsWatchRestart = s.qrDecode !== root.qrDecode || s.ocr !== root.ocr + || s.ocrLang !== root.ocrLang root.historyLimit = s.historyLimit root.maxAgeDays = s.maxAgeDays root.displayLimit = s.maxRows root.qrDecode = s.qrDecode + root.ocr = s.ocr + root.ocrLang = s.ocrLang + // The watchers read qr/ocr settings from the environment — restart them + // so changes take effect without a shell reload. + if (needsWatchRestart && watchProc.running) { + watchProc.running = false + watchRestartTimer.restart() + } root.applyRetentionPolicy() if (root.opened) root.rebuild() } @@ -389,7 +401,11 @@ Item { Process { id: watchProc command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"] - environment: ({ "CLIPBOARD_QR": root.qrDecode ? "1" : "0" }) + environment: ({ + "CLIPBOARD_QR": root.qrDecode ? "1" : "0", + "CLIPBOARD_OCR": root.ocr ? "1" : "0", + "CLIPBOARD_OCR_LANG": root.ocrLang + }) onExited: watchRestartTimer.restart() stdout: SplitParser { onRead: function(data) { root.addClipboardJson(data) } diff --git a/PreviewPane.qml b/PreviewPane.qml index 861ce72..ff42703 100644 --- a/PreviewPane.qml +++ b/PreviewPane.qml @@ -71,6 +71,10 @@ Item { var chips = [] if (r.app) chips.push(Classify.prettyApp(r.app)) if (e.qr) chips.push("QR: " + Classify.firstLine(e.qr, 40)) + if (e.ocr) { + var ow = Classify.textStats(e.ocr).words + chips.push("OCR: " + Classify.plural(ow, "word")) + } if (e.type === "text" && e.text) { var st = Classify.textStats(e.text) chips.push(Classify.plural(st.words, "word")) @@ -449,10 +453,62 @@ Item { } } + // OCR text recovered from the image (searchable like any text clip). + Rectangle { + id: ocrPanel + visible: root.entry && root.entry.ocr + width: parent.width + height: Math.min(Style.space(150), ocrContent.height + Style.space(12)) + radius: Style.cornerRadius + color: root.chipBg + + Column { + id: ocrContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: Style.space(6) + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(4) + + Text { + text: "󰐦 Recognized text (OCR)" + color: Color.accent + font.family: root.font_ + font.pixelSize: Style.font.caption + font.bold: true + } + + Flickable { + width: parent.width + height: ocrText.implicitHeight + clip: true + contentWidth: width + contentHeight: ocrText.implicitHeight + + TextEdit { + id: ocrText + width: parent.width + readOnly: true + activeFocusOnPress: false + text: root.entry && root.entry.ocr ? root.entry.ocr : "" + textFormat: TextEdit.PlainText + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.caption + wrapMode: TextEdit.Wrap + selectionColor: Util.alpha(Color.accent, 0.4) + } + } + } + } + Item { width: parent.width height: Math.max(0, parent.height - (root.entry && root.entry.qr ? qrContent.height + Style.space(12) + Style.space(8) : 0) + - (root.entry && root.entry.ocr ? ocrPanel.height + Style.space(8) : 0) - infoLabel.height - Style.space(12)) Image { diff --git a/Store.js b/Store.js index 5966698..937d0be 100644 --- a/Store.js +++ b/Store.js @@ -51,6 +51,7 @@ function normalize(value, now) { if (value.w) out.w = Number(value.w) if (value.h) out.h = Number(value.h) if (value.qr) out.qr = String(value.qr) + if (value.ocr) out.ocr = String(value.ocr) } else if (type === "files") { var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : [] if (paths.length === 0) return null @@ -104,8 +105,9 @@ function addEntry(history, entry, now) { // Re-copy of existing content: keep its pin state and usage count. if (existing.pinned) normalized.pinned = true if (existing.uses > 0) normalized.uses = existing.uses - // Keep expensive derived data (e.g. decoded QR) across re-captures. + // Keep expensive derived data (decoded QR, OCR) across re-captures. if (existing.qr && !normalized.qr) normalized.qr = existing.qr + if (existing.ocr && !normalized.ocr) normalized.ocr = existing.ocr continue } next.push(existing) @@ -200,7 +202,14 @@ function pruneByAge(history, maxAgeSeconds, now) { // optional; unknown keys are ignored. Reads only the plugins[] entry whose // id matches. function parseSettings(raw, pluginId) { - var out = { historyLimit: DEFAULT_LIMIT, maxAgeDays: 0, maxRows: 200, qrDecode: true } + var out = { + historyLimit: DEFAULT_LIMIT, + maxAgeDays: 0, + maxRows: 200, + qrDecode: true, + ocr: true, + ocrLang: "eng" + } var config = null try { config = JSON.parse(String(raw || "{}")) } catch (e) { return out } if (!config || !Array.isArray(config.plugins)) return out @@ -218,6 +227,8 @@ function parseSettings(raw, pluginId) { if (isFinite(n) && n >= 1) out.maxRows = Math.floor(n) if (typeof entry.qrDecode === "boolean") out.qrDecode = entry.qrDecode + if (typeof entry.ocr === "boolean") out.ocr = entry.ocr + if (typeof entry.ocrLang === "string" && entry.ocrLang) out.ocrLang = entry.ocrLang break } @@ -230,6 +241,8 @@ function buildRow(entry, derivedType, now) { if (entry.type === "image") { content = fileLabel(entry.path) + " " + String(entry.mime || "") if (entry.qr) content += " " + String(entry.qr).slice(0, 500) + // OCR text makes screenshots searchable like any text clip. + if (entry.ocr) content += " " + String(entry.ocr).slice(0, 4000) } else if (entry.type === "files") { content = (entry.paths || []).join(" ") } else { diff --git a/capture.py b/capture.py index 3066d9b..f316e38 100755 --- a/capture.py +++ b/capture.py @@ -16,6 +16,7 @@ binary payloads are skipped silently. import hashlib import json import os +import shutil import subprocess import sys import tempfile @@ -119,6 +120,10 @@ def capture_image(types, app): if qr: entry["qr"] = qr + ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng")) + if ocr: + entry["ocr"] = ocr + emit(entry) return True @@ -143,6 +148,30 @@ def decode_text(data): return text +def decode_ocr(path, lang): + """Recognize text in an image with tesseract; None when unavailable/binary.""" + if os.environ.get("CLIPBOARD_OCR", "1") == "0": + return None + # Tesseract is optional — degrade to no-OCR when missing. + if shutil.which("tesseract") is None: + return None + try: + r = subprocess.run( + ["tesseract", path, "stdout", "-l", lang, "--quiet"], + capture_output=True, timeout=30 + ) + if r.returncode != 0: + return None + text = r.stdout.decode("utf-8", "replace") + # Collapse tesseract's whitespace so the stored haystack stays dense. + text = "\n".join(line.strip() for line in text.splitlines() + if line.strip()) + text = text.strip() + return text[:4000] or None + except Exception: + return None + + def capture_uri_list(types, app): if "text/uri-list" not in types: return False diff --git a/install.sh b/install.sh index 18af3d1..e2754cc 100755 --- a/install.sh +++ b/install.sh @@ -39,14 +39,30 @@ echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)" omarchy plugin enable "$PLUGIN_ID" echo "==> Rebinding ALT+SHIFT+V" -if [[ -f $BINDINGS ]] && grep -q "^bindd = ALT SHIFT, V, " "$BINDINGS"; then - cp "$BINDINGS" "$BINDINGS.bak.$(date +%s)" - sed -i 's|^bindd = ALT SHIFT, V, .*|bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard|' "$BINDINGS" +# Omarchy's live config may be bindings.lua (Quattro) or legacy bindings.conf — +# edit whichever exists and let the other alone. +rebound=0 +if [[ -f $HOME/.config/hypr/bindings.lua ]]; then + LUA="$HOME/.config/hypr/bindings.lua" + if grep -q 'ALT + SHIFT + V' "$LUA"; then + cp "$LUA" "$LUA.bak.$(date +%s)" + sed -i 's|^o.bind("ALT + SHIFT + V", "Clipboard manager (vicinae)", "vicinae deeplink vicinae://launch/clipboard/history")|o.bind("ALT + SHIFT + V", "Clipboard manager (clipboard-history)", "omarchy-shell shell toggle tank.clipboard")|' "$LUA" + echo " rebound in bindings.lua (vicinae binding replaced)" + rebound=1 + fi +fi +if [[ $rebound != 1 && -f $HOME/.config/hypr/bindings.conf ]] && grep -q "^bindd = ALT SHIFT, V, " "$HOME/.config/hypr/bindings.conf"; then + CONF="$HOME/.config/hypr/bindings.conf" + cp "$CONF" "$CONF.bak.$(date +%s)" + sed -i 's|^bindd = ALT SHIFT, V, .*|bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard|' "$CONF" + echo " rebound in bindings.conf" + rebound=1 +fi +if (( rebound )); then hyprctl reload >/dev/null - echo " rebound; previous bindings backed up next to $BINDINGS" else - echo " no ALT SHIFT V binding found in $BINDINGS — add manually:" - echo ' bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard' + echo " no ALT+SHIFT+V binding found — add manually to ~/.config/hypr/bindings.lua:" + echo ' o.bind("ALT + SHIFT + V", "Clipboard manager (clipboard-history)", "omarchy-shell shell toggle tank.clipboard")' fi echo diff --git a/tests/test-store.mjs b/tests/test-store.mjs index 56f65e6..abc83de 100644 --- a/tests/test-store.mjs +++ b/tests/test-store.mjs @@ -170,3 +170,20 @@ test("parseSettings ignores garbage", () => { const s = Store.parseSettings("not json", "tank.clipboard") assert.equal(s.historyLimit, 1500) }) + +test("ocr passes through normalize, dedup, and search", () => { + const e = Store.normalize({ type: "image", path: "/x/s.png", mime: "image/png", ocr: "deploy staging" }) + assert.equal(e.ocr, "deploy staging") + let h = Store.addEntry([], { type: "image", path: "/x/s.png", ocr: "deploy staging", ts: 1 }) + h = Store.addEntry(h, { type: "image", path: "/x/s.png", ts: 2 }) + assert.equal(h[0].ocr, "deploy staging") + const row = Store.buildRow(h[0], "image", 2) + assert.ok(row.content.includes("deploy staging")) +}) + +test("parseSettings ocr keys", () => { + const raw = JSON.stringify({ plugins: [{ id: "tank.clipboard", ocr: false, ocrLang: "deu" }] }) + const s = Store.parseSettings(raw, "tank.clipboard") + assert.equal(s.ocr, false) + assert.equal(s.ocrLang, "deu") +})