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