From 4b7ba302722593a6c4f0208bd834a8f31c467456 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 13 Jun 2026 01:44:12 +0200 Subject: [PATCH] clipboard preview with image support --- Helpers/ClipHistory.qml | 94 ++++++++++++++++++++++++++++++++--- Modules/Clipboard/Content.qml | 70 +++++++++++++++++++++++--- 2 files changed, 149 insertions(+), 15 deletions(-) diff --git a/Helpers/ClipHistory.qml b/Helpers/ClipHistory.qml index d409af9..39c66ec 100644 --- a/Helpers/ClipHistory.qml +++ b/Helpers/ClipHistory.qml @@ -11,23 +11,33 @@ Singleton { id: root property string cliphistBinary: "cliphist" + property string currentEntry: "" property list entries: [] property real pasteDelay: 0.05 readonly property var preparedEntries: entries.map(a => ({ - name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`), + name: Fuzzy.prepare(displayText(a)), entry: a })) + property string previewImageFile: "/tmp/qs-cliphist-preview.img" + property string previewImageSource: "" + property bool previewIsImage: false + property string previewText: "" + property int previewToken: 0 property real scoreThreshold: 0.2 - function copy(entry) { + function copy(entry): void { Quickshell.execDetached(["bash", "-c", `printf '${shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]); } - function deleteEntry(entry) { + function deleteEntry(entry): void { deleteProc.deleteEntry(entry); } - function entryIsImage(entry) { + function displayText(entry): string { + return entry.replace(/^\s*\d+\s+/, ""); + } + + function entryIsImage(entry): bool { return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry)); } @@ -43,20 +53,42 @@ Singleton { }); } - function paste(entry) { + function paste(entry): void { Quickshell.execDetached(["bash", "-c", `printf '${shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]); } - function refresh() { + function refresh(): void { readProc.buffer = []; readProc.running = true; } - function shellSingleQuoteEscape(str) { + function refreshPreview(): void { + previewToken += 1; + const token = previewToken; + + if (!currentEntry) { + previewText = ""; + previewImageSource = ""; + previewIsImage = false; + return; + } + + previewIsImage = entryIsImage(currentEntry); + + if (previewIsImage) { + previewImageProc.token = token; + previewImageProc.running = true; + } else { + previewTextProc.token = token; + previewTextProc.running = true; + } + } + + function shellSingleQuoteEscape(str): string { return String(str).replace(/'/g, "'\\''"); } - function wipe() { + function wipe(): void { wipeProc.running = true; } @@ -107,6 +139,52 @@ Singleton { } } + Process { + id: previewTextProc + + property int token: 0 + + command: ["bash", "-c", ` + printf '%s' '${root.shellSingleQuoteEscape(root.currentEntry)}' | ${root.cliphistBinary} decode + `] + running: false + + stdout: StdioCollector { + onStreamFinished: { + if (previewTextProc.token !== root.previewToken) + return; + root.previewText = this.text; + } + } + } + + Process { + id: previewImageProc + + property int token: 0 + + command: ["bash", "-c", ` + set -euo pipefail + tmp='${root.shellSingleQuoteEscape(root.previewImageFile)}' + printf '%s' '${root.shellSingleQuoteEscape(root.currentEntry)}' | ${root.cliphistBinary} decode > "$tmp" + `] + running: false + + onExited: (exitCode, exitStatus) => { + if (token !== root.previewToken) + return; + if (exitCode !== 0) { + console.error("[Cliphist] image preview failed", exitCode, exitStatus); + return; + } + + root.previewImageSource = ""; + Qt.callLater(() => { + root.previewImageSource = `file://${root.previewImageFile}`; + }); + } + } + Process { id: readProc diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index 5d9dcbe..cad88d8 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import Quickshell import QtQuick +import QtQuick.Controls import QtQuick.Layouts import qs.Components import qs.Helpers @@ -15,10 +16,10 @@ Item { required property PersistentProperties visibilities implicitHeight: search.implicitHeight + entries.anchors.topMargin + ((view.spacing + itemHeight) * Config.clipboard.maxEntriesShown) - view.spacing - implicitWidth: Config.clipboard.sizes.width + implicitWidth: Config.clipboard.sizes.width + 500 Component.onCompleted: { - if (!ClipHistory.entries.length > 0) + if (ClipHistory.entries.length === 0) ClipHistory.refresh(); searchField.forceActiveFocus(); } @@ -27,7 +28,7 @@ Item { id: search anchors.left: parent.left - anchors.right: parent.right + anchors.right: entries.right anchors.top: parent.top color: DynamicColors.tPalette.m3surfaceContainer implicitHeight: 50 @@ -62,14 +63,50 @@ Item { } } + CustomClippingRect { + id: preview + + anchors.bottom: parent.bottom + anchors.left: entries.right + anchors.leftMargin: Appearance.spacing.normal + anchors.right: parent.right + anchors.top: parent.top + color: DynamicColors.tPalette.m3surfaceContainer + radius: 25 + + CustomText { + anchors.left: parent.left + anchors.margins: Appearance.padding.large + anchors.top: parent.top + text: ClipHistory.previewText + textFormat: Text.PlainText + visible: !ClipHistory.previewIsImage + width: preview.width - Appearance.padding.large * 2 + wrapMode: Text.Wrap + } + + Image { + anchors.fill: parent + anchors.margins: Appearance.padding.large + asynchronous: true + cache: false + fillMode: Image.PreserveAspectFit + mipmap: true + retainWhileLoading: true + smooth: true + source: ClipHistory.previewImageSource + visible: ClipHistory.previewIsImage + } + } + CustomClippingRect { id: entries anchors.bottom: parent.bottom anchors.left: parent.left - anchors.right: parent.right anchors.top: search.bottom anchors.topMargin: Appearance.spacing.normal + implicitWidth: Config.clipboard.sizes.width radius: Appearance.rounding.small CustomListView { @@ -88,6 +125,7 @@ Item { delegate: RowLayout { id: clipItem + readonly property bool isImage: ClipHistory.entryIsImage(modelData) required property string modelData height: root.itemHeight @@ -122,14 +160,24 @@ Item { maskSource: fadeMask } - CustomText { - id: text + MaterialIcon { + id: icon anchors.left: parent.left anchors.margins: Appearance.padding.normal anchors.verticalCenter: parent.verticalCenter + font.pointSize: Appearance.font.size.large + text: clipItem.isImage ? "image" : "text_fields" + } + + CustomText { + id: text + + anchors.left: icon.right + anchors.margins: Appearance.spacing.normal + anchors.verticalCenter: parent.verticalCenter elide: Text.ElideRight - text: clipItem.modelData + text: clipItem.isImage ? qsTr("Image") : ClipHistory.displayText(clipItem.modelData) } } @@ -199,6 +247,14 @@ Item { model: ScriptModel { values: ClipHistory.fuzzyQuery(searchField.text) } + + onCurrentItemChanged: { + if (!currentItem) + return; + + ClipHistory.currentEntry = currentItem.modelData; + ClipHistory.refreshPreview(); + } } } }