mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-06 03:07:04 +02:00
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:
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user