diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index 896211b8f17..c142f11548f 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 cc53f71e5f7..318903fe177 100644 --- a/.github/actions/spelling/expect/expect.txt +++ b/.github/actions/spelling/expect/expect.txt @@ -651,6 +651,7 @@ FONTSTRING FONTTYPE FONTWIDTH FONTWINDOW +foob FORCEOFFFEEDBACK FORCEONFEEDBACK FRAMECHANGED @@ -668,9 +669,11 @@ fuzzer fuzzmain fuzzmap fuzzwrapper +fuzzyfinder fwdecl fwe fwlink +fzf gci gcx gdi @@ -1248,6 +1251,7 @@ onecoreuuid ONECOREWINDOWS onehalf oneseq +oob openbash opencode opencon diff --git a/NOTICE.md b/NOTICE.md index bd99dd939ca..efe962bc0d0 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -285,6 +285,8 @@ specific language governing permissions and limitations under the License. **Source**: [https://github.com/commonmark/cmark](https://github.com/commonmark/cmark) ### License + +``` Copyright (c) 2014, John MacFarlane All rights reserved. @@ -455,6 +457,36 @@ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +## fzf + +### License + +``` +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. +``` # Microsoft Open Source diff --git a/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp b/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp index e96675fd329..023ac047023 100644 --- a/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp +++ b/src/cascadia/LocalTests_TerminalApp/FilteredCommandTests.cpp @@ -32,37 +32,35 @@ namespace TerminalAppLocalTests { auto result = RunOnUIThread([]() { const auto paletteItem{ winrt::make(L"AAAAAABBBBBBCCC") }; + const auto filteredCommand = winrt::make_self(paletteItem); + { Log::Comment(L"Testing command name segmentation with no filter"); - const auto filteredCommand = winrt::make_self(paletteItem); - auto segments = filteredCommand->_computeHighlightedName().Segments(); + 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()); } { 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->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L""))); + 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()); } { 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->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L"AAAAAABBBBBBCCC"))); + 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()); } { 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->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L"A"))); + 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()); @@ -71,9 +69,8 @@ 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->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L"a"))); + 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()); @@ -82,24 +79,20 @@ 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(); - VERIFY_ARE_EQUAL(segments.Size(), 4u); - VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"A"); - VERIFY_IS_TRUE(segments.GetAt(0).IsHighlighted()); - VERIFY_ARE_EQUAL(segments.GetAt(1).TextSegment(), L"AAAAA"); - VERIFY_IS_FALSE(segments.GetAt(1).IsHighlighted()); - VERIFY_ARE_EQUAL(segments.GetAt(2).TextSegment(), L"B"); - VERIFY_IS_TRUE(segments.GetAt(2).IsHighlighted()); - VERIFY_ARE_EQUAL(segments.GetAt(3).TextSegment(), L"BBBBBCCC"); - VERIFY_IS_FALSE(segments.GetAt(3).IsHighlighted()); + filteredCommand->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L"ab"))); + auto segments = filteredCommand->HighlightedName().Segments(); + VERIFY_ARE_EQUAL(segments.Size(), 3u); + VERIFY_ARE_EQUAL(segments.GetAt(0).TextSegment(), L"AAAAA"); + VERIFY_IS_FALSE(segments.GetAt(0).IsHighlighted()); + VERIFY_ARE_EQUAL(segments.GetAt(1).TextSegment(), L"AB"); + VERIFY_IS_TRUE(segments.GetAt(1).IsHighlighted()); + VERIFY_ARE_EQUAL(segments.GetAt(2).TextSegment(), L"BBBBBCCC"); + VERIFY_IS_FALSE(segments.GetAt(2).IsHighlighted()); } { 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->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L"abcd"))); + 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()); @@ -113,53 +106,37 @@ namespace TerminalAppLocalTests { auto result = RunOnUIThread([]() { const auto paletteItem{ winrt::make(L"AAAAAABBBBBBCCC") }; - { - 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(); - 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(); - 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(); - 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(); - 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(); - 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(); - 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" - } + const auto filteredCommand = winrt::make_self(paletteItem); + + const auto weigh = [&](const wchar_t* str) { + std::shared_ptr pattern; + if (str) + { + pattern = std::make_shared(fzf::matcher::ParsePattern(str)); + } + filteredCommand->UpdateFilter(std::move(pattern)); + return filteredCommand->Weight(); + }; + + const auto null = weigh(nullptr); + const auto empty = weigh(L""); + const auto full = weigh(L"AAAAAABBBBBBCCC"); + const auto firstChar = weigh(L"A"); + const auto otherCase = weigh(L"a"); + const auto severalChars = weigh(L"ab"); + + VERIFY_ARE_EQUAL(null, 0); + VERIFY_ARE_EQUAL(empty, 0); + VERIFY_IS_GREATER_THAN(full, 100); + + VERIFY_IS_GREATER_THAN(firstChar, 0); + VERIFY_IS_LESS_THAN(firstChar, full); + + VERIFY_IS_GREATER_THAN(otherCase, 0); + VERIFY_IS_LESS_THAN(otherCase, full); + + VERIFY_IS_GREATER_THAN(severalChars, otherCase); + VERIFY_IS_LESS_THAN(severalChars, full); }); VERIFY_SUCCEEDED(result); @@ -181,14 +158,10 @@ 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->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L""))); const auto filteredCommand2 = winrt::make_self(paletteItem2); - filteredCommand2->_Filter = L""; - filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName(); - filteredCommand2->_Weight = filteredCommand2->_computeWeight(); + filteredCommand2->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L""))); VERIFY_ARE_EQUAL(filteredCommand->Weight(), filteredCommand2->Weight()); VERIFY_IS_TRUE(winrt::TerminalApp::implementation::FilteredCommand::Compare(*filteredCommand, *filteredCommand2)); @@ -196,16 +169,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->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L"B"))); const auto filteredCommand2 = winrt::make_self(paletteItem2); - filteredCommand2->_Filter = L"B"; - filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName(); - filteredCommand2->_Weight = filteredCommand2->_computeWeight(); + filteredCommand2->UpdateFilter(std::make_shared(fzf::matcher::ParsePattern(L"B"))); - VERIFY_IS_TRUE(filteredCommand->Weight() < filteredCommand2->Weight()); // Second command gets more points due to the beginning of the word + VERIFY_IS_LESS_THAN(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 6d5540703fc..e2e7526db8c 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 d6c6c38a284..9d91ded8252 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" @@ -35,197 +36,75 @@ namespace winrt::TerminalApp::implementation void FilteredCommand::_constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item) { _Item = item; - _Filter = L""; _Weight = 0; - _HighlightedName = _computeHighlightedName(); + + _update(); // Recompute the highlighted name if the item name changes _itemChangedRevoker = _Item.PropertyChanged(winrt::auto_revoke, [weakThis{ get_weak() }](auto& /*sender*/, auto& e) { auto filteredCommand{ weakThis.get() }; if (filteredCommand && e.PropertyName() == L"Name") { - filteredCommand->HighlightedName(filteredCommand->_computeHighlightedName()); - filteredCommand->Weight(filteredCommand->_computeWeight()); + filteredCommand->_update(); } }); } - 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); - HighlightedName(_computeHighlightedName()); - Weight(_computeWeight()); + _pattern = pattern; + _update(); } } - // 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. - // - // 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) - // - // 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() + void FilteredCommand::_update() { - 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) - { - 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; - } - } - } + std::vector segments; + const auto commandName = _Item.Name(); + int32_t weight = 0; - // Either the filter or the item name were fully processed. - // If we were in the middle of the matched segment - add it. - if (isProcessingMatchedSegment) + if (!_pattern || _pattern->terms.empty()) { - auto sizeToReport = currentOffset - nextOffsetToReport; - if (sizeToReport > 0) - { - winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport }; - auto highlightedSegment{ winrt::make(segment, true) }; - segments.Append(highlightedSegment); - nextOffsetToReport = currentOffset; - } + segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(commandName, false)); } - - // 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) + else if (auto match = fzf::matcher::Match(commandName, *_pattern.get()); !match) { - winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport }; - auto highlightedSegment{ winrt::make(segment, false) }; - segments.Append(highlightedSegment); + segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(commandName, 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 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"). - // 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() - { - auto result = 0; - auto isNextSegmentWordBeginning = true; - - for (const auto& segment : _HighlightedName.Segments()) + else { - const auto& segmentText = segment.TextSegment(); - const auto segmentSize = segmentText.size(); + auto& matchResult = *match; + weight = matchResult.Score; - if (segment.IsHighlighted()) + size_t lastPos = 0; + for (const auto& run : matchResult.Runs) { - // 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) + const auto& [start, end] = run; + if (start > lastPos) { - result++; + hstring nonMatch{ til::safe_slice_abs(commandName, lastPos, start) }; + segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(nonMatch, false)); } + + hstring matchSeg{ til::safe_slice_abs(commandName, start, end + 1) }; + segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(matchSeg, true)); + + lastPos = end + 1; } - isNextSegmentWordBeginning = segmentSize > 0 && segmentText[segmentSize - 1] == L' '; + if (lastPos < commandName.size()) + { + hstring tail{ til::safe_slice_abs(commandName, lastPos, SIZE_T_MAX) }; + segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(tail, false)); + } } - return result; + HighlightedName(winrt::make(winrt::single_threaded_observable_vector(std::move(segments)))); + Weight(weight); } // Function Description: @@ -243,9 +122,9 @@ namespace winrt::TerminalApp::implementation if (firstWeight == secondWeight) { - std::wstring_view firstName{ first.Item().Name() }; - std::wstring_view secondName{ second.Item().Name() }; - return lstrcmpi(firstName.data(), secondName.data()) < 0; + const auto firstName = first.Item().Name(); + const auto secondName = second.Item().Name(); + return til::compare_linguistic_insensitive(firstName, secondName) < 0; } return firstWeight > secondWeight; diff --git a/src/cascadia/TerminalApp/FilteredCommand.h b/src/cascadia/TerminalApp/FilteredCommand.h index f304ad032a3..ff6f200c586 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 @@ -19,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); @@ -33,8 +33,8 @@ namespace winrt::TerminalApp::implementation void _constructFilteredCommand(const winrt::TerminalApp::PaletteItem& item); private: - winrt::TerminalApp::HighlightedText _computeHighlightedName(); - int _computeWeight(); + std::shared_ptr _pattern; + void _update(); 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 a63e6e81100..a5a1e34cf47 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 415e5d8201e..d2dd9fb03ed 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 c469e83fccc..2732522f3e7 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 = std::move(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 442b80243c8..3ebc7ca1583 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) diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj index 0896bc114a8..ac4357554cc 100644 --- a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj +++ b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj @@ -138,6 +138,7 @@ + @@ -212,6 +213,7 @@ TabBase.idl + TaskbarState.idl diff --git a/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters b/src/cascadia/TerminalApp/TerminalAppLib.vcxproj.filters index 12b3fa2add3..6dbf62453db 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 00000000000..04ac2144c36 --- /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 00000000000..6f2ac764cf9 --- /dev/null +++ b/src/cascadia/TerminalApp/fzf/fzf.cpp @@ -0,0 +1,432 @@ +#include "pch.h" +#include "fzf.h" + +#undef CharLower +#undef CharUpper + +using namespace fzf::matcher; + +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; +constexpr size_t npos = std::numeric_limits::max(); + +enum class CharClass : uint8_t +{ + NonWord = 0, + CharLower = 1, + CharUpper = 2, + Digit = 3, +}; + +static std::vector utf16ToUtf32(std::wstring_view text) +{ + const UChar* data = reinterpret_cast(text.data()); + int32_t dataLen = static_cast(text.size()); + int32_t cpCount = u_countChar32(data, dataLen); + + std::vector out(cpCount); + + UErrorCode status = U_ZERO_ERROR; + u_strToUTF32(out.data(), static_cast(out.size()), nullptr, data, dataLen, &status); + THROW_HR_IF(E_UNEXPECTED, status > U_ZERO_ERROR); + + return out; +} + +static void foldStringUtf32(std::vector& str) +{ + for (auto& cp : str) + { + cp = u_foldCase(cp, U_FOLD_CASE_DEFAULT); + } +} + +static size_t trySkip(const std::vector& input, const UChar32 searchChar, size_t startIndex) +{ + for (size_t i = startIndex; i < input.size(); ++i) + { + if (input[i] == searchChar) + { + return i; + } + } + return npos; +} + +// Unlike the equivalent in fzf, this one does more than Unicode. +static size_t asciiFuzzyIndex(const std::vector& input, const std::vector& pattern) +{ + size_t idx = 0; + size_t firstIdx = 0; + for (size_t pi = 0; pi < pattern.size(); ++pi) + { + idx = trySkip(input, pattern[pi], idx); + if (idx == npos) + { + return npos; + } + + if (pi == 0 && idx > 0) + { + firstIdx = idx - 1; + } + + idx++; + } + return firstIdx; +} + +static int16_t calculateBonus(CharClass prevClass, CharClass currentClass) +{ + if (prevClass == CharClass::NonWord && currentClass != CharClass::NonWord) + { + return BoundaryBonus; + } + if ((prevClass == CharClass::CharLower && currentClass == CharClass::CharUpper) || + (prevClass != CharClass::Digit && currentClass == CharClass::Digit)) + { + return CamelCaseBonus; + } + if (currentClass == CharClass::NonWord) + { + return NonWordBonus; + } + return 0; +} + +static constexpr auto s_charClassLut = []() { + std::array lut{}; + lut.fill(CharClass::NonWord); + lut[U_UPPERCASE_LETTER] = CharClass::CharUpper; + lut[U_LOWERCASE_LETTER] = CharClass::CharLower; + lut[U_MODIFIER_LETTER] = CharClass::CharLower; + lut[U_OTHER_LETTER] = CharClass::CharLower; + lut[U_DECIMAL_DIGIT_NUMBER] = CharClass::Digit; + return lut; +}(); + +static CharClass classOf(UChar32 ch) +{ + return s_charClassLut[u_charType(ch)]; +} + +static int32_t fzfFuzzyMatchV2(const std::vector& text, const std::vector& pattern, std::vector* pos) +{ + if (pattern.size() == 0) + { + return 0; + } + + auto foldedText = text; + foldStringUtf32(foldedText); + + size_t firstIndexOf = asciiFuzzyIndex(foldedText, pattern); + if (firstIndexOf == npos) + { + return 0; + } + + auto initialScores = std::vector(text.size()); + auto consecutiveScores = std::vector(text.size()); + auto firstOccurrenceOfEachChar = std::vector(pattern.size()); + auto bonusesSpan = std::vector(text.size()); + + int16_t maxScore = 0; + size_t maxScorePos = 0; + size_t patternIndex = 0; + size_t lastIndex = 0; + UChar32 firstPatternChar = pattern[0]; + UChar32 currentPatternChar = pattern[0]; + int16_t previousInitialScore = 0; + CharClass previousClass = CharClass::NonWord; + bool inGap = false; + + 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, text.size() - firstIndexOf); + + for (size_t i = 0; i < lowerTextSlice.size(); i++) + { + const auto currentChar = lowerTextSlice[i]; + const auto currentClass = classOf(text[i + firstIndexOf]); + const auto bonus = calculateBonus(previousClass, currentClass); + bonusesSlice[i] = bonus; + previousClass = currentClass; + + //currentPatternChar was already folded in ParsePattern + if (currentChar == currentPatternChar) + { + if (patternIndex < pattern.size()) + { + firstOccurrenceOfEachChar[patternIndex] = firstIndexOf + i; + patternIndex++; + if (patternIndex < pattern.size()) + { + currentPatternChar = pattern[patternIndex]; + } + } + lastIndex = firstIndexOf + i; + } + if (currentChar == firstPatternChar) + { + int16_t score = ScoreMatch + bonus * BonusFirstCharMultiplier; + initialScoresSlice[i] = score; + consecutiveScoresSlice[i] = 1; + if (pattern.size() == 1 && (score > maxScore)) + { + maxScore = score; + maxScorePos = firstIndexOf + i; + if (bonus == BoundaryBonus) + { + break; + } + } + inGap = false; + } + else + { + initialScoresSlice[i] = std::max(previousInitialScore + (inGap ? ScoreGapExtension : ScoreGapStart), 0); + consecutiveScoresSlice[i] = 0; + inGap = true; + } + previousInitialScore = initialScoresSlice[i]; + } + + if (patternIndex != pattern.size()) + { + return 0; + } + + if (pattern.size() == 1) + { + if (pos) + { + pos->push_back(maxScorePos); + } + return maxScore; + } + + const auto firstOccurrenceOfFirstChar = firstOccurrenceOfEachChar[0]; + const auto width = lastIndex - firstOccurrenceOfFirstChar + 1; + const auto rows = pattern.size(); + auto consecutiveCharMatrixSize = width * pattern.size(); + + std::vector scoreMatrix(width * rows); + std::copy_n(initialScores.begin() + firstOccurrenceOfFirstChar, width, scoreMatrix.begin()); + std::span scoreSpan(scoreMatrix); + + std::vector consecutiveCharMatrix(width * rows); + std::copy_n(consecutiveScores.begin() + firstOccurrenceOfFirstChar, width, consecutiveCharMatrix.begin()); + std::span consecutiveCharMatrixSpan(consecutiveCharMatrix); + + auto patternSliceStr = std::span(pattern).subspan(1); + + for (size_t off = 0; off < pattern.size() - 1; off++) + { + auto patternCharOffset = firstOccurrenceOfEachChar[off + 1]; + auto sliceLen = lastIndex - patternCharOffset + 1; + currentPatternChar = patternSliceStr[off]; + patternIndex = off + 1; + auto row = patternIndex * width; + inGap = false; + 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()); + + if (!scoreMatrixLeftSlice.empty()) + { + scoreMatrixLeftSlice[0] = 0; + } + + for (size_t j = 0; j < textSlice.size(); j++) + { + const auto currentChar = textSlice[j]; + const auto column = patternCharOffset + j; + const 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; + int16_t bonus = bonusSlice[j]; + consecutive = consecutiveCharMatrixDiagonalSlice[j] + 1; + if (bonus == BoundaryBonus) + { + consecutive = 1; + } + else if (consecutive > 1) + { + bonus = std::max({ bonus, BonusConsecutive, (bonusesSpan[column - consecutive + 1]) }); + } + if (diagonalScore + bonus < score) + { + diagonalScore += bonusSlice[j]; + consecutive = 0; + } + else + { + diagonalScore += bonus; + } + } + consecutiveCharMatrixSlice[j] = consecutive; + inGap = (diagonalScore < score); + int16_t cellScore = std::max(int16_t{ 0 }, std::max(diagonalScore, score)); + if (off + 2 == pattern.size() && cellScore > maxScore) + { + maxScore = cellScore; + maxScorePos = column; + } + scoreMatrixSlice[j] = cellScore; + } + } + + size_t currentColIndex = maxScorePos; + if (pos) + { + patternIndex = pattern.size() - 1; + bool preferCurrentMatch = true; + while (true) + { + const auto rowStartIndex = patternIndex * width; + const auto colOffset = currentColIndex - firstOccurrenceOfFirstChar; + const auto cellScore = scoreMatrix[rowStartIndex + colOffset]; + int32_t diagonalCellScore = 0; + int32_t 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 < + consecutiveCharMatrixSize) && + (consecutiveCharMatrix[rowStartIndex + width + colOffset + 1] > 0)); + } + } + return maxScore; +} + +Pattern fzf::matcher::ParsePattern(const std::wstring_view patternStr) +{ + Pattern patObj; + size_t pos = 0; + + while (true) + { + const auto beg = patternStr.find_first_not_of(L' ', pos); + if (beg == std::wstring_view::npos) + { + break; // No more non-space characters + } + + const auto end = std::min(patternStr.size(), patternStr.find_first_of(L' ', beg)); + const auto word = patternStr.substr(beg, end - beg); + auto codePoints = utf16ToUtf32(word); + foldStringUtf32(codePoints); + patObj.terms.push_back(std::move(codePoints)); + pos = end; + } + + return patObj; +} + +std::optional fzf::matcher::Match(std::wstring_view text, const Pattern& pattern) +{ + if (pattern.terms.empty()) + { + return MatchResult{}; + } + + const auto textCodePoints = utf16ToUtf32(text); + + int32_t totalScore = 0; + std::vector allUtf32Pos; + + for (const auto& term : pattern.terms) + { + std::vector termPos; + auto score = fzfFuzzyMatchV2(textCodePoints, term, &termPos); + if (score <= 0) + { + return std::nullopt; + } + + totalScore += score; + allUtf32Pos.insert(allUtf32Pos.end(), termPos.begin(), termPos.end()); + } + + std::ranges::sort(allUtf32Pos); + allUtf32Pos.erase(std::ranges::unique(allUtf32Pos).begin(), allUtf32Pos.end()); + + std::vector runs; + std::size_t nextCodePointPos = 0; + size_t utf16Offset = 0; + + bool inRun = false; + size_t runStart = 0; + + for (size_t cpIndex = 0; cpIndex < textCodePoints.size(); cpIndex++) + { + const auto cp = textCodePoints[cpIndex]; + const size_t cpWidth = U16_LENGTH(cp); + + const bool isMatch = (nextCodePointPos < allUtf32Pos.size() && allUtf32Pos[nextCodePointPos] == cpIndex); + if (isMatch) + { + if (!inRun) + { + runStart = utf16Offset; + inRun = true; + } + nextCodePointPos++; + } + else if (inRun) + { + runs.push_back({ runStart, utf16Offset - 1 }); + inRun = false; + } + + utf16Offset += cpWidth; + } + + if (inRun) + { + runs.push_back({ runStart, utf16Offset - 1 }); + } + + return MatchResult{ totalScore, std::move(runs) }; +} diff --git a/src/cascadia/TerminalApp/fzf/fzf.h b/src/cascadia/TerminalApp/fzf/fzf.h new file mode 100644 index 00000000000..2af4b75f91a --- /dev/null +++ b/src/cascadia/TerminalApp/fzf/fzf.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include + +namespace fzf::matcher +{ + struct TextRun + { + size_t Start; + size_t End; + }; + + struct MatchResult + { + int32_t Score = 0; + std::vector Runs; + }; + + struct Pattern + { + std::vector> terms; + }; + + Pattern ParsePattern(std::wstring_view patternStr); + 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 new file mode 100644 index 00000000000..e4f1fd1903e --- /dev/null +++ b/src/cascadia/ut_app/FzfTests.cpp @@ -0,0 +1,568 @@ +// 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(BonusForCamelCaseMatch); + 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); + TEST_METHOD(Russian_CaseMisMatch); + 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_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 AssertScoreAndRuns(std::wstring_view patternText, std::wstring_view text, int expectedScore, const std::vector& expectedRuns) + { + const auto pattern = fzf::matcher::ParsePattern(patternText); + const auto match = fzf::matcher::Match(text, pattern); + + if (expectedScore == 0 && expectedRuns.empty()) + { + VERIFY_ARE_EQUAL(std::nullopt, match); + return; + } + + VERIFY_IS_TRUE(match.has_value()); + VERIFY_ARE_EQUAL(expectedScore, match->Score); + + const auto& runs = match->Runs; + VERIFY_ARE_EQUAL(expectedRuns.size(), runs.size()); + + for (size_t i = 0; i < expectedRuns.size(); ++i) + { + VERIFY_ARE_EQUAL(expectedRuns[i].Start, runs[i].Start); + VERIFY_ARE_EQUAL(expectedRuns[i].End, runs[i].End); + } + } + + void FzfTests::AllPatternCharsDoNotMatch() + { + AssertScoreAndRuns( + L"fbb", + L"foo bar", + 0, + {}); + } + + void FzfTests::ConsecutiveChars() + { + AssertScoreAndRuns( + L"oba", + L"foobar", + ScoreMatch * 3 + BonusConsecutive * 2, + { { 2, 4 } }); + } + + void FzfTests::ConsecutiveChars_FirstCharBonus() + { + AssertScoreAndRuns( + L"foo", + L"foobar", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { { 0, 2 } }); + } + + void FzfTests::NonWordBonusBoundary_ConsecutiveChars() + { + AssertScoreAndRuns( + L"zshc", + L"/man1/zshcompctl.1", + ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusFirstCharMultiplier * BonusConsecutive * 3, + { { 6, 9 } }); + } + + void FzfTests::Russian_CaseMisMatch() + { + AssertScoreAndRuns( + L"новая", + L"Новая вкладка", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { { 0, 4 } }); + } + + void FzfTests::Russian_CaseMatch() + { + AssertScoreAndRuns( + L"Новая", + L"Новая вкладка", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { { 0, 4 } }); + } + + void FzfTests::German_CaseMatch() + { + AssertScoreAndRuns( + L"fuß", + L"Fußball", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { { 0, 2 } }); + } + + void FzfTests::German_CaseMisMatch_FoldResultsInMultipleCodePoints() + { + //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() + + AssertScoreAndRuns( + 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 + { { 0, 2 } }); + } + + void FzfTests::French_CaseMatch() + { + AssertScoreAndRuns( + L"Éco", + L"École", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { { 0, 2 } }); + } + + void FzfTests::French_CaseMisMatch() + { + AssertScoreAndRuns( + L"Éco", + L"école", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { { 0, 2 } }); + } + + void FzfTests::Greek_CaseMatch() + { + AssertScoreAndRuns( + L"λόγος", + L"λόγος", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { { 0, 4 } }); + } + + void FzfTests::Greek_CaseMisMatch() + { + //I think this tests validates folding (σ, ς) + AssertScoreAndRuns( + L"λόγοσ", + L"λόγος", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { { 0, 4 } }); + } + + void FzfTests::English_CaseMatch() + { + AssertScoreAndRuns( + L"Newer", + L"Newer tab", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { { 0, 4 } }); + } + + void FzfTests::English_CaseMisMatch() + { + AssertScoreAndRuns( + L"newer", + L"Newer tab", + ScoreMatch * 5 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 4, + { { 0, 4 } }); + } + + void FzfTests::SurrogatePair() + { + AssertScoreAndRuns( + L"N😀ewer", + L"N😀ewer tab", + ScoreMatch * 6 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 5, + { { 0, 6 } }); + } + + void FzfTests::SurrogatePair_ToUtf16Pos_ConsecutiveChars() + { + AssertScoreAndRuns( + L"N𠀋N😀𝄞e𐐷", + L"N𠀋N😀𝄞e𐐷 tab", + ScoreMatch * 7 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 6, + { { 0, 10 } }); + } + + void FzfTests::SurrogatePair_ToUtf16Pos_PreferConsecutiveChars() + { + AssertScoreAndRuns( + L"𠀋😀", + L"N𠀋😀wer 😀b𐐷 ", + ScoreMatch * 2 + BonusConsecutive * 2, + { { 1, 4 } }); + } + + void FzfTests::SurrogatePair_ToUtf16Pos_GapAndBoundary() + { + AssertScoreAndRuns( + L"𠀋😀", + L"N𠀋wer 😀b𐐷 ", + ScoreMatch * 2 + ScoreGapStart + ScoreGapExtension * 3 + BonusBoundary, + { { 1, 2 }, { 7, 8 } }); + } + + void FzfTests::MatchOnNonWordChars_CaseInSensitive() + { + AssertScoreAndRuns( + L"foo-b", + L"xFoo-Bar Baz", + (ScoreMatch + BonusCamel123 * BonusFirstCharMultiplier) + + (ScoreMatch + BonusCamel123) + + (ScoreMatch + BonusCamel123) + + (ScoreMatch + BonusBoundary) + + (ScoreMatch + BonusNonWord), + { { 1, 5 } }); + } + + void FzfTests::MatchOnNonWordCharsWithGap() + { + AssertScoreAndRuns( + L"12356", + L"abc123 456", + (ScoreMatch + BonusCamel123 * BonusFirstCharMultiplier) + + (ScoreMatch + BonusCamel123) + + (ScoreMatch + BonusCamel123) + + ScoreGapStart + + ScoreGapExtension + + ScoreMatch + + ScoreMatch + BonusConsecutive, + { { 3, 5 }, { 8, 9 } }); + } + + void FzfTests::BonusForCamelCaseMatch() + { + AssertScoreAndRuns( + L"def56", + L"abcDEF 456", + (ScoreMatch + BonusCamel123 * BonusFirstCharMultiplier) + + (ScoreMatch + BonusCamel123) + + (ScoreMatch + BonusCamel123) + + ScoreGapStart + + ScoreGapExtension + + ScoreMatch + + (ScoreMatch + BonusConsecutive), + { { 3, 5 }, { 8, 9 } }); + } + + void FzfTests::BonusBoundaryAndFirstCharMultiplier() + { + AssertScoreAndRuns( + L"fbb", + L"foo bar baz", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension, + { { 0, 0 }, { 4, 4 }, { 8, 8 } }); + } + + void FzfTests::MatchesAreCaseInSensitive() + { + AssertScoreAndRuns( + L"FBB", + L"foo bar baz", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension, + { { 0, 0 }, { 4, 4 }, { 8, 8 } }); + } + + void FzfTests::MultipleTerms() + { + auto term1Score = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive); + auto term2Score = ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive) * 3; + + AssertScoreAndRuns( + L"sp anta", + L"Split Pane, split: horizontal, profile: SSH: Antares", + term1Score + term2Score, + { { 0, 1 }, { 45, 48 } }); + } + + void FzfTests::MultipleTerms_AllCharsMatch() + { + auto term1Score = ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + (BonusFirstCharMultiplier * BonusConsecutive * 2); + auto term2Score = term1Score; + + AssertScoreAndRuns( + L"foo bar", + L"foo bar", + term1Score + term2Score, + { { 0, 2 }, { 4, 6 } }); + } + + void FzfTests::MultipleTerms_NotAllTermsMatch() + { + AssertScoreAndRuns( + L"sp anta zz", + L"Split Pane, split: horizontal, profile: SSH: Antares", + 0, + {}); + } + + void FzfTests::MatchesAreCaseInSensitive_BonusBoundary() + { + AssertScoreAndRuns( + L"fbb", + L"Foo Bar Baz", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusBoundary * 2 + 2 * ScoreGapStart + 4 * ScoreGapExtension, + { { 0, 0 }, { 4, 4 }, { 8, 8 } }); + } + + void FzfTests::TraceBackWillPickTheFirstMatchIfBothHaveTheSameScore() + { + AssertScoreAndRuns( + L"bar", + L"Foo Bar Bar", + (ScoreMatch + BonusBoundary * BonusFirstCharMultiplier) + + (ScoreMatch + BonusBoundary) + + (ScoreMatch + BonusBoundary), + //ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier * 2, + { { 4, 6 } }); + } + + void FzfTests::TraceBackWillPickTheMatchWithTheHighestScore() + { + AssertScoreAndRuns( + L"bar", + L"Foo aBar Bar", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier * 2, + { { 9, 11 } }); + } + + void FzfTests::TraceBackWillPickTheMatchWithTheHighestScore_Gaps() + { + AssertScoreAndRuns( + L"bar", + L"Boo Author Raz Bar", + ScoreMatch * 3 + BonusBoundary * BonusFirstCharMultiplier + BonusConsecutive * BonusFirstCharMultiplier * 2, + { { 15, 17 } }); + } + + void FzfTests::TraceBackWillPickEarlierCharsWhenNoBonus() + { + AssertScoreAndRuns( + L"clts", + L"close all tabs after this", + ScoreMatch * 4 + BonusBoundary * BonusFirstCharMultiplier + BonusFirstCharMultiplier * BonusConsecutive + ScoreGapStart + ScoreGapExtension * 7 + BonusBoundary + ScoreGapStart + ScoreGapExtension, + { { 0, 1 }, { 10, 10 }, { 13, 13 } }); + } + + void FzfTests::ConsecutiveMatchWillScoreHigherThanMatchWithGapWhenBothDontHaveBonus() + { + auto consecutiveScore = ScoreMatch * 3 + BonusConsecutive * 2; + auto gapScore = (ScoreMatch * 3) + ScoreGapStart + ScoreGapStart; + + AssertScoreAndRuns( + L"oob", + L"aoobar", + consecutiveScore, + { { 1, 3 } }); + + AssertScoreAndRuns( + L"oob", + L"aoaoabound", + gapScore, + { { 1, 1 }, { 3, 3 }, { 5, 5 } }); + + 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; + + AssertScoreAndRuns( + L"oob", + L"oobar", + consecutiveScore, + { { 0, 2 } }); + + AssertScoreAndRuns( + L"oob", + L"oaoabound", + gapScore, + { { 0, 0 }, { 2, 2 }, { 4, 4 } }); + + 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; + + AssertScoreAndRuns( + L"oob", + L"foobar", + consecutiveScore, + { { 1, 3 } }); + + AssertScoreAndRuns( + L"oob", + L"out-of-bound", + gapScore, + { { 0, 0 }, { 4, 4 }, { 7, 7 } }); + + VERIFY_IS_GREATER_THAN(gapScore, consecutiveScore); + } + + void FzfTests::MatchWithGapCanHaveHigherScoreThanConsecutiveWhenGapHasFirstCharBonus() + { + auto consecutiveScore = ScoreMatch * 2 + BonusConsecutive; + auto gapScore = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart; + + AssertScoreAndRuns( + L"ob", + L"aobar", + consecutiveScore, + { { 1, 2 } }); + + AssertScoreAndRuns( + L"ob", + L"oabar", + gapScore, + { { 0, 0 }, { 2, 2 } }); + + VERIFY_IS_GREATER_THAN(gapScore, consecutiveScore); + } + + void FzfTests::MatchWithGapThatMatchesOnTheFirstCharWillNoLongerHigherScoreThanConsecutiveCharsWhenTheGapIs11_2CharPattern() + { + auto consecutiveScore = ScoreMatch * 2 + BonusConsecutive; + auto gapScore = ScoreMatch * 2 + BonusBoundary * BonusFirstCharMultiplier + ScoreGapStart + ScoreGapExtension * 10; + + AssertScoreAndRuns( + L"ob", + L"aobar", + consecutiveScore, + { { 1, 2 } }); + + AssertScoreAndRuns( + L"ob", + L"oaaaaaaaaaaabar", + gapScore, + { { 0, 0 }, { 12, 12 } }); + + 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; + + AssertScoreAndRuns( + L"oba", + L"aobar", + consecutiveScore, + { { 1, 3 } }); + + AssertScoreAndRuns( + L"oba", + L"oaaaaaaaaaaabar", + gapScore, + { { 0, 0 }, { 12, 13 } }); + + 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; + + AssertScoreAndRuns( + L"oba", + L"aobar", + allConsecutiveScore, + { { 1, 3 } }); + + AssertScoreAndRuns( + L"oba", + L"oaaabzzar", + allBoundaryWithGapScore, + { { 0, 0 }, { 4, 4 }, { 7, 7 } }); + + 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; + + AssertScoreAndRuns( + L"obar", + L"aobar", + consecutiveScore, + { { 1, 4 } }); + + AssertScoreAndRuns( + L"obar", + L"oabzazr", + gapScore, + { { 0, 0 }, { 2, 2 }, { 4, 4 }, { 6, 6 } }); + + 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 705d5e35928..608bce64b6a 100644 --- a/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj +++ b/src/cascadia/ut_app/TerminalApp.UnitTests.vcxproj @@ -24,6 +24,7 @@ + Create diff --git a/src/inc/til.h b/src/inc/til.h index c79b0debdc5..531e571f750 100644 --- a/src/inc/til.h +++ b/src/inc/til.h @@ -57,7 +57,7 @@ namespace til // Terminal Implementation Library. Also: "Today I Learned" template as_view_t safe_slice_abs(const T& view, size_t beg, size_t end) { - const auto len = view.size(); + const size_t len = view.size(); end = std::min(end, len); beg = std::min(beg, end); return { view.data() + beg, end - beg }; @@ -66,7 +66,7 @@ namespace til // Terminal Implementation Library. Also: "Today I Learned" template as_view_t safe_slice_len(const T& view, size_t start, size_t count) { - const auto len = view.size(); + const size_t len = view.size(); start = std::min(start, len); count = std::min(count, len - start); return { view.data() + start, count }; diff --git a/src/inc/til/type_traits.h b/src/inc/til/type_traits.h index 1f426155ebf..f38380ada99 100644 --- a/src/inc/til/type_traits.h +++ b/src/inc/til/type_traits.h @@ -3,6 +3,11 @@ #pragma once +namespace winrt +{ + struct hstring; +} + namespace til { namespace details @@ -50,6 +55,12 @@ namespace til { using type = std::basic_string_view; }; + + template<> + struct as_view + { + using type = std::wstring_view; + }; } template