diff --git a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs index f87e46855..a3787aaab 100644 --- a/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs +++ b/Dalamud/Interface/Internal/Windows/PluginInstaller/PluginInstallerWindow.cs @@ -536,7 +536,8 @@ internal class PluginInstallerWindow : Window, IDisposable "###XlPluginInstaller_Search", Locs.Header_SearchPlaceholder, ref this.searchText, - 100); + 100, + ImGuiInputTextFlags.AutoSelectAll); ImGui.SameLine(); ImGui.SetCursorPosY(downShift); @@ -981,7 +982,7 @@ internal class PluginInstallerWindow : Window, IDisposable changelogs = this.dalamudChangelogManager.Changelogs.OfType(); } - var sortedChangelogs = changelogs?.Where(x => this.searchText.IsNullOrWhitespace() || x.Title.ToLowerInvariant().Contains(this.searchText.ToLowerInvariant())) + var sortedChangelogs = changelogs?.Where(x => this.searchText.IsNullOrWhitespace() || new FuzzyMatcher(this.searchText.ToLowerInvariant(), MatchMode.FuzzyParts).Matches(x.Title.ToLowerInvariant()) > 0) .OrderByDescending(x => x.Date).ToList(); if (sortedChangelogs == null || !sortedChangelogs.Any()) @@ -2889,8 +2890,8 @@ internal class PluginInstallerWindow : Window, IDisposable private bool IsManifestFiltered(IPluginManifest manifest) { - var searchString = this.searchText.ToLowerInvariant(); - var hasSearchString = !string.IsNullOrWhiteSpace(searchString); + var matcher = new FuzzyMatcher(this.searchText.ToLowerInvariant(), MatchMode.FuzzyParts); + var hasSearchString = !string.IsNullOrWhiteSpace(this.searchText); var oldApi = manifest.DalamudApiLevel < PluginManager.DalamudApiLevel; var installed = this.IsManifestInstalled(manifest).IsInstalled; @@ -2898,11 +2899,11 @@ internal class PluginInstallerWindow : Window, IDisposable return true; return hasSearchString && !( - (!manifest.Name.IsNullOrEmpty() && manifest.Name.ToLowerInvariant().Contains(searchString)) || - (!manifest.InternalName.IsNullOrEmpty() && manifest.InternalName.ToLowerInvariant().Contains(searchString)) || - (!manifest.Author.IsNullOrEmpty() && manifest.Author.Equals(this.searchText, StringComparison.InvariantCultureIgnoreCase)) || - (!manifest.Punchline.IsNullOrEmpty() && manifest.Punchline.ToLowerInvariant().Contains(searchString)) || - (manifest.Tags != null && manifest.Tags.Any(tag => tag.ToLowerInvariant().Contains(searchString)))); + (!manifest.Name.IsNullOrEmpty() && matcher.Matches(manifest.Name.ToLowerInvariant()) > 0) || + (!manifest.InternalName.IsNullOrEmpty() && matcher.Matches(manifest.InternalName.ToLowerInvariant()) > 0) || + (!manifest.Author.IsNullOrEmpty() && matcher.Matches(manifest.Author.ToLowerInvariant()) > 0) || + // (!manifest.Punchline.IsNullOrEmpty() && matcher.Matches(manifest.Punchline.ToLowerInvariant()) > 0) || // Removed because fuzzy match gets a little too excited with lots of random words + (manifest.Tags != null && matcher.MatchesAny(manifest.Tags.Select(term => term.ToLowerInvariant()).ToArray()) > 0)); } private (bool IsInstalled, LocalPlugin Plugin) IsManifestInstalled(IPluginManifest? manifest) diff --git a/Dalamud/Utility/FuzzyMatcher.cs b/Dalamud/Utility/FuzzyMatcher.cs new file mode 100644 index 000000000..647c9586d --- /dev/null +++ b/Dalamud/Utility/FuzzyMatcher.cs @@ -0,0 +1,273 @@ +#define BORDER_MATCHING + +namespace Dalamud.Utility; + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +internal readonly ref struct FuzzyMatcher +{ + private static readonly (int, int)[] EmptySegArray = Array.Empty<(int, int)>(); + + private readonly string needleString = string.Empty; + private readonly ReadOnlySpan needleSpan = ReadOnlySpan.Empty; + private readonly int needleFinalPosition = -1; + private readonly (int start, int end)[] needleSegments = EmptySegArray; + private readonly MatchMode mode = MatchMode.Simple; + + public FuzzyMatcher(string term, MatchMode matchMode) + { + needleString = term; + needleSpan = needleString.AsSpan(); + needleFinalPosition = needleSpan.Length - 1; + mode = matchMode; + + switch (matchMode) + { + case MatchMode.FuzzyParts: + needleSegments = FindNeedleSegments(needleSpan); + break; + case MatchMode.Fuzzy: + case MatchMode.Simple: + needleSegments = EmptySegArray; + break; + default: + throw new ArgumentOutOfRangeException(nameof(matchMode), matchMode, null); + } + } + + private static (int start, int end)[] FindNeedleSegments(ReadOnlySpan span) + { + var segments = new List<(int, int)>(); + var wordStart = -1; + + for (var i = 0; i < span.Length; i++) + { + if (span[i] is not ' ' and not '\u3000') + { + if (wordStart < 0) + { + wordStart = i; + } + } + else if (wordStart >= 0) + { + segments.Add((wordStart, i - 1)); + wordStart = -1; + } + } + + if (wordStart >= 0) + { + segments.Add((wordStart, span.Length - 1)); + } + + return segments.ToArray(); + } + + public int Matches(string value) + { + if (needleFinalPosition < 0) + { + return 0; + } + + if (mode == MatchMode.Simple) + { + return value.Contains(needleString) ? 1 : 0; + } + + var haystack = value.AsSpan(); + + if (mode == MatchMode.Fuzzy) + { + return GetRawScore(haystack, 0, needleFinalPosition); + } + + if (mode == MatchMode.FuzzyParts) + { + if (needleSegments.Length < 2) + { + return GetRawScore(haystack, 0, needleFinalPosition); + } + + var total = 0; + for (var i = 0; i < needleSegments.Length; i++) + { + var (start, end) = needleSegments[i]; + var cur = GetRawScore(haystack, start, end); + if (cur == 0) + { + return 0; + } + + total += cur; + } + + return total; + } + + return 8; + } + + public int MatchesAny(params string[] values) + { + var max = 0; + for (var i = 0; i < values.Length; i++) + { + var cur = Matches(values[i]); + if (cur > max) + { + max = cur; + } + } + + return max; + } + + private int GetRawScore(ReadOnlySpan haystack, int needleStart, int needleEnd) + { + var (startPos, gaps, consecutive, borderMatches, endPos) = FindForward(haystack, needleStart, needleEnd); + if (startPos < 0) + { + return 0; + } + + var needleSize = needleEnd - needleStart + 1; + + var score = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + // PluginLog.Debug( + // $"['{needleString.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] fwd: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={score}"); + + (startPos, gaps, consecutive, borderMatches) = FindReverse(haystack, endPos, needleStart, needleEnd); + var revScore = CalculateRawScore(needleSize, startPos, gaps, consecutive, borderMatches); + // PluginLog.Debug( + // $"['{needleString.Substring(needleStart, needleEnd - needleStart + 1)}' in '{haystack}'] rev: needleSize={needleSize} startPos={startPos} gaps={gaps} consecutive={consecutive} borderMatches={borderMatches} score={revScore}"); + + return int.Max(score, revScore); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static int CalculateRawScore(int needleSize, int startPos, int gaps, int consecutive, int borderMatches) + { + var score = 100 + + needleSize * 3 + + borderMatches * 3 + + consecutive * 5 + - startPos + - gaps * 2; + if (startPos == 0) + score += 5; + return score < 1 ? 1 : score; + } + + private (int startPos, int gaps, int consecutive, int borderMatches, int haystackIndex) FindForward( + ReadOnlySpan haystack, int needleStart, int needleEnd) + { + var needleIndex = needleStart; + var lastMatchIndex = -10; + + var startPos = 0; + var gaps = 0; + var consecutive = 0; + var borderMatches = 0; + + for (var haystackIndex = 0; haystackIndex < haystack.Length; haystackIndex++) + { + if (haystack[haystackIndex] == needleSpan[needleIndex]) + { +#if BORDER_MATCHING + if (haystackIndex > 0) + { + if (!char.IsLetterOrDigit(haystack[haystackIndex - 1])) + { + borderMatches++; + } + } +#endif + + needleIndex++; + + if (haystackIndex == lastMatchIndex + 1) + { + consecutive++; + } + + if (needleIndex > needleEnd) + { + return (startPos, gaps, consecutive, borderMatches, haystackIndex); + } + + lastMatchIndex = haystackIndex; + } + else + { + if (needleIndex > needleStart) + { + gaps++; + } + else + { + startPos++; + } + } + } + + return (-1, 0, 0, 0, 0); + } + + private (int startPos, int gaps, int consecutive, int borderMatches) FindReverse(ReadOnlySpan haystack, + int haystackLastMatchIndex, int needleStart, int needleEnd) + { + var needleIndex = needleEnd; + var revLastMatchIndex = haystack.Length + 10; + + var gaps = 0; + var consecutive = 0; + var borderMatches = 0; + + for (var haystackIndex = haystackLastMatchIndex; haystackIndex >= 0; haystackIndex--) + { + if (haystack[haystackIndex] == needleSpan[needleIndex]) + { +#if BORDER_MATCHING + if (haystackIndex > 0) + { + if (!char.IsLetterOrDigit(haystack[haystackIndex - 1])) + { + borderMatches++; + } + } +#endif + + needleIndex--; + + if (haystackIndex == revLastMatchIndex - 1) + { + consecutive++; + } + + if (needleIndex < needleStart) + { + return (haystackIndex, gaps, consecutive, borderMatches); + } + + revLastMatchIndex = haystackIndex; + } + else + { + gaps++; + } + } + + return (-1, 0, 0, 0); + } +} + +public enum MatchMode +{ + Simple, + Fuzzy, + FuzzyParts +}