Clipboard history picker: Omarchy shell plugin (tank.clipboard) with fuzzy search, rich previews, capture daemon
This commit is contained in:
@@ -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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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)
|
||||
}
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
Executable
+180
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+53
@@ -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
|
||||
Executable
+39
@@ -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
|
||||
Reference in New Issue
Block a user