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.Controls
|
||||
import QtQuick.Layouts
|
||||
import QtQuick.Effects
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Widgets
|
||||
import qs.Commons
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
import "../../Helpers/FuzzySort.js" as Fuzzysort
|
||||
|
||||
NPanel {
|
||||
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"))
|
||||
panelAnchorVerticalCenter: launcherPosition === "center"
|
||||
panelAnchorLeft: launcherPosition !== "center" && (launcherPosition.endsWith("_left"))
|
||||
panelAnchorRight: launcherPosition !== "center" && (launcherPosition.endsWith("_right"))
|
||||
panelAnchorBottom: launcherPosition.startsWith("bottom_")
|
||||
panelAnchorTop: launcherPosition.startsWith("top_")
|
||||
// Panel configuration
|
||||
panelWidth: {
|
||||
var w = Math.round(Math.max(screen?.width * 0.3, 500) * scaling)
|
||||
w = Math.min(w, screen?.width - Style.marginL * 2)
|
||||
return w
|
||||
}
|
||||
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
|
||||
|
||||
// Background opacity following bar's approach
|
||||
panelBackgroundColor: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b,
|
||||
Settings.data.appLauncher.backgroundOpacity)
|
||||
|
||||
// Properties
|
||||
property string searchText: ""
|
||||
property bool shouldResetCursor: false
|
||||
// Positioning
|
||||
readonly property string launcherPosition: Settings.data.appLauncher.position
|
||||
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) {
|
||||
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: {
|
||||
// Reset state when panel opens to avoid sticky modes
|
||||
if (searchText === "") {
|
||||
searchText = ""
|
||||
// Plugin registration
|
||||
function registerPlugin(plugin) {
|
||||
plugins.push(plugin)
|
||||
plugin.launcher = root
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedIndex = 0
|
||||
}
|
||||
// Focus search input on open and place cursor at end
|
||||
Qt.callLater(() => {
|
||||
if (searchInputBox && searchInputBox.inputItem) {
|
||||
searchInputBox.inputItem.forceActiveFocus()
|
||||
if (searchText && searchText.length > 0) {
|
||||
searchInputBox.inputItem.cursorPosition = searchText.length
|
||||
} else {
|
||||
searchInputBox.inputItem.cursorPosition = 0
|
||||
|
||||
onSearchTextChanged: updateResults()
|
||||
|
||||
// Lifecycle
|
||||
onOpened: {
|
||||
// Notify plugins
|
||||
for (let plugin of plugins) {
|
||||
if (plugin.onOpened)
|
||||
plugin.onOpened()
|
||||
}
|
||||
}
|
||||
})
|
||||
updateResults()
|
||||
}
|
||||
|
||||
onClosed: {
|
||||
// Reset search bar when launcher is closed
|
||||
searchText = ""
|
||||
selectedIndex = 0
|
||||
shouldResetCursor = true
|
||||
}
|
||||
|
||||
// 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()
|
||||
// Notify plugins
|
||||
for (let plugin of plugins) {
|
||||
if (plugin.onClosed)
|
||||
plugin.onClosed()
|
||||
}
|
||||
}
|
||||
|
||||
// Main filtering logic
|
||||
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
|
||||
// Navigation
|
||||
function selectNext() {
|
||||
if (filteredEntries.length > 0) {
|
||||
selectedIndex = Math.min(selectedIndex + 1, filteredEntries.length - 1)
|
||||
if (results.length > 0) {
|
||||
// Clamp the index to not exceed the last item
|
||||
selectedIndex = Math.min(selectedIndex + 1, results.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
function selectNextPage() {
|
||||
if (filteredEntries.length > 0) {
|
||||
const delegateHeight = 65 * scaling + (Style.marginXXS * scaling)
|
||||
const page = Math.max(1, Math.floor(appsList.height / delegateHeight))
|
||||
selectedIndex = Math.min(selectedIndex + page, filteredEntries.length - 1)
|
||||
function activate() {
|
||||
if (results.length > 0 && results[selectedIndex]) {
|
||||
const item = results[selectedIndex]
|
||||
if (item.onActivate) {
|
||||
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: {
|
||||
Logger.log("Launcher", "Component completed")
|
||||
Logger.log("Launcher", "DesktopEntries available:", typeof DesktopEntries !== 'undefined')
|
||||
if (typeof DesktopEntries !== 'undefined') {
|
||||
Logger.log("Launcher", "DesktopEntries.entries:",
|
||||
DesktopEntries.entries ? DesktopEntries.entries.length : 'undefined')
|
||||
// Load applications plugin
|
||||
const appsPlugin = Qt.createComponent("Plugins/ApplicationsPlugin.qml").createObject(this)
|
||||
if (appsPlugin) {
|
||||
registerPlugin(appsPlugin)
|
||||
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) {
|
||||
clipboardHistory.refresh()
|
||||
|
||||
// Load calculator plugin
|
||||
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 {
|
||||
color: Color.transparent
|
||||
|
||||
Component.onCompleted: {
|
||||
searchText = ""
|
||||
selectedIndex = 0
|
||||
if (searchInput?.forceActiveFocus) {
|
||||
searchInput.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginL * scaling
|
||||
spacing: Style.marginM * scaling
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
|
||||
Layout.bottomMargin: Style.marginM * scaling
|
||||
|
||||
// Wrapper ensures the input stretches to full width under RowLayout
|
||||
Item {
|
||||
FocusScope {
|
||||
id: searchInputWrap
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.round(Style.barHeight * scaling)
|
||||
|
||||
// This FocusScope should get focus when panel opens
|
||||
focus: true
|
||||
|
||||
NTextInput {
|
||||
id: searchInputBox
|
||||
id: searchInput
|
||||
anchors.fill: parent
|
||||
placeholderText: "Search applications... (use > to view commands)"
|
||||
|
||||
// The input should have focus within the scope
|
||||
focus: true
|
||||
|
||||
placeholderText: "Search entries... or use > for commands"
|
||||
text: searchText
|
||||
inputMaxWidth: 100000
|
||||
// Tune vertical centering on inner input
|
||||
inputMaxWidth: Number.MAX_SAFE_INTEGER
|
||||
|
||||
function forceActiveFocus() {
|
||||
// First ensure the scope has focus
|
||||
searchInputWrap.forceActiveFocus()
|
||||
// Then focus the actual input
|
||||
if (inputItem && inputItem.visible) {
|
||||
inputItem.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
searchInputBox.inputItem.font.pointSize = Style.fontSizeL * scaling
|
||||
searchInputBox.inputItem.verticalAlignment = TextInput.AlignVCenter
|
||||
// Ensure focus when launcher first appears
|
||||
Qt.callLater(() => {
|
||||
searchInputBox.inputItem.forceActiveFocus()
|
||||
if (searchText && searchText.length > 0) {
|
||||
searchInputBox.inputItem.cursorPosition = searchText.length
|
||||
} else {
|
||||
searchInputBox.inputItem.cursorPosition = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
onTextChanged: {
|
||||
if (searchText !== text) {
|
||||
searchText = text
|
||||
}
|
||||
Qt.callLater(() => selectedIndex = 0)
|
||||
if (shouldResetCursor && text === "") {
|
||||
searchInputBox.inputItem.cursorPosition = 0
|
||||
shouldResetCursor = false
|
||||
if (inputItem) {
|
||||
inputItem.font.pointSize = Style.fontSizeL * scaling
|
||||
inputItem.verticalAlignment = TextInput.AlignVCenter
|
||||
}
|
||||
}
|
||||
// Forward key navigation to behave like before
|
||||
Keys.onDownPressed: selectNext()
|
||||
Keys.onUpPressed: selectPrev()
|
||||
Keys.onEnterPressed: activateSelected()
|
||||
Keys.onReturnPressed: activateSelected()
|
||||
|
||||
onTextChanged: searchText = text
|
||||
|
||||
Keys.onDownPressed: root.selectNext()
|
||||
Keys.onUpPressed: root.selectPrev()
|
||||
Keys.onReturnPressed: root.activate()
|
||||
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
|
||||
NIconButton {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
visible: searchText.startsWith(">clip")
|
||||
icon: "delete_sweep"
|
||||
tooltipText: "Clear clipboard history"
|
||||
onClicked: CliphistService.wipeAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Applications list
|
||||
// Results list
|
||||
ListView {
|
||||
id: appsList
|
||||
id: resultsList
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
clip: true
|
||||
spacing: Style.marginXXS * scaling
|
||||
model: filteredEntries
|
||||
|
||||
model: results
|
||||
currentIndex: selectedIndex
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
maximumFlickVelocity: 2500
|
||||
flickDeceleration: 2000
|
||||
|
||||
clip: true
|
||||
cacheBuffer: resultsList.height * 2
|
||||
onCurrentIndexChanged: {
|
||||
cancelFlick()
|
||||
if (currentIndex >= 0) {
|
||||
positionViewAtIndex(currentIndex, ListView.Contain)
|
||||
}
|
||||
}
|
||||
|
||||
ScrollBar.vertical: ScrollBar {
|
||||
policy: ScrollBar.AsNeeded
|
||||
}
|
||||
|
||||
// Keep viewport anchored to the selected item when the clipboard model refreshes
|
||||
Connections {
|
||||
target: CliphistService
|
||||
function onRevisionChanged() {
|
||||
if (Settings.data.appLauncher.enableClipboardHistory && searchText.startsWith(">clip")) {
|
||||
// Clamp selection in case the list shrank
|
||||
if (selectedIndex >= filteredEntries.length) {
|
||||
selectedIndex = Math.max(0, filteredEntries.length - 1)
|
||||
}
|
||||
Qt.callLater(() => {
|
||||
appsList.positionViewAtIndex(selectedIndex, ListView.Contain)
|
||||
})
|
||||
}
|
||||
delegate: Rectangle {
|
||||
id: entry
|
||||
|
||||
property bool isSelected: mouseArea.containsMouse || (index === selectedIndex)
|
||||
property int badgeSize: Style.baseWidgetSize * 1.6 * scaling
|
||||
|
||||
// Property to reliably track the current item's ID.
|
||||
// This changes whenever the delegate is recycled for a new item.
|
||||
property var currentClipboardId: modelData.isImage ? modelData.clipboardId : ""
|
||||
|
||||
// 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: appsList.width - Style.marginS * scaling
|
||||
height: 65 * scaling
|
||||
width: resultsList.width - Style.marginS * scaling
|
||||
height: badgeSize + Style.marginM * 2 * scaling
|
||||
radius: Style.radiusM * scaling
|
||||
property bool isSelected: index === selectedIndex
|
||||
color: (appCardArea.containsMouse || isSelected) ? Color.mSecondary : Color.mSurface
|
||||
color: entry.isSelected ? Color.mTertiary : Color.mSurface
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
|
||||
Behavior on border.width {
|
||||
NumberAnimation {
|
||||
duration: Style.animationFast
|
||||
easing.type: Easing.OutCirc
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -418,97 +287,129 @@ NPanel {
|
|||
anchors.margins: Style.marginM * scaling
|
||||
spacing: Style.marginM * scaling
|
||||
|
||||
// App/clipboard icon with background
|
||||
// Icon badge or Image preview
|
||||
Rectangle {
|
||||
Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling
|
||||
Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling
|
||||
radius: Style.radiusS * scaling
|
||||
color: appCardArea.containsMouse ? Qt.darker(Color.mPrimary, 1.1) : Color.mSurfaceVariant
|
||||
property bool iconLoaded: (modelData.isCalculator || modelData.isClipboard || modelData.isCommand)
|
||||
|| (iconImg.status === Image.Ready && iconImg.source !== ""
|
||||
&& iconImg.status !== Image.Error && iconImg.source !== "")
|
||||
visible: !searchText.startsWith(">calc")
|
||||
Layout.preferredWidth: badgeSize
|
||||
Layout.preferredHeight: badgeSize
|
||||
radius: Style.radiusM * scaling
|
||||
color: Color.mSurfaceVariant
|
||||
clip: true
|
||||
|
||||
// Decode image thumbnails on demand
|
||||
Component.onCompleted: {
|
||||
if (modelData && modelData.type === 'image' && !CliphistService.imageDataById[modelData.id]) {
|
||||
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
|
||||
|
||||
// Image preview for clipboard images
|
||||
NImageRounded {
|
||||
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: {
|
||||
if (visible && modelData && modelData.type === 'image'
|
||||
&& !CliphistService.imageDataById[modelData.id]) {
|
||||
CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {})
|
||||
|
||||
// Loading indicator
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: parent.status === Image.Loading
|
||||
color: Color.mSurfaceVariant
|
||||
|
||||
BusyIndicator {
|
||||
anchors.centerIn: parent
|
||||
running: true
|
||||
width: Style.baseWidgetSize * 0.5 * scaling
|
||||
height: width
|
||||
}
|
||||
}
|
||||
|
||||
// Clipboard image display (pull from cache)
|
||||
Image {
|
||||
id: clipboardImage
|
||||
// Error fallback
|
||||
onStatusChanged: status => {
|
||||
if (status === Image.Error) {
|
||||
iconLoader.visible = true
|
||||
imagePreview.visible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Icon fallback
|
||||
Loader {
|
||||
id: iconLoader
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXS * scaling
|
||||
visible: modelData.type === 'image'
|
||||
source: modelData.type === 'image' ? (CliphistService.imageDataById[modelData.id] || "") : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: true
|
||||
}
|
||||
|
||||
visible: !modelData.isImage || imagePreview.status === Image.Error
|
||||
active: visible
|
||||
|
||||
sourceComponent: Component {
|
||||
IconImage {
|
||||
id: iconImg
|
||||
anchors.fill: parent
|
||||
anchors.margins: Style.marginXS * scaling
|
||||
source: modelData.icon ? Icons.iconFromName(modelData.icon, "application-x-executable") : ""
|
||||
visible: modelData.icon && source !== ""
|
||||
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 {
|
||||
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() : "?"
|
||||
font.pointSize: Style.fontSizeXXL * scaling
|
||||
font.weight: Style.fontWeightBold
|
||||
color: Color.mOnPrimary
|
||||
}
|
||||
|
||||
// Image type indicator overlay
|
||||
Rectangle {
|
||||
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
|
||||
}
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation {
|
||||
duration: Style.animationFast
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// App info
|
||||
// Text content
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Style.marginXXS * scaling
|
||||
spacing: 0 * scaling
|
||||
|
||||
NText {
|
||||
text: modelData.name || "Unknown"
|
||||
font.pointSize: Style.fontSizeL * scaling
|
||||
font.weight: Style.fontWeightBold
|
||||
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
|
||||
color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurface
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
NText {
|
||||
text: modelData.isCalculator ? (modelData.expr + " = " + modelData.result) : modelData.isClipboard ? modelData.content : modelData.isCommand ? modelData.content : (modelData.genericName || modelData.comment || "")
|
||||
font.pointSize: Style.fontSizeM * scaling
|
||||
color: (appCardArea.containsMouse || isSelected) ? Color.mOnPrimary : Color.mOnSurface
|
||||
text: modelData.description || ""
|
||||
font.pointSize: Style.fontSizeS * scaling
|
||||
color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant
|
||||
elide: Text.ElideRight
|
||||
Layout.fillWidth: true
|
||||
visible: text !== ""
|
||||
|
|
@ -517,41 +418,34 @@ NPanel {
|
|||
}
|
||||
|
||||
MouseArea {
|
||||
id: appCardArea
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: {
|
||||
selectedIndex = index
|
||||
activateSelected()
|
||||
root.activate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No results message
|
||||
NText {
|
||||
text: searchText.trim() !== "" ? "No applications found" : "No applications available"
|
||||
font.pointSize: Style.fontSizeL * scaling
|
||||
color: Color.mOnSurface
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
NDivider {
|
||||
Layout.fillWidth: true
|
||||
visible: filteredEntries.length === 0
|
||||
}
|
||||
|
||||
// Results count
|
||||
// Status
|
||||
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' : ''}`
|
||||
Layout.fillWidth: true
|
||||
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.mOnSurface
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
Layout.fillWidth: true
|
||||
visible: searchText.trim() !== ""
|
||||
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.Io
|
||||
import qs.Commons
|
||||
import qs.Services
|
||||
|
||||
// Thin wrapper around the cliphist CLI
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var history: []
|
||||
property bool initialized: false
|
||||
property int maxHistory: 50 // Limit clipboard history entries
|
||||
// Public API
|
||||
property bool active: Settings.isLoaded && Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable
|
||||
property bool loading: false
|
||||
property var items: [] // [{id, preview, mime, isImage}]
|
||||
|
||||
// Internal state
|
||||
property bool _enabled: true
|
||||
// Check if cliphist is available on the system
|
||||
property bool cliphistAvailable: false
|
||||
property bool dependencyChecked: false
|
||||
|
||||
// Cached history file path
|
||||
property string historyFile: Quickshell.env("NOCTALIA_CLIPBOARD_HISTORY_FILE")
|
||||
|| (Settings.cacheDir + "clipboard.json")
|
||||
// Optional automatic watchers to feed cliphist DB
|
||||
property bool autoWatch: true
|
||||
property bool watchersStarted: false
|
||||
|
||||
// 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()
|
||||
}
|
||||
// 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: ""
|
||||
|
||||
signal listCompleted
|
||||
|
||||
// Check if cliphist is available
|
||||
Component.onCompleted: {
|
||||
checkCliphistAvailability()
|
||||
}
|
||||
|
||||
JsonAdapter {
|
||||
id: historyAdapter
|
||||
property var history: []
|
||||
property double timestamp: 0
|
||||
}
|
||||
// Check dependency availability
|
||||
function checkCliphistAvailability() {
|
||||
if (dependencyChecked)
|
||||
return
|
||||
|
||||
dependencyCheckProcess.command = ["which", "cliphist"]
|
||||
dependencyCheckProcess.running = true
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 2000
|
||||
repeat: true
|
||||
running: root._enabled
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
// Detect current clipboard types (text/image)
|
||||
// Process to check if cliphist is available
|
||||
Process {
|
||||
id: typeProcess
|
||||
property bool isLoading: false
|
||||
property var currentTypes: []
|
||||
|
||||
id: dependencyCheckProcess
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.dependencyChecked = true
|
||||
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
|
||||
root.cliphistAvailable = true
|
||||
// Start watchers if feature is enabled
|
||||
if (root.active) {
|
||||
startWatchers()
|
||||
}
|
||||
} 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(
|
||||
"Clipboard History Unavailable",
|
||||
"The 'cliphist' application is not installed. Please install it to use clipboard history features.",
|
||||
false, 6000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stdout: StdioCollector {}
|
||||
// Start/stop watchers when enabled changes
|
||||
onActiveChanged: {
|
||||
if (root.active) {
|
||||
startWatchers()
|
||||
} else {
|
||||
stopWatchers()
|
||||
loading = false
|
||||
// Optional: clear items to avoid stale UI
|
||||
items = []
|
||||
}
|
||||
}
|
||||
|
||||
// Read image data
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// Emit the signal for subscribers
|
||||
root.listCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
// Read text data
|
||||
Process {
|
||||
id: textProcess
|
||||
property bool isLoading: false
|
||||
|
||||
id: decodeProc
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
textProcess.isLoading = false
|
||||
|
||||
if (exitCode === 0) {
|
||||
const content = String(stdout.text).trim()
|
||||
if (content && content.length > 0) {
|
||||
const entry = {
|
||||
"type": 'text',
|
||||
"content": content,
|
||||
"timestamp": new Date().getTime()
|
||||
const out = String(stdout.text)
|
||||
if (root._decodeCallback) {
|
||||
try {
|
||||
root._decodeCallback(out)
|
||||
} finally {
|
||||
root._decodeCallback = null
|
||||
}
|
||||
|
||||
// Check if this exact text content already exists
|
||||
const exists = root.history.find(item => {
|
||||
if (item.type === 'text') {
|
||||
return item.content === content
|
||||
}
|
||||
return item === content
|
||||
})
|
||||
|
||||
if (!exists) {
|
||||
// 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, ...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 {}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
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
|
||||
// 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 {
|
||||
const items = historyAdapter.history || []
|
||||
root.history = items.slice(0, maxHistory) // Apply limit when loading
|
||||
Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache")
|
||||
root._b64CurrentCb(url)
|
||||
} catch (e) {
|
||||
Logger.error("Clipboard", "Failed to load history:", e)
|
||||
root.history = []
|
||||
|
||||
}
|
||||
}
|
||||
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 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()
|
||||
// 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
|
||||
})
|
||||
} catch (e) {
|
||||
Logger.error("Clipboard", "Failed to save history:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
root.history = []
|
||||
saveHistory()
|
||||
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 getImageData(id) {
|
||||
if (id === undefined) {
|
||||
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 Quickshell.Services.Notifications
|
||||
|
||||
QtObject {
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Notification server instance
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ Rectangle {
|
|||
|
||||
property real scaledRadius: imageRadius * Settings.data.general.radiusRatio
|
||||
|
||||
signal statusChanged(int status)
|
||||
|
||||
color: Color.transparent
|
||||
radius: scaledRadius
|
||||
anchors.margins: Style.marginXXS * scaling
|
||||
|
|
@ -34,6 +36,8 @@ Rectangle {
|
|||
asynchronous: true
|
||||
antialiasing: true
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
|
||||
onStatusChanged: root.statusChanged(status)
|
||||
}
|
||||
|
||||
ShaderEffect {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue