Polish clipboard history display

This commit is contained in:
David Heinemeier Hansson
2026-05-29 13:35:28 +02:00
parent 4b003d2e39
commit 766c3b9a3f
4 changed files with 122 additions and 78 deletions
+85 -73
View File
@@ -34,9 +34,10 @@ Item {
property int contentMargin: Style.spacing.panelPadding property int contentMargin: Style.spacing.panelPadding
property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2) property int headerHeight: Math.max(Style.space(34), Style.font.title + Style.spacing.controlPaddingY * 2)
property int contentSpacing: Style.spacing.md 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 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 rowHeight: Math.max(Style.space(50), Style.font.body + Style.font.caption + Style.spacing.rowPaddingX * 2)
property int historyLimit: 300
function open(payloadJson) { function open(payloadJson) {
root.opened = true root.opened = true
@@ -71,14 +72,14 @@ Item {
} }
function saveHistory() { 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) { function addClipboardEntry(entry) {
var normalized = ClipboardHistory.normalizeEntry(entry) var normalized = ClipboardHistory.normalizeEntry(entry)
if (!normalized) return if (!normalized) return
root.history = ClipboardHistory.addEntry(root.history, normalized, 100) root.history = ClipboardHistory.addEntry(root.history, normalized, root.historyLimit)
root.saveHistory() root.saveHistory()
if (root.opened) root.rebuildDisplay() if (root.opened) root.rebuildDisplay()
} }
@@ -112,7 +113,7 @@ Item {
if (index < 0 || index >= displayModel.count) return if (index < 0 || index >= displayModel.count) return
var row = displayModel.get(index) var row = displayModel.get(index)
root.history = ClipboardHistory.removeEntryAt(root.history, row.index) root.history = ClipboardHistory.removeEntryAt(root.history, row.historyIndex)
root.saveHistory() root.saveHistory()
if (displayModel.count <= 1) { if (displayModel.count <= 1) {
@@ -138,7 +139,7 @@ Item {
previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "", previewImage: row.previewImage ? Util.fileUrl(row.previewImage) : "",
path: row.path, path: row.path,
mime: row.mime, mime: row.mime,
index: row.index historyIndex: row.index
}) })
} }
@@ -165,7 +166,7 @@ Item {
function setFilter(nextFilter) { function setFilter(nextFilter) {
root.filterText = nextFilter root.filterText = nextFilter
root.selectedIndex = 0 root.selectedIndex = 0
root.cursorActive = false root.cursorActive = true
root.rebuildDisplay() root.rebuildDisplay()
} }
@@ -187,7 +188,7 @@ Item {
if (row.entryType === "image") { if (row.entryType === "image") {
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-file", row.mime, row.path]) Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-file", row.mime, row.path])
} else if (row.fullText) { } 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") { if (row.entryType === "image") {
Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-file", "--copy-only", row.mime, row.path]) Quickshell.execDetached([root.omarchyPath + "/bin/omarchy-clipboard-paste-file", "--copy-only", row.mime, row.path])
} else if (row.fullText) { } 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 { Process {
id: initProc 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: { onExited: {
currentProc.command = [root.captureScript] currentProc.command = [root.captureScript]
currentProc.running = true currentProc.running = true
@@ -372,84 +373,89 @@ Item {
Row { Row {
anchors.fill: parent anchors.fill: parent
spacing: root.contentSpacing spacing: 0
ListView { Item {
id: resultList width: parent.width / 2
width: parent.width / 2 - root.contentSpacing / 2
height: parent.height height: parent.height
model: displayModel
clip: true clip: true
spacing: Style.space(4)
boundsBehavior: Flickable.StopAtBounds
delegate: Rectangle { ListView {
required property int index id: resultList
required property string entryType anchors.fill: parent
required property string previewText anchors.rightMargin: root.contentMargin
required property string fullText model: displayModel
required property string previewImage 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 readonly property bool hasCursor: root.cursorActive && index === root.selectedIndex
height: root.rowHeight
radius: root.cornerRadius
color: hasCursor ? root.selectedBackground : "transparent"
border.width: 0
Row { width: ListView.view.width
anchors.fill: parent height: root.rowHeight
anchors.leftMargin: Style.space(12) radius: root.cornerRadius
anchors.rightMargin: Style.space(12) color: hasCursor ? root.selectedBackground : "transparent"
anchors.topMargin: Style.space(8) border.width: 0
anchors.bottomMargin: Style.space(8)
spacing: Style.space(10)
Image { Row {
visible: parent.parent.entryType === "image" anchors.fill: parent
width: visible ? parent.height : 0 anchors.leftMargin: Style.space(12)
height: parent.height anchors.rightMargin: Style.space(12)
source: parent.parent.previewImage anchors.topMargin: Style.space(8)
fillMode: Image.PreserveAspectFit anchors.bottomMargin: Style.space(8)
asynchronous: true spacing: Style.space(10)
smooth: true
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 { MouseArea {
width: parent.width - (parent.parent.entryType === "image" ? parent.height + parent.spacing : 0) anchors.fill: parent
height: parent.height hoverEnabled: true
text: parent.parent.previewText cursorShape: Qt.PointingHandCursor
color: parent.parent.hasCursor ? root.selectedText : root.foreground onContainsMouseChanged: if (containsMouse) {
font.family: root.fontFamily root.cursorActive = true
font.pixelSize: Style.font.title root.selectedIndex = index
font.italic: parent.parent.entryType === "image" }
opacity: parent.parent.entryType === "image" ? 0.72 : 1.0 onClicked: {
elide: Text.ElideRight root.cursorActive = true
wrapMode: Text.NoWrap root.selectedIndex = index
verticalAlignment: Text.AlignVCenter 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 { Item {
width: parent.width / 2 - root.contentSpacing / 2 width: parent.width / 2
height: parent.height height: parent.height
clip: true clip: true
@@ -466,7 +472,10 @@ Item {
Text { Text {
visible: parent.activeRow && parent.activeRow.entryType === "text" visible: parent.activeRow && parent.activeRow.entryType === "text"
anchors.fill: parent 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 : "" text: parent.activeRow ? parent.activeRow.fullText : ""
color: root.foreground color: root.foreground
font.family: root.fontFamily font.family: root.fontFamily
@@ -479,7 +488,10 @@ Item {
Image { Image {
visible: parent.activeRow && parent.activeRow.entryType === "image" visible: parent.activeRow && parent.activeRow.entryType === "image"
anchors.fill: parent 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 : "" source: parent.activeRow ? parent.activeRow.previewImage : ""
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
verticalAlignment: Image.AlignTop verticalAlignment: Image.AlignTop
+15 -3
View File
@@ -13,11 +13,14 @@ function normalizeEntry(value) {
if (type === "image") { if (type === "image") {
var path = String(value.path || "") var path = String(value.path || "")
if (!path) return null if (!path) return null
return { var entry = {
type: "image", type: "image",
path: path, path: path,
mime: String(value.mime || "image/png") mime: String(value.mime || "image/png")
} }
if (value.capturedAt !== undefined && value.capturedAt !== null)
entry.capturedAt = String(value.capturedAt)
return entry
} }
return null return null
@@ -88,13 +91,21 @@ function parseEntryJson(line) {
function searchableText(entry) { function searchableText(entry) {
if (!entry) return "" 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 || "") 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) { function previewText(entry) {
if (!entry) return "" if (!entry) return ""
if (entry.type === "image") return "Image" if (entry.type === "image") return imagePreviewText(entry)
return String(entry.text || "").replace(/\s+/g, " ") return String(entry.text || "").replace(/\s+/g, " ")
} }
@@ -140,6 +151,7 @@ if (typeof module !== "undefined") {
parseEntryJson: parseEntryJson, parseEntryJson: parseEntryJson,
searchableText: searchableText, searchableText: searchableText,
previewText: previewText, previewText: previewText,
imagePreviewText: imagePreviewText,
displayRows: displayRows displayRows: displayRows
} }
} }
+4 -2
View File
@@ -29,7 +29,8 @@ emit_image() {
mv "$tmp" "$file" mv "$tmp" "$file"
fi 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() { emit_image_stream() {
@@ -54,7 +55,8 @@ emit_image_stream() {
mv "$tmp" "$file" mv "$tmp" "$file"
fi 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 case "${OMARCHY_CLIPBOARD_WATCH_MIME:-}" in
+18
View File
@@ -19,6 +19,12 @@ assertDeepEqual(
'clipboard normalizes image entries with default mime' '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( assertDeepEqual(
clipboard.parseHistory(JSON.stringify(['one', '', { type: 'text', text: 'two' }, { type: 'image', path: '/tmp/a.jpg', mime: 'image/jpeg' }])), 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' '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( assertDeepEqual(
clipboard.displayRows(history, 'image', 50).map(row => row.index), clipboard.displayRows(history, 'image', 50).map(row => row.index),
[2], [2],