Clipboard: improve reliability with large text

This commit is contained in:
Lemmy
2026-01-05 12:19:36 -05:00
parent f92de8884f
commit 21ddf8f13b
6 changed files with 196 additions and 92 deletions
+1
View File
@@ -2841,6 +2841,7 @@
"state-change-failed": "Failed to change Bluetooth state"
},
"clipboard": {
"long-text": "Long text",
"unavailable": "Clipboard history unavailable",
"unavailable-desc": "The 'cliphist' application is not installed. Please install it to use clipboard history features."
},
+3 -1
View File
@@ -111,7 +111,9 @@
"showCalendarEvents": true,
"showCalendarWeather": true,
"analogClockInCalendar": false,
"firstDayOfWeek": -1
"firstDayOfWeek": -1,
"hideWeatherTimezone": false,
"hideWeatherCityName": false
},
"calendar": {
"cards": [
-34
View File
@@ -1,34 +0,0 @@
.pragma library
/**
* Wrap text in a nicely styled HTML container for display
* @param {string} text - The text to display
* @returns {string} HTML string
*/
function wrapTextForDisplay(text) {
// Escape HTML special characters
const escapeHtml = (s) =>
s.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
return `
<div style="
font-family: 'Fira Code', 'Courier New', monospace;
white-space: pre-wrap;
background: linear-gradient(135deg, #2c3e50, #34495e);
color: #ecf0f1;
padding: 16px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
overflow-x: auto;
line-height: 1.5;
font-size: 14px;
border: 1px solid #3d566e;
">
${escapeHtml(text)}
</div>
`;
}
+75 -26
View File
@@ -1,7 +1,6 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import "../../../Helpers/TextFormatter.js" as TextFormatter
import qs.Commons
import qs.Services.Keyboard
import qs.Widgets
@@ -17,6 +16,42 @@ Item {
implicitHeight: contentArea.implicitHeight + Style.marginL * 2
function loadContent() {
if (!currentItem || !currentItem.clipboardId)
return;
if (isImageContent) {
// For images, check cache first then decode
imageDataUrl = ClipboardService.getImageData(currentItem.clipboardId) || "";
loadingFullContent = !imageDataUrl;
if (!imageDataUrl && currentItem.mime) {
ClipboardService.decodeToDataUrl(currentItem.clipboardId, currentItem.mime, null);
}
} else {
// For text, check sync cache first
const cached = ClipboardService.getContent(currentItem.clipboardId);
if (cached) {
fullContent = cached;
loadingFullContent = false;
} else {
// Show preview while loading full content
fullContent = currentItem.preview || "";
loadingFullContent = true;
// Async decode as fallback
var requestedId = currentItem.clipboardId;
ClipboardService.decode(requestedId, function (content) {
if (previewPanel.currentItem && previewPanel.currentItem.clipboardId === requestedId) {
var trimmed = content ? content.trim() : "";
if (trimmed !== "") {
previewPanel.fullContent = trimmed;
}
previewPanel.loadingFullContent = false;
}
});
}
}
}
Connections {
target: previewPanel
function onCurrentItemChanged() {
@@ -26,25 +61,22 @@ Item {
isImageContent = currentItem && currentItem.isImage;
if (currentItem && currentItem.clipboardId) {
if (isImageContent) {
imageDataUrl = ClipboardService.getImageData(currentItem.clipboardId) || "";
loadingFullContent = !imageDataUrl;
if (!imageDataUrl && currentItem.mime) {
ClipboardService.decodeToDataUrl(currentItem.clipboardId, currentItem.mime, null);
}
} else {
loadingFullContent = true;
ClipboardService.decode(currentItem.clipboardId, function (content) {
fullContent = TextFormatter.wrapTextForDisplay(content);
loadingFullContent = false;
});
}
loadContent();
}
}
}
readonly property int _rev: ClipboardService.revision
on_RevChanged: {
// When cache updates, try to load content if we're still showing loading or preview
if (currentItem && currentItem.clipboardId && !isImageContent && loadingFullContent) {
const cached = ClipboardService.getContent(currentItem.clipboardId);
if (cached) {
fullContent = cached;
loadingFullContent = false;
}
}
}
Timer {
id: imageUpdateTimer
@@ -86,21 +118,38 @@ Item {
fillMode: Image.PreserveAspectFit
}
ScrollView {
ColumnLayout {
anchors.fill: parent
anchors.margins: Style.marginS
clip: true
spacing: Style.marginXS
visible: !isImageContent && !loadingFullContent
TextArea {
text: fullContent
readOnly: true
wrapMode: Text.Wrap
textFormat: TextArea.RichText
font.pointSize: Style.fontSizeM
color: Color.mOnSurface
background: Rectangle {
color: "transparent"
NText {
Layout.fillWidth: true
visible: fullContent.length > 0
text: {
const chars = fullContent.length;
const words = fullContent.split(/\s+/).filter(w => w.length > 0).length;
const lines = fullContent.split('\n').length;
return `${chars} chars, ${words} words, ${lines} lines`;
}
pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}
ScrollView {
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
NText {
text: fullContent
width: parent.width
wrapMode: Text.Wrap
textFormat: Text.PlainText
font.pointSize: Style.fontSizeM
font.family: Settings.data.ui.fontFixed
color: Color.mOnSurface
}
}
}
@@ -261,9 +261,14 @@ Item {
description = description.substring(0, 77) + "...";
}
} else {
const chars = preview.length;
const words = preview.split(/\s+/).length;
description = `${chars} characters, ${words} word${words !== 1 ? 's' : ''}`;
// Preview is truncated at ~100 chars, so we can't show exact count
if (preview.length >= 100) {
description = I18n.tr("toast.clipboard.long-text");
} else {
const chars = preview.length;
const words = preview.split(/\s+/).length;
description = `${chars} characters, ${words} word${words !== 1 ? 's' : ''}`;
}
}
return {
+109 -28
View File
@@ -6,7 +6,7 @@ import Quickshell.Io
import qs.Commons
import qs.Services.UI
// Thin wrapper around the cliphist CLI
// Clipboard history service using cliphist + local content cache
Singleton {
id: root
@@ -27,11 +27,20 @@ Singleton {
property var imageDataById: ({})
property int revision: 0
// Local content cache - stores full text content by ID
// This avoids relying on cliphist decode which can be unreliable
property var contentCache: ({})
// Track the most recent clipboard content for instant access
property string _latestTextContent: ""
property string _latestTextId: ""
// Approximate first-seen timestamps for entries this session (seconds)
property var firstSeenById: ({})
// Internal: store callback for decode
property var _decodeCallback: null
property int _decodeRequestId: 0
// Queue for base64 decodes
property var _b64Queue: []
@@ -83,7 +92,6 @@ Singleton {
} else {
stopWatchers();
loading = false;
// Optional: clear items to avoid stale UI
items = [];
}
}
@@ -143,20 +151,26 @@ Singleton {
"mime": mime
};
});
items = parsed;
loading = false;
// Emit the signal for subscribers
// Try to capture current clipboard and associate with newest item
if (parsed.length > 0 && !parsed[0].isImage && !root.contentCache[parsed[0].id]) {
root.captureCurrentClipboard();
}
root.listCompleted();
}
}
Process {
id: decodeProc
property int requestId: 0
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
const out = String(stdout.text);
if (root._decodeCallback) {
if (requestId === root._decodeRequestId && root._decodeCallback) {
const out = String(stdout.text);
try {
root._decodeCallback(out);
} finally {
@@ -181,7 +195,6 @@ Singleton {
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
revision++;
// Refresh list after deletion completes
Qt.callLater(() => list());
}
}
@@ -209,26 +222,51 @@ Singleton {
}
}
// Long-running watchers to store new clipboard contents
// Text watcher - stores to cliphist and triggers content capture
Process {
id: watchText
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
// Auto-restart if watcher dies
if (root.autoWatch)
Qt.callLater(() => {
running = true;
});
if (root.autoWatch && root.watchersStarted) {
Qt.callLater(() => {
watchText.running = true;
});
}
}
}
// Image watcher
Process {
id: watchImage
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
if (root.autoWatch)
Qt.callLater(() => {
running = true;
});
if (root.autoWatch && root.watchersStarted) {
Qt.callLater(() => {
watchImage.running = true;
});
}
}
}
// Capture current clipboard text when needed
Process {
id: captureTextProc
stdout: StdioCollector {}
onExited: (exitCode, exitStatus) => {
if (exitCode === 0) {
const content = String(stdout.text);
if (content.length > 0) {
root._latestTextContent = content;
// Associate with newest item if we have one
if (root.items.length > 0 && !root.items[0].isImage) {
const newestId = root.items[0].id;
if (!root.contentCache[newestId]) {
root.contentCache[newestId] = content;
root.revision++;
}
}
}
}
}
}
@@ -236,10 +274,12 @@ Singleton {
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
return;
watchersStarted = true;
// Start text watcher
// Text watcher
watchText.command = ["wl-paste", "--type", "text", "--watch", "cliphist", "store"];
watchText.running = true;
// Start image watcher
// Image watcher
watchImage.command = ["wl-paste", "--type", "image", "--watch", "cliphist", "store"];
watchImage.running = true;
}
@@ -252,6 +292,14 @@ Singleton {
watchersStarted = false;
}
// Capture current clipboard text and cache it
function captureCurrentClipboard() {
if (captureTextProc.running)
return;
captureTextProc.command = ["wl-paste", "--no-newline"];
captureTextProc.running = true;
}
function list(maxPreviewWidth) {
if (!root.active || !root.cliphistAvailable) {
return;
@@ -264,14 +312,46 @@ Singleton {
listProc.running = true;
}
// Get content for an ID - uses cache first, falls back to cliphist decode
function getContent(id) {
if (root.contentCache[id]) {
return root.contentCache[id];
}
return null;
}
// Async decode - checks cache first, then falls back to cliphist
function decode(id, cb) {
if (!root.cliphistAvailable) {
if (cb)
cb("");
return;
}
root._decodeCallback = cb;
decodeProc.command = ["cliphist", "decode", id];
// Check cache first
const cached = root.contentCache[id];
if (cached) {
if (cb)
cb(cached);
return;
}
// Fall back to cliphist decode
if (decodeProc.running) {
decodeProc.running = false;
}
root._decodeRequestId++;
decodeProc.requestId = root._decodeRequestId;
root._decodeCallback = function (content) {
// Cache the result if successful
if (content && content.trim()) {
root.contentCache[id] = content;
}
if (cb)
cb(content);
};
const idStr = String(id).trim();
decodeProc.command = ["sh", "-lc", `cliphist decode ${idStr}`];
decodeProc.running = true;
}
@@ -320,7 +400,6 @@ Singleton {
if (!root.cliphistAvailable) {
return;
}
// decode and pipe to wl-copy; implement via shell to preserve binary
copyProc.command = ["sh", "-lc", `cliphist decode ${id} | wl-copy`];
copyProc.running = true;
}
@@ -329,8 +408,6 @@ Singleton {
if (!root.cliphistAvailable) {
return;
}
// Copy to clipboard and then simulate paste using wtype
// For images, use Ctrl+V (Ctrl+Shift+V is "paste as text" which doesn't work for images)
const isImage = mime && mime.startsWith("image/");
const typeArg = isImage ? ` --type ${mime}` : "";
const pasteKeys = isImage ? "wtype -M ctrl -k v" : "wtype -M ctrl -M shift v";
@@ -339,11 +416,9 @@ Singleton {
pasteProc.running = true;
}
// Paste plain text: copy to clipboard and simulate Ctrl+Shift+V
function pasteText(text) {
if (!text)
return;
// Escape single quotes for shell
const escaped = text.replace(/'/g, "'\\''");
const cmd = `printf '%s' '${escaped}' | wl-copy && wtype -M ctrl -M shift v`;
pasteProc.command = ["sh", "-lc", cmd];
@@ -357,9 +432,10 @@ Singleton {
if (deleteProc.running) {
return;
}
const idStr = String(id);
// Use Process to wait for deletion to complete before refreshing
deleteProc.command = ["sh", "-c", `echo ${idStr} | cliphist delete`];
const idStr = String(id).trim();
// Remove from cache
delete root.contentCache[idStr];
deleteProc.command = ["sh", "-lc", `cliphist delete ${idStr}`];
deleteProc.running = true;
}
@@ -367,6 +443,11 @@ Singleton {
if (!root.cliphistAvailable) {
return;
}
// Clear caches
root.contentCache = {};
root.imageDataById = {};
root._latestTextContent = "";
root._latestTextId = "";
Quickshell.execDetached(["cliphist", "wipe"]);
revision++;