CmdPal: Make Calculator Great Again (#44594)

## Summary of the Pull Request

This PR continues the tradition of alphabetical progress. After
[MBGA](#41961), we move on to **MCBA — Make Calculator Better Again!**

- Introduces limited automatic correction and completion of expressions.
- The goal is to allow uninterrupted typing and avoid disruptions when a
partially entered expression is temporarily invalid (which previously
caused the result to be replaced by an error message or hidden by the
fallback).
  - The implementation intentionally aims for a sweet spot:
    - Ignores trailing binary operators.
    - Automatically closes all opened parentheses.
- It is not exhaustive; for example, incomplete constants or functions
may still result in an invalid query.
- Copy current result to the search bar.
- Adds an option to copy the current result to the search bar when the
user types `=` at the end of the expression.
  - Adds a new menu item for the same action.
  - Fixes the **Save** command to also copy the result to the query.
- Adds support for the `factorial(x)` function and the `x!` expression.
- Factorial calculations are supported up to `170!` (limited by
`double`), but display is constrained by decimal conversion and allows
direct display of results up to `20!`.
- Adds support for the `sign(x)` function.
- Adds support for the `π` symbol as an alternative to the `pi`
constant.
- Adds a context menu item to the result list item and fallback that
displays the octal representation of the result.
- Implements beautification of the query:
- Converts technical symbols such as `*` or `/` to `×` or `÷`,
respectively.
- Not enabled for fallbacks for now, since the item text should match
the query to keep the score intact.
- Implements additional normalization of symbols in the query:
  - Percent: `%`, `%`, `﹪`
  - Minus: `−`, `-`, `–`, `—`
  - Factorial: `!`, `!`
- Multiplication: `*`, `×`, `∗`, `·`, `⋅`, `✕`, `✖`, `\u2062` (invisible
times)
  - Division: `/`, `÷`, ``, `:`
- Allows use of `²` and `³` as alternatives to `^2` and `^3`.
- Updates the unit test that was culture sensitive to force en-US output
(not an actual fix, but at least it clears false positive for now)
- Fixes pre-parsing of scientific notation to prevent capturing minus
sign as part of it.
- Fixes normalization/rounding of the result, so it can display small
values (the current solution turned it into a string with scientific
notation and couldn't parse it back).
- Updates test with new cases

## Pictures? Moving!

Previous behavior:


https://github.com/user-attachments/assets/ebcdcd85-797a-44f9-a8b1-a0f2f33c6b42

New behavior:


https://github.com/user-attachments/assets/5bd94663-a0d0-4d7d-8032-1030e79926c3





<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #43481
- [x] Closes: #43460
- [x] Closes: #42078
- [x] Closes: #41839
- [x] Closes: #39659
- [x] Closes: #40502
- [x] Related to: #41715
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2026-01-29 04:23:39 +01:00
committed by GitHub
parent 4694e99477
commit 0de2af77ac
21 changed files with 721 additions and 57 deletions

View File

@@ -19,6 +19,7 @@ public class CloseOnEnterTests
{
var settings = new Settings(closeOnEnter: true);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResult(
4m,
@@ -26,7 +27,8 @@ public class CloseOnEnterTests
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave);
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
@@ -41,6 +43,7 @@ public class CloseOnEnterTests
{
var settings = new Settings(closeOnEnter: false);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResult(
4m,
@@ -48,7 +51,8 @@ public class CloseOnEnterTests
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave);
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(SaveCommand));

View File

@@ -65,6 +65,9 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
["log10(3)", 0.47712125471966M],
["ln(e)", 1M],
["cosh(0)", 1M],
["1*10^(-5)", 0.00001M],
["1*10^(-15)", 0.0000000000000001M],
["1*10^(-16)", 0M],
];
[DataTestMethod]
@@ -192,9 +195,11 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
private static IEnumerable<object[]> Interpret_MustReturnExpectedResult_WhenCalled_Data =>
[
// ["factorial(5)", 120M], ToDo: this don't support now
// ["sign(-2)", -1M],
// ["sign(2)", +1M],
["factorial(5)", 120M],
["5!", 120M],
["(2+3)!", 120M],
["sign(-2)", -1M],
["sign(2)", +1M],
["abs(-2)", 2M],
["abs(2)", 2M],
["0+(1*2)/(0+1)", 2M], // Validate that division by "(0+1)" is not interpret as division by zero.
@@ -221,6 +226,9 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
[
["0.2E1", "en-US", 2M],
["0,2E1", "pt-PT", 2M],
["3.5e3 + 2.5E2", "en-US", 3750M],
["3,5e3 + 2,5E2", "fr-FR", 3750M],
["1E3-1E3/1.5", "en-US", 333.333333333333371M],
];
[DataTestMethod]
@@ -389,4 +397,17 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
Assert.IsNotNull(result);
Assert.AreEqual(expectedResult, result);
}
[DataTestMethod]
[DataRow("171!")]
[DataRow("1000!")]
public void Interpret_ReturnsError_WhenValueOverflowsDecimal(string input)
{
var settings = new Settings();
CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.IsFalse(string.IsNullOrEmpty(error));
Assert.AreNotEqual(null, error);
}
}

View File

@@ -0,0 +1,38 @@
// 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.Helper;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class IncompleteQueryTests
{
[DataTestMethod]
[DataRow("2+2+", "2+2")]
[DataRow("2+2*", "2+2")]
[DataRow("sin(30", "sin(30)")]
[DataRow("((1+2)", "((1+2))")]
[DataRow("2*(3+4", "2*(3+4)")]
[DataRow("(1+2", "(1+2)")]
[DataRow("2*(", "2")]
[DataRow("2*(((", "2")]
public void TestTryGetIncompleteQuerySuccess(string input, string expected)
{
var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery);
Assert.IsTrue(result);
Assert.AreEqual(expected, newQuery);
}
[DataTestMethod]
[DataRow("")]
[DataRow(" ")]
public void TestTryGetIncompleteQueryFail(string input)
{
var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery);
Assert.IsFalse(result);
Assert.AreEqual(input, newQuery);
}
}

