From f08c287b6448f0505c4c6cf3536a470ddb34f650 Mon Sep 17 00:00:00 2001 From: Eric Nelson Date: Sun, 16 Mar 2025 15:53:27 -0700 Subject: [PATCH 1/5] Update command palette search to use fzf algo --- .github/actions/spelling/excludes.txt | 1 + .github/actions/spelling/expect/expect.txt | 4 + src/cascadia/TerminalApp/FilteredCommand.cpp | 196 ++++----- .../TerminalApp/TerminalAppLib.vcxproj | 3 + .../TerminalAppLib.vcxproj.filters | 12 + src/cascadia/TerminalApp/fzf/LICENSE | 22 + src/cascadia/TerminalApp/fzf/fzf.cpp | 379 ++++++++++++++++++ src/cascadia/TerminalApp/fzf/fzf.h | 27 ++ src/cascadia/ut_app/FzfTests.cpp | 379 ++++++++++++++++++ .../ut_app/TerminalApp.UnitTests.vcxproj | 1 + 10 files changed, 903 insertions(+), 121 deletions(-) create mode 100644 src/cascadia/TerminalApp/fzf/LICENSE create mode 100644 src/cascadia/TerminalApp/fzf/fzf.cpp create mode 100644 src/cascadia/TerminalApp/fzf/fzf.h create mode 100644 src/cascadia/ut_app/FzfTests.cpp diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 896211b8f1..c142f11548 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -133,3 +133,4 @@ Resources/(?!en) ^\Qsrc/terminal/parser/ft_fuzzwrapper/run.bat\E$ ^\Qsrc/tools/lnkd/lnkd.bat\E$ ^\Qsrc/tools/pixels/pixels.bat\E$ +^\Qsrc/cascadia/ut_app/FzfTests.cpp\E$ diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt index e78cb97139..da62f0999f 100644 --- a/.github/actions/spelling/expect/expect.txt +++ b/.github/actions/spelling/expect/expect.txt @@ -649,6 +649,7 @@ FONTSTRING FONTTYPE FONTWIDTH FONTWINDOW +foob FORCEOFFFEEDBACK FORCEONFEEDBACK FRAMECHANGED @@ -666,9 +667,11 @@ fuzzer fuzzmain fuzzmap fuzzwrapper +fuzzyfinder fwdecl fwe fwlink +fzf gci gcx gdi @@ -1246,6 +1249,7 @@ onecoreuuid ONECOREWINDOWS onehalf oneseq +oob openbash opencode opencon diff --git a/src/cascadia/TerminalApp/FilteredCommand.cpp b/src/cascadia/TerminalApp/FilteredCommand.cpp index d6c6c38a28..266bfb601a 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.cpp +++ b/src/cascadia/TerminalApp/FilteredCommand.cpp @@ -5,6 +5,7 @@ #include "CommandPalette.h" #include "HighlightedText.h" #include +#include "fzf/fzf.h" #include "FilteredCommand.g.cpp" @@ -57,15 +58,16 @@ namespace winrt::TerminalApp::implementation if (filter != _Filter) { Filter(filter); - HighlightedName(_computeHighlightedName()); Weight(_computeWeight()); + HighlightedName(_computeHighlightedName()); } } // Method Description: // - Looks up the filter characters within the item name. - // Iterating through the filter and the item name it tries to associate the next filter character - // with the first appearance of this character in the item name suffix. + // Using the fzf algorithm to traceback from the maximum score to highlight the chars with the + // optimal match. (Preference is given to word boundaries, consecutive chars and special characters + // while penalties are given for gaps) // // E.g., for filter="c l t s" and name="close all tabs after this", the match will be "CLose TabS after this". // @@ -77,85 +79,45 @@ namespace winrt::TerminalApp::implementation // // E.g., ("CL", true) ("ose ", false), ("T", true), ("ab", false), ("S", true), ("after this", false) // - // TODO: we probably need to merge this logic with _getWeight computation? - // // Return Value: // - The HighlightedText object initialized with the segments computed according to the algorithm above. winrt::TerminalApp::HighlightedText FilteredCommand::_computeHighlightedName() { const auto segments = winrt::single_threaded_observable_vector(); auto commandName = _Item.Name(); - auto isProcessingMatchedSegment = false; - uint32_t nextOffsetToReport = 0; - uint32_t currentOffset = 0; - for (const auto searchChar : _Filter) + if (Weight() == 0) { - const WCHAR searchCharAsString[] = { searchChar, L'\0' }; - while (true) - { - if (currentOffset == commandName.size()) - { - // There are still unmatched filter characters but we finished scanning the name. - // In this case we return the entire item name as unmatched - auto entireNameSegment{ winrt::make(commandName, false) }; - segments.Clear(); - segments.Append(entireNameSegment); - return winrt::make(segments); - } - - // GH#9941: search should be locale-aware as well - // We use the same comparison method as upon sorting to guarantee consistent behavior - const WCHAR currentCharAsString[] = { commandName[currentOffset], L'\0' }; - auto isCurrentCharMatched = lstrcmpi(searchCharAsString, currentCharAsString) == 0; - if (isProcessingMatchedSegment != isCurrentCharMatched) - { - // We reached the end of the region (matched character came after a series of unmatched or vice versa). - // Conclude the segment and add it to the list. - // Skip segment if it is empty (might happen when the first character of the name is matched) - auto sizeToReport = currentOffset - nextOffsetToReport; - if (sizeToReport > 0) - { - winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport }; - auto highlightedSegment{ winrt::make(segment, isProcessingMatchedSegment) }; - segments.Append(highlightedSegment); - nextOffsetToReport = currentOffset; - } - isProcessingMatchedSegment = isCurrentCharMatched; - } - - currentOffset++; - - if (isCurrentCharMatched) - { - // We have matched this filter character, let's move to matching the next filter char - break; - } - } + segments.Append(winrt::TerminalApp::HighlightedTextSegment(commandName, false)); + return winrt::make(segments); } - // Either the filter or the item name were fully processed. - // If we were in the middle of the matched segment - add it. - if (isProcessingMatchedSegment) + auto pattern = fzf::matcher::ParsePattern(Filter()); + auto positions = fzf::matcher::GetPositions(commandName, pattern); + // positions are returned is sorted pairs by search term. E.g. sp anta {5,4,11,10,9,8} + // sorting these in ascending order so it is easier to build the text segments + std::ranges::sort(positions, std::less<>()); + // a position can be matched in multiple terms, removed duplicates to simplify segments + positions.erase(std::unique(positions.begin(), positions.end()), positions.end()); + size_t lastPosition = 0; + for (auto position : positions) { - auto sizeToReport = currentOffset - nextOffsetToReport; - if (sizeToReport > 0) + if (position > lastPosition) { - winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport }; - auto highlightedSegment{ winrt::make(segment, true) }; - segments.Append(highlightedSegment); - nextOffsetToReport = currentOffset; + hstring nonMatchSegment{ commandName.data() + lastPosition, static_cast(position - lastPosition) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(nonMatchSegment, false)); } + + hstring matchSegment{ commandName.data() + position, 1 }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(matchSegment, true)); + + lastPosition = position + 1; } - // Now create a segment for all remaining characters. - // We will have remaining characters as long as the filter is shorter than the item name. - auto sizeToReport = commandName.size() - nextOffsetToReport; - if (sizeToReport > 0) + if (lastPosition < commandName.size()) { - winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport }; - auto highlightedSegment{ winrt::make(segment, false) }; - segments.Append(highlightedSegment); + hstring segment{ commandName.data() + lastPosition, static_cast(commandName.size() - lastPosition) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(segment, false)); } return winrt::make(segments); @@ -164,37 +126,50 @@ namespace winrt::TerminalApp::implementation // Function Description: // - Calculates a "weighting" by which should be used to order a item // name relative to other names, given a specific search string. - // Currently, this is based off of two factors: - // * The weight is incremented once for each matched character of the - // search text. - // * If a matching character from the search text was found at the start - // of a word in the name, then we increment the weight again. - // * For example, for a search string "sp", we want "Split Pane" to - // appear in the list before "Close Pane" - // * Consecutive matches will be weighted higher than matches with - // characters in between the search characters. - // - This will return 0 if the item should not be shown. If all the - // characters of search text appear in order in `name`, then this function - // will return a positive number. There can be any number of characters - // separating consecutive characters in searchText. - // * For example: - // "name": "New Tab" - // "name": "Close Tab" - // "name": "Close Pane" - // "name": "[-] Split Horizontal" - // "name": "[ | ] Split Vertical" - // "name": "Next Tab" - // "name": "Prev Tab" - // "name": "Open Settings" - // "name": "Open Media Controls" - // * "open" should return both "**Open** Settings" and "**Open** Media Controls". - // * "Tab" would return "New **Tab**", "Close **Tab**", "Next **Tab**" and "Prev - // **Tab**". - // * "P" would return "Close **P**ane", "[-] S**p**lit Horizontal", "[ | ] - // S**p**lit Vertical", "**P**rev Tab", "O**p**en Settings" and "O**p**en Media - // Controls". - // * "sv" would return "[ | ] Split Vertical" (by matching the **S** in - // "Split", then the **V** in "Vertical"). + // Currently, this uses a derivative of the fzf implementation of the Smith Waterman algorithm: + // - This will return 0 if the item should not be shown. + // + // Factors that affect a score (Taken from the fzf repository) + // Scoring criteria + // ---------------- + // + // - We prefer matches at special positions, such as the start of a word, or + // uppercase character in camelCase words. + // - Note everything is converted to lower case so this does not apply + // + // - That is, we prefer an occurrence of the pattern with more characters + // matching at special positions, even if the total match length is longer. + // e.g. "fuzzyfinder" vs. "fuzzy-finder" on "ff" + // ```````````` + // - Also, if the first character in the pattern appears at one of the special + // positions, the bonus point for the position is multiplied by a constant + // as it is extremely likely that the first character in the typed pattern + // has more significance than the rest. + // e.g. "fo-bar" vs. "foob-r" on "br" + // `````` + // - But since fzf is still a fuzzy finder, not an acronym finder, we should also + // consider the total length of the matched substring. This is why we have the + // gap penalty. The gap penalty increases as the length of the gap (distance + // between the matching characters) increases, so the effect of the bonus is + // eventually cancelled at some point. + // e.g. "fuzzyfinder" vs. "fuzzy-blurry-finder" on "ff" + // ``````````` + // - Consequently, it is crucial to find the right balance between the bonus + // and the gap penalty. The parameters were chosen that the bonus is cancelled + // when the gap size increases beyond 8 characters. + // + // - The bonus mechanism can have the undesirable side effect where consecutive + // matches are ranked lower than the ones with gaps. + // e.g. "foobar" vs. "foo-bar" on "foob" + // ``````` + // - To correct this anomaly, we also give extra bonus point to each character + // in a consecutive matching chunk. + // e.g. "foobar" vs. "foo-bar" on "foob" + // `````` + // - The amount of consecutive bonus is primarily determined by the bonus of the + // first character in the chunk. + // e.g. "foobar" vs. "out-of-bound" on "oob" + // ```````````` // Arguments: // - searchText: the string of text to search for in `name` // - name: the name to check @@ -202,30 +177,9 @@ namespace winrt::TerminalApp::implementation // - the relative weight of this match int FilteredCommand::_computeWeight() { - auto result = 0; - auto isNextSegmentWordBeginning = true; - - for (const auto& segment : _HighlightedName.Segments()) - { - const auto& segmentText = segment.TextSegment(); - const auto segmentSize = segmentText.size(); - - if (segment.IsHighlighted()) - { - // Give extra point for each consecutive match - result += (segmentSize <= 1) ? segmentSize : 1 + 2 * (segmentSize - 1); - - // Give extra point if this segment is at the beginning of a word - if (isNextSegmentWordBeginning) - { - result++; - } - } - - isNextSegmentWordBeginning = segmentSize > 0 && segmentText[segmentSize - 1] == L' '; - } - - return result; + auto pattern = fzf::matcher::ParsePattern(Filter()); + auto score = fzf::matcher::GetScore(Item().Name(), pattern); + return score; } // Function Description: diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj index 0896bc114a..67ac4e82a4 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj @@ -138,6 +138,8 @@ + + @@ -212,6 +214,7 @@ TabBase.idl + TaskbarState.idl diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters index 12b3fa2add..6dbf62453d 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters @@ -41,6 +41,9 @@ highlightedText + + fzf + @@ -77,6 +80,12 @@ highlightedText + + fzf + + + fzf + @@ -176,6 +185,9 @@ {e490f626-547d-4b5b-b22d-c6d33c9e3210} + + {e4588ff4-c80a-40f7-be57-3e81f570a93d} + diff --git a/src/cascadia/TerminalApp/fzf/LICENSE b/src/cascadia/TerminalApp/fzf/LICENSE new file mode 100644 index 0000000000..04ac2144c3 --- /dev/null +++ b/src/cascadia/TerminalApp/fzf/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2013-2024 Junegunn Choi +Copyright (c) 2021-2025 Simon Hauser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/cascadia/TerminalApp/fzf/fzf.cpp b/src/cascadia/TerminalApp/fzf/fzf.cpp new file mode 100644 index 0000000000..dc5b782863 --- /dev/null +++ b/src/cascadia/TerminalApp/fzf/fzf.cpp @@ -0,0 +1,379 @@ +#include "pch.h" +#include "fzf.h" +#include +#include + +namespace fzf +{ + namespace matcher + { + constexpr int ScoreMatch = 16; + constexpr int ScoreGapStart = -3; + constexpr int ScoreGapExtension = -1; + constexpr int BoundaryBonus = ScoreMatch / 2; + constexpr int NonWordBonus = ScoreMatch / 2; + constexpr int CamelCaseBonus = BoundaryBonus + ScoreGapExtension; + constexpr int BonusConsecutive = -(ScoreGapStart + ScoreGapExtension); + constexpr int BonusFirstCharMultiplier = 2; + + enum CharClass + { + NonWord = 0, + CharLower = 1, + CharUpper = 2, + Digit = 3, + }; + + std::wstring_view TrimStart(const std::wstring_view str) + { + size_t start = str.find_first_not_of(L" "); + return (start == std::wstring::npos) ? std::wstring_view() : std::wstring_view(str).substr(start); + } + + std::wstring_view TrimSuffixSpaces(std::wstring_view input) + { + size_t end = input.size(); + while (end > 0 && input[end - 1] == L' ') + --end; + return input.substr(0, end); + } + + int IndexOfChar(std::wstring_view input, const WCHAR value[], int startIndex) + { + for (size_t i = startIndex; i < input.size(); i++) + { + const WCHAR currentCharAsString[] = { input[i], L'\0' }; + auto isCurrentCharMatched = lstrcmpi(value, currentCharAsString) == 0; + if (isCurrentCharMatched) + return static_cast(i); + } + return -1; + } + + int FuzzyIndexOf(std::wstring_view input, std::wstring_view pattern) + { + int idx = 0; + int firstIdx = 0; + for (size_t patternIndex = 0; patternIndex < pattern.size(); patternIndex++) + { + // GH#9941: search should be locale-aware as well + // We use the same comparison method as upon sorting to guarantee consistent behavior + const WCHAR searchCharAsString[] = { pattern[patternIndex], L'\0' }; + //const WCHAR currentCharAsString[] = { currentChar, L'\0' }; + idx = IndexOfChar(input, searchCharAsString, idx); + if (idx < 0) + return -1; + if (patternIndex == 0 && idx > 0) + firstIdx = idx - 1; + idx++; + } + return firstIdx; + } + + int CalculateBonus(CharClass prevClass, CharClass currentClass) + { + if (prevClass == NonWord && currentClass != NonWord) + return BoundaryBonus; + if ((prevClass == CharLower && currentClass == CharUpper) || + (prevClass != Digit && currentClass == Digit)) + return CamelCaseBonus; + if (currentClass == NonWord) + return NonWordBonus; + return 0; + } + + CharClass ClassOf(wchar_t ch) + { + if (std::iswlower(ch)) + return CharLower; + if (std::iswupper(ch)) + return CharUpper; + if (std::iswdigit(ch)) + return Digit; + return NonWord; + } + + FzfResult FzfFuzzyMatchV2(std::wstring_view text, std::wstring_view pattern, std::vector* pos) + { + size_t patternSize = pattern.size(); + size_t textSize = text.size(); + + if (patternSize == 0) + return { 0, 0, 0 }; + + int firstIndexOf = FuzzyIndexOf(text, pattern); + if (firstIndexOf < 0) + return { -1, -1, 0 }; + + auto initialScores = std::vector(textSize); + auto consecutiveScores = std::vector(textSize); + auto firstOccurrenceOfEachChar = std::vector(patternSize); + int maxScore = 0; + int maxScorePos = 0; + size_t patternIndex = 0; + int lastIndex = 0; + wchar_t firstPatternChar = pattern[0]; + wchar_t currentPatternChar = pattern[0]; + int previousInitialScore = 0; + CharClass previousClass = NonWord; + bool inGap = false; + + auto textCopy = std::wstring{ text }; + std::ranges::transform(textCopy, textCopy.begin(), [](wchar_t c) { + return std::towlower(c); + }); + std::wstring_view lowerText(textCopy.data(), textCopy.size()); + std::wstring_view lowerTextSlice = lowerText.substr(firstIndexOf, textCopy.size() - firstIndexOf); + auto initialScoresSlice = std::span(initialScores).subspan(firstIndexOf); + auto consecutiveScoresSlice = std::span(consecutiveScores).subspan(firstIndexOf); + auto bonusesSpan = std::vector(textSize); + auto bonusesSlice = std::span(bonusesSpan).subspan(firstIndexOf, textSize - firstIndexOf); + + for (size_t i = 0; i < lowerTextSlice.size(); i++) + { + wchar_t currentChar = lowerTextSlice[i]; + CharClass currentClass = ClassOf(currentChar); + int bonus = CalculateBonus(previousClass, currentClass); + bonusesSlice[i] = bonus; + previousClass = currentClass; + + // GH#9941: search should be locale-aware as well + // We use the same comparison method as upon sorting to guarantee consistent behavior + const WCHAR searchCharAsString[] = { currentPatternChar, L'\0' }; + const WCHAR currentCharAsString[] = { currentChar, L'\0' }; + auto isCurrentCharMatched = lstrcmpi(searchCharAsString, currentCharAsString) == 0; + if (isCurrentCharMatched) + { + if (patternIndex < pattern.size()) + { + firstOccurrenceOfEachChar[patternIndex] = firstIndexOf + static_cast(i); + patternIndex++; + if (patternIndex < patternSize) + currentPatternChar = pattern[patternIndex]; + } + lastIndex = firstIndexOf + static_cast(i); + } + if (currentChar == firstPatternChar) + { + int score = ScoreMatch + bonus * BonusFirstCharMultiplier; + initialScoresSlice[i] = score; + consecutiveScoresSlice[i] = 1; + if (patternSize == 1 && (score > maxScore)) + { + maxScore = score; + maxScorePos = firstIndexOf + static_cast(i); + if (bonus == BoundaryBonus) + break; + } + inGap = false; + } + else + { + initialScoresSlice[i] = inGap ? std::max(previousInitialScore + ScoreGapExtension, 0) : std::max(previousInitialScore + ScoreGapStart, 0); + consecutiveScoresSlice[i] = 0; + inGap = true; + } + previousInitialScore = initialScoresSlice[i]; + } + + if (patternIndex != pattern.size()) + return { -1, -1, 0 }; + + if (pattern.size() == 1) + { + if (pos) + pos->push_back(maxScorePos); + return { maxScorePos, maxScorePos + 1, maxScore }; + } + + int firstOccurrenceOfFirstChar = firstOccurrenceOfEachChar[0]; + int width = lastIndex - firstOccurrenceOfFirstChar + 1; + auto scoreMatrix = std::vector(width * patternSize); + std::copy_n(initialScores.begin() + firstOccurrenceOfFirstChar, + width, + scoreMatrix.begin()); + auto scoreSpan = std::span(scoreMatrix); + auto consecutiveCharMatrixSize = width * patternSize; + auto consecutiveCharMatrix = std::vector(consecutiveCharMatrixSize); + std::copy(consecutiveScores.begin() + firstOccurrenceOfFirstChar, + consecutiveScores.begin() + lastIndex, + consecutiveCharMatrix.begin()); + auto consecutiveCharMatrixSpan = std::span(consecutiveCharMatrix); + + std::wstring_view patternSliceStr = pattern.substr(1); + for (size_t off = 0; off < patternSize - 1; off++) + { + int patternCharOffset = firstOccurrenceOfEachChar[off + 1]; + currentPatternChar = patternSliceStr[off]; + patternIndex = off + 1; + size_t row = patternIndex * width; + inGap = false; + std::wstring_view textSlice = lowerText.substr(patternCharOffset, lastIndex - patternCharOffset + 1); + std::span bonusSlice(bonusesSpan.begin() + patternCharOffset, textSlice.size()); + std::span consecutiveCharMatrixSlice = consecutiveCharMatrixSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size()); + std::span consecutiveCharMatrixDiagonalSlice = consecutiveCharMatrixSpan.subspan( + +row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size()); + std::span scoreMatrixSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size()); + std::span scoreMatrixDiagonalSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size()); + std::span scoreMatrixLeftSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1, textSlice.size()); + if (!scoreMatrixLeftSlice.empty()) + scoreMatrixLeftSlice[0] = 0; + for (size_t j = 0; j < textSlice.size(); j++) + { + wchar_t currentChar = textSlice[j]; + int column = patternCharOffset + static_cast(j); + int score = inGap ? scoreMatrixLeftSlice[j] + ScoreGapExtension : scoreMatrixLeftSlice[j] + ScoreGapStart; + int diagonalScore = 0; + int consecutive = 0; + if (currentChar == currentPatternChar) + { + diagonalScore = scoreMatrixDiagonalSlice[j] + ScoreMatch; + int bonus = bonusSlice[j]; + consecutive = consecutiveCharMatrixDiagonalSlice[j] + 1; + if (bonus == BoundaryBonus) + consecutive = 1; + else if (consecutive > 1) + bonus = std::max({ bonus, BonusConsecutive, static_cast(bonusesSpan[(column - consecutive) + 1]) }); + if (diagonalScore + bonus < score) + { + diagonalScore += bonusSlice[j]; + consecutive = 0; + } + else + { + diagonalScore += bonus; + } + } + consecutiveCharMatrixSlice[j] = consecutive; + inGap = (diagonalScore < score); + int cellScore = std::max(0, std::max(diagonalScore, score)); + if (off == patternSize - 2 && cellScore > maxScore) + { + maxScore = cellScore; + maxScorePos = column; + } + scoreMatrixSlice[j] = cellScore; + } + } + + int currentColIndex = maxScorePos; + if (pos) + { + auto patternIndex = patternSize - 1; + bool preferCurrentMatch = true; + while (true) + { + size_t rowStartIndex = patternIndex * width; + int colOffset = currentColIndex - firstOccurrenceOfFirstChar; + int cellScore = scoreMatrix[rowStartIndex + colOffset]; + int diagonalCellScore = 0, leftCellScore = 0; + + if (patternIndex > 0 && currentColIndex >= firstOccurrenceOfEachChar[patternIndex]) + diagonalCellScore = scoreMatrix[rowStartIndex - width + colOffset - 1]; + if (currentColIndex > firstOccurrenceOfEachChar[patternIndex]) + leftCellScore = scoreMatrix[rowStartIndex + colOffset - 1]; + + if (cellScore > diagonalCellScore && + (cellScore > leftCellScore || (cellScore == leftCellScore && preferCurrentMatch))) + { + pos->push_back(currentColIndex); + if (patternIndex == 0) + break; + patternIndex--; + } + + currentColIndex--; + if (rowStartIndex + colOffset >= static_cast(consecutiveCharMatrixSize)) + break; + + preferCurrentMatch = (consecutiveCharMatrix[rowStartIndex + colOffset] > 1) || + ((rowStartIndex + width + colOffset + 1 < + static_cast(consecutiveCharMatrixSize)) && + (consecutiveCharMatrix[rowStartIndex + width + colOffset + 1] > 0)); + } + } + return { currentColIndex, maxScorePos + 1, maxScore }; + } + + int BonusAt(std::wstring_view input, int idx) + { + if (idx == 0) + return BoundaryBonus; + return CalculateBonus(ClassOf(input[idx - 1]), ClassOf(input[idx])); + } + + int LeadingWhiteSpace(std::wstring_view text) + { + int whiteSpaces = 0; + for (wchar_t c : text) + { + if (c != L' ') + break; + whiteSpaces++; + } + return whiteSpaces; + } + + Pattern ParsePattern(const std::wstring_view patternStr) + { + Pattern patObj; + if (patternStr.empty()) + return patObj; + + std::wstring_view trimmedPatternStr = TrimStart(patternStr); + trimmedPatternStr = TrimSuffixSpaces(trimmedPatternStr); + size_t pos = 0, found; + while ((found = trimmedPatternStr.find(L' ', pos)) != std::wstring::npos) + { + std::wstring term = std::wstring{ trimmedPatternStr.substr(pos, found - pos) }; + std::ranges::transform(term, term.begin(), ::towlower); + patObj.terms.push_back(term); + pos = found + 1; + } + if (pos < trimmedPatternStr.size()) + { + std::wstring term = std::wstring{ trimmedPatternStr.substr(pos) }; + std::ranges::transform(term, term.begin(), ::towlower); + patObj.terms.push_back(term); + } + + return patObj; + } + + int GetScore(std::wstring_view text, const Pattern& pattern) + { + if (pattern.terms.empty()) + return 1; + int totalScore = 0; + for (const auto& term : pattern.terms) + { + FzfResult res = FzfFuzzyMatchV2(text, term, nullptr); + if (res.Start >= 0) + { + totalScore += res.Score; + } + else + { + return 0; + } + } + return totalScore; + } + + std::vector GetPositions(std::wstring_view text, const Pattern& pattern) + { + std::vector result; + for (const auto& termSet : pattern.terms) + { + FzfResult algResult = FzfFuzzyMatchV2(text, termSet, &result); + if (algResult.Score == 0) + { + return {}; + } + } + return result; + } + + } + +} diff --git a/src/cascadia/TerminalApp/fzf/fzf.h b/src/cascadia/TerminalApp/fzf/fzf.h new file mode 100644 index 0000000000..5ce7dd6f0c --- /dev/null +++ b/src/cascadia/TerminalApp/fzf/fzf.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace fzf +{ + namespace matcher + { + struct FzfResult + { + int Start; + int End; + int Score; + }; + + class Pattern + { + public: + std::vector terms; + }; + + std::vector GetPositions(std::wstring_view text, const Pattern& pattern); + Pattern ParsePattern(const std::wstring_view patternStr); + int GetScore(std::wstring_view text, const Pattern& pattern); + } +} diff --git a/src/cascadia/ut_app/FzfTests.cpp b/src/cascadia/ut_app/FzfTests.cpp new file mode 100644 index 0000000000..4f7c9f91b2 --- /dev/null +++ b/src/cascadia/ut_app/FzfTests.cpp @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +#include "precomp.h" +#include "..\TerminalApp\fzf\fzf.h" + +using namespace Microsoft::Console; +using namespace WEX::Logging; +using namespace WEX::TestExecution; +using namespace WEX::Common; + +namespace TerminalAppUnitTests +{ + typedef enum + { + ScoreMatch = 16, + ScoreGapStart = -3, + ScoreGapExtension = -1, + BonusBoundary = ScoreMatch / 2, + BonusNonWord = ScoreMatch / 2, + BonusCamel123 = BonusBoundary + ScoreGapExtension, + BonusConsecutive = -(ScoreGapStart + ScoreGapExtension), + BonusFirstCharMultiplier = 2, + } score_t; + + class FzfTests + { + BEGIN_TEST_CLASS(FzfTests) + END_TEST_CLASS() + + TEST_METHOD(AllPatternCharsDoNotMatch); + TEST_METHOD(ConsecutiveChars); + TEST_METHOD(ConsecutiveChars_FirstCharBonus); + TEST_METHOD(NonWordBonusBoundary_ConsecutiveChars); + TEST_METHOD(MatchOnNonWordChars_CaseInSensitive); + TEST_METHOD(MatchOnNonWordCharsWithGap); + TEST_METHOD(BonusBoundaryAndFirstCharMultiplier); + TEST_METHOD(MatchesAreCaseInSensitive); + TEST_METHOD(MultipleTerms); + TEST_METHOD(MultipleTerms_AllCharsMatch); + TEST_METHOD(MultipleTerms_NotAllTermsMatch); + TEST_METHOD(MatchesAreCaseInSensitive_BonusBoundary); + TEST_METHOD(TraceBackWillPickTheFirstMatchIfBothHaveTheSameScore); + TEST_METHOD(TraceBackWillPickTheMatchWithTheHighestScore); + TEST_METHOD(TraceBackWillPickTheMatchWithTheHighestScore_Gaps); + TEST_METHOD(TraceBackWillPickEarlierCharsWhenNoBonus); + TEST_METHOD(MatchWithGapCanAHaveHigherScoreThanConsecutiveWhenGapMatchHasBoundaryBonus); + TEST_METHOD(ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothHaveFirstCharBonus); + TEST_METHOD(ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothDontHaveBonus); + TEST_METHOD(MatchWithGapCanHaveHigherScoreThanConsecutiveWhenGapHasFirstCharBonus); + TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerScoreHigherThanConsecutiveCharsWhenTheGapIs3_NoConsecutiveChar_4CharPattern); + TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_2CharPattern); + TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_3CharPattern_1ConsecutiveChar); + TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs5_NoConsecutiveChars_3CharPattern); + }; + + void AssertScoreAndPositions(std::wstring_view patternText, std::wstring_view text, int expectedScore, std::vector expectedPositions) + { + auto pattern = fzf::matcher::ParsePattern(patternText); + auto score = fzf::matcher::GetScore(text, pattern); + auto positions = fzf::matcher::GetPositions(text, pattern); + VERIFY_ARE_EQUAL(expectedScore, score); + VERIFY_ARE_EQUAL(expectedPositions.size(), positions.size()); + for (auto i = 0; i < expectedPositions.size(); i++) + { + VERIFY_ARE_EQUAL(expectedPositions[i], positions[i]); + } + } + + void FzfTests::AllPatternCharsDoNotMatch() + { + AssertScoreAndPositions( + L"fbb", + L"foo bar", + 0, + {}); + } + + void FzfTests::ConsecutiveChars() + { + AssertScoreAndPositions( + L"oba", + L"foobar", + ScoreMatch * 3 + BonusConsecutive * 2, + { 4, 3, 2 }); + } + + void FzfTests::ConsecutiveChars_FirstCharBonus() + { + AssertScoreAndPositions( + L"foo", + L"foobar", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { 2, 1, 0 }); + } + + void FzfTests::NonWordBonusBoundary_ConsecutiveChars() + { + AssertScoreAndPositions( + L"zshc", + L"/man1/zshcompctl.1", + ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusFirstCharMultiplier * BonusConsecutive * 3, + { 9, 8, 7, 6 }); + } + + void FzfTests::MatchOnNonWordChars_CaseInSensitive() + { + AssertScoreAndPositions( + L"foo-b", + L"xFoo-Bar Baz", + ScoreMatch * 5 + BonusConsecutive * 4 + BonusBoundary, + {5,4,3,2,1}); + } + + void FzfTests::MatchOnNonWordCharsWithGap() + { + AssertScoreAndPositions( + L"12356", + L"abc123 456", + ScoreMatch * 5 + BonusCamel123 * BonusFirstCharMultiplier + BonusCamel123 * 2 + BonusConsecutive + ScoreGapStart + ScoreGapExtension, + { 9, 8, 5, 4, 3 }); + } + + void FzfTests::BonusBoundaryAndFirstCharMultiplier() + { + AssertScoreAndPositions( + L"fbb", + L"foo bar baz", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension, + { 8, 4, 0 }); + } + + void FzfTests::MatchesAreCaseInSensitive() + { + AssertScoreAndPositions( + L"FBB", + L"foo bar baz", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension, + { 8, 4, 0 }); + } + + void FzfTests::MultipleTerms() + { + auto term1Score = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive); + auto term2Score = ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive) * 3; + + AssertScoreAndPositions( + L"sp anta", + L"Split Pane, split: horizontal, profile: SSH: Antares", + term1Score + term2Score, + {1, 0, 48, 47, 46, 45 }); + } + + void FzfTests::MultipleTerms_AllCharsMatch() + { + auto term1Score = ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive * 2); + auto term2Score = term1Score; + + AssertScoreAndPositions( + L"foo bar", + L"foo bar", + term1Score + term2Score, + {2,1,0,6,5,4}); + } + + void FzfTests::MultipleTerms_NotAllTermsMatch() + { + AssertScoreAndPositions( + L"sp anta zz", + L"Split Pane, split: horizontal, profile: SSH: Antares", + 0, + {}); + } + + void FzfTests::MatchesAreCaseInSensitive_BonusBoundary() + { + AssertScoreAndPositions( + L"fbb", + L"Foo Bar Baz", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension, + { 8, 4, 0 }); + } + + void FzfTests::TraceBackWillPickTheFirstMatchIfBothHaveTheSameScore() + { + AssertScoreAndPositions( + L"bar", + L"Foo Bar Bar", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier * 2, + { 6, 5, 4 }); + } + + void FzfTests::TraceBackWillPickTheMatchWithTheHighestScore() + { + AssertScoreAndPositions( + L"bar", + L"Foo aBar Bar", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier * 2, + { 11, 10, 9 }); + } + + void FzfTests::TraceBackWillPickTheMatchWithTheHighestScore_Gaps() + { + AssertScoreAndPositions( + L"bar", + L"Boo Author Raz Bar", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + {17,16,15}); + } + + void FzfTests::TraceBackWillPickEarlierCharsWhenNoBonus() + { + AssertScoreAndPositions( + L"clts", + L"close all tabs after this", + ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusFirstCharMultiplier * BonusConsecutive + ScoreGapStart + ScoreGapExtension * 7 + BonusBoundary + ScoreGapStart + ScoreGapExtension, + {13,10,1,0}); + } + + void FzfTests::ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothDontHaveBonus() + { + auto consecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2; + auto gapScore = (ScoreMatch * 3) + ScoreGapStart + ScoreGapStart; + + AssertScoreAndPositions( + L"oob", + L"aoobar", + consecutiveScore, + {3,2,1}); + + AssertScoreAndPositions( + L"oob", + L"aoaoabound", + gapScore, + {5,3,1}); + + VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore); + } + + void FzfTests::ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothHaveFirstCharBonus() + { + auto consecutiveScore = ScoreMatch * 3 + BonusFirstCharMultiplier * BonusBoundary + BonusFirstCharMultiplier * BonusConsecutive * 2; + auto gapScore = (ScoreMatch * 3) + (BonusBoundary * BonusFirstCharMultiplier) + ScoreGapStart + ScoreGapStart; + + AssertScoreAndPositions( + L"oob", + L"oobar", + consecutiveScore, + {2,1,0}); + + AssertScoreAndPositions( + L"oob", + L"oaoabound", + gapScore, + {4,2,0}); + + VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore); + } + + void FzfTests::MatchWithGapCanAHaveHigherScoreThanConsecutiveWhenGapMatchHasBoundaryBonus() + { + auto consecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2; + auto gapScore = (ScoreMatch * 3) + (BonusBoundary * BonusFirstCharMultiplier) + (BonusBoundary * 2) + ScoreGapStart + (ScoreGapExtension * 2) + ScoreGapStart + ScoreGapExtension; + + AssertScoreAndPositions( + L"oob", + L"foobar", + consecutiveScore, + {3,2,1}); + + AssertScoreAndPositions( + L"oob", + L"out-of-bound", + gapScore, + {7,4,0}); + + VERIFY_IS_GREATER_THAN(gapScore, consecutiveScore); + } + + void FzfTests::MatchWithGapCanHaveHigherScoreThanConsecutiveWhenGapHasFirstCharBonus() + { + auto consecutiveScore = ScoreMatch * 2 + BonusConsecutive; + auto gapScore = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart; + + AssertScoreAndPositions( + L"ob", + L"aobar", + consecutiveScore, + {2,1}); + + AssertScoreAndPositions( + L"ob", + L"oabar", + gapScore, + {2,0}); + + VERIFY_IS_GREATER_THAN(gapScore, consecutiveScore); + } + + void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_2CharPattern() + { + auto consecutiveScore = ScoreMatch * 2 + BonusConsecutive; + auto gapScore = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart + ScoreGapExtension * 10; + + AssertScoreAndPositions( + L"ob", + L"aobar", + consecutiveScore, + {2,1}); + + AssertScoreAndPositions( + L"ob", + L"oaaaaaaaaaaabar", + gapScore, + {12,0}); + + VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore); + } + + void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_3CharPattern_1ConsecutiveChar() + { + auto consecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2; + auto gapScore = ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive + ScoreGapStart + ScoreGapExtension * 10; + + AssertScoreAndPositions( + L"oba", + L"aobar", + consecutiveScore, + {3,2,1}); + + AssertScoreAndPositions( + L"oba", + L"oaaaaaaaaaaabar", + gapScore, + {13,12,0}); + + VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore); + } + + void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs5_NoConsecutiveChars_3CharPattern() + { + auto allConsecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2; + auto allBoundaryWithGapScore = ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart + ScoreGapExtension + ScoreGapExtension + ScoreGapStart + ScoreGapExtension; + + AssertScoreAndPositions( + L"oba", + L"aobar", + allConsecutiveScore, + {3,2,1}); + + AssertScoreAndPositions( + L"oba", + L"oaaabzzar", + allBoundaryWithGapScore, + {7,4,0}); + + VERIFY_IS_GREATER_THAN(allConsecutiveScore, allBoundaryWithGapScore); + } + + void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerScoreHigherThanConsecutiveCharsWhenTheGapIs3_NoConsecutiveChar_4CharPattern() + { + auto consecutiveScore = ScoreMatch * 4 + BonusConsecutive * 3; + auto gapScore = ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart * 3; + + AssertScoreAndPositions( + L"obar", + L"aobar", + consecutiveScore, + {4, 3,2,1}); + + AssertScoreAndPositions( + L"obar", + L"oabzazr", + gapScore, + {6, 4,2,0}); + + VERIFY_IS_GREATER_THAN(consecutiveScore, gapScore); + } +} diff --git a/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj b/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj index 705d5e3592..608bce64b6 100644 --- a/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj +++ b/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj @@ -24,6 +24,7 @@ + Create From 0bd9a028d7f68fb5751ef295f858f79b1ac28b7a Mon Sep 17 00:00:00 2001 From: Eric Nelson Date: Sat, 26 Apr 2025 15:23:22 -0700 Subject: [PATCH 2/5] Address Feedback for pull/18700 --- src/cascadia/TerminalApp/FilteredCommand.cpp | 21 ++-- src/cascadia/TerminalApp/FilteredCommand.h | 5 +- src/cascadia/TerminalApp/fzf/fzf.cpp | 83 +++++++------- src/cascadia/ut_app/FzfTests.cpp | 108 +++++++++++++++++++ 4 files changed, 159 insertions(+), 58 deletions(-) diff --git a/src/cascadia/TerminalApp/FilteredCommand.cpp b/src/cascadia/TerminalApp/FilteredCommand.cpp index 266bfb601a..f0060f55fa 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.cpp +++ b/src/cascadia/TerminalApp/FilteredCommand.cpp @@ -38,15 +38,17 @@ namespace winrt::TerminalApp::implementation _Item = item; _Filter = L""; _Weight = 0; - _HighlightedName = _computeHighlightedName(); + + const auto pattern = fzf::matcher::ParsePattern(Filter()); + _HighlightedName = _computeHighlightedName(pattern); // Recompute the highlighted name if the item name changes - _itemChangedRevoker = _Item.PropertyChanged(winrt::auto_revoke, [weakThis{ get_weak() }](auto& /*sender*/, auto& e) { + _itemChangedRevoker = _Item.PropertyChanged(winrt::auto_revoke, [weakThis{ get_weak() },pattern](auto& /*sender*/, auto& e) { auto filteredCommand{ weakThis.get() }; if (filteredCommand && e.PropertyName() == L"Name") { - filteredCommand->HighlightedName(filteredCommand->_computeHighlightedName()); - filteredCommand->Weight(filteredCommand->_computeWeight()); + filteredCommand->HighlightedName(filteredCommand->_computeHighlightedName(pattern)); + filteredCommand->Weight(filteredCommand->_computeWeight( pattern)); } }); } @@ -58,8 +60,9 @@ namespace winrt::TerminalApp::implementation if (filter != _Filter) { Filter(filter); - Weight(_computeWeight()); - HighlightedName(_computeHighlightedName()); + const auto pattern = fzf::matcher::ParsePattern(Filter()); + Weight(_computeWeight(pattern)); + HighlightedName(_computeHighlightedName(pattern)); } } @@ -81,7 +84,7 @@ namespace winrt::TerminalApp::implementation // // Return Value: // - The HighlightedText object initialized with the segments computed according to the algorithm above. - winrt::TerminalApp::HighlightedText FilteredCommand::_computeHighlightedName() + winrt::TerminalApp::HighlightedText FilteredCommand::_computeHighlightedName(const fzf::matcher::Pattern& pattern) { const auto segments = winrt::single_threaded_observable_vector(); auto commandName = _Item.Name(); @@ -92,7 +95,6 @@ namespace winrt::TerminalApp::implementation return winrt::make(segments); } - auto pattern = fzf::matcher::ParsePattern(Filter()); auto positions = fzf::matcher::GetPositions(commandName, pattern); // positions are returned is sorted pairs by search term. E.g. sp anta {5,4,11,10,9,8} // sorting these in ascending order so it is easier to build the text segments @@ -175,9 +177,8 @@ namespace winrt::TerminalApp::implementation // - name: the name to check // Return Value: // - the relative weight of this match - int FilteredCommand::_computeWeight() + int FilteredCommand::_computeWeight(const fzf::matcher::Pattern& pattern) { - auto pattern = fzf::matcher::ParsePattern(Filter()); auto score = fzf::matcher::GetScore(Item().Name(), pattern); return score; } diff --git a/src/cascadia/TerminalApp/FilteredCommand.h b/src/cascadia/TerminalApp/FilteredCommand.h index f304ad032a..13cee33d15 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.h +++ b/src/cascadia/TerminalApp/FilteredCommand.h @@ -5,6 +5,7 @@ #include "HighlightedTextControl.h" #include "FilteredCommand.g.h" +#include "fzf/fzf.h" // fwdecl unittest classes namespace TerminalAppLocalTests @@ -33,8 +34,8 @@ namespace winrt::TerminalApp::implementation void _constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item); private: - winrt::TerminalApp::HighlightedText _computeHighlightedName(); - int _computeWeight(); + winrt::TerminalApp::HighlightedText _computeHighlightedName(const fzf::matcher::Pattern& pattern); + int _computeWeight(const fzf::matcher::Pattern& pattern); Windows::UI::Xaml::Data::INotifyPropertyChanged::PropertyChanged_revoker _itemChangedRevoker; friend class TerminalAppLocalTests::FilteredCommandTests; diff --git a/src/cascadia/TerminalApp/fzf/fzf.cpp b/src/cascadia/TerminalApp/fzf/fzf.cpp index dc5b782863..a450753616 100644 --- a/src/cascadia/TerminalApp/fzf/fzf.cpp +++ b/src/cascadia/TerminalApp/fzf/fzf.cpp @@ -1,7 +1,7 @@ #include "pch.h" #include "fzf.h" #include -#include +#include namespace fzf { @@ -16,7 +16,7 @@ namespace fzf constexpr int BonusConsecutive = -(ScoreGapStart + ScoreGapExtension); constexpr int BonusFirstCharMultiplier = 2; - enum CharClass + enum CharClass : uint8_t { NonWord = 0, CharLower = 1, @@ -26,8 +26,8 @@ namespace fzf std::wstring_view TrimStart(const std::wstring_view str) { - size_t start = str.find_first_not_of(L" "); - return (start == std::wstring::npos) ? std::wstring_view() : std::wstring_view(str).substr(start); + const auto off = str.find_first_not_of(L' '); + return str.substr(std::min(off, str.size())); } std::wstring_view TrimSuffixSpaces(std::wstring_view input) @@ -38,14 +38,20 @@ namespace fzf return input.substr(0, end); } - int IndexOfChar(std::wstring_view input, const WCHAR value[], int startIndex) + wchar_t FoldCase(wchar_t c) noexcept { - for (size_t i = startIndex; i < input.size(); i++) + return static_cast(u_foldCase(c, U_FOLD_CASE_DEFAULT)); + } + + int IndexOfChar(std::wstring_view input, const wchar_t searchChar, int startIndex) + { + const wchar_t foldedSearch = FoldCase(searchChar); + for (int i = startIndex; i < static_cast(input.size()); ++i) { - const WCHAR currentCharAsString[] = { input[i], L'\0' }; - auto isCurrentCharMatched = lstrcmpi(value, currentCharAsString) == 0; - if (isCurrentCharMatched) - return static_cast(i); + if (FoldCase(input[i]) == foldedSearch) + { + return i; + } } return -1; } @@ -56,11 +62,7 @@ namespace fzf int firstIdx = 0; for (size_t patternIndex = 0; patternIndex < pattern.size(); patternIndex++) { - // GH#9941: search should be locale-aware as well - // We use the same comparison method as upon sorting to guarantee consistent behavior - const WCHAR searchCharAsString[] = { pattern[patternIndex], L'\0' }; - //const WCHAR currentCharAsString[] = { currentChar, L'\0' }; - idx = IndexOfChar(input, searchCharAsString, idx); + idx = IndexOfChar(input, pattern[patternIndex], idx); if (idx < 0) return -1; if (patternIndex == 0 && idx > 0) @@ -82,15 +84,20 @@ namespace fzf return 0; } + static constexpr auto s_charClassLut = []() { + std::array lut{}; + lut.fill(CharClass::NonWord); + lut[U_UPPERCASE_LETTER] = CharUpper; + lut[U_LOWERCASE_LETTER] = CharLower; + lut[U_MODIFIER_LETTER] = CharLower; + lut[U_OTHER_LETTER] = CharLower; + lut[U_DECIMAL_DIGIT_NUMBER] = Digit; + return lut; + }(); + CharClass ClassOf(wchar_t ch) { - if (std::iswlower(ch)) - return CharLower; - if (std::iswupper(ch)) - return CharUpper; - if (std::iswdigit(ch)) - return Digit; - return NonWord; + return s_charClassLut[u_charType(ch)]; } FzfResult FzfFuzzyMatchV2(std::wstring_view text, std::wstring_view pattern, std::vector* pos) @@ -120,7 +127,7 @@ namespace fzf auto textCopy = std::wstring{ text }; std::ranges::transform(textCopy, textCopy.begin(), [](wchar_t c) { - return std::towlower(c); + return FoldCase(c); }); std::wstring_view lowerText(textCopy.data(), textCopy.size()); std::wstring_view lowerTextSlice = lowerText.substr(firstIndexOf, textCopy.size() - firstIndexOf); @@ -137,12 +144,8 @@ namespace fzf bonusesSlice[i] = bonus; previousClass = currentClass; - // GH#9941: search should be locale-aware as well - // We use the same comparison method as upon sorting to guarantee consistent behavior - const WCHAR searchCharAsString[] = { currentPatternChar, L'\0' }; - const WCHAR currentCharAsString[] = { currentChar, L'\0' }; - auto isCurrentCharMatched = lstrcmpi(searchCharAsString, currentCharAsString) == 0; - if (isCurrentCharMatched) + auto lowerPatternChar = FoldCase(currentPatternChar); + if (currentChar == lowerPatternChar) { if (patternIndex < pattern.size()) { @@ -259,7 +262,7 @@ namespace fzf int currentColIndex = maxScorePos; if (pos) { - auto patternIndex = patternSize - 1; + patternIndex = patternSize - 1; bool preferCurrentMatch = true; while (true) { @@ -283,12 +286,12 @@ namespace fzf } currentColIndex--; - if (rowStartIndex + colOffset >= static_cast(consecutiveCharMatrixSize)) + if (rowStartIndex + colOffset >= consecutiveCharMatrixSize) break; preferCurrentMatch = (consecutiveCharMatrix[rowStartIndex + colOffset] > 1) || ((rowStartIndex + width + colOffset + 1 < - static_cast(consecutiveCharMatrixSize)) && + consecutiveCharMatrixSize) && (consecutiveCharMatrix[rowStartIndex + width + colOffset + 1] > 0)); } } @@ -302,18 +305,6 @@ namespace fzf return CalculateBonus(ClassOf(input[idx - 1]), ClassOf(input[idx])); } - int LeadingWhiteSpace(std::wstring_view text) - { - int whiteSpaces = 0; - for (wchar_t c : text) - { - if (c != L' ') - break; - whiteSpaces++; - } - return whiteSpaces; - } - Pattern ParsePattern(const std::wstring_view patternStr) { Pattern patObj; @@ -326,14 +317,14 @@ namespace fzf while ((found = trimmedPatternStr.find(L' ', pos)) != std::wstring::npos) { std::wstring term = std::wstring{ trimmedPatternStr.substr(pos, found - pos) }; - std::ranges::transform(term, term.begin(), ::towlower); + std::ranges::transform(term, term.begin(), FoldCase); patObj.terms.push_back(term); pos = found + 1; } if (pos < trimmedPatternStr.size()) { std::wstring term = std::wstring{ trimmedPatternStr.substr(pos) }; - std::ranges::transform(term, term.begin(), ::towlower); + std::ranges::transform(term, term.begin(), FoldCase); patObj.terms.push_back(term); } diff --git a/src/cascadia/ut_app/FzfTests.cpp b/src/cascadia/ut_app/FzfTests.cpp index 4f7c9f91b2..b6630a3da0 100644 --- a/src/cascadia/ut_app/FzfTests.cpp +++ b/src/cascadia/ut_app/FzfTests.cpp @@ -52,6 +52,16 @@ namespace TerminalAppUnitTests TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_2CharPattern); TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_3CharPattern_1ConsecutiveChar); TEST_METHOD(MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs5_NoConsecutiveChars_3CharPattern); + TEST_METHOD(Russian_CaseMisMatch); + TEST_METHOD(Russian_CaseMatch); + TEST_METHOD(English_CaseMatch); + TEST_METHOD(English_CaseMisMatch); + TEST_METHOD(French_CaseMatch); + TEST_METHOD(French_CaseMisMatch); + TEST_METHOD(German_CaseMatch); + TEST_METHOD(German_CaseMisMatch_MultipleCodePoints); + TEST_METHOD(Greek_CaseMisMatch); + TEST_METHOD(Greek_CaseMatch); }; void AssertScoreAndPositions(std::wstring_view patternText, std::wstring_view text, int expectedScore, std::vector expectedPositions) @@ -103,6 +113,104 @@ namespace TerminalAppUnitTests { 9, 8, 7, 6 }); } + void FzfTests::Russian_CaseMisMatch() + { + AssertScoreAndPositions( + L"новая", + L"Новая Π²ΠΊΠ»Π°Π΄ΠΊΠ°", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { 4, 3, 2, 1, 0 }); + } + + void FzfTests::Russian_CaseMatch() + { + AssertScoreAndPositions( + L"Новая", + L"Новая Π²ΠΊΠ»Π°Π΄ΠΊΠ°", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { 4, 3, 2, 1, 0 }); + } + + void FzfTests::German_CaseMatch() + { + AssertScoreAndPositions( + L"fuß", + L"Fußball", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { 2, 1, 0 }); + } + + void FzfTests::German_CaseMisMatch_MultipleCodePoints() + { + //This doesn't currently pass, I think I need to update the matcher to use U16_NEXT and then update the backtrace to normalize back to u16 positions + BEGIN_TEST_METHOD_PROPERTIES() + TEST_METHOD_PROPERTY(L"Ignore", L"true") + END_TEST_METHOD_PROPERTIES() + + AssertScoreAndPositions( + L"fuss", + L"Fußball", + //I think ScoreMatch * 4 is correct in this case since it matches 4 codepoints pattern??? fuss + ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 3, + //Only 3 positions in the text were matched + { 2, 1, 0 }); + } + + void FzfTests::French_CaseMatch() + { + AssertScoreAndPositions( + L"Γ‰co", + L"Γ‰cole", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { 2, 1, 0 }); + } + + void FzfTests::French_CaseMisMatch() + { + AssertScoreAndPositions( + L"Γ‰co", + L"Γ©cole", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { 2, 1, 0 }); + } + + void FzfTests::Greek_CaseMatch() + { + AssertScoreAndPositions( + L"Ξ»ΟŒΞ³ΞΏΟ‚", + L"Ξ»ΟŒΞ³ΞΏΟ‚", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { 4, 3, 2, 1, 0 }); + } + + void FzfTests::Greek_CaseMisMatch() + { + //I think this tests validates folding (Οƒ, Ο‚) + AssertScoreAndPositions( + L"Ξ»ΟŒΞ³ΞΏΟƒ", + L"Ξ»ΟŒΞ³ΞΏΟ‚", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { 4, 3, 2, 1, 0 }); + } + + void FzfTests::English_CaseMatch() + { + AssertScoreAndPositions( + L"Newer", + L"Newer tab", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { 4, 3, 2, 1, 0 }); + } + + void FzfTests::English_CaseMisMatch() + { + AssertScoreAndPositions( + L"newer", + L"Newer tab", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { 4, 3, 2, 1, 0 }); + } + void FzfTests::MatchOnNonWordChars_CaseInSensitive() { AssertScoreAndPositions( From 83664eb4cbe3a81d4d2f23a04825f857c7d150a7 Mon Sep 17 00:00:00 2001 From: Eric Nelson Date: Sun, 27 Apr 2025 16:43:40 -0700 Subject: [PATCH 3/5] Iterate code points in the Command Palette FZF search --- src/cascadia/TerminalApp/FilteredCommand.cpp | 55 ++- src/cascadia/TerminalApp/fzf/fzf.cpp | 364 +++++++++++++------ src/cascadia/TerminalApp/fzf/fzf.h | 13 +- src/cascadia/ut_app/FzfTests.cpp | 49 ++- 4 files changed, 342 insertions(+), 139 deletions(-) diff --git a/src/cascadia/TerminalApp/FilteredCommand.cpp b/src/cascadia/TerminalApp/FilteredCommand.cpp index f0060f55fa..672fc9fa93 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.cpp +++ b/src/cascadia/TerminalApp/FilteredCommand.cpp @@ -86,7 +86,7 @@ namespace winrt::TerminalApp::implementation // - The HighlightedText object initialized with the segments computed according to the algorithm above. winrt::TerminalApp::HighlightedText FilteredCommand::_computeHighlightedName(const fzf::matcher::Pattern& pattern) { - const auto segments = winrt::single_threaded_observable_vector(); + auto segments = winrt::single_threaded_observable_vector(); auto commandName = _Item.Name(); if (Weight() == 0) @@ -98,28 +98,53 @@ namespace winrt::TerminalApp::implementation auto positions = fzf::matcher::GetPositions(commandName, pattern); // positions are returned is sorted pairs by search term. E.g. sp anta {5,4,11,10,9,8} // sorting these in ascending order so it is easier to build the text segments - std::ranges::sort(positions, std::less<>()); + std::ranges::sort(positions); // a position can be matched in multiple terms, removed duplicates to simplify segments positions.erase(std::unique(positions.begin(), positions.end()), positions.end()); - size_t lastPosition = 0; - for (auto position : positions) + + std::vector> runs; + if (!positions.empty()) { - if (position > lastPosition) + size_t runStart = positions[0]; + size_t runEnd = runStart; + for (size_t i = 1; i < positions.size(); ++i) { - hstring nonMatchSegment{ commandName.data() + lastPosition, static_cast(position - lastPosition) }; - segments.Append(winrt::TerminalApp::HighlightedTextSegment(nonMatchSegment, false)); + if (positions[i] == runEnd + 1) + { + runEnd = positions[i]; + } + else + { + runs.emplace_back(runStart, runEnd); + runStart = positions[i]; + runEnd = runStart; + } } - - hstring matchSegment{ commandName.data() + position, 1 }; - segments.Append(winrt::TerminalApp::HighlightedTextSegment(matchSegment, true)); - - lastPosition = position + 1; + runs.emplace_back(runStart, runEnd); } - if (lastPosition < commandName.size()) + size_t lastPos = 0; + for (auto [start, end] : runs) { - hstring segment{ commandName.data() + lastPosition, static_cast(commandName.size() - lastPosition) }; - segments.Append(winrt::TerminalApp::HighlightedTextSegment(segment, false)); + if (start > lastPos) + { + hstring nonMatch{ commandName.data() + lastPos, + static_cast(start - lastPos) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(nonMatch, false)); + } + + hstring matchSeg{ commandName.data() + start, + static_cast(end - start + 1) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(matchSeg, true)); + + lastPos = end + 1; + } + + if (lastPos < commandName.size()) + { + hstring tail{ commandName.data() + lastPos, + static_cast(commandName.size() - lastPos) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(tail, false)); } return winrt::make(segments); diff --git a/src/cascadia/TerminalApp/fzf/fzf.cpp b/src/cascadia/TerminalApp/fzf/fzf.cpp index a450753616..d2cfb0d3da 100644 --- a/src/cascadia/TerminalApp/fzf/fzf.cpp +++ b/src/cascadia/TerminalApp/fzf/fzf.cpp @@ -1,20 +1,19 @@ #include "pch.h" #include "fzf.h" #include -#include namespace fzf { namespace matcher { - constexpr int ScoreMatch = 16; - constexpr int ScoreGapStart = -3; - constexpr int ScoreGapExtension = -1; - constexpr int BoundaryBonus = ScoreMatch / 2; - constexpr int NonWordBonus = ScoreMatch / 2; - constexpr int CamelCaseBonus = BoundaryBonus + ScoreGapExtension; - constexpr int BonusConsecutive = -(ScoreGapStart + ScoreGapExtension); - constexpr int BonusFirstCharMultiplier = 2; + constexpr int16_t ScoreMatch = 16; + constexpr int16_t ScoreGapStart = -3; + constexpr int16_t ScoreGapExtension = -1; + constexpr int16_t BoundaryBonus = ScoreMatch / 2; + constexpr int16_t NonWordBonus = ScoreMatch / 2; + constexpr int16_t CamelCaseBonus = BoundaryBonus + ScoreGapExtension; + constexpr int16_t BonusConsecutive = -(ScoreGapStart + ScoreGapExtension); + constexpr int16_t BonusFirstCharMultiplier = 2; enum CharClass : uint8_t { @@ -34,21 +33,22 @@ namespace fzf { size_t end = input.size(); while (end > 0 && input[end - 1] == L' ') + { --end; + } return input.substr(0, end); } - wchar_t FoldCase(wchar_t c) noexcept + UChar32 FoldCase(UChar32 c) noexcept { - return static_cast(u_foldCase(c, U_FOLD_CASE_DEFAULT)); + return u_foldCase(c, U_FOLD_CASE_DEFAULT); } - int IndexOfChar(std::wstring_view input, const wchar_t searchChar, int startIndex) + int32_t IndexOfChar(const std::vector& input, const UChar32 searchChar, int32_t startIndex) { - const wchar_t foldedSearch = FoldCase(searchChar); - for (int i = startIndex; i < static_cast(input.size()); ++i) + for (int32_t i = startIndex; i < static_cast(input.size()); ++i) { - if (FoldCase(input[i]) == foldedSearch) + if (input[i] == searchChar) { return i; } @@ -56,31 +56,43 @@ namespace fzf return -1; } - int FuzzyIndexOf(std::wstring_view input, std::wstring_view pattern) + int32_t FuzzyIndexOf(const std::vector& input, const std::vector& pattern) { - int idx = 0; - int firstIdx = 0; - for (size_t patternIndex = 0; patternIndex < pattern.size(); patternIndex++) + int32_t idx = 0; + int32_t firstIdx = 0; + for (int32_t pi = 0; pi < pattern.size(); ++pi) { - idx = IndexOfChar(input, pattern[patternIndex], idx); + idx = IndexOfChar(input, pattern[pi], idx); if (idx < 0) + { return -1; - if (patternIndex == 0 && idx > 0) + } + + if (pi == 0 && idx > 0) + { firstIdx = idx - 1; + } + idx++; } return firstIdx; } - int CalculateBonus(CharClass prevClass, CharClass currentClass) + int16_t CalculateBonus(CharClass prevClass, CharClass currentClass) { if (prevClass == NonWord && currentClass != NonWord) + { return BoundaryBonus; + } if ((prevClass == CharLower && currentClass == CharUpper) || (prevClass != Digit && currentClass == Digit)) + { return CamelCaseBonus; + } if (currentClass == NonWord) + { return NonWordBonus; + } return 0; } @@ -95,84 +107,97 @@ namespace fzf return lut; }(); - CharClass ClassOf(wchar_t ch) + CharClass ClassOf(UChar32 ch) { return s_charClassLut[u_charType(ch)]; } - FzfResult FzfFuzzyMatchV2(std::wstring_view text, std::wstring_view pattern, std::vector* pos) + FzfResult FzfFuzzyMatchV2(const std::vector& text, const std::vector& pattern, std::vector* pos) { - size_t patternSize = pattern.size(); - size_t textSize = text.size(); + int32_t patternSize = static_cast(pattern.size()); + int32_t textSize = static_cast(text.size()); if (patternSize == 0) + { return { 0, 0, 0 }; + } - int firstIndexOf = FuzzyIndexOf(text, pattern); + std::vector foldedText; + foldedText.reserve(text.size()); + for (auto cp : text) + { + auto foldedCp = u_foldCase(cp, U_FOLD_CASE_DEFAULT); + foldedText.push_back(foldedCp); + } + + int32_t firstIndexOf = FuzzyIndexOf(foldedText, pattern); if (firstIndexOf < 0) + { return { -1, -1, 0 }; + } - auto initialScores = std::vector(textSize); - auto consecutiveScores = std::vector(textSize); - auto firstOccurrenceOfEachChar = std::vector(patternSize); - int maxScore = 0; - int maxScorePos = 0; - size_t patternIndex = 0; - int lastIndex = 0; - wchar_t firstPatternChar = pattern[0]; - wchar_t currentPatternChar = pattern[0]; - int previousInitialScore = 0; + auto initialScores = std::vector(textSize); + auto consecutiveScores = std::vector(textSize); + auto firstOccurrenceOfEachChar = std::vector(patternSize); + auto bonusesSpan = std::vector(textSize); + + int16_t maxScore = 0; + int32_t maxScorePos = 0; + int32_t patternIndex = 0; + int32_t lastIndex = 0; + UChar32 firstPatternChar = pattern[0]; + UChar32 currentPatternChar = pattern[0]; + int16_t previousInitialScore = 0; CharClass previousClass = NonWord; bool inGap = false; - auto textCopy = std::wstring{ text }; - std::ranges::transform(textCopy, textCopy.begin(), [](wchar_t c) { - return FoldCase(c); - }); - std::wstring_view lowerText(textCopy.data(), textCopy.size()); - std::wstring_view lowerTextSlice = lowerText.substr(firstIndexOf, textCopy.size() - firstIndexOf); - auto initialScoresSlice = std::span(initialScores).subspan(firstIndexOf); - auto consecutiveScoresSlice = std::span(consecutiveScores).subspan(firstIndexOf); - auto bonusesSpan = std::vector(textSize); - auto bonusesSlice = std::span(bonusesSpan).subspan(firstIndexOf, textSize - firstIndexOf); + std::span lowerText(foldedText); + auto lowerTextSlice = lowerText.subspan(firstIndexOf); + auto initialScoresSlice = std::span(initialScores).subspan(firstIndexOf); + auto consecutiveScoresSlice = std::span(consecutiveScores).subspan(firstIndexOf); + auto bonusesSlice = std::span(bonusesSpan).subspan(firstIndexOf, textSize - firstIndexOf); - for (size_t i = 0; i < lowerTextSlice.size(); i++) + for (int32_t i = 0; i < lowerTextSlice.size(); i++) { - wchar_t currentChar = lowerTextSlice[i]; + UChar32 currentChar = lowerTextSlice[i]; CharClass currentClass = ClassOf(currentChar); - int bonus = CalculateBonus(previousClass, currentClass); + int16_t bonus = CalculateBonus(previousClass, currentClass); bonusesSlice[i] = bonus; previousClass = currentClass; - auto lowerPatternChar = FoldCase(currentPatternChar); - if (currentChar == lowerPatternChar) + //currentPatternChar was already folded in ParsePattern + if (currentChar == currentPatternChar) { if (patternIndex < pattern.size()) { - firstOccurrenceOfEachChar[patternIndex] = firstIndexOf + static_cast(i); + firstOccurrenceOfEachChar[patternIndex] = firstIndexOf + static_cast(i); patternIndex++; if (patternIndex < patternSize) + { currentPatternChar = pattern[patternIndex]; + } } - lastIndex = firstIndexOf + static_cast(i); + lastIndex = firstIndexOf + static_cast(i); } if (currentChar == firstPatternChar) { - int score = ScoreMatch + bonus * BonusFirstCharMultiplier; + int16_t score = ScoreMatch + bonus * BonusFirstCharMultiplier; initialScoresSlice[i] = score; consecutiveScoresSlice[i] = 1; if (patternSize == 1 && (score > maxScore)) { maxScore = score; - maxScorePos = firstIndexOf + static_cast(i); + maxScorePos = firstIndexOf + static_cast(i); if (bonus == BoundaryBonus) + { break; + } } inGap = false; } else { - initialScoresSlice[i] = inGap ? std::max(previousInitialScore + ScoreGapExtension, 0) : std::max(previousInitialScore + ScoreGapStart, 0); + initialScoresSlice[i] = inGap ? std::max(previousInitialScore + ScoreGapExtension, 0) : std::max(previousInitialScore + ScoreGapStart, 0); consecutiveScoresSlice[i] = 0; inGap = true; } @@ -180,63 +205,82 @@ namespace fzf } if (patternIndex != pattern.size()) + { return { -1, -1, 0 }; + } if (pattern.size() == 1) { if (pos) + { pos->push_back(maxScorePos); - return { maxScorePos, maxScorePos + 1, maxScore }; + } + int32_t end = maxScorePos + 1; + return { maxScorePos, end, maxScore }; } - int firstOccurrenceOfFirstChar = firstOccurrenceOfEachChar[0]; - int width = lastIndex - firstOccurrenceOfFirstChar + 1; - auto scoreMatrix = std::vector(width * patternSize); - std::copy_n(initialScores.begin() + firstOccurrenceOfFirstChar, - width, - scoreMatrix.begin()); - auto scoreSpan = std::span(scoreMatrix); + int32_t firstOccurrenceOfFirstChar = firstOccurrenceOfEachChar[0]; + int32_t width = lastIndex - firstOccurrenceOfFirstChar + 1; + int32_t rows = static_cast(pattern.size()); auto consecutiveCharMatrixSize = width * patternSize; - auto consecutiveCharMatrix = std::vector(consecutiveCharMatrixSize); - std::copy(consecutiveScores.begin() + firstOccurrenceOfFirstChar, - consecutiveScores.begin() + lastIndex, - consecutiveCharMatrix.begin()); - auto consecutiveCharMatrixSpan = std::span(consecutiveCharMatrix); - std::wstring_view patternSliceStr = pattern.substr(1); - for (size_t off = 0; off < patternSize - 1; off++) + std::vector scoreMatrix(width * rows); + std::copy_n( + initialScores.begin() + firstOccurrenceOfFirstChar, + width, + scoreMatrix.begin()); + auto scoreSpan = std::span(scoreMatrix); + + std::vector consecutiveCharMatrix(width * rows); + std::copy_n( + consecutiveScores.begin() + firstOccurrenceOfFirstChar, + width, + consecutiveCharMatrix.begin()); + auto consecutiveCharMatrixSpan = std::span(consecutiveCharMatrix); + + auto patternSliceStr = std::span(pattern).subspan(1); + + for (int32_t off = 0; off < patternSize - 1; off++) { - int patternCharOffset = firstOccurrenceOfEachChar[off + 1]; + auto patternCharOffset = firstOccurrenceOfEachChar[off + 1]; + auto sliceLen = lastIndex - patternCharOffset + 1; currentPatternChar = patternSliceStr[off]; patternIndex = off + 1; - size_t row = patternIndex * width; + int32_t row = patternIndex * width; inGap = false; - std::wstring_view textSlice = lowerText.substr(patternCharOffset, lastIndex - patternCharOffset + 1); - std::span bonusSlice(bonusesSpan.begin() + patternCharOffset, textSlice.size()); - std::span consecutiveCharMatrixSlice = consecutiveCharMatrixSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size()); - std::span consecutiveCharMatrixDiagonalSlice = consecutiveCharMatrixSpan.subspan( + auto tmp = lowerText.subspan(patternCharOffset, sliceLen); + std::span textSlice = lowerText.subspan(patternCharOffset, sliceLen); + std::span bonusSlice(bonusesSpan.begin() + patternCharOffset, textSlice.size()); + std::span consecutiveCharMatrixSlice = consecutiveCharMatrixSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size()); + std::span consecutiveCharMatrixDiagonalSlice = consecutiveCharMatrixSpan.subspan( +row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size()); - std::span scoreMatrixSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size()); - std::span scoreMatrixDiagonalSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size()); - std::span scoreMatrixLeftSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1, textSlice.size()); + std::span scoreMatrixSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size()); + std::span scoreMatrixDiagonalSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size()); + std::span scoreMatrixLeftSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1, textSlice.size()); if (!scoreMatrixLeftSlice.empty()) - scoreMatrixLeftSlice[0] = 0; - for (size_t j = 0; j < textSlice.size(); j++) { - wchar_t currentChar = textSlice[j]; - int column = patternCharOffset + static_cast(j); - int score = inGap ? scoreMatrixLeftSlice[j] + ScoreGapExtension : scoreMatrixLeftSlice[j] + ScoreGapStart; - int diagonalScore = 0; - int consecutive = 0; + scoreMatrixLeftSlice[0] = 0; + } + for (int32_t j = 0; j < textSlice.size(); j++) + { + UChar32 currentChar = textSlice[j]; + int32_t column = patternCharOffset + static_cast(j); + int16_t score = inGap ? scoreMatrixLeftSlice[j] + ScoreGapExtension : scoreMatrixLeftSlice[j] + ScoreGapStart; + int16_t diagonalScore = 0; + int16_t consecutive = 0; if (currentChar == currentPatternChar) { diagonalScore = scoreMatrixDiagonalSlice[j] + ScoreMatch; - int bonus = bonusSlice[j]; + int16_t bonus = bonusSlice[j]; consecutive = consecutiveCharMatrixDiagonalSlice[j] + 1; if (bonus == BoundaryBonus) + { consecutive = 1; + } else if (consecutive > 1) - bonus = std::max({ bonus, BonusConsecutive, static_cast(bonusesSpan[(column - consecutive) + 1]) }); + { + bonus = std::max({ bonus, BonusConsecutive, (bonusesSpan[column - consecutive + 1]) }); + } if (diagonalScore + bonus < score) { diagonalScore += bonusSlice[j]; @@ -249,7 +293,7 @@ namespace fzf } consecutiveCharMatrixSlice[j] = consecutive; inGap = (diagonalScore < score); - int cellScore = std::max(0, std::max(diagonalScore, score)); + int16_t cellScore = std::max(int16_t{0}, std::max(diagonalScore, score)); if (off == patternSize - 2 && cellScore > maxScore) { maxScore = cellScore; @@ -259,35 +303,43 @@ namespace fzf } } - int currentColIndex = maxScorePos; + int32_t currentColIndex = maxScorePos; if (pos) { patternIndex = patternSize - 1; bool preferCurrentMatch = true; while (true) { - size_t rowStartIndex = patternIndex * width; + int32_t rowStartIndex = patternIndex * width; int colOffset = currentColIndex - firstOccurrenceOfFirstChar; int cellScore = scoreMatrix[rowStartIndex + colOffset]; int diagonalCellScore = 0, leftCellScore = 0; if (patternIndex > 0 && currentColIndex >= firstOccurrenceOfEachChar[patternIndex]) + { diagonalCellScore = scoreMatrix[rowStartIndex - width + colOffset - 1]; + } if (currentColIndex > firstOccurrenceOfEachChar[patternIndex]) + { leftCellScore = scoreMatrix[rowStartIndex + colOffset - 1]; + } if (cellScore > diagonalCellScore && (cellScore > leftCellScore || (cellScore == leftCellScore && preferCurrentMatch))) { pos->push_back(currentColIndex); if (patternIndex == 0) + { break; + } patternIndex--; } currentColIndex--; if (rowStartIndex + colOffset >= consecutiveCharMatrixSize) + { break; + } preferCurrentMatch = (consecutiveCharMatrix[rowStartIndex + colOffset] > 1) || ((rowStartIndex + width + colOffset + 1 < @@ -295,50 +347,105 @@ namespace fzf (consecutiveCharMatrix[rowStartIndex + width + colOffset + 1] > 0)); } } - return { currentColIndex, maxScorePos + 1, maxScore }; + int32_t end = maxScorePos + 1; + return { currentColIndex, end, maxScore }; } int BonusAt(std::wstring_view input, int idx) { if (idx == 0) + { return BoundaryBonus; + } return CalculateBonus(ClassOf(input[idx - 1]), ClassOf(input[idx])); } + int32_t utf32Length(std::wstring_view str) + { + return u_countChar32(reinterpret_cast(str.data()), static_cast(str.size())); + } + + static std::vector ConvertUtf16ToCodePoints( + std::wstring_view text, + bool fold, + std::vector* utf16OffsetsOut = nullptr) + { + const UChar* data = reinterpret_cast(text.data()); + int32_t dataLen = static_cast(text.size()); + int32_t cpCount = utf32Length(text); + + std::vector out; + out.reserve(cpCount); + + if (utf16OffsetsOut) + { + utf16OffsetsOut->clear(); + utf16OffsetsOut->reserve(cpCount); + } + + int32_t src = 0; + while (src < dataLen) + { + auto startUnit = src; + if (utf16OffsetsOut) + { + utf16OffsetsOut->push_back(startUnit); + } + + UChar32 cp; + U16_NEXT(data, src, dataLen, cp); + + if (fold) + { + cp = u_foldCase(cp, U_FOLD_CASE_DEFAULT); + } + + out.push_back(cp); + } + + return out; + } + Pattern ParsePattern(const std::wstring_view patternStr) { Pattern patObj; if (patternStr.empty()) + { return patObj; - - std::wstring_view trimmedPatternStr = TrimStart(patternStr); - trimmedPatternStr = TrimSuffixSpaces(trimmedPatternStr); - size_t pos = 0, found; - while ((found = trimmedPatternStr.find(L' ', pos)) != std::wstring::npos) - { - std::wstring term = std::wstring{ trimmedPatternStr.substr(pos, found - pos) }; - std::ranges::transform(term, term.begin(), FoldCase); - patObj.terms.push_back(term); - pos = found + 1; } - if (pos < trimmedPatternStr.size()) + + auto trimmed = TrimStart(patternStr); + trimmed = TrimSuffixSpaces(trimmed); + + size_t pos = 0; + while (pos < trimmed.size()) { - std::wstring term = std::wstring{ trimmedPatternStr.substr(pos) }; - std::ranges::transform(term, term.begin(), FoldCase); - patObj.terms.push_back(term); + size_t found = trimmed.find(L' ', pos); + auto slice = (found == std::wstring_view::npos) ? trimmed.substr(pos) : trimmed.substr(pos, found - pos); + + patObj.terms.push_back(ConvertUtf16ToCodePoints(slice, true)); + + if (found == std::wstring_view::npos) + { + break; + } + pos = found + 1; } return patObj; } - int GetScore(std::wstring_view text, const Pattern& pattern) + int16_t GetScore(std::wstring_view text, const Pattern& pattern) { if (pattern.terms.empty()) + { return 1; - int totalScore = 0; + } + int16_t totalScore = 0; for (const auto& term : pattern.terms) { - FzfResult res = FzfFuzzyMatchV2(text, term, nullptr); + auto textCodePoints = ConvertUtf16ToCodePoints(text, false); + FzfResult res = FzfFuzzyMatchV2(textCodePoints, term, nullptr); if (res.Start >= 0) { totalScore += res.Score; @@ -351,12 +458,41 @@ namespace fzf return totalScore; } - std::vector GetPositions(std::wstring_view text, const Pattern& pattern) + static std::vector MapCodepointsToUtf16( + std::vector const& cpPos, + std::vector const& cpMap, + size_t dataLen) { - std::vector result; + std::vector utf16pos; + utf16pos.reserve(cpPos.size() * 2); + + for (int32_t cpIndex : cpPos) + { + int32_t start = cpMap[cpIndex]; + int32_t end = cpIndex + int32_t{ 1 } < static_cast(cpMap.size()) ? cpMap[cpIndex + 1] : static_cast(dataLen); + + for (int32_t cu = end - 1; cu >= start; --cu) + { + utf16pos.push_back(cu); + } + } + return utf16pos; + } + + std::vector GetPositions(std::wstring_view text, const Pattern& pattern) + { + std::vector result; for (const auto& termSet : pattern.terms) { - FzfResult algResult = FzfFuzzyMatchV2(text, termSet, &result); + std::vector codePointPos; + std::vector utf16map; + auto textCodePoints = ConvertUtf16ToCodePoints(text, false, &utf16map); + FzfResult algResult = FzfFuzzyMatchV2(textCodePoints, termSet, &codePointPos); + auto tmp = MapCodepointsToUtf16(codePointPos, utf16map, text.size()); + for (auto t : tmp) + { + result.push_back(t); + } if (algResult.Score == 0) { return {}; diff --git a/src/cascadia/TerminalApp/fzf/fzf.h b/src/cascadia/TerminalApp/fzf/fzf.h index 5ce7dd6f0c..710e549c98 100644 --- a/src/cascadia/TerminalApp/fzf/fzf.h +++ b/src/cascadia/TerminalApp/fzf/fzf.h @@ -2,6 +2,7 @@ #include #include +#include namespace fzf { @@ -9,19 +10,19 @@ namespace fzf { struct FzfResult { - int Start; - int End; - int Score; + int32_t Start; + int32_t End; + int16_t Score; }; class Pattern { public: - std::vector terms; + std::vector> terms; }; - std::vector GetPositions(std::wstring_view text, const Pattern& pattern); + std::vector GetPositions(std::wstring_view text, const Pattern& pattern); Pattern ParsePattern(const std::wstring_view patternStr); - int GetScore(std::wstring_view text, const Pattern& pattern); + int16_t GetScore(std::wstring_view text, const Pattern& pattern); } } diff --git a/src/cascadia/ut_app/FzfTests.cpp b/src/cascadia/ut_app/FzfTests.cpp index b6630a3da0..6750afee6d 100644 --- a/src/cascadia/ut_app/FzfTests.cpp +++ b/src/cascadia/ut_app/FzfTests.cpp @@ -56,15 +56,19 @@ namespace TerminalAppUnitTests TEST_METHOD(Russian_CaseMatch); TEST_METHOD(English_CaseMatch); TEST_METHOD(English_CaseMisMatch); + TEST_METHOD(SurrogatePair); TEST_METHOD(French_CaseMatch); TEST_METHOD(French_CaseMisMatch); TEST_METHOD(German_CaseMatch); - TEST_METHOD(German_CaseMisMatch_MultipleCodePoints); + TEST_METHOD(German_CaseMisMatch_FoldResultsInMultipleCodePoints); TEST_METHOD(Greek_CaseMisMatch); TEST_METHOD(Greek_CaseMatch); + TEST_METHOD(SurrogatePair_ToUtf16Pos_ConsecutiveChars); + TEST_METHOD(SurrogatePair_ToUtf16Pos_PreferConsecutiveChars); + TEST_METHOD(SurrogatePair_ToUtf16Pos_GapAndBoundary); }; - void AssertScoreAndPositions(std::wstring_view patternText, std::wstring_view text, int expectedScore, std::vector expectedPositions) + void AssertScoreAndPositions(std::wstring_view patternText, std::wstring_view text, int expectedScore, std::vector expectedPositions) { auto pattern = fzf::matcher::ParsePattern(patternText); auto score = fzf::matcher::GetScore(text, pattern); @@ -140,9 +144,10 @@ namespace TerminalAppUnitTests { 2, 1, 0 }); } - void FzfTests::German_CaseMisMatch_MultipleCodePoints() + void FzfTests::German_CaseMisMatch_FoldResultsInMultipleCodePoints() { - //This doesn't currently pass, I think I need to update the matcher to use U16_NEXT and then update the backtrace to normalize back to u16 positions + //This doesn't currently pass, I think ucase_toFullFolding would give the number of code points that resulted from the fold. + //I wasn't sure how to reference that BEGIN_TEST_METHOD_PROPERTIES() TEST_METHOD_PROPERTY(L"Ignore", L"true") END_TEST_METHOD_PROPERTIES() @@ -211,6 +216,42 @@ namespace TerminalAppUnitTests { 4, 3, 2, 1, 0 }); } + void FzfTests::SurrogatePair() + { + AssertScoreAndPositions( + L"NπŸ˜€ewer", + L"NπŸ˜€ewer tab", + ScoreMatch * 6 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 5, + { 6, 5, 4, 3, 2, 1, 0 }); + } + + void FzfTests::SurrogatePair_ToUtf16Pos_ConsecutiveChars() + { + AssertScoreAndPositions( + L"N𠀋NπŸ˜€π„že𐐷", + L"N𠀋NπŸ˜€π„že𐐷 tab", + ScoreMatch * 7 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 6, + { 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 }); + } + + void FzfTests::SurrogatePair_ToUtf16Pos_PreferConsecutiveChars() + { + AssertScoreAndPositions( + L"π €‹πŸ˜€", + L"Nπ €‹πŸ˜€wer πŸ˜€b𐐷 ", + ScoreMatch * 2 + BonusConsecutive * 2, + {4, 3, 2, 1}); + } + + void FzfTests::SurrogatePair_ToUtf16Pos_GapAndBoundary() + { + AssertScoreAndPositions( + L"π €‹πŸ˜€", + L"N𠀋wer πŸ˜€b𐐷 ", + ScoreMatch * 2 + ScoreGapStart + ScoreGapExtension * 3 + BonusBoundary, + {8, 7, 2, 1}); + } + void FzfTests::MatchOnNonWordChars_CaseInSensitive() { AssertScoreAndPositions( From d1c4c9428be2bb672ec5eba058193aebb8f95f5a Mon Sep 17 00:00:00 2001 From: Eric Nelson Date: Sun, 27 Apr 2025 21:02:20 -0700 Subject: [PATCH 4/5] Consolidate fzf calls --- src/cascadia/TerminalApp/FilteredCommand.cpp | 190 ++++++------------- src/cascadia/TerminalApp/FilteredCommand.h | 1 + src/cascadia/TerminalApp/fzf/fzf.cpp | 66 +++---- src/cascadia/TerminalApp/fzf/fzf.h | 10 +- src/cascadia/ut_app/FzfTests.cpp | 10 +- 5 files changed, 99 insertions(+), 178 deletions(-) diff --git a/src/cascadia/TerminalApp/FilteredCommand.cpp b/src/cascadia/TerminalApp/FilteredCommand.cpp index 672fc9fa93..7ef2feae24 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.cpp +++ b/src/cascadia/TerminalApp/FilteredCommand.cpp @@ -40,15 +40,14 @@ namespace winrt::TerminalApp::implementation _Weight = 0; const auto pattern = fzf::matcher::ParsePattern(Filter()); - _HighlightedName = _computeHighlightedName(pattern); + _update(); // Recompute the highlighted name if the item name changes _itemChangedRevoker = _Item.PropertyChanged(winrt::auto_revoke, [weakThis{ get_weak() },pattern](auto& /*sender*/, auto& e) { auto filteredCommand{ weakThis.get() }; if (filteredCommand && e.PropertyName() == L"Name") { - filteredCommand->HighlightedName(filteredCommand->_computeHighlightedName(pattern)); - filteredCommand->Weight(filteredCommand->_computeWeight( pattern)); + filteredCommand->_update(); } }); } @@ -60,152 +59,79 @@ namespace winrt::TerminalApp::implementation if (filter != _Filter) { Filter(filter); - const auto pattern = fzf::matcher::ParsePattern(Filter()); - Weight(_computeWeight(pattern)); - HighlightedName(_computeHighlightedName(pattern)); + _update(); } } - // Method Description: - // - Looks up the filter characters within the item name. - // Using the fzf algorithm to traceback from the maximum score to highlight the chars with the - // optimal match. (Preference is given to word boundaries, consecutive chars and special characters - // while penalties are given for gaps) - // - // E.g., for filter="c l t s" and name="close all tabs after this", the match will be "CLose TabS after this". - // - // The item name is then split into segments (groupings of matched and non matched characters). - // - // E.g., the segments were the example above will be "CL", "ose ", "T", "ab", "S", "after this". - // - // The segments matching the filter characters are marked as highlighted. - // - // E.g., ("CL", true) ("ose ", false), ("T", true), ("ab", false), ("S", true), ("after this", false) - // - // Return Value: - // - The HighlightedText object initialized with the segments computed according to the algorithm above. - winrt::TerminalApp::HighlightedText FilteredCommand::_computeHighlightedName(const fzf::matcher::Pattern& pattern) + void FilteredCommand::_update() { + auto pattern = fzf::matcher::ParsePattern(Filter()); auto segments = winrt::single_threaded_observable_vector(); auto commandName = _Item.Name(); - - if (Weight() == 0) + auto weight = 0; + if (auto match = fzf::matcher::Match(commandName, pattern); !match) { segments.Append(winrt::TerminalApp::HighlightedTextSegment(commandName, false)); - return winrt::make(segments); } - - auto positions = fzf::matcher::GetPositions(commandName, pattern); - // positions are returned is sorted pairs by search term. E.g. sp anta {5,4,11,10,9,8} - // sorting these in ascending order so it is easier to build the text segments - std::ranges::sort(positions); - // a position can be matched in multiple terms, removed duplicates to simplify segments - positions.erase(std::unique(positions.begin(), positions.end()), positions.end()); - - std::vector> runs; - if (!positions.empty()) + else { - size_t runStart = positions[0]; - size_t runEnd = runStart; - for (size_t i = 1; i < positions.size(); ++i) - { - if (positions[i] == runEnd + 1) - { - runEnd = positions[i]; - } - else - { - runs.emplace_back(runStart, runEnd); - runStart = positions[i]; - runEnd = runStart; - } - } - runs.emplace_back(runStart, runEnd); - } + auto& matchResult = *match; + weight = matchResult.Score; + auto positions = matchResult.Pos; + // positions are returned is sorted pairs by search term. E.g. sp anta {5,4,11,10,9,8} + // sorting these in ascending order so it is easier to build the text segments + std::ranges::sort(positions); + // a position can be matched in multiple terms, removed duplicates to simplify segments + positions.erase(std::unique(positions.begin(), positions.end()), positions.end()); - size_t lastPos = 0; - for (auto [start, end] : runs) - { - if (start > lastPos) + std::vector> runs; + if (!positions.empty()) { - hstring nonMatch{ commandName.data() + lastPos, - static_cast(start - lastPos) }; - segments.Append(winrt::TerminalApp::HighlightedTextSegment(nonMatch, false)); + size_t runStart = positions[0]; + size_t runEnd = runStart; + for (size_t i = 1; i < positions.size(); ++i) + { + if (positions[i] == runEnd + 1) + { + runEnd = positions[i]; + } + else + { + runs.emplace_back(runStart, runEnd); + runStart = positions[i]; + runEnd = runStart; + } + } + runs.emplace_back(runStart, runEnd); } - hstring matchSeg{ commandName.data() + start, - static_cast(end - start + 1) }; - segments.Append(winrt::TerminalApp::HighlightedTextSegment(matchSeg, true)); + size_t lastPos = 0; + for (auto [start, end] : runs) + { + if (start > lastPos) + { + hstring nonMatch{ commandName.data() + lastPos, + static_cast(start - lastPos) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(nonMatch, false)); + } - lastPos = end + 1; + hstring matchSeg{ commandName.data() + start, + static_cast(end - start + 1) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(matchSeg, true)); + + lastPos = end + 1; + } + + if (lastPos < commandName.size()) + { + hstring tail{ commandName.data() + lastPos, + static_cast(commandName.size() - lastPos) }; + segments.Append(winrt::TerminalApp::HighlightedTextSegment(tail, false)); + } } - if (lastPos < commandName.size()) - { - hstring tail{ commandName.data() + lastPos, - static_cast(commandName.size() - lastPos) }; - segments.Append(winrt::TerminalApp::HighlightedTextSegment(tail, false)); - } - - return winrt::make(segments); - } - - // Function Description: - // - Calculates a "weighting" by which should be used to order a item - // name relative to other names, given a specific search string. - // Currently, this uses a derivative of the fzf implementation of the Smith Waterman algorithm: - // - This will return 0 if the item should not be shown. - // - // Factors that affect a score (Taken from the fzf repository) - // Scoring criteria - // ---------------- - // - // - We prefer matches at special positions, such as the start of a word, or - // uppercase character in camelCase words. - // - Note everything is converted to lower case so this does not apply - // - // - That is, we prefer an occurrence of the pattern with more characters - // matching at special positions, even if the total match length is longer. - // e.g. "fuzzyfinder" vs. "fuzzy-finder" on "ff" - // ```````````` - // - Also, if the first character in the pattern appears at one of the special - // positions, the bonus point for the position is multiplied by a constant - // as it is extremely likely that the first character in the typed pattern - // has more significance than the rest. - // e.g. "fo-bar" vs. "foob-r" on "br" - // `````` - // - But since fzf is still a fuzzy finder, not an acronym finder, we should also - // consider the total length of the matched substring. This is why we have the - // gap penalty. The gap penalty increases as the length of the gap (distance - // between the matching characters) increases, so the effect of the bonus is - // eventually cancelled at some point. - // e.g. "fuzzyfinder" vs. "fuzzy-blurry-finder" on "ff" - // ``````````` - // - Consequently, it is crucial to find the right balance between the bonus - // and the gap penalty. The parameters were chosen that the bonus is cancelled - // when the gap size increases beyond 8 characters. - // - // - The bonus mechanism can have the undesirable side effect where consecutive - // matches are ranked lower than the ones with gaps. - // e.g. "foobar" vs. "foo-bar" on "foob" - // ``````` - // - To correct this anomaly, we also give extra bonus point to each character - // in a consecutive matching chunk. - // e.g. "foobar" vs. "foo-bar" on "foob" - // `````` - // - The amount of consecutive bonus is primarily determined by the bonus of the - // first character in the chunk. - // e.g. "foobar" vs. "out-of-bound" on "oob" - // ```````````` - // Arguments: - // - searchText: the string of text to search for in `name` - // - name: the name to check - // Return Value: - // - the relative weight of this match - int FilteredCommand::_computeWeight(const fzf::matcher::Pattern& pattern) - { - auto score = fzf::matcher::GetScore(Item().Name(), pattern); - return score; + HighlightedName(winrt::make(segments)); + Weight(weight); } // Function Description: diff --git a/src/cascadia/TerminalApp/FilteredCommand.h b/src/cascadia/TerminalApp/FilteredCommand.h index 13cee33d15..de5c328873 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.h +++ b/src/cascadia/TerminalApp/FilteredCommand.h @@ -34,6 +34,7 @@ namespace winrt::TerminalApp::implementation void _constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item); private: + void _update(); winrt::TerminalApp::HighlightedText _computeHighlightedName(const fzf::matcher::Pattern& pattern); int _computeWeight(const fzf::matcher::Pattern& pattern); Windows::UI::Xaml::Data::INotifyPropertyChanged::PropertyChanged_revoker _itemChangedRevoker; diff --git a/src/cascadia/TerminalApp/fzf/fzf.cpp b/src/cascadia/TerminalApp/fzf/fzf.cpp index d2cfb0d3da..9b62e9357f 100644 --- a/src/cascadia/TerminalApp/fzf/fzf.cpp +++ b/src/cascadia/TerminalApp/fzf/fzf.cpp @@ -435,29 +435,6 @@ namespace fzf return patObj; } - int16_t GetScore(std::wstring_view text, const Pattern& pattern) - { - if (pattern.terms.empty()) - { - return 1; - } - int16_t totalScore = 0; - for (const auto& term : pattern.terms) - { - auto textCodePoints = ConvertUtf16ToCodePoints(text, false); - FzfResult res = FzfFuzzyMatchV2(textCodePoints, term, nullptr); - if (res.Start >= 0) - { - totalScore += res.Score; - } - else - { - return 0; - } - } - return totalScore; - } - static std::vector MapCodepointsToUtf16( std::vector const& cpPos, std::vector const& cpMap, @@ -479,28 +456,35 @@ namespace fzf return utf16pos; } - std::vector GetPositions(std::wstring_view text, const Pattern& pattern) + std::optional Match(std::wstring_view text, const Pattern& pattern) { - std::vector result; - for (const auto& termSet : pattern.terms) + if (pattern.terms.empty()) { - std::vector codePointPos; - std::vector utf16map; - auto textCodePoints = ConvertUtf16ToCodePoints(text, false, &utf16map); - FzfResult algResult = FzfFuzzyMatchV2(textCodePoints, termSet, &codePointPos); - auto tmp = MapCodepointsToUtf16(codePointPos, utf16map, text.size()); - for (auto t : tmp) - { - result.push_back(t); - } - if (algResult.Score == 0) - { - return {}; - } + return MatchResult{};; } - return result; - } + int16_t totalScore = 0; + std::vector pos; + for (const auto& term : pattern.terms) + { + std::vector utf16map; + std::vector codePointPos; + auto textCodePoints = ConvertUtf16ToCodePoints(text, false, &utf16map); + FzfResult res = FzfFuzzyMatchV2(textCodePoints, term, &codePointPos); + if (res.Score <= 0) + { + return std::nullopt; + } + + auto termUtf16Pos = MapCodepointsToUtf16(codePointPos, utf16map, text.size()); + for (auto t : termUtf16Pos) + { + pos.push_back(t); + } + totalScore += res.Score; + } + return MatchResult{totalScore, pos}; + } } } diff --git a/src/cascadia/TerminalApp/fzf/fzf.h b/src/cascadia/TerminalApp/fzf/fzf.h index 710e549c98..5b6c4e6dab 100644 --- a/src/cascadia/TerminalApp/fzf/fzf.h +++ b/src/cascadia/TerminalApp/fzf/fzf.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include @@ -15,14 +14,19 @@ namespace fzf int16_t Score; }; + struct MatchResult + { + int16_t Score = 0; + std::vector Pos; + }; + class Pattern { public: std::vector> terms; }; - std::vector GetPositions(std::wstring_view text, const Pattern& pattern); Pattern ParsePattern(const std::wstring_view patternStr); - int16_t GetScore(std::wstring_view text, const Pattern& pattern); + std::optional Match(std::wstring_view text, const Pattern& pattern); } } diff --git a/src/cascadia/ut_app/FzfTests.cpp b/src/cascadia/ut_app/FzfTests.cpp index 6750afee6d..4edfbae99a 100644 --- a/src/cascadia/ut_app/FzfTests.cpp +++ b/src/cascadia/ut_app/FzfTests.cpp @@ -71,8 +71,14 @@ namespace TerminalAppUnitTests void AssertScoreAndPositions(std::wstring_view patternText, std::wstring_view text, int expectedScore, std::vector expectedPositions) { auto pattern = fzf::matcher::ParsePattern(patternText); - auto score = fzf::matcher::GetScore(text, pattern); - auto positions = fzf::matcher::GetPositions(text, pattern); + auto match = fzf::matcher::Match(text, pattern); + if (expectedScore == 0 && expectedPositions.size() == 0) + { + VERIFY_ARE_EQUAL(std::nullopt, match); + return; + } + auto score = match->Score; + auto positions = match->Pos; VERIFY_ARE_EQUAL(expectedScore, score); VERIFY_ARE_EQUAL(expectedPositions.size(), positions.size()); for (auto i = 0; i < expectedPositions.size(); i++) From fbf76f2d26986e6f4290336afe415f63d7ed37aa Mon Sep 17 00:00:00 2001 From: Eric Nelson Date: Mon, 28 Apr 2025 17:11:05 -0700 Subject: [PATCH 5/5] Update so fzf::matcher::ParsePattern does not get called for every item --- .../FilteredCommandTests.cpp | 87 ++++++++++--------- src/cascadia/TerminalApp/CommandPalette.cpp | 5 +- src/cascadia/TerminalApp/FilteredCommand.cpp | 17 ++-- src/cascadia/TerminalApp/FilteredCommand.h | 6 +- src/cascadia/TerminalApp/FilteredCommand.idl | 3 - .../TerminalApp/SnippetsPaneContent.cpp | 3 +- .../TerminalApp/SnippetsPaneContent.h | 10 ++- .../TerminalApp/SuggestionsControl.cpp | 5 +- 8 files changed, 72 insertions(+), 64 deletions(-) diff --git a/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp b/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp index e96675fd32..b8a7a361fb 100644 --- a/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp +++ b/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp @@ -35,7 +35,8 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing command name segmentation with no filter"); const auto filteredCommand = winrt::make_self(paletteItem); - auto segments = filteredCommand->_computeHighlightedName().Segments(); + filteredCommand->_update(); + auto segments = filteredCommand->HighlightedName().Segments(); VERIFY_ARE_EQUAL(segments.Size(), 1u); VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC"); VERIFY_IS_FALSE(segments.GetAt(0).IsHighlighted()); @@ -43,8 +44,9 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing command name segmentation with empty filter"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L""; - auto segments = filteredCommand->_computeHighlightedName().Segments(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"")); + filteredCommand->_update(); + auto segments = filteredCommand->HighlightedName().Segments(); VERIFY_ARE_EQUAL(segments.Size(), 1u); VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC"); VERIFY_IS_FALSE(segments.GetAt(0).IsHighlighted()); @@ -52,8 +54,9 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing command name segmentation with filter equal to the string"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"AAAAAABBBBBBCCC"; - auto segments = filteredCommand->_computeHighlightedName().Segments(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"AAAAAABBBBBBCCC")); + filteredCommand->_update(); + auto segments = filteredCommand->HighlightedName().Segments(); VERIFY_ARE_EQUAL(segments.Size(), 1u); VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC"); VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted()); @@ -61,8 +64,9 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing command name segmentation with filter with first character matching"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"A"; - auto segments = filteredCommand->_computeHighlightedName().Segments(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"A")); + filteredCommand->_update(); + auto segments = filteredCommand->HighlightedName().Segments(); VERIFY_ARE_EQUAL(segments.Size(), 2u); VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"A"); VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted()); @@ -72,8 +76,9 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing command name segmentation with filter with other case"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"a"; - auto segments = filteredCommand->_computeHighlightedName().Segments(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"a")); + filteredCommand->_update(); + auto segments = filteredCommand->HighlightedName().Segments(); VERIFY_ARE_EQUAL(segments.Size(), 2u); VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"A"); VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted()); @@ -83,8 +88,9 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing command name segmentation with filter matching several characters"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"ab"; - auto segments = filteredCommand->_computeHighlightedName().Segments(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"ab")); + filteredCommand->_update(); + auto segments = filteredCommand->HighlightedName().Segments(); VERIFY_ARE_EQUAL(segments.Size(), 4u); VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"A"); VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted()); @@ -98,8 +104,9 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing command name segmentation with non matching filter"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"abcd"; - auto segments = filteredCommand->_computeHighlightedName().Segments(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"abcd")); + filteredCommand->_update(); + auto segments = filteredCommand->HighlightedName().Segments(); VERIFY_ARE_EQUAL(segments.Size(), 1u); VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAAABBBBBBCCC"); VERIFY_IS_FALSE(segments.GetAt(0).IsHighlighted()); @@ -116,48 +123,48 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing weight of command with no filter"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - auto weight = filteredCommand->_computeWeight(); + filteredCommand->_update(); + auto weight = filteredCommand->Weight(); VERIFY_ARE_EQUAL(weight, 0); } { Log::Comment(L"Testing weight of command with empty filter"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L""; - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - auto weight = filteredCommand->_computeWeight(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"")); + filteredCommand->_update(); + auto weight = filteredCommand->Weight(); VERIFY_ARE_EQUAL(weight, 0); } { Log::Comment(L"Testing weight of command with filter equal to the string"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"AAAAAABBBBBBCCC"; - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - auto weight = filteredCommand->_computeWeight(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"AAAAAABBBBBBCCC")); + filteredCommand->_update(); + auto weight = filteredCommand->Weight(); VERIFY_ARE_EQUAL(weight, 30); // 1 point for the first char and 2 points for the 14 consequent ones + 1 point for the beginning of the word } { Log::Comment(L"Testing weight of command with filter with first character matching"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"A"; - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - auto weight = filteredCommand->_computeWeight(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"A")); + filteredCommand->_update(); + auto weight = filteredCommand->Weight(); VERIFY_ARE_EQUAL(weight, 2); // 1 point for the first char match + 1 point for the beginning of the word } { Log::Comment(L"Testing weight of command with filter with other case"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"a"; - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - auto weight = filteredCommand->_computeWeight(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"a")); + filteredCommand->_update(); + auto weight = filteredCommand->Weight(); VERIFY_ARE_EQUAL(weight, 2); // 1 point for the first char match + 1 point for the beginning of the word } { Log::Comment(L"Testing weight of command with filter matching several characters"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"ab"; - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - auto weight = filteredCommand->_computeWeight(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"ab")); + filteredCommand->_update(); + auto weight = filteredCommand->Weight(); VERIFY_ARE_EQUAL(weight, 3); // 1 point for the first char match + 1 point for the beginning of the word + 1 point for the match of "b" } }); @@ -181,14 +188,12 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing comparison of commands with empty filter"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L""; - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - filteredCommand->_Weight = filteredCommand->_computeWeight(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"")); + filteredCommand->_update(); const auto filteredCommand2 = winrt::make_self(paletteItem2); - filteredCommand2->_Filter = L""; - filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName(); - filteredCommand2->_Weight = filteredCommand2->_computeWeight(); + filteredCommand2->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"")); + filteredCommand->_update(); VERIFY_ARE_EQUAL(filteredCommand->Weight(), filteredCommand2->Weight()); VERIFY_IS_TRUE(winrt::TerminalApp::implementation::FilteredCommand::Compare(*filteredCommand, *filteredCommand2)); @@ -196,14 +201,12 @@ namespace TerminalAppLocalTests { Log::Comment(L"Testing comparison of commands with different weights"); const auto filteredCommand = winrt::make_self(paletteItem); - filteredCommand->_Filter = L"B"; - filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName(); - filteredCommand->_Weight = filteredCommand->_computeWeight(); + filteredCommand->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"B")); + filteredCommand->_update(); const auto filteredCommand2 = winrt::make_self(paletteItem2); - filteredCommand2->_Filter = L"B"; - filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName(); - filteredCommand2->_Weight = filteredCommand2->_computeWeight(); + filteredCommand2->_pattern = std::make_shared(fzf::matcher::ParsePattern(L"B")); + filteredCommand->_update(); VERIFY_IS_TRUE(filteredCommand->Weight() < filteredCommand2->Weight()); // Second command gets more points due to the beginning of the word VERIFY_IS_FALSE(winrt::TerminalApp::implementation::FilteredCommand::Compare(*filteredCommand, *filteredCommand2)); diff --git a/src/cascadia/TerminalApp/CommandPalette.cpp b/src/cascadia/TerminalApp/CommandPalette.cpp index 6d5540703f..e2e7526db8 100644 --- a/src/cascadia/TerminalApp/CommandPalette.cpp +++ b/src/cascadia/TerminalApp/CommandPalette.cpp @@ -1174,12 +1174,15 @@ namespace winrt::TerminalApp::implementation } else if (_currentMode == CommandPaletteMode::TabSearchMode || _currentMode == CommandPaletteMode::ActionMode || _currentMode == CommandPaletteMode::CommandlineMode) { + auto pattern = std::make_shared(fzf::matcher::ParsePattern(searchText)); + for (const auto& action : commandsToFilter) { // Update filter for all commands // This will modify the highlighting but will also lead to re-computation of weight (and consequently sorting). // Pay attention that it already updates the highlighting in the UI - action.UpdateFilter(searchText); + auto impl = winrt::get_self(action); + impl->UpdateFilter(pattern); // if there is active search we skip commands with 0 weight if (searchText.empty() || action.Weight() > 0) diff --git a/src/cascadia/TerminalApp/FilteredCommand.cpp b/src/cascadia/TerminalApp/FilteredCommand.cpp index 7ef2feae24..1859531851 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.cpp +++ b/src/cascadia/TerminalApp/FilteredCommand.cpp @@ -36,14 +36,12 @@ namespace winrt::TerminalApp::implementation void FilteredCommand::_constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item) { _Item = item; - _Filter = L""; _Weight = 0; - const auto pattern = fzf::matcher::ParsePattern(Filter()); _update(); // Recompute the highlighted name if the item name changes - _itemChangedRevoker = _Item.PropertyChanged(winrt::auto_revoke, [weakThis{ get_weak() },pattern](auto& /*sender*/, auto& e) { + _itemChangedRevoker = _Item.PropertyChanged(winrt::auto_revoke, [weakThis{ get_weak() }](auto& /*sender*/, auto& e) { auto filteredCommand{ weakThis.get() }; if (filteredCommand && e.PropertyName() == L"Name") { @@ -52,24 +50,27 @@ namespace winrt::TerminalApp::implementation }); } - void FilteredCommand::UpdateFilter(const winrt::hstring& filter) + void FilteredCommand::UpdateFilter(std::shared_ptr pattern) { // If the filter was not changed we want to prevent the re-computation of matching // that might result in triggering a notification event - if (filter != _Filter) + if (pattern != _pattern) { - Filter(filter); + _pattern = pattern; _update(); } } void FilteredCommand::_update() { - auto pattern = fzf::matcher::ParsePattern(Filter()); auto segments = winrt::single_threaded_observable_vector(); auto commandName = _Item.Name(); auto weight = 0; - if (auto match = fzf::matcher::Match(commandName, pattern); !match) + if (!_pattern || !_pattern->terms.empty()) + { + segments.Append(winrt::TerminalApp::HighlightedTextSegment(commandName, false)); + } + else if (auto match = fzf::matcher::Match(commandName, *_pattern.get()); !match) { segments.Append(winrt::TerminalApp::HighlightedTextSegment(commandName, false)); } diff --git a/src/cascadia/TerminalApp/FilteredCommand.h b/src/cascadia/TerminalApp/FilteredCommand.h index de5c328873..ff6f200c58 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.h +++ b/src/cascadia/TerminalApp/FilteredCommand.h @@ -20,13 +20,12 @@ namespace winrt::TerminalApp::implementation FilteredCommand() = default; FilteredCommand(const winrt::TerminalApp::PaletteItem& item); - virtual void UpdateFilter(const winrt::hstring& filter); + virtual void UpdateFilter(std::shared_ptr pattern); static int Compare(const winrt::TerminalApp::FilteredCommand& first, const winrt::TerminalApp::FilteredCommand& second); til::property_changed_event PropertyChanged; WINRT_OBSERVABLE_PROPERTY(winrt::TerminalApp::PaletteItem, Item, PropertyChanged.raise, nullptr); - WINRT_OBSERVABLE_PROPERTY(winrt::hstring, Filter, PropertyChanged.raise); WINRT_OBSERVABLE_PROPERTY(winrt::TerminalApp::HighlightedText, HighlightedName, PropertyChanged.raise); WINRT_OBSERVABLE_PROPERTY(int, Weight, PropertyChanged.raise); @@ -34,9 +33,8 @@ namespace winrt::TerminalApp::implementation void _constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item); private: + std::shared_ptr _pattern; void _update(); - winrt::TerminalApp::HighlightedText _computeHighlightedName(const fzf::matcher::Pattern& pattern); - int _computeWeight(const fzf::matcher::Pattern& pattern); Windows::UI::Xaml::Data::INotifyPropertyChanged::PropertyChanged_revoker _itemChangedRevoker; friend class TerminalAppLocalTests::FilteredCommandTests; diff --git a/src/cascadia/TerminalApp/FilteredCommand.idl b/src/cascadia/TerminalApp/FilteredCommand.idl index a63e6e8110..a5a1e34cf4 100644 --- a/src/cascadia/TerminalApp/FilteredCommand.idl +++ b/src/cascadia/TerminalApp/FilteredCommand.idl @@ -12,10 +12,7 @@ namespace TerminalApp FilteredCommand(PaletteItem item); PaletteItem Item { get; }; - String Filter; HighlightedText HighlightedName { get; }; Int32 Weight; - - void UpdateFilter(String filter); } } diff --git a/src/cascadia/TerminalApp/SnippetsPaneContent.cpp b/src/cascadia/TerminalApp/SnippetsPaneContent.cpp index 415e5d8201..d2dd9fb03e 100644 --- a/src/cascadia/TerminalApp/SnippetsPaneContent.cpp +++ b/src/cascadia/TerminalApp/SnippetsPaneContent.cpp @@ -32,6 +32,7 @@ namespace winrt::TerminalApp::implementation void SnippetsPaneContent::_updateFilteredCommands() { const auto& queryString = _filterBox().Text(); + auto pattern = std::make_shared(fzf::matcher::ParsePattern(queryString)); // DON'T replace the itemSource here. If you do, it'll un-expand all the // nested items the user has expanded. Instead, just update the filter. @@ -39,7 +40,7 @@ namespace winrt::TerminalApp::implementation for (const auto& t : _allTasks) { auto impl = winrt::get_self(t); - impl->UpdateFilter(queryString); + impl->UpdateFilter(pattern); } } diff --git a/src/cascadia/TerminalApp/SnippetsPaneContent.h b/src/cascadia/TerminalApp/SnippetsPaneContent.h index c469e83fcc..b2fc69cd35 100644 --- a/src/cascadia/TerminalApp/SnippetsPaneContent.h +++ b/src/cascadia/TerminalApp/SnippetsPaneContent.h @@ -77,13 +77,14 @@ namespace winrt::TerminalApp::implementation } } - void UpdateFilter(const winrt::hstring& filter) + void UpdateFilter(std::shared_ptr pattern) { - _filteredCommand->UpdateFilter(filter); + _pattern = pattern; + _filteredCommand->UpdateFilter(pattern); for (const auto& c : _children) { auto impl = winrt::get_self(c); - impl->UpdateFilter(filter); + impl->UpdateFilter(pattern); } PropertyChanged.raise(*this, Windows::UI::Xaml::Data::PropertyChangedEventArgs{ L"Visibility" }); @@ -108,6 +109,7 @@ namespace winrt::TerminalApp::implementation bool HasChildren() { return _children.Size() > 0; } winrt::Microsoft::Terminal::Settings::Model::Command Command() { return _command; } winrt::TerminalApp::FilteredCommand FilteredCommand() { return *_filteredCommand; } + std::shared_ptr _pattern; int32_t Row() { return HasChildren() ? 2 : 1; } // See the BODGY comment in the .XAML for explanation @@ -117,7 +119,7 @@ namespace winrt::TerminalApp::implementation winrt::Windows::UI::Xaml::Visibility Visibility() { // Is there no filter, or do we match it? - if (_filteredCommand->Filter().empty() || _filteredCommand->Weight() > 0) + if ((!_pattern || _pattern->terms.empty() || _filteredCommand->Weight() > 0)) { return winrt::Windows::UI::Xaml::Visibility::Visible; } diff --git a/src/cascadia/TerminalApp/SuggestionsControl.cpp b/src/cascadia/TerminalApp/SuggestionsControl.cpp index 442b80243c..3ebc7ca158 100644 --- a/src/cascadia/TerminalApp/SuggestionsControl.cpp +++ b/src/cascadia/TerminalApp/SuggestionsControl.cpp @@ -936,12 +936,15 @@ namespace winrt::TerminalApp::implementation auto commandsToFilter = _commandsToFilter(); { + auto pattern = std::make_shared(fzf::matcher::ParsePattern(searchText)); + for (const auto& action : commandsToFilter) { // Update filter for all commands // This will modify the highlighting but will also lead to re-computation of weight (and consequently sorting). // Pay attention that it already updates the highlighting in the UI - action.UpdateFilter(searchText); + auto impl = winrt::get_self(action); + impl->UpdateFilter(pattern); // if there is active search we skip commands with 0 weight if (searchText.empty() || action.Weight() > 0)