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 // Confirmation dialog state property bool showClearConfirmation: false 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 = "" // Clean up temporary image files cleanupTempFiles() } function cleanupTempFiles() { cleanupProcess.command = ["sh", "-c", "rm -f /tmp/clipboard_preview_*.png"] cleanupProcess.running = true } 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 // Simply hide the clipboard interface console.log("ClipboardHistory: Entry copied, hiding interface") hide() } function deleteEntry(entry) { // Use the full entry line for deletion console.log("Deleting entry:", entry) deleteProcess.command = ["sh", "-c", `echo '${entry.replace(/'/g, "'\\''")}' | cliphist delete`] 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/") || content.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(content)) { // Extract dimensions if available const dimensionMatch = content.match(/(\d+)x(\d+)/) if (dimensionMatch) { return `Image ${dimensionMatch[1]}×${dimensionMatch[2]}` } // Extract file type if available const typeMatch = content.match(/\b(png|jpg|jpeg|gif|bmp|webp)\b/i) if (typeMatch) { return `Image (${typeMatch[1].toUpperCase()})` } return "Image" } // Truncate long text if (content.length > 100) { return content.substring(0, 100) + "..." } return content } function getEntryType(entry) { // Improved image detection if (entry.includes("image/") || entry.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(entry) || /\b(png|jpg|jpeg|gif|bmp|webp)\b/i.test(entry)) { 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(500, parent.width - 200) height: Math.min(500, parent.height - 100) anchors.centerIn: parent color: activeTheme.popupBackground() radius: activeTheme.cornerRadiusXLarge border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.08) 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.primary.r, activeTheme.primary.g, activeTheme.primary.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.primary : activeTheme.surfaceText } MouseArea { id: clearArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: showClearConfirmation = true } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } // Close button Rectangle { width: 40 height: 32 radius: activeTheme.cornerRadius color: closeArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.12) : "transparent" Text { anchors.centerIn: parent text: "close" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize color: closeArea.containsMouse ? activeTheme.primary : 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, activeTheme.getContentBackgroundAlpha() * 0.4) border.color: searchField.focus ? activeTheme.primary : Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.08) 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 selectByMouse: true MouseArea { anchors.fill: parent hoverEnabled: true cursorShape: Qt.IBeamCursor acceptedButtons: Qt.NoButton } 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 // Improve scrolling responsiveness ScrollBar.vertical.policy: ScrollBar.AsNeeded ScrollBar.vertical.width: 12 ScrollBar.vertical.minimumSize: 0.1 // Minimum scrollbar handle size // Enable faster scrolling wheelEnabled: true ListView { id: clipboardList model: filteredClipboardModel spacing: activeTheme.spacingS // Improve scrolling performance cacheBuffer: 100 boundsBehavior: Flickable.StopAtBounds // Make mouse wheel scrolling more responsive property real wheelStepSize: 60 MouseArea { anchors.fill: parent acceptedButtons: Qt.NoButton onWheel: (wheel) => { var delta = wheel.angleDelta.y var steps = delta / 120 // Standard wheel step clipboardList.contentY -= steps * clipboardList.wheelStepSize // Ensure we stay within bounds if (clipboardList.contentY < 0) { clipboardList.contentY = 0 } else if (clipboardList.contentY > clipboardList.contentHeight - clipboardList.height) { clipboardList.contentY = Math.max(0, clipboardList.contentHeight - clipboardList.height) } } } delegate: Rectangle { width: clipboardList.width - 16 // Account for scrollbar space 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 content Row { anchors.verticalCenter: parent.verticalCenter width: parent.width - 80 // Adjusted for index number and delete button spacing: activeTheme.spacingM // Image preview - actual image display for images Rectangle { width: entryType === "image" ? 48 : 0 height: entryType === "image" ? 36 : 0 radius: activeTheme.cornerRadiusSmall color: Qt.rgba(activeTheme.surfaceVariant.r, activeTheme.surfaceVariant.g, activeTheme.surfaceVariant.b, 0.1) border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.2) border.width: 1 visible: entryType === "image" clip: true property string entryId: model.entry ? model.entry.split('\t')[0] : "" property string tempImagePath: "/tmp/clipboard_preview_" + entryId + ".png" // Actual image preview using cliphist decode Image { id: imagePreview anchors.fill: parent anchors.margins: 1 fillMode: Image.PreserveAspectCrop asynchronous: true cache: false source: parent.entryType === "image" && parent.entryId ? "file://" + parent.tempImagePath : "" Component.onCompleted: { if (parent.entryType === "image" && parent.entryId) { // Simple approach: use shell redirection to write to file imageDecodeProcess.entryId = parent.entryId imageDecodeProcess.tempPath = parent.tempImagePath imageDecodeProcess.imagePreview = imagePreview imageDecodeProcess.command = ["sh", "-c", `cliphist decode ${parent.entryId} > "${parent.tempImagePath}" 2>/dev/null`] imageDecodeProcess.running = true } } onStatusChanged: { if (status === Image.Error) { console.warn("Failed to load clipboard image from:", source) } } // Fallback icon when image fails to load or is loading Text { anchors.centerIn: parent text: imagePreview.status === Image.Loading ? "hourglass_empty" : imagePreview.status === Image.Error ? "broken_image" : "photo" font.family: activeTheme.iconFont font.pixelSize: imagePreview.status === Image.Loading ? 14 : 18 color: imagePreview.status === Image.Error ? activeTheme.error : activeTheme.primary visible: imagePreview.status !== Image.Ready SequentialAnimation on opacity { running: imagePreview.status === Image.Loading loops: Animation.Infinite NumberAnimation { to: 0.3; duration: 500 } NumberAnimation { to: 1.0; duration: 500 } } } } } Column { id: contentColumn anchors.verticalCenter: parent.verticalCenter width: parent.width - (entryType === "image" ? 60 : 0) 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 : 1 elide: Text.ElideRight visible: true // Show preview for all entry types including images } } } // Actions - Single centered delete button Rectangle { anchors.verticalCenter: parent.verticalCenter width: 32 height: 32 radius: activeTheme.cornerRadius color: deleteArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.12) : "transparent" z: 100 // Ensure it's above other elements Text { anchors.centerIn: parent text: "delete" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSize - 4 color: deleteArea.containsMouse ? activeTheme.primary : activeTheme.surfaceText } MouseArea { id: deleteArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor z: 101 // Ensure click area is above everything onClicked: (mouse) => { console.log("Delete clicked for entry:", model.entry) deleteEntry(model.entry) // Prevent the click from propagating to the entry area mouse.accepted = true } } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } } MouseArea { id: entryArea anchors.fill: parent anchors.rightMargin: 40 // Leave space for delete button 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) } } } } // Clear All Confirmation Dialog Rectangle { anchors.fill: parent color: Qt.rgba(0, 0, 0, 0.4) visible: showClearConfirmation z: 999 MouseArea { anchors.fill: parent onClicked: clipboardHistory.showClearConfirmation = false } } Rectangle { anchors.centerIn: parent width: 350 height: 200 // Increased height for better spacing radius: activeTheme.cornerRadiusLarge color: activeTheme.popupBackground() border.color: Qt.rgba(activeTheme.outline.r, activeTheme.outline.g, activeTheme.outline.b, 0.08) border.width: 1 visible: showClearConfirmation z: 1000 Column { anchors.centerIn: parent spacing: activeTheme.spacingL width: parent.width - 40 // Add top padding Item { width: 1 height: activeTheme.spacingM } Text { anchors.horizontalCenter: parent.horizontalCenter text: "warning" font.family: activeTheme.iconFont font.pixelSize: activeTheme.iconSizeLarge color: activeTheme.error } Text { anchors.horizontalCenter: parent.horizontalCenter text: "Clear All Clipboard History?" font.pixelSize: activeTheme.fontSizeLarge font.weight: Font.Bold color: activeTheme.surfaceText horizontalAlignment: Text.AlignHCenter } Text { anchors.horizontalCenter: parent.horizontalCenter text: "This action cannot be undone. All clipboard entries will be permanently deleted." font.pixelSize: activeTheme.fontSizeMedium color: Qt.rgba(activeTheme.surfaceText.r, activeTheme.surfaceText.g, activeTheme.surfaceText.b, 0.7) horizontalAlignment: Text.AlignHCenter wrapMode: Text.WordWrap width: parent.width } Row { anchors.horizontalCenter: parent.horizontalCenter spacing: activeTheme.spacingM // Cancel button Rectangle { width: 100 height: 40 radius: activeTheme.cornerRadius color: cancelArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.08) : "transparent" border.color: activeTheme.primary border.width: 1 Text { anchors.centerIn: parent text: "Cancel" font.pixelSize: activeTheme.fontSizeMedium font.weight: Font.Medium color: activeTheme.primary } MouseArea { id: cancelArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: clipboardHistory.showClearConfirmation = false } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } // Clear button Rectangle { width: 100 height: 40 radius: activeTheme.cornerRadius color: confirmArea.containsMouse ? Qt.rgba(activeTheme.primary.r, activeTheme.primary.g, activeTheme.primary.b, 0.8) : activeTheme.primary Text { anchors.centerIn: parent text: "Clear All" font.pixelSize: activeTheme.fontSizeMedium font.weight: Font.Medium color: activeTheme.surface } MouseArea { id: confirmArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor onClicked: { clipboardHistory.showClearConfirmation = false clearAll() } } Behavior on color { ColorAnimation { duration: activeTheme.shortDuration } } } } // Add some bottom padding Item { width: 1 height: activeTheme.spacingM } } } } // Clipboard processes Process { id: cleanupProcess running: false onExited: (exitCode) => { if (exitCode === 0) { console.log("Temporary image files cleaned up") } } } Process { id: imageDecodeProcess running: false property string entryId: "" property string tempPath: "" property var imagePreview: null onExited: (exitCode) => { if (exitCode === 0 && imagePreview && tempPath) { // Force the Image component to reload Qt.callLater(function() { imagePreview.source = "" imagePreview.source = "file://" + tempPath }) } } onStarted: { console.log("Starting image decode for entry:", entryId, "to path:", tempPath) } } 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") } } // Handle keyboard shortcuts Keys.onPressed: (event) => { if (event.key === Qt.Key_Escape) { clipboardHistory.hide() } } Component.onCompleted: { focus = true } } 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() } } } 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" } } }