clipboard preview with image support
Lint & Format (JS/TS) / lint-format (pull_request) Successful in 10s
Python / lint-format (pull_request) Successful in 17s
Python / test (pull_request) Successful in 30s
Lint & Format (Rust) / lint-format (pull_request) Successful in 1m6s

This commit is contained in:
2026-06-13 01:44:12 +02:00
parent d1085b749d
commit 4b7ba30272
2 changed files with 149 additions and 15 deletions
+86 -8
View File
@@ -11,23 +11,33 @@ Singleton {
id: root
property string cliphistBinary: "cliphist"
property string currentEntry: ""
property list<string> 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
+63 -7
View File
@@ -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();
}
}
}
}