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
+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