From 15e92a2752d2c6f1100f35eda106f65ecc6d7686 Mon Sep 17 00:00:00 2001 From: DuckySoLucky Date: Tue, 30 Dec 2025 15:11:44 +0100 Subject: [PATCH] feat(Launcher): ability to have calculator in inline search --- Assets/Translations/en.json | 4 + Assets/settings-default.json | 1 + Commons/Settings.qml | 3 +- Helpers/AdvancedMath.js | 3 + Modules/Panels/Launcher/Launcher.qml | 8 ++ .../Launcher/Plugins/CalculatorPlugin.qml | 85 ++++++++++++------- Modules/Panels/Settings/Tabs/LauncherTab.qml | 9 ++ 7 files changed, 81 insertions(+), 32 deletions(-) diff --git a/Assets/Translations/en.json b/Assets/Translations/en.json index 7449f3b96..d4fc3d96e 100644 --- a/Assets/Translations/en.json +++ b/Assets/Translations/en.json @@ -1789,6 +1789,10 @@ "description": "Show a preview of the clipboard content when using the >clip command.", "label": "Enable clip preview" }, + "inline-calculator": { + "description": "Show calculator results directly in search when typing math expressions (e.g., '17 * 6').", + "label": "Inline calculator" + }, "clipboard-history": { "description": "Access previously copied items from the launcher.", "label": "Enable clipboard history" diff --git a/Assets/settings-default.json b/Assets/settings-default.json index 304fdfd80..bb3ea4db8 100644 --- a/Assets/settings-default.json +++ b/Assets/settings-default.json @@ -178,6 +178,7 @@ "appLauncher": { "enableClipboardHistory": false, "enableClipPreview": true, + "inlineCalculator": false, "position": "center", "pinnedExecs": [], "useApp2Unit": false, diff --git a/Commons/Settings.qml b/Commons/Settings.qml index c0296ab77..df2d236c2 100644 --- a/Commons/Settings.qml +++ b/Commons/Settings.qml @@ -25,7 +25,7 @@ Singleton { - Default cache directory: ~/.cache/noctalia */ readonly property alias data: adapter // Used to access via Settings.data.xxx.yyy - readonly property int settingsVersion: 35 + readonly property int settingsVersion: 36 readonly property bool isDebug: Quickshell.env("NOCTALIA_DEBUG") === "1" readonly property string shellName: "noctalia" readonly property string configDir: Quickshell.env("NOCTALIA_CONFIG_DIR") || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/" + shellName + "/" @@ -393,6 +393,7 @@ Singleton { property JsonObject appLauncher: JsonObject { property bool enableClipboardHistory: false property bool enableClipPreview: true + property bool inlineCalculator: false // Position: center, top_left, top_right, bottom_left, bottom_right, bottom_center, top_center property string position: "center" property list pinnedExecs: [] diff --git a/Helpers/AdvancedMath.js b/Helpers/AdvancedMath.js index 9e9384ed3..bde5d2002 100644 --- a/Helpers/AdvancedMath.js +++ b/Helpers/AdvancedMath.js @@ -80,6 +80,9 @@ function evaluate(expression) { .replace(/\bcosd\s*\(/g, '(function(x) { return Math.cos(' + (Math.PI / 180) + ' * x); })(') .replace(/\btand\s*\(/g, '(function(x) { return Math.tan(' + (Math.PI / 180) + ' * x); })('); + // Handle ^ for exponentiation: convert 2^3 to Math.pow(2,3) + processed = processed.replace(/([\d.]+|\))\^([\d.]+|\([^)]*\))/g, 'Math.pow($1,$2)'); + // Sanitize expression (only allow safe characters) if (!/^[0-9+\-*/().\s\w,]+$/.test(processed)) { throw new Error("Invalid characters in expression"); diff --git a/Modules/Panels/Launcher/Launcher.qml b/Modules/Panels/Launcher/Launcher.qml index 660ddd91e..c2b7dc3b4 100644 --- a/Modules/Panels/Launcher/Launcher.qml +++ b/Modules/Panels/Launcher/Launcher.qml @@ -268,6 +268,14 @@ SmartPanel { results = results.concat(pluginResults); } } + + // Add inline calculator result at the end if available + if (searchText.trim() && calcPlugin.getInlineResult) { + const inlineResult = calcPlugin.getInlineResult(searchText); + if (inlineResult) { + results = results.concat([inlineResult]); + } + } } selectedIndex = 0; diff --git a/Modules/Panels/Launcher/Plugins/CalculatorPlugin.qml b/Modules/Panels/Launcher/Plugins/CalculatorPlugin.qml index 12ccfbbc5..8d5b84a87 100644 --- a/Modules/Panels/Launcher/Plugins/CalculatorPlugin.qml +++ b/Modules/Panels/Launcher/Plugins/CalculatorPlugin.qml @@ -12,6 +12,39 @@ Item { return query.startsWith(">calc") || (query.startsWith(">") && query.length > 1 && isMathExpression(query.substring(1))); } + function getInlineResult(query) { + if (!Settings.data.appLauncher.inlineCalculator) { + return null; + } + + if (query.startsWith(">")) { + return null; + } + + if (!isMathExpression(query)) { + return null; + } + + + try { + let result = AdvancedMath.evaluate(query.trim()); + return { + "name": AdvancedMath.formatResult(result), + "description": `${query} = ${result}`, + "icon": iconMode === "tabler" ? "calculator" : "accessories-calculator", + "isTablerIcon": true, + "isImage": false, + "isCalculatorResult": true, + "onActivate": function () { + // TODO: copy entry to clipboard via ClipHist + launcher.close(); + } + }; + } catch (error) { + return null; + } + } + function commands() { return [ { @@ -81,38 +114,28 @@ Item { } } - 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); + // Allow: digits, operators, parentheses, decimal points, whitespace, letters (for functions), commas + if (!/^[\d\s\+\-\*\/\(\)\.\%\^a-zA-Z,]+$/.test(expr)) { + return false; + } + + // Must contain at least one operator OR a function call (letter followed by parenthesis) + if (!/[+\-*/%\^]/.test(expr) && !/[a-zA-Z]\s*\(/.test(expr)) { + return false; + } + + // Reject if ends with an operator (incomplete expression) + if (/[+\-*/%\^]\s*$/.test(expr)) { + return false; + } + + // Reject if it's just letters (would match app names) + if (/^[a-zA-Z\s]+$/.test(expr)) { + return false; + } + + return true; } } diff --git a/Modules/Panels/Settings/Tabs/LauncherTab.qml b/Modules/Panels/Settings/Tabs/LauncherTab.qml index 07e1dfa90..16e97f03a 100644 --- a/Modules/Panels/Settings/Tabs/LauncherTab.qml +++ b/Modules/Panels/Settings/Tabs/LauncherTab.qml @@ -97,6 +97,15 @@ ColumnLayout { defaultValue: Settings.getDefaultValue("appLauncher.enableClipPreview") } + NToggle { + label: I18n.tr("settings.launcher.settings.inline-calculator.label") + description: I18n.tr("settings.launcher.settings.inline-calculator.description") + checked: Settings.data.appLauncher.inlineCalculator + onToggled: checked => Settings.data.appLauncher.inlineCalculator = checked + isSettings: true + defaultValue: Settings.getDefaultValue("appLauncher.inlineCalculator") + } + NToggle { label: I18n.tr("settings.launcher.settings.sort-by-usage.label") description: I18n.tr("settings.launcher.settings.sort-by-usage.description")