diff --git a/Clipboard.qml b/Clipboard.qml
index da55ee3..fc36041 100644
--- a/Clipboard.qml
+++ b/Clipboard.qml
@@ -28,6 +28,9 @@ Item {
property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json"
property int historyLimit: 1500
property int displayLimit: 200
+ property int maxAgeDays: 0 // 0 = keep forever
+ property bool qrDecode: true
+ property bool paused: false
property var typeCache: ({}) // id → derived type, memoized
// Theme surface tokens (menu) — tracks the active Omarchy theme.
@@ -46,8 +49,8 @@ Item {
readonly property string fontFamily: Style.font.menuFamily
readonly property int contentMargin: Style.spacing.panelPadding
readonly property int headerHeight: Math.max(Style.space(40), Style.font.heading + Style.spacing.controlPaddingY * 2)
- readonly property int cardWidth: Math.min(Style.space(980), panel.width - Style.gapsOut * 2)
- readonly property int cardHeight: Math.min(Style.space(680), panel.height - Style.gapsOut * 2)
+ readonly property int cardWidth: Style.space(980)
+ readonly property int cardHeight: Style.space(680)
readonly property int rowHeight: Style.space(52)
readonly property int listWidth: Math.round(card.width * 0.46)
@@ -76,19 +79,65 @@ Item {
else root.open()
}
+ // ------------------------------------------------------------ pause
+ // Toggle via IPC: omarchy-shell shell call tank.clipboard pause '{"paused":true}'
+ // (or {"paused":false}, or {"paused":"toggle"}), and Ctrl+Space in the picker.
+ function setPaused(next) {
+ root.paused = !!next
+ pausedFile.setText(JSON.stringify({ paused: root.paused }) + "\n")
+ }
+
+ function pause(payloadJson) {
+ var p = null
+ try { p = JSON.parse(String(payloadJson || "{}")) } catch (e) { p = null }
+ var next = p && typeof p.paused !== "undefined"
+ ? (p.paused === "toggle" ? !root.paused : !!p.paused)
+ : !root.paused
+ root.setPaused(next)
+ Quickshell.execDetached(["notify-send", "-a", "Clipboard History",
+ next ? "Clipboard history paused" : "Clipboard history resumed",
+ next ? "New copies are not being recorded." : "Recording new copies again."])
+ return JSON.stringify({ paused: root.paused })
+ }
+
+ function isPaused() { return JSON.stringify({ paused: root.paused }) }
+
+ // Screenshot helper: omarchy-shell shell call tank.clipboard debugSetFilter '{"text":"…"}'
+ // Opens the picker with a preset query so scripts can capture it keyboard-free.
+ function debugSetFilter(payloadJson) {
+ var p = {}
+ try { p = JSON.parse(String(payloadJson || "{}")) } catch (e) { p = {} }
+ root.open()
+ root.setFilter(String(p.text || ""))
+ return "ok"
+ }
+
// ------------------------------------------------------------ store
function loadHistory(raw) {
root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000))
root.typeCache = {}
+ root.applyRetentionPolicy()
if (root.opened) root.rebuild()
}
function saveHistory() {
var pruned = Store.prune(root.history, root.historyLimit)
- root.history = pruned.entries
+ var aged = Store.pruneByAge(pruned.entries, root.maxAgeDays > 0 ? root.maxAgeDays * 86400 : -1, Math.floor(Date.now() / 1000))
+ root.history = aged.entries
historyFile.setText(JSON.stringify(root.history, null, 1) + "\n")
- if (pruned.droppedImagePaths.length > 0) queueGc(pruned.droppedImagePaths)
+ var paths = pruned.droppedImagePaths.concat(aged.droppedImagePaths)
+ if (paths.length > 0) queueGc(paths)
+ }
+
+ // Full retention pass, run when history loads and when settings change.
+ function applyRetentionPolicy() {
+ if (root.maxAgeDays <= 0) return
+ var aged = Store.pruneByAge(root.history, root.maxAgeDays * 86400, Math.floor(Date.now() / 1000))
+ if (aged.entries.length === root.history.length) return
+ root.history = aged.entries
+ if (aged.droppedImagePaths.length > 0) queueGc(aged.droppedImagePaths)
+ root.saveHistory()
}
// Serialize GC batches: a single reusable Process would silently drop
@@ -105,6 +154,7 @@ Item {
}
function addClipboardJson(line) {
+ if (root.paused) return
var entry = null
try { entry = JSON.parse(String(line || "")) } catch (e) { return }
if (!entry) return
@@ -266,6 +316,41 @@ Item {
PointerMoveGate { id: pointerGate; referenceItem: card }
+ // User settings live on this plugin's entry in shell.json (hot-reloads):
+ // { "id": "tank.clipboard", "historyLimit": 1500, "maxAgeDays": 30, "maxRows": 200 }
+ FileView {
+ id: shellConfigFile
+ path: Quickshell.env("HOME") + "/.config/omarchy/shell.json"
+ watchChanges: true
+ printErrors: false
+ onLoaded: root.applySettings(text())
+ onLoadFailed: root.applySettings("{}")
+ onFileChanged: reload()
+ }
+
+ function applySettings(raw) {
+ var s = Store.parseSettings(raw, "tank.clipboard")
+ root.historyLimit = s.historyLimit
+ root.maxAgeDays = s.maxAgeDays
+ root.displayLimit = s.maxRows
+ root.qrDecode = s.qrDecode
+ root.applyRetentionPolicy()
+ if (root.opened) root.rebuild()
+ }
+
+ // Pause state survives shell restarts.
+ FileView {
+ id: pausedFile
+ path: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-paused.json"
+ watchChanges: true
+ atomicWrites: true
+ printErrors: false
+ onLoaded: {
+ try { root.paused = !!JSON.parse(text()).paused } catch (e) { root.paused = false }
+ }
+ onLoadFailed: root.paused = false
+ }
+
FileView {
id: historyFile
path: root.statePath
@@ -304,6 +389,7 @@ Item {
Process {
id: watchProc
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"]
+ environment: ({ "CLIPBOARD_QR": root.qrDecode ? "1" : "0" })
onExited: watchRestartTimer.restart()
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
@@ -327,32 +413,23 @@ Item {
// ------------------------------------------------------------ window
+ // Floating card: an unanchored layer surface is centered by the compositor,
+ // so the picker is just the card — no fullscreen scrim underneath.
PanelWindow {
id: panel
visible: root.opened
- anchors { top: true; bottom: true; left: true; right: true }
+ width: root.cardWidth
+ height: root.cardHeight
color: "transparent"
WlrLayershell.namespace: "tank-clipboard"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore
- Rectangle {
- anchors.fill: parent
- color: root.scrim
- }
-
- MouseArea {
- anchors.fill: parent
- onClicked: root.close()
- }
-
BorderSurface {
id: card
- width: root.cardWidth
- height: root.cardHeight
+ anchors.fill: parent
radius: root.cornerRadius
- anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
@@ -405,6 +482,9 @@ Item {
} else if (event.key === Qt.Key_Tab) {
root.togglePinIndex(root.selectedIndex)
event.accepted = true
+ } else if (event.key === Qt.Key_Equal && (event.modifiers & Qt.ControlModifier)) {
+ root.pause(JSON.stringify({ paused: "toggle" }))
+ event.accepted = true
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
root.openResult(root.currentResult)
event.accepted = true
@@ -497,6 +577,7 @@ Item {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: {
+ if (root.paused) return "⏸ paused"
var shown = root.results.length
var total = root.history.length
if (shown === total) return total + " items"
@@ -556,10 +637,29 @@ Item {
}
}
+ // ---- paused banner
+ Rectangle {
+ width: parent.width
+ height: root.paused ? Style.space(24) : 0
+ visible: root.paused
+ radius: Style.cornerRadius
+ color: Util.alpha(Color.urgent, 0.15)
+
+ Text {
+ anchors.centerIn: parent
+ text: "⏸ Paused — new copies are not being recorded · Ctrl+= to resume"
+ color: Color.urgent
+ font.family: root.fontFamily
+ font.pixelSize: Style.font.caption
+ }
+ }
+
// ---- list + preview
Item {
width: parent.width
- height: parent.height - root.headerHeight - chipsRow.height - footer.height - Style.space(30)
+ height: parent.height - root.headerHeight - chipsRow.height
+ - (root.paused ? Style.space(24) + Style.space(10) : 0)
+ - footer.height - Style.space(30)
ListView {
id: resultList
@@ -740,6 +840,7 @@ Item {
{ keys: "shift+enter", hint: "copy" },
{ keys: "ctrl+o", hint: "open" },
{ keys: "tab", hint: "pin" },
+ { keys: "ctrl+=", hint: "pause" },
{ keys: "del", hint: "remove" },
{ keys: "esc", hint: "close" }
]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..d4bfcd1
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 alanfortlink
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index fb56571..fefd8ac 100644
--- a/README.md
+++ b/README.md
@@ -1,35 +1,64 @@
# clipboard-history
-A Raycast/vicinae-style clipboard history manager for Omarchy — implemented as
-an Omarchy shell plugin (`tank.clipboard`, a clone of `omarchy.clipboard`) so it
-rides on the system theme, fonts, and layer-shell infrastructure.
+A Raycast/vicinae-style clipboard history manager for [Omarchy](https://omarchy.org),
+built as an Omarchy shell plugin. Fuzzy search over everything (content, type,
+source app, date — even text inside QR codes), rich per-type previews, and
+full theme integration.
-## Features
+
+
+
-- **Rich capture** — a `wl-paste --watch` daemon records every clip with mime
- 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:
+## Highlights
+
+- **Fuzzy search over everything** — fzf-style scoring across content, source
+ app, and type, boosted by recency, pins, and usage. Query tokens:
- `type:image|link|text|files|code|json|color|email|html|number` (prefix match)
- `app:firefox` — fuzzy match on the source app
- `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d`
-- **Raycast-style UI** — search bar with blinking cursor, type filter chips,
- two-line result rows with type icons / image thumbnails and metadata
- (type · app · age · size), and a right-hand preview pane:
- - images rendered inline (with dimensions + size)
- - colors shown as a swatch with hex/rgb/hsl values
- - links show the domain headline
- - JSON is pretty-printed; code/html shown appropriately
- - file lists with paths; every preview shows metadata chips (app, date,
- words/lines, bytes, pin/paste counts)
-- **Fully theme-integrated** — colors, spacing, corner radius, borders, and the
- monospace font all come from the Omarchy shell's `Color`/`Style` singletons;
- it re-themes itself on `omarchy theme set`.
+- **Rich previews, per type** — images rendered inline, QR codes show their
+ decoded content, colors show a swatch with hex/rgb/hsl, links show the
+ domain, JSON is pretty-printed, file lists show paths — every preview with
+ metadata chips (app, date, words/lines, size, paste counts)
+- **QR codes** — copied QR images are decoded with `zbarimg`; the payload is
+ shown in the list, preview, and is searchable like any text clip
+- **Rich capture** — a `wl-paste --watch` daemon records every clip with mime
+ 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
+- **Retention control** — configure how much and how long history is kept
+- **Pause/resume** — stop recording new copies without losing your history
+- **Fully theme-integrated** — colors, spacing, corner radius, borders, and
+ the monospace font all come from the Omarchy shell's theme singletons; it
+ re-themes itself on `omarchy theme set`
+
+
+
+
+
+## Install
+
+```bash
+omarchy plugin add https://github.com/alanfortlink/clipboard-history.git --enable
+```
+
+Optional QR support (usually already installed):
+
+```bash
+omarchy pkg add zbar
+```
+
+That's the whole install — `omarchy plugin add` clones the repo, validates the
+manifest, and enables it. It replaces the built-in `omarchy.clipboard`
+(restore it later with `omarchy plugin disable tank.clipboard`), and you bind
+it wherever you like:
+
+```bash
+# ~/.config/hypr/bindings.conf
+bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard
+```
+
+Updating: `omarchy plugin update tank.clipboard` · Uninstall: `omarchy plugin remove tank.clipboard`
## Keys
@@ -40,56 +69,89 @@ rides on the system theme, fonts, and layer-shell infrastructure.
| `Shift+Enter` | copy only |
| `Ctrl+O` | open (link → browser, image → editor, file → xdg-open, text → editor) |
| `Tab` | pin/unpin |
+| `Ctrl+=` | pause/resume recording |
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
| `Esc` | clear filter, then close |
-## Install
+Pause/resume is also scriptable — useful for automation or a custom binding:
```bash
-./install.sh
+omarchy-shell shell call tank.clipboard pause '{"paused":"toggle"}'
+omarchy-shell shell call tank.clipboard isPaused
```
-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`).
+## Configuration
-On other machines, install straight from git — no clone/step needed:
+Settings live on the plugin's entry in the `plugins` array of
+`~/.config/omarchy/shell.json` and hot-reload on save:
-```bash
-omarchy plugin add --enable
+```json
+{
+ "version": 1,
+ "plugins": [
+ {
+ "id": "tank.clipboard",
+ "historyLimit": 1500,
+ "maxAgeDays": 30,
+ "maxRows": 200
+ }
+ ]
+}
```
-Note: the local dev symlink trips `omarchy plugin validate` (it refuses
-symlinked plugin folders); a git-cloned copy validates clean.
+| Key | Default | Meaning |
+|-----|---------|---------|
+| `historyLimit` | `1500` | max entries kept in history |
+| `maxAgeDays` | `0` (forever) | drop entries older than N days (images get garbage-collected; pinned entries are exempt) |
+| `maxRows` | `200` | max rows the picker shows per search |
+| `qrDecode` | `true` | decode QR codes with `zbarimg` on captured images |
-## Layout
+History lives in `~/.local/state/omarchy/clipboard-history-rich.json`; image
+blobs are content-addressed under `~/.local/state/omarchy/clipboard-images/`.
-The repo root *is* the plugin folder (so `omarchy plugin add ` works
-directly — it validates and clones a repo whose root holds `manifest.json`).
+## How it compares
+
+| | built-in `omarchy.clipboard` | [sspaeti's OCR fork](https://github.com/sspaeti/omarchy-clipboard-plugin) | [Clipbasket](https://github.com/clipbasket/clipbasket-omarchy) | vicinae | **clipboard-history** |
+|---|---|---|---|---|---|
+| Fuzzy search | substring | + OCR text | ✓ | ✓ | content + app + type + date tokens (`type:`, `app:`, `<2h`, `today`) |
+| Previews | text/image | — | — | ✓ | images, **QR payloads**, color swatches, JSON pretty-print, links, files |
+| Metadata | basic | basic | SQLite | ✓ | size, source app, dims, word/line counts, pins, paste counts |
+| Retention config | 300 cap | limit | ✓ | ✓ | `historyLimit` + `maxAgeDays` + GC |
+| Pause recording | ✗ | ✗ | ? | ✓ | ✓ (in-picker + IPC) |
+| Theme-integrated shell overlay | ✓ | ✓ | ✓ | own | ✓ (uses Omarchy's theme singletons) |
+
+## Roadmap / ideas
+
+- OCR search: tesseract on captured images so text inside screenshots is
+ searchable ([reference implementation](https://github.com/sspaeti/omarchy-clipboard-plugin))
+- `autoPaste` mode swap: Enter = copy-only, Shift+Enter = paste (walker-style)
+- Small bar widget: last-copied item + paused indicator
+- Snippet editing: edit a pinned entry's content in place
+- Multi-select delete, export/import of history
+- Optional per-app capture exclusions beyond the password-manager hint
+
+## Development
```
-├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard)
+├── manifest.json # plugin manifest (replaces omarchy.clipboard via clonedFrom)
├── 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
+├── Store.js # history model: dedup, pins, retention, settings parsing
├── 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 (incl. QR decode)
├── paste-entry.sh # copy + shift-insert paste into the focused window
-└── open-entry.sh # open with the right app
-
-tests/ # node --test suites for the JS logic
+├── open-entry.sh # open with the right app
+├── tests/ # node --test suites for the JS logic
+└── scripts/ # screenshot regeneration helpers
```
-History is stored in `~/.local/state/omarchy/clipboard-history-rich.json`
-(image blobs content-addressed under `~/.local/state/omarchy/clipboard-images/`).
+- Run logic tests: `tests/run.sh`
+- Validate the manifest: `omarchy plugin validate `
+- Regenerate screenshots: `scripts/take-screenshots.sh` (stages demo content
+ and captures the picker keyboard-free over IPC)
+- Hot-reload after edits: `omarchy-shell shell rescanPlugins`
-## Development
+## License
-- 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
- the built-in clipboard plugin's `clipboard-history.json`.
+[MIT](LICENSE)
diff --git a/Store.js b/Store.js
index 1ff9ac7..5966698 100644
--- a/Store.js
+++ b/Store.js
@@ -174,6 +174,56 @@ function prune(history, limit) {
return { entries: kept, droppedImagePaths: droppedImagePaths }
}
+// Retention: drop entries older than maxAgeSeconds (-1 = keep forever).
+// Pinned entries are exempt — pins are favorites; delete them explicitly.
+// Returns { entries, droppedImagePaths } like prune().
+function pruneByAge(history, maxAgeSeconds, now) {
+ var values = Array.isArray(history) ? history : []
+ if (maxAgeSeconds === undefined || maxAgeSeconds === null || maxAgeSeconds < 0)
+ return { entries: values, droppedImagePaths: [] }
+ var cutoff = now - maxAgeSeconds
+ var kept = []
+ var droppedImagePaths = []
+ for (var i = 0; i < values.length; i++) {
+ var e = values[i]
+ if (!e) continue
+ if ((Number(e.ts) || 0) < cutoff && !e.pinned) {
+ if (e.type === "image" && e.path) droppedImagePaths.push(e.path)
+ continue
+ }
+ kept.push(e)
+ }
+ return { entries: kept, droppedImagePaths: droppedImagePaths }
+}
+
+// Parse this plugin's settings from the shell.json contents. Every key is
+// 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 config = null
+ try { config = JSON.parse(String(raw || "{}")) } catch (e) { return out }
+ if (!config || !Array.isArray(config.plugins)) return out
+ for (var i = 0; i < config.plugins.length; i++) {
+ var entry = config.plugins[i]
+ if (!entry || entry.id !== pluginId) continue
+
+ var n = Number(entry.historyLimit)
+ if (isFinite(n) && n >= 1) out.historyLimit = Math.floor(n)
+
+ n = Number(entry.maxAgeDays)
+ if (isFinite(n) && n >= 0) out.maxAgeDays = Math.floor(n)
+
+ n = Number(entry.maxRows)
+ if (isFinite(n) && n >= 1) out.maxRows = Math.floor(n)
+
+ if (typeof entry.qrDecode === "boolean") out.qrDecode = entry.qrDecode
+
+ break
+ }
+ return out
+}
+
// Build the search-row context Fuzzy.searchRows expects.
function buildRow(entry, derivedType, now) {
var content = ""
diff --git a/capture.py b/capture.py
index 262307a..3066d9b 100755
--- a/capture.py
+++ b/capture.py
@@ -115,7 +115,7 @@ def capture_image(types, app):
pass
return
- qr = decode_qr(path)
+ qr = decode_qr(path) if os.environ.get("CLIPBOARD_QR", "1") != "0" else None
if qr:
entry["qr"] = qr
diff --git a/docs/screenshots/picker.png b/docs/screenshots/picker.png
new file mode 100644
index 0000000..2946077
Binary files /dev/null and b/docs/screenshots/picker.png differ
diff --git a/docs/screenshots/search.png b/docs/screenshots/search.png
new file mode 100644
index 0000000..145dd41
Binary files /dev/null and b/docs/screenshots/search.png differ
diff --git a/scripts/physical-crop.py b/scripts/physical-crop.py
new file mode 100755
index 0000000..273822d
--- /dev/null
+++ b/scripts/physical-crop.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+"""Print the PHYSICAL-pixel crop geometry (WxH+X+Y) of the tank-clipboard
+layer surface on grim's combined framebuffer. Monitors report logical .x/.y
+with .scale; layers report logical xywh; grim captures physical pixels."""
+import json
+import re
+import subprocess
+
+layers = subprocess.run(["hyprctl", "layers"], capture_output=True, text=True).stdout
+m = re.search(r"xywh:\s*(\d+) (\d+) (\d+) (\d+).*namespace: tank-clipboard", layers)
+if not m:
+ raise SystemExit(1)
+lx, ly, lw, lh = map(int, m.groups())
+
+mons = json.loads(subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True).stdout)
+# Physical framebuffer layout: monitors sorted by (y, x); each occupies its
+# physical width/height; offsets accumulate.
+mons.sort(key=lambda d: (d["y"], d["x"]))
+for i, d in enumerate(mons):
+ logical_w = d["width"] / d["scale"]
+ if d["x"] <= lx < d["x"] + logical_w and d["y"] <= ly < d["y"] + d["height"] / d["scale"]:
+ phys_x = sum(o["width"] for o in mons[:i])
+ px = phys_x + round((lx - d["x"]) * d["scale"])
+ py = round((ly - d["y"]) * d["scale"])
+ pw = round(lw * d["scale"])
+ ph = round(lh * d["scale"])
+ print(f"{pw}x{ph}+{px}+{py}")
+ break
+else:
+ raise SystemExit(1)
diff --git a/scripts/take-screenshots.sh b/scripts/take-screenshots.sh
new file mode 100755
index 0000000..c70b706
--- /dev/null
+++ b/scripts/take-screenshots.sh
@@ -0,0 +1,47 @@
+#!/bin/bash
+# Regenerate README screenshots (docs/screenshots/*.png).
+# Stages demo clipboard entries, opens the picker keyboard-free (IPC), and
+# crops the card from a full-screen capture. Run in a graphical session.
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+OUT="$REPO_DIR/docs/screenshots"
+mkdir -p "$OUT"
+
+crop_shot() { # — open via IPC first; caller decides what's on screen
+ local out=$1
+ omarchy-shell shell hide tank.clipboard
+ sleep 0.4
+ local geo
+ case $out in
+ "$OUT/search.png")
+ omarchy-shell shell call tank.clipboard debugSetFilter '{"text":"omarchy"}'
+ ;;
+ *)
+ omarchy-shell shell toggle tank.clipboard
+ ;;
+ esac
+ sleep 1.5
+ local geo; geo=$(python3 "$REPO_DIR/scripts/physical-crop.py")
+ grim /tmp/clipshot-full.png
+ omarchy-shell shell hide tank.clipboard
+ sleep 0.4
+ magick /tmp/clipshot-full.png -crop "$geo" +repage "$out"
+}
+
+# ---- stage demo content ----------------------------------------------------
+qrencode -s 10 -o /tmp/clipshot-qr-repo.png "https://github.com/alanfortlink/clipboard-history"
+qrencode -s 10 -o /tmp/clipshot-qr-omarchy.png "https://omarchy.org"
+# stage newest-last so the QR pointing at this repo is selected on open
+printf 'The quick brown fox jumps over the lazy dog.' | wl-copy; sleep 1.5
+printf 'function pasteEntry(id) {\n const entry = store.findById(id)\n return paste(entry)\n}' | wl-copy; sleep 1.5
+printf '#7aa2f7' | wl-copy; sleep 1.5
+printf 'https://omarchy.org/manual/shell-plugins/' | wl-copy; sleep 1.5
+wl-copy --type image/png < /tmp/clipshot-qr-omarchy.png; sleep 1.5
+wl-copy --type image/png < /tmp/clipshot-qr-repo.png; sleep 1.5
+
+# ---- shots -----------------------------------------------------------------
+crop_shot "$OUT/picker.png"
+crop_shot "$OUT/search.png"
+
+echo "Wrote $OUT/picker.png and $OUT/search.png"
diff --git a/tests/test-store.mjs b/tests/test-store.mjs
index a4a8199..56f65e6 100644
--- a/tests/test-store.mjs
+++ b/tests/test-store.mjs
@@ -126,3 +126,47 @@ 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"))
})
+
+test("pruneByAge drops old entries, keeps pins, reports images", () => {
+ const now = 1000000
+ const h = [
+ { id: "new", type: "text", text: "fresh", ts: now - 10, bytes: 1, app: "", uses: 0 },
+ { id: "old", type: "text", text: "old", ts: now - 100 * 86400, bytes: 1, app: "", uses: 0 },
+ { id: "oldimg", type: "image", path: "/tmp/old.png", ts: now - 90 * 86400, bytes: 1, app: "", uses: 0 },
+ { id: "oldpin", type: "text", text: "pinned old", ts: now - 200 * 86400, pinned: true, bytes: 1, app: "", uses: 0 }
+ ]
+ const r = Store.pruneByAge(h, 30 * 86400, now)
+ assert.deepEqual(r.entries.map(e => e.id).sort().join(","), ["new", "oldpin"].sort().join(","))
+ assert.equal(r.droppedImagePaths.length, 1)
+})
+
+test("pruneByAge no-op when forever", () => {
+ const h = [{ id: "a", type: "text", text: "x", ts: 1, bytes: 1, app: "", uses: 0 }]
+ const r = Store.pruneByAge(h, -1, 1000000)
+ assert.equal(r.entries.length, 1)
+ assert.equal(r.droppedImagePaths.length, 0)
+})
+
+test("parseSettings from shell.json", () => {
+ const raw = JSON.stringify({
+ plugins: [
+ { id: "other.plugin" },
+ { id: "tank.clipboard", historyLimit: 500, maxAgeDays: 14, maxRows: 50, qrDecode: false, customKey: 1 }
+ ]
+ })
+ const s = Store.parseSettings(raw, "tank.clipboard")
+ assert.equal(s.historyLimit, 500)
+ assert.equal(s.maxAgeDays, 14)
+ assert.equal(s.maxRows, 50)
+ assert.equal(s.qrDecode, false)
+ const d = Store.parseSettings("{}", "tank.clipboard")
+ assert.equal(d.historyLimit, 1500)
+ assert.equal(d.maxAgeDays, 0)
+ assert.equal(d.maxRows, 200)
+ assert.equal(d.qrDecode, true)
+})
+
+test("parseSettings ignores garbage", () => {
+ const s = Store.parseSettings("not json", "tank.clipboard")
+ assert.equal(s.historyLimit, 1500)
+})