Floating card window, retention settings (maxAgeDays/historyLimit/maxRows), pause/resume (Ctrl+= + IPC), LICENSE, README with screenshots, screenshot tooling
This commit is contained in:
+120
-19
@@ -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" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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`.
|
|
||||||
|
|||||||
@@ -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
@@ -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 |
Executable
+30
@@ -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)
|
||||||
Executable
+47
@@ -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"
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user