[cmdpal] Port v1 calculator extension (#38629)

* init

* update

* Remove duplicated cp command

* Change the long desc

* Update notice.md

* Use the same icon for fallback item

* Add Rappl to expect list

* update notice.md

* Move the original order back.

* Make Radians become default choice

* Fix empty result

* Remove unused settings.
Move history back.
Refactory the query logic

* fix typo

* merge main

* CmdPal: minor calc updates (#38914)

A bunch of calc updates

* maintain the visibility of the history
* add other formats to the context menu #38708
* some other icon tidying

---------

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Mike Griese <migrie@microsoft.com>
This commit is contained in:
Yu Leng
2025-04-17 14:59:10 +08:00
committed by GitHub
parent 4f9e829155
commit 6cf73ce839
19 changed files with 1565 additions and 154 deletions

View File

@@ -0,0 +1,86 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class BracketHelper
{
public static bool IsBracketComplete(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return true;
}
var valueTuples = query
.Select(BracketTrail)
.Where(r => r != default);
var trailTest = new Stack<TrailType>();
foreach (var (direction, type) in valueTuples)
{
switch (direction)
{
case TrailDirection.Open:
trailTest.Push(type);
break;
case TrailDirection.Close:
// Try to get item out of stack
if (!trailTest.TryPop(out var popped))
{
return false;
}
if (type != popped)
{
return false;
}
continue;
default:
{
throw new ArgumentOutOfRangeException($"Can't process value (Parameter direction: {direction})");
}
}
}
return trailTest.Count == 0;
}
private static (TrailDirection Direction, TrailType Type) BracketTrail(char @char)
{
switch (@char)
{
case '(':
return (TrailDirection.Open, TrailType.Round);
case ')':
return (TrailDirection.Close, TrailType.Round);
case '[':
return (TrailDirection.Open, TrailType.Bracket);
case ']':
return (TrailDirection.Close, TrailType.Bracket);
default:
return default;
}
}
private enum TrailDirection
{
None,
Open,
Close,
}
private enum TrailType
{
None,
Bracket,
Round,
}
}

View File

@@ -0,0 +1,127 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using Mages.Core;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class CalculateEngine
{
private static readonly Engine _magesEngine = new Engine(new Configuration
{
Scope = new Dictionary<string, object>
{
{ "e", Math.E }, // e is not contained in the default mages engine
},
});
public const int RoundingDigits = 10;
public enum TrigMode
{
Radians,
Degrees,
Gradians,
}
/// <summary>
/// Interpret
/// </summary>
/// <param name="cultureInfo">Use CultureInfo.CurrentCulture if something is user facing</param>
public static CalculateResult Interpret(SettingsManager settings, string input, CultureInfo cultureInfo, out string error)
{
error = default;
if (!CalculateHelper.InputValid(input))
{
return default;
}
// check for division by zero
// We check if the string contains a slash followed by space (optional) and zero. Whereas the zero must not be followed by a dot, comma, 'b', 'o' or 'x' as these indicate a number with decimal digits or a binary/octal/hexadecimal value respectively. The zero must also not be followed by other digits.
if (new Regex("\\/\\s*0(?!(?:[,\\.0-9]|[box]0*[1-9a-f]))", RegexOptions.IgnoreCase).Match(input).Success)
{
error = Properties.Resources.calculator_division_by_zero;
return default;
}
// mages has quirky log representation
// mage has log == ln vs log10
input = input.
Replace("log(", "log10(", true, CultureInfo.CurrentCulture).
Replace("ln(", "log(", true, CultureInfo.CurrentCulture);
input = CalculateHelper.FixHumanMultiplicationExpressions(input);
// Get the user selected trigonometry unit
TrigMode trigMode = settings.TrigUnit;
// Modify trig functions depending on angle unit setting
input = CalculateHelper.UpdateTrigFunctions(input, trigMode);
// Expand conversions between trig units
input = CalculateHelper.ExpandTrigConversions(input, trigMode);
var result = _magesEngine.Interpret(input);
// This could happen for some incorrect queries, like pi(2)
if (result == null)
{
error = Properties.Resources.calculator_expression_not_complete;
return default;
}
result = TransformResult(result);
if (result is string)
{
error = result as string;
return default;
}
if (string.IsNullOrEmpty(result?.ToString()))
{
return default;
}
var decimalResult = Convert.ToDecimal(result, cultureInfo);
var roundedResult = Round(decimalResult);
return new CalculateResult()
{
Result = decimalResult,
RoundedResult = roundedResult,
};
}
public static decimal Round(decimal value)
{
return Math.Round(value, RoundingDigits, MidpointRounding.AwayFromZero);
}
private static dynamic TransformResult(object result)
{
if (result.ToString() == "NaN")
{
return Properties.Resources.calculator_not_a_number;
}
if (result is Function)
{
return Properties.Resources.calculator_expression_not_complete;
}
if (result is double[,])
{
// '[10,10]' is interpreted as array by mages engine
return Properties.Resources.calculator_double_array_returned;
}
return result;
}
}

View File

@@ -0,0 +1,328 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Text.RegularExpressions;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class CalculateHelper
{
private static readonly Regex RegValidExpressChar = new Regex(
@"^(" +
@"%|" +
@"ceil\s*\(|floor\s*\(|exp\s*\(|max\s*\(|min\s*\(|abs\s*\(|log(?:2|10)?\s*\(|ln\s*\(|sqrt\s*\(|pow\s*\(|" +
@"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\([^\)]|" +
@"sin\s*\(|cos\s*\(|tan\s*\(|arcsin\s*\(|arccos\s*\(|arctan\s*\(|" +
@"sinh\s*\(|cosh\s*\(|tanh\s*\(|arsinh\s*\(|arcosh\s*\(|artanh\s*\(|" +
@"rad\s*\(|deg\s*\(|grad\s*\(|" + /* trigonometry unit conversion macros */
@"pi|" +
@"==|~=|&&|\|\||" +
@"((-?(\d+(\.\d*)?)|-?(\.\d+))[Ee](-?\d+))|" + /* expression from CheckScientificNotation between parenthesis */
@"e|[0-9]|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|[\+\-\*\/\^\., ""]|[\(\)\|\!\[\]]" +
@")+$",
RegexOptions.Compiled);
private const string DegToRad = "(pi / 180) * ";
private const string DegToGrad = "(10 / 9) * ";
private const string GradToRad = "(pi / 200) * ";
private const string GradToDeg = "(9 / 10) * ";
private const string RadToDeg = "(180 / pi) * ";
private const string RadToGrad = "(200 / pi) * ";
public static bool InputValid(string input)
{
if (string.IsNullOrWhiteSpace(input))
{
throw new ArgumentNullException(paramName: nameof(input));
}
if (!RegValidExpressChar.IsMatch(input))
{
return false;
}
if (!BracketHelper.IsBracketComplete(input))
{
return false;
}
// If the input ends with a binary operator then it is not a valid input to mages and the Interpret function would throw an exception. Because we expect here that the user has not finished typing we block those inputs.
var trimmedInput = input.TrimEnd();
if (trimmedInput.EndsWith('+') || trimmedInput.EndsWith('-') || trimmedInput.EndsWith('*') || trimmedInput.EndsWith('|') || trimmedInput.EndsWith('\\') || trimmedInput.EndsWith('^') || trimmedInput.EndsWith('=') || trimmedInput.EndsWith('&') || trimmedInput.EndsWith('/') || trimmedInput.EndsWith('%'))
{
return false;
}
return true;
}
public static string FixHumanMultiplicationExpressions(string input)
{
var output = CheckScientificNotation(input);
output = CheckNumberOrConstantThenParenthesisExpr(output);
output = CheckNumberOrConstantThenFunc(output);
output = CheckParenthesisExprThenFunc(output);
output = CheckParenthesisExprThenParenthesisExpr(output);
output = CheckNumberThenConstant(output);
output = CheckConstantThenConstant(output);
return output;
}
private static string CheckScientificNotation(string input)
{
/**
* NOTE: By the time the expression gets to us, it's already in English format.
*
* Regex explanation:
* (-?(\d+({0}\d*)?)|-?({0}\d+)): Used to capture one of two types:
* -?(\d+({0}\d*)?): Captures a decimal number starting with a number (e.g. "-1.23")
* -?({0}\d+): Captures a decimal number without leading number (e.g. ".23")
* e: Captures 'e' or 'E'
* (-?\d+): Captures an integer number (e.g. "-1" or "23")
*/
var p = @"(-?(\d+(\.\d*)?)|-?(\.\d+))e(-?\d+)";
return Regex.Replace(input, p, "($1 * 10^($5))", RegexOptions.IgnoreCase);
}
/*
* num (exp)
* const (exp)
*/
private static string CheckNumberOrConstantThenParenthesisExpr(string input)
{
var output = input;
do
{
input = output;
output = Regex.Replace(input, @"(\d+|pi|e)\s*(\()", m =>
{
if (m.Index > 0 && char.IsLetter(input[m.Index - 1]))
{
return m.Value;
}
return $"{m.Groups[1].Value} * {m.Groups[2].Value}";
});
}
while (output != input);
return output;
}
/*
* num func
* const func
*/
private static string CheckNumberOrConstantThenFunc(string input)
{
var output = input;
do
{
input = output;
output = Regex.Replace(input, @"(\d+|pi|e)\s*([a-zA-Z]+[0-9]*\s*\()", m =>
{
if (input[m.Index] == 'e' && input[m.Index + 1] == 'x' && input[m.Index + 2] == 'p')
{
return m.Value;
}
if (m.Index > 0 && char.IsLetter(input[m.Index - 1]))
{
return m.Value;
}
return $"{m.Groups[1].Value} * {m.Groups[2].Value}";
});
}
while (output != input);
return output;
}
/*
* (exp) func
* func func
*/
private static string CheckParenthesisExprThenFunc(string input)
{
var p = @"(\))\s*([a-zA-Z]+[0-9]*\s*\()";
var r = "$1 * $2";
return Regex.Replace(input, p, r);
}
/*
* (exp) (exp)
* func (exp)
*/
private static string CheckParenthesisExprThenParenthesisExpr(string input)
{
var p = @"(\))\s*(\()";
var r = "$1 * $2";
return Regex.Replace(input, p, r);
}
/*
* num const
*/
private static string CheckNumberThenConstant(string input)
{
var output = input;
do
{
input = output;
output = Regex.Replace(input, @"(\d+)\s*(pi|e)", m =>
{
if (m.Index > 0 && char.IsLetter(input[m.Index - 1]))
{
return m.Value;
}
return $"{m.Groups[1].Value} * {m.Groups[2].Value}";
});
}
while (output != input);
return output;
}
/*
* const const
*/
private static string CheckConstantThenConstant(string input)
{
var output = input;
do
{
input = output;
output = Regex.Replace(input, @"(pi|e)\s*(pi|e)", m =>
{
if (m.Index > 0 && char.IsLetter(input[m.Index - 1]))
{
return m.Value;
}
return $"{m.Groups[1].Value} * {m.Groups[2].Value}";
});
}
while (output != input);
return output;
}
// Gets the index of the closing bracket of a function
private static int FindClosingBracketIndex(string input, int start)
{
var bracketCount = 0; // Set count to zero
for (var i = start; i < input.Length; i++)
{
if (input[i] == '(')
{
bracketCount++;
}
else if (input[i] == ')')
{
bracketCount--;
if (bracketCount == 0)
{
return i;
}
}
}
return -1; // Unmatched brackets
}
private static string ModifyTrigFunction(string input, string function, string modification)
{
// Get the RegEx pattern to match, depending on whether the function is inverse or normal
var pattern = function.StartsWith("arc", StringComparison.Ordinal) ? string.Empty : @"(?<!c)";
pattern += $@"{function}\s*\(";
var index = 0; // Index for match to ensure that the same match is not found twice
Regex regex = new Regex(pattern);
Match match;
while ((match = regex.Match(input, index)).Success)
{
index = match.Index + match.Groups[0].Length + modification.Length; // Get the next index to look from for further matches
var endIndex = FindClosingBracketIndex(input, match.Index + match.Groups[0].Length - 1); // Find the index of the closing bracket of the function
// If no valid bracket index was found, try the next match
if (endIndex == -1)
{
continue;
}
var argument = input.Substring(match.Index + match.Groups[0].Length, endIndex - (match.Index + match.Groups[0].Length)); // Extract the argument between the brackets
var replaced = function.StartsWith("arc", StringComparison.Ordinal) ? $"{modification}({match.Groups[0].Value}{argument}))" : $"{match.Groups[0].Value}{modification}({argument}))"; // The string to substitute in, handles differing formats of inverse functions
input = input.Remove(match.Index, endIndex - match.Index + 1); // Remove the match from the input
input = input.Insert(match.Index, replaced); // Substitute with the new string
}
return input;
}
public static string UpdateTrigFunctions(string input, CalculateEngine.TrigMode mode)
{
var modifiedInput = input;
if (mode == CalculateEngine.TrigMode.Degrees)
{
modifiedInput = ModifyTrigFunction(modifiedInput, "sin", DegToRad);
modifiedInput = ModifyTrigFunction(modifiedInput, "cos", DegToRad);
modifiedInput = ModifyTrigFunction(modifiedInput, "tan", DegToRad);
modifiedInput = ModifyTrigFunction(modifiedInput, "arcsin", RadToDeg);
modifiedInput = ModifyTrigFunction(modifiedInput, "arccos", RadToDeg);
modifiedInput = ModifyTrigFunction(modifiedInput, "arctan", RadToDeg);
}
else if (mode == CalculateEngine.TrigMode.Gradians)
{
modifiedInput = ModifyTrigFunction(modifiedInput, "sin", GradToRad);
modifiedInput = ModifyTrigFunction(modifiedInput, "cos", GradToRad);
modifiedInput = ModifyTrigFunction(modifiedInput, "tan", GradToRad);
modifiedInput = ModifyTrigFunction(modifiedInput, "arcsin", RadToGrad);
modifiedInput = ModifyTrigFunction(modifiedInput, "arccos", RadToGrad);
modifiedInput = ModifyTrigFunction(modifiedInput, "arctan", RadToGrad);
}
return modifiedInput;
}
private static string ModifyMathFunction(string input, string function, string modification)
{
// Create the pattern to match the function, opening bracket, and any spaces in between
var pattern = $@"{function}\s*\(";
return Regex.Replace(input, pattern, modification + "(");
}
public static string ExpandTrigConversions(string input, CalculateEngine.TrigMode mode)
{
var modifiedInput = input;
// Expand "rad", "deg" and "grad" to their respective conversions for the current trig unit
if (mode == CalculateEngine.TrigMode.Radians)
{
modifiedInput = ModifyMathFunction(modifiedInput, "deg", DegToRad);
modifiedInput = ModifyMathFunction(modifiedInput, "grad", GradToRad);
modifiedInput = ModifyMathFunction(modifiedInput, "rad", string.Empty);
}
else if (mode == CalculateEngine.TrigMode.Degrees)
{
modifiedInput = ModifyMathFunction(modifiedInput, "deg", string.Empty);
modifiedInput = ModifyMathFunction(modifiedInput, "grad", GradToDeg);
modifiedInput = ModifyMathFunction(modifiedInput, "rad", RadToDeg);
}
else if (mode == CalculateEngine.TrigMode.Gradians)
{
modifiedInput = ModifyMathFunction(modifiedInput, "deg", DegToGrad);
modifiedInput = ModifyMathFunction(modifiedInput, "grad", string.Empty);
modifiedInput = ModifyMathFunction(modifiedInput, "rad", RadToGrad);
}
return modifiedInput;
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public struct CalculateResult : IEquatable<CalculateResult>
{
public decimal? Result { get; set; }
public decimal? RoundedResult { get; set; }
public bool Equals(CalculateResult other)
{
return Result == other.Result && RoundedResult == other.RoundedResult;
}
public override bool Equals(object obj)
{
return obj is CalculateResult other && Equals(other);
}
public override int GetHashCode()
{
return HashCode.Combine(Result, RoundedResult);
}
public static bool operator ==(CalculateResult left, CalculateResult right)
{
return left.Equals(right);
}
public static bool operator !=(CalculateResult left, CalculateResult right)
{
return !(left == right);
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class CalculatorIcons
{
public static IconInfo ResultIcon => new("\uE94E");
public static IconInfo SaveIcon => new("\uE74E");
public static IconInfo ErrorIcon => new("\uE783");
public static IconInfo ProviderIcon => IconHelpers.FromRelativePath("Assets\\Calculator.svg");
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal static class ErrorHandler
{
/// <summary>
/// Method to handles errors while calculating
/// </summary>
/// <param name="isFallbackSearch">Bool to indicate if it is a fallback query.</param>
/// <param name="queryInput">User input as string including the action keyword.</param>
/// <param name="errorMessage">Error message if applicable.</param>
/// <param name="exception">Exception if applicable.</param>
/// <returns>List of results to show. Either an error message or an empty list.</returns>
/// <exception cref="ArgumentException">Thrown if <paramref name="errorMessage"/> and <paramref name="exception"/> are both filled with their default values.</exception>
internal static ListItem OnError(bool isFallbackSearch, string queryInput, string errorMessage, Exception exception = default)
{
string userMessage;
if (errorMessage != default)
{
Logger.LogError($"Failed to calculate <{queryInput}>: {errorMessage}");
userMessage = errorMessage;
}
else if (exception != default)
{
Logger.LogError($"Exception when query for <{queryInput}>", exception);
userMessage = exception.Message;
}
else
{
throw new ArgumentException("The arguments error and exception have default values. One of them has to be filled with valid error data (error message/exception)!");
}
return isFallbackSearch ? null : CreateErrorResult(userMessage);
}
private static ListItem CreateErrorResult(string errorMessage)
{
return new ListItem(new NoOpCommand())
{
Title = Properties.Resources.calculator_calculation_failed_title,
Subtitle = errorMessage,
Icon = CalculatorIcons.ErrorIcon,
};
}
}

View File

@@ -0,0 +1,144 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
/// <summary>
/// Tries to convert all numbers in a text from one culture format to another.
/// </summary>
public class NumberTranslator
{
private readonly CultureInfo sourceCulture;
private readonly CultureInfo targetCulture;
private readonly Regex splitRegexForSource;
private readonly Regex splitRegexForTarget;
private NumberTranslator(CultureInfo sourceCulture, CultureInfo targetCulture)
{
this.sourceCulture = sourceCulture;
this.targetCulture = targetCulture;
splitRegexForSource = GetSplitRegex(this.sourceCulture);
splitRegexForTarget = GetSplitRegex(this.targetCulture);
}
/// <summary>
/// Create a new <see cref="NumberTranslator"/>.
/// </summary>
/// <param name="sourceCulture">source culture</param>
/// <param name="targetCulture">target culture</param>
/// <returns>Number translator for target culture</returns>
public static NumberTranslator Create(CultureInfo sourceCulture, CultureInfo targetCulture)
{
ArgumentNullException.ThrowIfNull(sourceCulture);
ArgumentNullException.ThrowIfNull(targetCulture);
return new NumberTranslator(sourceCulture, targetCulture);
}
/// <summary>
/// Translate from source to target culture.
/// </summary>
/// <param name="input">input string to translate</param>
/// <returns>translated string</returns>
public string Translate(string input)
{
return Translate(input, sourceCulture, targetCulture, splitRegexForSource);
}
/// <summary>
/// Translate from target to source culture.
/// </summary>
/// <param name="input">input string to translate back to source culture</param>
/// <returns>source culture string</returns>
public string TranslateBack(string input)
{
return Translate(input, targetCulture, sourceCulture, splitRegexForTarget);
}
private static string Translate(string input, CultureInfo cultureFrom, CultureInfo cultureTo, Regex splitRegex)
{
var outputBuilder = new StringBuilder();
var hexRegex = new Regex(@"(?:(0x[\da-fA-F]+))");
var hexTokens = hexRegex.Split(input);
foreach (var hexToken in hexTokens)
{
if (hexToken.StartsWith("0x", StringComparison.InvariantCultureIgnoreCase))
{
// Mages engine has issues processing large hex number (larger than 7 hex digits + 0x prefix = 9 characters). So we convert it to decimal and pass it to the engine.
if (hexToken.Length > 9)
{
try
{
var num = Convert.ToInt64(hexToken, 16);
var numStr = num.ToString(cultureFrom);
outputBuilder.Append(numStr);
}
catch (Exception)
{
outputBuilder.Append(hexToken);
}
}
else
{
outputBuilder.Append(hexToken);
}
continue;
}
var tokens = splitRegex.Split(hexToken);
foreach (var token in tokens)
{
var leadingZeroCount = 0;
// Count leading zero characters.
foreach (var c in token)
{
if (c != '0')
{
break;
}
leadingZeroCount++;
}
// number is all zero characters. no need to add zero characters at the end.
if (token.Length == leadingZeroCount)
{
leadingZeroCount = 0;
}
decimal number;
outputBuilder.Append(
decimal.TryParse(token, NumberStyles.Number, cultureFrom, out number)
? (new string('0', leadingZeroCount) + number.ToString(cultureTo))
: token.Replace(cultureFrom.TextInfo.ListSeparator, cultureTo.TextInfo.ListSeparator));
}
}
return outputBuilder.ToString();
}
private static Regex GetSplitRegex(CultureInfo culture)
{
var splitPattern = $"((?:\\d|{Regex.Escape(culture.NumberFormat.NumberDecimalSeparator)}";
if (!string.IsNullOrEmpty(culture.NumberFormat.NumberGroupSeparator))
{
splitPattern += $"|{Regex.Escape(culture.NumberFormat.NumberGroupSeparator)}";
}
splitPattern += ")+)";
return new Regex(splitPattern);
}
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Text;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static partial class QueryHelper
{
public static ListItem Query(string query, SettingsManager settings, bool isFallbackSearch, TypedEventHandler<object, object> handleSave = null)
{
ArgumentNullException.ThrowIfNull(query);
if (!isFallbackSearch)
{
ArgumentNullException.ThrowIfNull(handleSave);
}
CultureInfo inputCulture = settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
CultureInfo outputCulture = settings.OutputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
// Happens if the user has only typed the action key so far
if (string.IsNullOrEmpty(query))
{
return null;
}
NumberTranslator translator = NumberTranslator.Create(inputCulture, new CultureInfo("en-US"));
var input = translator.Translate(query.Normalize(NormalizationForm.FormKC));
if (!CalculateHelper.InputValid(input))
{
return null;
}
try
{
// Using CurrentUICulture since this is user facing
var result = CalculateEngine.Interpret(settings, input, outputCulture, out var errorMessage);
// This could happen for some incorrect queries, like pi(2)
if (result.Equals(default(CalculateResult)))
{
// If errorMessage is not default then do error handling
return errorMessage == default ? null : ErrorHandler.OnError(isFallbackSearch, query, errorMessage);
}
if (isFallbackSearch)
{
// Fallback search
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, query);
}
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, query, handleSave);
}
catch (Mages.Core.ParseException)
{
// Invalid input
return ErrorHandler.OnError(isFallbackSearch, query, Properties.Resources.calculator_expression_not_complete);
}
catch (OverflowException)
{
// Result to big to convert to decimal
return ErrorHandler.OnError(isFallbackSearch, query, Properties.Resources.calculator_not_covert_to_decimal);
}
catch (Exception e)
{
// Any other crash occurred
// We want to keep the process alive if any the mages library throws any exceptions.
return ErrorHandler.OnError(isFallbackSearch, query, default, e);
}
}
}

View File

@@ -0,0 +1,103 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class ResultHelper
{
public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query, TypedEventHandler<object, object> handleSave)
{
// Return null when the expression is not a valid calculator query.
if (roundedResult == null)
{
return null;
}
var result = roundedResult?.ToString(outputCulture);
// Create a SaveCommand and subscribe to the SaveRequested event
// This can append the result to the history list.
var saveCommand = new SaveCommand(result);
saveCommand.SaveRequested += handleSave;
var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query);
return new ListItem(saveCommand)
{
// Using CurrentCulture since this is user facing
Icon = CalculatorIcons.ResultIcon,
Title = result,
Subtitle = query,
TextToSuggest = result,
MoreCommands = [
new CommandContextItem(copyCommandItem.Command)
{
Icon = copyCommandItem.Icon,
Title = copyCommandItem.Title,
Subtitle = copyCommandItem.Subtitle,
},
..copyCommandItem.MoreCommands,
],
};
}
public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query)
{
// Return null when the expression is not a valid calculator query.
if (roundedResult == null)
{
return null;
}
var decimalResult = roundedResult?.ToString(outputCulture);
List<CommandContextItem> context = [];
if (decimal.IsInteger((decimal)roundedResult))
{
var i = decimal.ToInt64((decimal)roundedResult);
try
{
var hexResult = "0x" + i.ToString("X", outputCulture);
context.Add(new CommandContextItem(new CopyTextCommand(hexResult) { Name = Properties.Resources.calculator_copy_hex })
{
Title = hexResult,
});
}
catch (Exception ex)
{
Logger.LogError("Error parsing hex format", ex);
}
try
{
var binaryResult = "0b" + i.ToString("B", outputCulture);
context.Add(new CommandContextItem(new CopyTextCommand(binaryResult) { Name = Properties.Resources.calculator_copy_binary })
{
Title = binaryResult,
});
}
catch (Exception ex)
{
Logger.LogError("Error parsing binary format", ex);
}
}
return new ListItem(new CopyTextCommand(decimalResult))
{
// Using CurrentCulture since this is user facing
Title = decimalResult,
Subtitle = query,
TextToSuggest = decimalResult,
MoreCommands = context.ToArray(),
};
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed partial class SaveCommand : InvokableCommand
{
private readonly string _result;
public event TypedEventHandler<object, object> SaveRequested;
public SaveCommand(string result)
{
Name = Resources.calculator_save_command_name;
Icon = CalculatorIcons.SaveIcon;
_result = result;
}
public override ICommandResult Invoke()
{
SaveRequested?.Invoke(this, this);
ClipboardHelper.SetText(_result);
return CommandResult.KeepOpen();
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.IO;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public class SettingsManager : JsonSettingsManager
{
private static readonly string _namespace = "calculator";
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
private static readonly List<ChoiceSetSetting.Choice> _trigUnitChoices = new()
{
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_trig_unit_radians, "0"),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_trig_unit_degrees, "1"),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_trig_unit_gradians, "2"),
};
private readonly ChoiceSetSetting _trigUnit = new(
Namespaced(nameof(TrigUnit)),
Properties.Resources.calculator_settings_trig_unit_mode,
Properties.Resources.calculator_settings_trig_unit_mode_description,
_trigUnitChoices);
private readonly ToggleSetting _inputUseEnNumberFormat = new(
Namespaced(nameof(InputUseEnglishFormat)),
Properties.Resources.calculator_settings_in_en_format,
Properties.Resources.calculator_settings_in_en_format_description,
false);
private readonly ToggleSetting _outputUseEnNumberFormat = new(
Namespaced(nameof(OutputUseEnglishFormat)),
Properties.Resources.calculator_settings_out_en_format,
Properties.Resources.calculator_settings_out_en_format_description,
false);
public CalculateEngine.TrigMode TrigUnit
{
get
{
if (_trigUnit.Value == null || string.IsNullOrEmpty(_trigUnit.Value))
{
return CalculateEngine.TrigMode.Radians;
}
var success = int.TryParse(_trigUnit.Value, out var result);
if (!success)
{
return CalculateEngine.TrigMode.Radians;
}
switch (result)
{
case 0:
return CalculateEngine.TrigMode.Radians;
case 1:
return CalculateEngine.TrigMode.Degrees;
case 2:
return CalculateEngine.TrigMode.Gradians;
default:
return CalculateEngine.TrigMode.Radians;
}
}
}
public bool InputUseEnglishFormat => _inputUseEnNumberFormat.Value;
public bool OutputUseEnglishFormat => _outputUseEnNumberFormat.Value;
internal static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "settings.json");
}
public SettingsManager()
{
FilePath = SettingsJsonPath();
Settings.Add(_trigUnit);
Settings.Add(_inputUseEnNumberFormat);
Settings.Add(_outputUseEnNumberFormat);
// Load settings from file upon initialization
LoadSettings();
Settings.SettingsChanged += (s, a) => this.SaveSettings();
}
}