CmdPal: Add precomputed fuzzy string matching to Command Palette (#44090)

## Summary of the Pull Request

This PR improves fuzzy matching in Command Palette by:
- Precomputing normalized strings to enable faster comparisons
- Reducing memory allocations during matching, effectively down to zero

It also introduces several behavioral improvements:
- Strips diacritics from the normalized search string to improve
matching across languages
- Suppresses the same-case bonus when the query consists entirely of
lowercase characters -- reflecting typical user input patterns
- Allows skipping word separators -- enabling queries like Power Point
to match PowerPoint

This implementation is currently kept internal and is used only on the
home page. For other scenarios, the `FuzzyStringMatcher` from
`Microsoft.CommandPalette.Extensions.Toolkit` is being improved instead.

`PrecomputedFuzzyMatcher` offers up to a 100× performance improvement
over the current `FuzzyStringMatcher`, and approximately 2–5× better
performance compared to the improved version.

The improvement might seem small, but it adds up and becomes quite
noticeable when filtering the entire home page—whether the user starts a
new search or changes the query non-incrementally (e.g., using
backspace).


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

- [x] Closes: #45226
- [x] Closes: #44066
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **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-02-09 20:37:59 +01:00
committed by GitHub
parent 740dbf5699
commit 7477b561a1
38 changed files with 2626 additions and 138 deletions

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -43,38 +44,34 @@ public partial class MainListPageResultFactoryTests
public override string ToString() => Title;
}
private static Scored<IListItem> S(string title, int score)
private static RoScored<IListItem> S(string title, int score)
{
return new Scored<IListItem>
{
Score = score,
Item = new MockListItem { Title = title },
};
return new RoScored<IListItem>(score: score, item: new MockListItem { Title = title });
}
[TestMethod]
public void Merge_PrioritizesListsCorrectly()
{
var filtered = new List<Scored<IListItem>>
var filtered = new List<RoScored<IListItem>>
{
S("F1", 100),
S("F2", 50),
};
var scoredFallback = new List<Scored<IListItem>>
var scoredFallback = new List<RoScored<IListItem>>
{
S("SF1", 100),
S("SF2", 60),
};
var apps = new List<Scored<IListItem>>
var apps = new List<RoScored<IListItem>>
{
S("A1", 100),
S("A2", 55),
};
// Fallbacks are not scored.
var fallbacks = new List<Scored<IListItem>>
var fallbacks = new List<RoScored<IListItem>>
{
S("FB1", 0),
S("FB2", 0),
@@ -104,7 +101,7 @@ public partial class MainListPageResultFactoryTests
[TestMethod]
public void Merge_AppliesAppLimit()
{
var apps = new List<Scored<IListItem>>
var apps = new List<RoScored<IListItem>>
{
S("A1", 100),
S("A2", 90),
@@ -126,7 +123,7 @@ public partial class MainListPageResultFactoryTests
[TestMethod]
public void Merge_FiltersEmptyFallbacks()
{
var fallbacks = new List<Scored<IListItem>>
var fallbacks = new List<RoScored<IListItem>>
{
S("FB1", 0),
S("FB3", 0),

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
@@ -263,10 +264,12 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
};
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history);
var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history);
var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history);
var scoreA = MainListPage.ScoreTopLevelItem(q, items[0], history, fuzzyMatcher);
var scoreB = MainListPage.ScoreTopLevelItem(q, items[1], history, fuzzyMatcher);
var scoreC = MainListPage.ScoreTopLevelItem(q, items[2], history, fuzzyMatcher);
// Assert
// All of these equally match the query, and they're all in the same bucket,
@@ -296,6 +299,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
return history;
}
private static IPrecomputedFuzzyMatcher CreateMatcher()
{
return new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
}
private sealed record ScoredItem(ListItemMock Item, int Score)
{
public string Title => Item.Title;
@@ -337,9 +345,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
var q = fuzzyMatcher.PrecomputeQuery("C");
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, emptyHistory, fuzzyMatcher)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items");
for (var i = 0; i < unweightedScores.Count; i++)
{
@@ -380,7 +390,10 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
@@ -398,6 +411,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
// Add extra uses of VS Code to try and push it above Terminal
for (var i = 0; i < 10; i++)
@@ -405,7 +420,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
history.AddHistoryItem(items[1].Id);
}
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
@@ -423,6 +438,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
// We're gonna run this test and keep adding more uses of VS Code till
// it breaks past Command Prompt
@@ -431,7 +448,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
{
history.AddHistoryItem(vsCodeId);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);