From 766c3b9a3f560ccbd3510f398d62763d6566efc3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 29 May 2026 13:35:28 +0200 Subject: [PATCH] Polish clipboard history display --- shell/plugins/clipboard/Clipboard.qml | 158 +++++++++++--------- shell/plugins/clipboard/ClipboardHistory.js | 18 ++- shell/plugins/clipboard/capture.sh | 6 +- test/shell.d/clipboard-test.sh | 18 +++ 4 files changed, 122 insertions(+), 78 deletions(-) diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index 56ab6a3e..a472249d 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -34,9 +34,10 @@ Item { 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(800), panel.width - Style.gapsOut * 2) + 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 @@ -71,14 +72,14 @@ Item { } function saveHistory() { - historyFile.setText(JSON.stringify(root.history.slice(0, 100), null, 2) + "\n") + 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, 100) + root.history = ClipboardHistory.addEntry(root.history, normalized, root.historyLimit) root.saveHistory() if (root.opened) root.rebuildDisplay() } @@ -112,7 +113,7 @@ Item { if (index < 0 || index >= displayModel.count) return var row = displayModel.get(index) - root.history = ClipboardHistory.removeEntryAt(root.history, row.index) + root.history = ClipboardHistory.removeEntryAt(root.history, row.historyIndex) root.saveHistory() if (displayModel.count <= 1) { @@ -138,7 +139,7 @@ Item { previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "", path: row.path, mime: row.mime, - index: row.index + historyIndex: row.index }) } @@ -165,7 +166,7 @@ Item { function setFilter(nextFilter) { root.filterText = nextFilter root.selectedIndex = 0 - root.cursorActive = false + root.cursorActive = true root.rebuildDisplay() } @@ -187,7 +188,7 @@ Item { 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.index)]) + Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-text", "--shift-insert", "--history-index", String(row.historyIndex)]) } } @@ -197,7 +198,7 @@ Item { 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.index)]) + Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-text", "--copy-only", "--history-index", String(row.historyIndex)]) } } @@ -218,7 +219,7 @@ Item { Process { id: initProc - command: ["bash", "-c", "mkdir -p ~/.local/state/omarchy\nscript=$1\nfor pid in $(pgrep -x wl-paste || true); do\n cmdline=$(tr '\\0' ' ' <\"/proc/$pid/cmdline\" 2>/dev/null || true)\n if [[ $cmdline == *\"wl-paste --watch $script \"* || $cmdline == *\"wl-paste --type \"*\" --watch $script \"* ]]; then\n kill \"$pid\" 2>/dev/null || true\n fi\ndone", "clipboard-init", root.captureScript] + command: ["bash", "-c", "mkdir -p ~/.local/state/omarchy\nscript=$1\nfor pid in $(pgrep -x wl-paste || true); do\n cmdline=$(tr '\\0' ' ' <\"/proc/$pid/cmdline\" 2>/dev/null || true)\n if [[ $cmdline == *\"wl-paste --watch $script \"* || $cmdline == *\"wl-paste --type \"*\" --watch $script \"* || $cmdline == *\"/shell/plugins/clipboard/capture.sh\"* ]]; then\n kill \"$pid\" 2>/dev/null || true\n fi\ndone", "clipboard-init", root.captureScript] onExited: { currentProc.command = [root.captureScript] currentProc.running = true @@ -372,84 +373,89 @@ Item { Row { anchors.fill: parent - spacing: root.contentSpacing + spacing: 0 - ListView { - id: resultList - width: parent.width / 2 - root.contentSpacing / 2 + Item { + width: parent.width / 2 height: parent.height - model: displayModel clip: true - spacing: Style.space(4) - boundsBehavior: Flickable.StopAtBounds - delegate: Rectangle { - required property int index - required property string entryType - required property string previewText - required property string fullText - required property string previewImage + ListView { + id: resultList + anchors.fill: parent + anchors.rightMargin: root.contentMargin + model: displayModel + clip: true + spacing: Style.space(4) + boundsBehavior: Flickable.StopAtBounds - readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex + delegate: Rectangle { + required property int index + required property string entryType + required property string previewText + required property string fullText + required property string previewImage - width: ListView.view.width - height: root.rowHeight - radius: root.cornerRadius - color: hasCursor ? root.selectedBackground : "transparent" - border.width: 0 + readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex - 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) + width: ListView.view.width + height: root.rowHeight + radius: root.cornerRadius + color: hasCursor ? root.selectedBackground : "transparent" + border.width: 0 - Image { - visible: parent.parent.entryType === "image" - width: visible ? parent.height : 0 - height: parent.height - source: parent.parent.previewImage - fillMode: Image.PreserveAspectFit - asynchronous: true - smooth: true + 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.entryType === "image" + 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.entryType === "image" ? 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" ? 0.72 : 1.0 + elide: Text.ElideRight + wrapMode: Text.NoWrap + verticalAlignment: Text.AlignVCenter + } } - Text { - width: parent.width - (parent.parent.entryType === "image" ? 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 - font.italic: parent.parent.entryType === "image" - opacity: parent.parent.entryType === "image" ? 0.72 : 1.0 - elide: Text.ElideRight - wrapMode: Text.NoWrap - verticalAlignment: Text.AlignVCenter - } - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onContainsMouseChanged: if (containsMouse) { - root.cursorActive = true - root.selectedIndex = index - } - onClicked: { - root.cursorActive = true - root.selectedIndex = index - root.activateIndex(index) + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onContainsMouseChanged: if (containsMouse) { + root.cursorActive = true + root.selectedIndex = index + } + onClicked: { + root.cursorActive = true + root.selectedIndex = index + root.activateIndex(index) + } } } } } Item { - width: parent.width / 2 - root.contentSpacing / 2 + width: parent.width / 2 height: parent.height clip: true @@ -466,7 +472,10 @@ Item { Text { visible: parent.activeRow && parent.activeRow.entryType === "text" anchors.fill: parent - anchors.margins: Style.space(16) + 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 @@ -479,7 +488,10 @@ Item { Image { visible: parent.activeRow && parent.activeRow.entryType === "image" anchors.fill: parent - anchors.margins: Style.space(16) + 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 diff --git a/shell/plugins/clipboard/ClipboardHistory.js b/shell/plugins/clipboard/ClipboardHistory.js index be28433c..5a1865e2 100644 --- a/shell/plugins/clipboard/ClipboardHistory.js +++ b/shell/plugins/clipboard/ClipboardHistory.js @@ -13,11 +13,14 @@ function normalizeEntry(value) { if (type === "image") { var path = String(value.path || "") if (!path) return null - return { + var entry = { type: "image", path: path, mime: String(value.mime || "image/png") } + if (value.capturedAt !== undefined && value.capturedAt !== null) + entry.capturedAt = String(value.capturedAt) + return entry } return null @@ -88,13 +91,21 @@ function parseEntryJson(line) { function searchableText(entry) { if (!entry) return "" - if (entry.type === "image") return "image " + String(entry.mime || "") + if (entry.type === "image") return "image screenshot " + String(entry.mime || "") + " " + String(entry.capturedAt || "") return String(entry.text || "") } +function imagePreviewText(entry) { + var timestamp = String(entry && entry.capturedAt || "") + if (!timestamp) return "Image" + + var label = String(entry && entry.mime || "") === "image/png" ? "Screenshot" : "Image" + return label + " " + timestamp +} + function previewText(entry) { if (!entry) return "" - if (entry.type === "image") return "Image" + if (entry.type === "image") return imagePreviewText(entry) return String(entry.text || "").replace(/\s+/g, " ") } @@ -140,6 +151,7 @@ if (typeof module !== "undefined") { parseEntryJson: parseEntryJson, searchableText: searchableText, previewText: previewText, + imagePreviewText: imagePreviewText, displayRows: displayRows } } diff --git a/shell/plugins/clipboard/capture.sh b/shell/plugins/clipboard/capture.sh index 08aab31d..57bd465b 100755 --- a/shell/plugins/clipboard/capture.sh +++ b/shell/plugins/clipboard/capture.sh @@ -29,7 +29,8 @@ emit_image() { mv "$tmp" "$file" fi - jq -cn --arg mime "$mime" --arg path "$file" '{type:"image", mime:$mime, path:$path}' + jq -cn --arg mime "$mime" --arg path "$file" --arg captured_at "$(date +'%A %H:%M')" \ + '{type:"image", mime:$mime, path:$path, capturedAt:$captured_at}' } emit_image_stream() { @@ -54,7 +55,8 @@ emit_image_stream() { mv "$tmp" "$file" fi - jq -cn --arg mime "$mime" --arg path "$file" '{type:"image", mime:$mime, path:$path}' + jq -cn --arg mime "$mime" --arg path "$file" --arg captured_at "$(date +'%A %H:%M')" \ + '{type:"image", mime:$mime, path:$path, capturedAt:$captured_at}' } case "${OMARCHY_CLIPBOARD_WATCH_MIME:-}" in diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index 0a991eda..9bbe4cd5 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -19,6 +19,12 @@ assertDeepEqual( 'clipboard normalizes image entries with default mime' ) +assertDeepEqual( + clipboard.normalizeEntry({ type: 'image', path: '/tmp/a.png', mime: 'image/png', capturedAt: 'Friday 14:42' }), + { type: 'image', path: '/tmp/a.png', mime: 'image/png', capturedAt: 'Friday 14:42' }, + 'clipboard keeps image capture timestamps' +) + assertDeepEqual( clipboard.parseHistory(JSON.stringify(['one', '', { type: 'text', text: 'two' }, { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }])), [ @@ -65,6 +71,18 @@ assertDeepEqual( 'clipboard display rows search image metadata' ) +assertDeepEqual( + clipboard.displayRows([{ type: 'image', path: '/tmp/a.png', mime: 'image/png', capturedAt: 'Friday 14:42' }], '', 50)[0].previewText, + 'Screenshot Friday 14:42', + 'clipboard labels timestamped png image entries as screenshots' +) + +assertDeepEqual( + clipboard.displayRows([{ type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg', capturedAt: 'Friday 14:42' }], '', 50)[0].previewText, + 'Image Friday 14:42', + 'clipboard labels timestamped non-png image entries as images' +) + assertDeepEqual( clipboard.displayRows(history, 'image', 50).map(row => row.index), [2],