Compare commits

...

1 Commits

Author SHA1 Message Date
Mike Griese
a99596927d I guess this is fuzzy.rs, for cmdpal 2025-06-03 13:34:55 -05:00
5 changed files with 197 additions and 15 deletions

View File

@@ -204,19 +204,19 @@ public partial class MainListPage : DynamicListPage,
// * otherwise full weight match
var nameMatch = isWhiteSpace ?
(title.Contains(query) ? 1 : 0) :
StringMatcher.FuzzySearch(query, title).Score;
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) :
(StringMatcher.FuzzySearch(query, topLevelOrAppItem.Subtitle).Score - 4) / 2.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 : StringMatcher.FuzzySearch(query, extensionDisplayName).Score / 1.5;
var extensionTitleMatch = isWhiteSpace ? 0 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5;
var scores = new[]
{

View File

@@ -57,11 +57,11 @@ public partial class ContextMenuStackViewModel : ObservableObject
return 0;
}
var nameMatch = StringMatcher.FuzzySearch(query, item.Title);
var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Title);
var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle);
var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Subtitle);
return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max();
return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
}
public CommandContextItemViewModel? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key)

View File

@@ -5,7 +5,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -95,10 +94,9 @@ public partial class ListItemViewModel(IListItem model, WeakReference<IPageConte
UpdateProperty(propertyName);
}
// TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
// TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
public bool MatchesFilter(string filter) => StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success;
// // TODO: Do we want filters to match descriptions and other properties? Tags, etc... Yes?
// // TODO: Do we want to save off the score here so we can sort by it in our ListViewModel?
// public bool MatchesFilter(string filter) => StringMatcher.FuzzySearch(filter, Title).Success || StringMatcher.FuzzySearch(filter, Subtitle).Success;
public override string ToString() => $"{Name} ListItemViewModel";
public override bool Equals(object? obj) => obj is ListItemViewModel vm && vm.Model.Equals(this.Model);
@@ -120,7 +118,7 @@ public partial class ListItemViewModel(IListItem model, WeakReference<IPageConte
{
// Tags being an ObservableCollection instead of a List lead to
// many COM exception issues.
Tags = new(newTags);
Tags = [.. newTags];
UpdateProperty(nameof(Tags));
UpdateProperty(nameof(HasTags));

View File

@@ -270,9 +270,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
return 1;
}
var nameMatch = StringMatcher.FuzzySearch(query, listItem.Title);
var descriptionMatch = StringMatcher.FuzzySearch(query, listItem.Subtitle);
return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max();
var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Title);
var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, listItem.Subtitle);
return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
}
private struct ScoredListItemViewModel

View File

@@ -0,0 +1,184 @@
// 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 System.Globalization;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
// Inspired by the fuzzy.rs from edit.exe
public static class FuzzyStringMatcher
{
private const int NOMATCH = 0;
public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true)
{
var (s, _) = ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches);
return s;
}
public static (int Score, List<int> Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
{
return (NOMATCH, new List<int>());
}
var target = haystack.ToCharArray();
var query = needle.ToCharArray();
if (target.Length < query.Length)
{
return (NOMATCH, new List<int>());
}
var targetLower = FoldCase(haystack);
var queryLower = FoldCase(needle);
var targetLowerChars = targetLower.ToCharArray();
var queryLowerChars = queryLower.ToCharArray();
var area = query.Length * target.Length;
var scores = new int[area];
var matches = new int[area];
for (var qi = 0; qi < query.Length; qi++)
{
var qiOffset = qi * target.Length;
var qiPrevOffset = qi > 0 ? (qi - 1) * target.Length : 0;
for (var ti = 0; ti < target.Length; ti++)
{
var currentIndex = qiOffset + ti;
var diagIndex = (qi > 0 && ti > 0) ? qiPrevOffset + ti - 1 : 0;
var leftScore = ti > 0 ? scores[currentIndex - 1] : 0;
var diagScore = (qi > 0 && ti > 0) ? scores[diagIndex] : 0;
var matchSeqLen = (qi > 0 && ti > 0) ? matches[diagIndex] : 0;
var score = (diagScore == 0 && qi != 0) ? 0 :
ComputeCharScore(
query[qi],
queryLowerChars[qi],
ti != 0 ? target[ti - 1] : null,
target[ti],
targetLowerChars[ti],
matchSeqLen);
var isValidScore = score != 0 && diagScore + score >= leftScore &&
(allowNonContiguousMatches || qi > 0 ||
targetLowerChars.Skip(ti).Take(queryLowerChars.Length).SequenceEqual(queryLowerChars));
if (isValidScore)
{
matches[currentIndex] = matchSeqLen + 1;
scores[currentIndex] = diagScore + score;
}
else
{
matches[currentIndex] = NOMATCH;
scores[currentIndex] = leftScore;
}
}
}
var positions = new List<int>();
if (query.Length > 0 && target.Length > 0)
{
var qi = query.Length - 1;
var ti = target.Length - 1;
while (true)
{
var index = (qi * target.Length) + ti;
if (matches[index] == NOMATCH)
{
if (ti == 0)
{
break;
}
ti--;
}
else
{
positions.Add(ti);
if (qi == 0 || ti == 0)
{
break;
}
qi--;
ti--;
}
}
positions.Reverse();
}
return (scores[area - 1], positions);
}
private static string FoldCase(string input)
{
return input.ToLower(CultureInfo.InvariantCulture);
}
private static int ComputeCharScore(
char query,
char queryLower,
char? targetPrev,
char targetCurr,
char targetLower,
int matchSeqLen)
{
if (!ConsiderAsEqual(queryLower, targetLower))
{
return 0;
}
var score = 1; // Character match bonus
if (matchSeqLen > 0)
{
score += matchSeqLen * 5; // Consecutive match bonus
}
if (query == targetCurr)
{
score += 1; // Same case bonus
}
if (targetPrev.HasValue)
{
var sepBonus = ScoreSeparator(targetPrev.Value);
if (sepBonus > 0)
{
score += sepBonus;
}
else if (char.IsUpper(targetCurr) && matchSeqLen == 0)
{
score += 2; // CamelCase bonus
}
}
else
{
score += 8; // Start of word bonus
}
return score;
}
private static bool ConsiderAsEqual(char a, char b)
{
return a == b || (a == '/' && b == '\\') || (a == '\\' && b == '/');
}
private static int ScoreSeparator(char ch)
{
return ch switch
{
'/' or '\\' => 5,
'_' or '-' or '.' or ' ' or '\'' or '"' or ':' => 4,
_ => 0,
};
}
}