Refactor AppLauncher, fix clipboardhistory, add persistent history (max 50 entries), add advanced math stuff

This commit is contained in:
Ly-sec
2025-08-20 13:19:39 +02:00
parent 7ce8e51fa3
commit 044da17763
5 changed files with 690 additions and 263 deletions

152
Helpers/AdvancedMath.js Normal file
View File

@@ -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"
];
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}