diff --git a/.gitignore b/.gitignore index 751553b..99d3ecc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.bak +__pycache__/ diff --git a/plugin/Classify.js b/Classify.js similarity index 100% rename from plugin/Classify.js rename to Classify.js diff --git a/plugin/Clipboard.qml b/Clipboard.qml similarity index 98% rename from plugin/Clipboard.qml rename to Clipboard.qml index 84a7ffd..da55ee3 100644 --- a/plugin/Clipboard.qml +++ b/Clipboard.qml @@ -794,9 +794,15 @@ Item { var subtitleParts = [] if (e.type === "image") { - titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "") - if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h - subtitleParts.push(Classify.formatBytes(r.row.bytes)) + if (e.qr) { + titleHtml = Fuzzy.escapeHtml(Classify.firstLine(e.qr, 60)) + subtitleParts.push("QR") + if (e.mime) subtitleParts.push(e.mime.replace("image/", "").toUpperCase()) + } else { + titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "") + if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h + subtitleParts.push(Classify.formatBytes(r.row.bytes)) + } } else if (e.type === "files") { var base = Classify.fileBase(e.paths[0]) titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base) @@ -808,6 +814,7 @@ Item { } subtitleParts.push(Classify.typeLabel(derived)) + if (e.type === "image" && e.qr) subtitleParts.pop() // "QR" badge already says it if (r.row.app) subtitleParts.push(Classify.prettyApp(r.row.app)) if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000))) diff --git a/plugin/Fuzzy.js b/Fuzzy.js similarity index 100% rename from plugin/Fuzzy.js rename to Fuzzy.js diff --git a/plugin/PreviewPane.qml b/PreviewPane.qml similarity index 86% rename from plugin/PreviewPane.qml rename to PreviewPane.qml index 9322beb..773db04 100644 --- a/plugin/PreviewPane.qml +++ b/PreviewPane.qml @@ -57,6 +57,7 @@ Item { var e = r.entry var chips = [] if (r.app) chips.push(Classify.prettyApp(r.app)) + if (e.qr) chips.push("QR: " + Classify.firstLine(e.qr, 40)) if (e.type === "text" && e.text) { var st = Classify.textStats(e.text) chips.push(Classify.plural(st.words, "word")) @@ -105,7 +106,7 @@ Item { text: { if (!root.entry) return "" var e = root.entry - if (e.type === "image") return Classify.fileBase(e.path) + if (e.type === "image") return e.qr ? Classify.firstLine(e.qr, 120) : Classify.fileBase(e.path) if (e.type === "files") { var base = Classify.fileBase(e.paths[0]) return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base @@ -116,6 +117,7 @@ Item { font.family: root.font_ font.pixelSize: Style.font.title font.bold: true + textFormat: Text.PlainText elide: Text.ElideRight maximumLineCount: 1 } @@ -303,9 +305,68 @@ Item { visible: root.derived === "image" spacing: Style.space(8) + // Decoded QR payload sits above the image so it is immediately readable. + Rectangle { + visible: root.entry && root.entry.qr + width: parent.width + height: qrContent.height + Style.space(12) + radius: Style.cornerRadius + color: root.chipBg + + Column { + id: qrContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: Style.space(6) + anchors.topMargin: Style.space(6) + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(4) + + Row { + spacing: Style.space(6) + + Text { + text: "󰐲" + color: Color.accent + font.family: root.font_ + font.pixelSize: Style.font.body + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "QR code content" + color: Color.accent + font.family: root.font_ + font.pixelSize: Style.font.caption + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + } + + TextEdit { + id: qrValue + width: parent.width + readOnly: true + activeFocusOnPress: false + text: root.entry && root.entry.qr ? root.entry.qr : "" + textFormat: TextEdit.PlainText + color: root.fg + font.family: root.font_ + font.pixelSize: Style.font.body + font.bold: true + wrapMode: TextEdit.WrapAnywhere + selectionColor: Util.alpha(Color.accent, 0.4) + } + } + } + Item { width: parent.width - height: parent.height - infoLabel.height - Style.space(12) + height: Math.max(0, parent.height + - (root.entry && root.entry.qr ? qrContent.height + Style.space(12) + Style.space(8) : 0) + - infoLabel.height - Style.space(12)) Image { id: img @@ -474,6 +535,7 @@ Item { id: chipLabel anchors.centerIn: parent text: parent.chipText + textFormat: Text.PlainText color: root.mutedFg font.family: root.font_ font.pixelSize: Style.font.caption diff --git a/README.md b/README.md index 6e2a14d..fb56571 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,9 @@ rides on the system theme, fonts, and layer-shell infrastructure. type, byte size, source app (via `hyprctl`), timestamp, and image dimensions. Text, images, and `file://` URI lists (file-manager copies) are supported; password-manager clips and binary payloads are skipped. +- **QR codes** — copied QR images are decoded with `zbarimg`; the payload shows + in the list title, the preview pane (copy-selectable), and a metadata chip, + and is fuzzy-searchable like any text clip. - **Fuzzy search over everything** — fzf-style scoring over content, source app, and type, plus recency, pin, and usage boosts. Query tokens: - `type:image|link|text|files|code|json|color|email|html|number` (prefix match) @@ -46,22 +49,34 @@ rides on the system theme, fonts, and layer-shell infrastructure. ./install.sh ``` -This symlinks `plugin/` into `~/.config/omarchy/plugins/tank.clipboard`, -rescans + enables the plugin (the built-in `omarchy.clipboard` is replaced — -revert with `omarchy plugin disable tank.clipboard`), and rebinds -`ALT+SHIFT+V` from vicinae to this picker (backing up `bindings.conf`). +This symlinks the repo (the plugin root) into +`~/.config/omarchy/plugins/tank.clipboard`, rescans + enables the plugin (the +built-in `omarchy.clipboard` is replaced — revert with +`omarchy plugin disable tank.clipboard`), and rebinds `ALT+SHIFT+V` from +vicinae to this picker (backing up `bindings.conf`). + +On other machines, install straight from git — no clone/step needed: + +```bash +omarchy plugin add --enable +``` + +Note: the local dev symlink trips `omarchy plugin validate` (it refuses +symlinked plugin folders); a git-cloned copy validates clean. ## Layout +The repo root *is* the plugin folder (so `omarchy plugin add ` works +directly — it validates and clones a repo whose root holds `manifest.json`). + ``` -plugin/ ├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard) ├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers ├── PreviewPane.qml # per-type preview + metadata chips ├── Store.js # history model: dedup, pins, pruning, ids ├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting ├── Classify.js # type detection, app names, formatting, color math -├── capture.py # clipboard watcher → one JSON line per clip +├── capture.py # clipboard watcher → one JSON line per clip (incl. QR decode) ├── paste-entry.sh # copy + shift-insert paste into the focused window └── open-entry.sh # open with the right app @@ -73,7 +88,7 @@ History is stored in `~/.local/state/omarchy/clipboard-history-rich.json` ## Development -- Run logic tests: `tests/run.sh` (45 tests). +- Run logic tests: `tests/run.sh` (49 tests). - After editing files in the repo, reload with `omarchy-shell shell rescanPlugins` (the plugin dir is a symlink, so the shell's inotify does not watch repo edits). - Data lives in `~/.local/state/omarchy/`; the picker state is independent of diff --git a/plugin/Store.js b/Store.js similarity index 96% rename from plugin/Store.js rename to Store.js index 8fd553e..1ff9ac7 100644 --- a/plugin/Store.js +++ b/Store.js @@ -50,6 +50,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) } else if (type === "files") { var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : [] if (paths.length === 0) return null @@ -103,6 +104,8 @@ 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. + if (existing.qr && !normalized.qr) normalized.qr = existing.qr continue } next.push(existing) @@ -176,6 +179,7 @@ function buildRow(entry, derivedType, now) { var content = "" if (entry.type === "image") { content = fileLabel(entry.path) + " " + String(entry.mime || "") + if (entry.qr) content += " " + String(entry.qr).slice(0, 500) } else if (entry.type === "files") { content = (entry.paths || []).join(" ") } else { diff --git a/plugin/capture.py b/capture.py similarity index 91% rename from plugin/capture.py rename to capture.py index f9a7d78..262307a 100755 --- a/plugin/capture.py +++ b/capture.py @@ -65,6 +65,22 @@ def emit(entry): sys.stdout.flush() +def decode_qr(path): + """Decode a QR code with zbarimg; returns the payload or None.""" + try: + r = subprocess.run( + ["zbarimg", "-q", "--raw", "--", path], + capture_output=True, timeout=10 + ) + # zbarimg exits 4 when no barcode is found. + if r.returncode != 0: + return None + text = r.stdout.decode("utf-8", "replace").rstrip("\n") + return text or None + except Exception: + return None + + def capture_image(types, app): mime = next((m for m in IMAGE_MIMES if m in types), None) if not mime: @@ -98,6 +114,11 @@ def capture_image(types, app): except OSError: pass return + + qr = decode_qr(path) + if qr: + entry["qr"] = qr + emit(entry) return True diff --git a/install.sh b/install.sh index cb4bc00..18af3d1 100755 --- a/install.sh +++ b/install.sh @@ -1,21 +1,23 @@ #!/bin/bash # Install the tank.clipboard Omarchy shell plugin from this repo. # -# - symlinks plugin/ into ~/.config/omarchy/plugins/tank.clipboard +# - symlinks the repo root (the plugin folder) into ~/.config/omarchy/plugins/tank.clipboard # - rescans + enables it (shell.json gets plugins[] entry; the built-in # omarchy.clipboard is recorded in disabledPlugins[] and routed here) # - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf) set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PLUGIN_DIR="$REPO_DIR/plugin" +PLUGIN_DIR="$REPO_DIR" PLUGINS_DIR="$HOME/.config/omarchy/plugins" PLUGIN_ID="tank.clipboard" BINDINGS="$HOME/.config/hypr/bindings.conf" -echo "==> Symlinking plugin into $PLUGINS_DIR/$PLUGIN_ID" +echo "==> Linking plugin into $PLUGINS_DIR/$PLUGIN_ID" mkdir -p "$PLUGINS_DIR" ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID" +# On other machines, install directly from git instead: +# omarchy plugin add --enable chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh echo "==> Rescanning shell plugins" diff --git a/plugin/manifest.json b/manifest.json similarity index 100% rename from plugin/manifest.json rename to manifest.json diff --git a/plugin/open-entry.sh b/open-entry.sh similarity index 100% rename from plugin/open-entry.sh rename to open-entry.sh diff --git a/plugin/paste-entry.sh b/paste-entry.sh similarity index 100% rename from plugin/paste-entry.sh rename to paste-entry.sh diff --git a/plugin/__pycache__/capture.cpython-314.pyc b/plugin/__pycache__/capture.cpython-314.pyc deleted file mode 100644 index 101baf1..0000000 Binary files a/plugin/__pycache__/capture.cpython-314.pyc and /dev/null differ diff --git a/tests/harness.mjs b/tests/harness.mjs index 4aeb615..b55ab5c 100644 --- a/tests/harness.mjs +++ b/tests/harness.mjs @@ -6,7 +6,7 @@ import path from "node:path" import vm from "node:vm" const testsDir = path.dirname(fileURLToPath(import.meta.url)) -const pluginDir = path.join(testsDir, "..", "plugin") +const pluginDir = path.join(testsDir, "..") export function loadLib(name) { let src = readFileSync(path.join(pluginDir, name), "utf8") diff --git a/tests/test-store.mjs b/tests/test-store.mjs index 3d014a5..a4a8199 100644 --- a/tests/test-store.mjs +++ b/tests/test-store.mjs @@ -110,3 +110,19 @@ test("entryId distinct for distinct content", () => { const b = Store.entryId({ type: "text", text: "two" }) assert.notEqual(a, b) }) + +test("qr payload passes through normalize and dedup", () => { + const e = Store.normalize({ type: "image", path: "/x/qr.png", mime: "image/png", w: 100, h: 100, qr: "https://example.com" }) + assert.equal(e.qr, "https://example.com") + // Re-copy of the same image keeps the decoded QR even if the new capture lacks it. + let h = Store.addEntry([], { type: "image", path: "/x/qr.png", qr: "https://example.com", ts: 1 }) + h = Store.addEntry(h, { type: "image", path: "/x/qr.png", ts: 2 }) + assert.equal(h.length, 1) + assert.equal(h[0].qr, "https://example.com") + assert.equal(h[0].ts, 2) +}) + +test("buildRow makes QR payload searchable", () => { + const row = Store.buildRow({ type: "image", path: "/x/qr.png", mime: "image/png", qr: "secret-payload-xyz", ts: 1, bytes: 10, app: "", uses: 0 }, "image", 1) + assert.ok(row.content.includes("secret-payload-xyz")) +})