Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "ClipboardHistory.js" as ClipboardHistory
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string blobPath: Quickshell.env("BLOB_PATH")
|
||||
property bool opened: false
|
||||
property string filterText: ""
|
||||
property int selectedIndex: 0
|
||||
property bool cursorActive: false
|
||||
property bool clearConfirmOpen: false
|
||||
property var history: []
|
||||
|
||||
property string historyPath: Quickshell.env("HOME") + "/.local/state/blob/clipboard-history.json"
|
||||
property string captureScript: root.blobPath + "/shell/plugins/clipboard/capture.sh"
|
||||
// Shares the [menu] surface tokens — themes that style the menu also
|
||||
// style the clipboard. Selected-row colors composed in the
|
||||
// singleton so consumers drop them straight into Rectangle bindings.
|
||||
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
|
||||
readonly property int cornerRadius: Style.cornerRadius
|
||||
property string fontFamily: Style.font.menuFamily
|
||||
property int contentMargin: Style.spacing.panelPadding
|
||||
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
|
||||
property int contentSpacing: Style.spacing.md
|
||||
property int cardWidth: Math.min(Style.space(875), panel.width - Style.gapsOut * 2)
|
||||
property int cardHeight: Math.min(Style.space(600), panel.height - Style.gapsOut * 2)
|
||||
property int rowHeight: Math.max(Style.space(50), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2)
|
||||
property int historyLimit: 300
|
||||
|
||||
function open(payloadJson) {
|
||||
root.opened = true
|
||||
root.filterText = ""
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = true
|
||||
root.disarmPointer()
|
||||
root.rebuildDisplay()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
function close() {
|
||||
root.cancelClearHistory()
|
||||
root.opened = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.opened) root.close()
|
||||
else root.open("{}")
|
||||
}
|
||||
|
||||
function normalizeEntry(value) {
|
||||
return ClipboardHistory.normalizeEntry(value)
|
||||
}
|
||||
|
||||
function entryKey(entry) {
|
||||
return ClipboardHistory.entryKey(entry)
|
||||
}
|
||||
|
||||
function loadHistory(raw) {
|
||||
root.history = ClipboardHistory.parseHistory(raw)
|
||||
if (root.opened) root.rebuildDisplay()
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
historyFile.setText(JSON.stringify(root.history.slice(0, root.historyLimit), null, 2) + "\n")
|
||||
}
|
||||
|
||||
function addClipboardEntry(entry) {
|
||||
var normalized = ClipboardHistory.normalizeEntry(entry)
|
||||
if (!normalized) return
|
||||
|
||||
root.history = ClipboardHistory.addEntry(root.history, normalized, root.historyLimit)
|
||||
root.saveHistory()
|
||||
if (root.opened) root.rebuildDisplay()
|
||||
}
|
||||
|
||||
function addClipboardJson(line) {
|
||||
root.addClipboardEntry(ClipboardHistory.parseEntryJson(line))
|
||||
}
|
||||
|
||||
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() {
|
||||
root.history = ClipboardHistory.clearHistory()
|
||||
root.saveHistory()
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = false
|
||||
root.disarmPointer()
|
||||
root.clearConfirmOpen = false
|
||||
root.rebuildDisplay()
|
||||
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
|
||||
}
|
||||
|
||||
function removeDisplayIndex(index) {
|
||||
if (index < 0 || index >= displayModel.count) return
|
||||
|
||||
var row = displayModel.get(index)
|
||||
root.history = ClipboardHistory.removeEntryAt(root.history, row.historyIndex)
|
||||
root.saveHistory()
|
||||
|
||||
if (displayModel.count <= 1) {
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = false
|
||||
} else if (root.selectedIndex >= displayModel.count - 1) {
|
||||
root.selectedIndex = displayModel.count - 2
|
||||
}
|
||||
|
||||
root.disarmPointer()
|
||||
root.rebuildDisplay()
|
||||
}
|
||||
|
||||
function rebuildDisplay() {
|
||||
var rows = ClipboardHistory.displayRows(root.history, root.filterText, 50)
|
||||
|
||||
displayModel.clear()
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var row = rows[i]
|
||||
displayModel.append({
|
||||
entryType: row.entryType,
|
||||
fullText: row.fullText,
|
||||
previewText: row.previewText,
|
||||
previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "",
|
||||
path: row.path,
|
||||
mime: row.mime,
|
||||
historyIndex: row.index
|
||||
})
|
||||
}
|
||||
|
||||
if (displayModel.count === 0) selectedIndex = 0
|
||||
else if (selectedIndex >= displayModel.count) selectedIndex = displayModel.count - 1
|
||||
else if (selectedIndex < 0) selectedIndex = 0
|
||||
|
||||
Qt.callLater(function() {
|
||||
if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
})
|
||||
}
|
||||
|
||||
function select(delta) {
|
||||
if (displayModel.count === 0) return
|
||||
root.disarmPointer()
|
||||
if (!cursorActive) {
|
||||
cursorActive = true
|
||||
selectedIndex = delta < 0 ? displayModel.count - 1 : 0
|
||||
} else {
|
||||
selectedIndex = (selectedIndex + delta + displayModel.count) % displayModel.count
|
||||
}
|
||||
resultList.positionViewAtIndex(selectedIndex, ListView.Contain)
|
||||
}
|
||||
|
||||
function selectAbsolute(index) {
|
||||
if (displayModel.count === 0) return
|
||||
root.disarmPointer()
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = Math.max(0, Math.min(index, displayModel.count - 1))
|
||||
resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain)
|
||||
}
|
||||
|
||||
function setFilter(nextFilter) {
|
||||
root.filterText = nextFilter
|
||||
root.selectedIndex = 0
|
||||
root.cursorActive = true
|
||||
root.disarmPointer()
|
||||
root.rebuildDisplay()
|
||||
}
|
||||
|
||||
function disarmPointer() {
|
||||
pointerGate.reset()
|
||||
}
|
||||
|
||||
function selectFromPointer(index, item, mouse) {
|
||||
if (!pointerGate.moved(item, mouse)) return
|
||||
root.cursorActive = true
|
||||
root.selectedIndex = index
|
||||
}
|
||||
|
||||
function activateIndex(index) {
|
||||
if (index < 0 || index >= displayModel.count) return
|
||||
var row = displayModel.get(index)
|
||||
root.applySelected(row)
|
||||
}
|
||||
|
||||
function copyIndex(index) {
|
||||
if (index < 0 || index >= displayModel.count) return
|
||||
var row = displayModel.get(index)
|
||||
root.copySelected(row)
|
||||
}
|
||||
|
||||
function openIndex(index) {
|
||||
if (index < 0 || index >= displayModel.count) return
|
||||
var row = displayModel.get(index)
|
||||
root.openSelected(row)
|
||||
}
|
||||
|
||||
function applySelected(row) {
|
||||
if (!row) return
|
||||
root.opened = false
|
||||
if (row.entryType === "image") {
|
||||
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-file", row.mime, row.path])
|
||||
} else if (row.fullText) {
|
||||
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-text", "--shift-insert", "--history-index", String(row.historyIndex)])
|
||||
}
|
||||
}
|
||||
|
||||
function copySelected(row) {
|
||||
if (!row) return
|
||||
root.opened = false
|
||||
if (row.entryType === "image") {
|
||||
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-file", "--copy-only", row.mime, row.path])
|
||||
} else if (row.fullText) {
|
||||
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-text", "--copy-only", "--history-index", String(row.historyIndex)])
|
||||
}
|
||||
}
|
||||
|
||||
function openSelected(row) {
|
||||
if (!row) return
|
||||
root.opened = false
|
||||
Quickshell.execDetached([root.blobPath + "/bin/blob-clipboard-open", "--history-index", String(row.historyIndex)])
|
||||
}
|
||||
|
||||
Component.onCompleted: initProc.running = true
|
||||
|
||||
ListModel { id: displayModel }
|
||||
|
||||
PointerMoveGate {
|
||||
id: pointerGate
|
||||
referenceItem: card
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: historyFile
|
||||
path: root.historyPath
|
||||
watchChanges: true
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
onLoaded: root.loadHistory(text())
|
||||
onLoadFailed: root.loadHistory("[]")
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
// Reap watchers left behind by a previous shell instance, then start our
|
||||
// own. The pdeathsig on the watchers makes the kernel kill them whenever
|
||||
// the shell exits, however it exits, so no further lifecycle management.
|
||||
Process {
|
||||
id: initProc
|
||||
command: ["pkill", "-f", "wl-paste .*--watch .*/shell/plugins/clipboard/capture\\.sh"]
|
||||
onExited: {
|
||||
currentProc.running = true
|
||||
textWatchProc.running = true
|
||||
imageWatchProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: currentProc
|
||||
command: [root.captureScript]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.addClipboardJson(text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: textWatchProc
|
||||
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "text", "--watch", root.captureScript, "text"]
|
||||
onExited: watchRestartTimer.restart()
|
||||
stdout: SplitParser {
|
||||
onRead: function(data) { root.addClipboardJson(data) }
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: imageWatchProc
|
||||
command: ["setpriv", "--pdeathsig", "TERM", "wl-paste", "--type", "image/png", "--watch", root.captureScript, "image/png"]
|
||||
onExited: watchRestartTimer.restart()
|
||||
stdout: SplitParser {
|
||||
onRead: function(data) { root.addClipboardJson(data) }
|
||||
}
|
||||
}
|
||||
|
||||
// A watcher that dies takes clipboard history with it, silently: copying still
|
||||
// works, the picker still opens, and the old entries are all still there, so
|
||||
// nothing recorded until the next shell reload. Bring it back instead.
|
||||
Timer {
|
||||
id: watchRestartTimer
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
if (!textWatchProc.running) textWatchProc.running = true
|
||||
if (!imageWatchProc.running) imageWatchProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: panel
|
||||
visible: root.opened
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
WlrLayershell.namespace: "blob-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: {} }
|
||||
|
||||
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 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.removeDisplayIndex(root.selectedIndex)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Up) {
|
||||
root.select(-1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Down) {
|
||||
root.select(1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageUp) {
|
||||
root.select(-6)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_PageDown) {
|
||||
root.select(6)
|
||||
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(displayModel.count - 1)
|
||||
event.accepted = true
|
||||
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
|
||||
if (root.cursorActive && (event.modifiers & Qt.AltModifier)) root.openIndex(root.selectedIndex)
|
||||
else if (root.cursorActive && (event.modifiers & Qt.ShiftModifier)) root.copyIndex(root.selectedIndex)
|
||||
else if (root.cursorActive) root.activateIndex(root.selectedIndex)
|
||||
else if (displayModel.count > 0) root.cursorActive = true
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: card.contentTopInset
|
||||
anchors.rightMargin: card.contentRightInset
|
||||
anchors.bottomMargin: card.contentBottomInset
|
||||
anchors.leftMargin: card.contentLeftInset
|
||||
spacing: root.contentSpacing
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: root.headerHeight
|
||||
radius: root.cornerRadius
|
||||
color: "transparent"
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.filterText || "Search clipboard…"
|
||||
color: root.foreground
|
||||
opacity: root.filterText ? 1 : 0.58
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.heading
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: parent.height - root.headerHeight - root.contentSpacing
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
width: parent.width / 2
|
||||
height: parent.height
|
||||
clip: true
|
||||
|
||||
ListView {
|
||||
id: resultList
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.contentMargin
|
||||
model: displayModel
|
||||
clip: true
|
||||
spacing: Style.space(4)
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
delegate: Rectangle {
|
||||
id: row
|
||||
required property int index
|
||||
required property string entryType
|
||||
required property string previewText
|
||||
required property string fullText
|
||||
required property string previewImage
|
||||
|
||||
readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
|
||||
|
||||
width: ListView.view.width
|
||||
height: root.rowHeight
|
||||
radius: root.cornerRadius
|
||||
color: hasCursor ? root.selectedBackground : "transparent"
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.space(12)
|
||||
anchors.rightMargin: Style.space(12)
|
||||
anchors.topMargin: Style.space(8)
|
||||
anchors.bottomMargin: Style.space(8)
|
||||
spacing: Style.space(10)
|
||||
|
||||
Image {
|
||||
visible: parent.parent.previewImage.length > 0
|
||||
width: visible ? parent.height : 0
|
||||
height: parent.height
|
||||
source: parent.parent.previewImage
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
width: parent.width - (parent.parent.previewImage.length > 0 ? parent.height + parent.spacing : 0)
|
||||
height: parent.height
|
||||
text: parent.parent.previewText
|
||||
color: parent.parent.hasCursor ? root.selectedText : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
opacity: parent.parent.entryType === "image" || parent.parent.entryType === "file" ? 0.72 : 1.0
|
||||
elide: Text.ElideRight
|
||||
wrapMode: Text.NoWrap
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
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.activateIndex(row.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: parent.width / 2
|
||||
height: parent.height
|
||||
clip: true
|
||||
|
||||
property var activeRow: displayModel.count > 0 && root.selectedIndex >= 0 && root.selectedIndex < displayModel.count ? displayModel.get(root.selectedIndex) : null
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: Style.normalBorderWidth
|
||||
color: Util.alpha(root.border, 0.28)
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: parent.activeRow && !parent.activeRow.previewImage
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.contentMargin
|
||||
anchors.rightMargin: 0
|
||||
anchors.topMargin: 0
|
||||
anchors.bottomMargin: 0
|
||||
text: parent.activeRow ? parent.activeRow.fullText : ""
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.title
|
||||
wrapMode: Text.WrapAnywhere
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignTop
|
||||
}
|
||||
|
||||
Image {
|
||||
visible: parent.activeRow && parent.activeRow.previewImage
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: root.contentMargin
|
||||
anchors.rightMargin: 0
|
||||
anchors.topMargin: 0
|
||||
anchors.bottomMargin: 0
|
||||
source: parent.activeRow ? parent.activeRow.previewImage : ""
|
||||
fillMode: Image.PreserveAspectFit
|
||||
verticalAlignment: Image.AlignTop
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
textFormat: Text.PlainText
|
||||
text: root.history.length === 0 ? "Clipboard is empty" : "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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
function normalizeEntry(value) {
|
||||
if (typeof value === "string")
|
||||
return value.trim().length > 0 ? { type: "text", text: value } : null
|
||||
|
||||
if (!value || typeof value !== "object") return null
|
||||
|
||||
var type = String(value.type || value.kind || "")
|
||||
if (type === "text") {
|
||||
var text = String(value.text || "")
|
||||
return text.trim().length > 0 ? { type: "text", text: text } : null
|
||||
}
|
||||
|
||||
if (type === "image") {
|
||||
var path = String(value.path || "")
|
||||
if (!path) return null
|
||||
var entry = {
|
||||
type: "image",
|
||||
path: path,
|
||||
mime: String(value.mime || "image/png")
|
||||
}
|
||||
if (value.capturedAt !== undefined && value.capturedAt !== null)
|
||||
entry.capturedAt = String(value.capturedAt)
|
||||
return entry
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function entryKey(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.type === "image") return "image:" + String(entry.path || "")
|
||||
return "text:" + String(entry.text || "")
|
||||
}
|
||||
|
||||
function parseHistory(raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(String(raw || "[]"))
|
||||
var next = []
|
||||
if (!Array.isArray(parsed)) return next
|
||||
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
var entry = normalizeEntry(parsed[i])
|
||||
if (entry) next.push(entry)
|
||||
}
|
||||
return next
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function addEntry(history, entry, limit) {
|
||||
var normalized = normalizeEntry(entry)
|
||||
var max = limit === undefined || limit === null ? 100 : Number(limit)
|
||||
if (isNaN(max)) max = 100
|
||||
max = Math.max(0, max)
|
||||
if (!normalized) return Array.isArray(history) ? history.slice(0, max) : []
|
||||
if (max === 0) return []
|
||||
|
||||
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 = normalizeEntry(values[i])
|
||||
if (!existing || entryKey(existing) === key) continue
|
||||
next.push(existing)
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
function removeEntryAt(history, index) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var target = Number(index)
|
||||
if (isNaN(target) || target < 0 || target >= values.length) return values.slice()
|
||||
|
||||
var next = values.slice()
|
||||
next.splice(target, 1)
|
||||
return next
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
return []
|
||||
}
|
||||
|
||||
function parseEntryJson(line) {
|
||||
var raw = String(line || "").trim()
|
||||
if (!raw) return null
|
||||
try { return normalizeEntry(JSON.parse(raw)) } catch (e) { return null }
|
||||
}
|
||||
|
||||
function searchableText(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.type === "image") return "image screenshot " + String(entry.mime || "") + " " + String(entry.capturedAt || "")
|
||||
return String(entry.text || "") + " " + fileEntryText(entry)
|
||||
}
|
||||
|
||||
function decodeFileUri(uri) {
|
||||
var value = String(uri || "").trim()
|
||||
if (value.indexOf("file://") !== 0) return ""
|
||||
|
||||
var path = value.substring(7)
|
||||
if (path.indexOf("localhost/") === 0) path = path.substring(9)
|
||||
if (path.charAt(0) !== "/") return ""
|
||||
|
||||
try { return decodeURIComponent(path) } catch (e) { return path }
|
||||
}
|
||||
|
||||
function filePaths(entry) {
|
||||
if (!entry || entry.type !== "text") return []
|
||||
|
||||
var lines = String(entry.text || "").split(/\r?\n/)
|
||||
var paths = []
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var path = decodeFileUri(lines[i])
|
||||
if (path) paths.push(path)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
function fileName(path) {
|
||||
var parts = String(path || "").split("/")
|
||||
return parts.length > 0 ? parts[parts.length - 1] : String(path || "")
|
||||
}
|
||||
|
||||
function isImagePath(path) {
|
||||
return /\.(png|jpe?g|webp|gif|bmp|tiff?)$/i.test(String(path || ""))
|
||||
}
|
||||
|
||||
function fileEntryText(entry) {
|
||||
var paths = filePaths(entry)
|
||||
if (paths.length === 0) return ""
|
||||
if (paths.length === 1) return fileName(paths[0])
|
||||
return paths.length + " files"
|
||||
}
|
||||
|
||||
function imagePreviewText(entry) {
|
||||
var timestamp = String(entry && entry.capturedAt || "")
|
||||
if (!timestamp) return "Image"
|
||||
|
||||
var label = String(entry && entry.mime || "") === "image/png" ? "Screenshot" : "Image"
|
||||
return label + " from " + timestamp
|
||||
}
|
||||
|
||||
function previewText(entry) {
|
||||
if (!entry) return ""
|
||||
if (entry.type === "image") return imagePreviewText(entry)
|
||||
var fileText = fileEntryText(entry)
|
||||
if (fileText) return fileText
|
||||
return String(entry.text || "").replace(/\s+/g, " ")
|
||||
}
|
||||
|
||||
function fullText(entry) {
|
||||
if (!entry) return ""
|
||||
var paths = filePaths(entry)
|
||||
if (paths.length > 0) return paths.join("\n")
|
||||
return String(entry.text || "")
|
||||
}
|
||||
|
||||
// The picker only ever searches and renders a prefix of an entry, so scan and
|
||||
// render just that much. A single huge paste otherwise costs hundreds of
|
||||
// megabytes of string work on every keystroke and stalls the whole shell.
|
||||
// Pasting reads the full entry back from history by index, so nothing is lost.
|
||||
var displayTextLimit = 8192
|
||||
|
||||
function cappedEntry(entry) {
|
||||
if (!entry || entry.type !== "text" || entry.text.length <= displayTextLimit) return entry
|
||||
|
||||
// Cut on a line break so a file:// URI never truncates into a bogus path.
|
||||
var cut = entry.text.lastIndexOf("\n", displayTextLimit)
|
||||
return { type: "text", text: entry.text.slice(0, cut > 0 ? cut : displayTextLimit) }
|
||||
}
|
||||
|
||||
function displayRows(history, query, limit) {
|
||||
var values = Array.isArray(history) ? history : []
|
||||
var needle = String(query || "").trim().toLowerCase()
|
||||
var max = limit === undefined || limit === null ? 50 : Number(limit)
|
||||
if (isNaN(max)) max = 50
|
||||
max = Math.max(0, max)
|
||||
if (max === 0) return []
|
||||
|
||||
var rows = []
|
||||
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var entry = cappedEntry(normalizeEntry(values[i]))
|
||||
if (!entry) continue
|
||||
if (needle && searchableText(entry).toLowerCase().indexOf(needle) < 0) continue
|
||||
|
||||
var paths = filePaths(entry)
|
||||
var isFile = paths.length > 0
|
||||
var isImage = entry.type === "image"
|
||||
var previewPath = isImage ? String(entry.path || "") : (isFile && paths.length === 1 && isImagePath(paths[0]) ? paths[0] : "")
|
||||
rows.push({
|
||||
entryType: isFile ? "file" : entry.type,
|
||||
fullText: isImage ? "" : fullText(entry),
|
||||
previewText: previewText(entry),
|
||||
previewImage: previewPath,
|
||||
path: isImage ? String(entry.path || "") : (isFile && paths.length === 1 ? paths[0] : ""),
|
||||
mime: isImage ? String(entry.mime || "image/png") : "text/plain",
|
||||
index: i
|
||||
})
|
||||
if (rows.length >= max) break
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
normalizeEntry: normalizeEntry,
|
||||
entryKey: entryKey,
|
||||
parseHistory: parseHistory,
|
||||
addEntry: addEntry,
|
||||
removeEntryAt: removeEntryAt,
|
||||
clearHistory: clearHistory,
|
||||
parseEntryJson: parseEntryJson,
|
||||
searchableText: searchableText,
|
||||
previewText: previewText,
|
||||
imagePreviewText: imagePreviewText,
|
||||
filePaths: filePaths,
|
||||
fileEntryText: fileEntryText,
|
||||
fullText: fullText,
|
||||
displayRows: displayRows
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Captures the current clipboard as a JSON entry on stdout. In watch mode,
|
||||
# wl-paste invokes this with the payload on stdin and the mime as $1. Without
|
||||
# arguments, it snapshots the current selection itself.
|
||||
|
||||
set -o pipefail
|
||||
|
||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/blob"
|
||||
IMAGE_DIR="$STATE_DIR/clipboard-images"
|
||||
mkdir -p "$IMAGE_DIR"
|
||||
|
||||
types=$(wl-paste --list-types 2>/dev/null || true)
|
||||
|
||||
if [[ ${CLIPBOARD_STATE:-} == "sensitive" ]] || grep -qx 'x-kde-passwordManagerHint' <<<"$types"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
emit_image() {
|
||||
local mime="$1"
|
||||
local ext tmp hash file
|
||||
|
||||
ext=${mime#image/}
|
||||
[[ $ext == jpeg ]] && ext=jpg
|
||||
|
||||
tmp=$(mktemp --tmpdir="$IMAGE_DIR" clipboard.XXXXXX) || return 0
|
||||
cat >"$tmp"
|
||||
if [[ ! -s $tmp ]]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
|
||||
hash=$(sha256sum "$tmp" | awk '{print $1}')
|
||||
file="$IMAGE_DIR/$hash.$ext"
|
||||
if [[ -e $file ]]; then
|
||||
rm -f "$tmp"
|
||||
else
|
||||
mv "$tmp" "$file"
|
||||
fi
|
||||
|
||||
jq -cn --arg mime "$mime" --arg path "$file" --arg captured_at "$(date +'%A %H:%M')" \
|
||||
'{type:"image", mime:$mime, path:$path, capturedAt:$captured_at}'
|
||||
}
|
||||
|
||||
emit_text() {
|
||||
perl -MEncode=decode,FB_CROAK,LEAVE_SRC -MJSON::PP=encode_json -0777 -e '
|
||||
my $raw = <STDIN>;
|
||||
exit unless length $raw;
|
||||
|
||||
my $encoding;
|
||||
my $heuristic_encoding = 0;
|
||||
if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) {
|
||||
$encoding = "UTF-16";
|
||||
} elsif (length($raw) % 2 == 0 && index($raw, "\0") >= 0) {
|
||||
my $units = length($raw) / 2;
|
||||
my $nuls = $raw =~ tr/\0/\0/;
|
||||
|
||||
# Neither byte lane can reach the padding threshold when the entire
|
||||
# payload contains fewer NULs than that, so avoid two full string passes.
|
||||
if ($nuls * 4 >= $units * 3) {
|
||||
my $even_bytes = $raw;
|
||||
$even_bytes =~ s/(.)./$1/sg;
|
||||
my $even_nuls = $even_bytes =~ tr/\0/\0/;
|
||||
undef $even_bytes;
|
||||
|
||||
my $odd_bytes = $raw;
|
||||
$odd_bytes =~ s/.(.)/$1/sg;
|
||||
my $odd_nuls = $odd_bytes =~ tr/\0/\0/;
|
||||
|
||||
# BOM-less UTF-16 is indistinguishable from NUL-separated bytes. Decode
|
||||
# only when at least three quarters of the code units have consistent
|
||||
# padding and fewer than one quarter have NULs in the opposite byte.
|
||||
if ($odd_nuls * 4 >= $units * 3 && $even_nuls * 4 < $units) {
|
||||
$encoding = "UTF-16LE";
|
||||
$heuristic_encoding = 1;
|
||||
} elsif ($even_nuls * 4 >= $units * 3 && $odd_nuls * 4 < $units) {
|
||||
$encoding = "UTF-16BE";
|
||||
$heuristic_encoding = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK | LEAVE_SRC) } : undef;
|
||||
if ($heuristic_encoding && defined($text) && $text =~ /[\x00-\x08\x0E-\x1A\x1C-\x1F]/) {
|
||||
$text = undef;
|
||||
}
|
||||
$text = decode("UTF-8", $raw) unless defined $text;
|
||||
print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n";
|
||||
'
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
text) emit_text; exit 0 ;;
|
||||
image/*) emit_image "$1"; exit 0 ;;
|
||||
esac
|
||||
|
||||
for mime in image/png image/jpeg image/webp image/gif image/bmp image/tiff; do
|
||||
if grep -qx "$mime" <<<"$types"; then
|
||||
timeout 2s wl-paste --type "$mime" 2>/dev/null | emit_image "$mime"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -q '^text/' <<<"$types" || grep -qx 'UTF8_STRING' <<<"$types" || grep -qx 'STRING' <<<"$types"; then
|
||||
wl-paste --type text --no-newline 2>/dev/null | emit_text
|
||||
fi
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.clipboard",
|
||||
"name": "Clipboard",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "A clipboard manager to view and paste history",
|
||||
"kinds": [
|
||||
"overlay"
|
||||
],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"overlay": "Clipboard.qml"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user