mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +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:
@@ -8,6 +8,7 @@ using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Core.Common.Text;
|
||||
using Microsoft.CmdPal.Core.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.Ext.Apps;
|
||||
using Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
@@ -24,7 +25,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||
/// This class encapsulates the data we load from built-in providers and extensions to use within the same extension-UI system for a <see cref="ListPage"/>.
|
||||
/// TODO: Need to think about how we structure/interop for the page -> section -> item between the main setup, the extensions, and our viewmodels.
|
||||
/// </summary>
|
||||
public partial class MainListPage : DynamicListPage,
|
||||
public sealed partial class MainListPage : DynamicListPage,
|
||||
IRecipient<ClearSearchMessage>,
|
||||
IRecipient<UpdateFallbackItemsMessage>, IDisposable
|
||||
{
|
||||
@@ -32,13 +33,18 @@ public partial class MainListPage : DynamicListPage,
|
||||
private readonly AliasManager _aliasManager;
|
||||
private readonly SettingsModel _settings;
|
||||
private readonly AppStateModel _appStateModel;
|
||||
private List<Scored<IListItem>>? _filteredItems;
|
||||
private List<Scored<IListItem>>? _filteredApps;
|
||||
private readonly ScoringFunction<IListItem> _scoringFunction;
|
||||
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
|
||||
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
|
||||
|
||||
private RoScored<IListItem>[]? _filteredItems;
|
||||
private RoScored<IListItem>[]? _filteredApps;
|
||||
|
||||
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
|
||||
// asynchronously, so scoring must happen lazily when GetItems is called.
|
||||
private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
|
||||
private IEnumerable<Scored<IListItem>>? _fallbackItems;
|
||||
private IEnumerable<RoScored<IListItem>>? _scoredFallbackItems;
|
||||
private IEnumerable<RoScored<IListItem>>? _fallbackItems;
|
||||
|
||||
private bool _includeApps;
|
||||
private bool _filteredItemsIncludesApps;
|
||||
private int _appResultLimit = 10;
|
||||
@@ -48,7 +54,12 @@ public partial class MainListPage : DynamicListPage,
|
||||
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
|
||||
public MainListPage(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel)
|
||||
public MainListPage(
|
||||
TopLevelCommandManager topLevelCommandManager,
|
||||
SettingsModel settings,
|
||||
AliasManager aliasManager,
|
||||
AppStateModel appStateModel,
|
||||
IFuzzyMatcherProvider fuzzyMatcherProvider)
|
||||
{
|
||||
Title = Resources.builtin_home_name;
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
|
||||
@@ -58,6 +69,10 @@ public partial class MainListPage : DynamicListPage,
|
||||
_aliasManager = aliasManager;
|
||||
_appStateModel = appStateModel;
|
||||
_tlcManager = topLevelCommandManager;
|
||||
_fuzzyMatcherProvider = fuzzyMatcherProvider;
|
||||
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current);
|
||||
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks);
|
||||
|
||||
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
|
||||
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
|
||||
|
||||
@@ -190,8 +205,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
var timer = new Stopwatch();
|
||||
timer.Start();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
@@ -354,15 +368,14 @@ public partial class MainListPage : DynamicListPage,
|
||||
|
||||
if (_includeApps)
|
||||
{
|
||||
var allNewApps = AllAppsCommandProvider.Page.GetItems().ToList();
|
||||
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
|
||||
|
||||
// We need to remove pinned apps from allNewApps so they don't show twice.
|
||||
var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
|
||||
|
||||
if (pinnedApps.Length > 0)
|
||||
{
|
||||
newApps = allNewApps.Where(w =>
|
||||
pinnedApps.IndexOf(((AppListItem)w).AppIdentifier) < 0);
|
||||
newApps = allNewApps.Where(w => pinnedApps.IndexOf(w.AppIdentifier) < 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -376,11 +389,10 @@ public partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
}
|
||||
|
||||
var history = _appStateModel.RecentCommands!;
|
||||
Func<string, IListItem, int> scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); };
|
||||
var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
|
||||
|
||||
// Produce a list of everything that matches the current filter.
|
||||
_filteredItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, scoreItem)];
|
||||
_filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
@@ -388,21 +400,14 @@ public partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
|
||||
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id));
|
||||
_scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_scoredFallbackItems = ListHelpers.FilterListWithScores<IListItem>(newFallbacksForScoring ?? [], SearchText, scoreItem);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Func<string, IListItem, int> scoreFallbackItem = (a, b) => { return ScoreFallbackItem(a, b, _settings.FallbackRanks); };
|
||||
_fallbackItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], SearchText, scoreFallbackItem)];
|
||||
_fallbackItems = InternalListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
@@ -412,18 +417,7 @@ public partial class MainListPage : DynamicListPage,
|
||||
// Produce a list of filtered apps with the appropriate limit
|
||||
if (newApps.Any())
|
||||
{
|
||||
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, scoreItem);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// We'll apply this limit in the GetItems method after merging with commands
|
||||
// but we need to know the limit now to avoid re-scoring apps
|
||||
var appLimit = AllAppsCommandProvider.TopLevelResultLimit;
|
||||
|
||||
_filteredApps = [.. scoredApps];
|
||||
_filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
@@ -431,10 +425,15 @@ public partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
}
|
||||
|
||||
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
|
||||
Logger.LogDebug($"Filter with '{newSearch}' in {filterDoneTimestamp}ms");
|
||||
|
||||
RaiseItemsChanged();
|
||||
|
||||
timer.Stop();
|
||||
Logger.LogDebug($"Filter with '{newSearch}' in {timer.ElapsedMilliseconds}ms");
|
||||
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
|
||||
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
|
||||
|
||||
stopwatch.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +477,11 @@ public partial class MainListPage : DynamicListPage,
|
||||
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
|
||||
// fact that we want fallback handlers down-weighted, so that they don't
|
||||
// _always_ show up first.
|
||||
internal static int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem, IRecentCommandsManager history)
|
||||
internal static int ScoreTopLevelItem(
|
||||
in FuzzyQuery query,
|
||||
IListItem topLevelOrAppItem,
|
||||
IRecentCommandsManager history,
|
||||
IPrecomputedFuzzyMatcher precomputedFuzzyMatcher)
|
||||
{
|
||||
var title = topLevelOrAppItem.Title;
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
@@ -486,94 +489,80 @@ public partial class MainListPage : DynamicListPage,
|
||||
return 0;
|
||||
}
|
||||
|
||||
var isWhiteSpace = string.IsNullOrWhiteSpace(query);
|
||||
|
||||
var isFallback = false;
|
||||
var isAliasSubstringMatch = false;
|
||||
var isAliasMatch = false;
|
||||
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
|
||||
|
||||
var extensionDisplayName = string.Empty;
|
||||
FuzzyTarget? extensionDisplayNameTarget = null;
|
||||
if (topLevelOrAppItem is TopLevelViewModel topLevel)
|
||||
{
|
||||
isFallback = topLevel.IsFallback;
|
||||
extensionDisplayNameTarget = topLevel.GetExtensionNameTarget(precomputedFuzzyMatcher);
|
||||
|
||||
if (topLevel.HasAlias)
|
||||
{
|
||||
var alias = topLevel.AliasText;
|
||||
isAliasMatch = alias == query;
|
||||
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase);
|
||||
isAliasMatch = alias == query.Original;
|
||||
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
|
||||
extensionDisplayName = topLevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty;
|
||||
}
|
||||
|
||||
// StringMatcher.FuzzySearch will absolutely BEEF IT if you give it a
|
||||
// whitespace-only query.
|
||||
//
|
||||
// in that scenario, we'll just use a simple string contains for the
|
||||
// query. Maybe someone is really looking for things with a space in
|
||||
// them, I don't know.
|
||||
|
||||
// Title:
|
||||
// * whitespace query: 1 point
|
||||
// * otherwise full weight match
|
||||
var nameMatch = isWhiteSpace ?
|
||||
(title.Contains(query) ? 1 : 0) :
|
||||
FuzzyStringMatcher.ScoreFuzzy(query, title);
|
||||
|
||||
// Subtitle:
|
||||
// * whitespace query: 1/2 point
|
||||
// * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer
|
||||
var descriptionMatch = isWhiteSpace ?
|
||||
(topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) :
|
||||
(FuzzyStringMatcher.ScoreFuzzy(query, topLevelOrAppItem.Subtitle) - 4) / 2.0;
|
||||
|
||||
// Extension title: despite not being visible, give the extension name itself some weight
|
||||
// * whitespace query: 0 points
|
||||
// * otherwise more weight than a subtitle, but not much
|
||||
var extensionTitleMatch = isWhiteSpace ? 0 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5;
|
||||
|
||||
var scores = new[]
|
||||
// Handle whitespace query separately - FuzzySearch doesn't handle it well
|
||||
if (string.IsNullOrWhiteSpace(query.Original))
|
||||
{
|
||||
nameMatch,
|
||||
descriptionMatch,
|
||||
isFallback ? 1 : 0, // Always give fallbacks a chance
|
||||
};
|
||||
var max = scores.Max();
|
||||
return ScoreWhitespaceQuery(query.Original, title, topLevelOrAppItem.Subtitle, isFallback);
|
||||
}
|
||||
|
||||
// _Add_ the extension name. This will bubble items that match both
|
||||
// title and extension name up above ones that just match title.
|
||||
// e.g. "git" will up-weight "GitHub searches" from the GitHub extension
|
||||
// above "git" from "whatever"
|
||||
max = max + extensionTitleMatch;
|
||||
// Get precomputed targets
|
||||
var (titleTarget, subtitleTarget) = topLevelOrAppItem is IPrecomputedListItem precomputedItem
|
||||
? (precomputedItem.GetTitleTarget(precomputedFuzzyMatcher), precomputedItem.GetSubtitleTarget(precomputedFuzzyMatcher))
|
||||
: (precomputedFuzzyMatcher.PrecomputeTarget(title), precomputedFuzzyMatcher.PrecomputeTarget(topLevelOrAppItem.Subtitle));
|
||||
|
||||
// Score components
|
||||
var nameScore = precomputedFuzzyMatcher.Score(query, titleTarget);
|
||||
var descriptionScore = (precomputedFuzzyMatcher.Score(query, subtitleTarget) - 4) / 2.0;
|
||||
var extensionScore = extensionDisplayNameTarget is { } extTarget ? precomputedFuzzyMatcher.Score(query, extTarget) / 1.5 : 0;
|
||||
|
||||
// Take best match from title/description/fallback, then add extension score
|
||||
// Extension adds to max so items matching both title AND extension bubble up
|
||||
var baseScore = Math.Max(Math.Max(nameScore, descriptionScore), isFallback ? 1 : 0);
|
||||
var matchScore = baseScore + extensionScore;
|
||||
|
||||
// Apply a penalty to fallback items so they rank below direct matches.
|
||||
// Fallbacks that dynamically match queries (like RDP connections) should
|
||||
// appear after apps and direct command matches.
|
||||
if (isFallback && max > 1)
|
||||
if (isFallback && matchScore > 1)
|
||||
{
|
||||
// Reduce fallback scores by 50% to prioritize direct matches
|
||||
max = max * 0.5;
|
||||
matchScore = matchScore * 0.5;
|
||||
}
|
||||
|
||||
var matchSomething = max
|
||||
+ (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0));
|
||||
// Alias matching: exact match is overwhelming priority, substring match adds a small boost
|
||||
var aliasBoost = isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0);
|
||||
var totalMatch = matchScore + aliasBoost;
|
||||
|
||||
// If we matched title, subtitle, or alias (something real), then
|
||||
// here we add the recent command weight boost
|
||||
//
|
||||
// Otherwise something like `x` will still match everything you've run before
|
||||
var finalScore = matchSomething * 10;
|
||||
if (matchSomething > 0)
|
||||
// Apply scaling and history boost only if we matched something real
|
||||
var finalScore = totalMatch * 10;
|
||||
if (totalMatch > 0)
|
||||
{
|
||||
var recentWeightBoost = history.GetCommandHistoryWeight(id);
|
||||
finalScore += recentWeightBoost;
|
||||
finalScore += history.GetCommandHistoryWeight(id);
|
||||
}
|
||||
|
||||
return (int)finalScore;
|
||||
}
|
||||
|
||||
internal static int ScoreFallbackItem(string query, IListItem topLevelOrAppItem, string[] fallbackRanks)
|
||||
private static int ScoreWhitespaceQuery(string query, string title, string subtitle, bool isFallback)
|
||||
{
|
||||
// Simple contains check for whitespace queries
|
||||
var nameMatch = title.Contains(query, StringComparison.Ordinal) ? 1.0 : 0;
|
||||
var descriptionMatch = subtitle.Contains(query, StringComparison.Ordinal) ? 0.5 : 0;
|
||||
var baseScore = Math.Max(Math.Max(nameMatch, descriptionMatch), isFallback ? 1 : 0);
|
||||
|
||||
return (int)(baseScore * 10);
|
||||
}
|
||||
|
||||
private static int ScoreFallbackItem(IListItem topLevelOrAppItem, string[] fallbackRanks)
|
||||
{
|
||||
// Default to 1 so it always shows in list.
|
||||
var finalScore = 1;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#pragma warning disable IDE0007 // Use implicit type
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
@@ -16,10 +17,10 @@ internal static class MainListPageResultFactory
|
||||
/// applying an application result limit and filtering fallback items as needed.
|
||||
/// </summary>
|
||||
public static IListItem[] Create(
|
||||
IList<Scored<IListItem>>? filteredItems,
|
||||
IList<Scored<IListItem>>? scoredFallbackItems,
|
||||
IList<Scored<IListItem>>? filteredApps,
|
||||
IList<Scored<IListItem>>? fallbackItems,
|
||||
IList<RoScored<IListItem>>? filteredItems,
|
||||
IList<RoScored<IListItem>>? scoredFallbackItems,
|
||||
IList<RoScored<IListItem>>? filteredApps,
|
||||
IList<RoScored<IListItem>>? fallbackItems,
|
||||
int appResultLimit)
|
||||
{
|
||||
if (appResultLimit < 0)
|
||||
@@ -147,7 +148,7 @@ internal static class MainListPageResultFactory
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int GetNonEmptyFallbackItemsCount(IList<Scored<IListItem>>? fallbackItems)
|
||||
private static int GetNonEmptyFallbackItemsCount(IList<RoScored<IListItem>>? fallbackItems)
|
||||
{
|
||||
int fallbackItemsCount = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user