clipboard preview with image support
This commit is contained in:
+86
-8
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user