Clipboard history picker: Omarchy shell plugin (tank.clipboard) with fuzzy search, rich previews, capture daemon

This commit is contained in:
2026-09-05 15:25:15 +01:00
commit 44808ffd2c
17 changed files with 2982 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
*.bak
+80
View File
@@ -0,0 +1,80 @@
# 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.
## 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.
- **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)
- `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`.
## Keys
| Key | Action |
| --- | --- |
| `Ctrl+N` / `Ctrl+P` (or arrows) | navigate results |
| `Enter` | copy to clipboard and paste into the focused window |
| `Shift+Enter` | copy only |
| `Ctrl+O` | open (link → browser, image → editor, file → xdg-open, text → editor) |
| `Tab` | pin/unpin |
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
| `Esc` | clear filter, then close |
## Install
```bash
./install.sh
```
This symlinks `plugin/` into `~/.config/omarchy/plugins/tank.clipboard`,
rescans + enables the plugin (the built-in `omarchy.clipboard` is replaced —
revert with `omarchy plugin disable tank.clipboard`), and rebinds
`ALT+SHIFT+V` from vicinae to this picker (backing up `bindings.conf`).
## Layout
```
plugin/
├── manifest.json # plugin manifest (clonedFrom omarchy.clipboard)
├── Clipboard.qml # picker overlay: search, chips, list, keys, capture watchers
├── PreviewPane.qml # per-type preview + metadata chips
├── Store.js # history model: dedup, pins, pruning, ids
├── Fuzzy.js # query parser, fuzzy matcher, scoring, highlighting
├── Classify.js # type detection, app names, formatting, color math
├── capture.py # clipboard watcher → one JSON line per clip
├── 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
```
History is stored in `~/.local/state/omarchy/clipboard-history-rich.json`
(image blobs content-addressed under `~/.local/state/omarchy/clipboard-images/`).
## Development
- Run logic tests: `tests/run.sh` (45 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`.
Executable
+52
View File
@@ -0,0 +1,52 @@
#!/bin/bash
# Install the tank.clipboard Omarchy shell plugin from this repo.
#
# - symlinks plugin/ into ~/.config/omarchy/plugins/tank.clipboard
# - rescans + enables it (shell.json gets plugins[] entry; the built-in
# omarchy.clipboard is recorded in disabledPlugins[] and routed here)
# - rebinds ALT+SHIFT+V from vicinae to this picker (backs up bindings.conf)
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PLUGIN_DIR="$REPO_DIR/plugin"
PLUGINS_DIR="$HOME/.config/omarchy/plugins"
PLUGIN_ID="tank.clipboard"
BINDINGS="$HOME/.config/hypr/bindings.conf"
echo "==> Symlinking plugin into $PLUGINS_DIR/$PLUGIN_ID"
mkdir -p "$PLUGINS_DIR"
ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID"
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh
echo "==> Rescanning shell plugins"
omarchy-shell shell rescanPlugins >/dev/null
discovered=0
for _ in $(seq 1 40); do
if omarchy-plugin-list --json | jq -e --arg id "$PLUGIN_ID" 'any(.[]; .id == $id)' >/dev/null 2>&1; then
discovered=1
break
fi
sleep 0.05
done
if [[ $discovered != 1 ]]; then
echo "ERROR: plugin was not discovered by the shell" >&2
exit 1
fi
echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)"
omarchy plugin enable "$PLUGIN_ID"
echo "==> Rebinding ALT+SHIFT+V"
if [[ -f $BINDINGS ]] && grep -q "ALT SHIFT, V" "$BINDINGS"; then
cp "$BINDINGS" "$BINDINGS.bak.$(date +%s)"
sed -i 's|^bindd = ALT SHIFT, V, .*|bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard|' "$BINDINGS"
hyprctl reload >/dev/null
echo " rebound; previous bindings backed up next to $BINDINGS"
else
echo " no ALT SHIFT V binding found in $BINDINGS — add manually:"
echo ' bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell shell toggle tank.clipboard'
fi
echo
echo "Done. Press ALT+SHIFT+V to open the clipboard picker."
echo "Edit code in $REPO_DIR; run 'omarchy-shell shell rescanPlugins' after changes."
+326
View File
@@ -0,0 +1,326 @@
.pragma library
// Entry classification and formatting helpers.
// Pure ES5, no imports — runs in QML (.pragma library) and node (tests).
// ---------------------------------------------------------------- types
var TYPES = {
image: { icon: "󰋲", label: "Image" },
files: { icon: "󰉋", label: "Files" },
color: { icon: "󰏘", label: "Color" },
link: { icon: "󰌹", label: "Link" },
email: { icon: "󰇮", label: "Email" },
json: { icon: "󰘦", label: "JSON" },
code: { icon: "󰅴", label: "Code" },
html: { icon: "󰩟", label: "HTML" },
number: { icon: "󰎠", label: "Number" },
text: { icon: "󰈙", label: "Text" }
}
function typeIcon(t) { var e = TYPES[t]; return e ? e.icon : TYPES.text.icon }
function typeLabel(t) { var e = TYPES[t]; return e ? e.label : "Text" }
// Derived display type for a stored entry.
// entry: { type: "text"|"image"|"files", text?, path?, paths? }
function deriveType(entry) {
if (!entry) return "text"
if (entry.type === "image") return "image"
if (entry.type === "files") return "files"
var text = String(entry.text || "")
if (!text) return "text"
var trimmed = text.trim()
if (!trimmed) return "text"
if (trimmed.indexOf("\n") === -1) {
if (isColor(trimmed)) return "color"
if (isEmail(trimmed)) return "email"
if (isUrl(trimmed)) return "link"
if (isNumber(trimmed)) return "number"
}
if (trimmed.charAt(0) === "<" &&
/<\/?[a-z][a-z0-9-]*(\s[^>]*)?>/i.test(trimmed.slice(0, 200)))
return "html"
if (isJson(trimmed)) return "json"
if (looksLikeCode(trimmed)) return "code"
return "text"
}
var COLOR_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
var RGB_RE = /^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
var HSL_RE = /^hsla?\(\s*\d{1,3}\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%\s*(?:,\s*(?:0|1|0?\.\d+)\s*)?\)$/i
function isColor(s) {
return COLOR_RE.test(s) || RGB_RE.test(s) || HSL_RE.test(s)
}
function clampByte(n) { return Math.max(0, Math.min(255, Math.round(n))) }
// "#rgb"/"#rgba"/"#rrggbb"/"#rrggbbaa"/"rgb()"/"hsl()" → [r, g, b] or null.
function colorToRgb(s) {
s = String(s || "").trim()
var m = /^#([0-9a-f]+)$/i.exec(s)
if (m) {
var hex = m[1]
if (hex.length === 3 || hex.length === 4) {
return [
parseInt(hex.charAt(0) + hex.charAt(0), 16),
parseInt(hex.charAt(1) + hex.charAt(1), 16),
parseInt(hex.charAt(2) + hex.charAt(2), 16)
]
}
if (hex.length === 6 || hex.length === 8) {
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16)
]
}
return null
}
m = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})/i.exec(s)
if (m) return [clampByte(Number(m[1])), clampByte(Number(m[2])), clampByte(Number(m[3]))]
if (!HSL_RE.test(s)) return null
var hsl = colorToHsl(s)
if (!hsl) return null
return hslToRgb(hsl[0], hsl[1], hsl[2])
}
function colorToHsl(s) {
s = String(s || "").trim()
var m = /^hsla?\(\s*(\d{1,3})\s*,\s*(\d{1,3})%\s*,\s*(\d{1,3})%/i.exec(s)
if (m) return [Number(m[1]), Number(m[2]), Number(m[3])]
var rgb = colorToRgb(s)
if (!rgb) return null
return rgbToHsl(rgb[0], rgb[1], rgb[2])
}
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255
var max = Math.max(r, g, b), min = Math.min(r, g, b)
var h = 0, s = 0
var l = (max + min) / 2
if (max !== min) {
var d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0))
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h = Math.round(h * 60)
}
return [h, Math.round(s * 100), Math.round(l * 100)]
}
function hslToRgb(h, s, l) {
s /= 100; l /= 100
var c = (1 - Math.abs(2 * l - 1)) * s
var x = c * (1 - Math.abs(((h / 60) % 2) - 1))
var m = l - c / 2
var rgb
if (h < 60) rgb = [c, x, 0]
else if (h < 120) rgb = [x, c, 0]
else if (h < 180) rgb = [0, c, x]
else if (h < 240) rgb = [0, x, c]
else if (h < 300) rgb = [x, 0, c]
else rgb = [c, 0, x]
return [clampByte((rgb[0] + m) * 255), clampByte((rgb[1] + m) * 255), clampByte((rgb[2] + m) * 255)]
}
var URL_RE = /^(?:https?:\/\/|www\.)[^\s]+$/i
var BARE_DOMAIN_RE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?:\/[^\s]*)?$/i
function isUrl(s) {
return URL_RE.test(s) || BARE_DOMAIN_RE.test(s)
}
var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/
function isEmail(s) { return EMAIL_RE.test(s) }
var NUMBER_RE = /^[+-]?(?:\d{1,3}(?:,\d{3})*|\d+)(?:\.\d+)?%?$/
function isNumber(s) { return NUMBER_RE.test(s) }
function isJson(s) {
var c = s.charAt(0)
if (c !== "{" && c !== "[" && c !== '"') return false
try {
JSON.parse(s)
return true
} catch (e) {
return false
}
}
// Pretty-print JSON; returns "" when not parseable or too large.
function prettyJson(text, maxLen) {
var cap = maxLen === undefined ? 65536 : maxLen
if (!text || text.length > cap) return ""
try {
return JSON.stringify(JSON.parse(text), null, 2)
} catch (e) {
return ""
}
}
var CODE_HINTS = [
/(?:^|\n)\s*(?:def |class |function |fn |func |const |let |var |import |from |package |using |#include)/,
/(?:=>|->|::|&&|\|\||===|!==|:=)/,
/(?:^|\n)\s*(?:if|for|while|switch|try|elif|elsif)\s*[(\s{]/,
/;\s*$/,
/^\s*#!/m,
/<\/?[a-z][^>]*>/ // xml-ish (checked after html derive, fine for code view)
]
function looksLikeCode(s) {
if (s.length > 100000) return false
// Needs at least a little structure; single prose words are never code.
var hints = 0
for (var i = 0; i < CODE_HINTS.length; i++) {
if (CODE_HINTS[i].test(s)) hints++
if (hints >= 2) return true
}
// Many lines with consistent indentation / trailing semicolons.
var lines = s.split("\n")
if (lines.length >= 3) {
var indented = 0
for (var l = 0; l < lines.length; l++) {
if (/^[ \t]+\S/.test(lines[l]) || /;\s*$/.test(lines[l])) indented++
}
if (indented / lines.length > 0.5) return true
}
return false
}
function stripHtml(s) {
return String(s || "")
.replace(/<(?:script|style)\b[^>]*>[\s\S]*?<\/(?:script|style)>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim()
}
function urlDomain(url) {
var m = /^(?:https?:\/\/)?(?:www\.)?([^\/\s]+)/i.exec(String(url || "").trim())
return m ? m[1] : ""
}
// ---------------------------------------------------------------- apps
var APP_NAMES = {
ghostty: "Ghostty", "com.mitchellh.ghostty": "Ghostty", foot: "Foot",
alacritty: "Alacritty", kitty: "kitty", wezterm: "WezTerm",
"org.wezfurlong.wezterm": "WezTerm", code: "VS Code", "code-oss": "VS Code",
Code: "VS Code", firefox: "Firefox", chromium: "Chromium",
"Google-chrome": "Chrome", "google-chrome": "Chrome", brave: "Brave",
"brave-browser": "Brave", slack: "Slack", discord: "Discord",
obsidian: "Obsidian", spotify: "Spotify", telegram: "Telegram",
"org.telegram.desktop": "Telegram", nautilus: "Files",
"org.gnome.Nautilus": "Files", thunar: "Thunar", dolphin: "Dolphin",
"org.kde.dolphin": "Dolphin", nvim: "Neovim", neovide: "Neovide",
emacs: "Emacs", "jetbrains-idea": "IntelliJ IDEA", zed: "Zed", dev: "Dev",
wps: "WPS", gimp: "GIMP", inkscape: "Inkscape", blender: "Blender",
thunderbird: "Thunderbird", keepassxc: "KeePassXC", steam: "Steam"
}
function prettyApp(cls) {
var s = String(cls || "").trim()
if (!s) return ""
if (APP_NAMES[s]) return APP_NAMES[s]
// Strip reverse-DNS prefixes and version suffixes: com.foo.Bar-2.1 → Bar
var noDns = s.replace(/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+\./i, "")
noDns = noDns.replace(/[-_.]\d.*$/, "")
if (!noDns) return s
return noDns.charAt(0).toUpperCase() + noDns.slice(1)
}
// ---------------------------------------------------------------- formatting
function formatBytes(n) {
n = Number(n)
if (!isFinite(n) || n < 0) return ""
if (n < 1024) return n + " B"
if (n < 1024 * 1024) return (n / 1024).toFixed(n < 10240 ? 1 : 0) + " KB"
return (n / (1024 * 1024)).toFixed(1) + " MB"
}
function formatAge(ts, now) {
ts = Number(ts)
if (!isFinite(ts) || ts <= 0) return ""
var s = Math.max(0, Math.floor((now - ts)))
if (s < 45) return "just now"
if (s < 3600) return Math.floor(s / 60) + "m ago"
if (s < 86400) {
var h = Math.floor(s / 3600)
return h + "h ago"
}
var d = Math.floor(s / 86400)
if (d < 7) return d + "d ago"
return formatDate(ts)
}
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
function pad2(n) { return n < 10 ? "0" + n : "" + n }
function formatDate(ts) {
ts = Number(ts)
if (!isFinite(ts) || ts <= 0) return ""
var d = new Date(ts * 1000)
return MONTHS[d.getMonth()] + " " + d.getDate() + ", " +
pad2(d.getHours()) + ":" + pad2(d.getMinutes())
}
function formatFullDate(ts) {
ts = Number(ts)
if (!isFinite(ts) || ts <= 0) return ""
var d = new Date(ts * 1000)
return MONTHS[d.getMonth()] + " " + d.getDate() + ", " + d.getFullYear() +
" at " + pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds())
}
function plural(n, word) {
return n === 1 ? "1 " + word : n + " " + word + "s"
}
function textStats(text) {
var s = String(text || "")
var words = 0
var m = s.match(/\S+/g)
if (m) words = m.length
return {
chars: s.length,
words: words,
lines: s === "" ? 0 : s.split("\n").length
}
}
function firstLine(text, max) {
var s = String(text || "")
var nl = s.indexOf("\n")
var line = nl === -1 ? s : s.slice(0, nl)
if (max && line.length > max) return line.slice(0, max) + "…"
return line
}
function fileBase(p) {
var s = String(p || "").replace(/\/+$/, "")
var idx = s.lastIndexOf("/")
return idx === -1 ? s : s.slice(idx + 1)
}
function fileDir(p) {
var s = String(p || "")
var idx = s.lastIndexOf("/")
if (idx <= 0) return idx === 0 ? "/" : ""
return s.slice(0, idx)
}
+821
View File
@@ -0,0 +1,821 @@
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
import qs.Ui
import "Store.js" as Store
import "Fuzzy.js" as Fuzzy
import "Classify.js" as Classify
// Clipboard history picker — Raycast-style: fuzzy search bar, result list,
// and a per-type preview pane. Clone of omarchy.clipboard with richer
// capture metadata (app, size, dims, pins, usage) and full-text fuzzy search.
Item {
id: root
property bool opened: false
property string filterText: ""
property string typeFilter: "" // chip filter, "" = all; combined into the query
property int selectedIndex: 0
property bool cursorActive: false
property bool cursorVisible: true
property bool clearConfirmOpen: false
property var history: []
property var results: [] // [{ row, score, positions }]
property string pluginDir: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, "").replace(/\/$/, "")
property string statePath: Quickshell.env("HOME") + "/.local/state/omarchy/clipboard-history-rich.json"
property int historyLimit: 1500
property int displayLimit: 200
property var typeCache: ({}) // id → derived type, memoized
// Theme surface tokens (menu) — tracks the active Omarchy theme.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
property color scrim: Color.menu.scrim
property color selectedBackground: Color.menu.selectedBackground
property color selectedText: Color.menu.selectedText
property color accent: Color.accent
property color mutedFg: Util.alpha(foreground, 0.55)
property color chipBg: Util.alpha(foreground, 0.07)
property color lineColor: Util.alpha(foreground, 0.14)
readonly property int cornerRadius: Style.cornerRadius
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 rowHeight: Style.space(52)
readonly property int listWidth: Math.round(card.width * 0.46)
readonly property var currentResult: results.length > 0 && selectedIndex >= 0 && selectedIndex < results.length ? results[selectedIndex] : null
// ------------------------------------------------------------ lifecycle
function open() {
root.opened = true
root.filterText = ""
root.typeFilter = ""
root.selectedIndex = 0
root.cursorActive = true
root.disarmPointer()
root.rebuild()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
root.cancelClearHistory()
root.opened = false
}
function toggle() {
if (root.opened) root.close()
else root.open()
}
// ------------------------------------------------------------ store
function loadHistory(raw) {
root.history = Store.parseHistory(raw, Math.floor(Date.now() / 1000))
root.typeCache = {}
if (root.opened) root.rebuild()
}
function saveHistory() {
var pruned = Store.prune(root.history, root.historyLimit)
root.history = pruned.entries
historyFile.setText(JSON.stringify(root.history, null, 1) + "\n")
if (pruned.droppedImagePaths.length > 0) {
var cmd = ["rm", "-f"].concat(pruned.droppedImagePaths)
gcProc.command = cmd
gcProc.running = true
}
}
function addClipboardJson(line) {
var entry = null
try { entry = JSON.parse(String(line || "")) } catch (e) { return }
if (!entry) return
root.history = Store.addEntry(root.history, entry, root.historyLimit, Math.floor(Date.now() / 1000))
root.saveHistory()
if (root.opened) root.rebuild()
}
// ------------------------------------------------------------ search
function effectiveQuery() {
var q = root.filterText
if (root.typeFilter === "pinned") return q + (q ? " " : "") + "is:pinned"
if (root.typeFilter) return "type:" + root.typeFilter + (q ? " " + q : "")
return q
}
function rebuild() {
var now = Math.floor(Date.now() / 1000)
var rows = []
for (var i = 0; i < root.history.length; i++) {
var entry = root.history[i]
var derived = root.typeCache[entry.id]
if (derived === undefined) {
derived = Classify.deriveType(entry)
root.typeCache[entry.id] = derived
}
rows.push(Store.buildRow(entry, derived, now))
}
root.results = Fuzzy.searchRows(rows, root.effectiveQuery(), now, root.displayLimit)
if (root.results.length === 0) root.selectedIndex = 0
else if (root.selectedIndex >= root.results.length) root.selectedIndex = root.results.length - 1
Qt.callLater(function() {
if (root.results.length > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
})
}
function setFilter(next) {
root.filterText = next
root.selectedIndex = 0
root.cursorActive = true
root.disarmPointer()
root.rebuild()
}
function setTypeFilter(next) {
root.typeFilter = next
root.selectedIndex = 0
root.disarmPointer()
root.rebuild()
}
// ------------------------------------------------------------ navigation
function select(delta) {
if (root.results.length === 0) return
root.disarmPointer()
if (!root.cursorActive) {
root.cursorActive = true
root.selectedIndex = delta < 0 ? root.results.length - 1 : 0
} else {
root.selectedIndex = (root.selectedIndex + delta + root.results.length) % root.results.length
}
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
function selectAbsolute(index) {
if (root.results.length === 0) return
root.disarmPointer()
root.cursorActive = true
root.selectedIndex = Math.max(0, Math.min(index, root.results.length - 1))
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
}
function disarmPointer() {
pointerGate.reset()
}
function selectFromPointer(index, item, mouse) {
if (!pointerGate.moved(item, mouse)) return
root.cursorActive = true
root.selectedIndex = index
}
// ------------------------------------------------------------ actions
function pasteResult(result) {
if (!result) return
root.close()
root.history = Store.touch(root.history, result.row.entry.id, Math.floor(Date.now() / 1000))
root.saveHistory()
Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id])
}
function copyResult(result) {
if (!result) return
root.close()
Quickshell.execDetached([root.pluginDir + "/paste-entry.sh", result.row.entry.id, "--copy-only"])
}
function openResult(result) {
if (!result) return
root.close()
Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id])
}
function removeIndex(index) {
if (index < 0 || index >= root.results.length) return
var entry = root.results[index].row.entry
root.history = Store.removeById(root.history, entry.id)
delete root.typeCache[entry.id]
root.saveHistory()
if (root.results.length <= 1) root.selectedIndex = 0
else if (root.selectedIndex >= root.results.length - 1) root.selectedIndex = root.results.length - 2
root.rebuild()
}
function togglePinIndex(index) {
if (index < 0 || index >= root.results.length) return
root.history = Store.togglePin(root.history, root.results[index].row.entry.id)
root.saveHistory()
root.rebuild()
}
function requestClearHistory() {
if (root.history.length === 0) return
clearConfirm.selectedIndex = 1
root.clearConfirmOpen = true
}
function cancelClearHistory() {
root.clearConfirmOpen = false
root.disarmPointer()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function confirmClearHistory() {
var dropped = []
for (var i = 0; i < root.history.length; i++) {
if (root.history[i].type === "image" && root.history[i].path) dropped.push(root.history[i].path)
}
root.history = []
root.typeCache = {}
root.saveHistory()
if (dropped.length > 0) {
gcProc.command = ["rm", "-f"].concat(dropped)
gcProc.running = true
}
root.selectedIndex = 0
root.clearConfirmOpen = false
root.rebuild()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
Component.onCompleted: initProc.running = true
// ------------------------------------------------------------ capture
ListModel { id: displayModel }
PointerMoveGate { id: pointerGate; referenceItem: card }
FileView {
id: historyFile
path: root.statePath
watchChanges: true
atomicWrites: true
printErrors: false
onLoaded: root.loadHistory(text())
onLoadFailed: root.loadHistory("[]")
onFileChanged: reload()
}
Process { id: gcProc }
// Reap watchers left behind by a previous shell instance, then start our
// own. pdeathsig kills them whenever the shell exits.
Process {
id: initProc
command: ["pkill", "-f", "wl-paste .*--watch .*capture\\.py"]
onExited: {
snapshotProc.running = true
watchProc.running = true
}
}
Process {
id: snapshotProc
command: ["python3", root.pluginDir + "/capture.py"]
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
Process {
id: watchProc
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--watch", "python3", root.pluginDir + "/capture.py", "watch"]
onExited: watchRestartTimer.restart()
stdout: SplitParser {
onRead: function(data) { root.addClipboardJson(data) }
}
}
Timer {
id: watchRestartTimer
interval: 1000
repeat: false
onTriggered: if (!watchProc.running) watchProc.running = true
}
// Cursor blink
Timer {
running: root.opened
interval: 530
repeat: true
onTriggered: root.cursorVisible = !root.cursorVisible
}
// ------------------------------------------------------------ window
PanelWindow {
id: panel
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
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
radius: root.cornerRadius
anchors.centerIn: parent
color: root.background
borderSpec: root.borderSpec
padding: root.contentMargin
MouseArea { anchors.fill: parent; onClicked: function() {} }
Item {
id: keyCatcher
anchors.fill: parent
z: root.clearConfirmOpen ? 20 : 0
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (root.clearConfirmOpen) {
if (clearConfirm.handleKey(event)) event.accepted = true
return
}
if (event.key === Qt.Key_Escape) {
if (root.filterText) root.setFilter("")
else if (root.typeFilter) root.setTypeFilter("")
else root.close()
event.accepted = true
} else if (Util.editsFilter(event, root.filterText)) {
root.setFilter(Util.editedFilter(event, root.filterText))
event.accepted = true
} else if (event.key === Qt.Key_Delete) {
if (event.modifiers & Qt.ShiftModifier) root.requestClearHistory()
else root.removeIndex(root.selectedIndex)
event.accepted = true
} else if (event.key === Qt.Key_Up || (event.key === Qt.Key_P && (event.modifiers & Qt.ControlModifier))) {
root.select(-1)
event.accepted = true
} else if (event.key === Qt.Key_Down || (event.key === Qt.Key_N && (event.modifiers & Qt.ControlModifier))) {
root.select(1)
event.accepted = true
} else if (event.key === Qt.Key_PageUp) {
root.select(-8)
event.accepted = true
} else if (event.key === Qt.Key_PageDown) {
root.select(8)
event.accepted = true
} else if (event.key === Qt.Key_Home) {
root.selectAbsolute(0)
event.accepted = true
} else if (event.key === Qt.Key_End) {
root.selectAbsolute(root.results.length - 1)
event.accepted = true
} else if (event.key === Qt.Key_Tab) {
root.togglePinIndex(root.selectedIndex)
event.accepted = true
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
root.openResult(root.currentResult)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (event.modifiers & Qt.ShiftModifier) root.copyResult(root.currentResult)
else if (event.modifiers & Qt.AltModifier) root.openResult(root.currentResult)
else root.pasteResult(root.currentResult)
event.accepted = true
} else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127) {
root.setFilter(root.filterText + event.text)
event.accepted = true
}
}
ConfirmDialog {
id: clearConfirm
anchors.fill: parent
opened: root.clearConfirmOpen
z: 10
message: "Delete entire clipboard history?"
confirmText: "Delete"
background: root.background
foreground: root.foreground
scrim: root.scrim
selectedBackground: root.selectedBackground
selectedText: root.selectedText
fontFamily: root.fontFamily
cornerRadius: root.cornerRadius
onCanceled: root.cancelClearHistory()
onConfirmed: root.confirmClearHistory()
}
}
// ---------------------------------------------------------- layout
Column {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
spacing: Style.space(10)
// ---- search bar
Item {
width: parent.width
height: root.headerHeight
Text {
id: searchIcon
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "󰍛"
color: root.accent
font.family: root.fontFamily
font.pixelSize: Style.font.heading
}
Row {
anchors.left: searchIcon.right
anchors.leftMargin: Style.space(10)
anchors.right: countLabel.left
anchors.rightMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
id: searchText
width: Math.min(implicitWidth, parent.width - Style.space(6))
text: root.filterText || "Search clipboard\u2026 (type:image app:firefox <2h is:pinned)"
color: root.foreground
opacity: root.filterText.length > 0 ? 1 : 0.4
font.family: root.fontFamily
font.pixelSize: Style.font.heading
elide: Text.ElideRight
maximumLineCount: 1
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: Math.max(1, Style.space(2))
height: Style.font.heading
color: root.accent
visible: root.cursorVisible && root.cursorActive
}
}
Text {
id: countLabel
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: {
var shown = root.results.length
var total = root.history.length
if (shown === total) return total + " items"
return shown + " of " + total
}
color: root.mutedFg
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// ---- type chips
Row {
id: chipsRow
width: parent.width
spacing: Style.space(6)
Repeater {
model: [
{ key: "", label: "All" },
{ key: "text", label: "󰈙 Text" },
{ key: "link", label: "󰌹 Links" },
{ key: "image", label: "󰋲 Images" },
{ key: "files", label: "󰉋 Files" },
{ key: "code", label: "󰅴 Code" },
{ key: "json", label: "󰘦 JSON" },
{ key: "color", label: "󰏘 Colors" },
{ key: "pinned", label: "★ Pinned" }
]
delegate: Rectangle {
required property var modelData
property string chipKey: modelData.key
property string chipLabel: modelData.label
property bool active: root.typeFilter === chipKey
radius: height / 2
color: active ? Util.alpha(root.accent, 0.18) : root.chipBg
width: chipLabel_.implicitWidth + Style.space(16)
height: Style.space(22)
Text {
id: chipLabel_
anchors.centerIn: parent
text: modelData.label
color: parent.active ? root.accent : root.mutedFg
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.setTypeFilter(parent.chipKey)
}
}
}
}
// ---- list + preview
Item {
width: parent.width
height: parent.height - root.headerHeight - chipsRow.height - footer.height - Style.space(30)
ListView {
id: resultList
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
width: root.listWidth
model: displayModel
clip: true
spacing: Style.space(3)
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle {
id: row
required property int index
required property var row_ // results[i]: { row, score, positions }
required property string derived
required property string titleHtml
required property string subtitle
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
readonly property var entry: row_ ? row_.row.entry : null
width: resultList.width
height: root.rowHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(10)
// thumbnail for images, glyph tile otherwise
Rectangle {
width: Style.space(36)
height: Style.space(36)
radius: Style.space(6)
color: root.chipBg
anchors.verticalCenter: parent.verticalCenter
clip: true
Image {
anchors.fill: parent
visible: row.derived === "image"
source: row.entry && row.entry.path ? Util.fileUrl(row.entry.path) : ""
fillMode: Image.PreserveAspectCrop
asynchronous: true
smooth: true
sourceSize.width: 72
sourceSize.height: 72
}
Text {
anchors.centerIn: parent
visible: row.derived !== "image"
text: Classify.typeIcon(row.derived)
color: root.foreground
opacity: 0.85
font.family: root.fontFamily
font.pixelSize: Style.font.iconLarge
}
}
Column {
width: parent.width - Style.space(46) - pinMark.width
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
width: parent.width
text: row.titleHtml
textFormat: Text.StyledText
color: row.hasCursor ? root.selectedText : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
width: parent.width
text: row.subtitle
color: root.mutedFg
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
maximumLineCount: 1
}
}
Text {
id: pinMark
anchors.verticalCenter: parent.verticalCenter
text: "★"
visible: row.entry && row.entry.pinned ? true : false
color: root.accent
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: function(mouse) { root.selectFromPointer(row.index, row, mouse) }
onClicked: {
root.cursorActive = true
root.selectedIndex = row.index
root.pasteResult(root.currentResult)
}
}
}
}
// divider between list and preview
Rectangle {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.leftMargin: root.listWidth + Style.space(6)
width: 1
color: root.lineColor
}
PreviewPane {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.right: parent.right
anchors.left: parent.left
anchors.leftMargin: root.listWidth + Style.space(14)
result: root.currentResult
visible: root.currentResult !== null
}
// empty state
Column {
anchors.centerIn: parent
spacing: Style.space(8)
visible: displayModel.count === 0
Text {
text: "󰅌"
color: root.selectedText
opacity: 0.8
font.family: root.fontFamily
font.pixelSize: Style.font.displayLarge
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
Text {
text: root.history.length === 0
? "Clipboard is empty — copy something first"
: "No matches for “" + root.filterText + "”"
color: root.foreground
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.title
horizontalAlignment: Text.AlignHCenter
width: parent.width
}
}
}
// ---- footer key hints
Row {
id: footer
width: parent.width
spacing: Style.space(10)
Repeater {
model: [
{ keys: "ctrl+n/p", hint: "navigate" },
{ keys: "enter", hint: "paste" },
{ keys: "shift+enter", hint: "copy" },
{ keys: "ctrl+o", hint: "open" },
{ keys: "tab", hint: "pin" },
{ keys: "del", hint: "remove" },
{ keys: "esc", hint: "close" }
]
delegate: Row {
required property var modelData
spacing: Style.space(4)
Rectangle {
radius: Style.space(3)
color: root.chipBg
width: keyText.implicitWidth + Style.space(10)
height: Style.space(18)
anchors.verticalCenter: parent.verticalCenter
Text {
id: keyText
anchors.centerIn: parent
text: modelData.keys
color: root.mutedFg
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Text {
text: modelData.hint
color: root.mutedFg
opacity: 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.caption
anchors.verticalCenter: parent.verticalCenter
}
}
}
}
}
}
}
// ------------------------------------------------------------ display model
// results → displayModel rows. Wrapped in function so it can be called from
// rebuild(); title/subtitle strings precomputed here, not in delegates.
function syncDisplayModel() {
displayModel.clear()
for (var i = 0; i < root.results.length; i++) {
var r = root.results[i]
var e = r.row.entry
var derived = r.row.type
var titleHtml = ""
var subtitleParts = []
if (e.type === "image") {
titleHtml = "Image" + (e.mime ? " · " + e.mime.replace("image/", "").toUpperCase() : "")
if (e.w && e.h) titleHtml += " · " + e.w + "×" + e.h
subtitleParts.push(Classify.formatBytes(r.row.bytes))
} else if (e.type === "files") {
var base = Classify.fileBase(e.paths[0])
titleHtml = Fuzzy.escapeHtml(e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base)
subtitleParts.push(Classify.fileDir(e.paths[0]))
} else {
titleHtml = Fuzzy.highlightFirstLine(e.text, r.positions, "<b><font color=\"" + root.accentHex() + "\">", "</font></b>")
var st = Classify.textStats(e.text)
if (st.words > 0) subtitleParts.push(Classify.plural(st.words, "word"))
}
subtitleParts.push(Classify.typeLabel(derived))
if (r.row.app) subtitleParts.push(Classify.prettyApp(r.row.app))
if (r.row.ts) subtitleParts.push(Classify.formatAge(r.row.ts, Math.floor(Date.now() / 1000)))
displayModel.append({
row_: r,
derived: derived,
titleHtml: titleHtml,
subtitle: subtitleParts.join(" · ")
})
}
}
// 6-digit hex for rich-text <font color> tags, whatever toString() returns.
function accentHex() {
var s = root.accent.toString()
return "#" + s.slice(-6)
}
onResultsChanged: syncDisplayModel()
}
+322
View File
@@ -0,0 +1,322 @@
.pragma library
// Fuzzy search engine for the clipboard picker.
//
// Pure ES5, no imports, so the same file runs inside QML (.pragma library)
// and under node (tests load it with the `vm` module).
//
// Query language (whitespace separated tokens):
// plain words fuzzy subsequence match against content + app + type
// type:<prefix> filter by derived type (image, link, color, code, json,
// file, email, html, number, text) — prefix match
// app:<word> fuzzy match against the source app
// is:pinned pin only pinned entries
// today yesterday age filters
// week month age filters
// <10m >2h <3d age comparisons (s/m/h/d/w suffixes)
//
// Rows handed to searchRows() are precomputed by the caller:
// { entry, content, app, type, ts, pinned, uses, bytes }
// ---------------------------------------------------------------- durations
var DURATION_UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }
function parseDuration(str) {
var m = /^(\d+(?:\.\d+)?)\s*([smhdw])$/.exec(String(str || "").trim().toLowerCase())
if (!m) return NaN
var unit = DURATION_UNITS[m[2]]
return Number(m[1]) * unit
}
var AGE_WORDS = {
today: 86400,
yesterday: 172800,
week: 604800,
month: 2592000,
year: 31536000
}
var TYPE_ALIASES = {
image: "image", img: "image", pic: "image", photo: "image", screenshot: "image",
link: "link", url: "link", links: "link", urls: "link",
color: "color", colour: "color", hex: "color",
code: "code", snippet: "code",
json: "json",
file: "files", files: "files", folder: "files", path: "files", dir: "files",
email: "email", mail: "email",
html: "html",
number: "number", num: "number",
text: "text", txt: "text"
}
// ---------------------------------------------------------------- query
function parseQuery(q) {
var out = { terms: [], type: "", app: "", pinned: false, minAge: -1, maxAge: -1 }
var raw = String(q || "").split(/\s+/)
for (var i = 0; i < raw.length; i++) {
var tok = raw[i]
if (!tok) continue
var lower = tok.toLowerCase()
if (lower.indexOf("type:") === 0 && tok.length > 5) {
var wanted = lower.slice(5)
var canonical = TYPE_ALIASES[wanted]
if (canonical) { out.type = canonical; continue }
// prefix match: "type:im" → image
for (var key in TYPE_ALIASES) {
if (key.indexOf(wanted) === 0) { out.type = TYPE_ALIASES[key]; break }
}
if (out.type) continue
// unknown type — treat the whole token as a term
out.terms.push(tok)
continue
}
if (lower.indexOf("app:") === 0 && tok.length > 4) {
out.app = tok.slice(4).toLowerCase()
continue
}
if (lower === "is:pinned" || lower === "pinned" || lower === "pin") {
out.pinned = true
continue
}
if (lower === "is:text" || lower === "star" || lower === "fav") {
out.terms.push(tok)
continue
}
if (AGE_WORDS[lower] !== undefined) {
out.maxAge = AGE_WORDS[lower]
if (lower === "yesterday") out.minAge = 0 // refined below relative to now; caller passes now
continue
}
if ((lower.charAt(0) === "<" || lower.charAt(0) === ">") && lower.length > 1) {
var dur = parseDuration(lower.slice(1))
if (!isNaN(dur)) {
if (lower.charAt(0) === "<") out.maxAge = dur
else out.minAge = dur
continue
}
}
out.terms.push(tok)
}
return out
}
// ---------------------------------------------------------------- fuzzy match
// Greedy case-insensitive subsequence match with fzf-style bonuses.
// Returns null when `needle` is not a subsequence of `haystack`, otherwise
// { score, positions } where positions are indices into the raw haystack.
function fuzzyMatch(needle, haystack) {
if (!needle) return { score: 0, positions: [] }
if (!haystack) return null
var nl = needle.toLowerCase()
var hl = haystack.toLowerCase()
var n = nl.length
var h = hl.length
if (n > h) return null
// Fast reject: all characters must be present in order via greedy scan.
var positions = new Array(n)
var j = 0
for (var i = 0; i < h && j < n; i++) {
if (hl.charCodeAt(i) === nl.charCodeAt(j)) positions[j++] = i
}
if (j < n) return null
// Exact substring bonus (case-insensitive).
var substringIdx = hl.indexOf(nl)
var score = 0
var consecutive = 0
var prev = -2
for (var k = 0; k < n; k++) {
var pos = positions[k]
if (pos === prev + 1) consecutive++
else consecutive = 0
score += 4 + consecutive * 3
// Word boundary bonus: start of string or after a non-alphanumeric.
if (pos === 0) score += 10
else {
var prevChar = hl.charCodeAt(pos - 1)
if (!(prevChar >= 97 && prevChar <= 122) &&
!(prevChar >= 48 && prevChar <= 57) &&
!(prevChar >= 65 && prevChar <= 90))
score += 8
}
// Gap penalty (light — we rank mostly by bonuses and recency).
if (k > 0) {
var gap = pos - positions[k - 1] - 1
if (gap > 0) score -= Math.min(6, gap)
}
prev = pos
}
if (substringIdx >= 0) {
var bonus = 40
if (substringIdx === 0) bonus += 25
// Prefer matches in shorter haystacks.
bonus -= Math.min(20, hl.length / 200)
if (bonus > score) {
score = bonus
for (var c = 0; c < n; c++) positions[c] = substringIdx + c
}
}
// Length penalty so "func" ranks higher in short snippets than in essays.
score -= Math.min(25, hl.length / 400)
return { score: score, positions: positions }
}
var FIELDS = [
{ name: "content", weight: 1.0 },
{ name: "app", weight: 0.7 },
{ name: "type", weight: 0.6 }
]
// Every term must match in at least one field; the score is the sum of each
// term's best field score (already weighted). Positions returned are for the
// best-scoring content-field match (for highlighting).
function matchRow(queryTerms, row) {
var total = 0
var contentPositions = null
for (var t = 0; t < queryTerms.length; t++) {
var term = queryTerms[t]
var best = -1
for (var f = 0; f < FIELDS.length; f++) {
var field = FIELDS[f]
var text = field.name === "content" ? row.content
: field.name === "app" ? row.app
: row.type
if (!text) continue
var m = fuzzyMatch(term, text)
if (m) {
var weighted = m.score * field.weight
if (weighted > best) {
best = weighted
if (field.name === "content") contentPositions = m.positions
}
}
}
if (best < 0) return null
total += best
}
return { score: total, positions: contentPositions }
}
function ageFilterOk(parsed, ageSeconds) {
if (parsed.maxAge >= 0 && ageSeconds > parsed.maxAge) return false
if (parsed.minAge >= 0 && ageSeconds < parsed.minAge) return false
return true
}
// "yesterday" means older than 24h but within 48h.
function refineYesterday(parsed, now) {
// Only applies when the query contained the bare word "yesterday" and no
// explicit duration bounds; approximated by caller passing minAge via the
// maxAge=172800 already set. We require age > 86400 - slack handled here.
return parsed
}
function recencyBonus(ts, now) {
if (!ts) return 0
var ageDays = Math.max(0, (now - ts) / 86400)
return 120 / (1 + ageDays)
}
// rows: [{ entry, content, app, type, ts, pinned, uses, bytes }]
// Returns up to `limit` rows sorted by relevance: [{ row, score, positions, type }]
function searchRows(rows, queryStr, now, limit) {
var parsed = parseQuery(queryStr)
var results = []
if (parsed.maxAge < 0 && parsed.terms.length === 0 && !parsed.type && !parsed.app && !parsed.pinned)
parsed.maxAge = -1 // no-op, keeps empty-query path below cheap
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
if (parsed.type && row.type !== parsed.type) continue
if (parsed.pinned && !row.entry.pinned) continue
var age = row.ts > 0 ? Math.max(0, now - row.ts) : 0
if (parsed.maxAge >= 0 && age > parsed.maxAge) continue
if (parsed.minAge >= 0 && age < parsed.minAge) continue
if (parsed.app) {
var appMatch = fuzzyMatch(parsed.app, row.app || "")
if (!appMatch) continue
}
var matched = null
if (parsed.terms.length > 0) {
matched = matchRow(parsed.terms, row)
if (!matched) continue
}
var score = matched ? matched.score : 0
score += recencyBonus(row.ts, now)
if (row.entry.pinned) score += 500
if (row.uses > 0) score += 4 * Math.min(10, row.uses)
results.push({
row: row,
score: score,
positions: matched ? matched.positions : null
})
}
results.sort(function(a, b) {
if (b.score !== a.score) return b.score - a.score
return (b.row.ts || 0) - (a.row.ts || 0)
})
var cap = limit === undefined ? 200 : limit
if (results.length > cap) results.length = cap
return results
}
// Escape for QML StyledText (HTML-ish).
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
// Build highlight markup for the first line of `text` given match positions.
// positions are indices into the RAW text; text is escaped segment-by-segment.
function highlightFirstLine(text, positions, openTag, closeTag) {
text = String(text || "")
var firstLineEnd = text.indexOf("\n")
var line = firstLineEnd === -1 ? text : text.slice(0, firstLineEnd)
if (!positions || positions.length === 0 || !line)
return escapeHtml(line)
var inLine = []
for (var i = 0; i < positions.length; i++) {
if (positions[i] < line.length) inLine.push(positions[i])
}
if (inLine.length === 0) return escapeHtml(line)
var out = ""
var prev = 0
for (var p = 0; p < inLine.length; p++) {
var idx = inLine[p]
if (idx < prev) continue // match spans past the line break — skip tail
out += escapeHtml(line.slice(prev, idx))
out += openTag + escapeHtml(line.charAt(idx)) + closeTag
prev = idx + 1
}
out += escapeHtml(line.slice(prev))
return out
}
+486
View File
@@ -0,0 +1,486 @@
import QtQuick
import qs.Commons
import qs.Ui
import "Classify.js" as Classify
// Right-hand preview pane for the clipboard picker.
// Renders a full preview plus metadata chips, per derived type.
// `result` is a Fuzzy search result: { row: {entry, content, app, type, ...}, positions }
Item {
id: root
property var result: null
property var entry: result ? result.row.entry : null
property string derived: result ? result.row.type : ""
readonly property string font_: Style.font.menuFamily
readonly property color fg: Color.menu.text
readonly property color mutedFg: Util.alpha(fg, 0.55)
readonly property color chipBg: Util.alpha(fg, 0.07)
readonly property color lineColor: Util.alpha(fg, 0.16)
property string bodyText: ""
onResultChanged: prepare()
function prepare() {
bodyText = ""
if (!entry) return
var t = derived
if (t === "json") {
var pretty = Classify.prettyJson(String(entry.text || ""), 200000)
bodyText = pretty || String(entry.text || "")
} else if (t === "html") {
bodyText = Classify.stripHtml(String(entry.text || "")) || String(entry.text || "")
} else if (t === "text" || t === "code" || t === "email" || t === "number") {
bodyText = String(entry.text || "")
}
}
function rawSafe() {
return entry ? String(entry.text || "").trim() : ""
}
function rgbLine() {
var rgb = Classify.colorToRgb(rawSafe())
return rgb ? "rgb(" + rgb[0] + ", " + rgb[1] + ", " + rgb[2] + ")" : ""
}
function hslLine() {
var hsl = Classify.colorToHsl(rawSafe())
return hsl ? "hsl(" + hsl[0] + ", " + hsl[1] + "%, " + hsl[2] + "%)" : ""
}
function metaChips() {
if (!result) return []
var r = result.row
var e = r.entry
var chips = []
if (r.app) chips.push(Classify.prettyApp(r.app))
if (e.type === "text" && e.text) {
var st = Classify.textStats(e.text)
chips.push(Classify.plural(st.words, "word"))
chips.push(Classify.plural(st.lines, "line"))
}
if (r.bytes > 0) chips.push(Classify.formatBytes(r.bytes))
if (e.type === "image" && e.w && e.h) chips.push(e.w + "×" + e.h)
if (e.type === "image") chips.push(e.mime || "image")
if (e.pinned) chips.push("★ pinned")
if (r.uses > 0) chips.push("pasted " + r.uses + "×")
return chips
}
// ------------------------------------------------------------- header
Row {
id: headerRow
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
spacing: Style.space(10)
Rectangle {
width: Style.space(38)
height: width
radius: Style.cornerRadius
color: root.chipBg
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: Classify.typeIcon(root.derived)
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.heading
}
}
Column {
width: parent.width - Style.space(48) - badge.width - Style.space(10)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
width: parent.width
text: {
if (!root.entry) return ""
var e = root.entry
if (e.type === "image") return Classify.fileBase(e.path)
if (e.type === "files") {
var base = Classify.fileBase(e.paths[0])
return e.paths.length > 1 ? base + " +" + (e.paths.length - 1) + " more" : base
}
return Classify.firstLine(e.text, 200)
}
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
width: parent.width
text: {
if (!root.result) return ""
var r = root.result.row
var parts = []
if (r.ts) parts.push(Classify.formatFullDate(r.ts))
if (r.bytes) parts.push(Classify.formatBytes(r.bytes))
return parts.join(" · ")
}
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
elide: Text.ElideRight
maximumLineCount: 1
}
}
Rectangle {
id: badge
anchors.verticalCenter: parent.verticalCenter
radius: height / 2
color: root.chipBg
width: badgeLabel.implicitWidth + Style.space(14)
height: Style.space(20)
Text {
id: badgeLabel
anchors.centerIn: parent
text: Classify.typeIcon(root.derived) + " " + Classify.typeLabel(root.derived)
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
}
Rectangle {
id: divider
anchors.top: headerRow.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.space(10)
height: 1
color: root.lineColor
}
// ------------------------------------------------------------- body
// text-ish body (text / code / json / html / email / number)
Flickable {
id: textBody
anchors.top: divider.bottom
anchors.bottom: metaRow.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.space(10)
anchors.bottomMargin: Style.space(10)
visible: root.bodyText !== ""
clip: true
contentWidth: width
contentHeight: bodyEdit.implicitHeight
boundsBehavior: Flickable.StopAtBounds
WheelHandler {
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
onWheel: function(ev) {
if (ev.angleDelta.y < 0) textBody.flick(0, -240)
else textBody.flick(0, 240)
ev.accepted = true
}
}
TextEdit {
id: bodyEdit
width: parent.width
readOnly: true
activeFocusOnPress: false
text: root.bodyText
textFormat: TextEdit.PlainText
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.body
wrapMode: TextEdit.Wrap
selectionColor: Util.alpha(Color.accent, 0.4)
}
}
// color body
Column {
anchors.top: divider.bottom
anchors.bottom: metaRow.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.space(10)
anchors.bottomMargin: Style.space(10)
visible: root.derived === "color"
spacing: Style.space(14)
Rectangle {
width: Math.min(Style.space(240), parent.width)
height: Style.space(120)
radius: Style.cornerRadius
color: root.hexRe.test(root.rawSafe()) ? root.rawSafe() : "transparent"
border.width: 1
border.color: root.lineColor
}
Text {
text: root.rawSafe()
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.heading
font.bold: true
}
Text {
visible: root.rgbLine().length > 0
text: root.rgbLine()
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.body
}
Text {
visible: root.hslLine().length > 0
text: root.hslLine()
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.body
}
}
// link body
Column {
anchors.top: divider.bottom
anchors.bottom: metaRow.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.space(10)
anchors.bottomMargin: Style.space(10)
visible: root.derived === "link"
spacing: Style.space(10)
Text {
text: Classify.urlDomain(root.entry ? String(root.entry.text || "") : "")
color: Color.accent
font.family: root.font_
font.pixelSize: Style.font.display
font.bold: true
elide: Text.ElideRight
width: parent.width
maximumLineCount: 1
}
Text {
text: root.entry ? Classify.firstLine(String(root.entry.text || ""), 400) : ""
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.body
wrapMode: Text.WrapAnywhere
width: parent.width
}
Text {
text: "Ctrl+O opens this link in your browser"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
// image body
Column {
anchors.top: divider.bottom
anchors.bottom: metaRow.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.space(10)
anchors.bottomMargin: Style.space(10)
visible: root.derived === "image"
spacing: Style.space(8)
Item {
width: parent.width
height: parent.height - infoLabel.height - Style.space(12)
Image {
id: img
anchors.centerIn: parent
width: parent.width
height: parent.height
source: root.entry && root.entry.path ? Util.fileUrl(root.entry.path) : ""
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
cache: false
}
Rectangle {
anchors.fill: img
color: "transparent"
border.width: 1
border.color: root.lineColor
radius: Style.space(4)
visible: img.status === Image.Ready
}
Text {
anchors.centerIn: parent
visible: img.status === Image.Error || img.status === Image.Null
text: "Preview unavailable"
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.body
}
}
Text {
id: infoLabel
text: {
if (!root.entry) return ""
var e = root.entry
var dims = e.w && e.h ? e.w + " × " + e.h + " px · " : ""
return dims + (e.mime || "image") + (e.bytes ? " · " + Classify.formatBytes(e.bytes) : "")
}
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
// files body
Column {
anchors.top: divider.bottom
anchors.bottom: metaRow.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: Style.space(10)
anchors.bottomMargin: Style.space(10)
visible: root.derived === "files"
spacing: Style.space(6)
clip: true
Repeater {
model: {
if (!root.entry || root.entry.type !== "files") return []
return (root.entry.paths || []).slice(0, 10)
}
delegate: Row {
required property var modelData
width: parent ? parent.width : 0
spacing: Style.space(8)
Text {
text: "󰈚"
color: Color.accent
font.family: root.font_
font.pixelSize: Style.font.body
}
Text {
text: Classify.fileBase(modelData)
color: root.fg
font.family: root.font_
font.pixelSize: Style.font.body
elide: Text.ElideMiddle
width: parent.width - Style.space(24)
maximumLineCount: 1
}
}
}
Text {
visible: root.entry && root.entry.paths && root.entry.paths.length > 10
text: root.entry && root.entry.paths ? "… and " + (root.entry.paths.length - 10) + " more" : ""
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
Text {
text: root.entry && root.entry.paths && root.entry.paths.length > 0
? "in " + Classify.fileDir(root.entry.paths[0])
: ""
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
elide: Text.ElideMiddle
width: parent.width
maximumLineCount: 1
}
}
// ------------------------------------------------------------- meta chips
Row {
id: metaRow
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
spacing: Style.space(6)
Rectangle {
width: Style.space(20)
height: Style.space(20)
radius: height / 2
color: root.chipBg
anchors.verticalCenter: parent.verticalCenter
Text {
anchors.centerIn: parent
text: Classify.typeIcon(root.derived)
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
Rectangle {
radius: height / 2
color: root.chipBg
width: typeChipText.implicitWidth + Style.space(14)
height: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
Text {
id: typeChipText
anchors.centerIn: parent
text: Classify.typeLabel(root.derived)
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
Repeater {
model: root.metaChips()
delegate: Rectangle {
required property var modelData
property string chipText: String(modelData)
radius: height / 2
color: root.chipBg
width: chipLabel.implicitWidth + Style.space(14)
height: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
Text {
id: chipLabel
anchors.centerIn: parent
text: parent.chipText
color: root.mutedFg
font.family: root.font_
font.pixelSize: Style.font.caption
}
}
}
}
readonly property var hexRe: /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i
}
+201
View File
@@ -0,0 +1,201 @@
.pragma library
// History store operations: normalization, dedup, pins, pruning.
// Pure ES5 — runs in QML (.pragma library) and node (tests).
var DEFAULT_LIMIT = 1500
// FNV-1a 32-bit, hex. Used for stable entry ids.
function hash32(s) {
var h = 0x811c9dc5
s = String(s || "")
for (var i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0
}
return ("0000000" + h.toString(16)).slice(-8)
}
function entryKey(entry) {
if (!entry) return ""
if (entry.type === "image") return "image:" + String(entry.path || "")
if (entry.type === "files") return "files:" + (entry.paths || []).join("\n")
return "text:" + String(entry.text || "")
}
function entryId(entry) {
if (!entry) return ""
if (entry.type === "image") return "img:" + hash32(String(entry.path || ""))
if (entry.type === "files") return "files:" + hash32((entry.paths || []).join("\n"))
return "txt:" + hash32(String(entry.text || "")) + ":" + String(entry.text || "").length
}
// Validate + fill defaults. Returns null for entries that should be dropped.
function normalize(value, now) {
if (!value || typeof value !== "object") return null
var type = String(value.type || "")
var out = null
if (type === "text") {
var text = String(value.text || "")
if (!text.trim()) return null
out = { type: "text", text: text }
} else if (type === "image") {
var path = String(value.path || "")
if (!path) return null
out = {
type: "image",
path: path,
mime: String(value.mime || "image/png")
}
if (value.w) out.w = Number(value.w)
if (value.h) out.h = Number(value.h)
} else if (type === "files") {
var paths = Array.isArray(value.paths) ? value.paths.filter(function(p) { return !!p }) : []
if (paths.length === 0) return null
out = { type: "files", paths: paths }
} else {
return null
}
var ts = Number(value.ts)
out.ts = isFinite(ts) && ts > 0 ? Math.floor(ts) : (now || 0)
var bytes = Number(value.bytes)
out.bytes = isFinite(bytes) && bytes >= 0 ? Math.floor(bytes) : 0
out.app = String(value.app || "")
out.id = String(value.id || entryId(out))
var uses = Number(value.uses)
out.uses = isFinite(uses) && uses > 0 ? Math.floor(uses) : 0
if (value.pinned) out.pinned = true
return out
}
function parseHistory(raw, now) {
try {
var parsed = JSON.parse(String(raw || "[]"))
var next = []
if (!Array.isArray(parsed)) return next
for (var i = 0; i < parsed.length; i++) {
var e = normalize(parsed[i], now)
if (e) next.push(e)
}
return next
} catch (err) {
return []
}
}
// Add (or bump) an entry; newest first. Keeps pin state and usage count of
// the existing copy when the same content is copied again.
function addEntry(history, entry, limit, now) {
var normalized = normalize(entry, now)
var max = limit === undefined || limit === null ? DEFAULT_LIMIT : Number(limit)
if (isNaN(max) || max < 1) max = DEFAULT_LIMIT
if (!normalized) return Array.isArray(history) ? history.slice(0, max) : []
var key = entryKey(normalized)
var next = [normalized]
var values = Array.isArray(history) ? history : []
for (var i = 0; i < values.length && next.length < max; i++) {
var existing = normalize(values[i], now)
if (!existing) continue
if (entryKey(existing) === key) {
// Re-copy of existing content: keep its pin state and usage count.
if (existing.pinned) normalized.pinned = true
if (existing.uses > 0) normalized.uses = existing.uses
continue
}
next.push(existing)
}
return next
}
function findById(history, id) {
var values = Array.isArray(history) ? history : []
for (var i = 0; i < values.length; i++) {
if (values[i] && values[i].id === id) return values[i]
}
return null
}
function removeById(history, id) {
var values = Array.isArray(history) ? history : []
var next = []
for (var i = 0; i < values.length; i++) {
if (values[i] && values[i].id === id) continue
next.push(values[i])
}
return next
}
function togglePin(history, id) {
var values = Array.isArray(history) ? history : []
var entry = findById(values, id)
if (!entry) return values
if (entry.pinned) delete entry.pinned
else entry.pinned = true
return values
}
function touch(history, id, now) {
var values = Array.isArray(history) ? history : []
var entry = findById(values, id)
if (!entry) return values
entry.uses = (Number(entry.uses) || 0) + 1
entry.ts = now || entry.ts
// Move to front like a fresh copy would.
var next = [entry]
for (var i = 0; i < values.length; i++) {
if (values[i] !== entry) next.push(values[i])
}
return next
}
// Enforce the limit. Returns { entries, droppedImagePaths } so the caller can
// garbage-collect image blobs.
function prune(history, limit) {
var values = Array.isArray(history) ? history : []
var max = limit === undefined || limit === null ? DEFAULT_LIMIT : Number(limit)
if (isNaN(max) || max < 1) max = DEFAULT_LIMIT
if (values.length <= max) return { entries: values, droppedImagePaths: [] }
var kept = values.slice(0, max)
var dropped = values.slice(max)
var droppedImagePaths = []
for (var i = 0; i < dropped.length; i++) {
if (dropped[i] && dropped[i].type === "image" && dropped[i].path)
droppedImagePaths.push(dropped[i].path)
}
// Keep pins even beyond the limit? No — pins are ordered to the top by
// search instead; the store stays purely recency-ordered.
return { entries: kept, droppedImagePaths: droppedImagePaths }
}
// Build the search-row context Fuzzy.searchRows expects.
function buildRow(entry, derivedType, now) {
var content = ""
if (entry.type === "image") {
content = fileLabel(entry.path) + " " + String(entry.mime || "")
} else if (entry.type === "files") {
content = (entry.paths || []).join(" ")
} else {
// Cap haystack size for speed; deep content is still previewable.
content = String(entry.text || "").slice(0, 4000)
}
return {
entry: entry,
content: content,
app: String(entry.app || ""),
type: derivedType,
ts: Number(entry.ts) || 0,
pinned: !!entry.pinned,
uses: Number(entry.uses) || 0,
bytes: Number(entry.bytes) || 0
}
}
function fileLabel(p) {
var s = String(p || "")
var idx = s.lastIndexOf("/")
return idx === -1 ? s : s.slice(idx + 1)
}
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Clipboard capture for the tank.clipboard Omarchy shell plugin.
Invoked by `wl-paste --watch` (payload on stdin is ignored — we probe
ourselves so a single watcher covers every mime) or with no args to snapshot
the current clipboard. Emits exactly one JSON line per capture:
{"type":"text","text":...,"ts":...,"bytes":...,"app":...}
{"type":"image","mime":...,"path":...,"w":...,"h":...,"bytes":...,"ts":...,"app":...}
{"type":"files","paths":[...],"bytes":...,"ts":...,"app":...}
Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and
binary payloads are skipped silently.
"""
import hashlib
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.parse
STATE_DIR = os.path.join(
os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")), "omarchy"
)
IMAGE_DIR = os.path.join(STATE_DIR, "clipboard-images")
IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp", "image/tiff"]
IMAGE_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp",
"image/gif": "gif", "image/bmp": "bmp", "image/tiff": "tiff"}
TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"]
def run(args, timeout=5):
try:
r = subprocess.run(args, capture_output=True, timeout=timeout)
return r.stdout if r.returncode == 0 else None
except Exception:
return None
def list_types():
out = run(["wl-paste", "--list-types"])
if not out:
return []
return [line for line in out.decode("utf-8", "replace").splitlines() if line]
def focused_app():
out = run(["hyprctl", "activewindow", "-j"], timeout=2)
if not out:
return ""
try:
data = json.loads(out)
return str(data.get("class") or "")
except Exception:
return ""
def emit(entry):
entry["ts"] = int(time.time())
print(json.dumps(entry))
sys.stdout.flush()
def capture_image(types, app):
mime = next((m for m in IMAGE_MIMES if m in types), None)
if not mime:
return
data = run(["wl-paste", "--type", mime, "--no-newline"])
if not data:
return
os.makedirs(IMAGE_DIR, exist_ok=True)
digest = hashlib.sha256(data).hexdigest()
path = os.path.join(IMAGE_DIR, f"{digest}.{IMAGE_EXT[mime]}")
entry = {"type": "image", "mime": mime, "path": path, "bytes": len(data), "app": app}
# Dimensions via Pillow when available; harmless without it.
try:
from PIL import Image # noqa: PLC0415
import io # noqa: PLC0415
with Image.open(io.BytesIO(data)) as im:
entry["w"], entry["h"] = im.size
except Exception:
pass
if not os.path.exists(path):
fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR)
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
return
emit(entry)
def decode_text(data):
"""Best-effort text decode; returns None for binary-looking payloads."""
for enc in ("utf-8", "utf-16"):
try:
text = data.decode(enc)
break
except (UnicodeDecodeError, UnicodeError):
continue
else:
return None
# Reject payloads that still look binary after decoding.
if "\x00" in text:
return None
if text:
nul_control = sum(1 for c in text if ord(c) < 32 and c not in "\n\r\t")
if nul_control / max(1, len(text)) > 0.05:
return None
return text
def capture_uri_list(types, app):
if "text/uri-list" not in types:
return False
data = run(["wl-paste", "--type", "text/uri-list", "--no-newline"])
if not data:
return False
text = decode_text(data)
if text is None:
return False
uris = [u.strip() for u in text.splitlines() if u.strip() and not u.startswith("#")]
if not uris:
return False
paths = []
for uri in uris:
if uri.startswith("file://"):
paths.append(urllib.parse.unquote(urllib.parse.urlparse(uri).path))
else:
return False # remote URI → keep it as plain text below
if not paths:
return False
emit({"type": "files", "paths": paths, "bytes": len(data), "app": app})
return True
def capture_text(types, app):
mime = next((m for m in TEXT_TYPES if m in types), None)
if not mime:
return
data = run(["wl-paste", "--type", mime, "--no-newline"])
if not data:
return
text = decode_text(data)
if text is None or not text.strip():
return
emit({"type": "text", "text": text, "bytes": len(data), "app": app})
def main():
sys.stdin.close() # watcher payload unused; we probe ourselves
types = list_types()
if not types:
return
if "x-kde-passwordManagerHint" in types:
return
if os.environ.get("CLIPBOARD_STATE", "") == "sensitive":
return
app = focused_app()
if capture_image(types, app):
return
if capture_uri_list(types, app):
return
capture_text(types, app)
if __name__ == "__main__":
main()
+18
View File
@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"id": "tank.clipboard",
"name": "Clipboard History",
"version": "1.0.0",
"author": "tank",
"description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Clipboard.qml"
},
"omarchy": {
"clonedFrom": "omarchy.clipboard"
}
}
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# Open a history entry from the tank.clipboard store with the right app.
# Usage: open-entry.sh <entry-id>
set -euo pipefail
STATE="$HOME/.local/state/omarchy/clipboard-history-rich.json"
ID="${1:-}"
[[ -n $ID && -r $STATE ]] || exit 0
entry=$(jq -c --arg id "$ID" '[.[] | select(.id == $id)][0]' "$STATE") || exit 0
[[ $entry != "null" && -n $entry ]] || exit 0
open_text() {
local text=$1 url=""
url=$(grep -Eom1 'https?://[^[:space:]"<>]+' <<<"$text" || true)
if [[ -z $url ]]; then
local www
www=$(grep -Eom1 'www\.[^[:space:]]+' <<<"$text" || true)
[[ -n $www ]] && url="https://$www"
fi
if [[ -z $url && $text =~ ^[[:space:]]*([[:alnum:]][[:alnum:].-]+\.[[:alpha:]]{2,})(/[^[:space:]]*)?[[:space:]]*$ ]]; then
url="https://${BASH_REMATCH[1]}${BASH_REMATCH[2]}"
fi
if [[ -n $url ]]; then
exec omarchy-launch-browser "$url"
fi
local dir file
dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/clipboard-open"
mkdir -p "$dir"
file=$(mktemp --tmpdir="$dir" clipboard.XXXXXX.txt)
printf '%s' "$text" >"$file"
exec omarchy-launch-editor "$file"
}
case $(jq -r '.type' <<<"$entry") in
image)
path=$(jq -r '.path' <<<"$entry")
[[ -r $path ]] || exit 0
if command -v tensaku-edit >/dev/null 2>&1; then
exec tensaku-edit "$path"
fi
exec xdg-open "$path"
;;
files)
first=$(jq -r '.paths[0]' <<<"$entry")
[[ -n $first ]] || exit 0
exec xdg-open "$first"
;;
text)
open_text "$(jq -j '.text' <<<"$entry")"
;;
esac
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# Paste (or just copy) a history entry from the tank.clipboard store.
# Usage: paste-entry.sh <entry-id> [--copy-only]
set -euo pipefail
STATE="$HOME/.local/state/omarchy/clipboard-history-rich.json"
ID="${1:-}"
MODE="${2:-}"
[[ -n $ID && -r $STATE ]] || exit 0
entry=$(jq -c --arg id "$ID" '[.[] | select(.id == $id)][0]' "$STATE") || exit 0
[[ $entry != "null" && -n $entry ]] || exit 0
type=$(jq -r '.type' <<<"$entry")
case "$type" in
image)
path=$(jq -r '.path' <<<"$entry")
mime=$(jq -r '.mime // "image/png"' <<<"$entry")
[[ -r $path ]] || exit 0
wl-copy --type "$mime" <"$path"
;;
files)
while IFS= read -r p; do
printf 'file://%s\n' "$p"
done < <(jq -r '.paths[]' <<<"$entry") | wl-copy --type text/uri-list
;;
*)
jq -j '.text' <<<"$entry" | wl-copy
;;
esac
[[ $MODE == "--copy-only" ]] && exit 0
# Give the layer surface a moment to close and focus to fall back to the
# previously-focused window, then paste via the universal Shift+Insert.
sleep 0.15
wtype -M shift -k Insert -m shift 2>/dev/null || true
+21
View File
@@ -0,0 +1,21 @@
// Test harness: loads .pragma library files (plain ES5, no imports) into a
// fresh vm context so the same files work in QML and under node.
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import path from "node:path"
import vm from "node:vm"
const testsDir = path.dirname(fileURLToPath(import.meta.url))
const pluginDir = path.join(testsDir, "..", "plugin")
export function loadLib(name) {
let src = readFileSync(path.join(pluginDir, name), "utf8")
// QML's ".pragma library" directive is not valid JS outside QML.
src = src.replace(/^\.pragma\s+library\s*$/m, "")
const sandbox = {}
vm.createContext(sandbox)
vm.runInContext(src, sandbox)
return sandbox
}
export const here = pluginDir
Executable
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# Run all plugin logic tests. Requires node.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")/.."
exec node --test 'tests/*.mjs'
+116
View File
@@ -0,0 +1,116 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { loadLib } from "./harness.mjs"
const Classify = loadLib("Classify.js")
test("deriveType image/files pass through", () => {
assert.equal(Classify.deriveType({ type: "image", path: "/tmp/a.png" }), "image")
assert.equal(Classify.deriveType({ type: "files", paths: ["/tmp/a"] }), "files")
})
test("deriveType colors", () => {
assert.equal(Classify.deriveType({ type: "text", text: "#ff0000" }), "color")
assert.equal(Classify.deriveType({ type: "text", text: "#f00" }), "color")
assert.equal(Classify.deriveType({ type: "text", text: "#ff0000cc" }), "color")
assert.equal(Classify.deriveType({ type: "text", text: "rgb(12, 34, 56)" }), "color")
assert.equal(Classify.deriveType({ type: "text", text: "rgba(1,2,3,0.5)" }), "color")
assert.equal(Classify.deriveType({ type: "text", text: "hsl(120, 50%, 50%)" }), "color")
assert.equal(Classify.deriveType({ type: "text", text: "#gggggg" }), "text")
})
test("deriveType link / email / number", () => {
assert.equal(Classify.deriveType({ type: "text", text: "https://example.com/x?y=1" }), "link")
assert.equal(Classify.deriveType({ type: "text", text: "www.example.com" }), "link")
assert.equal(Classify.deriveType({ type: "text", text: "example.com" }), "link")
assert.equal(Classify.deriveType({ type: "text", text: "foo@bar.io" }), "email")
assert.equal(Classify.deriveType({ type: "text", text: "42" }), "number")
assert.equal(Classify.deriveType({ type: "text", text: "-3.14" }), "number")
assert.equal(Classify.deriveType({ type: "text", text: "1,234,567" }), "number")
})
test("deriveType json / code / html", () => {
assert.equal(Classify.deriveType({ type: "text", text: '{"a": [1, 2, 3]}' }), "json")
assert.equal(Classify.deriveType({ type: "text", text: "[1, 2, 3]" }), "json")
assert.equal(Classify.deriveType({ type: "text", text: "<html><body>hi</body></html>" }), "html")
assert.equal(Classify.deriveType({ type: "text", text: "<div class=\"x\">y</div>" }), "html")
const code = "def main():\n return print('hello world')\n\nif __name__ == '__main__':\n main()"
assert.equal(Classify.deriveType({ type: "text", text: code }), "code")
assert.equal(Classify.deriveType({ type: "text", text: "just a normal sentence, nothing special" }), "text")
})
test("prettyJson", () => {
assert.equal(Classify.prettyJson('{"a":1}'), '{\n "a": 1\n}')
assert.equal(Classify.prettyJson("not json"), "")
assert.equal(Classify.prettyJson('{"a":1}', 3), "")
})
test("stripHtml", () => {
assert.equal(Classify.stripHtml("<p>Hello &amp; <b>world</b></p>"), "Hello & world")
assert.equal(Classify.stripHtml("<style>x{}</style><p>hi</p>"), "hi")
})
test("urlDomain", () => {
assert.equal(Classify.urlDomain("https://www.example.com/a/b"), "example.com")
assert.equal(Classify.urlDomain("example.com:8080/x"), "example.com:8080")
})
test("prettyApp", () => {
assert.equal(Classify.prettyApp("com.mitchellh.ghostty"), "Ghostty")
assert.equal(Classify.prettyApp("firefox"), "Firefox")
assert.equal(Classify.prettyApp("Code"), "VS Code")
assert.equal(Classify.prettyApp("org.gnome.Nautilus"), "Files")
assert.equal(Classify.prettyApp("someapp-2.1.0"), "Someapp")
assert.equal(Classify.prettyApp(""), "")
})
test("formatBytes", () => {
assert.equal(Classify.formatBytes(512), "512 B")
assert.equal(Classify.formatBytes(2048), "2.0 KB")
assert.equal(Classify.formatBytes(1048576), "1.0 MB")
assert.equal(Classify.formatBytes(NaN), "")
})
test("formatAge", () => {
const now = 1000000000
assert.equal(Classify.formatAge(now - 10, now), "just now")
assert.equal(Classify.formatAge(now - 120, now), "2m ago")
assert.equal(Classify.formatAge(now - 7200, now), "2h ago")
assert.equal(Classify.formatAge(now - 86400 * 2, now), "2d ago")
assert.ok(Classify.formatAge(now - 86400 * 30, now).length > 0)
})
test("textStats", () => {
const s = Classify.textStats("one two three\nfour")
assert.equal(s.words, 4)
assert.equal(s.lines, 2)
assert.equal(s.chars, 18)
})
test("typeIcon/label cover all types", () => {
for (const t of ["image", "files", "color", "link", "email", "json", "code", "html", "number", "text"]) {
assert.ok(Classify.typeIcon(t).length > 0, t)
assert.ok(Classify.typeLabel(t).length > 0, t)
}
})
test("colorToRgb hex forms", () => {
assert.deepEqual([...Classify.colorToRgb("#ff0000")], [255, 0, 0])
assert.deepEqual([...Classify.colorToRgb("#f00")], [255, 0, 0])
assert.deepEqual([...Classify.colorToRgb("#ff0000cc")], [255, 0, 0])
assert.deepEqual([...Classify.colorToRgb("rgb(12, 34, 56)")], [12, 34, 56])
})
test("colorToHsl conversions", () => {
const hsl = Classify.colorToHsl("#ff0000")
assert.deepEqual([...hsl], [0, 100, 50])
const rgb = Classify.colorToRgb("hsl(120, 100%, 50%)")
assert.deepEqual([...rgb], [0, 255, 0])
const back = Classify.rgbToHsl(...Classify.hslToRgb(240, 100, 50))
assert.deepEqual([...back], [240, 100, 50])
})
test("colorToRgb rejects junk", () => {
assert.equal(Classify.colorToRgb("#gg"), null)
assert.equal(Classify.colorToRgb("hello"), null)
})
+162
View File
@@ -0,0 +1,162 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { loadLib } from "./harness.mjs"
const Fuzzy = loadLib("Fuzzy.js")
test("parseDuration", () => {
assert.equal(Fuzzy.parseDuration("10m"), 600)
assert.equal(Fuzzy.parseDuration("2h"), 7200)
assert.equal(Fuzzy.parseDuration("3d"), 259200)
assert.equal(Fuzzy.parseDuration("1w"), 604800)
assert.equal(Fuzzy.parseDuration("45s"), 45)
assert.ok(isNaN(Fuzzy.parseDuration("abc")))
assert.ok(isNaN(Fuzzy.parseDuration("10")))
})
test("parseQuery basics", () => {
const q = Fuzzy.parseQuery("type:image app:firefox today")
assert.equal(q.type, "image")
assert.equal(q.app, "firefox")
assert.equal(q.maxAge, 86400)
assert.equal(q.terms.length, 0)
})
test("parseQuery type prefixes and aliases", () => {
assert.equal(Fuzzy.parseQuery("type:img").type, "image")
assert.equal(Fuzzy.parseQuery("type:url").type, "link")
assert.equal(Fuzzy.parseQuery("type:colour").type, "color")
assert.equal(Fuzzy.parseQuery("type:js").type, "json")
})
test("parseQuery age comparisons", () => {
const q = Fuzzy.parseQuery("<2h hello >30s")
assert.equal(q.maxAge, 7200)
assert.equal(q.minAge, 30)
assert.equal(q.terms.length, 1); assert.equal(q.terms[0], "hello")
})
test("parseQuery pinned", () => {
assert.equal(Fuzzy.parseQuery("pin").pinned, true)
assert.equal(Fuzzy.parseQuery("is:pinned foo").pinned, true)
assert.equal(Fuzzy.parseQuery("foo").pinned, false)
})
test("fuzzyMatch subsequence", () => {
assert.ok(Fuzzy.fuzzyMatch("hlo", "hello world"))
assert.equal(Fuzzy.fuzzyMatch("zzz", "hello world"), null)
assert.ok(Fuzzy.fuzzyMatch("", ""))
const m = Fuzzy.fuzzyMatch("", "anything")
assert.equal(m.positions.length, 0)
})
test("fuzzyMatch scores prefix above scattered", () => {
const prefix = Fuzzy.fuzzyMatch("fun", "function foo()")
const scattered = Fuzzy.fuzzyMatch("fun", "a x f a u a n a")
assert.ok(prefix.score > scattered.score)
})
test("fuzzyMatch consecutive beats gapped", () => {
const consec = Fuzzy.fuzzyMatch("abc", "xxabcxx")
const gapped = Fuzzy.fuzzyMatch("abc", "axbxc")
assert.ok(consec.score > gapped.score)
})
test("fuzzyMatch prefers shorter haystack on substring ties", () => {
const short = Fuzzy.fuzzyMatch("error", "error handling")
const long = Fuzzy.fuzzyMatch("error", "an unexpected error occurred while processing the request payload")
assert.ok(short.score > long.score)
})
test("searchRows empty query sorts by recency", () => {
const now = 1000000
const rows = [
{ entry: {}, content: "old", app: "", type: "text", ts: now - 500000, pinned: false, uses: 0, bytes: 3 },
{ entry: {}, content: "new", app: "", type: "text", ts: now - 10, pinned: false, uses: 0, bytes: 3 }
]
const res = Fuzzy.searchRows(rows, "", now, 10)
assert.equal(res.length, 2)
assert.equal(res[0].row.content, "new")
})
test("searchRows fuzzy ranks match above recency when strong", () => {
const now = 1000000
const rows = [
{ entry: {}, content: "completely unrelated old entry", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 5 },
{ entry: {}, content: "function makeAwesome() { return true }", app: "", type: "code", ts: now - 86400 * 30, pinned: false, uses: 0, bytes: 5 }
]
const res = Fuzzy.searchRows(rows, "awesome", now, 10)
assert.ok(res[0].row.content.toLowerCase().includes("awesome"))
})
test("searchRows type filter", () => {
const now = 1000000
const rows = [
{ entry: {}, content: "https://example.com", app: "", type: "link", ts: now, pinned: false, uses: 0, bytes: 3 },
{ entry: {}, content: "plain text", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
]
const res = Fuzzy.searchRows(rows, "type:link", now, 10)
assert.equal(res.length, 1)
assert.equal(res[0].row.type, "link")
})
test("searchRows multi-term AND", () => {
const now = 1000000
const rows = [
{ entry: {}, content: "deploy staging now", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 },
{ entry: {}, content: "deploy production", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
]
const res = Fuzzy.searchRows(rows, "deploy staging", now, 10)
assert.equal(res.length, 1)
assert.equal(res[0].row.content, "deploy staging now")
})
test("searchRows matches app field", () => {
const now = 1000000
const rows = [
{ entry: {}, content: "some text", app: "firefox", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 },
{ entry: {}, content: "other text", app: "ghostty", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
]
const res = Fuzzy.searchRows(rows, "fire", now, 10)
assert.equal(res.length, 1)
assert.equal(res[0].row.app, "firefox")
})
test("searchRows pinned boost wins", () => {
const now = 1000000
const rows = [
{ entry: { pinned: true }, content: "zzz pinned weak", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 },
{ entry: {}, content: "fresh", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
]
const res = Fuzzy.searchRows(rows, "", now, 10)
assert.equal(res[0].row.content, "zzz pinned weak")
})
test("searchRows respects limit", () => {
const now = 1000000
const rows = []
for (let i = 0; i < 50; i++)
rows.push({ entry: {}, content: "item " + i, app: "", type: "text", ts: now - i, pinned: false, uses: 0, bytes: 3 })
const res = Fuzzy.searchRows(rows, "", now, 10)
assert.equal(res.length, 10)
})
test("highlightFirstLine escapes and wraps", () => {
const m = Fuzzy.fuzzyMatch("ab", "a <b> abc")
const html = Fuzzy.highlightFirstLine("a <b> abc", m.positions, "<b>", "</b>")
assert.ok(!html.includes("<b> <"))
assert.ok(html.includes("&lt;b&gt;"))
assert.ok(html.includes("<b>a</b>"))
})
test("highlightFirstLine multiline only first line", () => {
const text = "alpha\nbeta\nalpha"
const m = Fuzzy.fuzzyMatch("alpha", text)
const html = Fuzzy.highlightFirstLine(text, m.positions, "<b>", "</b>")
assert.ok(!html.includes("\n"))
})
test("parseQuery yesterday bounds", () => {
const q = Fuzzy.parseQuery("yesterday")
assert.equal(q.maxAge, 172800)
})
+99
View File
@@ -0,0 +1,99 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { loadLib } from "./harness.mjs"
const Store = loadLib("Store.js")
test("hash32 stable and hex", () => {
assert.equal(Store.hash32("hello"), Store.hash32("hello"))
assert.match(Store.hash32("hello"), /^[0-9a-f]{8}$/)
assert.notEqual(Store.hash32("hello"), Store.hash32("hellp"))
})
test("normalize text", () => {
const e = Store.normalize({ type: "text", text: " hi ", ts: 100, app: "ff", bytes: 4 })
assert.equal(e.text, " hi ")
assert.equal(e.ts, 100)
assert.equal(e.uses, 0)
assert.ok(e.id.startsWith("txt:"))
assert.equal(Store.normalize({ type: "text", text: " " }), null)
assert.equal(Store.normalize({ type: "bogus" }), null)
})
test("normalize image and files", () => {
const img = Store.normalize({ type: "image", path: "/x/a.png", mime: "image/png", w: 10, h: 20 })
assert.equal(img.mime, "image/png")
assert.equal(img.w, 10)
assert.equal(Store.normalize({ type: "image", path: "" }), null)
const files = Store.normalize({ type: "files", paths: ["/a", "", "/b"] })
assert.equal(files.paths.length, 2); assert.equal(files.paths[0], "/a"); assert.equal(files.paths[1], "/b")
assert.equal(Store.normalize({ type: "files", paths: [] }), null)
})
test("addEntry dedupes and bumps to front, keeps pin/uses", () => {
let h = []
h = Store.addEntry(h, { type: "text", text: "a", ts: 1 }, 10)
h = Store.addEntry(h, { type: "text", text: "b", ts: 2 }, 10)
assert.equal(h.length, 2)
const a = Store.findById(h, h.find(e => e.text === "a").id)
a.pinned = true
a.uses = 3
h = Store.addEntry(h, { type: "text", text: "a", ts: 5 }, 10)
assert.equal(h.length, 2)
assert.equal(h[0].text, "a")
assert.equal(h[0].ts, 5)
assert.equal(h[0].pinned, true)
assert.equal(h[0].uses, 3)
})
test("addEntry respects limit", () => {
let h = []
for (let i = 0; i < 20; i++) h = Store.addEntry(h, { type: "text", text: "t" + i, ts: i }, 5)
assert.equal(h.length, 5)
assert.equal(h[0].text, "t19")
})
test("removeById / findById / togglePin / touch", () => {
let h = Store.addEntry([], { type: "text", text: "x", ts: 1 }, 10)
const id = h[0].id
assert.equal(Store.findById(h, id).text, "x")
h = Store.togglePin(h, id)
assert.equal(h[0].pinned, true)
h = Store.togglePin(h, id)
assert.equal(h[0].pinned, undefined)
h = Store.touch(h, id, 999)
assert.equal(h[0].uses, 1)
assert.equal(h[0].ts, 999)
h = Store.removeById(h, id)
assert.equal(h.length, 0)
assert.equal(Store.findById(h, id), null)
})
test("prune reports dropped images", () => {
let h = []
for (let i = 0; i < 10; i++)
h.push({ id: "i" + i, type: i >= 6 ? "image" : "text", path: "/tmp/img" + i + ".png", text: "t" + i, ts: i, bytes: 1, app: "", uses: 0 })
const r = Store.prune(h, 5)
assert.equal(r.entries.length, 5)
assert.equal(r.droppedImagePaths.length, 4)
assert.deepEqual([...r.droppedImagePaths].join(","), ["/tmp/img6.png","/tmp/img7.png","/tmp/img8.png","/tmp/img9.png"].join(","))
})
test("parseHistory tolerates garbage", () => {
assert.equal(Store.parseHistory("not json").length, 0)
assert.equal(Store.parseHistory('{"a":1}').length, 0)
assert.equal(Store.parseHistory("[]").length, 0)
const h = Store.parseHistory('[{"type":"text","text":"ok"},{"type":"bogus"}]')
assert.equal(h.length, 1)
})
test("buildRow caps content haystack", () => {
const row = Store.buildRow({ type: "text", text: "x".repeat(9000), ts: 1, bytes: 9000, app: "a", uses: 0 }, "text", 1)
assert.equal(row.content.length, 4000)
})
test("entryId distinct for distinct content", () => {
const a = Store.entryId({ type: "text", text: "one" })
const b = Store.entryId({ type: "text", text: "two" })
assert.notEqual(a, b)
})