View File

@@ -0,0 +1,27 @@
// 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.Helper;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class QueryHelperTests
{
[DataTestMethod]
[DataRow("2²", "4")]
[DataRow("2³", "8")]
[DataRow("2", "2")]
[DataRow("2\u00A0*\u00A02", "4")] // Non-breaking space
[DataRow("20:10", "2")] // Colon as division
public void Interpret_HandlesNormalizedInputs(string input, string expected)
{
var settings = new Settings();
var result = QueryHelper.Query(input, settings, false, out _, (_, _) => { });
Assert.IsNotNull(result);
Assert.AreEqual(expected, result.Title);
}
}

View File

@@ -6,7 +6,6 @@ using System.Linq;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Pages;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
@@ -72,7 +71,7 @@ public class QueryTests : CommandPaletteUnitTestBase
[DataRow("sin(60)", "0.809016", CalculateEngine.TrigMode.Gradians)]
public void TrigModeSettingsTest(string input, string expected, CalculateEngine.TrigMode trigMode)
{
var settings = new Settings(trigUnit: trigMode);
var settings = new Settings(trigUnit: trigMode, outputUseEnglishFormat: true);
var page = new CalculatorListPage(settings);

View File

@@ -12,17 +12,26 @@ public class Settings : ISettingsInterface
private readonly bool inputUseEnglishFormat;
private readonly bool outputUseEnglishFormat;
private readonly bool closeOnEnter;
private readonly bool copyResultToSearchBarIfQueryEndsWithEqualSign;
private readonly bool autoFixQuery;
private readonly bool inputNormalization;
public Settings(
CalculateEngine.TrigMode trigUnit = CalculateEngine.TrigMode.Radians,
bool inputUseEnglishFormat = false,
bool outputUseEnglishFormat = false,
bool closeOnEnter = true)
bool closeOnEnter = true,
bool copyResultToSearchBarIfQueryEndsWithEqualSign = true,
bool autoFixQuery = true,
bool inputNormalization = true)
{
this.trigUnit = trigUnit;
this.inputUseEnglishFormat = inputUseEnglishFormat;
this.outputUseEnglishFormat = outputUseEnglishFormat;
this.closeOnEnter = closeOnEnter;
this.copyResultToSearchBarIfQueryEndsWithEqualSign = copyResultToSearchBarIfQueryEndsWithEqualSign;
this.autoFixQuery = autoFixQuery;
this.inputNormalization = inputNormalization;
}
public CalculateEngine.TrigMode TrigUnit => trigUnit;
@@ -32,4 +41,10 @@ public class Settings : ISettingsInterface
public bool OutputUseEnglishFormat => outputUseEnglishFormat;
public bool CloseOnEnter => closeOnEnter;
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => copyResultToSearchBarIfQueryEndsWithEqualSign;
public bool AutoFixQuery => autoFixQuery;
public bool InputNormalization => inputNormalization;
}