From 742a600e38836bfb60679acaa96e6935f2e300f3 Mon Sep 17 00:00:00 2001 From: LemmyCook Date: Tue, 2 Sep 2025 22:20:01 -0400 Subject: [PATCH] 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) + } + }