From 6034c4816d11e1e355b205d739ce781216926a01 Mon Sep 17 00:00:00 2001 From: zach Date: Sat, 13 Jun 2026 20:11:58 +0200 Subject: [PATCH] clipboard preview with syntax support --- Config/ClipboardConfig.qml | 2 + Config/Config.qml | 2 + Helpers/ClipHistory.qml | 116 +++++++++++++++++++++ Modules/Clipboard/Content.qml | 22 ++-- Modules/Settings/Controls/SettingsPage.qml | 2 +- 5 files changed, 137 insertions(+), 7 deletions(-) diff --git a/Config/ClipboardConfig.qml b/Config/ClipboardConfig.qml index 5d52ef1..4867301 100644 --- a/Config/ClipboardConfig.qml +++ b/Config/ClipboardConfig.qml @@ -8,6 +8,8 @@ JsonObject { component Sizes: JsonObject { property int itemHeight: 60 + property int minPreviewWidth: 200 + property int previewWidth: 500 property int width: 500 } } diff --git a/Config/Config.qml b/Config/Config.qml index b10be07..f7f8600 100644 --- a/Config/Config.qml +++ b/Config/Config.qml @@ -123,6 +123,8 @@ Singleton { maxEntriesShown: clipboard.maxEntriesShown, sizes: { width: clipboard.sizes.width, + previewWidth: clipboard.sizes.previewWidth, + minPreviewWidth: clipboard.sizes.minPreviewWidth, itemHeight: clipboard.sizes.itemHeight } }; diff --git a/Helpers/ClipHistory.qml b/Helpers/ClipHistory.qml index 39c66ec..3c7fca4 100644 --- a/Helpers/ClipHistory.qml +++ b/Helpers/ClipHistory.qml @@ -20,7 +20,9 @@ Singleton { })) property string previewImageFile: "/tmp/qs-cliphist-preview.img" property string previewImageSource: "" + property bool previewIsCode: looksLikeCode(previewText) property bool previewIsImage: false + readonly property string previewMarkup: previewIsCode ? generateHighlightedMarkup(previewText) : previewText property string previewText: "" property int previewToken: 0 property real scoreThreshold: 0.2 @@ -41,6 +43,10 @@ Singleton { return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry)); } + function escapeHtml(str): string { + return String(str).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + } + function fuzzyQuery(search: string): var { if (search.trim() === "") { return entries; @@ -53,6 +59,115 @@ Singleton { }); } + function generateHighlightedMarkup(text): string { + const raw = String(text ?? ""); + const lines = raw.split("\n").map(line => highlightCodeLine(line)); + return `
${lines.join("
")}
`; + } + + function highlightCodeLine(rawLine): string { + const line = String(rawLine ?? ""); + + const kwColor = DynamicColors.palette.m3primary; + const strColor = DynamicColors.palette.m3tertiary; + const comColor = Qt.alpha(DynamicColors.palette.m3onSurface, 0.55); + + const keywordRe = /\b(function|class|import|const|let|var|if|else|for|while|return|switch|case|break|continue|try|catch|throw|async|await|new|null|true|false|public|private|protected|static|extends|struct|enum)\b/g; + + let out = ""; + let i = 0; + + while (i < line.length) { + const ch = line[i]; + + // Line comment + if (line.slice(i, i + 2) === "//") { + out += `${escapeHtml(line.slice(i))}`; + break; + } + + // Shell/Python-style comment + if (ch === "#" && i === 0) { + out += `${escapeHtml(line.slice(i))}`; + break; + } + + // Quoted string + if (ch === "'" || ch === '"' || ch === "`") { + const quote = ch; + let j = i + 1; + while (j < line.length) { + if (line[j] === "\\") { + j += 2; + continue; + } + if (line[j] === quote) { + j += 1; + break; + } + j += 1; + } + + out += `${escapeHtml(line.slice(i, j))}`; + i = j; + continue; + } + + // Plain code text until next special token + let j = i; + while (j < line.length) { + const two = line.slice(j, j + 2); + const c = line[j]; + + if (two === "//") + break; + if (c === "'" || c === '"' || c === "`") + break; + if (c === "#" && j === 0) + break; + + j += 1; + } + + let segment = escapeHtml(line.slice(i, j)); + segment = segment.replace(keywordRe, `$1`); + out += segment; + i = j; + } + + return out === "" ? " " : out; + } + + function looksLikeCode(text): bool { + const t = String(text ?? "").trim(); + + if (t === "") + return false; + + const lines = t.split("\n"); + + if (lines.length < 2) + return false; + + let score = 0; + + for (const line of lines) { + if (/^\s{4,}|\t/.test(line)) + score += 2; + + if (/[{}()[\];]/.test(line)) + score += 1; + + if (/\b(function|class|import|const|let|var|if|else|for|while|return|switch|case|try|catch|async|await)\b/.test(line)) + score += 2; + + if (/=>|:=/.test(line)) + score += 1; + } + + return score >= 4; + } + function paste(entry): void { Quickshell.execDetached(["bash", "-c", `printf '${shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]); } @@ -73,6 +188,7 @@ Singleton { return; } + previewImageSource = ""; previewIsImage = entryIsImage(currentEntry); if (previewIsImage) { diff --git a/Modules/Clipboard/Content.qml b/Modules/Clipboard/Content.qml index b07dbe2..a9b9b18 100644 --- a/Modules/Clipboard/Content.qml +++ b/Modules/Clipboard/Content.qml @@ -16,7 +16,7 @@ Item { required property PersistentProperties visibilities implicitHeight: search.implicitHeight + entries.anchors.topMargin + ((view.spacing + itemHeight) * Config.clipboard.maxEntriesShown) - view.spacing - implicitWidth: Config.clipboard.sizes.width + 500 + implicitWidth: Config.clipboard.sizes.width + preview.width + preview.anchors.leftMargin Component.onCompleted: { if (ClipHistory.entries.length === 0) @@ -69,23 +69,33 @@ Item { 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 + implicitWidth: ClipHistory.previewIsImage ? Math.max(Math.min(imagePreview.sourceSize.width + imagePreview.anchors.margins * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) : Math.max(Math.min(textPreview.paintedWidth + textPreview.anchors.margins * 2, Config.clipboard.sizes.previewWidth), Config.clipboard.sizes.minPreviewWidth) radius: 25 + Behavior on implicitWidth { + Anim { + } + } + CustomText { + id: textPreview + anchors.left: parent.left anchors.margins: Appearance.padding.large anchors.top: parent.top - text: ClipHistory.previewText - textFormat: Text.PlainText + font.family: ClipHistory.previewIsCode ? Appearance.font.family.mono : Appearance.font.family.sans + text: ClipHistory.previewMarkup + textFormat: ClipHistory.previewIsCode ? Text.RichText : Text.PlainText visible: !ClipHistory.previewIsImage - width: preview.width - Appearance.padding.large * 2 + width: Config.clipboard.sizes.previewWidth - anchors.margins * 2 wrapMode: Text.Wrap } Image { + id: imagePreview + anchors.fill: parent anchors.margins: Appearance.padding.large asynchronous: true @@ -95,7 +105,7 @@ Item { retainWhileLoading: true smooth: true source: ClipHistory.previewImageSource - visible: ClipHistory.previewIsImage + visible: ClipHistory.previewIsImage && ClipHistory.previewImageSource !== "" } } diff --git a/Modules/Settings/Controls/SettingsPage.qml b/Modules/Settings/Controls/SettingsPage.qml index 7aa54ed..b3cae9c 100644 --- a/Modules/Settings/Controls/SettingsPage.qml +++ b/Modules/Settings/Controls/SettingsPage.qml @@ -25,7 +25,7 @@ Item { CustomClippingWrapperRect { anchors.fill: parent - child: flickable.contentItem + child: flickable radius: Appearance.rounding.normal - Appearance.padding.extraSmall }