Merge branch 'launcher-evolved'
This commit is contained in:
commit
1e81a89a1a
10 changed files with 1051 additions and 1159 deletions
|
|
@ -1,151 +0,0 @@
|
||||||
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
|
|
||||||
Logger.warn("Calculator", "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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import QtQuick
|
|
||||||
import Quickshell
|
|
||||||
import qs.Commons
|
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
QtObject {
|
|
||||||
id: clipboardHistory
|
|
||||||
|
|
||||||
function parseImageMeta(preview) {
|
|
||||||
const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i
|
|
||||||
const m = (preview || "").match(re)
|
|
||||||
if (!m)
|
|
||||||
return null
|
|
||||||
return {
|
|
||||||
"size": m[1],
|
|
||||||
"fmt": (m[2] || "").toUpperCase(),
|
|
||||||
"w": Number(m[3]),
|
|
||||||
"h": Number(m[4])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTextPreview(preview) {
|
|
||||||
const normalized = (preview || "").replace(/\s+/g, ' ').trim()
|
|
||||||
const lines = normalized.split(/\n+/)
|
|
||||||
const title = (lines[0] || "Text").slice(0, 60)
|
|
||||||
const subtitle = (lines.length > 1) ? lines[1].slice(0, 80) : ""
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"subtitle": subtitle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createClipboardEntry(item) {
|
|
||||||
if (item.isImage) {
|
|
||||||
const meta = parseImageMeta(item.preview)
|
|
||||||
const title = meta ? `Image ${meta.w}×${meta.h}` : "Image"
|
|
||||||
const subtitle = ""
|
|
||||||
return {
|
|
||||||
"isClipboard": true,
|
|
||||||
"name": title,
|
|
||||||
"content": subtitle,
|
|
||||||
"icon": "image",
|
|
||||||
"type": 'image',
|
|
||||||
"id": item.id,
|
|
||||||
"mime": item.mime
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const parts = formatTextPreview(item.preview)
|
|
||||||
return {
|
|
||||||
"isClipboard": true,
|
|
||||||
"name": parts.title,
|
|
||||||
"content": "",
|
|
||||||
"icon": "content_paste",
|
|
||||||
"type": 'text',
|
|
||||||
"id": item.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createEmptyEntry() {
|
|
||||||
return {
|
|
||||||
"isClipboard": true,
|
|
||||||
"name": "No clipboard history",
|
|
||||||
"content": "No matching clipboard entries found",
|
|
||||||
"icon": "content_paste_off",
|
|
||||||
"execute": function () {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function processQuery(query, items) {
|
|
||||||
const results = []
|
|
||||||
if (!query.startsWith(">clip")) {
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchTerm = query.slice(5).trim().toLowerCase()
|
|
||||||
|
|
||||||
// Dependency hook without side effects
|
|
||||||
const _rev = CliphistService.revision
|
|
||||||
const source = items || CliphistService.items
|
|
||||||
|
|
||||||
source.forEach(function (item) {
|
|
||||||
const hay = (item.preview || "").toLowerCase()
|
|
||||||
if (!searchTerm || hay.indexOf(searchTerm) !== -1) {
|
|
||||||
const entry = createClipboardEntry(item)
|
|
||||||
// Attach execute at this level to avoid duplicating functions
|
|
||||||
entry.execute = function () {
|
|
||||||
CliphistService.copyToClipboard(item.id)
|
|
||||||
}
|
|
||||||
results.push(entry)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (results.length === 0) {
|
|
||||||
results.push(createEmptyEntry())
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
CliphistService.list(100)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAll() {
|
|
||||||
CliphistService.wipeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,415 +1,284 @@
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Effects
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
|
||||||
import Quickshell.Wayland
|
|
||||||
import Quickshell.Widgets
|
import Quickshell.Widgets
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
|
||||||
import "../../Helpers/FuzzySort.js" as Fuzzysort
|
|
||||||
|
|
||||||
NPanel {
|
NPanel {
|
||||||
id: root
|
id: root
|
||||||
panelWidth: Math.min(700 * scaling, screen?.width * 0.75)
|
|
||||||
panelHeight: Math.min(550 * scaling, screen?.height * 0.8)
|
|
||||||
// Positioning derives from Settings.data.bar.position for vertical (top/bottom)
|
|
||||||
// and from Settings.data.appLauncher.position for horizontal vs center.
|
|
||||||
// Options: center, top_left, top_right, bottom_left, bottom_right, bottom_center, top_center
|
|
||||||
readonly property string launcherPosition: Settings.data.appLauncher.position
|
|
||||||
|
|
||||||
panelAnchorHorizontalCenter: launcherPosition === "center" || (launcherPosition.endsWith("_center"))
|
// Panel configuration
|
||||||
panelAnchorVerticalCenter: launcherPosition === "center"
|
panelWidth: {
|
||||||
panelAnchorLeft: launcherPosition !== "center" && (launcherPosition.endsWith("_left"))
|
var w = Math.round(Math.max(screen?.width * 0.3, 500) * scaling)
|
||||||
panelAnchorRight: launcherPosition !== "center" && (launcherPosition.endsWith("_right"))
|
w = Math.min(w, screen?.width - Style.marginL * 2)
|
||||||
panelAnchorBottom: launcherPosition.startsWith("bottom_")
|
return w
|
||||||
panelAnchorTop: launcherPosition.startsWith("top_")
|
}
|
||||||
|
panelHeight: {
|
||||||
|
var h = Math.round(Math.max(screen?.height * 0.5, 600) * scaling)
|
||||||
|
h = Math.min(h, screen?.height - Style.barHeight * scaling - Style.marginL * 2)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Enable keyboard focus for launcher (needed for search)
|
|
||||||
panelKeyboardFocus: true
|
panelKeyboardFocus: true
|
||||||
|
|
||||||
// Background opacity following bar's approach
|
|
||||||
panelBackgroundColor: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b,
|
panelBackgroundColor: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b,
|
||||||
Settings.data.appLauncher.backgroundOpacity)
|
Settings.data.appLauncher.backgroundOpacity)
|
||||||
|
|
||||||
// Properties
|
// Positioning
|
||||||
property string searchText: ""
|
readonly property string launcherPosition: Settings.data.appLauncher.position
|
||||||
property bool shouldResetCursor: false
|
panelAnchorHorizontalCenter: launcherPosition === "center" || launcherPosition.endsWith("_center")
|
||||||
|
panelAnchorVerticalCenter: launcherPosition === "center"
|
||||||
|
panelAnchorLeft: launcherPosition !== "center" && launcherPosition.endsWith("_left")
|
||||||
|
panelAnchorRight: launcherPosition !== "center" && launcherPosition.endsWith("_right")
|
||||||
|
panelAnchorBottom: launcherPosition.startsWith("bottom_")
|
||||||
|
panelAnchorTop: launcherPosition.startsWith("top_")
|
||||||
|
|
||||||
// Add function to set search text programmatically
|
// Core state
|
||||||
|
property string searchText: ""
|
||||||
|
property int selectedIndex: 0
|
||||||
|
property var results: []
|
||||||
|
property var plugins: []
|
||||||
|
property var activePlugin: null
|
||||||
|
|
||||||
|
// Public API for plugins
|
||||||
function setSearchText(text) {
|
function setSearchText(text) {
|
||||||
searchText = text
|
searchText = text
|
||||||
// The searchInput will automatically update via the text binding
|
|
||||||
// Focus and cursor position will be handled by the TextField's Component.onCompleted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpened: {
|
// Plugin registration
|
||||||
// Reset state when panel opens to avoid sticky modes
|
function registerPlugin(plugin) {
|
||||||
if (searchText === "") {
|
plugins.push(plugin)
|
||||||
searchText = ""
|
plugin.launcher = root
|
||||||
selectedIndex = 0
|
if (plugin.init)
|
||||||
|
plugin.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search handling
|
||||||
|
function updateResults() {
|
||||||
|
results = []
|
||||||
|
activePlugin = null
|
||||||
|
|
||||||
|
// Check for command mode
|
||||||
|
if (searchText.startsWith(">")) {
|
||||||
|
// Find plugin that handles this command
|
||||||
|
for (let plugin of plugins) {
|
||||||
|
if (plugin.handleCommand && plugin.handleCommand(searchText)) {
|
||||||
|
activePlugin = plugin
|
||||||
|
results = plugin.getResults(searchText)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show available commands if just ">"
|
||||||
|
if (searchText === ">" && !activePlugin) {
|
||||||
|
for (let plugin of plugins) {
|
||||||
|
if (plugin.commands) {
|
||||||
|
results = results.concat(plugin.commands())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular search - let plugins contribute results
|
||||||
|
for (let plugin of plugins) {
|
||||||
|
if (plugin.handleSearch) {
|
||||||
|
const pluginResults = plugin.getResults(searchText)
|
||||||
|
results = results.concat(pluginResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Focus search input on open and place cursor at end
|
|
||||||
Qt.callLater(() => {
|
selectedIndex = 0
|
||||||
if (searchInputBox && searchInputBox.inputItem) {
|
}
|
||||||
searchInputBox.inputItem.forceActiveFocus()
|
|
||||||
if (searchText && searchText.length > 0) {
|
onSearchTextChanged: updateResults()
|
||||||
searchInputBox.inputItem.cursorPosition = searchText.length
|
|
||||||
} else {
|
// Lifecycle
|
||||||
searchInputBox.inputItem.cursorPosition = 0
|
onOpened: {
|
||||||
}
|
// Notify plugins
|
||||||
}
|
for (let plugin of plugins) {
|
||||||
})
|
if (plugin.onOpened)
|
||||||
|
plugin.onOpened()
|
||||||
|
}
|
||||||
|
updateResults()
|
||||||
}
|
}
|
||||||
|
|
||||||
onClosed: {
|
onClosed: {
|
||||||
// Reset search bar when launcher is closed
|
// Notify plugins
|
||||||
searchText = ""
|
for (let plugin of plugins) {
|
||||||
selectedIndex = 0
|
if (plugin.onClosed)
|
||||||
shouldResetCursor = true
|
plugin.onClosed()
|
||||||
}
|
|
||||||
|
|
||||||
// Import modular components
|
|
||||||
Calculator {
|
|
||||||
id: calculator
|
|
||||||
}
|
|
||||||
|
|
||||||
ClipboardHistory {
|
|
||||||
id: clipboardHistory
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll cliphist while in clipboard mode to keep entries fresh
|
|
||||||
Timer {
|
|
||||||
id: clipRefreshTimer
|
|
||||||
interval: 2000
|
|
||||||
repeat: true
|
|
||||||
running: Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")
|
|
||||||
onTriggered: clipboardHistory.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Properties
|
|
||||||
property var desktopEntries: DesktopEntries.applications.values
|
|
||||||
property int selectedIndex: 0
|
|
||||||
|
|
||||||
// Refresh clipboard when user starts typing clipboard commands
|
|
||||||
onSearchTextChanged: {
|
|
||||||
if (Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")) {
|
|
||||||
clipboardHistory.refresh()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main filtering logic
|
// Navigation
|
||||||
property var filteredEntries: {
|
|
||||||
// Explicit dependency so changes to items/decoded images retrigger this binding
|
|
||||||
const _clipItems = Settings.data.appLauncher.enableClipboardHistory ? CliphistService.items : []
|
|
||||||
const _clipRev = Settings.data.appLauncher.enableClipboardHistory ? CliphistService.revision : 0
|
|
||||||
|
|
||||||
var query = searchText ? searchText.toLowerCase() : ""
|
|
||||||
if (Settings.data.appLauncher.enableClipboardHistory && query.startsWith(">clip")) {
|
|
||||||
return clipboardHistory.processQuery(query, _clipItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!desktopEntries || desktopEntries.length === 0) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out entries that shouldn't be displayed
|
|
||||||
var visibleEntries = desktopEntries.filter(entry => {
|
|
||||||
if (!entry || entry.noDisplay) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
var results = []
|
|
||||||
|
|
||||||
// Handle special commands
|
|
||||||
if (query === ">") {
|
|
||||||
results.push({
|
|
||||||
"isCommand": true,
|
|
||||||
"name": ">calc",
|
|
||||||
"content": "Calculator - evaluate mathematical expressions",
|
|
||||||
"icon": "calculate",
|
|
||||||
"execute": executeCalcCommand
|
|
||||||
})
|
|
||||||
if (Settings.data.appLauncher.enableClipboardHistory) {
|
|
||||||
results.push({
|
|
||||||
"isCommand": true,
|
|
||||||
"name": ">clip",
|
|
||||||
"content": "Clipboard history - browse and restore clipboard items",
|
|
||||||
"icon": "content_paste",
|
|
||||||
"execute": executeClipCommand
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle calculator
|
|
||||||
if (query.startsWith(">calc")) {
|
|
||||||
return calculator.processQuery(query, "calc")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle direct math expressions after ">"
|
|
||||||
if (query.startsWith(">") && query.length > 1 && (!Settings.data.appLauncher.enableClipboardHistory
|
|
||||||
|| !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
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command execution functions
|
|
||||||
function executeCalcCommand() {
|
|
||||||
setSearchText(">calc ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function executeClipCommand() {
|
|
||||||
setSearchText(">clip ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation functions
|
|
||||||
function selectNext() {
|
function selectNext() {
|
||||||
if (filteredEntries.length > 0) {
|
if (results.length > 0) {
|
||||||
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1)
|
// Clamp the index to not exceed the last item
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, results.length - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPrev() {
|
function selectPrev() {
|
||||||
if (filteredEntries.length > 0) {
|
if (results.length > 0) {
|
||||||
|
// Clamp the index to not go below the first item (0)
|
||||||
selectedIndex = Math.max(selectedIndex - 1, 0)
|
selectedIndex = Math.max(selectedIndex - 1, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectNextPage() {
|
function activate() {
|
||||||
if (filteredEntries.length > 0) {
|
if (results.length > 0 && results[selectedIndex]) {
|
||||||
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling)
|
const item = results[selectedIndex]
|
||||||
const page = Math.max(1, Math.floor(appsList.height / delegateHeight))
|
if (item.onActivate) {
|
||||||
selectedIndex = Math.min(selectedIndex + page, filteredEntries.length - 1)
|
item.onActivate()
|
||||||
}
|
|
||||||
}
|
|
||||||
function selectPrevPage() {
|
|
||||||
if (filteredEntries.length > 0) {
|
|
||||||
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling)
|
|
||||||
const page = Math.max(1, Math.floor(appsList.height / delegateHeight))
|
|
||||||
selectedIndex = Math.max(selectedIndex - page, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateSelected() {
|
|
||||||
if (filteredEntries.length === 0)
|
|
||||||
return
|
|
||||||
|
|
||||||
var modelData = filteredEntries[selectedIndex]
|
|
||||||
if (modelData && modelData.execute) {
|
|
||||||
if (modelData.isCommand) {
|
|
||||||
modelData.execute()
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
modelData.execute()
|
|
||||||
}
|
}
|
||||||
root.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load plugins
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
Logger.log("Launcher", "Component completed")
|
// Load applications plugin
|
||||||
Logger.log("Launcher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined')
|
const appsPlugin = Qt.createComponent("Plugins/ApplicationsPlugin.qml").createObject(this)
|
||||||
if (typeof DesktopEntries !== 'undefined') {
|
if (appsPlugin) {
|
||||||
Logger.log("Launcher", "DesktopEntries.entries:",
|
registerPlugin(appsPlugin)
|
||||||
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined')
|
Logger.log("Launcher", "Registered: ApplicationsPlugin")
|
||||||
|
} else {
|
||||||
|
Logger.error("Launcher", "Failed to load ApplicationsPlugin")
|
||||||
}
|
}
|
||||||
// Start clipboard refresh immediately on open if enabled
|
|
||||||
if (Settings.data.appLauncher.enableClipboardHistory) {
|
// Load calculator plugin
|
||||||
clipboardHistory.refresh()
|
const calcPlugin = Qt.createComponent("Plugins/CalculatorPlugin.qml").createObject(this)
|
||||||
|
if (calcPlugin) {
|
||||||
|
registerPlugin(calcPlugin)
|
||||||
|
Logger.log("Launcher", "Registered: CalculatorPlugin")
|
||||||
|
} else {
|
||||||
|
Logger.error("Launcher", "Failed to load CalculatorPlugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load clipboard history plugin
|
||||||
|
const clipboardPlugin = Qt.createComponent("Plugins/ClipboardPlugin.qml").createObject(this)
|
||||||
|
if (clipboardPlugin) {
|
||||||
|
registerPlugin(clipboardPlugin)
|
||||||
|
Logger.log("Launcher", "Registered: clipboardPlugin")
|
||||||
|
} else {
|
||||||
|
Logger.error("Launcher", "Failed to load clipboardPlugin")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main content container
|
// UI
|
||||||
panelContent: Rectangle {
|
panelContent: Rectangle {
|
||||||
color: Color.transparent
|
color: Color.transparent
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
searchText = ""
|
||||||
|
selectedIndex = 0
|
||||||
|
if (searchInput?.forceActiveFocus) {
|
||||||
|
searchInput.forceActiveFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Style.marginL * scaling
|
anchors.margins: Style.marginL * scaling
|
||||||
spacing: Style.marginM * scaling
|
spacing: Style.marginM * scaling
|
||||||
|
|
||||||
RowLayout {
|
FocusScope {
|
||||||
|
id: searchInputWrap
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
|
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
|
||||||
Layout.bottomMargin: Style.marginM * scaling
|
|
||||||
|
|
||||||
// Wrapper ensures the input stretches to full width under RowLayout
|
// This FocusScope should get focus when panel opens
|
||||||
Item {
|
focus: true
|
||||||
id: searchInputWrap
|
|
||||||
Layout.fillWidth: true
|
|
||||||
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
|
|
||||||
|
|
||||||
NTextInput {
|
NTextInput {
|
||||||
id: searchInputBox
|
id: searchInput
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
placeholderText: "Search applications... (use > to view commands)"
|
|
||||||
text: searchText
|
// The input should have focus within the scope
|
||||||
inputMaxWidth: 100000
|
focus: true
|
||||||
// Tune vertical centering on inner input
|
|
||||||
Component.onCompleted: {
|
placeholderText: "Search entries... or use > for commands"
|
||||||
searchInputBox.inputItem.font.pointSize = Style.fontSizeL * scaling
|
text: searchText
|
||||||
searchInputBox.inputItem.verticalAlignment = TextInput.AlignVCenter
|
inputMaxWidth: Number.MAX_SAFE_INTEGER
|
||||||
// Ensure focus when launcher first appears
|
|
||||||
Qt.callLater(() => {
|
function forceActiveFocus() {
|
||||||
searchInputBox.inputItem.forceActiveFocus()
|
// First ensure the scope has focus
|
||||||
if (searchText && searchText.length > 0) {
|
searchInputWrap.forceActiveFocus()
|
||||||
searchInputBox.inputItem.cursorPosition = searchText.length
|
// Then focus the actual input
|
||||||
} else {
|
if (inputItem && inputItem.visible) {
|
||||||
searchInputBox.inputItem.cursorPosition = 0
|
inputItem.forceActiveFocus()
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
onTextChanged: {
|
|
||||||
if (searchText !== text) {
|
|
||||||
searchText = text
|
|
||||||
}
|
|
||||||
Qt.callLater(() => selectedIndex = 0)
|
|
||||||
if (shouldResetCursor && text === "") {
|
|
||||||
searchInputBox.inputItem.cursorPosition = 0
|
|
||||||
shouldResetCursor = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Forward key navigation to behave like before
|
|
||||||
Keys.onDownPressed: selectNext()
|
|
||||||
Keys.onUpPressed: selectPrev()
|
|
||||||
Keys.onEnterPressed: activateSelected()
|
|
||||||
Keys.onReturnPressed: activateSelected()
|
|
||||||
Keys.onEscapePressed: root.close()
|
|
||||||
Keys.onPressed: event => {
|
|
||||||
if (event.key === Qt.Key_PageDown) {
|
|
||||||
appsList.cancelFlick()
|
|
||||||
root.selectNextPage()
|
|
||||||
event.accepted = true
|
|
||||||
} else if (event.key === Qt.Key_PageUp) {
|
|
||||||
appsList.cancelFlick()
|
|
||||||
root.selectPrevPage()
|
|
||||||
event.accepted = true
|
|
||||||
} else if (event.key === Qt.Key_Home) {
|
|
||||||
appsList.cancelFlick()
|
|
||||||
selectedIndex = 0
|
|
||||||
event.accepted = true
|
|
||||||
} else if (event.key === Qt.Key_End) {
|
|
||||||
appsList.cancelFlick()
|
|
||||||
if (filteredEntries.length > 0) {
|
|
||||||
selectedIndex = filteredEntries.length - 1
|
|
||||||
}
|
|
||||||
event.accepted = true
|
|
||||||
}
|
|
||||||
if (event.modifiers & Qt.ControlModifier) {
|
|
||||||
switch (event.key) {
|
|
||||||
case Qt.Key_J:
|
|
||||||
appsList.cancelFlick()
|
|
||||||
root.selectNext()
|
|
||||||
event.accepted = true
|
|
||||||
break
|
|
||||||
case Qt.Key_K:
|
|
||||||
appsList.cancelFlick()
|
|
||||||
root.selectPrev()
|
|
||||||
event.accepted = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clear-all action to the right of the input
|
Component.onCompleted: {
|
||||||
NIconButton {
|
if (inputItem) {
|
||||||
Layout.alignment: Qt.AlignVCenter
|
inputItem.font.pointSize = Style.fontSizeL * scaling
|
||||||
visible: searchText.startsWith(">clip")
|
inputItem.verticalAlignment = TextInput.AlignVCenter
|
||||||
icon: "delete_sweep"
|
}
|
||||||
tooltipText: "Clear clipboard history"
|
}
|
||||||
onClicked: CliphistService.wipeAll()
|
|
||||||
|
onTextChanged: searchText = text
|
||||||
|
|
||||||
|
Keys.onDownPressed: root.selectNext()
|
||||||
|
Keys.onUpPressed: root.selectPrev()
|
||||||
|
Keys.onReturnPressed: root.activate()
|
||||||
|
Keys.onEscapePressed: root.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applications list
|
// Results list
|
||||||
ListView {
|
ListView {
|
||||||
id: appsList
|
id: resultsList
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
clip: true
|
|
||||||
spacing: Style.marginXXS * scaling
|
spacing: Style.marginXXS * scaling
|
||||||
model: filteredEntries
|
|
||||||
|
model: results
|
||||||
currentIndex: selectedIndex
|
currentIndex: selectedIndex
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
|
||||||
maximumFlickVelocity: 2500
|
clip: true
|
||||||
flickDeceleration: 2000
|
cacheBuffer: resultsList.height * 2
|
||||||
onCurrentIndexChanged: {
|
onCurrentIndexChanged: {
|
||||||
cancelFlick()
|
cancelFlick()
|
||||||
if (currentIndex >= 0) {
|
if (currentIndex >= 0) {
|
||||||
positionViewAtIndex(currentIndex, ListView.Contain)
|
positionViewAtIndex(currentIndex, ListView.Contain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollBar.vertical: ScrollBar {
|
ScrollBar.vertical: ScrollBar {
|
||||||
policy: ScrollBar.AsNeeded
|
policy: ScrollBar.AsNeeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep viewport anchored to the selected item when the clipboard model refreshes
|
delegate: Rectangle {
|
||||||
Connections {
|
id: entry
|
||||||
target: CliphistService
|
|
||||||
function onRevisionChanged() {
|
property bool isSelected: mouseArea.containsMouse || (index === selectedIndex)
|
||||||
if (Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")) {
|
property int badgeSize: Style.baseWidgetSize * 1.6 * scaling
|
||||||
// Clamp selection in case the list shrank
|
|
||||||
if (selectedIndex >= filteredEntries.length) {
|
// Property to reliably track the current item's ID.
|
||||||
selectedIndex = Math.max(0, filteredEntries.length - 1)
|
// This changes whenever the delegate is recycled for a new item.
|
||||||
}
|
property var currentClipboardId: modelData.isImage ? modelData.clipboardId : ""
|
||||||
Qt.callLater(() => {
|
|
||||||
appsList.positionViewAtIndex(selectedIndex, ListView.Contain)
|
// When this delegate is assigned a new image item, trigger the decode.
|
||||||
})
|
onCurrentClipboardIdChanged: {
|
||||||
|
// Check if it's a valid ID and if the data isn't already cached.
|
||||||
|
if (currentClipboardId && !ClipboardService.getImageData(currentClipboardId)) {
|
||||||
|
ClipboardService.decodeToDataUrl(currentClipboardId, modelData.mime, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
delegate: Rectangle {
|
width: resultsList.width - Style.marginS * scaling
|
||||||
width: appsList.width - Style.marginS * scaling
|
height: badgeSize + Style.marginM * 2 * scaling
|
||||||
height: 65 * scaling
|
|
||||||
radius: Style.radiusM * scaling
|
radius: Style.radiusM * scaling
|
||||||
property bool isSelected: index === selectedIndex
|
color: entry.isSelected ? Color.mTertiary : Color.mSurface
|
||||||
color: (appCardArea.containsMouse || isSelected) ? Color.mSecondary : Color.mSurface
|
|
||||||
|
|
||||||
Behavior on color {
|
Behavior on color {
|
||||||
ColorAnimation {
|
ColorAnimation {
|
||||||
duration: Style.animationFast
|
duration: Style.animationFast
|
||||||
}
|
easing.type: Easing.OutCirc
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on border.color {
|
|
||||||
ColorAnimation {
|
|
||||||
duration: Style.animationFast
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Behavior on border.width {
|
|
||||||
NumberAnimation {
|
|
||||||
duration: Style.animationFast
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,97 +287,129 @@ NPanel {
|
||||||
anchors.margins: Style.marginM * scaling
|
anchors.margins: Style.marginM * scaling
|
||||||
spacing: Style.marginM * scaling
|
spacing: Style.marginM * scaling
|
||||||
|
|
||||||
// App/clipboard icon with background
|
// Icon badge or Image preview
|
||||||
Rectangle {
|
Rectangle {
|
||||||
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
|
Layout.preferredWidth: badgeSize
|
||||||
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
|
Layout.preferredHeight: badgeSize
|
||||||
radius: Style.radiusS * scaling
|
radius: Style.radiusM * scaling
|
||||||
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant
|
color: Color.mSurfaceVariant
|
||||||
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|
clip: true
|
||||||
|| (iconImg.status === Image.Ready && iconImg.source !== ""
|
|
||||||
&& iconImg.status !== Image.Error && iconImg.source !== "")
|
|
||||||
visible: !searchText.startsWith(">calc")
|
|
||||||
|
|
||||||
// Decode image thumbnails on demand
|
|
||||||
Component.onCompleted: {
|
// Image preview for clipboard images
|
||||||
if (modelData && modelData.type === 'image' && !CliphistService.imageDataById[modelData.id]) {
|
NImageRounded {
|
||||||
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
|
id: imagePreview
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: modelData.isImage
|
||||||
|
imageRadius: Style.radiusM * scaling
|
||||||
|
|
||||||
|
// This property creates a dependency on the service's revision counter
|
||||||
|
readonly property int _rev: ClipboardService.revision
|
||||||
|
|
||||||
|
// Fetches from the service's cache.
|
||||||
|
// The dependency on `_rev` ensures this binding is re-evaluated when the cache is updated.
|
||||||
|
imagePath: {
|
||||||
|
_rev
|
||||||
|
return ClipboardService.getImageData(modelData.clipboardId) || ""
|
||||||
}
|
}
|
||||||
}
|
|
||||||
onVisibleChanged: {
|
// Loading indicator
|
||||||
if (visible && modelData && modelData.type === 'image'
|
Rectangle {
|
||||||
&& !CliphistService.imageDataById[modelData.id]) {
|
anchors.fill: parent
|
||||||
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
|
visible: parent.status === Image.Loading
|
||||||
|
color: Color.mSurfaceVariant
|
||||||
|
|
||||||
|
BusyIndicator {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
running: true
|
||||||
|
width: Style.baseWidgetSize * 0.5 * scaling
|
||||||
|
height: width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error fallback
|
||||||
|
onStatusChanged: status => {
|
||||||
|
if (status === Image.Error) {
|
||||||
|
iconLoader.visible = true
|
||||||
|
imagePreview.visible = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clipboard image display (pull from cache)
|
// Icon fallback
|
||||||
Image {
|
Loader {
|
||||||
id: clipboardImage
|
id: iconLoader
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: Style.marginXS * scaling
|
anchors.margins: Style.marginXS * scaling
|
||||||
visible: modelData.type === 'image'
|
|
||||||
source: modelData.type === 'image' ? (CliphistService.imageDataById[modelData.id] || "") : ""
|
visible: !modelData.isImage || imagePreview.status === Image.Error
|
||||||
fillMode: Image.PreserveAspectCrop
|
active: visible
|
||||||
asynchronous: true
|
|
||||||
cache: true
|
sourceComponent: Component {
|
||||||
}
|
IconImage {
|
||||||
|
anchors.fill: parent
|
||||||
IconImage {
|
source: modelData.icon ? Icons.iconFromName(modelData.icon, "application-x-executable") : ""
|
||||||
id: iconImg
|
visible: modelData.icon && source !== ""
|
||||||
anchors.fill: parent
|
asynchronous: true
|
||||||
anchors.margins: Style.marginXS * scaling
|
}
|
||||||
asynchronous: true
|
}
|
||||||
source: modelData.isCalculator ? "" : modelData.isClipboard ? "" : modelData.isCommand ? modelData.icon : Icons.iconFromName(
|
|
||||||
modelData.icon,
|
|
||||||
"application-x-executable")
|
|
||||||
visible: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand || parent.iconLoaded)
|
|
||||||
&& modelData.type !== 'image'
|
|
||||||
}
|
|
||||||
|
|
||||||
Rectangle {
|
|
||||||
anchors.fill: parent
|
|
||||||
anchors.margins: Style.marginXS * scaling
|
|
||||||
radius: Style.radiusXS * scaling
|
|
||||||
color: Color.mPrimary
|
|
||||||
opacity: Style.opacityMedium
|
|
||||||
visible: !parent.iconLoaded
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback text if no icon and no image
|
||||||
NText {
|
NText {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|
visible: !imagePreview.visible && !iconLoader.visible
|
||||||
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
|
text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?"
|
||||||
font.pointSize: Style.fontSizeXXL * scaling
|
font.pointSize: Style.fontSizeXXL * scaling
|
||||||
font.weight: Style.fontWeightBold
|
font.weight: Style.fontWeightBold
|
||||||
color: Color.mPrimary
|
color: Color.mOnPrimary
|
||||||
}
|
}
|
||||||
|
|
||||||
Behavior on color {
|
// Image type indicator overlay
|
||||||
ColorAnimation {
|
Rectangle {
|
||||||
duration: Style.animationFast
|
visible: modelData.isImage && imagePreview.visible
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.margins: 2 * scaling
|
||||||
|
width: formatLabel.width + 6 * scaling
|
||||||
|
height: formatLabel.height + 2 * scaling
|
||||||
|
radius: Style.radiusM * scaling
|
||||||
|
color: Color.mSurfaceVariant
|
||||||
|
|
||||||
|
NText {
|
||||||
|
id: formatLabel
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: {
|
||||||
|
if (!modelData.isImage)
|
||||||
|
return ""
|
||||||
|
const desc = modelData.description || ""
|
||||||
|
const parts = desc.split(" • ")
|
||||||
|
return parts[0] || "IMG"
|
||||||
|
}
|
||||||
|
font.pointSize: Style.fontSizeXXS * scaling
|
||||||
|
color: Color.mPrimary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// App info
|
// Text content
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
spacing: Style.marginXXS * scaling
|
spacing: 0 * scaling
|
||||||
|
|
||||||
NText {
|
NText {
|
||||||
text: modelData.name || "Unknown"
|
text: modelData.name || "Unknown"
|
||||||
font.pointSize: Style.fontSizeL * scaling
|
font.pointSize: Style.fontSizeL * scaling
|
||||||
font.weight: Style.fontWeightBold
|
font.weight: Style.fontWeightBold
|
||||||
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
|
color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurface
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
}
|
}
|
||||||
|
|
||||||
NText {
|
NText {
|
||||||
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "")
|
text: modelData.description || ""
|
||||||
font.pointSize: Style.fontSizeM * scaling
|
font.pointSize: Style.fontSizeS * scaling
|
||||||
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
|
color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: text !== ""
|
visible: text !== ""
|
||||||
|
|
@ -517,41 +418,34 @@ NPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: appCardArea
|
id: mouseArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
selectedIndex = index
|
selectedIndex = index
|
||||||
activateSelected()
|
root.activate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No results message
|
NDivider {
|
||||||
NText {
|
|
||||||
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
|
|
||||||
font.pointSize: Style.fontSizeL * scaling
|
|
||||||
color: Color.mOnSurface
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: filteredEntries.length === 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Results count
|
// Status
|
||||||
NText {
|
NText {
|
||||||
text: searchText.startsWith(
|
|
||||||
">clip") ? (Settings.data.appLauncher.enableClipboardHistory ? `${filteredEntries.length} clipboard item${filteredEntries.length !== 1 ? 's' : ''}` : `Clipboard history is disabled`) : searchText.startsWith(
|
|
||||||
">calc") ? `${filteredEntries.length} result${filteredEntries.length
|
|
||||||
!== 1 ? 's' : ''}` : `${filteredEntries.length} application${filteredEntries.length
|
|
||||||
!== 1 ? 's' : ''}`
|
|
||||||
font.pointSize: Style.fontSizeXS * scaling
|
|
||||||
color: Color.mOnSurface
|
|
||||||
horizontalAlignment: Text.AlignHCenter
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
visible: searchText.trim() !== ""
|
text: {
|
||||||
|
if (results.length === 0)
|
||||||
|
return searchText ? "No results" : ""
|
||||||
|
const prefix = activePlugin?.name ? `${activePlugin.name}: ` : ""
|
||||||
|
return prefix + `${results.length} result${results.length !== 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
font.pointSize: Style.fontSizeXS * scaling
|
||||||
|
color: Color.mOnSurfaceVariant
|
||||||
|
horizontalAlignment: Text.AlignCenter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
96
Modules/Launcher/Plugins/ApplicationsPlugin.qml
Normal file
96
Modules/Launcher/Plugins/ApplicationsPlugin.qml
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Services
|
||||||
|
import "../../../Helpers/FuzzySort.js" as Fuzzysort
|
||||||
|
|
||||||
|
Item {
|
||||||
|
property var launcher: null
|
||||||
|
property string name: "Applications"
|
||||||
|
property bool handleSearch: true
|
||||||
|
property var entries: []
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
loadApplications()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpened() {
|
||||||
|
// Refresh apps when launcher opens
|
||||||
|
loadApplications()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadApplications() {
|
||||||
|
if (typeof DesktopEntries === 'undefined') {
|
||||||
|
Logger.warn("ApplicationsPlugin", "DesktopEntries service not available")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allApps = DesktopEntries.applications.values || []
|
||||||
|
entries = allApps.filter(app => app && !app.noDisplay)
|
||||||
|
Logger.log("ApplicationsPlugin", `Loaded ${entries.length} applications`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResults(query) {
|
||||||
|
if (!entries || entries.length === 0)
|
||||||
|
return []
|
||||||
|
|
||||||
|
if (!query || query.trim() === "") {
|
||||||
|
// Return all apps alphabetically
|
||||||
|
return entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())).slice(
|
||||||
|
0, 50) // Limit to 50 for performance
|
||||||
|
.map(app => createResultEntry(app))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fuzzy search if available, fallback to simple search
|
||||||
|
if (typeof Fuzzysort !== 'undefined') {
|
||||||
|
const fuzzyResults = Fuzzysort.go(query, entries, {
|
||||||
|
"keys": ["name", "comment", "genericName"],
|
||||||
|
"threshold": -1000,
|
||||||
|
"limit": 20
|
||||||
|
})
|
||||||
|
|
||||||
|
return fuzzyResults.map(result => createResultEntry(result.obj))
|
||||||
|
} else {
|
||||||
|
// Fallback to simple search
|
||||||
|
const searchTerm = query.toLowerCase()
|
||||||
|
return entries.filter(app => {
|
||||||
|
const name = (app.name || "").toLowerCase()
|
||||||
|
const comment = (app.comment || "").toLowerCase()
|
||||||
|
const generic = (app.genericName || "").toLowerCase()
|
||||||
|
return name.includes(searchTerm) || comment.includes(searchTerm) || generic.includes(
|
||||||
|
searchTerm)
|
||||||
|
}).sort((a, b) => {
|
||||||
|
// Prioritize name matches
|
||||||
|
const aName = a.name.toLowerCase()
|
||||||
|
const bName = b.name.toLowerCase()
|
||||||
|
const aStarts = aName.startsWith(searchTerm)
|
||||||
|
const bStarts = bName.startsWith(searchTerm)
|
||||||
|
if (aStarts && !bStarts)
|
||||||
|
return -1
|
||||||
|
if (!aStarts && bStarts)
|
||||||
|
return 1
|
||||||
|
return aName.localeCompare(bName)
|
||||||
|
}).slice(0, 20).map(app => createResultEntry(app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResultEntry(app) {
|
||||||
|
return {
|
||||||
|
"name": app.name || "Unknown",
|
||||||
|
"description": app.genericName || app.comment || "",
|
||||||
|
"icon": app.icon || "application-x-executable",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {
|
||||||
|
Logger.log("ApplicationsPlugin", `Launching: ${app.name}`)
|
||||||
|
if (app.execute) {
|
||||||
|
app.execute()
|
||||||
|
} else if (app.exec) {
|
||||||
|
// Fallback to manual execution
|
||||||
|
Process.execute(app.exec)
|
||||||
|
}
|
||||||
|
launcher.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
Modules/Launcher/Plugins/CalculatorPlugin.qml
Normal file
106
Modules/Launcher/Plugins/CalculatorPlugin.qml
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import QtQuick
|
||||||
|
import qs.Services
|
||||||
|
import "../../../Helpers/AdvancedMath.js" as AdvancedMath
|
||||||
|
|
||||||
|
Item {
|
||||||
|
property var launcher: null
|
||||||
|
property string name: "Calculator"
|
||||||
|
|
||||||
|
function handleCommand(query) {
|
||||||
|
// Handle >calc command or direct math expressions after >
|
||||||
|
return query.startsWith(">calc") || (query.startsWith(">") && query.length > 1 && isMathExpression(
|
||||||
|
query.substring(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function commands() {
|
||||||
|
return [{
|
||||||
|
"name": ">calc",
|
||||||
|
"description": "Calculator - evaluate mathematical expressions",
|
||||||
|
"icon": "accessories-calculator",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {
|
||||||
|
launcher.setSearchText(">calc ")
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResults(query) {
|
||||||
|
let expression = ""
|
||||||
|
|
||||||
|
if (query.startsWith(">calc")) {
|
||||||
|
expression = query.substring(5).trim()
|
||||||
|
} else if (query.startsWith(">")) {
|
||||||
|
expression = query.substring(1).trim()
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expression) {
|
||||||
|
return [{
|
||||||
|
"name": "Calculator",
|
||||||
|
"description": "Enter a mathematical expression",
|
||||||
|
"icon": "accessories-calculator",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result = AdvancedMath.evaluate(expression.trim())
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"name": AdvancedMath.formatResult(result),
|
||||||
|
"description": `${expression} = ${result}`,
|
||||||
|
"icon": "accessories-calculator",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {
|
||||||
|
// TODO: copy entry to clipboard via ClipHist
|
||||||
|
launcher.close()
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
} catch (error) {
|
||||||
|
return [{
|
||||||
|
"name": "Error",
|
||||||
|
"description": error.message || "Invalid expression",
|
||||||
|
"icon": "dialog-error",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateExpression(expr) {
|
||||||
|
// Sanitize input - only allow safe characters
|
||||||
|
const sanitized = expr.replace(/[^0-9\+\-\*\/\(\)\.\s\%]/g, '')
|
||||||
|
if (sanitized !== expr) {
|
||||||
|
throw new Error("Invalid characters in expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't allow empty expressions
|
||||||
|
if (!sanitized.trim()) {
|
||||||
|
throw new Error("Empty expression")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use Function constructor for safe evaluation
|
||||||
|
// This is safer than eval() but still evaluate math
|
||||||
|
const result = Function('"use strict"; return (' + sanitized + ')')()
|
||||||
|
|
||||||
|
// Check for valid result
|
||||||
|
if (!isFinite(result)) {
|
||||||
|
throw new Error("Result is not a finite number")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round to reasonable precision to avoid floating point issues
|
||||||
|
return Math.round(result * 1000000000) / 1000000000
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("Invalid mathematical expression")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMathExpression(expr) {
|
||||||
|
// Check if string looks like a math expression
|
||||||
|
// Allow digits, operators, parentheses, decimal points, and whitespace
|
||||||
|
return /^[\d\s\+\-\*\/\(\)\.\%]+$/.test(expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
265
Modules/Launcher/Plugins/ClipboardPlugin.qml
Normal file
265
Modules/Launcher/Plugins/ClipboardPlugin.qml
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Commons
|
||||||
|
import qs.Services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
// Plugin metadata
|
||||||
|
property string name: "Clipboard History"
|
||||||
|
property var launcher: null
|
||||||
|
|
||||||
|
// Plugin capabilities
|
||||||
|
property bool handleSearch: false // Don't handle regular search
|
||||||
|
|
||||||
|
// Internal state
|
||||||
|
property bool isWaitingForData: false
|
||||||
|
property bool gotResults: false
|
||||||
|
property string lastSearchText: ""
|
||||||
|
|
||||||
|
// Listen for clipboard data updates
|
||||||
|
Connections {
|
||||||
|
target: ClipboardService
|
||||||
|
function onListCompleted() {
|
||||||
|
if (gotResults && (lastSearchText === searchText)) {
|
||||||
|
// Do not update results after the first fetch.
|
||||||
|
// This will avoid the list resetting every 2seconds when the service updates.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Refresh results if we're waiting for data or if clipboard plugin is active
|
||||||
|
if (isWaitingForData || (launcher && launcher.searchText.startsWith(">clip"))) {
|
||||||
|
isWaitingForData = false
|
||||||
|
gotResults = true
|
||||||
|
if (launcher) {
|
||||||
|
launcher.updateResults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize plugin
|
||||||
|
function init() {
|
||||||
|
Logger.log("ClipboardPlugin", "Initialized")
|
||||||
|
// Pre-load clipboard data if service is active
|
||||||
|
if (ClipboardService.active) {
|
||||||
|
ClipboardService.list(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when launcher opens
|
||||||
|
function onOpened() {
|
||||||
|
// Refresh clipboard history when launcher opens
|
||||||
|
if (ClipboardService.active) {
|
||||||
|
isWaitingForData = true
|
||||||
|
ClipboardService.list(100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this plugin handles the command
|
||||||
|
function handleCommand(searchText) {
|
||||||
|
return searchText.startsWith(">clip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return available commands when user types ">"
|
||||||
|
function commands() {
|
||||||
|
return [{
|
||||||
|
"name": ">clip",
|
||||||
|
"description": "Search clipboard history",
|
||||||
|
"icon": "text-x-generic",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {
|
||||||
|
launcher.setSearchText(">clip ")
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
"name": ">clip clear",
|
||||||
|
"description": "Clear all clipboard history",
|
||||||
|
"icon": "text-x-generic",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {
|
||||||
|
ClipboardService.wipeAll()
|
||||||
|
launcher.close()
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get search results
|
||||||
|
function getResults(searchText) {
|
||||||
|
if (!searchText.startsWith(">clip")) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSearchText = searchText
|
||||||
|
const results = []
|
||||||
|
const query = searchText.slice(5).trim()
|
||||||
|
|
||||||
|
// Check if clipboard service is not active
|
||||||
|
if (!ClipboardService.active) {
|
||||||
|
return [{
|
||||||
|
"name": "Clipboard History Disabled",
|
||||||
|
"description": "Enable clipboard history in settings or install cliphist",
|
||||||
|
"icon": "view-refresh",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special command: clear
|
||||||
|
if (query === "clear") {
|
||||||
|
return [{
|
||||||
|
"name": "Clear Clipboard History",
|
||||||
|
"description": "Remove all items from clipboard history",
|
||||||
|
"icon": "delete_sweep",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {
|
||||||
|
ClipboardService.wipeAll()
|
||||||
|
launcher.close()
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state if data is being loaded
|
||||||
|
if (ClipboardService.loading || isWaitingForData) {
|
||||||
|
return [{
|
||||||
|
"name": "Loading clipboard history...",
|
||||||
|
"description": "Please wait",
|
||||||
|
"icon": "view-refresh",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get clipboard items
|
||||||
|
const items = ClipboardService.items || []
|
||||||
|
|
||||||
|
// If no items and we haven't tried loading yet, trigger a load
|
||||||
|
if (items.length === 0 && !ClipboardService.loading) {
|
||||||
|
isWaitingForData = true
|
||||||
|
ClipboardService.list(100)
|
||||||
|
return [{
|
||||||
|
"name": "Loading clipboard history...",
|
||||||
|
"description": "Please wait",
|
||||||
|
"icon": "view-refresh",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search clipboard items
|
||||||
|
const searchTerm = query.toLowerCase()
|
||||||
|
|
||||||
|
// Filter and format results
|
||||||
|
items.forEach(function (item) {
|
||||||
|
const preview = (item.preview || "").toLowerCase()
|
||||||
|
|
||||||
|
// Skip if search term doesn't match
|
||||||
|
if (searchTerm && preview.indexOf(searchTerm) === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format the result based on type
|
||||||
|
let entry
|
||||||
|
if (item.isImage) {
|
||||||
|
entry = formatImageEntry(item)
|
||||||
|
} else {
|
||||||
|
entry = formatTextEntry(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add activation handler
|
||||||
|
entry.onActivate = function () {
|
||||||
|
ClipboardService.copyToClipboard(item.id)
|
||||||
|
launcher.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show empty state if no results
|
||||||
|
if (results.length === 0) {
|
||||||
|
results.push({
|
||||||
|
"name": searchTerm ? "No matching clipboard items" : "Clipboard is empty",
|
||||||
|
"description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here",
|
||||||
|
"icon": "text-x-generic",
|
||||||
|
"isImage": false,
|
||||||
|
"onActivate": function () {// Do nothing
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
//Logger.log("ClipboardPlugin", `Returning ${results.length} results for query: "${query}"`)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format image clipboard entry
|
||||||
|
function formatImageEntry(item) {
|
||||||
|
const meta = parseImageMeta(item.preview)
|
||||||
|
|
||||||
|
// The launcher's delegate will now be responsible for fetching the image data.
|
||||||
|
// This function's role is to provide the necessary metadata for that request.
|
||||||
|
return {
|
||||||
|
"name": meta ? `Image ${meta.w}×${meta.h}` : "Image",
|
||||||
|
"description": meta ? `${meta.fmt} • ${meta.size}` : item.mime || "Image data",
|
||||||
|
"icon": "image",
|
||||||
|
"isImage": true,
|
||||||
|
"imageWidth": meta ? meta.w : 0,
|
||||||
|
"imageHeight": meta ? meta.h : 0,
|
||||||
|
"clipboardId": item.id,
|
||||||
|
"mime": item.mime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Format text clipboard entry with preview
|
||||||
|
function formatTextEntry(item) {
|
||||||
|
const preview = (item.preview || "").trim()
|
||||||
|
const lines = preview.split('\n').filter(l => l.trim())
|
||||||
|
|
||||||
|
// Use first line as title, limit length
|
||||||
|
let title = lines[0] || "Empty text"
|
||||||
|
if (title.length > 60) {
|
||||||
|
title = title.substring(0, 57) + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use second line or character count as description
|
||||||
|
let description = ""
|
||||||
|
if (lines.length > 1) {
|
||||||
|
description = lines[1]
|
||||||
|
if (description.length > 80) {
|
||||||
|
description = description.substring(0, 77) + "..."
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const chars = preview.length
|
||||||
|
const words = preview.split(/\s+/).length
|
||||||
|
description = `${chars} characters, ${words} word${words !== 1 ? 's' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": title,
|
||||||
|
"description": description,
|
||||||
|
"icon": "text-x-generic",
|
||||||
|
"isImage": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Parse image metadata from preview string
|
||||||
|
function parseImageMeta(preview) {
|
||||||
|
const re = /\[\[\s*binary data\s+([\d\.]+\s*(?:KiB|MiB|GiB|B))\s+(\w+)\s+(\d+)x(\d+)\s*\]\]/i
|
||||||
|
const match = (preview || "").match(re)
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"size": match[1],
|
||||||
|
"fmt": (match[2] || "").toUpperCase(),
|
||||||
|
"w": Number(match[3]),
|
||||||
|
"h": Number(match[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to get image data for a clipboard item
|
||||||
|
// This can be called by the launcher when rendering
|
||||||
|
function getImageForItem(clipboardId) {
|
||||||
|
return ClipboardService.getImageData ? ClipboardService.getImageData(clipboardId) : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,225 +4,333 @@ import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import qs.Commons
|
import qs.Commons
|
||||||
import qs.Services
|
|
||||||
|
|
||||||
|
// Thin wrapper around the cliphist CLI
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var history: []
|
// Public API
|
||||||
property bool initialized: false
|
property bool active: Settings.isLoaded && Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable
|
||||||
property int maxHistory: 50 // Limit clipboard history entries
|
property bool loading: false
|
||||||
|
property var items: [] // [{id, preview, mime, isImage}]
|
||||||
|
|
||||||
// Internal state
|
// Check if cliphist is available on the system
|
||||||
property bool _enabled: true
|
property bool cliphistAvailable: false
|
||||||
|
property bool dependencyChecked: false
|
||||||
|
|
||||||
// Cached history file path
|
// Optional automatic watchers to feed cliphist DB
|
||||||
property string historyFile: Quickshell.env("NOCTALIA_CLIPBOARD_HISTORY_FILE")
|
property bool autoWatch: true
|
||||||
|| (Settings.cacheDir + "clipboard.json")
|
property bool watchersStarted: false
|
||||||
|
|
||||||
// Persisted storage for clipboard history
|
// Expose decoded thumbnails by id and a revision to notify bindings
|
||||||
property FileView historyFileView: FileView {
|
property var imageDataById: ({})
|
||||||
id: historyFileView
|
property int revision: 0
|
||||||
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 {
|
// Approximate first-seen timestamps for entries this session (seconds)
|
||||||
id: historyAdapter
|
property var firstSeenById: ({})
|
||||||
property var history: []
|
|
||||||
property double timestamp: 0
|
// Internal: store callback for decode
|
||||||
}
|
property var _decodeCallback: null
|
||||||
|
|
||||||
|
// Queue for base64 decodes
|
||||||
|
property var _b64Queue: []
|
||||||
|
property var _b64CurrentCb: null
|
||||||
|
property string _b64CurrentMime: ""
|
||||||
|
property string _b64CurrentId: ""
|
||||||
|
|
||||||
|
signal listCompleted
|
||||||
|
|
||||||
|
// Check if cliphist is available
|
||||||
|
Component.onCompleted: {
|
||||||
|
checkCliphistAvailability()
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
// Check dependency availability
|
||||||
interval: 2000
|
function checkCliphistAvailability() {
|
||||||
repeat: true
|
if (dependencyChecked)
|
||||||
running: root._enabled
|
return
|
||||||
onTriggered: root.refresh()
|
|
||||||
|
dependencyCheckProcess.command = ["which", "cliphist"]
|
||||||
|
dependencyCheckProcess.running = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect current clipboard types (text/image)
|
// Process to check if cliphist is available
|
||||||
Process {
|
Process {
|
||||||
id: typeProcess
|
id: dependencyCheckProcess
|
||||||
property bool isLoading: false
|
stdout: StdioCollector {}
|
||||||
property var currentTypes: []
|
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.dependencyChecked = true
|
||||||
if (exitCode === 0) {
|
if (exitCode === 0) {
|
||||||
currentTypes = String(stdout.text).trim().split('\n').filter(t => t)
|
root.cliphistAvailable = true
|
||||||
|
// Start watchers if feature is enabled
|
||||||
// Always check for text first
|
if (root.active) {
|
||||||
textProcess.command = ["wl-paste", "-n", "--type", "text/plain"]
|
startWatchers()
|
||||||
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 {
|
} else {
|
||||||
typeProcess.isLoading = false
|
root.cliphistAvailable = false
|
||||||
}
|
// Show toast notification if feature is enabled but cliphist is missing
|
||||||
}
|
if (Settings.data.appLauncher.enableClipboardHistory) {
|
||||||
|
ToastService.showWarning(
|
||||||
stdout: StdioCollector {}
|
"Clipboard History Unavailable",
|
||||||
}
|
"The 'cliphist' application is not installed. Please install it to use clipboard history features.",
|
||||||
|
false, 6000)
|
||||||
// Read image data
|
|
||||||
Process {
|
|
||||||
id: imageProcess
|
|
||||||
property string mimeType: ""
|
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
if (exitCode === 0) {
|
|
||||||
const base64 = stdout.text.trim()
|
|
||||||
if (base64) {
|
|
||||||
const entry = {
|
|
||||||
"type": 'image',
|
|
||||||
"mimeType": mimeType,
|
|
||||||
"data": `data:${mimeType};base64,${base64}`,
|
|
||||||
"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) {
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read text data
|
// Start/stop watchers when enabled changes
|
||||||
|
onActiveChanged: {
|
||||||
|
if (root.active) {
|
||||||
|
startWatchers()
|
||||||
|
} else {
|
||||||
|
stopWatchers()
|
||||||
|
loading = false
|
||||||
|
// Optional: clear items to avoid stale UI
|
||||||
|
items = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: periodically refresh list so UI updates even if not in clip mode
|
||||||
|
Timer {
|
||||||
|
interval: 5000
|
||||||
|
repeat: true
|
||||||
|
running: root.active
|
||||||
|
onTriggered: list()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal process objects
|
||||||
Process {
|
Process {
|
||||||
id: textProcess
|
id: listProc
|
||||||
property bool isLoading: false
|
stdout: StdioCollector {}
|
||||||
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
onExited: (exitCode, exitStatus) => {
|
||||||
textProcess.isLoading = false
|
const out = String(stdout.text)
|
||||||
|
const lines = out.split('\n').filter(l => l.length > 0)
|
||||||
|
// cliphist list default format: "<id> <preview>" or "<id>\t<preview>"
|
||||||
|
const parsed = lines.map(l => {
|
||||||
|
let id = ""
|
||||||
|
let preview = ""
|
||||||
|
const m = l.match(/^(\d+)\s+(.+)$/)
|
||||||
|
if (m) {
|
||||||
|
id = m[1]
|
||||||
|
preview = m[2]
|
||||||
|
} else {
|
||||||
|
const tab = l.indexOf('\t')
|
||||||
|
id = tab > -1 ? l.slice(0, tab) : l
|
||||||
|
preview = tab > -1 ? l.slice(tab + 1) : ""
|
||||||
|
}
|
||||||
|
const lower = preview.toLowerCase()
|
||||||
|
const isImage = lower.startsWith("[image]") || lower.includes(" binary data ")
|
||||||
|
// Best-effort mime guess from preview
|
||||||
|
var mime = "text/plain"
|
||||||
|
if (isImage) {
|
||||||
|
if (lower.includes(" png"))
|
||||||
|
mime = "image/png"
|
||||||
|
else if (lower.includes(" jpg") || lower.includes(" jpeg"))
|
||||||
|
mime = "image/jpeg"
|
||||||
|
else if (lower.includes(" webp"))
|
||||||
|
mime = "image/webp"
|
||||||
|
else if (lower.includes(" gif"))
|
||||||
|
mime = "image/gif"
|
||||||
|
else
|
||||||
|
mime = "image/*"
|
||||||
|
}
|
||||||
|
// Record first seen time for new ids (approximate copy time)
|
||||||
|
if (!root.firstSeenById[id]) {
|
||||||
|
root.firstSeenById[id] = Time.timestamp
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"preview": preview,
|
||||||
|
"isImage": isImage,
|
||||||
|
"mime": mime
|
||||||
|
}
|
||||||
|
})
|
||||||
|
items = parsed
|
||||||
|
loading = false
|
||||||
|
|
||||||
if (exitCode === 0) {
|
// Emit the signal for subscribers
|
||||||
const content = String(stdout.text).trim()
|
root.listCompleted()
|
||||||
if (content && content.length > 0) {
|
}
|
||||||
const entry = {
|
}
|
||||||
"type": 'text',
|
|
||||||
"content": content,
|
|
||||||
"timestamp": new Date().getTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this exact text content already exists
|
Process {
|
||||||
const exists = root.history.find(item => {
|
id: decodeProc
|
||||||
if (item.type === 'text') {
|
stdout: StdioCollector {}
|
||||||
return item.content === content
|
onExited: (exitCode, exitStatus) => {
|
||||||
}
|
const out = String(stdout.text)
|
||||||
return item === content
|
if (root._decodeCallback) {
|
||||||
})
|
try {
|
||||||
|
root._decodeCallback(out)
|
||||||
if (!exists) {
|
} finally {
|
||||||
// Normalize existing history entries
|
root._decodeCallback = null
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as initialized and clean up loading states
|
|
||||||
root.initialized = true
|
|
||||||
if (!imageProcess.running) {
|
|
||||||
typeProcess.isLoading = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: copyProc
|
||||||
stdout: StdioCollector {}
|
stdout: StdioCollector {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
// Base64 decode pipeline (queued)
|
||||||
if (!typeProcess.isLoading && !textProcess.isLoading && !imageProcess.running) {
|
Process {
|
||||||
typeProcess.isLoading = true
|
id: decodeB64Proc
|
||||||
typeProcess.command = ["wl-paste", "-l"]
|
stdout: StdioCollector {}
|
||||||
typeProcess.running = true
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
const b64 = String(stdout.text).trim()
|
||||||
|
if (root._b64CurrentCb) {
|
||||||
|
const url = `data:${root._b64CurrentMime};base64,${b64}`
|
||||||
|
try {
|
||||||
|
root._b64CurrentCb(url)
|
||||||
|
} catch (e) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (root._b64CurrentId !== "") {
|
||||||
|
root.imageDataById[root._b64CurrentId] = `data:${root._b64CurrentMime};base64,${b64}`
|
||||||
|
root.revision += 1
|
||||||
|
}
|
||||||
|
root._b64CurrentCb = null
|
||||||
|
root._b64CurrentMime = ""
|
||||||
|
root._b64CurrentId = ""
|
||||||
|
Qt.callLater(root._startNextB64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromHistory() {
|
// Long-running watchers to store new clipboard contents
|
||||||
// Populate in-memory history from cached file
|
Process {
|
||||||
try {
|
id: watchText
|
||||||
const items = historyAdapter.history || []
|
stdout: StdioCollector {}
|
||||||
root.history = items.slice(0, maxHistory) // Apply limit when loading
|
onExited: (exitCode, exitStatus) => {
|
||||||
Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache")
|
// Auto-restart if watcher dies
|
||||||
} catch (e) {
|
if (root.autoWatch)
|
||||||
Logger.error("Clipboard", "Failed to load history:", e)
|
Qt.callLater(() => {
|
||||||
root.history = []
|
running = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Process {
|
||||||
|
id: watchImage
|
||||||
|
stdout: StdioCollector {}
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
if (root.autoWatch)
|
||||||
|
Qt.callLater(() => {
|
||||||
|
running = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveHistory() {
|
function startWatchers() {
|
||||||
try {
|
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
|
||||||
// Ensure we don't exceed the maximum history limit
|
return
|
||||||
const limitedHistory = root.history.slice(0, maxHistory)
|
watchersStarted = true
|
||||||
|
// Start text watcher
|
||||||
|
watchText.command = ["wl-paste", "--type", "text", "--watch", "cliphist", "store"]
|
||||||
|
watchText.running = true
|
||||||
|
// Start image watcher
|
||||||
|
watchImage.command = ["wl-paste", "--type", "image", "--watch", "cliphist", "store"]
|
||||||
|
watchImage.running = true
|
||||||
|
}
|
||||||
|
|
||||||
historyAdapter.history = limitedHistory
|
function stopWatchers() {
|
||||||
historyAdapter.timestamp = Time.timestamp
|
if (!watchersStarted)
|
||||||
|
return
|
||||||
|
watchText.running = false
|
||||||
|
watchImage.running = false
|
||||||
|
watchersStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure cache directory exists
|
function list(maxPreviewWidth) {
|
||||||
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir])
|
if (!root.active || !root.cliphistAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (listProc.running)
|
||||||
|
return
|
||||||
|
loading = true
|
||||||
|
const width = maxPreviewWidth || 100
|
||||||
|
listProc.command = ["cliphist", "list", "-preview-width", String(width)]
|
||||||
|
listProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
Qt.callLater(function () {
|
function decode(id, cb) {
|
||||||
historyFileView.writeAdapter()
|
if (!root.cliphistAvailable) {
|
||||||
})
|
if (cb)
|
||||||
} catch (e) {
|
cb("")
|
||||||
Logger.error("Clipboard", "Failed to save history:", e)
|
return
|
||||||
|
}
|
||||||
|
root._decodeCallback = cb
|
||||||
|
decodeProc.command = ["cliphist", "decode", id]
|
||||||
|
decodeProc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeToDataUrl(id, mime, cb) {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
if (cb)
|
||||||
|
cb("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If cached, return immediately
|
||||||
|
if (root.imageDataById[id]) {
|
||||||
|
if (cb)
|
||||||
|
cb(root.imageDataById[id])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Queue request; ensures single process handles sequentially
|
||||||
|
root._b64Queue.push({
|
||||||
|
"id": id,
|
||||||
|
"mime": mime || "image/*",
|
||||||
|
"cb": cb
|
||||||
|
})
|
||||||
|
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
|
||||||
|
_startNextB64()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearHistory() {
|
function getImageData(id) {
|
||||||
root.history = []
|
if (id === undefined) {
|
||||||
saveHistory()
|
return null
|
||||||
|
}
|
||||||
|
return root.imageDataById[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startNextB64() {
|
||||||
|
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
|
||||||
|
return
|
||||||
|
const job = root._b64Queue.shift()
|
||||||
|
root._b64CurrentCb = job.cb
|
||||||
|
root._b64CurrentMime = job.mime
|
||||||
|
root._b64CurrentId = job.id
|
||||||
|
decodeB64Proc.command = ["sh", "-lc", `cliphist decode ${job.id} | base64 -w 0`]
|
||||||
|
decodeB64Proc.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(id) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteById(id) {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Quickshell.execDetached(["cliphist", "delete", id])
|
||||||
|
revision++
|
||||||
|
Qt.callLater(() => list())
|
||||||
|
}
|
||||||
|
|
||||||
|
function wipeAll() {
|
||||||
|
if (!root.cliphistAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Quickshell.execDetached(["cliphist", "wipe"])
|
||||||
|
revision++
|
||||||
|
Qt.callLater(() => list())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,322 +0,0 @@
|
||||||
pragma Singleton
|
|
||||||
|
|
||||||
import QtQuick
|
|
||||||
import Quickshell
|
|
||||||
import Quickshell.Io
|
|
||||||
import qs.Commons
|
|
||||||
|
|
||||||
// Thin wrapper around the cliphist CLI
|
|
||||||
Singleton {
|
|
||||||
id: root
|
|
||||||
|
|
||||||
// Public API
|
|
||||||
property var items: [] // [{id, preview, mime, isImage}]
|
|
||||||
property bool loading: false
|
|
||||||
// Active only when feature is enabled, settings have finished initial load, and cliphist is available
|
|
||||||
property bool active: Settings.data.appLauncher.enableClipboardHistory && Settings.isLoaded && cliphistAvailable
|
|
||||||
|
|
||||||
// Check if cliphist is available on the system
|
|
||||||
property bool cliphistAvailable: false
|
|
||||||
property bool dependencyChecked: false
|
|
||||||
|
|
||||||
// Optional automatic watchers to feed cliphist DB
|
|
||||||
property bool autoWatch: true
|
|
||||||
property bool watchersStarted: false
|
|
||||||
|
|
||||||
// Expose decoded thumbnails by id and a revision to notify bindings
|
|
||||||
property var imageDataById: ({})
|
|
||||||
property int revision: 0
|
|
||||||
|
|
||||||
// Approximate first-seen timestamps for entries this session (seconds)
|
|
||||||
property var firstSeenById: ({})
|
|
||||||
|
|
||||||
// Internal: store callback for decode
|
|
||||||
property var _decodeCallback: null
|
|
||||||
|
|
||||||
// Queue for base64 decodes
|
|
||||||
property var _b64Queue: []
|
|
||||||
property var _b64CurrentCb: null
|
|
||||||
property string _b64CurrentMime: ""
|
|
||||||
property string _b64CurrentId: ""
|
|
||||||
|
|
||||||
// Check if cliphist is available
|
|
||||||
Component.onCompleted: {
|
|
||||||
checkCliphistAvailability()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check dependency availability
|
|
||||||
function checkCliphistAvailability() {
|
|
||||||
if (dependencyChecked)
|
|
||||||
return
|
|
||||||
|
|
||||||
dependencyCheckProcess.command = ["which", "cliphist"]
|
|
||||||
dependencyCheckProcess.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process to check if cliphist is available
|
|
||||||
Process {
|
|
||||||
id: dependencyCheckProcess
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
root.dependencyChecked = true
|
|
||||||
if (exitCode === 0) {
|
|
||||||
root.cliphistAvailable = true
|
|
||||||
// Start watchers if feature is enabled
|
|
||||||
if (root.active) {
|
|
||||||
startWatchers()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
root.cliphistAvailable = false
|
|
||||||
// Show toast notification if feature is enabled but cliphist is missing
|
|
||||||
if (Settings.data.appLauncher.enableClipboardHistory) {
|
|
||||||
ToastService.showWarning(
|
|
||||||
"Clipboard History Unavailable",
|
|
||||||
"The 'cliphist' application is not installed. Please install it to use clipboard history features.",
|
|
||||||
false, 6000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start/stop watchers when enabled changes
|
|
||||||
onActiveChanged: {
|
|
||||||
if (root.active && root.cliphistAvailable) {
|
|
||||||
startWatchers()
|
|
||||||
} else {
|
|
||||||
stopWatchers()
|
|
||||||
loading = false
|
|
||||||
// Optional: clear items to avoid stale UI
|
|
||||||
items = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: periodically refresh list so UI updates even if not in clip mode
|
|
||||||
Timer {
|
|
||||||
interval: 5000
|
|
||||||
repeat: true
|
|
||||||
running: root.active && root.cliphistAvailable
|
|
||||||
onTriggered: list()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal process objects
|
|
||||||
Process {
|
|
||||||
id: listProc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
const out = String(stdout.text)
|
|
||||||
const lines = out.split('\n').filter(l => l.length > 0)
|
|
||||||
// cliphist list default format: "<id> <preview>" or "<id>\t<preview>"
|
|
||||||
const parsed = lines.map(l => {
|
|
||||||
let id = ""
|
|
||||||
let preview = ""
|
|
||||||
const m = l.match(/^(\d+)\s+(.+)$/)
|
|
||||||
if (m) {
|
|
||||||
id = m[1]
|
|
||||||
preview = m[2]
|
|
||||||
} else {
|
|
||||||
const tab = l.indexOf('\t')
|
|
||||||
id = tab > -1 ? l.slice(0, tab) : l
|
|
||||||
preview = tab > -1 ? l.slice(tab + 1) : ""
|
|
||||||
}
|
|
||||||
const lower = preview.toLowerCase()
|
|
||||||
const isImage = lower.startsWith("[image]") || lower.includes(" binary data ")
|
|
||||||
// Best-effort mime guess from preview
|
|
||||||
var mime = "text/plain"
|
|
||||||
if (isImage) {
|
|
||||||
if (lower.includes(" png"))
|
|
||||||
mime = "image/png"
|
|
||||||
else if (lower.includes(" jpg") || lower.includes(" jpeg"))
|
|
||||||
mime = "image/jpeg"
|
|
||||||
else if (lower.includes(" webp"))
|
|
||||||
mime = "image/webp"
|
|
||||||
else if (lower.includes(" gif"))
|
|
||||||
mime = "image/gif"
|
|
||||||
else
|
|
||||||
mime = "image/*"
|
|
||||||
}
|
|
||||||
// Record first seen time for new ids (approximate copy time)
|
|
||||||
if (!root.firstSeenById[id]) {
|
|
||||||
root.firstSeenById[id] = Time.timestamp
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"id": id,
|
|
||||||
"preview": preview,
|
|
||||||
"isImage": isImage,
|
|
||||||
"mime": mime
|
|
||||||
}
|
|
||||||
})
|
|
||||||
items = parsed
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: decodeProc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
const out = String(stdout.text)
|
|
||||||
if (root._decodeCallback) {
|
|
||||||
try {
|
|
||||||
root._decodeCallback(out)
|
|
||||||
} finally {
|
|
||||||
root._decodeCallback = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
|
||||||
id: copyProc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base64 decode pipeline (queued)
|
|
||||||
Process {
|
|
||||||
id: decodeB64Proc
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
const b64 = String(stdout.text).trim()
|
|
||||||
if (root._b64CurrentCb) {
|
|
||||||
const url = `data:${root._b64CurrentMime};base64,${b64}`
|
|
||||||
try {
|
|
||||||
root._b64CurrentCb(url)
|
|
||||||
} finally {
|
|
||||||
|
|
||||||
/* noop */ }
|
|
||||||
}
|
|
||||||
if (root._b64CurrentId !== "") {
|
|
||||||
root.imageDataById[root._b64CurrentId] = `data:${root._b64CurrentMime};base64,${b64}`
|
|
||||||
root.revision += 1
|
|
||||||
}
|
|
||||||
root._b64CurrentCb = null
|
|
||||||
root._b64CurrentMime = ""
|
|
||||||
root._b64CurrentId = ""
|
|
||||||
Qt.callLater(root._startNextB64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Long-running watchers to store new clipboard contents
|
|
||||||
Process {
|
|
||||||
id: watchText
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
// Auto-restart if watcher dies
|
|
||||||
if (root.autoWatch)
|
|
||||||
Qt.callLater(() => {
|
|
||||||
running = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Process {
|
|
||||||
id: watchImage
|
|
||||||
stdout: StdioCollector {}
|
|
||||||
onExited: (exitCode, exitStatus) => {
|
|
||||||
if (root.autoWatch)
|
|
||||||
Qt.callLater(() => {
|
|
||||||
running = true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startWatchers() {
|
|
||||||
if (!root.active || !autoWatch || watchersStarted || !root.cliphistAvailable)
|
|
||||||
return
|
|
||||||
watchersStarted = true
|
|
||||||
// Start text watcher
|
|
||||||
watchText.command = ["wl-paste", "--type", "text", "--watch", "cliphist", "store"]
|
|
||||||
watchText.running = true
|
|
||||||
// Start image watcher
|
|
||||||
watchImage.command = ["wl-paste", "--type", "image", "--watch", "cliphist", "store"]
|
|
||||||
watchImage.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopWatchers() {
|
|
||||||
if (!watchersStarted)
|
|
||||||
return
|
|
||||||
watchText.running = false
|
|
||||||
watchImage.running = false
|
|
||||||
watchersStarted = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function list(maxPreviewWidth) {
|
|
||||||
if (!root.active || !root.cliphistAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (listProc.running)
|
|
||||||
return
|
|
||||||
loading = true
|
|
||||||
const width = maxPreviewWidth || 100
|
|
||||||
listProc.command = ["cliphist", "list", "-preview-width", String(width)]
|
|
||||||
listProc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function decode(id, cb) {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
if (cb)
|
|
||||||
cb("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
root._decodeCallback = cb
|
|
||||||
decodeProc.command = ["cliphist", "decode", id]
|
|
||||||
decodeProc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeToDataUrl(id, mime, cb) {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
if (cb)
|
|
||||||
cb("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// If cached, return immediately
|
|
||||||
if (root.imageDataById[id]) {
|
|
||||||
if (cb)
|
|
||||||
cb(root.imageDataById[id])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Queue request; ensures single process handles sequentially
|
|
||||||
root._b64Queue.push({
|
|
||||||
"id": id,
|
|
||||||
"mime": mime || "image/*",
|
|
||||||
"cb": cb
|
|
||||||
})
|
|
||||||
if (!decodeB64Proc.running && root._b64CurrentCb === null) {
|
|
||||||
_startNextB64()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _startNextB64() {
|
|
||||||
if (root._b64Queue.length === 0 || !root.cliphistAvailable)
|
|
||||||
return
|
|
||||||
const job = root._b64Queue.shift()
|
|
||||||
root._b64CurrentCb = job.cb
|
|
||||||
root._b64CurrentMime = job.mime
|
|
||||||
root._b64CurrentId = job.id
|
|
||||||
decodeB64Proc.command = ["sh", "-lc", `cliphist decode ${job.id} | base64 -w 0`]
|
|
||||||
decodeB64Proc.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyToClipboard(id) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteById(id) {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Quickshell.execDetached(["cliphist", "delete", id])
|
|
||||||
Qt.callLater(() => list())
|
|
||||||
}
|
|
||||||
|
|
||||||
function wipeAll() {
|
|
||||||
if (!root.cliphistAvailable) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Quickshell.execDetached(["cliphist", "wipe"])
|
|
||||||
Qt.callLater(() => list())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,7 +7,7 @@ import qs.Commons
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import Quickshell.Services.Notifications
|
import Quickshell.Services.Notifications
|
||||||
|
|
||||||
QtObject {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
// Notification server instance
|
// Notification server instance
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ Rectangle {
|
||||||
|
|
||||||
property real scaledRadius: imageRadius * Settings.data.general.radiusRatio
|
property real scaledRadius: imageRadius * Settings.data.general.radiusRatio
|
||||||
|
|
||||||
|
signal statusChanged(int status)
|
||||||
|
|
||||||
color: Color.transparent
|
color: Color.transparent
|
||||||
radius: scaledRadius
|
radius: scaledRadius
|
||||||
anchors.margins: Style.marginXXS * scaling
|
anchors.margins: Style.marginXXS * scaling
|
||||||
|
|
@ -34,6 +36,8 @@ Rectangle {
|
||||||
asynchronous: true
|
asynchronous: true
|
||||||
antialiasing: true
|
antialiasing: true
|
||||||
fillMode: Image.PreserveAspectCrop
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
|
||||||
|
onStatusChanged: root.statusChanged(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
ShaderEffect {
|
ShaderEffect {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue