Compare commits
10 Commits
99d9a65283
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a979a6b143 | |||
| f4fc81eefe | |||
| 4522fdbce1 | |||
| b1ebdb83e6 | |||
| b1d98971c9 | |||
| 8127ae4bba | |||
| 4662c8002d | |||
| 4f562ab215 | |||
| 2c999201db | |||
| 1d7a9d6cde |
+24
-14
@@ -10,7 +10,7 @@ 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.
|
||||
// capture metadata (app, size, dims, pins, usage) and full-text fuzzy filtering.
|
||||
Item {
|
||||
id: root
|
||||
|
||||
@@ -83,20 +83,8 @@ Item {
|
||||
else root.open()
|
||||
}
|
||||
|
||||
// Direct target for explicit bindings and pause/resume automation.
|
||||
IpcHandler {
|
||||
target: "alanfortlink.clipboard"
|
||||
function open(): void { root.open() }
|
||||
function close(): void { root.close() }
|
||||
function show(): void { root.open() }
|
||||
function hide(): void { root.close() }
|
||||
function toggle(): void { root.toggle() }
|
||||
function pause(payloadJson: string): string { return root.pause(payloadJson) }
|
||||
function isPaused(): string { return root.isPaused() }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ pause
|
||||
// Toggle via IPC: omarchy-shell alanfortlink.clipboard pause '{"paused":true}'
|
||||
// Toggle via IPC: omarchy-shell shell call alanfortlink.clipboard pause '{"paused":true}'
|
||||
// (or {"paused":false}, or {"paused":"toggle"}), and Ctrl+Space in the picker.
|
||||
function setPaused(next) {
|
||||
root.paused = !!next
|
||||
@@ -280,6 +268,22 @@ Item {
|
||||
Quickshell.execDetached([root.pluginDir + "/open-entry.sh", result.row.entry.id])
|
||||
}
|
||||
|
||||
// Text content of an entry as a string: the text itself, or for images the
|
||||
// recognized (OCR) text, falling back to a decoded QR payload.
|
||||
function textContent(result) {
|
||||
if (!result) return ""
|
||||
var e = result.row.entry
|
||||
if (e.type === "image") return String(e.ocr || e.qr || "")
|
||||
return String(e.text || "")
|
||||
}
|
||||
|
||||
// Copy a plain string (not an entry) to the clipboard and close.
|
||||
function copyText(text) {
|
||||
if (!text) return
|
||||
root.close()
|
||||
Quickshell.execDetached(["wl-copy", "--type", "text/plain", "--", text])
|
||||
}
|
||||
|
||||
function removeIndex(index) {
|
||||
if (index < 0 || index >= root.results.length) return
|
||||
var entry = root.results[index].row.entry
|
||||
@@ -572,6 +576,9 @@ Item {
|
||||
} else if (event.key === Qt.Key_O && (event.modifiers & Qt.ControlModifier)) {
|
||||
root.openResult(root.currentResult)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_C && (event.modifiers & Qt.ControlModifier) && (event.modifiers & Qt.ShiftModifier)) {
|
||||
root.copyText(root.textContent(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)
|
||||
@@ -939,6 +946,8 @@ Item {
|
||||
anchors.leftMargin: root.listWidth + Style.space(14)
|
||||
result: root.currentResult
|
||||
openAction: function() { root.openResult(root.currentResult) }
|
||||
copyTextAction: function(text) { root.copyText(text) }
|
||||
pluginDir: root.pluginDir
|
||||
visible: root.currentResult !== null
|
||||
}
|
||||
|
||||
@@ -984,6 +993,7 @@ Item {
|
||||
{ keys: "enter", hint: "paste" },
|
||||
{ keys: "shift+enter", hint: "copy" },
|
||||
{ keys: "ctrl+o", hint: "open" },
|
||||
{ keys: "ctrl+shift+c", hint: "copy text" },
|
||||
{ keys: "tab", hint: "pin" },
|
||||
{ keys: "ctrl+=", hint: "pause" },
|
||||
{ keys: "del", hint: "remove" },
|
||||
|
||||
@@ -153,7 +153,7 @@ function fuzzyMatch(needle, haystack) {
|
||||
score += 8
|
||||
}
|
||||
|
||||
// Gap penalty (light — we rank mostly by bonuses and recency).
|
||||
// Gap penalty (light).
|
||||
if (k > 0) {
|
||||
var gap = pos - positions[k - 1] - 1
|
||||
if (gap > 0) score -= Math.min(6, gap)
|
||||
@@ -227,7 +227,7 @@ function recencyBonus(ts, now) {
|
||||
}
|
||||
|
||||
// rows: [{ entry, content, app, type, ts, pinned, uses, bytes }]
|
||||
// Returns up to `limit` rows sorted by relevance: [{ row, score, positions, type }]
|
||||
// Returns up to `limit` matching rows, newest first: [{ row, score, positions, type }]
|
||||
function searchRows(rows, queryStr, now, limit) {
|
||||
var parsed = parseQuery(queryStr)
|
||||
var results = []
|
||||
@@ -255,11 +255,9 @@ function searchRows(rows, queryStr, now, limit) {
|
||||
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)
|
||||
|
||||
// Search only filters. The list is always ordered newest-first; no
|
||||
// relevance, pin, or usage ranking.
|
||||
var score = 0
|
||||
results.push({
|
||||
row: row,
|
||||
score: score,
|
||||
@@ -268,7 +266,6 @@ function searchRows(rows, queryStr, now, limit) {
|
||||
}
|
||||
|
||||
results.sort(function(a, b) {
|
||||
if (b.score !== a.score) return b.score - a.score
|
||||
return (b.row.ts || 0) - (a.row.ts || 0)
|
||||
})
|
||||
|
||||
|
||||
+283
-61
@@ -1,4 +1,5 @@
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "Classify.js" as Classify
|
||||
@@ -14,6 +15,57 @@ Item {
|
||||
property string derived: result ? result.row.type : ""
|
||||
// Wired by the picker: opens the current result (browser for links).
|
||||
property var openAction: function() {}
|
||||
// Wired by the picker: copies a string (OCR text, QR payload) to the clipboard.
|
||||
property var copyTextAction: function(text) {}
|
||||
property string pluginDir: ""
|
||||
property int fileIndex: 0
|
||||
readonly property var filePaths: entry && entry.type === "files" ? entry.paths || [] : []
|
||||
readonly property string selectedFile: filePaths.length ? filePaths[Math.min(fileIndex, filePaths.length - 1)] : ""
|
||||
property var fileInfo: ({})
|
||||
property int fileRequest: 0
|
||||
onFilePathsChanged: fileIndex = 0
|
||||
onSelectedFileChanged: {
|
||||
fileRequest++
|
||||
fileInfo = ({})
|
||||
fileProbe.running = false
|
||||
fileDelay.restart()
|
||||
}
|
||||
Timer {
|
||||
id: fileDelay
|
||||
interval: 120
|
||||
onTriggered: {
|
||||
if (!root.selectedFile || !root.pluginDir) return
|
||||
fileProbe.command = ["python3", root.pluginDir + "/scripts/file-preview.py", String(root.fileRequest), root.selectedFile]
|
||||
fileProbe.running = true
|
||||
}
|
||||
}
|
||||
Process {
|
||||
id: fileProbe
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
try {
|
||||
var data = JSON.parse(text)
|
||||
if (String(data.request) === String(root.fileRequest)) root.fileInfo = data.file
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
function fileDetails() {
|
||||
var f = fileInfo
|
||||
var parts = []
|
||||
if (f.kind) parts.push(f.kind)
|
||||
if (f.bytes !== undefined && f.kind !== "Folder") parts.push(Classify.formatBytes(f.bytes))
|
||||
if (f.width && f.height) parts.push(f.width + "×" + f.height)
|
||||
if (f.duration) {
|
||||
var seconds = Math.floor(f.duration)
|
||||
parts.push(Math.floor(seconds / 60) + ":" + (seconds % 60 < 10 ? "0" : "") + seconds % 60)
|
||||
}
|
||||
if (f.audio) parts.push(f.audio)
|
||||
if (f.modified) parts.push("Modified " + new Date(f.modified * 1000).toLocaleString())
|
||||
return parts.join(" · ")
|
||||
}
|
||||
|
||||
|
||||
readonly property string font_: Style.font.menuFamily
|
||||
readonly property color fg: Color.menu.text
|
||||
@@ -21,22 +73,19 @@ Item {
|
||||
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 || "")
|
||||
}
|
||||
// Textual types render in the scrollable body; every other type has its
|
||||
// own block below. Derived as bindings (not set from onResultChanged) so
|
||||
// the body text and the type can never disagree mid-update, which used to
|
||||
// leave a stale text body visible underneath an image preview.
|
||||
readonly property bool textual: derived === "text" || derived === "code"
|
||||
|| derived === "email" || derived === "number"
|
||||
|| derived === "json" || derived === "html"
|
||||
readonly property string bodyText: {
|
||||
if (!entry || !textual) return ""
|
||||
var raw = String(entry.text || "")
|
||||
if (derived === "json") return Classify.prettyJson(raw, 200000) || raw
|
||||
if (derived === "html") return Classify.stripHtml(raw) || raw
|
||||
return raw
|
||||
}
|
||||
|
||||
function rawSafe() {
|
||||
@@ -80,7 +129,7 @@ Item {
|
||||
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 (r.bytes > 0 && e.type !== "files") 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")
|
||||
@@ -197,7 +246,7 @@ Item {
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: Style.space(10)
|
||||
anchors.bottomMargin: Style.space(10)
|
||||
visible: root.bodyText !== ""
|
||||
visible: root.textual && root.bodyText !== ""
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: bodyEdit.implicitHeight
|
||||
@@ -396,6 +445,30 @@ Item {
|
||||
font.bold: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
radius: height / 2
|
||||
color: Util.alpha(Color.accent, 0.15)
|
||||
width: qrCopyLabel.implicitWidth + Style.space(16)
|
||||
height: Style.space(20)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
id: qrCopyLabel
|
||||
anchors.centerIn: parent
|
||||
text: " Copy"
|
||||
color: Color.accent
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.copyTextAction(root.entry ? String(root.entry.qr || "") : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
@@ -458,9 +531,13 @@ Item {
|
||||
id: ocrPanel
|
||||
visible: !!(root.entry && root.entry.ocr)
|
||||
width: parent.width
|
||||
height: Math.min(Style.space(150), ocrContent.height + Style.space(12))
|
||||
// Never taller than the cap: the body scrolls instead of overflowing
|
||||
// the panel (which used to spill over the image below it).
|
||||
readonly property int maxHeight: Math.max(Style.space(80), Math.floor(parent.height * 0.4))
|
||||
height: Math.min(maxHeight, ocrHeader.height + Style.space(4) + ocrText.implicitHeight + Style.space(12))
|
||||
radius: Style.cornerRadius
|
||||
color: root.chipBg
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: ocrContent
|
||||
@@ -472,20 +549,62 @@ Item {
|
||||
anchors.rightMargin: Style.space(6)
|
||||
spacing: Style.space(4)
|
||||
|
||||
Row {
|
||||
id: ocrHeader
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
text: " Recognized text (OCR)"
|
||||
color: Color.accent
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
radius: height / 2
|
||||
color: Util.alpha(Color.accent, 0.15)
|
||||
width: ocrCopyLabel.implicitWidth + Style.space(16)
|
||||
height: Style.space(20)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
Text {
|
||||
id: ocrCopyLabel
|
||||
anchors.centerIn: parent
|
||||
text: " Copy text"
|
||||
color: Color.accent
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.copyTextAction(root.entry ? String(root.entry.ocr || "") : "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: ocrFlick
|
||||
width: parent.width
|
||||
height: ocrText.implicitHeight
|
||||
height: ocrPanel.height - ocrHeader.height - Style.space(4) - Style.space(12)
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: ocrText.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: function(ev) {
|
||||
if (ev.angleDelta.y < 0) ocrFlick.flick(0, -240)
|
||||
else ocrFlick.flick(0, 240)
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: ocrText
|
||||
@@ -556,8 +675,10 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
// files body
|
||||
Column {
|
||||
// File content is bounded by a scrollable viewport, including long paths
|
||||
// and text snippets. Selecting another file loads its details on demand.
|
||||
Flickable {
|
||||
id: filesBody
|
||||
anchors.top: divider.bottom
|
||||
anchors.bottom: metaRow.top
|
||||
anchors.left: parent.left
|
||||
@@ -565,57 +686,158 @@ Item {
|
||||
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)
|
||||
contentWidth: width
|
||||
contentHeight: fileContent.height
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
Connections {
|
||||
target: root
|
||||
function onSelectedFileChanged() { filesBody.contentY = 0 }
|
||||
}
|
||||
|
||||
delegate: Row {
|
||||
required property var modelData
|
||||
width: parent ? parent.width : 0
|
||||
WheelHandler {
|
||||
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||
onWheel: function(ev) {
|
||||
filesBody.flick(0, ev.angleDelta.y < 0 ? -240 : 240)
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
Column {
|
||||
id: fileContent
|
||||
width: filesBody.width
|
||||
spacing: Style.space(8)
|
||||
|
||||
Row {
|
||||
width: parent.width
|
||||
spacing: Style.space(8)
|
||||
visible: root.filePaths.length > 1
|
||||
Repeater {
|
||||
model: ["‹ Previous", "Next ›"]
|
||||
delegate: Rectangle {
|
||||
required property int index
|
||||
required property string modelData
|
||||
width: navLabel.implicitWidth + Style.space(16)
|
||||
height: Style.space(24)
|
||||
color: root.chipBg
|
||||
radius: Style.cornerRadius
|
||||
Text {
|
||||
text: ""
|
||||
color: Color.accent
|
||||
id: navLabel
|
||||
anchors.centerIn: parent
|
||||
text: modelData
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: root.fileIndex = (root.fileIndex + (index === 0 ? -1 : 1) + root.filePaths.length) % root.filePaths.length
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
text: Classify.fileBase(modelData)
|
||||
text: (root.fileIndex + 1) + " / " + root.filePaths.length
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: Classify.fileBase(root.selectedFile)
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.WrapAnywhere
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideMiddle
|
||||
width: parent.width - Style.space(24)
|
||||
maximumLineCount: 1
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
Rectangle {
|
||||
id: thumbnailPanel
|
||||
readonly property bool loading: fileDelay.running || fileProbe.running || fileThumbnail.status === Image.Loading
|
||||
width: parent.width
|
||||
maximumLineCount: 1
|
||||
height: visible ? Math.min(Style.space(240), filesBody.height * 0.55) : 0
|
||||
visible: loading || !!root.fileInfo.thumbnail
|
||||
color: root.chipBg
|
||||
radius: Style.cornerRadius
|
||||
clip: true
|
||||
Image {
|
||||
id: fileThumbnail
|
||||
anchors.fill: parent
|
||||
visible: status === Image.Ready && !thumbnailPanel.loading
|
||||
source: root.fileInfo.thumbnail || ""
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
sourceSize.width: 640
|
||||
sourceSize.height: 360
|
||||
}
|
||||
Column {
|
||||
anchors.centerIn: parent
|
||||
spacing: Style.space(8)
|
||||
visible: thumbnailPanel.loading
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: "◌"
|
||||
color: Color.accent
|
||||
font.pixelSize: Style.space(28)
|
||||
NumberAnimation on rotation {
|
||||
from: 0
|
||||
to: 360
|
||||
duration: 1000
|
||||
loops: Animation.Infinite
|
||||
running: thumbnailPanel.loading && filesBody.visible
|
||||
}
|
||||
}
|
||||
Text {
|
||||
text: "Loading preview…"
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: !thumbnailPanel.loading && fileThumbnail.status === Image.Error
|
||||
text: "Thumbnail unavailable"
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.fileInfo.error || root.fileDetails() || (thumbnailPanel.loading ? "Loading file information…" : "File information unavailable")
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.WrapAnywhere
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.selectedFile
|
||||
textFormat: Text.PlainText
|
||||
wrapMode: Text.WrapAnywhere
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
TextEdit {
|
||||
width: parent.width
|
||||
visible: text.length > 0
|
||||
text: root.fileInfo.text || root.fileInfo.details || ""
|
||||
textFormat: TextEdit.PlainText
|
||||
readOnly: true
|
||||
activeFocusOnPress: false
|
||||
wrapMode: TextEdit.WrapAnywhere
|
||||
color: root.fg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.body
|
||||
}
|
||||
Text {
|
||||
visible: !!root.fileInfo.truncated
|
||||
text: "Preview limited to the first 8 KiB"
|
||||
color: root.mutedFg
|
||||
font.family: root.font_
|
||||
font.pixelSize: Style.font.caption
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ full theme integration.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Fuzzy search over everything** — fzf-style scoring across content, source
|
||||
app, and type, boosted by recency, pins, and usage. Query tokens:
|
||||
- **Fuzzy search over everything** — fzf-style matching across content, source
|
||||
app, and type. The list is always newest-first; search only filters it. Query tokens:
|
||||
- `type:image|link|text|files|code|json|color|email|html|number` (prefix match)
|
||||
- `app:firefox` — fuzzy match on the source app
|
||||
- `is:pinned`, `today`, `yesterday`, `week`, `<2h`, `>30s`, `<3d`
|
||||
@@ -61,6 +61,35 @@ That's the whole install — `omarchy plugin add` clones the repo, validates the
|
||||
manifest, and enables it. It replaces the built-in `omarchy.clipboard`
|
||||
(restore it later with `omarchy plugin disable alanfortlink.clipboard`).
|
||||
|
||||
### File previews
|
||||
|
||||
Copied files show their name, type, size, modification time, and path. Image
|
||||
and video files show a thumbnail and resolution; videos and audio show duration,
|
||||
and audio also shows codec, sample rate, and channels. PDFs show a first-page
|
||||
thumbnail and page information. Text and code files show the first 8 KiB.
|
||||
Folders and other files retain basic information. Use Previous/Next to inspect
|
||||
multiple copied files. Details load on selection, including for existing history.
|
||||
|
||||
Media previews use optional `ffmpeg` (`ffprobe` included); PDF previews use
|
||||
optional `poppler` (`pdfinfo` and `pdftoppm`). Without them, basic file information
|
||||
still works. Preview commands have time and output limits, and media thumbnails
|
||||
are skipped above 40 megapixels. Missing or moved files show an error.
|
||||
|
||||
### Dependencies
|
||||
|
||||
All are regular Arch packages; nothing is downloaded or run at install time.
|
||||
Required: `wl-clipboard`, `jq`, `python3`, `wtype` (paste into the focused
|
||||
window). Optional: `zbar` (QR decoding) and `tesseract` (OCR search) — without
|
||||
them the picker still works and shows the `omarchy pkg add …` command to add them.
|
||||
No sudo or pkexec is required by the plugin itself.
|
||||
|
||||
The clipboard owner is treated as untrusted. Payloads are streamed through a
|
||||
capped reader and dropped when they exceed 32 MiB (images) or 4 MiB (text), or
|
||||
take longer than 5 s to deliver; QR decoding and OCR only run on images under
|
||||
40 megapixels, as read from the file header without decoding. Override with
|
||||
`CLIPBOARD_MAX_IMAGE_BYTES`, `CLIPBOARD_MAX_TEXT_BYTES`, and
|
||||
`CLIPBOARD_MAX_PARSE_PIXELS` in the shell's environment.
|
||||
|
||||
Omarchy's default `Super+Ctrl+V` routes to it via the clone mechanism. An
|
||||
existing `Super+Shift+V` binding targeting `omarchy.clipboard` routes to it too.
|
||||
For a custom binding, edit the **live** config —
|
||||
@@ -68,14 +97,31 @@ For a custom binding, edit the **live** config —
|
||||
but is not sourced):
|
||||
|
||||
```lua
|
||||
o.bind("SUPER + SHIFT + V", "Clipboard manager (clipboard-history)",
|
||||
"omarchy-shell alanfortlink.clipboard toggle")
|
||||
o.bind("SUPER + SHIFT + V", "Clipboard manager",
|
||||
"omarchy-shell shell toggle omarchy.clipboard")
|
||||
```
|
||||
|
||||
(If your config is still legacy `bindings.conf`: `bindd = ALT SHIFT, V, …`.)
|
||||
|
||||
Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy plugin remove alanfortlink.clipboard`
|
||||
|
||||
### Upgrading from a pre-1.0 install
|
||||
|
||||
If you installed an earlier build (plugin id `tank.clipboard`, or a symlinked
|
||||
checkout via `install.sh`), remove it, reinstall from git, and repair any direct
|
||||
shortcut bindings so they target Omarchy's clone-aware `omarchy.clipboard` id:
|
||||
|
||||
```bash
|
||||
omarchy plugin remove tank.clipboard --yes # or: alanfortlink.clipboard
|
||||
omarchy plugin add https://github.com/alanfortlink/clipboard-history.git --enable --yes
|
||||
bash ~/.config/omarchy/plugins/alanfortlink.clipboard/scripts/configure-bindings.sh
|
||||
omarchy restart shell
|
||||
```
|
||||
|
||||
History data is preserved. `configure-bindings.sh` edits `bindings.lua` (or
|
||||
`bindings.conf`) and leaves a timestamped `.bak` copy next to it; it is only
|
||||
ever run when you invoke it explicitly.
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Action |
|
||||
@@ -84,6 +130,7 @@ Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy
|
||||
| `Enter` | copy to clipboard and paste into the focused window |
|
||||
| `Shift+Enter` | copy only |
|
||||
| `Ctrl+O` | open a pure link in the browser, an image in the editor, or a file externally. Text entries stay in the picker; use `Return` to paste them. QR images open their **decoded link** in the browser; the preview pane also has clickable "Open link" chips for links and QR payloads |
|
||||
| `Ctrl+Shift+C` | copy the entry's text content as plain text: for images, the OCR text (or QR payload). The QR and OCR panels also have **Copy** chips |
|
||||
| `Tab` | pin/unpin |
|
||||
| `Ctrl+=` | pause/resume recording |
|
||||
| `Delete` | remove entry · `Shift+Delete` clear all (with confirm) |
|
||||
@@ -92,8 +139,8 @@ Updating: `omarchy plugin update alanfortlink.clipboard` · Uninstall: `omarchy
|
||||
Pause/resume is also scriptable — useful for automation or a custom binding:
|
||||
|
||||
```bash
|
||||
omarchy-shell alanfortlink.clipboard pause '{"paused":"toggle"}'
|
||||
omarchy-shell alanfortlink.clipboard isPaused
|
||||
omarchy-shell shell call alanfortlink.clipboard pause '{"paused":"toggle"}'
|
||||
omarchy-shell shell call alanfortlink.clipboard isPaused
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
+172
-21
@@ -11,12 +11,19 @@ the current clipboard. Emits exactly one JSON line per capture:
|
||||
|
||||
Sensitive clips (x-kde-passwordManagerHint / CLIPBOARD_STATE=sensitive) and
|
||||
binary payloads are skipped silently.
|
||||
|
||||
The clipboard owner is untrusted: every payload is read through a bounded
|
||||
reader (byte cap + deadline) and QR/OCR only run on images whose header
|
||||
dimensions are under a pixel cap, so a hostile or runaway source cannot make
|
||||
the plugin buffer an unbounded payload or decode a decompression bomb.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -34,6 +41,24 @@ IMAGE_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp",
|
||||
TEXT_TYPES = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING", "TEXT", "COMPOUND_TEXT"]
|
||||
|
||||
|
||||
def _env_int(name, default):
|
||||
try:
|
||||
return max(0, int(os.environ.get(name, "") or default))
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
# Payload limits. Anything larger is dropped (not truncated) so the history
|
||||
# never holds a partial clip. Overridable per environment.
|
||||
MAX_IMAGE_BYTES = _env_int("CLIPBOARD_MAX_IMAGE_BYTES", 32 * 1024 * 1024)
|
||||
MAX_TEXT_BYTES = _env_int("CLIPBOARD_MAX_TEXT_BYTES", 4 * 1024 * 1024)
|
||||
# QR decoding and OCR decode the full bitmap; skip them above this many
|
||||
# pixels (the image is still recorded and previewed by Qt, which has its own
|
||||
# allocation limits).
|
||||
MAX_PARSE_PIXELS = _env_int("CLIPBOARD_MAX_PARSE_PIXELS", 40 * 1000 * 1000)
|
||||
READ_TIMEOUT = 5.0
|
||||
|
||||
|
||||
def run(args, timeout=5):
|
||||
try:
|
||||
r = subprocess.run(args, capture_output=True, timeout=timeout)
|
||||
@@ -42,6 +67,115 @@ def run(args, timeout=5):
|
||||
return None
|
||||
|
||||
|
||||
def read_bounded(args, limit, timeout=READ_TIMEOUT):
|
||||
"""Run `args` and return its stdout, or None if it exits non-zero, writes
|
||||
more than `limit` bytes, or does not finish within `timeout` seconds.
|
||||
Output is streamed and the process is killed as soon as the cap is hit,
|
||||
so memory use is bounded by `limit` regardless of what the source sends."""
|
||||
try:
|
||||
proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||
except Exception:
|
||||
return None
|
||||
chunks = []
|
||||
total = 0
|
||||
ok = True
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
ok = False
|
||||
break
|
||||
ready, _, _ = select.select([proc.stdout], [], [], remaining)
|
||||
if not ready:
|
||||
ok = False
|
||||
break
|
||||
chunk = os.read(proc.stdout.fileno(), 65536)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > limit:
|
||||
ok = False
|
||||
break
|
||||
chunks.append(chunk)
|
||||
except Exception:
|
||||
ok = False
|
||||
finally:
|
||||
if not ok:
|
||||
proc.kill()
|
||||
try:
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
proc.stdout.close()
|
||||
if not ok or proc.returncode != 0:
|
||||
return None
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def read_clipboard(mime, limit):
|
||||
return read_bounded(["wl-paste", "--type", mime, "--no-newline"], limit)
|
||||
|
||||
|
||||
def image_size(data, mime):
|
||||
"""(width, height) from the container header alone — no pixel decoding.
|
||||
Returns None when the header is not understood."""
|
||||
try:
|
||||
if mime == "image/png" and data[:8] == b"\x89PNG\r\n\x1a\n" and data[12:16] == b"IHDR":
|
||||
return struct.unpack(">II", data[16:24])
|
||||
if mime == "image/gif" and data[:6] in (b"GIF87a", b"GIF89a"):
|
||||
return struct.unpack("<HH", data[6:10])
|
||||
if mime == "image/bmp" and data[:2] == b"BM":
|
||||
w, h = struct.unpack("<ii", data[18:26])
|
||||
return abs(w), abs(h)
|
||||
if mime == "image/webp" and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
||||
chunk = data[12:16]
|
||||
if chunk == b"VP8X":
|
||||
w = int.from_bytes(data[24:27], "little") + 1
|
||||
h = int.from_bytes(data[27:30], "little") + 1
|
||||
return w, h
|
||||
if chunk == b"VP8L" and data[20] == 0x2F:
|
||||
bits = int.from_bytes(data[21:25], "little")
|
||||
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
||||
if chunk == b"VP8 ":
|
||||
w, h = struct.unpack("<HH", data[26:30])
|
||||
return w & 0x3FFF, h & 0x3FFF
|
||||
if mime == "image/jpeg" and data[:2] == b"\xff\xd8":
|
||||
i = 2
|
||||
n = len(data)
|
||||
while i + 9 < n:
|
||||
if data[i] != 0xFF:
|
||||
i += 1
|
||||
continue
|
||||
marker = data[i + 1]
|
||||
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
||||
i += 2
|
||||
continue
|
||||
length = struct.unpack(">H", data[i + 2:i + 4])[0]
|
||||
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
||||
h, w = struct.unpack(">HH", data[i + 5:i + 9])
|
||||
return w, h
|
||||
i += 2 + length
|
||||
if mime == "image/tiff" and data[:4] in (b"II*\x00", b"MM\x00*"):
|
||||
end = "<" if data[:2] == b"II" else ">"
|
||||
off = struct.unpack(end + "I", data[4:8])[0]
|
||||
count = struct.unpack(end + "H", data[off:off + 2])[0]
|
||||
w = h = None
|
||||
for k in range(min(count, 64)):
|
||||
e = off + 2 + k * 12
|
||||
tag, typ = struct.unpack(end + "HH", data[e:e + 4])
|
||||
val = struct.unpack(end + ("H" if typ == 3 else "I"), data[e + 8:e + 8 + (2 if typ == 3 else 4)])[0]
|
||||
if tag == 256:
|
||||
w = val
|
||||
elif tag == 257:
|
||||
h = val
|
||||
if w and h:
|
||||
return w, h
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def list_types():
|
||||
out = run(["wl-paste", "--list-types"])
|
||||
if not out:
|
||||
@@ -86,22 +220,22 @@ 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"])
|
||||
data = read_clipboard(mime, MAX_IMAGE_BYTES)
|
||||
if not data:
|
||||
return
|
||||
# Oversized, slow, or empty: skip the clip entirely (an image mime was
|
||||
# offered, so we do not fall through to a text capture of it either).
|
||||
return True
|
||||
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
|
||||
# Dimensions from the header only; the bitmap is never decoded here.
|
||||
size = image_size(data, mime)
|
||||
if size:
|
||||
entry["w"], entry["h"] = int(size[0]), int(size[1])
|
||||
# Unknown or huge dimensions → no QR/OCR pass (both decode the full image).
|
||||
parse_ok = bool(size) and size[0] * size[1] <= MAX_PARSE_PIXELS
|
||||
|
||||
if not os.path.exists(path):
|
||||
fd, tmp = tempfile.mkstemp(dir=IMAGE_DIR)
|
||||
@@ -116,11 +250,11 @@ def capture_image(types, app):
|
||||
pass
|
||||
return
|
||||
|
||||
qr = decode_qr(path) if os.environ.get("CLIPBOARD_QR", "1") != "0" else None
|
||||
qr = decode_qr(path) if parse_ok and os.environ.get("CLIPBOARD_QR", "1") != "0" else None
|
||||
if qr:
|
||||
entry["qr"] = qr
|
||||
|
||||
ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng"))
|
||||
ocr = decode_ocr(path, os.environ.get("CLIPBOARD_OCR_LANG", "eng")) if parse_ok else None
|
||||
if ocr:
|
||||
entry["ocr"] = ocr
|
||||
|
||||
@@ -175,7 +309,7 @@ def decode_ocr(path, lang):
|
||||
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"])
|
||||
data = read_clipboard("text/uri-list", MAX_TEXT_BYTES)
|
||||
if not data:
|
||||
return False
|
||||
text = decode_text(data)
|
||||
@@ -200,7 +334,7 @@ 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"])
|
||||
data = read_clipboard(mime, MAX_TEXT_BYTES)
|
||||
if not data:
|
||||
return
|
||||
text = decode_text(data)
|
||||
@@ -209,14 +343,31 @@ def capture_text(types, app):
|
||||
emit({"type": "text", "text": text, "bytes": len(data), "app": app})
|
||||
|
||||
|
||||
def main():
|
||||
# The watcher pipes the clipboard payload to us; wl-clipboard blocks on
|
||||
# writes if we close the pipe early, so drain it instead (we still probe
|
||||
# types ourselves below).
|
||||
def drain_stdin(timeout=READ_TIMEOUT):
|
||||
"""Discard the watcher's payload in fixed chunks, with a total deadline.
|
||||
|
||||
False makes main exit without probing; process exit closes the pipe so
|
||||
a stalled or endless producer cannot keep this helper alive indefinitely.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
try:
|
||||
sys.stdin.buffer.read()
|
||||
except Exception:
|
||||
pass
|
||||
fd = sys.stdin.fileno()
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return False
|
||||
ready, _, _ = select.select([fd], [], [], remaining)
|
||||
if not ready:
|
||||
return False
|
||||
if not os.read(fd, 65536):
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
if not drain_stdin():
|
||||
return
|
||||
types = list_types()
|
||||
if not types:
|
||||
return
|
||||
|
||||
+5
-47
@@ -5,7 +5,7 @@
|
||||
# - rescans + enables it (shell.json gets plugins[] entry; the built-in
|
||||
# omarchy.clipboard is recorded in disabledPlugins[] and routed here)
|
||||
# - checks and installs dependencies transparently (QR/OCR degrade without them)
|
||||
# - binds SUPER+SHIFT+V and ALT+SHIFT+V directly to this picker (with backup)
|
||||
# - binds SUPER+SHIFT+V and ALT+SHIFT+V to the clone-aware source id (with backup)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -38,7 +38,8 @@ mkdir -p "$PLUGINS_DIR"
|
||||
ln -sTfn "$PLUGIN_DIR" "$PLUGINS_DIR/$PLUGIN_ID"
|
||||
# On other machines, install directly from git instead:
|
||||
# omarchy plugin add <this-repo-git-url> --enable
|
||||
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh
|
||||
chmod +x "$PLUGIN_DIR"/capture.py "$PLUGIN_DIR"/paste-entry.sh "$PLUGIN_DIR"/open-entry.sh \
|
||||
"$PLUGIN_DIR"/scripts/configure-bindings.sh
|
||||
|
||||
echo "==> Rescanning shell plugins"
|
||||
omarchy-shell shell rescanPlugins >/dev/null
|
||||
@@ -58,51 +59,8 @@ fi
|
||||
echo "==> Enabling $PLUGIN_ID (replaces built-in omarchy.clipboard)"
|
||||
omarchy plugin enable "$PLUGIN_ID"
|
||||
|
||||
echo "==> Binding SUPER+SHIFT+V and ALT+SHIFT+V directly to $PLUGIN_ID"
|
||||
# Do not route through `shell toggle`: plugin rescans can leave its panel Loader
|
||||
# stale. The plugin-owned IPC target always controls the mapped picker window.
|
||||
rebound=0
|
||||
if [[ -f $HOME/.config/hypr/bindings.lua ]]; then
|
||||
LUA="$HOME/.config/hypr/bindings.lua"
|
||||
tmp=$(mktemp)
|
||||
python3 - "$LUA" >"$tmp" <<'PY'
|
||||
import re, sys
|
||||
text = open(sys.argv[1]).read()
|
||||
command = "omarchy-shell alanfortlink.clipboard toggle"
|
||||
for chord in ("SUPER + SHIFT + V", "ALT + SHIFT + V"):
|
||||
line = f'o.bind("{chord}", "Clipboard manager (clipboard-history)", "{command}")'
|
||||
pattern = re.compile(r'^\s*o\.bind\("' + re.escape(chord) + r'".*$', re.M)
|
||||
if pattern.search(text):
|
||||
text = pattern.sub(line, text, count=1)
|
||||
else:
|
||||
text = text.rstrip() + "\n" + line + "\n"
|
||||
sys.stdout.write(text)
|
||||
PY
|
||||
if ! cmp -s "$LUA" "$tmp"; then
|
||||
cp "$LUA" "$LUA.bak.$(date +%s)"
|
||||
mv "$tmp" "$LUA"
|
||||
echo " updated bindings.lua (backup created)"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
echo " bindings.lua already correct"
|
||||
fi
|
||||
rebound=1
|
||||
elif [[ -f $HOME/.config/hypr/bindings.conf ]]; then
|
||||
CONF="$HOME/.config/hypr/bindings.conf"
|
||||
cp "$CONF" "$CONF.bak.$(date +%s)"
|
||||
sed -i '/^bindd = \(SUPER\|ALT\) SHIFT, V, /d' "$CONF"
|
||||
printf '%s\n' \
|
||||
'bindd = SUPER SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell alanfortlink.clipboard toggle' \
|
||||
'bindd = ALT SHIFT, V, Clipboard manager (clipboard-history), exec, omarchy-shell alanfortlink.clipboard toggle' >>"$CONF"
|
||||
echo " updated bindings.conf (backup created)"
|
||||
rebound=1
|
||||
fi
|
||||
if (( rebound )); then
|
||||
hyprctl reload >/dev/null
|
||||
else
|
||||
echo "ERROR: no live Hyprland bindings file found" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> Binding SUPER+SHIFT+V and ALT+SHIFT+V to the clipboard source"
|
||||
"$PLUGIN_DIR/scripts/configure-bindings.sh"
|
||||
|
||||
echo
|
||||
echo "Done. Press SUPER+SHIFT+V (or ALT+SHIFT+V) to open the clipboard picker."
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "alanfortlink.clipboard",
|
||||
"name": "Clipboard History",
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"author": "alanfortlink",
|
||||
"description": "Raycast-style clipboard history: fuzzy search over content, type, app and date, with rich per-type previews",
|
||||
"kinds": [
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
+11
-1
@@ -11,7 +11,9 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import capture # noqa: E402 (image_size / MAX_PARSE_PIXELS shared with the daemon)
|
||||
|
||||
STATE = os.path.join(
|
||||
os.environ.get("XDG_STATE_HOME", os.path.expanduser("~/.local/state")),
|
||||
@@ -56,6 +58,14 @@ def main():
|
||||
continue
|
||||
if not os.path.exists(entry["path"]):
|
||||
continue
|
||||
# Same guard as the capture daemon: never decode an image whose header
|
||||
# dimensions are unknown or above the pixel cap.
|
||||
with open(entry["path"], "rb") as f:
|
||||
head = f.read(65536)
|
||||
size = capture.image_size(head, entry.get("mime") or "image/png")
|
||||
if not size or size[0] * size[1] > capture.MAX_PARSE_PIXELS:
|
||||
print(f" - {entry['id']}: skipped (size {size or 'unknown'})")
|
||||
continue
|
||||
text = ocr(entry["path"], args.lang)
|
||||
if text:
|
||||
entry["ocr"] = text
|
||||
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Configure clipboard shortcuts through Omarchy's clone-aware source id.
|
||||
# The same bindings open alanfortlink.clipboard while installed and the
|
||||
# built-in omarchy.clipboard after this plugin is disabled or removed.
|
||||
set -euo pipefail
|
||||
|
||||
HYPR_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
|
||||
LUA="$HYPR_DIR/bindings.lua"
|
||||
CONF="$HYPR_DIR/bindings.conf"
|
||||
STAMP=$(date +%s)
|
||||
changed=0
|
||||
|
||||
if [[ -f $LUA ]]; then
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "${tmp:-}"' EXIT
|
||||
python3 - "$LUA" >"$tmp" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
path = sys.argv[1]
|
||||
text = open(path, encoding="utf-8").read()
|
||||
command = "omarchy-shell shell toggle omarchy.clipboard"
|
||||
for chord in ("SUPER + SHIFT + V", "ALT + SHIFT + V"):
|
||||
line = f'o.bind("{chord}", "Clipboard manager", "{command}")'
|
||||
pattern = re.compile(r'^\s*o\.bind\("' + re.escape(chord) + r'".*$', re.MULTILINE)
|
||||
if pattern.search(text):
|
||||
text = pattern.sub(line, text, count=1)
|
||||
else:
|
||||
text = text.rstrip() + "\n" + line + "\n"
|
||||
sys.stdout.write(text)
|
||||
PY
|
||||
if ! cmp -s "$LUA" "$tmp"; then
|
||||
cp "$LUA" "$LUA.bak.clipboard.$STAMP"
|
||||
mv "$tmp" "$LUA"
|
||||
changed=1
|
||||
echo "Updated $LUA (backup: $LUA.bak.clipboard.$STAMP)"
|
||||
else
|
||||
echo "$LUA already uses clone-aware clipboard bindings"
|
||||
fi
|
||||
elif [[ -f $CONF ]]; then
|
||||
tmp=$(mktemp)
|
||||
trap 'rm -f "${tmp:-}"' EXIT
|
||||
grep -vE '^bindd = (SUPER|ALT) SHIFT, V, ' "$CONF" >"$tmp" || true
|
||||
printf '%s\n' \
|
||||
'bindd = SUPER SHIFT, V, Clipboard manager, exec, omarchy-shell shell toggle omarchy.clipboard' \
|
||||
'bindd = ALT SHIFT, V, Clipboard manager, exec, omarchy-shell shell toggle omarchy.clipboard' >>"$tmp"
|
||||
if ! cmp -s "$CONF" "$tmp"; then
|
||||
cp "$CONF" "$CONF.bak.clipboard.$STAMP"
|
||||
mv "$tmp" "$CONF"
|
||||
changed=1
|
||||
echo "Updated $CONF (backup: $CONF.bak.clipboard.$STAMP)"
|
||||
else
|
||||
echo "$CONF already uses clone-aware clipboard bindings"
|
||||
fi
|
||||
else
|
||||
echo "configure-bindings: no bindings.lua or bindings.conf found in $HYPR_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if (( changed )); then
|
||||
hyprctl reload >/dev/null
|
||||
fi
|
||||
|
||||
errors=$(hyprctl configerrors)
|
||||
if [[ -n $errors ]]; then
|
||||
printf '%s\n' "$errors" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Clipboard shortcuts ready: SUPER+SHIFT+V and ALT+SHIFT+V"
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded, on-demand metadata for one local copied file; never modifies it."""
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from capture import read_bounded
|
||||
|
||||
|
||||
def preview(path):
|
||||
result = {'path': path}
|
||||
deadline = time.monotonic() + 6
|
||||
|
||||
def run(args, limit=128 * 1024):
|
||||
remaining = deadline - time.monotonic()
|
||||
return read_bounded(args, limit, timeout=min(2, remaining)) if remaining > 0 else None
|
||||
|
||||
try:
|
||||
info = os.stat(path)
|
||||
result.update(bytes=info.st_size, modified=int(info.st_mtime))
|
||||
if stat.S_ISDIR(info.st_mode):
|
||||
result['kind'] = 'Folder'
|
||||
return result
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
result['kind'] = 'Special file'
|
||||
return result
|
||||
mime = mimetypes.guess_type(path)[0] or 'application/octet-stream'
|
||||
result['mime'] = mime
|
||||
result['kind'] = mime
|
||||
# Do not let a media playlist cause ffmpeg to open referenced resources.
|
||||
media = Path(path).suffix.lower() in {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.avif', '.heic', '.mp4', '.mkv', '.webm', '.mov', '.avi', '.m4v', '.mp3', '.flac', '.wav', '.ogg', '.opus', '.m4a', '.aac'}
|
||||
if media:
|
||||
options = ['-v', 'error', '-max_alloc', '67108864', '-protocol_whitelist', 'file', '-probesize', '1048576', '-analyzeduration', '1000000']
|
||||
data = run(['ffprobe'] + options + ['-show_entries', 'format=duration:stream=codec_type,codec_name,width,height,sample_rate,channels', '-of', 'json', path])
|
||||
if data:
|
||||
parsed = json.loads(data)
|
||||
duration = float(parsed.get('format', {}).get('duration', 0))
|
||||
if 0 < duration < 1e9:
|
||||
result['duration'] = duration
|
||||
streams = parsed.get('streams', [])
|
||||
visual = next((s for s in streams if s.get('codec_type') == 'video'), {})
|
||||
audio = next((s for s in streams if s.get('codec_type') == 'audio'), {})
|
||||
for key in ('width', 'height'):
|
||||
if visual.get(key):
|
||||
result[key] = int(visual[key])
|
||||
if audio:
|
||||
result['audio'] = ' · '.join(str(audio[k]) + suffix for k, suffix in [('codec_name', ''), ('sample_rate', ' Hz'), ('channels', ' channels')] if audio.get(k))
|
||||
if 0 < result.get('width', 0) * result.get('height', 0) <= 40000000:
|
||||
thumb = run(['ffmpeg'] + options + ['-nostdin', '-threads', '1', '-i', path, '-frames:v', '1', '-vf', 'scale=640:360:force_original_aspect_ratio=decrease', '-threads', '1', '-f', 'image2pipe', '-c:v', 'mjpeg', '-protocol_whitelist', 'pipe', 'pipe:1'], 512 * 1024)
|
||||
if thumb:
|
||||
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
||||
elif mime == 'application/pdf':
|
||||
thumb = run(['pdftoppm', '-f', '1', '-singlefile', '-scale-to', '640', '-jpeg', path], 512 * 1024)
|
||||
if thumb:
|
||||
result['thumbnail'] = 'data:image/jpeg;base64,' + base64.b64encode(thumb).decode('ascii')
|
||||
data = run(['pdfinfo', path])
|
||||
if data:
|
||||
result['details'] = '\n'.join(line.strip() for line in data.decode('utf-8', 'replace').splitlines() if line.startswith(('Pages:', 'Page size:', 'Title:', 'Author:')))
|
||||
elif mime.startswith('text/') or Path(path).suffix.lower() in {'.json', '.yaml', '.yml', '.toml', '.md', '.py', '.js', '.ts', '.qml', '.sh', '.rs', '.go', '.log', '.csv'}:
|
||||
# Nonblocking open avoids hanging on a file replaced with a FIFO.
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, 'rb') as source:
|
||||
if stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
||||
data = source.read(8193)
|
||||
if b'\0' not in data:
|
||||
result['text'] = data[:8192].decode('utf-8', 'replace')
|
||||
result['truncated'] = len(data) > 8192
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
result['error'] = str(error)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps({'request': sys.argv[1], 'file': preview(os.path.abspath(sys.argv[2]))}))
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Run all plugin logic tests. Requires node.
|
||||
# Run all plugin logic tests. Requires node and python3.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")/.."
|
||||
python3 -m unittest discover -s tests -p 'test_*.py'
|
||||
exec node --test 'tests/*.mjs'
|
||||
|
||||
+21
-4
@@ -79,6 +79,21 @@ test("searchRows empty query sorts by recency", () => {
|
||||
assert.equal(res[0].row.content, "new")
|
||||
})
|
||||
|
||||
test("searchRows: always newest first, pins and pastes do not reorder", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: {}, content: "pasted twice, 5 min ago", app: "", type: "text", ts: now - 300, pinned: false, uses: 2, bytes: 3 },
|
||||
{ entry: {}, content: "just copied", app: "", type: "text", ts: now - 5, pinned: false, uses: 0, bytes: 3 },
|
||||
{ entry: { pinned: true }, content: "pinned, old", app: "", type: "text", ts: now - 86400 * 3, pinned: true, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.deepEqual(Array.from(res, r => r.row.content), ["just copied", "pasted twice, 5 min ago", "pinned, old"])
|
||||
const typed = Fuzzy.searchRows(rows, "type:text <1h", now, 10)
|
||||
assert.deepEqual(Array.from(typed, r => r.row.content), ["just copied", "pasted twice, 5 min ago"])
|
||||
const searched = Fuzzy.searchRows(rows, "past", now, 10)
|
||||
assert.deepEqual(Array.from(searched, r => r.row.content), ["pasted twice, 5 min ago"])
|
||||
})
|
||||
|
||||
test("searchRows fuzzy ranks match above recency when strong", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
@@ -122,14 +137,16 @@ test("searchRows matches app field", () => {
|
||||
assert.equal(res[0].row.app, "firefox")
|
||||
})
|
||||
|
||||
test("searchRows pinned boost wins", () => {
|
||||
test("searchRows is:pinned filters without reordering", () => {
|
||||
const now = 1000000
|
||||
const rows = [
|
||||
{ entry: { pinned: true }, content: "zzz pinned weak", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 },
|
||||
{ entry: { pinned: true }, content: "zzz pinned old", app: "", type: "text", ts: now - 86400 * 10, pinned: true, uses: 0, bytes: 3 },
|
||||
{ entry: {}, content: "fresh", app: "", type: "text", ts: now, pinned: false, uses: 0, bytes: 3 }
|
||||
]
|
||||
const res = Fuzzy.searchRows(rows, "", now, 10)
|
||||
assert.equal(res[0].row.content, "zzz pinned weak")
|
||||
assert.equal(Fuzzy.searchRows(rows, "", now, 10)[0].row.content, "fresh")
|
||||
const pinned = Fuzzy.searchRows(rows, "is:pinned", now, 10)
|
||||
assert.equal(pinned.length, 1)
|
||||
assert.equal(pinned[0].row.content, "zzz pinned old")
|
||||
})
|
||||
|
||||
test("searchRows respects limit", () => {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
class WatcherInputTests(unittest.TestCase):
|
||||
def helper(self, stdin, timeout=0.2):
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "-c", """
|
||||
import capture
|
||||
import sys
|
||||
import tracemalloc
|
||||
tracemalloc.start()
|
||||
drain = capture.drain_stdin
|
||||
capture.drain_stdin = lambda: drain(timeout=float(sys.argv[1]))
|
||||
capture.list_types = lambda: print('probed') or []
|
||||
capture.main()
|
||||
assert tracemalloc.get_traced_memory()[1] < 1024 * 1024
|
||||
""", str(timeout)], stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
def finish(self, proc, expected, payload=None):
|
||||
try:
|
||||
out, err = proc.communicate(input=payload, timeout=8)
|
||||
self.assertEqual(proc.returncode, 0, err.decode())
|
||||
self.assertEqual(out, expected)
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
def test_large_payload_is_discarded_without_accumulation(self):
|
||||
with tempfile.TemporaryFile() as source:
|
||||
source.truncate(64 * 1024 * 1024)
|
||||
self.finish(self.helper(source, timeout=5), b"probed\n")
|
||||
|
||||
def test_finite_pipe_still_probes(self):
|
||||
self.finish(self.helper(subprocess.PIPE, timeout=5), b"probed\n",
|
||||
payload=b"clipboard content\n" * 16384)
|
||||
|
||||
def test_stalled_pipe_exits_without_probing(self):
|
||||
reader, writer = os.pipe()
|
||||
try:
|
||||
with os.fdopen(reader, "rb") as source:
|
||||
self.finish(self.helper(source), b"")
|
||||
finally:
|
||||
os.close(writer)
|
||||
|
||||
def test_endless_source_exits_without_probing(self):
|
||||
with open('/dev/zero', 'rb') as source:
|
||||
self.finish(self.helper(source), b"")
|
||||
|
||||
def test_empty_input_still_probes(self):
|
||||
self.finish(self.helper(subprocess.DEVNULL), b"probed\n")
|
||||
@@ -0,0 +1,69 @@
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location('file_preview', Path(__file__).resolve().parents[1] / 'scripts/file-preview.py')
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
|
||||
class FilePreviewTests(unittest.TestCase):
|
||||
def test_text_is_bounded_and_path_preserved(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / 'a " quoted.txt'
|
||||
path.write_text('a' * 10000)
|
||||
data = module.preview(str(path))
|
||||
self.assertEqual(data['bytes'], 10000)
|
||||
self.assertEqual(len(data['text']), 8192)
|
||||
self.assertTrue(data['truncated'])
|
||||
self.assertEqual(data['path'], str(path))
|
||||
|
||||
def test_missing_file_and_folder(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
self.assertEqual(module.preview(directory)['kind'], 'Folder')
|
||||
self.assertIn('error', module.preview(directory + '/missing'))
|
||||
|
||||
def test_special_file_is_not_read(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = directory + '/fifo.txt'
|
||||
os.mkfifo(path)
|
||||
self.assertEqual(module.preview(path)['kind'], 'Special file')
|
||||
|
||||
def test_missing_media_tools_preserve_basic_metadata(self):
|
||||
with tempfile.NamedTemporaryFile(suffix='.mp4') as source:
|
||||
with patch.object(module, 'read_bounded', return_value=None):
|
||||
data = module.preview(source.name)
|
||||
self.assertEqual(data['mime'], 'video/mp4')
|
||||
self.assertIn('bytes', data)
|
||||
self.assertNotIn('thumbnail', data)
|
||||
|
||||
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
|
||||
def test_video_resolution_duration_and_thumbnail(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = directory + '/clip.mkv'
|
||||
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'color=c=red:s=160x90:d=1', '-c:v', 'ffv1', path], check=True, timeout=10)
|
||||
data = module.preview(path)
|
||||
self.assertEqual((data['width'], data['height']), (160, 90))
|
||||
self.assertAlmostEqual(data['duration'], 1, places=1)
|
||||
self.assertTrue(data['thumbnail'].startswith('data:image/jpeg;base64,'))
|
||||
|
||||
@unittest.skipUnless(shutil.which('ffmpeg') and shutil.which('ffprobe'), 'ffmpeg optional')
|
||||
def test_audio_details(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = directory + '/audio.wav'
|
||||
subprocess.run(['ffmpeg', '-v', 'error', '-f', 'lavfi', '-i', 'sine=duration=1', path], check=True, timeout=10)
|
||||
data = module.preview(path)
|
||||
self.assertAlmostEqual(data['duration'], 1, places=1)
|
||||
self.assertIn('44100 Hz', data['audio'])
|
||||
self.assertNotIn('thumbnail', data)
|
||||
|
||||
def test_generic_binary_not_shown_as_text(self):
|
||||
with tempfile.NamedTemporaryFile(suffix='.txt') as source:
|
||||
source.write(b'abc\x00def')
|
||||
source.flush()
|
||||
self.assertNotIn('text', module.preview(source.name))
|
||||
Reference in New Issue
Block a user