CmdPal: replace custom fuzzy matching in Window Walker (#44807)

## Summary of the Pull Request

This PR replaces the custom search controller and fuzzy matching with
standard classes from the Extension SDK Toolkit.

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

- [ ] Closes: #xxx
<!-- - [ ] 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
- [ ] **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:50 +01:00
committed by GitHub
parent 0de2af77ac
commit cc2dce8816
6 changed files with 79 additions and 506 deletions

View File

@@ -1,134 +0,0 @@
// 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.
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
/// <summary>
/// Class housing fuzzy matching methods
/// </summary>
internal static class FuzzyMatching
{
/// <summary>
/// Finds the best match (the one with the most
/// number of letters adjacent to each other) and
/// returns the index location of each of the letters
/// of the matches
/// </summary>
/// <param name="text">The text to search inside of</param>
/// <param name="searchText">the text to search for</param>
/// <returns>returns the index location of each of the letters of the matches</returns>
internal static List<int> FindBestFuzzyMatch(string text, string searchText)
{
ArgumentNullException.ThrowIfNull(searchText);
ArgumentNullException.ThrowIfNull(text);
// Using CurrentCulture since this is user facing
searchText = searchText.ToLower(CultureInfo.CurrentCulture);
text = text.ToLower(CultureInfo.CurrentCulture);
// Create a grid to march matches like
// e.g.
// a b c a d e c f g
// a x x
// c x x
var matches = new bool[text.Length, searchText.Length];
for (var firstIndex = 0; firstIndex < text.Length; firstIndex++)
{
for (var secondIndex = 0; secondIndex < searchText.Length; secondIndex++)
{
matches[firstIndex, secondIndex] =
searchText[secondIndex] == text[firstIndex] ?
true :
false;
}
}
// use this table to get all the possible matches
List<List<int>> allMatches = GetAllMatchIndexes(matches);
// return the score that is the max
var maxScore = allMatches.Count > 0 ? CalculateScoreForMatches(allMatches[0]) : 0;
List<int> bestMatch = allMatches.Count > 0 ? allMatches[0] : new List<int>();
foreach (var match in allMatches)
{
var score = CalculateScoreForMatches(match);
if (score > maxScore)
{
bestMatch = match;
maxScore = score;
}
}
return bestMatch;
}
/// <summary>
/// Gets all the possible matches to the search string with in the text
/// </summary>
/// <param name="matches"> a table showing the matches as generated by
/// a two dimensional array with the first dimension the text and the second
/// one the search string and each cell marked as an intersection between the two</param>
/// <returns>a list of the possible combinations that match the search text</returns>
internal static List<List<int>> GetAllMatchIndexes(bool[,] matches)
{
ArgumentNullException.ThrowIfNull(matches);
List<List<int>> results = new List<List<int>>();
for (var secondIndex = 0; secondIndex < matches.GetLength(1); secondIndex++)
{
for (var firstIndex = 0; firstIndex < matches.GetLength(0); firstIndex++)
{
if (secondIndex == 0 && matches[firstIndex, secondIndex])
{
results.Add(new List<int> { firstIndex });
}
else if (matches[firstIndex, secondIndex])
{
var tempList = results.Where(x => x.Count == secondIndex && x[x.Count - 1] < firstIndex).Select(x => x.ToList()).ToList();
foreach (var pathSofar in tempList)
{
pathSofar.Add(firstIndex);
}
results.AddRange(tempList);
}
}
results = results.Where(x => x.Count == secondIndex + 1).ToList();
}
return results.Where(x => x.Count == matches.GetLength(1)).ToList();
}
/// <summary>
/// Calculates the score for a string
/// </summary>
/// <param name="matches">the index of the matches</param>
/// <returns>an integer representing the score</returns>
internal static int CalculateScoreForMatches(List<int> matches)
{
ArgumentNullException.ThrowIfNull(matches);
var score = 0;
for (var currentIndex = 1; currentIndex < matches.Count; currentIndex++)
{
var previousIndex = currentIndex - 1;
score -= matches[currentIndex] - matches[previousIndex];
}
return score == 0 ? -10000 : score;
}
}

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.WindowWalker.Commands; using Microsoft.CmdPal.Ext.WindowWalker.Commands;
using Microsoft.CmdPal.Ext.WindowWalker.Helpers; using Microsoft.CmdPal.Ext.WindowWalker.Helpers;
using Microsoft.CmdPal.Ext.WindowWalker.Properties; using Microsoft.CmdPal.Ext.WindowWalker.Properties;
@@ -19,33 +19,58 @@ internal static class ResultHelper
/// <summary> /// <summary>
/// Returns a list of all results for the query. /// Returns a list of all results for the query.
/// </summary> /// </summary>
/// <param name="searchControllerResults">List with all search controller matches</param> /// <param name="scoredWindows">List with all search controller matches</param>
/// <returns>List of results</returns> /// <returns>List of results</returns>
internal static List<WindowWalkerListItem> GetResultList(List<SearchResult> searchControllerResults, bool isKeywordSearch) internal static WindowWalkerListItem[] GetResultList(ICollection<Scored<Window>>? scoredWindows)
{ {
if (searchControllerResults is null || searchControllerResults.Count == 0) if (scoredWindows is null || scoredWindows.Count == 0)
{ {
return []; return [];
} }
var resultsList = new List<WindowWalkerListItem>(searchControllerResults.Count); var list = scoredWindows as IList<Scored<Window>> ?? new List<Scored<Window>>(scoredWindows);
var addExplorerInfo = searchControllerResults.Any(x =>
string.Equals(x.Result.Process.Name, "explorer.exe", StringComparison.OrdinalIgnoreCase) &&
x.Result.Process.IsShellProcess);
// Process each SearchResult to convert it into a Result. var addExplorerInfo = false;
// Using parallel processing if the operation is CPU-bound and the list is large. for (var i = 0; i < list.Count; i++)
resultsList = searchControllerResults {
.AsParallel() var window = list[i].Item;
.Select(x => CreateResultFromSearchResult(x)) if (window?.Process is null)
.ToList(); {
continue;
}
if (string.Equals(window.Process.Name, "explorer.exe", StringComparison.OrdinalIgnoreCase) && window.Process.IsShellProcess)
{
addExplorerInfo = true;
break;
}
}
var projected = new WindowWalkerListItem[list.Count];
if (list.Count >= 32)
{
Parallel.For(0, list.Count, i =>
{
projected[i] = CreateResultFromSearchResult(list[i]);
});
}
else
{
for (var i = 0; i < list.Count; i++)
{
projected[i] = CreateResultFromSearchResult(list[i]);
}
}
if (addExplorerInfo && !SettingsManager.Instance.HideExplorerSettingInfo) if (addExplorerInfo && !SettingsManager.Instance.HideExplorerSettingInfo)
{ {
resultsList.Insert(0, GetExplorerInfoResult()); var withInfo = new WindowWalkerListItem[projected.Length + 1];
withInfo[0] = GetExplorerInfoResult();
Array.Copy(projected, 0, withInfo, 1, projected.Length);
return withInfo;
} }
return resultsList; return projected;
} }
/// <summary> /// <summary>
@@ -53,16 +78,15 @@ internal static class ResultHelper
/// </summary> /// </summary>
/// <param name="searchResult">The SearchResult object to convert.</param> /// <param name="searchResult">The SearchResult object to convert.</param>
/// <returns>A Result object populated with data from the SearchResult.</returns> /// <returns>A Result object populated with data from the SearchResult.</returns>
private static WindowWalkerListItem CreateResultFromSearchResult(SearchResult searchResult) private static WindowWalkerListItem CreateResultFromSearchResult(Scored<Window> searchResult)
{ {
var item = new WindowWalkerListItem(searchResult.Result) var item = new WindowWalkerListItem(searchResult.Item)
{ {
Title = searchResult.Result.Title, Title = searchResult.Item.Title,
Subtitle = GetSubtitle(searchResult.Result), Subtitle = GetSubtitle(searchResult.Item),
Tags = GetTags(searchResult.Result), Tags = GetTags(searchResult.Item),
}; };
item.MoreCommands = ContextMenuHelper.GetContextMenuResults(item).ToArray(); item.MoreCommands = ContextMenuHelper.GetContextMenuResults(item).ToArray();
return item; return item;
} }

View File

@@ -1,150 +0,0 @@
// 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.
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Microsoft.CmdPal.Ext.WindowWalker.Helpers;
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
/// <summary>
/// Responsible for searching and finding matches for the strings provided.
/// Essentially the UI independent model of the application
/// </summary>
internal sealed class SearchController
{
/// <summary>
/// the current search text
/// </summary>
private string searchText;
/// <summary>
/// Open window search results
/// </summary>
private List<SearchResult>? searchMatches;
/// <summary>
/// Singleton pattern
/// </summary>
private static SearchController? instance;
/// <summary>
/// Gets or sets the current search text
/// </summary>
internal string SearchText
{
get => searchText;
set =>
searchText = value.ToLower(CultureInfo.CurrentCulture).Trim();
}
/// <summary>
/// Gets the open window search results
/// </summary>
internal List<SearchResult> SearchMatches => new List<SearchResult>(searchMatches ?? []).OrderByDescending(x => x.Score).ToList();
/// <summary>
/// Gets singleton Pattern
/// </summary>
internal static SearchController Instance
{
get
{
instance ??= new SearchController();
return instance;
}
}
/// <summary>
/// Initializes a new instance of the <see cref="SearchController"/> class.
/// Initializes the search controller object
/// </summary>
private SearchController()
{
searchText = string.Empty;
}
/// <summary>
/// Event handler for when the search text has been updated
/// </summary>
internal void UpdateSearchText(string searchText)
{
SearchText = searchText;
SyncOpenWindowsWithModel();
}
/// <summary>
/// Syncs the open windows with the OpenWindows Model
/// </summary>
internal void SyncOpenWindowsWithModel()
{
System.Diagnostics.Debug.Print("Syncing WindowSearch result with OpenWindows Model");
var snapshotOfOpenWindows = OpenWindows.Instance.Windows;
searchMatches = string.IsNullOrWhiteSpace(SearchText) ? AllOpenWindows(snapshotOfOpenWindows) : FuzzySearchOpenWindows(snapshotOfOpenWindows);
}
/// <summary>
/// Search method that matches the title of windows with the user search text
/// </summary>
/// <param name="openWindows">what windows are open</param>
/// <returns>Returns search results</returns>
private List<SearchResult> FuzzySearchOpenWindows(List<Window> openWindows)
{
List<SearchResult> result = [];
var searchStrings = new SearchString(searchText, SearchResult.SearchType.Fuzzy);
foreach (var window in openWindows)
{
var titleMatch = FuzzyMatching.FindBestFuzzyMatch(window.Title, searchStrings.SearchText);
var processMatch = FuzzyMatching.FindBestFuzzyMatch(window.Process.Name ?? string.Empty, searchStrings.SearchText);
if ((titleMatch.Count != 0 || processMatch.Count != 0) && window.Title.Length != 0)
{
result.Add(new SearchResult(window, titleMatch, processMatch, searchStrings.SearchType));
}
}
System.Diagnostics.Debug.Print("Found " + result.Count + " windows that match the search text");
return result;
}
/// <summary>
/// Search method that matches all the windows with a title
/// </summary>
/// <param name="openWindows">what windows are open</param>
/// <returns>Returns search results</returns>
private List<SearchResult> AllOpenWindows(List<Window> openWindows)
{
List<SearchResult> result = [];
foreach (var window in openWindows)
{
if (window.Title.Length != 0)
{
result.Add(new SearchResult(window));
}
}
return SettingsManager.Instance.InMruOrder
? result.ToList()
: result
.OrderBy(w => w.Result.Title)
.ToList();
}
/// <summary>
/// Event args for a window list update event
/// </summary>
internal sealed class SearchResultUpdateEventArgs : EventArgs
{
}
}

View File

@@ -1,147 +0,0 @@
// 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.
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
using System.Collections.Generic;
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
/// <summary>
/// Contains search result windows with each window including the reason why the result was included
/// </summary>
internal sealed class SearchResult
{
/// <summary>
/// Gets the actual window reference for the search result
/// </summary>
internal Window Result
{
get;
private set;
}
/// <summary>
/// Gets the list of indexes of the matching characters for the search in the title window
/// </summary>
internal List<int> SearchMatchesInTitle
{
get;
private set;
}
/// <summary>
/// Gets the list of indexes of the matching characters for the search in the
/// name of the process
/// </summary>
internal List<int> SearchMatchesInProcessName
{
get;
private set;
}
/// <summary>
/// Gets the type of match (shortcut, fuzzy or nothing)
/// </summary>
internal SearchType SearchResultMatchType
{
get;
private set;
}
/// <summary>
/// Gets a score indicating how well this matches what we are looking for
/// </summary>
internal int Score
{
get;
private set;
}
/// <summary>
/// Gets the source of where the best score was found
/// </summary>
internal TextType BestScoreSource
{
get;
private set;
}
/// <summary>
/// Initializes a new instance of the <see cref="SearchResult"/> class.
/// Constructor
/// </summary>
internal SearchResult(Window window, List<int> matchesInTitle, List<int> matchesInProcessName, SearchType matchType)
{
Result = window;
SearchMatchesInTitle = matchesInTitle;
SearchMatchesInProcessName = matchesInProcessName;
SearchResultMatchType = matchType;
CalculateScore();
}
/// <summary>
/// Initializes a new instance of the <see cref="SearchResult"/> class.
/// </summary>
internal SearchResult(Window window)
{
Result = window;
SearchMatchesInTitle = new List<int>();
SearchMatchesInProcessName = new List<int>();
SearchResultMatchType = SearchType.Empty;
CalculateScore();
}
/// <summary>
/// Calculates the score for how closely this window matches the search string
/// </summary>
/// <remarks>
/// Higher Score is better
/// </remarks>
private void CalculateScore()
{
if (FuzzyMatching.CalculateScoreForMatches(SearchMatchesInProcessName) >
FuzzyMatching.CalculateScoreForMatches(SearchMatchesInTitle))
{
Score = FuzzyMatching.CalculateScoreForMatches(SearchMatchesInProcessName);
BestScoreSource = TextType.ProcessName;
}
else
{
Score = FuzzyMatching.CalculateScoreForMatches(SearchMatchesInTitle);
BestScoreSource = TextType.WindowTitle;
}
}
/// <summary>
/// The type of text that a string represents
/// </summary>
internal enum TextType
{
ProcessName,
WindowTitle,
}
/// <summary>
/// The type of search
/// </summary>
internal enum SearchType
{
/// <summary>
/// the search string is empty, which means all open windows are
/// going to be returned
/// </summary>
Empty,
/// <summary>
/// Regular fuzzy match search
/// </summary>
Fuzzy,
/// <summary>
/// The user has entered text that has been matched to a shortcut
/// and the shortcut is now being searched
/// </summary>
Shortcut,
}
}

View File

@@ -1,45 +0,0 @@
// 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.
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
/// <summary>
/// A class to represent a search string
/// </summary>
/// <remarks>Class was added in order to be able to attach various context data to
/// a search string</remarks>
internal sealed class SearchString
{
/// <summary>
/// Gets where is the search string coming from (is it a shortcut
/// or direct string, etc...)
/// </summary>
internal SearchResult.SearchType SearchType
{
get;
private set;
}
/// <summary>
/// Gets the actual text we are searching for
/// </summary>
internal string SearchText
{
get;
private set;
}
/// <summary>
/// Initializes a new instance of the <see cref="SearchString"/> class.
/// Constructor
/// </summary>
/// <param name="searchText">text from search</param>
/// <param name="searchType">type of search</param>
internal SearchString(string searchText, SearchResult.SearchType searchType)
{
SearchText = searchText;
SearchType = searchType;
}
}

View File

@@ -3,9 +3,8 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System; using System;
using System.Collections.Generic;
using System.Globalization;
using Microsoft.CmdPal.Ext.WindowWalker.Components; using Microsoft.CmdPal.Ext.WindowWalker.Components;
using Microsoft.CmdPal.Ext.WindowWalker.Helpers;
using Microsoft.CmdPal.Ext.WindowWalker.Properties; using Microsoft.CmdPal.Ext.WindowWalker.Properties;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -33,10 +32,12 @@ internal sealed partial class WindowWalkerListPage : DynamicListPage, IDisposabl
}; };
} }
public override void UpdateSearchText(string oldSearch, string newSearch) => public override void UpdateSearchText(string oldSearch, string newSearch)
{
RaiseItemsChanged(0); RaiseItemsChanged(0);
}
public List<WindowWalkerListItem> Query(string query) private WindowWalkerListItem[] Query(string query)
{ {
ArgumentNullException.ThrowIfNull(query); ArgumentNullException.ThrowIfNull(query);
@@ -46,13 +47,37 @@ internal sealed partial class WindowWalkerListPage : DynamicListPage, IDisposabl
WindowWalkerCommandsProvider.VirtualDesktopHelperInstance.UpdateDesktopList(); WindowWalkerCommandsProvider.VirtualDesktopHelperInstance.UpdateDesktopList();
OpenWindows.Instance.UpdateOpenWindowsList(_cancellationTokenSource.Token); OpenWindows.Instance.UpdateOpenWindowsList(_cancellationTokenSource.Token);
SearchController.Instance.UpdateSearchText(query);
var searchControllerResults = SearchController.Instance.SearchMatches;
return ResultHelper.GetResultList(searchControllerResults, !string.IsNullOrEmpty(query)); var windows = OpenWindows.Instance.Windows;
if (string.IsNullOrWhiteSpace(query))
{
if (!SettingsManager.Instance.InMruOrder)
{
windows.Sort(static (a, b) => string.Compare(a?.Title, b?.Title, StringComparison.OrdinalIgnoreCase));
} }
public override IListItem[] GetItems() => Query(SearchText).ToArray(); var results = new Scored<Window>[windows.Count];
for (var i = 0; i < windows.Count; i++)
{
results[i] = new Scored<Window> { Item = windows[i], Score = 100 };
}
return ResultHelper.GetResultList(results);
}
var scored = ListHelpers.FilterListWithScores(windows, query, ScoreFunction);
return ResultHelper.GetResultList([.. scored]);
}
private static int ScoreFunction(string q, Window window)
{
var titleScore = FuzzyStringMatcher.ScoreFuzzy(q, window.Title);
var processNameScore = FuzzyStringMatcher.ScoreFuzzy(q, window.Process?.Name ?? string.Empty);
return Math.Max(titleScore, processNameScore);
}
public override IListItem[] GetItems() => Query(SearchText);
public void Dispose() public void Dispose()
{ {