Clipboard history is fed by two wl-paste --watch processes started once when the shell loads. When one dies, nothing notices: copying still works, the picker still opens, and the old entries are all still there, so history just silently stops recording until the next shell reload. Respawn them on exit instead. The one-shot timer both coalesces the case where both watchers die together and keeps a wl-paste that cannot start at all from spinning as fast as fork allows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
610 lines
20 KiB
QML
610 lines
20 KiB
QML
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 omarchyPath: Quickshell.env("OMARCHY_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/omarchy/clipboard-history.json"
|
|
property string captureScript: root.omarchyPath + "/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.omarchyPath + "/bin/omarchy-clipboard-paste-file", row.mime, row.path])
|
|
} else if (row.fullText) {
|
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-text", "--shift-insert", "--history-index", String(row.historyIndex)])
|
|
}
|
|
}
|
|
|
|
function copySelected(row) {
|
|
if (!row) return
|
|
root.opened = false
|
|
if (row.entryType === "image") {
|
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-file", "--copy-only", row.mime, row.path])
|
|
} else if (row.fullText) {
|
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-text", "--copy-only", "--history-index", String(row.historyIndex)])
|
|
}
|
|
}
|
|
|
|
function openSelected(row) {
|
|
if (!row) return
|
|
root.opened = false
|
|
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-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: "omarchy-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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|