import QtQuick import QtQuick.Controls import Quickshell import Quickshell.Widgets import Quickshell.Wayland import Quickshell.Io import "../Common" PanelWindow { id: clipboardHistory property bool isVisible: false property int totalCount: 0 // Use the global Theme singleton property var activeTheme: Theme // Window properties color: "transparent" visible: isVisible anchors { top: true left: true right: true bottom: true } WlrLayershell.layer: WlrLayershell.Overlay WlrLayershell.exclusiveZone: -1 WlrLayershell.keyboardFocus: isVisible ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None // Clipboard entries model property var clipboardEntries: [] ListModel { id: clipboardModel } ListModel { id: filteredClipboardModel } function updateFilteredModel() { filteredClipboardModel.clear() for (let i = 0; i < clipboardModel.count; i++) { const entry = clipboardModel.get(i).entry if (searchField.text.trim().length === 0) { filteredClipboardModel.append({"entry": entry}) } else { const content = getEntryPreview(entry).toLowerCase() if (content.includes(searchField.text.toLowerCase())) { filteredClipboardModel.append({"entry": entry}) } } } // Update total count clipboardHistory.totalCount = filteredClipboardModel.count } function toggle() { if (isVisible) { hide() } else { show() } } function show() { clipboardHistory.isVisible = true searchField.focus = true refreshClipboard() console.log("ClipboardHistory: Opening and refreshing") } function hide() { clipboardHistory.isVisible = false searchField.focus = false searchField.text = "" } function refreshClipboard() { clipboardProcess.running = true } function copyEntry(entry) { const entryId = entry.split('\t')[0] copyProcess.command = ["sh", "-c", `cliphist decode ${entryId} | wl-copy`] copyProcess.running = true hide() } function deleteEntry(entry) { const entryId = entry.split('\t')[0] deleteProcess.command = ["cliphist", "delete-query", entryId] deleteProcess.running = true } function clearAll() { clearProcess.running = true } function getEntryPreview(entry) { // Remove cliphist ID prefix and clean up content let content = entry.replace(/^\s*\d+\s+/, "") // Handle different content types if (content.includes("image/")) { const match = content.match(/(\d+)x(\d+)/) return match ? `Image ${match[1]}×${match[2]}` : "Image" } // Truncate long text if (content.length > 100) { return content.substring(0, 100) + "..." } return content } function getEntryType(entry) { if (entry.includes("image/")) return "image" if (entry.length > 200) return "long_text" return "text" } // Background overlay Rectangle { anchors.fill: parent color: Qt.rgba(0, 0, 0, 0.5) opacity: clipboardHistory.isVisible ? 1.0 : 0.0 visible: clipboardHistory.isVisible Behavior on opacity { NumberAnimation { duration: activeTheme.mediumDuration easing.type: activeTheme.emphasizedEasing } } MouseArea { anchors.fill: parent enabled: clipboardHistory.isVisible onClicked: clipboardHistory.hide() } } // Main clipboard container Rectangle { id: clipboardContainer width: Math.min(600, parent.width - 200) height: Math.min(500, parent.height - 100) anchors.centerIn: parent color: activeTheme.surfaceContainer radius: activeTheme.cornerRadiusXLarge border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.2) border.width: 1 opacity: clipboardHistory.isVisible ? 1.0 : 0.0 scale: clipboardHistory.isVisible ? 1.0 : 0.9 Behavior on opacity { NumberAnimation { duration: activeTheme.mediumDuration easing.type: activeTheme.emphasizedEasing } } Behavior on scale { NumberAnimation { duration: activeTheme.mediumDuration easing.type: activeTheme.emphasizedEasing } } // Header section Column { id: headerSection anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right anchors.margins: activeTheme.spacingXL spacing: activeTheme.spacingL // Title and actions Item { width: parent.width height: 40 Text { id: titleText anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter text: "Clipboard History" + (clipboardHistory.totalCount > 0 ? ` (${clipboardHistory.totalCount})` : "") font.pixelSize: activeTheme.fontSizeLarge + 4 font.weight: Font.Bold color: activeTheme.surfaceText } Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: activeTheme.spacingS // Clear all button Rectangle { id: clearAllButton width: 40 height: 32 radius: activeTheme.cornerRadius color: clearArea.containsMouse ? Qt.rgba(activeTheme.error.r, activeTheme.error.g, activeTheme.error.b, 0.12) : "transparent" visible: clipboardHistory.totalCount > 0 Text { anchors.centerIn: parent text: "delete_sweep" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize color: clearArea.containsMouse ? activeTheme.error : activeTheme.surfaceText } MouseArea { id: clearArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: clearAll() } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } // Close button Rectangle { width: 40 height: 32 radius: activeTheme.cornerRadius color: closeArea.containsMouse ? Qt.rgba(activeTheme.error.r, activeTheme.error.g, activeTheme.error.b, 0.12) : "transparent" Text { anchors.centerIn: parent text: "close" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize color: closeArea.containsMouse ? activeTheme.error : activeTheme.surfaceText } MouseArea { id: closeArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: clipboardHistory.hide() } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } } } // Search field Rectangle { width: parent.width height: 48 radius: activeTheme.cornerRadiusLarge color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.3) border.color: searchField.focus ? activeTheme.primary : Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.2) border.width: searchField.focus ? 2 : 1 Row { anchors.left: parent.left anchors.leftMargin: activeTheme.spacingL anchors.verticalCenter: parent.verticalCenter spacing: activeTheme.spacingM Text { text: "search" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize color: searchField.focus ? activeTheme.primary : Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.6) anchors.verticalCenter: parent.verticalCenter } TextInput { id: searchField width: parent.parent.width - 80 height: parent.parent.height font.pixelSize: activeTheme.fontSizeLarge color: activeTheme.surfaceText verticalAlignment: TextInput.AlignVCenter onTextChanged: updateFilteredModel() Keys.onPressed: (event) => { if (event.key === Qt.Key_Escape) { clipboardHistory.hide() } } // Placeholder text Text { text: "Search clipboard entries..." font: searchField.font color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.6) anchors.verticalCenter: parent.verticalCenter visible: searchField.text.length === 0 && !searchField.focus } } } Behavior on border.color { ColorAnimation { duration: activeTheme.shortDuration } } } } // Clipboard entries Rectangle { anchors.top: headerSection.bottom anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right anchors.margins: activeTheme.spacingXL anchors.topMargin: activeTheme.spacingL color: "transparent" ScrollView { anchors.fill: parent clip: true ListView { id: clipboardList model: filteredClipboardModel spacing: activeTheme.spacingS delegate: Rectangle { width: clipboardList.width height: Math.max(60, contentColumn.implicitHeight + activeTheme.spacingM * 2) radius: activeTheme.cornerRadius color: entryArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.08) : Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.05) border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.1) border.width: 1 property string entryType: getEntryType(model.entry) property string entryPreview: getEntryPreview(model.entry) property int entryIndex: index + 1 Row { anchors.fill: parent anchors.margins: activeTheme.spacingM spacing: activeTheme.spacingL // Index number Rectangle { width: 24 height: 24 radius: 12 color: Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.2) anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent text: entryIndex.toString() font.pixelSize: activeTheme.fontSizeSmall font.weight: Font.Bold color: activeTheme.primary } } // Entry type icon Rectangle { width: 36 height: 36 radius: activeTheme.cornerRadius color: Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.12) anchors.verticalCenter: parent.verticalCenter Text { anchors.centerIn: parent text: { switch (entryType) { case "image": return "image" case "long_text": return "subject" default: return "content_paste" } } font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize - 4 color: activeTheme.primary } } // Entry content Column { id: contentColumn anchors.verticalCenter: parent.verticalCenter width: parent.width - 140 // Adjusted for index number spacing: activeTheme.spacingXS Text { text: { switch (entryType) { case "image": return "Image • " + entryPreview case "long_text": return "Long Text" default: return "Text" } } font.pixelSize: activeTheme.fontSizeSmall color: activeTheme.primary font.weight: Font.Medium width: parent.width elide: Text.ElideRight } Text { text: entryPreview font.pixelSize: activeTheme.fontSizeMedium color: activeTheme.surfaceText width: parent.width wrapMode: Text.WordWrap maximumLineCount: entryType === "long_text" ? 3 : 2 elide: Text.ElideRight visible: entryType !== "image" } } // Actions Column { anchors.verticalCenter: parent.verticalCenter spacing: activeTheme.spacingXS // Copy button Rectangle { width: 28 height: 28 radius: activeTheme.cornerRadiusSmall color: copyArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.12) : "transparent" Text { anchors.centerIn: parent text: "content_copy" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize - 8 color: copyArea.containsMouse ? activeTheme.primary : activeTheme.surfaceText } MouseArea { id: copyArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: copyEntry(model.entry) } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } // Delete button Rectangle { width: 28 height: 28 radius: activeTheme.cornerRadiusSmall color: deleteArea.containsMouse ? Qt.rgba(activeTheme.error.r, activeTheme.error.g, activeTheme.error.b, 0.12) : "transparent" Text { anchors.centerIn: parent text: "delete" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize - 8 color: deleteArea.containsMouse ? activeTheme.error : activeTheme.surfaceText } MouseArea { id: deleteArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: deleteEntry(model.entry) } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } } } MouseArea { id: entryArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: copyEntry(model.entry) } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } } // Empty state Column { anchors.centerIn: parent spacing: activeTheme.spacingL visible: clipboardHistory.totalCount === 0 Text { anchors.horizontalCenter: parent.horizontalCenter text: "content_paste_off" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSizeLarge + 16 color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.3) } Text { anchors.horizontalCenter: parent.horizontalCenter text: "No clipboard history" font.pixelSize: activeTheme.fontSizeLarge color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.6) } Text { anchors.horizontalCenter: parent.horizontalCenter text: "Copy something to see it here" font.pixelSize: activeTheme.fontSizeMedium color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.4) } } } } } // Clipboard processes Process { id: clipboardProcess command: ["cliphist", "list"] running: false stdout: SplitParser { splitMarker: "\n" onRead: (line) => { if (line.trim()) { clipboardHistory.clipboardEntries.push(line) clipboardModel.append({"entry": line}) } } } onStarted: { clipboardHistory.clipboardEntries = [] clipboardModel.clear() console.log("ClipboardHistory: Starting cliphist process...") } onExited: (exitCode) => { if (exitCode === 0) { updateFilteredModel() } else { console.warn("ClipboardHistory: Failed to load clipboard history") } } } Process { id: copyProcess running: false onExited: (exitCode) => { if (exitCode !== 0) { console.warn("ClipboardHistory: Failed to copy entry") } } } Process { id: deleteProcess running: false onExited: (exitCode) => { if (exitCode === 0) { refreshClipboard() } } } Process { id: clearProcess command: ["cliphist", "wipe"] running: false onExited: (exitCode) => { if (exitCode === 0) { clipboardHistory.clipboardEntries = [] clipboardModel.clear() updateFilteredModel() } } } Keys.onPressed: (event) => { if (event.key === Qt.Key_Escape) { hide() } } IpcHandler { target: "clipboard" function open() { console.log("ClipboardHistory: IPC open() called") clipboardHistory.show() return "CLIPBOARD_OPEN_SUCCESS" } function close() { console.log("ClipboardHistory: IPC close() called") clipboardHistory.hide() return "CLIPBOARD_CLOSE_SUCCESS" } function toggle() { console.log("ClipboardHistory: IPC toggle() called") clipboardHistory.toggle() return "CLIPBOARD_TOGGLE_SUCCESS" } } }