From 044da177634a49e32af17dc5f30cf77a5fbee923 Mon Sep 17 00:00:00 2001 From: Ly-sec Date: Wed, 20 Aug 2025 13:19:39 +0200 Subject: [PATCH] Refactor AppLauncher, fix clipboardhistory, add persistent history (max 50 entries), add advanced math stuff --- Helpers/AdvancedMath.js | 152 ++++++++++ Modules/AppLauncher/AppLauncher.qml | 351 +++++++---------------- Modules/AppLauncher/Calculator.qml | 161 +++++++++++ Modules/AppLauncher/ClipboardHistory.qml | 158 ++++++++++ Services/ClipboardService.qml | 131 +++++++-- 5 files changed, 690 insertions(+), 263 deletions(-) create mode 100644 Helpers/AdvancedMath.js create mode 100644 Modules/AppLauncher/Calculator.qml create mode 100644 Modules/AppLauncher/ClipboardHistory.qml diff --git a/Helpers/AdvancedMath.js b/Helpers/AdvancedMath.js new file mode 100644 index 0000000..9e9384e --- /dev/null +++ b/Helpers/AdvancedMath.js @@ -0,0 +1,152 @@ +// AdvancedMath.js - Lightweight math library for Noctalia Calculator +// Provides advanced mathematical functions beyond basic arithmetic + +// Helper function to convert degrees to radians +function toRadians(degrees) { + return degrees * (Math.PI / 180); +} + +// Helper function to convert radians to degrees +function toDegrees(radians) { + return radians * (180 / Math.PI); +} + +// Constants +var constants = { + PI: Math.PI, + E: Math.E, + LN2: Math.LN2, + LN10: Math.LN10, + LOG2E: Math.LOG2E, + LOG10E: Math.LOG10E, + SQRT1_2: Math.SQRT1_2, + SQRT2: Math.SQRT2 +}; + +// Safe evaluation function that handles advanced math +function evaluate(expression) { + try { + // Replace mathematical constants + var processed = expression + .replace(/\bpi\b/gi, Math.PI) + .replace(/\be\b/gi, Math.E); + + // Replace function calls with Math object equivalents + processed = processed + // Trigonometric functions + .replace(/\bsin\s*\(/g, 'Math.sin(') + .replace(/\bcos\s*\(/g, 'Math.cos(') + .replace(/\btan\s*\(/g, 'Math.tan(') + .replace(/\basin\s*\(/g, 'Math.asin(') + .replace(/\bacos\s*\(/g, 'Math.acos(') + .replace(/\batan\s*\(/g, 'Math.atan(') + .replace(/\batan2\s*\(/g, 'Math.atan2(') + + // Hyperbolic functions + .replace(/\bsinh\s*\(/g, 'Math.sinh(') + .replace(/\bcosh\s*\(/g, 'Math.cosh(') + .replace(/\btanh\s*\(/g, 'Math.tanh(') + .replace(/\basinh\s*\(/g, 'Math.asinh(') + .replace(/\bacosh\s*\(/g, 'Math.acosh(') + .replace(/\batanh\s*\(/g, 'Math.atanh(') + + // Logarithmic and exponential functions + .replace(/\blog\s*\(/g, 'Math.log10(') + .replace(/\bln\s*\(/g, 'Math.log(') + .replace(/\bexp\s*\(/g, 'Math.exp(') + .replace(/\bpow\s*\(/g, 'Math.pow(') + + // Root functions + .replace(/\bsqrt\s*\(/g, 'Math.sqrt(') + .replace(/\bcbrt\s*\(/g, 'Math.cbrt(') + + // Rounding and absolute + .replace(/\babs\s*\(/g, 'Math.abs(') + .replace(/\bfloor\s*\(/g, 'Math.floor(') + .replace(/\bceil\s*\(/g, 'Math.ceil(') + .replace(/\bround\s*\(/g, 'Math.round(') + .replace(/\btrunc\s*\(/g, 'Math.trunc(') + + // Min/Max + .replace(/\bmin\s*\(/g, 'Math.min(') + .replace(/\bmax\s*\(/g, 'Math.max(') + + // Random + .replace(/\brandom\s*\(\s*\)/g, 'Math.random()'); + + // Handle degree versions of trig functions + processed = processed + .replace(/\bsind\s*\(/g, '(function(x) { return Math.sin(' + (Math.PI / 180) + ' * x); })(') + .replace(/\bcosd\s*\(/g, '(function(x) { return Math.cos(' + (Math.PI / 180) + ' * x); })(') + .replace(/\btand\s*\(/g, '(function(x) { return Math.tan(' + (Math.PI / 180) + ' * x); })('); + + // Sanitize expression (only allow safe characters) + if (!/^[0-9+\-*/().\s\w,]+$/.test(processed)) { + throw new Error("Invalid characters in expression"); + } + + // Evaluate the processed expression + var result = eval(processed); + + if (!isFinite(result) || isNaN(result)) { + throw new Error("Invalid result"); + } + + return result; + } catch (error) { + throw new Error("Evaluation failed: " + error.message); + } +} + +// Format result for display +function formatResult(result) { + if (Number.isInteger(result)) { + return result.toString(); + } + + // Handle very large or very small numbers + if (Math.abs(result) >= 1e15 || (Math.abs(result) < 1e-6 && result !== 0)) { + return result.toExponential(6); + } + + // Normal decimal formatting + return parseFloat(result.toFixed(10)).toString(); +} + +// Get list of available functions for help +function getAvailableFunctions() { + return [ + // Basic arithmetic: +, -, *, /, %, ^, () + + // Trigonometric functions + "sin(x), cos(x), tan(x) - trigonometric functions (radians)", + "sind(x), cosd(x), tand(x) - trigonometric functions (degrees)", + "asin(x), acos(x), atan(x) - inverse trigonometric", + "atan2(y, x) - two-argument arctangent", + + // Hyperbolic functions + "sinh(x), cosh(x), tanh(x) - hyperbolic functions", + "asinh(x), acosh(x), atanh(x) - inverse hyperbolic", + + // Logarithmic and exponential + "log(x) - base 10 logarithm", + "ln(x) - natural logarithm", + "exp(x) - e^x", + "pow(x, y) - x^y", + + // Root functions + "sqrt(x) - square root", + "cbrt(x) - cube root", + + // Rounding and absolute + "abs(x) - absolute value", + "floor(x), ceil(x), round(x), trunc(x)", + + // Min/Max/Random + "min(a, b, ...), max(a, b, ...)", + "random() - random number 0-1", + + // Constants + "pi, e - mathematical constants" + ]; +} diff --git a/Modules/AppLauncher/AppLauncher.qml b/Modules/AppLauncher/AppLauncher.qml index e7bae91..58f0c2c 100644 --- a/Modules/AppLauncher/AppLauncher.qml +++ b/Modules/AppLauncher/AppLauncher.qml @@ -15,30 +15,125 @@ import "../../Helpers/FuzzySort.js" as Fuzzysort NLoader { id: appLauncher isLoaded: false - // Clipboard state is persisted in Services/ClipboardService.qml + content: Component { NPanel { id: appLauncherPanel WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand - // No local timer/processes; use persistent Clipboard service - - // Removed local clipboard processes; handled by Clipboard service - - // Copy helpers via simple exec; avoid keeping processes alive locally - function copyImageBase64(mime, base64) { - Quickshell.execDetached(["sh", "-lc", `printf %s ${base64} | base64 -d | wl-copy -t '${mime}'`]) + // Import modular components + Calculator { + id: calculator } - function copyText(text) { - Quickshell.execDetached(["sh", "-lc", `printf %s ${text} | wl-copy -t text/plain;charset=utf-8`]) + ClipboardHistory { + id: clipboardHistory } - function updateClipboardHistory() { - ClipboardService.refresh() + // Properties + property var desktopEntries: DesktopEntries.applications.values + property string searchText: "" + property int selectedIndex: 0 + + // Refresh clipboard when user starts typing clipboard commands + onSearchTextChanged: { + if (searchText.startsWith(">clip")) { + clipboardHistory.refresh() + } } + // Main filtering logic + property var filteredEntries: { + Logger.log("AppLauncher", "Total desktop entries:", desktopEntries ? desktopEntries.length : 0) + if (!desktopEntries || desktopEntries.length === 0) { + Logger.log("AppLauncher", "No desktop entries available") + return [] + } + + // Filter out entries that shouldn't be displayed + var visibleEntries = desktopEntries.filter(entry => { + if (!entry || entry.noDisplay) { + return false + } + return true + }) + + Logger.log("AppLauncher", "Visible entries:", visibleEntries.length) + + var query = searchText ? searchText.toLowerCase() : "" + var results = [] + + // Handle special commands + if (query === ">") { + results.push({ + "isCommand": true, + "name": ">calc", + "content": "Calculator - evaluate mathematical expressions", + "icon": "calculate", + "execute": executeCalcCommand + }) + + results.push({ + "isCommand": true, + "name": ">clip", + "content": "Clipboard history - browse and restore clipboard items", + "icon": "content_paste", + "execute": executeClipCommand + }) + + return results + } + + // Handle clipboard history + if (query.startsWith(">clip")) { + return clipboardHistory.processQuery(query) + } + + // Handle calculator + if (query.startsWith(">calc")) { + return calculator.processQuery(query, "calc") + } + + // Handle direct math expressions after ">" + if (query.startsWith(">") && query.length > 1 && !query.startsWith(">clip") && !query.startsWith(">calc")) { + const mathResults = calculator.processQuery(query, "direct") + if (mathResults.length > 0) { + return mathResults + } + // If math evaluation fails, fall through to regular search + } + + // Regular app search + if (!query) { + results = results.concat(visibleEntries.sort(function (a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + })) + } else { + var fuzzyResults = Fuzzysort.go(query, visibleEntries, { + "keys": ["name", "comment", "genericName"] + }) + results = results.concat(fuzzyResults.map(function (r) { + return r.obj + })) + } + + Logger.log("AppLauncher", "Filtered entries:", results.length) + return results + } + + // Command execution functions + function executeCalcCommand() { + searchText = ">calc " + searchInput.cursorPosition = searchText.length + } + + function executeClipCommand() { + searchText = ">clip " + searchInput.cursorPosition = searchText.length + } + + // Navigation functions function selectNext() { if (filteredEntries.length > 0) { selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1) @@ -67,233 +162,6 @@ NLoader { } } - property var desktopEntries: DesktopEntries.applications.values - property string searchText: "" - property int selectedIndex: 0 - - // Refresh clipboard when user starts typing clipboard commands - onSearchTextChanged: { - if (searchText.startsWith(">clip")) { - ClipboardService.refresh() - } - } - property var filteredEntries: { - Logger.log("AppLauncher", "Total desktop entries:", desktopEntries ? desktopEntries.length : 0) - if (!desktopEntries || desktopEntries.length === 0) { - Logger.log("AppLauncher", "No desktop entries available") - return [] - } - - // Filter out entries that shouldn't be displayed - var visibleEntries = desktopEntries.filter(entry => { - if (!entry || entry.noDisplay) { - return false - } - return true - }) - - Logger.log("AppLauncher", "Visible entries:", visibleEntries.length) - - var query = searchText ? searchText.toLowerCase() : "" - var results = [] - - // Handle special commands - if (query === ">") { - results.push({ - "isCommand": true, - "name": ">calc", - "content": "Calculator - evaluate mathematical expressions", - "icon": "tag", - "execute": function () { - searchText = ">calc " - searchInput.cursorPosition = searchText.length - } - }) - - results.push({ - "isCommand": true, - "name": ">clip", - "content": "Clipboard history - browse and restore clipboard items", - "icon": "content_paste", - "execute": function () { - searchText = ">clip " - searchInput.cursorPosition = searchText.length - } - }) - - return results - } - - // Handle clipboard history - if (query.startsWith(">clip")) { - const searchTerm = query.slice(5).trim() - - ClipboardService.history.forEach(function (clip, index) { - let searchContent = clip.type === 'image' ? clip.mimeType : clip.content || clip - - if (!searchTerm || searchContent.toLowerCase().includes(searchTerm)) { - let entry - if (clip.type === 'image') { - entry = { - "isClipboard": true, - "name": "Image from " + new Date(clip.timestamp).toLocaleTimeString(), - "content": "Image: " + clip.mimeType, - "icon": "image", - "type": 'image', - "data": clip.data, - "execute": function () { - const base64Data = clip.data.split(',')[1] - copyImageBase64(clip.mimeType, base64Data) - Quickshell.execDetached(["notify-send", "Clipboard", "Image copied: " + clip.mimeType]) - } - } - } else { - const textContent = clip.content || clip - let displayContent = textContent - let previewContent = "" - - displayContent = displayContent.replace(/\s+/g, ' ').trim() - - if (displayContent.length > 50) { - previewContent = displayContent - displayContent = displayContent.split('\n')[0].substring(0, 50) + "..." - } - - entry = { - "isClipboard": true, - "name": displayContent, - "content": previewContent || textContent, - "icon": "content_paste", - "execute": function () { - Quickshell.clipboardText = String(textContent) - copyText(String(textContent)) - var preview = (textContent.length > 50) ? textContent.slice(0, 50) + "…" : textContent - Quickshell.execDetached(["notify-send", "Clipboard", "Text copied: " + preview]) - } - } - } - results.push(entry) - } - }) - - if (results.length === 0) { - results.push({ - "isClipboard": true, - "name": "No clipboard history", - "content": "No matching clipboard entries found", - "icon": "content_paste_off" - }) - } - - return results - } - - // Handle direct math expressions after ">" - if (query.startsWith(">") && query.length > 1 && !query.startsWith(">clip") && !query.startsWith(">calc")) { - var mathExpr = query.slice(1).trim() - // Check if it looks like a math expression (contains numbers and math operators) - if (mathExpr && /[0-9+\-*/().]/.test(mathExpr)) { - try { - var sanitizedExpr = mathExpr.replace(/[^0-9+\-*/().\s]/g, '') - var result = eval(sanitizedExpr) - - if (isFinite(result) && !isNaN(result)) { - var displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, '') - results.push({ - "isCalculator": true, - "name": `${mathExpr} = ${displayResult}`, - "result": result, - "expr": mathExpr, - "icon": "tag", - "execute": function () { - Quickshell.clipboardText = displayResult - copyText(displayResult) - Quickshell.execDetached( - ["notify-send", "Calculator", `${mathExpr} = ${displayResult} (copied to clipboard)`]) - } - }) - return results - } - } catch (error) { - // If math evaluation fails, fall through to regular search - } - } - } - - // Handle calculator - if (query.startsWith(">calc")) { - var expr = searchText.slice(5).trim() - if (expr && expr !== "") { - try { - // Simple evaluation - only allow basic math operations - var sanitizedExpr = expr.replace(/[^0-9+\-*/().\s]/g, '') - var result = eval(sanitizedExpr) - - if (isFinite(result) && !isNaN(result)) { - var displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, - '') - results.push({ - "isCalculator": true, - "name": `${expr} = ${displayResult}`, - "result": result, - "expr": expr, - "icon": "tag", - "execute": function () { - Quickshell.clipboardText = displayResult - copyText(displayResult) - Quickshell.execDetached( - ["notify-send", "Calculator", `${expr} = ${displayResult} (copied to clipboard)`]) - } - }) - } else { - results.push({ - "isCalculator": true, - "name": "Invalid expression", - "content": "Please enter a valid mathematical expression", - "icon": "tag", - "execute": function () {} - }) - } - } catch (error) { - results.push({ - "isCalculator": true, - "name": "Invalid expression", - "content": "Please enter a valid mathematical expression", - "icon": "tag", - "execute": function () {} - }) - } - } else { - // Show placeholder when just ">calc" is entered - results.push({ - "isCalculator": true, - "name": "Calculator", - "content": "Enter a mathematical expression (e.g., 5+5, 2*3, 10/2)", - "icon": "tag", - "execute": function () {} - }) - } - return results - } - - // Regular app search - if (!query) { - results = results.concat(visibleEntries.sort(function (a, b) { - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - })) - } else { - var fuzzyResults = Fuzzysort.go(query, visibleEntries, { - "keys": ["name", "comment", "genericName"] - }) - results = results.concat(fuzzyResults.map(function (r) { - return r.obj - })) - } - - Logger.log("AppLauncher", "Filtered entries:", results.length) - return results - } - Component.onCompleted: { Logger.log("AppLauncher", "Component completed") Logger.log("AppLauncher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined') @@ -302,7 +170,7 @@ NLoader { DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined') } // Start clipboard refresh immediately on open - updateClipboardHistory() + clipboardHistory.refresh() } // Main content container @@ -368,7 +236,8 @@ NLoader { anchors.verticalCenter: parent.verticalCenter onTextChanged: { searchText = text - selectedIndex = 0 // Reset selection when search changes + // Defer selectedIndex reset to avoid binding loops + Qt.callLater(() => selectedIndex = 0) } selectedTextColor: Color.mOnSurface selectionColor: Color.mPrimary diff --git a/Modules/AppLauncher/Calculator.qml b/Modules/AppLauncher/Calculator.qml new file mode 100644 index 0000000..8675f0d --- /dev/null +++ b/Modules/AppLauncher/Calculator.qml @@ -0,0 +1,161 @@ +import QtQuick +import Quickshell +import qs.Commons + +import "../../Helpers/AdvancedMath.js" as AdvancedMath + +QtObject { + id: calculator + + // Function to evaluate mathematical expressions + function evaluate(expression) { + if (!expression || expression.trim() === "") { + return { + "isValid": false, + "result": "", + "displayResult": "", + "error": "Empty expression" + } + } + + try { + // Try advanced math first + if (typeof AdvancedMath !== 'undefined') { + const result = AdvancedMath.evaluate(expression.trim()) + const displayResult = AdvancedMath.formatResult(result) + + return { + "isValid": true, + "result": result, + "displayResult": displayResult, + "expression": expression, + "error": "" + } + } else { + // Fallback to basic evaluation + console.log("AdvancedMath not available, using basic eval") + + // Basic preprocessing for common functions + var processed = expression.trim() + .replace(/\bpi\b/gi, Math.PI) + .replace(/\be\b/gi, Math.E) + .replace(/\bsqrt\s*\(/g, 'Math.sqrt(') + .replace(/\bsin\s*\(/g, 'Math.sin(') + .replace(/\bcos\s*\(/g, 'Math.cos(') + .replace(/\btan\s*\(/g, 'Math.tan(') + .replace(/\blog\s*\(/g, 'Math.log10(') + .replace(/\bln\s*\(/g, 'Math.log(') + .replace(/\bexp\s*\(/g, 'Math.exp(') + .replace(/\bpow\s*\(/g, 'Math.pow(') + .replace(/\babs\s*\(/g, 'Math.abs(') + + // Sanitize and evaluate + if (!/^[0-9+\-*/().\s\w,]+$/.test(processed)) { + throw new Error("Invalid characters in expression") + } + + const result = eval(processed) + + if (!isFinite(result) || isNaN(result)) { + throw new Error("Invalid result") + } + + const displayResult = Number.isInteger(result) ? result.toString() : result.toFixed(6).replace(/\.?0+$/, '') + + return { + "isValid": true, + "result": result, + "displayResult": displayResult, + "expression": expression, + "error": "" + } + } + } catch (error) { + return { + "isValid": false, + "result": "", + "displayResult": "", + "error": error.message || error.toString() + } + } + } + + // Generate calculator entry for display + function createEntry(expression, searchContext = "") { + const evaluation = evaluate(expression) + + if (!evaluation.isValid) { + return { + "isCalculator": true, + "name": "Invalid expression", + "content": evaluation.error, + "icon": "error", + "execute": function () { + // Do nothing for invalid expressions + } + } + } + + const displayName = searchContext === "calc" + ? `${expression} = ${evaluation.displayResult}` + : `${expression} = ${evaluation.displayResult}` + + return { + "isCalculator": true, + "name": displayName, + "result": evaluation.result, + "expr": expression, + "displayResult": evaluation.displayResult, + "icon": "calculate", + "execute": function () { + Quickshell.clipboardText = evaluation.displayResult + // Also copy using shell command for better compatibility + Quickshell.execDetached(["sh", "-lc", `printf %s ${evaluation.displayResult} | wl-copy -t text/plain;charset=utf-8`]) + Quickshell.execDetached([ + "notify-send", + "Calculator", + `${expression} = ${evaluation.displayResult} (copied to clipboard)` + ]) + } + } + } + + // Create placeholder entry for empty calculator mode + function createPlaceholderEntry() { + return { + "isCalculator": true, + "name": "Calculator", + "content": "Try: sqrt(16), sin(1), cos(0), pi*2, exp(1), pow(2,8), abs(-5)", + "icon": "calculate", + "execute": function () { + // Do nothing for placeholder + } + } + } + + // Process calculator queries + function processQuery(query, searchContext = "") { + const results = [] + + if (searchContext === "calc") { + // Handle ">calc" mode + const expr = query.slice(5).trim() + if (expr && expr !== "") { + results.push(createEntry(expr, "calc")) + } else { + results.push(createPlaceholderEntry()) + } + } else if (query.startsWith(">") && query.length > 1 && !query.startsWith(">clip") && !query.startsWith(">calc")) { + // Handle direct math expressions after ">" + const mathExpr = query.slice(1).trim() + const evaluation = evaluate(mathExpr) + + if (evaluation.isValid) { + results.push(createEntry(mathExpr, "direct")) + } + // If invalid, don't add anything - let it fall through to regular search + } + + return results + } +} diff --git a/Modules/AppLauncher/ClipboardHistory.qml b/Modules/AppLauncher/ClipboardHistory.qml new file mode 100644 index 0000000..dfd4d8c --- /dev/null +++ b/Modules/AppLauncher/ClipboardHistory.qml @@ -0,0 +1,158 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Services + +QtObject { + id: clipboardHistory + + // Copy helpers for different content types + function copyImageBase64(mime, base64) { + Quickshell.execDetached(["sh", "-lc", `printf %s ${base64} | base64 -d | wl-copy -t '${mime}'`]) + } + + function copyText(text) { + // Use printf with proper quoting to handle special characters + Quickshell.execDetached(["sh", "-c", `printf '%s' ${JSON.stringify(text)} | wl-copy -t text/plain`]) + } + + // Create clipboard entry for display + function createClipboardEntry(clip, index) { + if (clip.type === 'image') { + return { + "isClipboard": true, + "name": "Image from " + new Date(clip.timestamp).toLocaleTimeString(), + "content": "Image: " + clip.mimeType, + "icon": "image", + "type": 'image', + "data": clip.data, + "timestamp": clip.timestamp, + "index": index, + "execute": function () { + const dataParts = clip.data.split(',') + const base64Data = dataParts.length > 1 ? dataParts[1] : clip.data + copyImageBase64(clip.mimeType, base64Data) + Quickshell.execDetached(["notify-send", "Clipboard", "Image copied: " + clip.mimeType]) + } + } + } else { + // Handle text content + const textContent = clip.content || clip + let displayContent = textContent + let previewContent = "" + + // Normalize whitespace for display + displayContent = displayContent.replace(/\s+/g, ' ').trim() + + // Create preview for long content + if (displayContent.length > 50) { + previewContent = displayContent + displayContent = displayContent.split('\n')[0].substring(0, 50) + "..." + } + + return { + "isClipboard": true, + "name": displayContent, + "content": previewContent || textContent, + "icon": "content_paste", + "type": 'text', + "timestamp": clip.timestamp, + "index": index, + "textData": textContent, // Store the text data for the execute function + "execute": function () { + const text = this.textData || clip.content || clip + Quickshell.clipboardText = String(text) + copyText(String(text)) + var preview = (text.length > 50) ? text.slice(0, 50) + "…" : text + Quickshell.execDetached(["notify-send", "Clipboard", "Text copied: " + preview]) + } + } + } + } + + // Create empty state entry + function createEmptyEntry() { + return { + "isClipboard": true, + "name": "No clipboard history", + "content": "No matching clipboard entries found", + "icon": "content_paste_off", + "execute": function () { + // Do nothing for empty state + } + } + } + + // Process clipboard queries + function processQuery(query) { + const results = [] + + if (!query.startsWith(">clip")) { + return results + } + + // Extract search term after ">clip " + const searchTerm = query.slice(5).trim() + + // Note: Clipboard refresh should be handled externally to avoid binding loops + + // Process each clipboard item + ClipboardService.history.forEach(function (clip, index) { + let searchContent = clip.type === 'image' ? clip.mimeType : clip.content || clip + + // Apply search filter if provided + if (!searchTerm || searchContent.toLowerCase().includes(searchTerm.toLowerCase())) { + const entry = createClipboardEntry(clip, index) + results.push(entry) + } + }) + + // Show empty state if no results + if (results.length === 0) { + results.push(createEmptyEntry()) + } + + return results + } + + // Create command entry for clipboard mode (deprecated - use direct creation in parent) + function createCommandEntry() { + return { + "isCommand": true, + "name": ">clip", + "content": "Clipboard history - browse and restore clipboard items", + "icon": "content_paste", + "execute": function () { + // This should be handled by the parent component + } + } + } + + // Utility function to refresh clipboard + function refresh() { + ClipboardService.refresh() + } + + // Get clipboard history count + function getHistoryCount() { + return ClipboardService.history ? ClipboardService.history.length : 0 + } + + // Get formatted timestamp for display + function formatTimestamp(timestamp) { + return new Date(timestamp).toLocaleTimeString() + } + + // Get clipboard entry by index + function getEntryByIndex(index) { + if (ClipboardService.history && index >= 0 && index < ClipboardService.history.length) { + return ClipboardService.history[index] + } + return null + } + + // Clear all clipboard history + function clearAll() { + ClipboardService.clearHistory() + } +} diff --git a/Services/ClipboardService.qml b/Services/ClipboardService.qml index 008e83c..558b958 100644 --- a/Services/ClipboardService.qml +++ b/Services/ClipboardService.qml @@ -11,12 +11,40 @@ Singleton { property var history: [] property bool initialized: false + property int maxHistory: 50 // Limit clipboard history entries // Internal state property bool _enabled: true + // Cached history file path + property string historyFile: Quickshell.env("NOCTALIA_CLIPBOARD_HISTORY_FILE") + || (Settings.cacheDir + "clipboard.json") + + // Persisted storage for clipboard history + property FileView historyFileView: FileView { + id: historyFileView + objectName: "clipboardHistoryFileView" + path: historyFile + watchChanges: false // We don't need to watch changes for clipboard + onAdapterUpdated: writeAdapter() + Component.onCompleted: reload() + onLoaded: loadFromHistory() + onLoadFailed: function (error) { + // Create file on first use + if (error.toString().includes("No such file") || error === 2) { + writeAdapter() + } + } + + JsonAdapter { + id: historyAdapter + property var history: [] + property double timestamp: 0 + } + } + Timer { - interval: 1000 + interval: 2000 repeat: true running: root._enabled onTriggered: root.refresh() @@ -32,14 +60,17 @@ Singleton { if (exitCode === 0) { currentTypes = String(stdout.text).trim().split('\n').filter(t => t) + // Always check for text first + textProcess.command = ["wl-paste", "-n", "--type", "text/plain"] + textProcess.isLoading = true + textProcess.running = true + + // Also check for images if available const imageType = currentTypes.find(t => t.startsWith('image/')) if (imageType) { imageProcess.mimeType = imageType imageProcess.command = ["sh", "-c", `wl-paste -n -t "${imageType}" | base64 -w 0`] imageProcess.running = true - } else { - textProcess.command = ["wl-paste", "-n", "--type", "text/plain"] - textProcess.running = true } } else { typeProcess.isLoading = false @@ -65,17 +96,31 @@ Singleton { "timestamp": new Date().getTime() } + // Check if this exact image already exists const exists = root.history.find(item => item.type === 'image' && item.data === entry.data) if (!exists) { - root.history = [entry, ...root.history].slice(0, 20) + // Normalize existing history and add the new image + const normalizedHistory = root.history.map(item => { + if (typeof item === 'string') { + return { + "type": 'text', + "content": item, + "timestamp": new Date().getTime() - 1000 // Make it slightly older + } + } + return item + }) + root.history = [entry, ...normalizedHistory].slice(0, maxHistory) + saveHistory() } } } + // Always mark as initialized when done if (!textProcess.isLoading) { root.initialized = true + typeProcess.isLoading = false } - typeProcess.isLoading = false } stdout: StdioCollector {} @@ -87,15 +132,18 @@ Singleton { property bool isLoading: false onExited: (exitCode, exitStatus) => { + textProcess.isLoading = false + if (exitCode === 0) { const content = String(stdout.text).trim() - if (content) { + if (content && content.length > 0) { const entry = { "type": 'text', "content": content, "timestamp": new Date().getTime() } + // Check if this exact text content already exists const exists = root.history.find(item => { if (item.type === 'text') { return item.content === content @@ -104,36 +152,75 @@ Singleton { }) if (!exists) { - const newHistory = root.history.map(item => { - if (typeof item === 'string') { - return { - "type": 'text', - "content": item, - "timestamp": new Date().getTime() - } - } - return item - }) + // Normalize existing history entries + const normalizedHistory = root.history.map(item => { + if (typeof item === 'string') { + return { + "type": 'text', + "content": item, + "timestamp": new Date().getTime() - 1000 // Make it slightly older + } + } + return item + }) - root.history = [entry, ...newHistory].slice(0, 20) + root.history = [entry, ...normalizedHistory].slice(0, maxHistory) + saveHistory() } } - } else { - textProcess.isLoading = false } + // Mark as initialized and clean up loading states root.initialized = true - typeProcess.isLoading = false + if (!imageProcess.running) { + typeProcess.isLoading = false + } } stdout: StdioCollector {} } function refresh() { - if (!typeProcess.isLoading && !textProcess.isLoading) { + if (!typeProcess.isLoading && !textProcess.isLoading && !imageProcess.running) { typeProcess.isLoading = true typeProcess.command = ["wl-paste", "-l"] typeProcess.running = true } } + + function loadFromHistory() { + // Populate in-memory history from cached file + try { + const items = historyAdapter.history || [] + root.history = items.slice(0, maxHistory) // Apply limit when loading + Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache") + } catch (e) { + Logger.error("Clipboard", "Failed to load history:", e) + root.history = [] + } + } + + function saveHistory() { + try { + // Ensure we don't exceed the maximum history limit + const limitedHistory = root.history.slice(0, maxHistory) + + historyAdapter.history = limitedHistory + historyAdapter.timestamp = Time.timestamp + + // Ensure cache directory exists + Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]) + + Qt.callLater(function () { + historyFileView.writeAdapter() + }) + } catch (e) { + Logger.error("Clipboard", "Failed to save history:", e) + } + } + + function clearHistory() { + root.history = [] + saveHistory() + } }