install.sh: rebind ALT+SHIFT+V in bindings.lua (the live Quattro config), not just legacy bindings.conf

This commit is contained in:
2026-09-05 23:16:20 +01:00
parent 6da0c42847
commit c48c10b3ec
6 changed files with 156 additions and 9 deletions
+17 -1
View File
@@ -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) }
+56
View File
@@ -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 {
+15 -2
View File
@@ -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 {
+29
View File
@@ -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
+22 -6
View File
@@ -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
+17
View File
@@ -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")
})