mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
Clipboard: improve reliability with large text
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -111,7 +111,9 @@
|
||||
"showCalendarEvents": true,
|
||||
"showCalendarWeather": true,
|
||||
"analogClockInCalendar": false,
|
||||
"firstDayOfWeek": -1
|
||||
"firstDayOfWeek": -1,
|
||||
"hideWeatherTimezone": false,
|
||||
"hideWeatherCityName": false
|
||||
},
|
||||
"calendar": {
|
||||
"cards": [
|
||||
|
||||
@@ -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, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user