Floating card window, retention settings (maxAgeDays/historyLimit/maxRows), pause/resume (Ctrl+= + IPC), LICENSE, README with screenshots, screenshot tooling

This commit is contained in:
2026-09-05 20:28:03 +01:00
parent 1aac582e90
commit 66ba5bd3db
10 changed files with 428 additions and 73 deletions
+120 -19
View File
@@ -28,6 +28,9 @@ Item {
property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json" property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json"
property int historyLimit: 1500 property int historyLimit: 1500
property int displayLimit: 200 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 property var typeCache: ({}) // id → derived type, memoized
// Theme surface tokens (menu) — tracks the active Omarchy theme. // Theme surface tokens (menu) — tracks the active Omarchy theme.
@@ -46,8 +49,8 @@ Item {
readonly property string fontFamily: Style.font.menuFamily readonly property string fontFamily: Style.font.menuFamily
readonly property int contentMargin: Style.spacing.panelPadding 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 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 cardWidth: Style.space(980)
readonly property int cardHeight: Math.min(Style.space(680), panel.height - Style.gapsOut * 2) readonly property int cardHeight: Style.space(680)
readonly property int rowHeight: Style.space(52) readonly property int rowHeight: Style.space(52)
readonly property int listWidth: Math.round(card.width * 0.46) readonly property int listWidth: Math.round(card.width * 0.46)
@@ -76,19 +79,65 @@ Item {
else root.open() 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 // ------------------------------------------------------------ store
function loadHistory(raw) { function loadHistory(raw) {
root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000)) root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000))
root.typeCache = {} root.typeCache = {}
root.applyRetentionPolicy()
if (root.opened) root.rebuild() if (root.opened) root.rebuild()
} }
function saveHistory() { function saveHistory() {
var pruned = Store.prune(root.history, root.historyLimit) 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") 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 // Serialize GC batches: a single reusable Process would silently drop
@@ -105,6 +154,7 @@ Item {
} }
function addClipboardJson(line) { function addClipboardJson(line) {
if (root.paused) return
var entry = null var entry = null
try { entry = JSON.parse(String(line || "")) } catch (e) { return } try { entry = JSON.parse(String(line || "")) } catch (e) { return }
if (!entry) return if (!entry) return
@@ -266,6 +316,41 @@ Item {
PointerMoveGate { id: pointerGate; referenceItem: card } 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 { FileView {
id: historyFile id: historyFile
path: root.statePath path: root.statePath
@@ -304,6 +389,7 @@ 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" })
onExited: watchRestartTimer.restart() onExited: watchRestartTimer.restart()
stdout: SplitParser { stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) } onRead: function(data) { root.addClipboardJson(data) }
@@ -327,32 +413,23 @@ Item {
// ------------------------------------------------------------ window // ------------------------------------------------------------ window
// Floating card: an unanchored layer surface is centered by the compositor,
// so the picker is just the card — no fullscreen scrim underneath.
PanelWindow { PanelWindow {
id: panel id: panel
visible: root.opened visible: root.opened
anchors { top: true; bottom: true; left: true; right: true } width: root.cardWidth
height: root.cardHeight
color: "transparent" color: "transparent"
WlrLayershell.namespace: "tank-clipboard" WlrLayershell.namespace: "tank-clipboard"
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
Rectangle {
anchors.fill: parent
color: root.scrim
}
MouseArea {
anchors.fill: parent
onClicked: root.close()
}
BorderSurface { BorderSurface {
id: card id: card
width: root.cardWidth anchors.fill: parent
height: root.cardHeight
radius: root.cornerRadius radius: root.cornerRadius
anchors.centerIn: parent
color: root.background color: root.background
borderSpec: root.borderSpec borderSpec: root.borderSpec
padding: root.contentMargin padding: root.contentMargin
@@ -405,6 +482,9 @@ Item {
} else if (event.key === Qt.Key_Tab) { } else if (event.key === Qt.Key_Tab) {
root.togglePinIndex(root.selectedIndex) root.togglePinIndex(root.selectedIndex)
event.accepted = true 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)) { } else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
root.openResult(root.currentResult) root.openResult(root.currentResult)
event.accepted = true event.accepted = true
@@ -497,6 +577,7 @@ Item {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: { text: {
if (root.paused) return "⏸ paused"
var shown = root.results.length var shown = root.results.length
var total = root.history.length var total = root.history.length
if (shown === total) return total + " items" 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 // ---- list + preview
Item { Item {
width: parent.width 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 { ListView {
id: resultList id: resultList
@@ -740,6 +840,7 @@ Item {
{ keys: "shift+enter", hint: "copy" }, { keys: "shift+enter", hint: "copy" },
{ keys: "ctrl+o", hint: "open" }, { keys: "ctrl+o", hint: "open" },
{ keys: "tab", hint: "pin" }, { keys: "tab", hint: "pin" },
{ keys: "ctrl+=", hint: "pause" },
{ keys: "del", hint: "remove" }, { keys: "del", hint: "remove" },
{ keys: "esc", hint: "close" } { keys: "esc", hint: "close" }
] ]
+21
View File
@@ -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.
+115 -53
View File
@@ -1,35 +1,64 @@
# clipboard-history # clipboard-history
A Raycast/vicinae-style clipboard history manager for Omarchy — implemented as A Raycast/vicinae-style clipboard history manager for [Omarchy](https://omarchy.org),
an Omarchy shell plugin (`tank.clipboard`, a clone of `omarchy.clipboard`) so it built as an Omarchy shell plugin. Fuzzy search over everything (content, type,
rides on the system theme, fonts, and layer-shell infrastructure. source app, date — even text inside QR codes), rich per-type previews, and
full theme integration.
## Features <p align="center">
<img src="docs/screenshots/picker.png" alt="clipboard-history picker showing a QR code with its decoded content" width="720">
</p>
- **Rich capture** — a `wl-paste --watch` daemon records every clip with mime ## Highlights
type, byte size, source app (via `hyprctl`), timestamp, and image dimensions.
Text, images, and `file://` URI lists (file-manager copies) are supported; - **Fuzzy search over everything** — fzf-style scoring across content, source
password-manager clips and binary payloads are skipped. app, and type, boosted by recency, pins, and usage. Query tokens:
- **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) - `type:image|link|text|files|code|json|color|email|html|number` (prefix match)
- `app:firefox` — fuzzy match on the source app - `app:firefox` — fuzzy match on the source app
- `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d` - `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d`
- **Raycast-style UI** — search bar with blinking cursor, type filter chips, - **Rich previews, per type** — images rendered inline, QR codes show their
two-line result rows with type icons / image thumbnails and metadata decoded content, colors show a swatch with hex/rgb/hsl, links show the
(type · app · age · size), and a right-hand preview pane: domain, JSON is pretty-printed, file lists show paths — every preview with
- images rendered inline (with dimensions + size) metadata chips (app, date, words/lines, size, paste counts)
- colors shown as a swatch with hex/rgb/hsl values - **QR codes** — copied QR images are decoded with `zbarimg`; the payload is
- links show the domain headline shown in the list, preview, and is searchable like any text clip
- JSON is pretty-printed; code/html shown appropriately - **Rich capture** — a `wl-paste --watch` daemon records every clip with mime
- file lists with paths; every preview shows metadata chips (app, date, type, byte size, source app (via `hyprctl`), timestamp, and image dimensions.
words/lines, bytes, pin/paste counts) Text, images, and `file://` URI lists (file-manager copies) are supported;
- **Fully theme-integrated** — colors, spacing, corner radius, borders, and the password-manager clips and binary payloads are skipped
monospace font all come from the Omarchy shell's `Color`/`Style` singletons; - **Retention control** — configure how much and how long history is kept
it re-themes itself on `omarchy theme set`. - **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`
<p align="center">
<img src="docs/screenshots/search.png" alt="fuzzy search for “omarchy” with highlighted matches" width="720">
</p>
## 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 ## Keys
@@ -40,56 +69,89 @@ rides on the system theme, fonts, and layer-shell infrastructure.
| `Shift+Enter` | copy only | | `Shift+Enter` | copy only |
| `Ctrl+O` | open (link → browser, image → editor, file → xdg-open, text → editor) | | `Ctrl+O` | open (link → browser, image → editor, file → xdg-open, text → editor) |
| `Tab` | pin/unpin | | `Tab` | pin/unpin |
| `Ctrl+=` | pause/resume recording |
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) | | `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
| `Esc` | clear filter, then close | | `Esc` | clear filter, then close |
## Install Pause/resume is also scriptable — useful for automation or a custom binding:
```bash ```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 ## Configuration
`~/.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: Settings live on the plugin's entry in the `plugins` array of
`~/.config/omarchy/shell.json` and hot-reload on save:
```bash ```json
omarchy plugin add <this-repo-git-url> --enable {
"version": 1,
"plugins": [
{
"id": "tank.clipboard",
"historyLimit": 1500,
"maxAgeDays": 30,
"maxRows": 200
}
]
}
``` ```
Note: the local dev symlink trips `omarchy plugin validate` (it refuses | Key | Default | Meaning |
symlinked plugin folders); a git-cloned copy validates clean. |-----|---------|---------|
| `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 <git-url>` works ## How it compares
directly — it validates and clones a repo whose root holds `manifest.json`).
| | 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 ├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers
├── PreviewPane.qml # per-type preview + metadata chips ├── 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 ├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting
├── Classify.js # type detection, app names, formatting, color math ├── Classify.js # type detection, app names, formatting, color math
├── capture.py # clipboard watcher → one JSON line per clip (incl. QR decode) ├── capture.py # clipboard watcher → one JSON line per clip (incl. QR decode)
├── paste-entry.sh # copy + shift-insert paste into the focused window ├── paste-entry.sh # copy + shift-insert paste into the focused window
── open-entry.sh # open with the right app ── open-entry.sh # open with the right app
├── tests/ # node --test suites for the JS logic
tests/ # node --test suites for the JS logic └── scripts/ # screenshot regeneration helpers
``` ```
History is stored in `~/.local/state/omarchy/clipboard-history-rich.json` - Run logic tests: `tests/run.sh`
(image blobs content-addressed under `~/.local/state/omarchy/clipboard-images/`). - Validate the manifest: `omarchy plugin validate <checkout-dir>`
- 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). [MIT](LICENSE)
- 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`.
+50
View File
@@ -174,6 +174,56 @@ function prune(history, limit) {
return { entries: kept, droppedImagePaths: droppedImagePaths } 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. // Build the search-row context Fuzzy.searchRows expects.
function buildRow(entry, derivedType, now) { function buildRow(entry, derivedType, now) {
var content = "" var content = ""
+1 -1
View File
@@ -115,7 +115,7 @@ def capture_image(types, app):
pass pass
return return
qr = decode_qr(path) qr = decode_qr(path) if os.environ.get("CLIPBOARD_QR", "1") != "0" else None
if qr: if qr:
entry["qr"] = qr entry["qr"] = qr
Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+30
View File
@@ -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)
+47
View File
@@ -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() { # <output.png> — 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"
+44
View File
@@ -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) 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")) 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)
})