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 displayLimit: 200
property int maxAgeDays: 0 // 0 = keep forever property int maxAgeDays: 0 // 0 = keep forever
property bool qrDecode: true property bool qrDecode: true
property bool ocr: true
property string ocrLang: "eng"
property bool paused: false property bool paused: false
property var typeCache: ({}) // id → derived type, memoized property var typeCache: ({}) // id → derived type, memoized
@@ -330,10 +332,20 @@ Item {
function applySettings(raw) { function applySettings(raw) {
var s = Store.parseSettings(raw, "tank.clipboard") 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.historyLimit = s.historyLimit
root.maxAgeDays = s.maxAgeDays root.maxAgeDays = s.maxAgeDays
root.displayLimit = s.maxRows root.displayLimit = s.maxRows
root.qrDecode = s.qrDecode 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() root.applyRetentionPolicy()
if (root.opened) root.rebuild() if (root.opened) root.rebuild()
} }
@@ -389,7 +401,11 @@ Item {
Process { Process {
id: watchProc id: watchProc
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"] 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() onExited: watchRestartTimer.restart()
stdout: SplitParser { stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) } onRead: function(data) { root.addClipboardJson(data) }
+56
View File
@@ -71,6 +71,10 @@ Item {
var chips = [] var chips = []
if (r.app) chips.push(Classify.prettyApp(r.app)) if (r.app) chips.push(Classify.prettyApp(r.app))
if (e.qr) chips.push("QR: " + Classify.firstLine(e.qr, 40)) 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) { if (e.type === "text" && e.text) {
var st = Classify.textStats(e.text) var st = Classify.textStats(e.text)
chips.push(Classify.plural(st.words, "word")) 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 { Item {
width: parent.width width: parent.width
height: Math.max(0, parent.height height: Math.max(0, parent.height
- (root.entry && root.entry.qr ? qrContent.height + Style.space(12) + Style.space(8) : 0) - (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)) - infoLabel.height - Style.space(12))
Image { Image {
+15 -2
View File
@@ -51,6 +51,7 @@ function normalize(value, now) {
if (value.w) out.w = Number(value.w) if (value.w) out.w = Number(value.w)
if (value.h) out.h = Number(value.h) if (value.h) out.h = Number(value.h)
if (value.qr) out.qr = String(value.qr) if (value.qr) out.qr = String(value.qr)
if (value.ocr) out.ocr = String(value.ocr)
} else if (type === "files") { } else if (type === "files") {
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : [] var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
if (paths.length === 0) return null 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. // Re-copy of existing content: keep its pin state and usage count.
if (existing.pinned) normalized.pinned = true if (existing.pinned) normalized.pinned = true
if (existing.uses > 0) normalized.uses = existing.uses 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.qr && !normalized.qr) normalized.qr = existing.qr
if (existing.ocr && !normalized.ocr) normalized.ocr = existing.ocr
continue continue
} }
next.push(existing) next.push(existing)
@@ -200,7 +202,14 @@ function pruneByAge(history, maxAgeSeconds, now) {
// optional; unknown keys are ignored. Reads only the plugins[] entry whose // optional; unknown keys are ignored. Reads only the plugins[] entry whose
// id matches. // id matches.
function parseSettings(raw, pluginId) { 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 var config = null
try { config = JSON.parse(String(raw || "{}")) } catch (e) { return out } try { config = JSON.parse(String(raw || "{}")) } catch (e) { return out }
if (!config || !Array.isArray(config.plugins)) 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 (isFinite(n) && n >= 1) out.maxRows = Math.floor(n)
if (typeof entry.qrDecode === "boolean") out.qrDecode = entry.qrDecode 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 break
} }
@@ -230,6 +241,8 @@ function buildRow(entry, derivedType, now) {
if (entry.type === "image") { if (entry.type === "image") {
content = fileLabel(entry.path) + " " + String(entry.mime || "") content = fileLabel(entry.path) + " " + String(entry.mime || "")
if (entry.qr) content += " " + String(entry.qr).slice(0, 500) 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") { } else if (entry.type === "files") {
content = (entry.paths || []).join(" ") content = (entry.paths || []).join(" ")
} else { } else {
+29
View File
@@ -16,6 +16,7 @@ binary payloads are skipped silently.
import hashlib import hashlib
import json import json
import os import os
import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -119,6 +120,10 @@ def capture_image(types, app):
if qr: if qr:
entry["qr"] = qr entry["qr"] = qr
ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng"))
if ocr:
entry["ocr"] = ocr
emit(entry) emit(entry)
return True return True
@@ -143,6 +148,30 @@ def decode_text(data):
return text 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): def capture_uri_list(types, app):
if "text/uri-list" not in types: if "text/uri-list" not in types:
return False return False
+22 -6
View File
@@ -39,14 +39,30 @@ echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)"
omarchy plugin enable "$PLUGIN_ID" omarchy plugin enable "$PLUGIN_ID"
echo "==> Rebinding ALT+SHIFT+V" echo "==> Rebinding ALT+SHIFT+V"
if [[ -f $BINDINGS ]] && grep -q "^bindd = ALT SHIFT, V, " "$BINDINGS"; then # Omarchy's live config may be bindings.lua (Quattro) or legacy bindings.conf —
cp "$BINDINGS" "$BINDINGS.bak.$(date +%s)" # edit whichever exists and let the other alone.
sed -i 's|^bindd = ALT SHIFT, V, .*|bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard|' "$BINDINGS" 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 hyprctl reload >/dev/null
echo " rebound; previous bindings backed up next to $BINDINGS"
else else
echo " no ALT SHIFT V binding found in $BINDINGS — add manually:" echo " no ALT+SHIFT+V binding found — add manually to ~/.config/hypr/bindings.lua:"
echo ' bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard' echo ' o.bind("ALT + SHIFT + V", "Clipboard manager (clipboard-history)", "omarchy-shell shell toggle tank.clipboard")'
fi fi
echo echo
+17
View File
@@ -170,3 +170,20 @@ test("parseSettings ignores garbage", () => {
const s = Store.parseSettings("not json", "tank.clipboard") const s = Store.parseSettings("not json", "tank.clipboard")
assert.equal(s.historyLimit, 1500) 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")
})