diff --git a/Flow.Launcher.Infrastructure/PinyinAlphabet.cs b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs index 80fd12820..6c2a94e82 100644 --- a/Flow.Launcher.Infrastructure/PinyinAlphabet.cs +++ b/Flow.Launcher.Infrastructure/PinyinAlphabet.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Text; using JetBrains.Annotations; @@ -8,14 +9,109 @@ using ToolGood.Words.Pinyin; namespace Flow.Launcher.Infrastructure { + public class TranslationMapping + { + private bool constructed; + + private List originalIndexs = new List(); + private List translatedIndexs = new List(); + private int translaedLength = 0; + + public string key { get; private set; } + + public void setKey(string key) + { + this.key = key; + } + + public void AddNewIndex(int originalIndex, int translatedIndex, int length) + { + if (constructed) + throw new InvalidOperationException("Mapping shouldn't be changed after constructed"); + + originalIndexs.Add(originalIndex); + translatedIndexs.Add(translatedIndex); + translatedIndexs.Add(translatedIndex + length); + translaedLength += length - 1; + } + + public int MapToOriginalIndex(int translatedIndex) + { + if (translatedIndex > translatedIndexs.Last()) + return translatedIndex - translaedLength - 1; + + int lowerBound = 0; + int upperBound = originalIndexs.Count - 1; + + int count = 0; + + // Corner case handle + if (translatedIndex < translatedIndexs[0]) + return translatedIndex; + if (translatedIndex > translatedIndexs.Last()) + { + int indexDef = 0; + for (int k = 0; k < originalIndexs.Count; k++) + { + indexDef += translatedIndexs[k * 2 + 1] - translatedIndexs[k * 2]; + } + + return translatedIndex - indexDef - 1; + } + + // Binary Search with Range + for (int i = originalIndexs.Count / 2;; count++) + { + if (translatedIndex < translatedIndexs[i * 2]) + { + // move to lower middle + upperBound = i; + i = (i + lowerBound) / 2; + } + else if (translatedIndex > translatedIndexs[i * 2 + 1] - 1) + { + lowerBound = i; + // move to upper middle + // due to floor of integer division, move one up on corner case + i = (i + upperBound + 1) / 2; + } + else + return originalIndexs[i]; + + if (upperBound - lowerBound <= 1 && + translatedIndex > translatedIndexs[lowerBound * 2 + 1] && + translatedIndex < translatedIndexs[upperBound * 2]) + { + int indexDef = 0; + + for (int j = 0; j < upperBound; j++) + { + indexDef += translatedIndexs[j * 2 + 1] - translatedIndexs[j * 2]; + } + + return translatedIndex - indexDef - 1; + } + } + } + + public void endConstruct() + { + if (constructed) + throw new InvalidOperationException("Mapping has already been constructed"); + constructed = true; + } + } + public interface IAlphabet { - string Translate(string stringToTranslate); + public (string translation, TranslationMapping map) Translate(string stringToTranslate); } public class PinyinAlphabet : IAlphabet { - private ConcurrentDictionary _pinyinCache = new ConcurrentDictionary(); + private ConcurrentDictionary _pinyinCache = + new ConcurrentDictionary(); + private Settings _settings; public void Initialize([NotNull] Settings settings) @@ -23,7 +119,7 @@ namespace Flow.Launcher.Infrastructure _settings = settings ?? throw new ArgumentNullException(nameof(settings)); } - public string Translate(string content) + public (string translation, TranslationMapping map) Translate(string content) { if (_settings.ShouldUsePinyin) { @@ -34,14 +130,7 @@ namespace Flow.Launcher.Infrastructure var resultList = WordsHelper.GetPinyinList(content); StringBuilder resultBuilder = new StringBuilder(); - - for (int i = 0; i < resultList.Length; i++) - { - if (content[i] >= 0x3400 && content[i] <= 0x9FD5) - resultBuilder.Append(resultList[i].First()); - } - - resultBuilder.Append(' '); + TranslationMapping map = new TranslationMapping(); bool pre = false; @@ -49,6 +138,7 @@ namespace Flow.Launcher.Infrastructure { if (content[i] >= 0x3400 && content[i] <= 0x9FD5) { + map.AddNewIndex(i, resultBuilder.Length, resultList[i].Length + 1); resultBuilder.Append(' '); resultBuilder.Append(resultList[i]); pre = true; @@ -60,15 +150,21 @@ namespace Flow.Launcher.Infrastructure pre = false; resultBuilder.Append(' '); } + resultBuilder.Append(resultList[i]); } } - return _pinyinCache[content] = resultBuilder.ToString(); + map.endConstruct(); + + var key = resultBuilder.ToString(); + map.setKey(key); + + return _pinyinCache[content] = (key, map); } else { - return content; + return (content, null); } } else @@ -78,7 +174,7 @@ namespace Flow.Launcher.Infrastructure } else { - return content; + return (content, null); } } } diff --git a/Flow.Launcher.Infrastructure/StringMatcher.cs b/Flow.Launcher.Infrastructure/StringMatcher.cs index bc4e4b408..3ffa9f7b1 100644 --- a/Flow.Launcher.Infrastructure/StringMatcher.cs +++ b/Flow.Launcher.Infrastructure/StringMatcher.cs @@ -31,7 +31,20 @@ namespace Flow.Launcher.Infrastructure } /// - /// Current method: + /// Current method has two parts, Acronym Match and Fuzzy Search: + /// + /// Acronym Match: + /// Charater listed below will be considered as acronym + /// 1. Character on index 0 + /// 2. Character appears after a space + /// 3. Character that is UpperCase + /// 4. Character that is number + /// + /// Acronym Match will succeed when all query characters match with acronyms in stringToCompare. + /// If any of the characters in the query isn't matched with stringToCompare, Acronym Match will fail. + /// Score will be calculated based the percentage of all query characters matched with total acronyms in stringToCompare. + /// + /// Fuzzy Search: /// Character matching + substring matching; /// 1. Query search string is split into substrings, separator is whitespace. /// 2. Check each query substring's characters against full compare string, @@ -43,20 +56,21 @@ namespace Flow.Launcher.Infrastructure /// public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt) { - if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query)) return new MatchResult (false, UserSettingSearchPrecision); - - query = query.Trim(); + if (string.IsNullOrEmpty(stringToCompare) || string.IsNullOrEmpty(query)) + return new MatchResult(false, UserSettingSearchPrecision); - if (_alphabet != null) - { - query = _alphabet.Translate(query); - stringToCompare = _alphabet.Translate(stringToCompare); - } + query = query.Trim(); + TranslationMapping translationMapping; + (stringToCompare, translationMapping) = _alphabet?.Translate(stringToCompare) ?? (stringToCompare, null); + + var currentAcronymQueryIndex = 0; + var acronymMatchData = new List(); + int acronymsTotalCount = 0; + int acronymsMatched = 0; var fullStringToCompareWithoutCase = opt.IgnoreCase ? stringToCompare.ToLower() : stringToCompare; - var queryWithoutCase = opt.IgnoreCase ? query.ToLower() : query; - + var querySubstrings = queryWithoutCase.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); int currentQuerySubstringIndex = 0; var currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; @@ -74,17 +88,44 @@ namespace Flow.Launcher.Infrastructure for (var compareStringIndex = 0; compareStringIndex < fullStringToCompareWithoutCase.Length; compareStringIndex++) { + // If acronyms matching successfully finished, this gets the remaining not matched acronyms for score calculation + if (currentAcronymQueryIndex >= query.Length && acronymsMatched == query.Length) + { + if (IsAcronymCount(stringToCompare, compareStringIndex)) + acronymsTotalCount++; + continue; + } + + if (currentAcronymQueryIndex >= query.Length || + currentAcronymQueryIndex >= query.Length && allQuerySubstringsMatched) + break; // To maintain a list of indices which correspond to spaces in the string to compare // To populate the list only for the first query substring - if (fullStringToCompareWithoutCase[compareStringIndex].Equals(' ') && currentQuerySubstringIndex == 0) - { + if (fullStringToCompareWithoutCase[compareStringIndex] == ' ' && currentQuerySubstringIndex == 0) spaceIndices.Add(compareStringIndex); + + // Acronym Match + if (IsAcronym(stringToCompare, compareStringIndex)) + { + if (fullStringToCompareWithoutCase[compareStringIndex] == + queryWithoutCase[currentAcronymQueryIndex]) + { + acronymMatchData.Add(compareStringIndex); + acronymsMatched++; + + currentAcronymQueryIndex++; + } } - if (fullStringToCompareWithoutCase[compareStringIndex] != currentQuerySubstring[currentQuerySubstringCharacterIndex]) + if (IsAcronymCount(stringToCompare, compareStringIndex)) + acronymsTotalCount++; + + if (allQuerySubstringsMatched || fullStringToCompareWithoutCase[compareStringIndex] != + currentQuerySubstring[currentQuerySubstringCharacterIndex]) { matchFoundInPreviousLoop = false; + continue; } @@ -106,14 +147,16 @@ namespace Flow.Launcher.Infrastructure // in order to do so we need to verify all previous chars are part of the pattern var startIndexToVerify = compareStringIndex - currentQuerySubstringCharacterIndex; - if (AllPreviousCharsMatched(startIndexToVerify, currentQuerySubstringCharacterIndex, fullStringToCompareWithoutCase, currentQuerySubstring)) + if (AllPreviousCharsMatched(startIndexToVerify, currentQuerySubstringCharacterIndex, + fullStringToCompareWithoutCase, currentQuerySubstring)) { matchFoundInPreviousLoop = true; // if it's the beginning character of the first query substring that is matched then we need to update start index firstMatchIndex = currentQuerySubstringIndex == 0 ? startIndexToVerify : firstMatchIndex; - indexList = GetUpdatedIndexList(startIndexToVerify, currentQuerySubstringCharacterIndex, firstMatchIndexInWord, indexList); + indexList = GetUpdatedIndexList(startIndexToVerify, currentQuerySubstringCharacterIndex, + firstMatchIndexInWord, indexList); } } @@ -126,49 +169,96 @@ namespace Flow.Launcher.Infrastructure if (currentQuerySubstringCharacterIndex == currentQuerySubstring.Length) { // if any of the substrings was not matched then consider as all are not matched - allSubstringsContainedInCompareString = matchFoundInPreviousLoop && allSubstringsContainedInCompareString; + allSubstringsContainedInCompareString = + matchFoundInPreviousLoop && allSubstringsContainedInCompareString; currentQuerySubstringIndex++; - allQuerySubstringsMatched = AllQuerySubstringsMatched(currentQuerySubstringIndex, querySubstrings.Length); + allQuerySubstringsMatched = + AllQuerySubstringsMatched(currentQuerySubstringIndex, querySubstrings.Length); + if (allQuerySubstringsMatched) - break; + continue; // otherwise move to the next query substring currentQuerySubstring = querySubstrings[currentQuerySubstringIndex]; currentQuerySubstringCharacterIndex = 0; } } - + + // return acronym match if all query char matched + if (acronymsMatched > 0 && acronymsMatched == query.Length) + { + int acronymScore = acronymsMatched * 100 / acronymsTotalCount; + + if (acronymScore >= (int)UserSettingSearchPrecision) + { + acronymMatchData = acronymMatchData.Select(x => translationMapping?.MapToOriginalIndex(x) ?? x).Distinct().ToList(); + return new MatchResult(true, UserSettingSearchPrecision, acronymMatchData, acronymScore); + } + } + // proceed to calculate score if every char or substring without whitespaces matched if (allQuerySubstringsMatched) { var nearestSpaceIndex = CalculateClosestSpaceIndex(spaceIndices, firstMatchIndex); - var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString); + var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, + lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString); - return new MatchResult(true, UserSettingSearchPrecision, indexList, score); + var resultList = indexList.Select(x => translationMapping?.MapToOriginalIndex(x) ?? x).Distinct().ToList(); + return new MatchResult(true, UserSettingSearchPrecision, resultList, score); } return new MatchResult(false, UserSettingSearchPrecision); } + private bool IsAcronym(string stringToCompare, int compareStringIndex) + { + if (IsAcronymChar(stringToCompare, compareStringIndex) || IsAcronymNumber(stringToCompare, compareStringIndex)) + return true; + + return false; + } + + // When counting acronyms, treat a set of numbers as one acronym ie. Visual 2019 as 2 acronyms instead of 5 + private bool IsAcronymCount(string stringToCompare, int compareStringIndex) + { + if (IsAcronymChar(stringToCompare, compareStringIndex)) + return true; + + if (IsAcronymNumber(stringToCompare, compareStringIndex)) + return compareStringIndex == 0 || char.IsWhiteSpace(stringToCompare[compareStringIndex - 1]); + + return false; + } + + private bool IsAcronymChar(string stringToCompare, int compareStringIndex) + => char.IsUpper(stringToCompare[compareStringIndex]) || + compareStringIndex == 0 || // 0 index means char is the start of the compare string, which is an acronym + char.IsWhiteSpace(stringToCompare[compareStringIndex - 1]); + + private bool IsAcronymNumber(string stringToCompare, int compareStringIndex) + => stringToCompare[compareStringIndex] >= 0 && stringToCompare[compareStringIndex] <= 9; + // To get the index of the closest space which preceeds the first matching index private int CalculateClosestSpaceIndex(List spaceIndices, int firstMatchIndex) { - if (spaceIndices.Count == 0) + var closestSpaceIndex = -1; + + // spaceIndices should be ordered asc + foreach (var index in spaceIndices) { - return -1; - } - else - { - int? ind = spaceIndices.OrderBy(item => (firstMatchIndex - item)).Where(item => firstMatchIndex > item).FirstOrDefault(); - int closestSpaceIndex = ind ?? -1; - return closestSpaceIndex; + if (index < firstMatchIndex) + closestSpaceIndex = index; + else + break; } + + return closestSpaceIndex; } private static bool AllPreviousCharsMatched(int startIndexToVerify, int currentQuerySubstringCharacterIndex, - string fullStringToCompareWithoutCase, string currentQuerySubstring) + string fullStringToCompareWithoutCase, string currentQuerySubstring) { var allMatch = true; for (int indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++) @@ -182,8 +272,9 @@ namespace Flow.Launcher.Infrastructure return allMatch; } - - private static List GetUpdatedIndexList(int startIndexToVerify, int currentQuerySubstringCharacterIndex, int firstMatchIndexInWord, List indexList) + + private static List GetUpdatedIndexList(int startIndexToVerify, int currentQuerySubstringCharacterIndex, + int firstMatchIndexInWord, List indexList) { var updatedList = new List(); @@ -201,10 +292,12 @@ namespace Flow.Launcher.Infrastructure private static bool AllQuerySubstringsMatched(int currentQuerySubstringIndex, int querySubstringsLength) { + // Acronym won't utilize the substring to match return currentQuerySubstringIndex >= querySubstringsLength; } - private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex, int matchLen, bool allSubstringsContainedInCompareString) + private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex, int matchLen, + bool allSubstringsContainedInCompareString) { // A match found near the beginning of a string is scored more than a match found near the end // A match is scored more if the characters in the patterns are closer to each other, diff --git a/Flow.Launcher.Test/FuzzyMatcherTest.cs b/Flow.Launcher.Test/FuzzyMatcherTest.cs index 14a0fd92a..bbddcbd2a 100644 --- a/Flow.Launcher.Test/FuzzyMatcherTest.cs +++ b/Flow.Launcher.Test/FuzzyMatcherTest.cs @@ -93,7 +93,8 @@ namespace Flow.Launcher.Test [TestCase("cand")] [TestCase("cpywa")] [TestCase("ccs")] - public void GivenQueryString_WhenAppliedPrecisionFiltering_ThenShouldReturnGreaterThanPrecisionScoreResults(string searchTerm) + public void GivenQueryString_WhenAppliedPrecisionFiltering_ThenShouldReturnGreaterThanPrecisionScoreResults( + string searchTerm) { var results = new List(); var matcher = new StringMatcher(); @@ -109,9 +110,9 @@ namespace Flow.Launcher.Test foreach (var precisionScore in GetPrecisionScores()) { var filteredResult = results.Where(result => result.Score >= precisionScore) - .Select(result => result) - .OrderByDescending(x => x.Score) - .ToList(); + .Select(result => result) + .OrderByDescending(x => x.Score) + .ToList(); Debug.WriteLine(""); Debug.WriteLine("###############################################"); @@ -120,6 +121,7 @@ namespace Flow.Launcher.Test { Debug.WriteLine("SCORE: " + item.Score.ToString() + ", FoundString: " + item.Title); } + Debug.WriteLine("###############################################"); Debug.WriteLine(""); @@ -129,20 +131,21 @@ namespace Flow.Launcher.Test [TestCase(Chrome, Chrome, 157)] [TestCase(Chrome, LastIsChrome, 147)] - [TestCase(Chrome, HelpCureHopeRaiseOnMindEntityChrome, 25)] + [TestCase("chro", HelpCureHopeRaiseOnMindEntityChrome, 50)] + [TestCase("chr", HelpCureHopeRaiseOnMindEntityChrome, 30)] [TestCase(Chrome, UninstallOrChangeProgramsOnYourComputer, 21)] [TestCase(Chrome, CandyCrushSagaFromKing, 0)] [TestCase("sql", MicrosoftSqlServerManagementStudio, 110)] - [TestCase("sql manag", MicrosoftSqlServerManagementStudio, 121)]//double spacing intended + [TestCase("sql manag", MicrosoftSqlServerManagementStudio, 121)] //double spacing intended public void WhenGivenQueryString_ThenShouldReturn_TheDesiredScoring( string queryString, string compareString, int expectedScore) { // When, Given - var matcher = new StringMatcher(); + var matcher = new StringMatcher {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; var rawScore = matcher.FuzzyMatch(queryString, compareString).RawScore; // Should - Assert.AreEqual(expectedScore, rawScore, + Assert.AreEqual(expectedScore, rawScore, $"Expected score for compare string '{compareString}': {expectedScore}, Actual: {rawScore}"); } @@ -154,8 +157,17 @@ namespace Flow.Launcher.Test [TestCase("chr", "Candy Crush Saga from King", SearchPrecisionScore.Regular, false)] [TestCase("chr", "Candy Crush Saga from King", SearchPrecisionScore.None, true)] [TestCase("ccs", "Candy Crush Saga from King", SearchPrecisionScore.Low, true)] - [TestCase("cand", "Candy Crush Saga from King",SearchPrecisionScore.Regular, true)] + [TestCase("cand", "Candy Crush Saga from King", SearchPrecisionScore.Regular, true)] [TestCase("cand", "Help cure hope raise on mind entity Chrome", SearchPrecisionScore.Regular, false)] + [TestCase("vsc", VisualStudioCode, SearchPrecisionScore.Regular, true)] + [TestCase("vs", VisualStudioCode, SearchPrecisionScore.Regular, true)] + [TestCase("vc", VisualStudioCode, SearchPrecisionScore.Regular, true)] + [TestCase("vts", VisualStudioCode, SearchPrecisionScore.Regular, false)] + [TestCase("vcs", VisualStudioCode, SearchPrecisionScore.Regular, false)] + [TestCase("wt", "Windows Terminal From Microsoft Store", SearchPrecisionScore.Regular, false)] + [TestCase("vsp", "Visual Studio 2019 Preview", SearchPrecisionScore.Regular, true)] + [TestCase("vsp", "2019 Visual Studio Preview", SearchPrecisionScore.Regular, true)] + [TestCase("2019p", "Visual Studio 2019 Preview", SearchPrecisionScore.Regular, true)] public void WhenGivenDesiredPrecision_ThenShouldReturn_AllResultsGreaterOrEqual( string queryString, string compareString, @@ -171,14 +183,15 @@ namespace Flow.Launcher.Test Debug.WriteLine(""); Debug.WriteLine("###############################################"); Debug.WriteLine($"QueryString: {queryString} CompareString: {compareString}"); - Debug.WriteLine($"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int)expectedPrecisionScore})"); + Debug.WriteLine( + $"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); Debug.WriteLine("###############################################"); Debug.WriteLine(""); // Should Assert.AreEqual(expectedPrecisionResult, matchResult.IsSearchPrecisionScoreMet(), - $"Query:{queryString}{Environment.NewLine} " + - $"Compare:{compareString}{Environment.NewLine}" + + $"Query: {queryString}{Environment.NewLine} " + + $"Compare: {compareString}{Environment.NewLine}" + $"Raw Score: {matchResult.RawScore}{Environment.NewLine}" + $"Precision Score: {(int)expectedPrecisionScore}"); } @@ -196,8 +209,9 @@ namespace Flow.Launcher.Test [TestCase("sql serv man", MicrosoftSqlServerManagementStudio, SearchPrecisionScore.Regular, true)] [TestCase("sql studio", MicrosoftSqlServerManagementStudio, SearchPrecisionScore.Regular, true)] [TestCase("mic", MicrosoftSqlServerManagementStudio, SearchPrecisionScore.Regular, true)] + [TestCase("mssms", MicrosoftSqlServerManagementStudio, SearchPrecisionScore.Regular, true)] + [TestCase("msms", MicrosoftSqlServerManagementStudio, SearchPrecisionScore.Regular, true)] [TestCase("chr", "Shutdown", SearchPrecisionScore.Regular, false)] - [TestCase("mssms", MicrosoftSqlServerManagementStudio, SearchPrecisionScore.Regular, false)] [TestCase("chr", "Change settings for text-to-speech and for speech recognition (if installed).", SearchPrecisionScore.Regular, false)] [TestCase("ch r", "Change settings for text-to-speech and for speech recognition (if installed).", SearchPrecisionScore.Regular, true)] [TestCase("a test", "This is a test", SearchPrecisionScore.Regular, true)] @@ -212,7 +226,7 @@ namespace Flow.Launcher.Test bool expectedPrecisionResult) { // When - var matcher = new StringMatcher { UserSettingSearchPrecision = expectedPrecisionScore }; + var matcher = new StringMatcher {UserSettingSearchPrecision = expectedPrecisionScore}; // Given var matchResult = matcher.FuzzyMatch(queryString, compareString); @@ -220,7 +234,8 @@ namespace Flow.Launcher.Test Debug.WriteLine(""); Debug.WriteLine("###############################################"); Debug.WriteLine($"QueryString: {queryString} CompareString: {compareString}"); - Debug.WriteLine($"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int)expectedPrecisionScore})"); + Debug.WriteLine( + $"RAW SCORE: {matchResult.RawScore.ToString()}, PrecisionLevelSetAt: {expectedPrecisionScore} ({(int) expectedPrecisionScore})"); Debug.WriteLine("###############################################"); Debug.WriteLine(""); @@ -239,7 +254,7 @@ namespace Flow.Launcher.Test string queryString, string compareString1, string compareString2) { // When - var matcher = new StringMatcher { UserSettingSearchPrecision = SearchPrecisionScore.Regular }; + var matcher = new StringMatcher {UserSettingSearchPrecision = SearchPrecisionScore.Regular}; // Given var compareString1Result = matcher.FuzzyMatch(queryString, compareString1); @@ -248,8 +263,10 @@ namespace Flow.Launcher.Test Debug.WriteLine(""); Debug.WriteLine("###############################################"); Debug.WriteLine($"QueryString: \"{queryString}\"{Environment.NewLine}"); - Debug.WriteLine($"CompareString1: \"{compareString1}\", Score: {compareString1Result.Score}{Environment.NewLine}"); - Debug.WriteLine($"CompareString2: \"{compareString2}\", Score: {compareString2Result.Score}{Environment.NewLine}"); + Debug.WriteLine( + $"CompareString1: \"{compareString1}\", Score: {compareString1Result.Score}{Environment.NewLine}"); + Debug.WriteLine( + $"CompareString2: \"{compareString2}\", Score: {compareString2Result.Score}{Environment.NewLine}"); Debug.WriteLine("###############################################"); Debug.WriteLine(""); @@ -257,13 +274,13 @@ namespace Flow.Launcher.Test Assert.True(compareString1Result.Score > compareString2Result.Score, $"Query: \"{queryString}\"{Environment.NewLine} " + $"CompareString1: \"{compareString1}\", Score: {compareString1Result.Score}{Environment.NewLine}" + - $"Should be greater than{ Environment.NewLine}" + + $"Should be greater than{Environment.NewLine}" + $"CompareString2: \"{compareString2}\", Score: {compareString1Result.Score}{Environment.NewLine}"); } [TestCase("vim", "Vim", "ignoreDescription", "ignore.exe", "Vim Diff", "ignoreDescription", "ignore.exe")] public void WhenMultipleResults_ExactMatchingResult_ShouldHaveGreatestScore( - string queryString, string firstName, string firstDescription, string firstExecutableName, + string queryString, string firstName, string firstDescription, string firstExecutableName, string secondName, string secondDescription, string secondExecutableName) { // Act @@ -276,15 +293,39 @@ namespace Flow.Launcher.Test var secondDescriptionMatch = matcher.FuzzyMatch(queryString, secondDescription).RawScore; var secondExecutableNameMatch = matcher.FuzzyMatch(queryString, secondExecutableName).RawScore; - var firstScore = new[] { firstNameMatch, firstDescriptionMatch, firstExecutableNameMatch }.Max(); - var secondScore = new[] { secondNameMatch, secondDescriptionMatch, secondExecutableNameMatch }.Max(); + var firstScore = new[] {firstNameMatch, firstDescriptionMatch, firstExecutableNameMatch}.Max(); + var secondScore = new[] {secondNameMatch, secondDescriptionMatch, secondExecutableNameMatch}.Max(); // Assert Assert.IsTrue(firstScore > secondScore, $"Query: \"{queryString}\"{Environment.NewLine} " + $"Name of first: \"{firstName}\", Final Score: {firstScore}{Environment.NewLine}" + - $"Should be greater than{ Environment.NewLine}" + + $"Should be greater than{Environment.NewLine}" + $"Name of second: \"{secondName}\", Final Score: {secondScore}{Environment.NewLine}"); } + + [TestCase("vsc", "Visual Studio Code", 100)] + [TestCase("jbr", "JetBrain Rider", 100)] + [TestCase("jr", "JetBrain Rider", 66)] + [TestCase("vs", "Visual Studio", 100)] + [TestCase("vs", "Visual Studio Preview", 66)] + [TestCase("vsp", "Visual Studio Preview", 100)] + [TestCase("pc", "postman canary", 100)] + [TestCase("psc", "Postman super canary", 100)] + [TestCase("psc", "Postman super Canary", 100)] + [TestCase("vsp", "Visual Studio", 0)] + [TestCase("vps", "Visual Studio", 0)] + [TestCase(Chrome, HelpCureHopeRaiseOnMindEntityChrome, 75)] + public void WhenGivenAnAcronymQuery_ShouldReturnAcronymScore(string queryString, string compareString, + int desiredScore) + { + var matcher = new StringMatcher(); + var score = matcher.FuzzyMatch(queryString, compareString).Score; + Assert.IsTrue(score == desiredScore, + $@"Query: ""{queryString}"" + CompareString: ""{compareString}"" + Score: {score} + Desired Score: {desiredScore}"); + } } -} \ No newline at end of file +} diff --git a/Flow.Launcher/Flow.Launcher.csproj b/Flow.Launcher/Flow.Launcher.csproj index fddaaab6a..289a502d0 100644 --- a/Flow.Launcher/Flow.Launcher.csproj +++ b/Flow.Launcher/Flow.Launcher.csproj @@ -1,4 +1,4 @@ - + WinExe diff --git a/Flow.Launcher/ViewModel/MainViewModel.cs b/Flow.Launcher/ViewModel/MainViewModel.cs index 8195c745a..05dbb3a8b 100644 --- a/Flow.Launcher/ViewModel/MainViewModel.cs +++ b/Flow.Launcher/ViewModel/MainViewModel.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; +using System.Threading.Tasks.Dataflow; using System.Windows; using System.Windows.Input; using NHotkey; @@ -18,7 +19,6 @@ using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedCommands; using Flow.Launcher.Storage; using Flow.Launcher.Infrastructure.Logger; -using System.Threading.Tasks.Dataflow; namespace Flow.Launcher.ViewModel { @@ -45,6 +45,7 @@ namespace Flow.Launcher.ViewModel private bool _saved; private readonly Internationalization _translator = InternationalizationManager.Instance; + private BufferBlock _resultsUpdateQueue; private Task _resultsViewUpdateTask; @@ -74,30 +75,14 @@ namespace Flow.Launcher.ViewModel _selectedResults = Results; InitializeKeyCommands(); - RegisterViewUpdate(); RegisterResultsUpdatedEvent(); - SetHotkey(_settings.Hotkey, OnHotkey); SetCustomPluginHotkey(); SetOpenResultModifiers(); } - private void RegisterResultsUpdatedEvent() - { - foreach (var pair in PluginManager.GetPluginsForInterface()) - { - var plugin = (IResultUpdated) pair.Plugin; - plugin.ResultsUpdated += (s, e) => - { - PluginManager.UpdatePluginMetadata(e.Results, pair.Metadata, e.Query); - if (e.Query.Search == _lastQuery.Search) - _resultsUpdateQueue.Post(new ResultsForUpdate(e.Results, pair.Metadata, e.Query, _updateToken)); - }; - } - } - private void RegisterViewUpdate() { _resultsUpdateQueue = new BufferBlock(); @@ -136,6 +121,22 @@ namespace Flow.Launcher.ViewModel } } + private void RegisterResultsUpdatedEvent() + { + foreach (var pair in PluginManager.GetPluginsForInterface()) + { + var plugin = (IResultUpdated)pair.Plugin; + plugin.ResultsUpdated += (s, e) => + { + if (e.Query.RawQuery == QueryText) // TODO: allow cancellation + { + PluginManager.UpdatePluginMetadata(e.Results, pair.Metadata, e.Query); + _resultsUpdateQueue.Post(new ResultsForUpdate(e.Results, pair.Metadata, e.Query, _updateToken)); + } + }; + } + } + private void InitializeKeyCommands() { @@ -233,7 +234,6 @@ namespace Flow.Launcher.ViewModel public ResultsViewModel Results { get; private set; } public ResultsViewModel ContextMenu { get; private set; } public ResultsViewModel History { get; private set; } - private string _lastQueryText; private string _queryText; @@ -393,7 +393,7 @@ namespace Flow.Launcher.ViewModel Title = string.Format(title, h.Query), SubTitle = string.Format(time, h.ExecutedDateTime), IcoPath = "Images\\history.png", - OriginQuery = new Query {RawQuery = h.Query}, + OriginQuery = new Query { RawQuery = h.Query }, Action = _ => { SelectedResults = Results; diff --git a/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj b/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj index 2e352d832..cb2507a2b 100644 --- a/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj +++ b/Plugins/Flow.Launcher.Plugin.PluginsManager/Flow.Launcher.Plugin.PluginsManager.csproj @@ -1,5 +1,4 @@  - Library netcoreapp3.1 diff --git a/Plugins/Flow.Launcher.Plugin.Program/Programs/UWP.cs b/Plugins/Flow.Launcher.Plugin.Program/Programs/UWP.cs index 3ea78156d..5db26aa70 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/Programs/UWP.cs +++ b/Plugins/Flow.Launcher.Plugin.Program/Programs/UWP.cs @@ -18,6 +18,7 @@ using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin.Program.Logger; using IStream = AppxPackaing.IStream; using Rect = System.Windows.Rect; +using Flow.Launcher.Plugin.SharedModels; namespace Flow.Launcher.Plugin.Program.Programs { @@ -206,12 +207,11 @@ namespace Flow.Launcher.Plugin.Program.Programs } catch (Exception e) { - ProgramLogger.LogException("UWP" ,"CurrentUserPackages", $"id","An unexpected error occured and " + ProgramLogger.LogException("UWP", "CurrentUserPackages", $"id", "An unexpected error occured and " + $"unable to verify if package is valid", e); return false; } - - + return valid; }); return ps; @@ -263,21 +263,40 @@ namespace Flow.Launcher.Plugin.Program.Programs public string LogoPath { get; set; } public UWP Package { get; set; } - public Application(){} + public Application() { } public Result Result(string query, IPublicAPI api) { - var title = (Name, Description) switch - { - (var n, null) => n, - (var n, var d) when d.StartsWith(n) => d, - (var n, var d) when n.StartsWith(d) => n, - (var n, var d) when !string.IsNullOrEmpty(d) => $"{n}: {d}", - _ => Name - }; + string title; + MatchResult matchResult; - var matchResult = StringMatcher.FuzzySearch(query, title); + // We suppose Name won't be null + if (Description == null || Name.StartsWith(Description)) + { + title = Name; + matchResult = StringMatcher.FuzzySearch(query, title); + } + else if (Description.StartsWith(Name)) + { + title = Description; + matchResult = StringMatcher.FuzzySearch(query, Description); + } + else + { + title = $"{Name}: {Description}"; + var nameMatch = StringMatcher.FuzzySearch(query, Name); + var desciptionMatch = StringMatcher.FuzzySearch(query, Description); + if (desciptionMatch.Score > nameMatch.Score) + { + for (int i = 0; i < desciptionMatch.MatchData.Count; i++) + { + desciptionMatch.MatchData[i] += Name.Length + 2; // 2 is ": " + } + matchResult = desciptionMatch; + } + else matchResult = nameMatch; + } if (!matchResult.Success) return null; @@ -311,7 +330,7 @@ namespace Flow.Launcher.Plugin.Program.Programs Action = _ => { - Main.StartProcess(Process.Start, + Main.StartProcess(Process.Start, new ProcessStartInfo( !string.IsNullOrEmpty(Main._settings.CustomizedExplorer) ? Main._settings.CustomizedExplorer @@ -403,14 +422,14 @@ namespace Flow.Launcher.Plugin.Program.Programs public string FormattedPriReferenceValue(string packageName, string rawPriReferenceValue) { const string prefix = "ms-resource:"; - + if (string.IsNullOrWhiteSpace(rawPriReferenceValue) || !rawPriReferenceValue.StartsWith(prefix)) return rawPriReferenceValue; string key = rawPriReferenceValue.Substring(prefix.Length); if (key.StartsWith("//")) return $"{prefix}{key}"; - + if (!key.StartsWith("/")) { key = $"/{key}"; diff --git a/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs b/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs index 77278330a..fd994aeb3 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs +++ b/Plugins/Flow.Launcher.Plugin.Program/Programs/Win32.cs @@ -12,6 +12,7 @@ using Shell; using Flow.Launcher.Infrastructure; using Flow.Launcher.Plugin.Program.Logger; using Flow.Launcher.Plugin.SharedCommands; +using Flow.Launcher.Plugin.SharedModels; namespace Flow.Launcher.Plugin.Program.Programs { @@ -36,19 +37,38 @@ namespace Flow.Launcher.Plugin.Program.Programs public Result Result(string query, IPublicAPI api) { - var title = (Name, Description) switch + string title; + MatchResult matchResult; + + // We suppose Name won't be null + if (Description == null || Name.StartsWith(Description)) { - (var n, null) => n, - (var n, var d) when d.StartsWith(n) => d, - (var n, var d) when n.StartsWith(d) => n, - (var n, var d) when !string.IsNullOrEmpty(d) => $"{n}: {d}", - _ => Name - }; + title = Name; + matchResult = StringMatcher.FuzzySearch(query, title); + } + else if (Description.StartsWith(Name)) + { + title = Description; + matchResult = StringMatcher.FuzzySearch(query, Description); + } + else + { + title = $"{Name}: {Description}"; + var nameMatch = StringMatcher.FuzzySearch(query, Name); + var desciptionMatch = StringMatcher.FuzzySearch(query, Description); + if (desciptionMatch.Score > nameMatch.Score) + { + for (int i = 0; i < desciptionMatch.MatchData.Count; i++) + { + desciptionMatch.MatchData[i] += Name.Length + 2; // 2 is ": " + } + matchResult = desciptionMatch; + } + else matchResult = nameMatch; + } - var matchResult = StringMatcher.FuzzySearch(query, title); + if (!matchResult.Success) return null; - if (!matchResult.Success) - return null; var result = new Result { @@ -58,7 +78,7 @@ namespace Flow.Launcher.Plugin.Program.Programs Score = matchResult.Score, TitleHighlightData = matchResult.MatchData, ContextData = this, - Action = e => + Action = _ => { var info = new ProcessStartInfo { @@ -268,10 +288,10 @@ namespace Flow.Launcher.Plugin.Program.Programs try { var paths = Directory.EnumerateFiles(directory, "*", new EnumerationOptions - { - IgnoreInaccessible = true, - RecurseSubdirectories = true - }) + { + IgnoreInaccessible = true, + RecurseSubdirectories = true + }) .Where(x => suffixes.Contains(Extension(x))); return paths; diff --git a/Plugins/Flow.Launcher.Plugin.Program/plugin.json b/Plugins/Flow.Launcher.Plugin.Program/plugin.json index 1dedd9909..f713a33ec 100644 --- a/Plugins/Flow.Launcher.Plugin.Program/plugin.json +++ b/Plugins/Flow.Launcher.Plugin.Program/plugin.json @@ -4,7 +4,7 @@ "Name": "Program", "Description": "Search programs in Flow.Launcher", "Author": "qianlifeng", - "Version": "1.3.0", + "Version": "1.4.0", "Language": "csharp", "Website": "https://github.com/Flow-Launcher/Flow.Launcher", "ExecuteFileName": "Flow.Launcher.Plugin.Program.dll", diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj b/Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj index c542dc89b..d3042722b 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj +++ b/Plugins/Flow.Launcher.Plugin.Shell/Flow.Launcher.Plugin.Shell.csproj @@ -54,10 +54,10 @@ PreserveNewest - - MSBuild:Compile - Designer - + + MSBuild:Compile + Designer + diff --git a/Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj b/Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj index 8ef4dc0fb..c25e759d3 100644 --- a/Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj +++ b/Plugins/Flow.Launcher.Plugin.Sys/Flow.Launcher.Plugin.Sys.csproj @@ -48,10 +48,10 @@ PreserveNewest - - MSBuild:Compile - Designer - + + MSBuild:Compile + Designer +