diff --git a/Modules/Launcher/Calculator.qml b/Modules/Launcher/Calculator.qml deleted file mode 100644 index 1082b89..0000000 --- a/Modules/Launcher/Calculator.qml +++ /dev/null @@ -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 - } -} diff --git a/Modules/Launcher/ClipboardHistory.qml b/Modules/Launcher/ClipboardHistory.qml deleted file mode 100644 index ed3af89..0000000 --- a/Modules/Launcher/ClipboardHistory.qml +++ /dev/null @@ -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() - } -} diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 3030ed5..6712c3d 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -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 = "" - selectedIndex = 0 + // 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) + } + } } - // 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 - } - } - }) + + selectedIndex = 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 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() + function activate() { + if (results.length > 0 && results[selectedIndex]) { + const item = results[selectedIndex] + if (item.onActivate) { + item.onActivate() } - 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 { + FocusScope { + id: searchInputWrap 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 { - 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 - anchors.fill: parent - placeholderText: "Search applications... (use > to view commands)" - text: searchText - inputMaxWidth: 100000 - // Tune vertical centering on inner input - 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 - } - }) + NTextInput { + id: searchInput + anchors.fill: parent + + // The input should have focus within the scope + focus: true + + placeholderText: "Search entries... or use > for commands" + text: searchText + 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() } - 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 - NIconButton { - Layout.alignment: Qt.AlignVCenter - visible: searchText.startsWith(">clip") - icon: "delete_sweep" - tooltipText: "Clear clipboard history" - onClicked: CliphistService.wipeAll() + Component.onCompleted: { + if (inputItem) { + inputItem.font.pointSize = Style.fontSizeL * scaling + inputItem.verticalAlignment = TextInput.AlignVCenter + } + } + + onTextChanged: searchText = text + + Keys.onDownPressed: root.selectNext() + Keys.onUpPressed: root.selectPrev() + Keys.onReturnPressed: root.activate() + Keys.onEscapePressed: root.close() } } - // 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 + } + } + + // Error fallback + onStatusChanged: status => { + if (status === Image.Error) { + iconLoader.visible = true + imagePreview.visible = false + } } } - // Clipboard image display (pull from cache) - Image { - id: clipboardImage + // 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 - } - - IconImage { - id: iconImg - anchors.fill: parent - 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 + + visible: !modelData.isImage || imagePreview.status === Image.Error + active: visible + + sourceComponent: Component { + IconImage { + anchors.fill: parent + source: modelData.icon ? Icons.iconFromName(modelData.icon, "application-x-executable") : "" + visible: modelData.icon && source !== "" + asynchronous: true + } + } } + // 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.mPrimary + color: Color.mOnPrimary } - Behavior on color { - ColorAnimation { - duration: Style.animationFast + // 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 } } } - // 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' : ''}` - font.pointSize: Style.fontSizeXS * scaling - color: Color.mOnSurface - horizontalAlignment: Text.AlignHCenter 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 } } } diff --git a/Modules/Launcher/Plugins/ApplicationsPlugin.qml b/Modules/Launcher/Plugins/ApplicationsPlugin.qml new file mode 100644 index 0000000..dcc6f6c --- /dev/null +++ b/Modules/Launcher/Plugins/ApplicationsPlugin.qml @@ -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() + } + } + } +} diff --git a/Modules/Launcher/Plugins/CalculatorPlugin.qml b/Modules/Launcher/Plugins/CalculatorPlugin.qml new file mode 100644 index 0000000..292517f --- /dev/null +++ b/Modules/Launcher/Plugins/CalculatorPlugin.qml @@ -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) + } + } diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml new file mode 100644 index 0000000..00cfb44 --- /dev/null +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -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 + } +} diff --git a/Services/ClipboardService.qml b/Services/ClipboardService.qml index 899ec77..8e2df62 100644 --- a/Services/ClipboardService.qml +++ b/Services/ClipboardService.qml @@ -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 - JsonAdapter { - id: historyAdapter - property var history: [] - property double timestamp: 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() } - Timer { - interval: 2000 - repeat: true - running: root._enabled - onTriggered: root.refresh() + // Check dependency availability + function checkCliphistAvailability() { + if (dependencyChecked) + return + + dependencyCheckProcess.command = ["which", "cliphist"] + dependencyCheckProcess.running = true } - // 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 - } - } - - stdout: StdioCollector {} - } - - // 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() - } + 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) } } - - // 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 { - id: textProcess - property bool isLoading: false - + id: listProc + stdout: StdioCollector {} 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: " " or "\t" + 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) { - const content = String(stdout.text).trim() - if (content && content.length > 0) { - const entry = { - "type": 'text', - "content": content, - "timestamp": new Date().getTime() - } + // Emit the signal for subscribers + root.listCompleted() + } + } - // 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() - } + Process { + id: decodeProc + stdout: StdioCollector {} + onExited: (exitCode, exitStatus) => { + const out = String(stdout.text) + if (root._decodeCallback) { + try { + root._decodeCallback(out) + } finally { + root._decodeCallback = null } } - - // 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 + // 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) + } 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() { - // Populate in-memory history from cached file - try { - const items = historyAdapter.history || [] - root.history = items.slice(0, maxHistory) // Apply limit when loading - Logger.log("Clipboard", "Loaded", root.history.length, "entries from cache") - } catch (e) { - Logger.error("Clipboard", "Failed to load history:", e) - root.history = [] + // 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 saveHistory() { - try { - // Ensure we don't exceed the maximum history limit - const limitedHistory = root.history.slice(0, maxHistory) + 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 + } - historyAdapter.history = limitedHistory - historyAdapter.timestamp = Time.timestamp + function stopWatchers() { + if (!watchersStarted) + return + watchText.running = false + watchImage.running = false + watchersStarted = false + } - // Ensure cache directory exists - Quickshell.execDetached(["mkdir", "-p", Settings.cacheDir]) + 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 + } - Qt.callLater(function () { - historyFileView.writeAdapter() - }) - } catch (e) { - Logger.error("Clipboard", "Failed to save history:", e) + 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 clearHistory() { - root.history = [] - saveHistory() + 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()) } } diff --git a/Services/CliphistService.qml b/Services/CliphistService.qml deleted file mode 100644 index 87f14f3..0000000 --- a/Services/CliphistService.qml +++ /dev/null @@ -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: " " or "\t" - 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()) - } -} diff --git a/Services/NotificationService.qml b/Services/NotificationService.qml index 0354df5..71ffe13 100644 --- a/Services/NotificationService.qml +++ b/Services/NotificationService.qml @@ -7,7 +7,7 @@ import qs.Commons import qs.Services import Quickshell.Services.Notifications -QtObject { +Singleton { id: root // Notification server instance diff --git a/Widgets/NImageRounded.qml b/Widgets/NImageRounded.qml index 14ff263..76654fc 100644 --- a/Widgets/NImageRounded.qml +++ b/Widgets/NImageRounded.qml @@ -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 {