From 742a600e38836bfb60679acaa96e6935f2e300f3 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Tue, 2 Sep 2025 22:20:01 -0400 Subject: [PATCH 1/9] Launcher: first refactoring pass --- Modules/Launcher/Calculator.qml | 151 ----- Modules/Launcher/ClipboardHistory.qml | 108 ---- Modules/Launcher/Launcher.qml | 590 ++++++------------ .../Launcher/Plugins/ApplicationsPlugin.qml | 95 +++ Modules/Launcher/Plugins/CalculatorPlugin.qml | 105 ++++ 5 files changed, 383 insertions(+), 666 deletions(-) delete mode 100644 Modules/Launcher/Calculator.qml delete mode 100644 Modules/Launcher/ClipboardHistory.qml create mode 100644 Modules/Launcher/Plugins/ApplicationsPlugin.qml create mode 100644 Modules/Launcher/Plugins/CalculatorPlugin.qml 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..a056c4f 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -1,415 +1,245 @@ 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 + + // Panel configuration 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_") - - // Enable keyboard focus for launcher (needed for search) + panelHeight: Math.min(600 * scaling, screen?.height * 0.8) 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") } } - // 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 { + // Wrapper ensures the input stretches to full width under RowLayout + Item { + 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) + // Search input + NTextInput { + id: searchInput + anchors.fill: parent // The NTextInput fills the wrapper + Layout.preferredHeight: Style.barHeight * scaling - 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 - } - }) - } - 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 - } - } - } + placeholderText: "Search entries... or use > for commands" + text: searchText + inputMaxWidth: Number.MAX_SAFE_INTEGER + + function forceActiveFocus() { + inputItem.forceActiveFocus() } - } - // 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: { + 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 + //boundsBehavior: Flickable.StopAtBounds + // maximumFlickVelocity: 2500 + // flickDeceleration: 2000 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 { - width: appsList.width - Style.marginS * scaling + id: entry + + property bool isSelected: mouseArea.containsMouse || (index === selectedIndex) + + width: resultsList.width - Style.marginS * scaling height: 65 * 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 +248,50 @@ NPanel { anchors.margins: Style.marginM * scaling spacing: Style.marginM * scaling - // App/clipboard icon with background + // Icon badge 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") - - // Decode image thumbnails on demand - Component.onCompleted: { - if (modelData && modelData.type === 'image' && !CliphistService.imageDataById[modelData.id]) { - CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {}) - } - } - onVisibleChanged: { - if (visible && modelData && modelData.type === 'image' - && !CliphistService.imageDataById[modelData.id]) { - CliphistService.decodeToDataUrl(modelData.id, modelData.mime || "image/*", function () {}) - } - } - - // Clipboard image display (pull from cache) - Image { - id: clipboardImage - 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 - } + color: Color.mSurfaceVariant 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 if no icon NText { anchors.centerIn: parent - visible: !parent.iconLoaded && !(modelData.isCalculator || modelData.isClipboard || modelData.isCommand) + visible: !modelData.icon || parent.children[0].source === "" text: modelData.name ? modelData.name.charAt(0).toUpperCase() : "?" font.pointSize: Style.fontSizeXXL * scaling font.weight: Style.fontWeightBold - color: Color.mPrimary - } - - Behavior on color { - ColorAnimation { - duration: Style.animationFast - } + color: Color.mOnPrimary } } - // App info + // Text 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 +300,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..29df787 --- /dev/null +++ b/Modules/Launcher/Plugins/ApplicationsPlugin.qml @@ -0,0 +1,95 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Commons +import qs.Services +import "../../../Helpers/FuzzySort.js" as Fuzzysort + +QtObject { + 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", + "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..281e0e5 --- /dev/null +++ b/Modules/Launcher/Plugins/CalculatorPlugin.qml @@ -0,0 +1,105 @@ +import QtQuick +import qs.Services +import "../../../Helpers/AdvancedMath.js" as AdvancedMath + +QtObject { + 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", + "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", + "onActivate": function () {} + }] + } + + try { + let result = AdvancedMath.evaluate(expression.trim()) + + return [{ + "name": AdvancedMath.formatResult(result), + "description": `${expression} = ${result}`, + "icon": "accessories-calculator", + "onActivate": function () { + // Copy result to clipboard if service available + // if (typeof ClipboardService !== 'undefined') { + // ClipboardService.copy(result.toString()) + // } + launcher.close() + } + }] + } catch (error) { + return [{ + "name": "Error", + "description": error.message || "Invalid expression", + "icon": "dialog-error", + "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) + } + } From 1599ee568207a613f9ac97fda42f9e5caf76f1e9 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 08:01:24 -0400 Subject: [PATCH 2/9] Launcher: Working clipboard plugin --- Modules/Launcher/Launcher.qml | 9 + Modules/Launcher/Plugins/ClipboardPlugin.qml | 183 +++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 Modules/Launcher/Plugins/ClipboardPlugin.qml diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index a056c4f..3270351 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -146,6 +146,15 @@ NPanel { } 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") + } } // UI diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml new file mode 100644 index 0000000..93c69bf --- /dev/null +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -0,0 +1,183 @@ +import QtQuick +import Quickshell +import qs.Commons +import qs.Services + +QtObject { + id: root + + // Plugin metadata + property string name: "Clipboard History" + property var launcher: null + + // Plugin capabilities + property bool handleSearch: false // Don't handle regular search + + // Initialize plugin + function init() { + Logger.log("ClipboardPlugin", "Initialized") + } + + // Called when launcher opens + function onOpened() { + // Refresh clipboard history when launcher opens + CliphistService.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: "content_paste", + onActivate: function() { + launcher.setSearchText(">clip ") + } + }, + { + name: ">clip clear", + description: "Clear all clipboard history", + icon: "delete_sweep", + onActivate: function() { + CliphistService.wipeAll() + launcher.close() + } + } + ] + } + + // Get search results + function getResults(searchText) { + if (!searchText.startsWith(">clip")) { + return [] + } + + const results = [] + const query = searchText.slice(5).trim() + + // Special command: clear + if (query === "clear") { + return [{ + name: "Clear Clipboard History", + description: "Remove all items from clipboard history", + icon: "delete_sweep", + onActivate: function() { + CliphistService.wipeAll() + launcher.close() + } + }] + } + + // Search clipboard items + const searchTerm = query.toLowerCase() + + // Force dependency update + const _rev = CliphistService.revision + const items = CliphistService.items || [] + + // 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() { + CliphistService.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: "content_paste_off", + onActivate: function() { + // Do nothing + } + }) + } + + return results + } + + // Helper: Format image clipboard entry + function formatImageEntry(item) { + const meta = parseImageMeta(item.preview) + + return { + name: meta ? `Image ${meta.w}×${meta.h}` : "Image", + description: meta ? `${meta.fmt} • ${meta.size}` : item.mime || "Image data", + icon: "image" + } + } + + // Helper: Format text clipboard entry + 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: "description" + } + } + + // 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]) + } + } +} \ No newline at end of file From 7548ffc191daabb071ac6f04f1935a8be43c3416 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 08:05:06 -0400 Subject: [PATCH 3/9] Laucher: Fix wayland warning about focus surface stealing --- Modules/Launcher/Launcher.qml | 26 +++- Modules/Launcher/Plugins/ClipboardPlugin.qml | 146 +++++++++---------- 2 files changed, 89 insertions(+), 83 deletions(-) diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 3270351..fcf94bb 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -174,29 +174,39 @@ NPanel { anchors.margins: Style.marginL * scaling spacing: 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) - // Search input + // This FocusScope should get focus when panel opens + focus: true + NTextInput { id: searchInput - anchors.fill: parent // The NTextInput fills the wrapper - Layout.preferredHeight: Style.barHeight * scaling + 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() { - inputItem.forceActiveFocus() + // First ensure the scope has focus + searchInputWrap.forceActiveFocus() + // Then focus the actual input + if (inputItem && inputItem.visible) { + inputItem.forceActiveFocus() + } } Component.onCompleted: { - inputItem.font.pointSize = Style.fontSizeL * scaling - inputItem.verticalAlignment = TextInput.AlignVCenter + if (inputItem) { + inputItem.font.pointSize = Style.fontSizeL * scaling + inputItem.verticalAlignment = TextInput.AlignVCenter + } } onTextChanged: searchText = text diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml index 93c69bf..2bbf6aa 100644 --- a/Modules/Launcher/Plugins/ClipboardPlugin.qml +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -5,91 +5,88 @@ import qs.Services QtObject { id: root - + // Plugin metadata property string name: "Clipboard History" property var launcher: null - + // Plugin capabilities - property bool handleSearch: false // Don't handle regular search - + property bool handleSearch: false // Don't handle regular search + // Initialize plugin function init() { Logger.log("ClipboardPlugin", "Initialized") } - + // Called when launcher opens function onOpened() { // Refresh clipboard history when launcher opens CliphistService.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: "content_paste", - onActivate: function() { - launcher.setSearchText(">clip ") - } - }, - { - name: ">clip clear", - description: "Clear all clipboard history", - icon: "delete_sweep", - onActivate: function() { - CliphistService.wipeAll() - launcher.close() - } - } - ] + return [{ + "name": ">clip", + "description": "Search clipboard history", + "icon": "content_paste", + "onActivate": function () { + launcher.setSearchText(">clip ") + } + }, { + "name": ">clip clear", + "description": "Clear all clipboard history", + "icon": "delete_sweep", + "onActivate": function () { + CliphistService.wipeAll() + launcher.close() + } + }] } - + // Get search results function getResults(searchText) { if (!searchText.startsWith(">clip")) { return [] } - + const results = [] const query = searchText.slice(5).trim() - + // Special command: clear if (query === "clear") { return [{ - name: "Clear Clipboard History", - description: "Remove all items from clipboard history", - icon: "delete_sweep", - onActivate: function() { - CliphistService.wipeAll() - launcher.close() - } - }] + "name": "Clear Clipboard History", + "description": "Remove all items from clipboard history", + "icon": "delete_sweep", + "onActivate": function () { + CliphistService.wipeAll() + launcher.close() + } + }] } - + // Search clipboard items const searchTerm = query.toLowerCase() - + // Force dependency update const _rev = CliphistService.revision const items = CliphistService.items || [] - + // Filter and format results - items.forEach(function(item) { + 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) { @@ -97,53 +94,52 @@ QtObject { } else { entry = formatTextEntry(item) } - + // Add activation handler - entry.onActivate = function() { + entry.onActivate = function () { CliphistService.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: "content_paste_off", - onActivate: function() { - // Do nothing - } - }) + "name": searchTerm ? "No matching clipboard items" : "Clipboard is empty", + "description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here", + "icon": "content_paste_off", + "onActivate": function () {// Do nothing + } + }) } - + return results } - + // Helper: Format image clipboard entry function formatImageEntry(item) { const meta = parseImageMeta(item.preview) - + return { - name: meta ? `Image ${meta.w}×${meta.h}` : "Image", - description: meta ? `${meta.fmt} • ${meta.size}` : item.mime || "Image data", - icon: "image" + "name": meta ? `Image ${meta.w}×${meta.h}` : "Image", + "description": meta ? `${meta.fmt} • ${meta.size}` : item.mime || "Image data", + "icon": "image" } } - + // Helper: Format text clipboard entry 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) { @@ -156,28 +152,28 @@ QtObject { const words = preview.split(/\s+/).length description = `${chars} characters, ${words} word${words !== 1 ? 's' : ''}` } - + return { - name: title, - description: description, - icon: "description" + "name": title, + "description": description, + "icon": "description" } } - + // 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]) + "size": match[1], + "fmt": (match[2] || "").toUpperCase(), + "w": Number(match[3]), + "h": Number(match[4]) } } -} \ No newline at end of file +} From ded133d1646f1959a9e1d8a934a8242d4866ba03 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 08:44:10 -0400 Subject: [PATCH 4/9] Launcher: wip image preview --- Modules/Launcher/Launcher.qml | 117 ++++++++++++++++-- .../Launcher/Plugins/ApplicationsPlugin.qml | 1 + Modules/Launcher/Plugins/CalculatorPlugin.qml | 4 + Modules/Launcher/Plugins/ClipboardPlugin.qml | 44 ++++++- Services/CliphistService.qml | 9 ++ 5 files changed, 159 insertions(+), 16 deletions(-) diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index fcf94bb..b26b38d 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -245,13 +245,15 @@ NPanel { policy: ScrollBar.AsNeeded } + // Replace the delegate in Launcher.qml's ListView with this enhanced version: delegate: Rectangle { id: entry property bool isSelected: mouseArea.containsMouse || (index === selectedIndex) + property int badgeSize: Style.baseWidgetSize * 1.75 * scaling width: resultsList.width - Style.marginS * scaling - height: 65 * scaling + height: badgeSize + Style.marginM * 2 *scaling radius: Style.radiusM * scaling color: entry.isSelected ? Color.mTertiary : Color.mSurface @@ -267,33 +269,107 @@ NPanel { anchors.margins: Style.marginM * scaling spacing: Style.marginM * scaling - // Icon badge + // Icon badge or Image preview Rectangle { - Layout.preferredWidth: Style.baseWidgetSize * 1.25 * scaling - Layout.preferredHeight: Style.baseWidgetSize * 1.25 * scaling - radius: Style.radiusS * scaling + Layout.preferredWidth: badgeSize + Layout.preferredHeight: badgeSize + radius: Style.radiusM * scaling color: Color.mSurfaceVariant + clip: true - IconImage { + // Image preview for clipboard images + Image { + id: imagePreview anchors.fill: parent - anchors.margins: Style.marginXS * scaling - source: modelData.icon ? Icons.iconFromName(modelData.icon, "application-x-executable") : "" - visible: modelData.icon && source !== "" + anchors.margins: 2 * scaling + visible: modelData.isImage && modelData.imageSource + source: modelData.imageSource || "" + fillMode: Image.PreserveAspectFit + smooth: true + mipmap: true asynchronous: true + cache: true + + // 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: { + if (status === Image.Error) { + // Fall back to icon + iconLoader.visible = true + imagePreview.visible = false + } + } } - // Fallback if no icon + // Icon fallback + Loader { + id: iconLoader + anchors.fill: parent + anchors.margins: Style.marginXS * scaling + visible: !modelData.isImage || !modelData.imageSource || 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: !modelData.icon || parent.children[0].source === "" + 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: 2 * scaling + color: Qt.rgba(0, 0, 0, 0.7) + + 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 + } + } } - // Text + // Text content ColumnLayout { Layout.fillWidth: true spacing: 0 * scaling @@ -315,6 +391,23 @@ NPanel { Layout.fillWidth: true visible: text !== "" } + + // // Show text preview for text items if space allows + // NText { + // visible: !modelData.isImage && modelData.fullText && modelData.fullText.length > 100 + // text: { + // if (!modelData.fullText) return "" + // const preview = modelData.fullText.substring(0, 150).replace(/\n/g, " ") + // return preview + (modelData.fullText.length > 150 ? "..." : "") + // } + // font.pointSize: Style.fontSizeXS * scaling + // color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant + // opacity: 0.7 + // elide: Text.ElideRight + // maximumLineCount: 2 + // wrapMode: Text.WordWrap + // Layout.fillWidth: true + // } } } diff --git a/Modules/Launcher/Plugins/ApplicationsPlugin.qml b/Modules/Launcher/Plugins/ApplicationsPlugin.qml index 29df787..2bf45a9 100644 --- a/Modules/Launcher/Plugins/ApplicationsPlugin.qml +++ b/Modules/Launcher/Plugins/ApplicationsPlugin.qml @@ -80,6 +80,7 @@ QtObject { "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) { diff --git a/Modules/Launcher/Plugins/CalculatorPlugin.qml b/Modules/Launcher/Plugins/CalculatorPlugin.qml index 281e0e5..a9578dd 100644 --- a/Modules/Launcher/Plugins/CalculatorPlugin.qml +++ b/Modules/Launcher/Plugins/CalculatorPlugin.qml @@ -17,6 +17,7 @@ QtObject { "name": ">calc", "description": "Calculator - evaluate mathematical expressions", "icon": "accessories-calculator", + "isImage": false, "onActivate": function () { launcher.setSearchText(">calc ") } @@ -39,6 +40,7 @@ QtObject { "name": "Calculator", "description": "Enter a mathematical expression", "icon": "accessories-calculator", + "isImage": false, "onActivate": function () {} }] } @@ -50,6 +52,7 @@ QtObject { "name": AdvancedMath.formatResult(result), "description": `${expression} = ${result}`, "icon": "accessories-calculator", + "isImage": false, "onActivate": function () { // Copy result to clipboard if service available // if (typeof ClipboardService !== 'undefined') { @@ -63,6 +66,7 @@ QtObject { "name": "Error", "description": error.message || "Invalid expression", "icon": "dialog-error", + "isImage": false, "onActivate": function () {} }] } diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml index 2bbf6aa..d0e2269 100644 --- a/Modules/Launcher/Plugins/ClipboardPlugin.qml +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -35,6 +35,7 @@ QtObject { "name": ">clip", "description": "Search clipboard history", "icon": "content_paste", + "isImage": false, "onActivate": function () { launcher.setSearchText(">clip ") } @@ -42,6 +43,7 @@ QtObject { "name": ">clip clear", "description": "Clear all clipboard history", "icon": "delete_sweep", + "isImage": false, "onActivate": function () { CliphistService.wipeAll() launcher.close() @@ -110,6 +112,7 @@ QtObject { "name": searchTerm ? "No matching clipboard items" : "Clipboard is empty", "description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here", "icon": "content_paste_off", + "isImage": false, "onActivate": function () {// Do nothing } }) @@ -118,18 +121,44 @@ QtObject { return results } - // Helper: Format image clipboard entry + // Helper: Format image clipboard entry with actual image data function formatImageEntry(item) { const meta = parseImageMeta(item.preview) + // Get the actual image data/path from the clipboard service + // This assumes CliphistService provides either a path or base64 data + let imageData = null + + // Try to get image data from the service + // Method 1: If the service provides a file path + if (item.imagePath) { + imageData = "file://" + item.imagePath + } // Method 2: If the service provides base64 data + else if (item.imageData) { + imageData = ClipHistService.getImageData(item.id) + + // "data:" + (item.mime || "image/png") + ";base64," + item.imageData + } // Method 3: If we need to fetch it from the service + + // else if (item.id) { + // // Some clipboard services might require fetching the image separately + // // This would depend on your CliphistService implementation + // imageData = CliphistService.getImageData ? CliphistService.getImageData(item.id) : null + // } return { "name": meta ? `Image ${meta.w}×${meta.h}` : "Image", "description": meta ? `${meta.fmt} • ${meta.size}` : item.mime || "Image data", - "icon": "image" + "icon": "image", + "isImage": true, + "imageSource": imageData, + "imageWidth": meta ? meta.w : 0, + "imageHeight": meta ? meta.h : 0, + "clipboardId"// Add clipboard item ID for potential async loading + : item.id } } - // Helper: Format text clipboard entry + // Helper: Format text clipboard entry with preview function formatTextEntry(item) { const preview = (item.preview || "").trim() const lines = preview.split('\n').filter(l => l.trim()) @@ -156,7 +185,8 @@ QtObject { return { "name": title, "description": description, - "icon": "description" + "icon": "description", + "isImage": false } } @@ -176,4 +206,10 @@ QtObject { "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 CliphistService.getImageData ? CliphistService.getImageData(clipboardId) : null + } } diff --git a/Services/CliphistService.qml b/Services/CliphistService.qml index 87f14f3..60b9a34 100644 --- a/Services/CliphistService.qml +++ b/Services/CliphistService.qml @@ -284,6 +284,11 @@ Singleton { } } + function getImageData(id) { + return root.imageDataById[id] + } + + function _startNextB64() { if (root._b64Queue.length === 0 || !root.cliphistAvailable) return @@ -316,7 +321,11 @@ Singleton { if (!root.cliphistAvailable) { return } + Quickshell.execDetached(["cliphist", "wipe"]) + + revision++ + Qt.callLater(() => list()) } } From 132dbce3a3e92a88c17c8dcad23b2ad246d7d39c Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 09:22:27 -0400 Subject: [PATCH 5/9] Launcher: wip image preview --- Modules/Launcher/Launcher.qml | 56 ++++++++++---------- Modules/Launcher/Plugins/ClipboardPlugin.qml | 43 +++++++-------- Services/CliphistService.qml | 9 +++- 3 files changed, 55 insertions(+), 53 deletions(-) diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index b26b38d..4c44ad6 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -231,9 +231,6 @@ NPanel { clip: true cacheBuffer: resultsList.height * 2 - //boundsBehavior: Flickable.StopAtBounds - // maximumFlickVelocity: 2500 - // flickDeceleration: 2000 onCurrentIndexChanged: { cancelFlick() if (currentIndex >= 0) { @@ -245,15 +242,26 @@ NPanel { policy: ScrollBar.AsNeeded } - // Replace the delegate in Launcher.qml's ListView with this enhanced version: delegate: Rectangle { id: entry property bool isSelected: mouseArea.containsMouse || (index === selectedIndex) - property int badgeSize: Style.baseWidgetSize * 1.75 * scaling + property int badgeSize: Style.baseWidgetSize * 1.75 * 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 && !CliphistService.getImageData(currentClipboardId)) { + CliphistService.decodeToDataUrl(currentClipboardId, modelData.mime, null) + } + } width: resultsList.width - Style.marginS * scaling - height: badgeSize + Style.marginM * 2 *scaling + height: badgeSize + Style.marginM * 2 * scaling radius: Style.radiusM * scaling color: entry.isSelected ? Color.mTertiary : Color.mSurface @@ -282,8 +290,19 @@ NPanel { id: imagePreview anchors.fill: parent anchors.margins: 2 * scaling - visible: modelData.isImage && modelData.imageSource - source: modelData.imageSource || "" + visible: modelData.isImage + + // This property creates a dependency on the service's revision counter + readonly property int _rev: CliphistService.revision + + // Fetches from the service's cache. + // The dependency on `_rev` ensures this binding is re-evaluated + // when the cache is updated by the service. + source: { + _rev + return CliphistService.getImageData(modelData.clipboardId) || "" + } + fillMode: Image.PreserveAspectFit smooth: true mipmap: true @@ -307,7 +326,6 @@ NPanel { // Error fallback onStatusChanged: { if (status === Image.Error) { - // Fall back to icon iconLoader.visible = true imagePreview.visible = false } @@ -319,7 +337,8 @@ NPanel { id: iconLoader anchors.fill: parent anchors.margins: Style.marginXS * scaling - visible: !modelData.isImage || !modelData.imageSource || imagePreview.status === Image.Error + + visible: !modelData.isImage || imagePreview.status === Image.Error active: visible sourceComponent: Component { @@ -391,23 +410,6 @@ NPanel { Layout.fillWidth: true visible: text !== "" } - - // // Show text preview for text items if space allows - // NText { - // visible: !modelData.isImage && modelData.fullText && modelData.fullText.length > 100 - // text: { - // if (!modelData.fullText) return "" - // const preview = modelData.fullText.substring(0, 150).replace(/\n/g, " ") - // return preview + (modelData.fullText.length > 150 ? "..." : "") - // } - // font.pointSize: Style.fontSizeXS * scaling - // color: entry.isSelected ? Color.mOnTertiary : Color.mOnSurfaceVariant - // opacity: 0.7 - // elide: Text.ElideRight - // maximumLineCount: 2 - // wrapMode: Text.WordWrap - // Layout.fillWidth: true - // } } } diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml index d0e2269..8d51124 100644 --- a/Modules/Launcher/Plugins/ClipboardPlugin.qml +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -13,6 +13,17 @@ QtObject { // Plugin capabilities property bool handleSearch: false // Don't handle regular search + // Connections { + // target: CliphistService + // // Use the function syntax for on + // function onListCompleted() { + // // Only refresh if the clipboard plugin is active + // if (launcher && launcher.activePlugin === root) { + // launcher.updateResults() + // } + // } + // } + // Initialize plugin function init() { Logger.log("ClipboardPlugin", "Initialized") @@ -121,40 +132,22 @@ QtObject { return results } - // Helper: Format image clipboard entry with actual image data + // Helper: Format image clipboard entry function formatImageEntry(item) { const meta = parseImageMeta(item.preview) - // Get the actual image data/path from the clipboard service - // This assumes CliphistService provides either a path or base64 data - let imageData = null - - // Try to get image data from the service - // Method 1: If the service provides a file path - if (item.imagePath) { - imageData = "file://" + item.imagePath - } // Method 2: If the service provides base64 data - else if (item.imageData) { - imageData = ClipHistService.getImageData(item.id) - - // "data:" + (item.mime || "image/png") + ";base64," + item.imageData - } // Method 3: If we need to fetch it from the service - - // else if (item.id) { - // // Some clipboard services might require fetching the image separately - // // This would depend on your CliphistService implementation - // imageData = CliphistService.getImageData ? CliphistService.getImageData(item.id) : null - // } + // 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, - "imageSource": imageData, "imageWidth": meta ? meta.w : 0, "imageHeight": meta ? meta.h : 0, - "clipboardId"// Add clipboard item ID for potential async loading - : item.id + "clipboardId"// Provide the ID and mime type for the delegate to make an async request + : item.id, + "mime": item.mime } } @@ -185,7 +178,7 @@ QtObject { return { "name": title, "description": description, - "icon": "description", + "icon": "text-x-generic", "isImage": false } } diff --git a/Services/CliphistService.qml b/Services/CliphistService.qml index 60b9a34..e521369 100644 --- a/Services/CliphistService.qml +++ b/Services/CliphistService.qml @@ -39,6 +39,8 @@ Singleton { property string _b64CurrentMime: "" property string _b64CurrentId: "" + signal listCompleted() + // Check if cliphist is available Component.onCompleted: { checkCliphistAvailability() @@ -147,6 +149,9 @@ Singleton { }) items = parsed loading = false + + // Emit the signal for subscribers + root.listCompleted() } } @@ -285,10 +290,12 @@ Singleton { } function getImageData(id) { + if (id === undefined) { + return null + } return root.imageDataById[id] } - function _startNextB64() { if (root._b64Queue.length === 0 || !root.cliphistAvailable) return From 20b29f98a7906c3e7ec17645fc4ec480ce154790 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 09:35:33 -0400 Subject: [PATCH 6/9] Launcher: deleted ClipboardService, renamed CliphistService to ClipboardService. --- Modules/Launcher/Launcher.qml | 8 +- Modules/Launcher/Plugins/CalculatorPlugin.qml | 5 +- Modules/Launcher/Plugins/ClipboardPlugin.qml | 37 +- Services/ClipboardService.qml | 460 +++++++++++------- Services/CliphistService.qml | 338 ------------- 5 files changed, 314 insertions(+), 534 deletions(-) delete mode 100644 Services/CliphistService.qml diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 4c44ad6..7f57b53 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -255,8 +255,8 @@ NPanel { // 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 && !CliphistService.getImageData(currentClipboardId)) { - CliphistService.decodeToDataUrl(currentClipboardId, modelData.mime, null) + if (currentClipboardId && !ClipboardService.getImageData(currentClipboardId)) { + ClipboardService.decodeToDataUrl(currentClipboardId, modelData.mime, null) } } @@ -293,14 +293,14 @@ NPanel { visible: modelData.isImage // This property creates a dependency on the service's revision counter - readonly property int _rev: CliphistService.revision + 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 by the service. source: { _rev - return CliphistService.getImageData(modelData.clipboardId) || "" + return ClipboardService.getImageData(modelData.clipboardId) || "" } fillMode: Image.PreserveAspectFit diff --git a/Modules/Launcher/Plugins/CalculatorPlugin.qml b/Modules/Launcher/Plugins/CalculatorPlugin.qml index a9578dd..ee13c70 100644 --- a/Modules/Launcher/Plugins/CalculatorPlugin.qml +++ b/Modules/Launcher/Plugins/CalculatorPlugin.qml @@ -54,10 +54,7 @@ QtObject { "icon": "accessories-calculator", "isImage": false, "onActivate": function () { - // Copy result to clipboard if service available - // if (typeof ClipboardService !== 'undefined') { - // ClipboardService.copy(result.toString()) - // } + // TODO: copy entry to clipboard via ClipHist launcher.close() } }] diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml index 8d51124..586473b 100644 --- a/Modules/Launcher/Plugins/ClipboardPlugin.qml +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -13,9 +13,9 @@ QtObject { // Plugin capabilities property bool handleSearch: false // Don't handle regular search + // Listen for clipboard data updates // Connections { - // target: CliphistService - // // Use the function syntax for on + // target: ClipboardService // function onListCompleted() { // // Only refresh if the clipboard plugin is active // if (launcher && launcher.activePlugin === root) { @@ -32,7 +32,7 @@ QtObject { // Called when launcher opens function onOpened() { // Refresh clipboard history when launcher opens - CliphistService.list(100) + ClipboardService.list(100) } // Check if this plugin handles the command @@ -56,7 +56,7 @@ QtObject { "icon": "delete_sweep", "isImage": false, "onActivate": function () { - CliphistService.wipeAll() + ClipboardService.wipeAll() launcher.close() } }] @@ -78,18 +78,29 @@ QtObject { "description": "Remove all items from clipboard history", "icon": "delete_sweep", "onActivate": function () { - CliphistService.wipeAll() + ClipboardService.wipeAll() launcher.close() } }] } + // Show loading state if data isn't ready yet + if (ClipboardService.loading) { + return [{ + "name": "Loading clipboard history...", + "description": "Please wait", + "icon": "view-refresh", + "isImage": false, + "onActivate": function () {} + }] + } + // Search clipboard items const searchTerm = query.toLowerCase() // Force dependency update - const _rev = CliphistService.revision - const items = CliphistService.items || [] + const _rev = ClipboardService.revision + const items = ClipboardService.items || [] // Filter and format results items.forEach(function (item) { @@ -110,7 +121,7 @@ QtObject { // Add activation handler entry.onActivate = function () { - CliphistService.copyToClipboard(item.id) + ClipboardService.copyToClipboard(item.id) launcher.close() } @@ -122,13 +133,14 @@ QtObject { results.push({ "name": searchTerm ? "No matching clipboard items" : "Clipboard is empty", "description": searchTerm ? `No items containing "${query}"` : "Copy something to see it here", - "icon": "content_paste_off", + "icon": "text-x-generic", "isImage": false, "onActivate": function () {// Do nothing } }) } + Logger.log("ClipboardPlugin", `Returning ${results.length} results`) return results } @@ -145,8 +157,7 @@ QtObject { "isImage": true, "imageWidth": meta ? meta.w : 0, "imageHeight": meta ? meta.h : 0, - "clipboardId"// Provide the ID and mime type for the delegate to make an async request - : item.id, + "clipboardId": item.id, "mime": item.mime } } @@ -203,6 +214,6 @@ QtObject { // Public method to get image data for a clipboard item // This can be called by the launcher when rendering function getImageForItem(clipboardId) { - return CliphistService.getImageData ? CliphistService.getImageData(clipboardId) : null + return ClipboardService.getImageData ? ClipboardService.getImageData(clipboardId) : null } -} +} \ No newline at end of file diff --git a/Services/ClipboardService.qml b/Services/ClipboardService.qml index 899ec77..e521369 100644 --- a/Services/ClipboardService.qml +++ b/Services/ClipboardService.qml @@ -4,225 +4,335 @@ 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 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 - // 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 && 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: 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) + } 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) } } - 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]) + 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 e521369..0000000 --- a/Services/CliphistService.qml +++ /dev/null @@ -1,338 +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: "" - - signal listCompleted() - - // 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 - - // Emit the signal for subscribers - root.listCompleted() - } - } - - 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 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]) - Qt.callLater(() => list()) - } - - function wipeAll() { - if (!root.cliphistAvailable) { - return - } - - Quickshell.execDetached(["cliphist", "wipe"]) - - revision++ - - Qt.callLater(() => list()) - } -} From 7b2d490ba7f12fba05044754b07da7f4d26f5d28 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 10:25:44 -0400 Subject: [PATCH 7/9] Launcher: clipboard, prevent unecessary refresh while browsing --- Modules/Launcher/Launcher.qml | 16 +++- .../Launcher/Plugins/ApplicationsPlugin.qml | 2 +- Modules/Launcher/Plugins/CalculatorPlugin.qml | 2 +- Modules/Launcher/Plugins/ClipboardPlugin.qml | 88 ++++++++++++++----- Services/ClipboardService.qml | 18 ++-- Services/NotificationService.qml | 2 +- 6 files changed, 91 insertions(+), 37 deletions(-) diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 7f57b53..9317675 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -11,8 +11,18 @@ NPanel { id: root // Panel configuration - panelWidth: Math.min(700 * scaling, screen?.width * 0.75) - panelHeight: Math.min(600 * scaling, screen?.height * 0.8) + 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 + } + + panelKeyboardFocus: true panelBackgroundColor: Qt.rgba(Color.mSurface.r, Color.mSurface.g, Color.mSurface.b, Settings.data.appLauncher.backgroundOpacity) @@ -246,7 +256,7 @@ NPanel { id: entry property bool isSelected: mouseArea.containsMouse || (index === selectedIndex) - property int badgeSize: Style.baseWidgetSize * 1.75 * scaling + 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. diff --git a/Modules/Launcher/Plugins/ApplicationsPlugin.qml b/Modules/Launcher/Plugins/ApplicationsPlugin.qml index 2bf45a9..dcc6f6c 100644 --- a/Modules/Launcher/Plugins/ApplicationsPlugin.qml +++ b/Modules/Launcher/Plugins/ApplicationsPlugin.qml @@ -5,7 +5,7 @@ import qs.Commons import qs.Services import "../../../Helpers/FuzzySort.js" as Fuzzysort -QtObject { +Item { property var launcher: null property string name: "Applications" property bool handleSearch: true diff --git a/Modules/Launcher/Plugins/CalculatorPlugin.qml b/Modules/Launcher/Plugins/CalculatorPlugin.qml index ee13c70..292517f 100644 --- a/Modules/Launcher/Plugins/CalculatorPlugin.qml +++ b/Modules/Launcher/Plugins/CalculatorPlugin.qml @@ -2,7 +2,7 @@ import QtQuick import qs.Services import "../../../Helpers/AdvancedMath.js" as AdvancedMath -QtObject { +Item { property var launcher: null property string name: "Calculator" diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml index 586473b..26087e0 100644 --- a/Modules/Launcher/Plugins/ClipboardPlugin.qml +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -3,7 +3,7 @@ import Quickshell import qs.Commons import qs.Services -QtObject { +Item { id: root // Plugin metadata @@ -13,26 +13,47 @@ QtObject { // 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() { - // // Only refresh if the clipboard plugin is active - // if (launcher && launcher.activePlugin === root) { - // launcher.updateResults() - // } - // } - // } + Connections { + target: ClipboardService + function onListCompleted() { + if (gotResults) { + // 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 - ClipboardService.list(100) + if (ClipboardService.active) { + isWaitingForData = true + ClipboardService.list(100) + } } // Check if this plugin handles the command @@ -45,7 +66,7 @@ QtObject { return [{ "name": ">clip", "description": "Search clipboard history", - "icon": "content_paste", + "icon": "text-x-generic", "isImage": false, "onActivate": function () { launcher.setSearchText(">clip ") @@ -53,7 +74,7 @@ QtObject { }, { "name": ">clip clear", "description": "Clear all clipboard history", - "icon": "delete_sweep", + "icon": "text-x-generic", "isImage": false, "onActivate": function () { ClipboardService.wipeAll() @@ -68,15 +89,28 @@ QtObject { 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() @@ -84,8 +118,24 @@ QtObject { }] } - // Show loading state if data isn't ready yet - if (ClipboardService.loading) { + // 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", @@ -98,10 +148,6 @@ QtObject { // Search clipboard items const searchTerm = query.toLowerCase() - // Force dependency update - const _rev = ClipboardService.revision - const items = ClipboardService.items || [] - // Filter and format results items.forEach(function (item) { const preview = (item.preview || "").toLowerCase() @@ -140,7 +186,7 @@ QtObject { }) } - Logger.log("ClipboardPlugin", `Returning ${results.length} results`) + //Logger.log("ClipboardPlugin", `Returning ${results.length} results for query: "${query}"`) return results } @@ -216,4 +262,4 @@ QtObject { function getImageForItem(clipboardId) { return ClipboardService.getImageData ? ClipboardService.getImageData(clipboardId) : null } -} \ No newline at end of file +} diff --git a/Services/ClipboardService.qml b/Services/ClipboardService.qml index e521369..8e2df62 100644 --- a/Services/ClipboardService.qml +++ b/Services/ClipboardService.qml @@ -10,10 +10,9 @@ Singleton { id: root // Public API - property var items: [] // [{id, preview, mime, isImage}] + property bool active: Settings.isLoaded && Settings.data.appLauncher.enableClipboardHistory && cliphistAvailable 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 + property var items: [] // [{id, preview, mime, isImage}] // Check if cliphist is available on the system property bool cliphistAvailable: false @@ -39,7 +38,7 @@ Singleton { property string _b64CurrentMime: "" property string _b64CurrentId: "" - signal listCompleted() + signal listCompleted // Check if cliphist is available Component.onCompleted: { @@ -82,7 +81,7 @@ Singleton { // Start/stop watchers when enabled changes onActiveChanged: { - if (root.active && root.cliphistAvailable) { + if (root.active) { startWatchers() } else { stopWatchers() @@ -96,7 +95,7 @@ Singleton { Timer { interval: 5000 repeat: true - running: root.active && root.cliphistAvailable + running: root.active onTriggered: list() } @@ -185,9 +184,9 @@ Singleton { const url = `data:${root._b64CurrentMime};base64,${b64}` try { root._b64CurrentCb(url) - } finally { + } catch (e) { - /* noop */ } + } } if (root._b64CurrentId !== "") { root.imageDataById[root._b64CurrentId] = `data:${root._b64CurrentMime};base64,${b64}` @@ -321,6 +320,7 @@ Singleton { return } Quickshell.execDetached(["cliphist", "delete", id]) + revision++ Qt.callLater(() => list()) } @@ -330,9 +330,7 @@ Singleton { } Quickshell.execDetached(["cliphist", "wipe"]) - revision++ - 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 From 24620210fe984456ad4622cabbc889f921181e7e Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 10:43:00 -0400 Subject: [PATCH 8/9] Launcher: improved clipboard images look --- Modules/Launcher/Launcher.qml | 20 +++++++------------- Modules/Launcher/Plugins/ClipboardPlugin.qml | 2 +- Widgets/NImageRounded.qml | 4 ++++ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 9317675..9f78a9d 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -295,30 +295,24 @@ NPanel { color: Color.mSurfaceVariant clip: true + // Image preview for clipboard images - Image { + NImageRounded { id: imagePreview anchors.fill: parent - anchors.margins: 2 * scaling 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 by the service. - source: { + // The dependency on `_rev` ensures this binding is re-evaluated when the cache is updated. + imagePath: { _rev return ClipboardService.getImageData(modelData.clipboardId) || "" } - fillMode: Image.PreserveAspectFit - smooth: true - mipmap: true - asynchronous: true - cache: true - // Loading indicator Rectangle { anchors.fill: parent @@ -379,8 +373,8 @@ NPanel { anchors.margins: 2 * scaling width: formatLabel.width + 6 * scaling height: formatLabel.height + 2 * scaling - radius: 2 * scaling - color: Qt.rgba(0, 0, 0, 0.7) + radius: Style.radiusM * scaling + color: Color.mSurfaceVariant NText { id: formatLabel diff --git a/Modules/Launcher/Plugins/ClipboardPlugin.qml b/Modules/Launcher/Plugins/ClipboardPlugin.qml index 26087e0..00cfb44 100644 --- a/Modules/Launcher/Plugins/ClipboardPlugin.qml +++ b/Modules/Launcher/Plugins/ClipboardPlugin.qml @@ -22,7 +22,7 @@ Item { Connections { target: ClipboardService function onListCompleted() { - if (gotResults) { + 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 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 { From 11a13ce5897648e264f8dcdc3583d4ee828c8506 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Wed, 3 Sep 2025 11:11:37 -0400 Subject: [PATCH 9/9] Launcher: Fix missing argument to onStatusChanged --- Modules/Launcher/Launcher.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Launcher/Launcher.qml b/Modules/Launcher/Launcher.qml index 9f78a9d..6712c3d 100644 --- a/Modules/Launcher/Launcher.qml +++ b/Modules/Launcher/Launcher.qml @@ -328,7 +328,7 @@ NPanel { } // Error fallback - onStatusChanged: { + onStatusChanged: status => { if (status === Image.Error) { iconLoader.visible = true imagePreview.visible = false