Update command palette search to prioritize "longest substring" match. (#18700)

It's the fzf algorithm!

Repurposed work from #16586
- I think the fzf algo fits here where it optimizes to find the optimal
  match based on consecutive chars and word boundaries.
- There are some edge cases where a match with a small gap could get a
  higher score than a match of consecutive chars when the match with a
  gap has other bonuses (FirstChar * Boundary Bonus). This can be
  adjusted by adjusting the bonuses or removing them if needed.
- From reading the thread in #6693 it looked like you guys were leaning
  towards something like the fzf algo.
- License file is now updated in
  https://github.com/nvim-telescope/telescope-fzf-native.nvim repository
  - https://github.com/nvim-telescope/telescope-fzf-native.nvim/pull/148
  - https://github.com/junegunn/fzf/issues/4310
- Removed the following from the original implementation to minimize
  complexity and the size of the PR. (Let me know if any of these should
  be added back).
  - Query expressions "$:StartsWith ^:EndsWith |:Or !:Not etc" 
- Slab to avoid allocating the scoring matrix. This felt like overkill
  for the number of items in the command pallete.
- Fallback to V1 algorithm for very long strings. I want to say that the
  command palette won't have strings this long.
- Added the logic from GH#9941 that copies pattern and text chars to
  string for comparision with lstrcmpi
  - It does this twice now which isn't great...

Closes #6693

---------

Co-authored-by: Leonard Hecker <lhecker@microsoft.com>
This commit is contained in:
e82eric 2025-06-02 17:22:24 -07:00 committed by GitHub
parent 37d5aec1cf
commit 74b5a0c975
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1235 additions and 269 deletions

View File

@ -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$

View File

@ -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

View File

@ -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

View File

@ -32,37 +32,35 @@ namespace TerminalAppLocalTests
{
auto result = RunOnUIThread([]() {
const auto paletteItem{ winrt::make<winrt::TerminalApp::implementation::CommandLinePaletteItem>(L"AAAAAABBBBBBCCC") };
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
{
Log::Comment(L"Testing command name segmentation with no filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(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<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(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<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"AAAAAABBBBBBCCC";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(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<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"A";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(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<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"a";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(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<winrt::TerminalApp::implementation::FilteredCommand>(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::Pattern>(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<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"abcd";
auto segments = filteredCommand->_computeHighlightedName().Segments();
filteredCommand->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(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<winrt::TerminalApp::implementation::CommandLinePaletteItem>(L"AAAAAABBBBBBCCC") };
{
Log::Comment(L"Testing weight of command with no filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
auto weight = filteredCommand->_computeWeight();
VERIFY_ARE_EQUAL(weight, 0);
}
const auto weigh = [&](const wchar_t* str) {
std::shared_ptr<fzf::matcher::Pattern> pattern;
if (str)
{
Log::Comment(L"Testing weight of command with empty filter");
const auto filteredCommand = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(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<winrt::TerminalApp::implementation::FilteredCommand>(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<winrt::TerminalApp::implementation::FilteredCommand>(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<winrt::TerminalApp::implementation::FilteredCommand>(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<winrt::TerminalApp::implementation::FilteredCommand>(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"
pattern = std::make_shared<fzf::matcher::Pattern>(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<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
filteredCommand->_Weight = filteredCommand->_computeWeight();
filteredCommand->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"")));
const auto filteredCommand2 = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem2);
filteredCommand2->_Filter = L"";
filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName();
filteredCommand2->_Weight = filteredCommand2->_computeWeight();
filteredCommand2->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(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<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem);
filteredCommand->_Filter = L"B";
filteredCommand->_HighlightedName = filteredCommand->_computeHighlightedName();
filteredCommand->_Weight = filteredCommand->_computeWeight();
filteredCommand->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(fzf::matcher::ParsePattern(L"B")));
const auto filteredCommand2 = winrt::make_self<winrt::TerminalApp::implementation::FilteredCommand>(paletteItem2);
filteredCommand2->_Filter = L"B";
filteredCommand2->_HighlightedName = filteredCommand2->_computeHighlightedName();
filteredCommand2->_Weight = filteredCommand2->_computeWeight();
filteredCommand2->UpdateFilter(std::make_shared<fzf::matcher::Pattern>(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));
}
});

View File

@ -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::Pattern>(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<implementation::FilteredCommand>(action);
impl->UpdateFilter(pattern);
// if there is active search we skip commands with 0 weight
if (searchText.empty() || action.Weight() > 0)

View File

@ -5,6 +5,7 @@
#include "CommandPalette.h"
#include "HighlightedText.h"
#include <LibraryResources.h>
#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<fzf::matcher::Pattern> 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<winrt::TerminalApp::HighlightedTextSegment>();
auto commandName = _Item.Name();
auto isProcessingMatchedSegment = false;
uint32_t nextOffsetToReport = 0;
uint32_t currentOffset = 0;
std::vector<winrt::TerminalApp::HighlightedTextSegment> segments;
const auto commandName = _Item.Name();
int32_t weight = 0;
for (const auto searchChar : _Filter)
if (!_pattern || _pattern->terms.empty())
{
const WCHAR searchCharAsString[] = { searchChar, L'\0' };
while (true)
segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(commandName, false));
}
else if (auto match = fzf::matcher::Match(commandName, *_pattern.get()); !match)
{
if (currentOffset == commandName.size())
segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(commandName, false));
}
else
{
// 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<HighlightedTextSegment>(commandName, false) };
segments.Clear();
segments.Append(entireNameSegment);
return winrt::make<HighlightedText>(segments);
auto& matchResult = *match;
weight = matchResult.Score;
size_t lastPos = 0;
for (const auto& run : matchResult.Runs)
{
const auto& [start, end] = run;
if (start > lastPos)
{
hstring nonMatch{ til::safe_slice_abs(commandName, lastPos, start) };
segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(nonMatch, false));
}
// 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<HighlightedTextSegment>(segment, isProcessingMatchedSegment) };
segments.Append(highlightedSegment);
nextOffsetToReport = currentOffset;
}
isProcessingMatchedSegment = isCurrentCharMatched;
hstring matchSeg{ til::safe_slice_abs(commandName, start, end + 1) };
segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(matchSeg, true));
lastPos = end + 1;
}
currentOffset++;
if (isCurrentCharMatched)
if (lastPos < commandName.size())
{
// We have matched this filter character, let's move to matching the next filter char
break;
}
}
}
// 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 sizeToReport = currentOffset - nextOffsetToReport;
if (sizeToReport > 0)
{
winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport };
auto highlightedSegment{ winrt::make<HighlightedTextSegment>(segment, true) };
segments.Append(highlightedSegment);
nextOffsetToReport = currentOffset;
hstring tail{ til::safe_slice_abs(commandName, lastPos, SIZE_T_MAX) };
segments.emplace_back(winrt::TerminalApp::HighlightedTextSegment(tail, 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)
{
winrt::hstring segment{ commandName.data() + nextOffsetToReport, sizeToReport };
auto highlightedSegment{ winrt::make<HighlightedTextSegment>(segment, false) };
segments.Append(highlightedSegment);
}
return winrt::make<HighlightedText>(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())
{
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;
HighlightedName(winrt::make<HighlightedText>(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;

View File

@ -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<fzf::matcher::Pattern> 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<fzf::matcher::Pattern> _pattern;
void _update();
Windows::UI::Xaml::Data::INotifyPropertyChanged::PropertyChanged_revoker _itemChangedRevoker;
friend class TerminalAppLocalTests::FilteredCommandTests;

View File

@ -12,10 +12,7 @@ namespace TerminalApp
FilteredCommand(PaletteItem item);
PaletteItem Item { get; };
String Filter;
HighlightedText HighlightedName { get; };
Int32 Weight;
void UpdateFilter(String filter);
}
}

View File

@ -32,6 +32,7 @@ namespace winrt::TerminalApp::implementation
void SnippetsPaneContent::_updateFilteredCommands()
{
const auto& queryString = _filterBox().Text();
auto pattern = std::make_shared<fzf::matcher::Pattern>(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<implementation::FilteredTask>(t);
impl->UpdateFilter(queryString);
impl->UpdateFilter(pattern);
}
}

View File

@ -77,13 +77,14 @@ namespace winrt::TerminalApp::implementation
}
}
void UpdateFilter(const winrt::hstring& filter)
void UpdateFilter(std::shared_ptr<fzf::matcher::Pattern> pattern)
{
_filteredCommand->UpdateFilter(filter);
_pattern = std::move(pattern);
_filteredCommand->UpdateFilter(_pattern);
for (const auto& c : _children)
{
auto impl = winrt::get_self<implementation::FilteredTask>(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<fzf::matcher::Pattern> _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;
}

View File

@ -936,12 +936,15 @@ namespace winrt::TerminalApp::implementation
auto commandsToFilter = _commandsToFilter();
{
auto pattern = std::make_shared<fzf::matcher::Pattern>(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<implementation::FilteredCommand>(action);
impl->UpdateFilter(pattern);
// if there is active search we skip commands with 0 weight
if (searchText.empty() || action.Weight() > 0)

View File

@ -138,6 +138,7 @@
</ClInclude>
<ClInclude Include="FilteredCommand.h" />
<ClInclude Include="Pane.h" />
<ClInclude Include="fzf/fzf.h" />
<ClInclude Include="ColorHelper.h" />
<ClInclude Include="pch.h" />
<ClInclude Include="ShortcutActionDispatch.h">
@ -212,6 +213,7 @@
<ClCompile Include="TabBase.cpp">
<DependentUpon>TabBase.idl</DependentUpon>
</ClCompile>
<ClCompile Include="fzf/fzf.cpp" />
<ClCompile Include="TabPaletteItem.cpp" />
<ClCompile Include="TaskbarState.cpp">
<DependentUpon>TaskbarState.idl</DependentUpon>

View File

@ -41,6 +41,9 @@
<ClCompile Include="HighlightedText.cpp">
<Filter>highlightedText</Filter>
</ClCompile>
<ClCompile Include="fzf/fzf.cpp">
<Filter>fzf</Filter>
</ClCompile>
<ClCompile Include="Toast.cpp" />
<ClCompile Include="LanguageProfileNotifier.cpp" />
<ClCompile Include="Monarch.cpp" />
@ -77,6 +80,12 @@
<ClInclude Include="HighlightedText.h">
<Filter>highlightedText</Filter>
</ClInclude>
<ClInclude Include="fzf/fzf.h">
<Filter>fzf</Filter>
</ClInclude>
<ClInclude Include="fzf/LICENSE">
<Filter>fzf</Filter>
</ClInclude>
<ClInclude Include="Toast.h" />
<ClInclude Include="LanguageProfileNotifier.h" />
<ClInclude Include="WindowsPackageManagerFactory.h" />
@ -176,6 +185,9 @@
<Filter Include="highlightedText">
<UniqueIdentifier>{e490f626-547d-4b5b-b22d-c6d33c9e3210}</UniqueIdentifier>
</Filter>
<Filter Include="fzf">
<UniqueIdentifier>{e4588ff4-c80a-40f7-be57-3e81f570a93d}</UniqueIdentifier>
</Filter>
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="App.xaml">

View File

@ -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.

View File

@ -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<size_t>::max();
enum class CharClass : uint8_t
{
NonWord = 0,
CharLower = 1,
CharUpper = 2,
Digit = 3,
};
static std::vector<UChar32> utf16ToUtf32(std::wstring_view text)
{
const UChar* data = reinterpret_cast<const UChar*>(text.data());
int32_t dataLen = static_cast<int32_t>(text.size());
int32_t cpCount = u_countChar32(data, dataLen);
std::vector<UChar32> out(cpCount);
UErrorCode status = U_ZERO_ERROR;
u_strToUTF32(out.data(), static_cast<int32_t>(out.size()), nullptr, data, dataLen, &status);
THROW_HR_IF(E_UNEXPECTED, status > U_ZERO_ERROR);
return out;
}
static void foldStringUtf32(std::vector<UChar32>& str)
{
for (auto& cp : str)
{
cp = u_foldCase(cp, U_FOLD_CASE_DEFAULT);
}
}
static size_t trySkip(const std::vector<UChar32>& 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<UChar32>& input, const std::vector<UChar32>& 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<CharClass, U_CHAR_CATEGORY_COUNT> 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<UChar32>& text, const std::vector<UChar32>& pattern, std::vector<size_t>* 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<int16_t>(text.size());
auto consecutiveScores = std::vector<int16_t>(text.size());
auto firstOccurrenceOfEachChar = std::vector<size_t>(pattern.size());
auto bonusesSpan = std::vector<int16_t>(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<const UChar32> 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<int16_t>(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<int16_t> scoreMatrix(width * rows);
std::copy_n(initialScores.begin() + firstOccurrenceOfFirstChar, width, scoreMatrix.begin());
std::span scoreSpan(scoreMatrix);
std::vector<int16_t> 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<const UChar32> textSlice = lowerText.subspan(patternCharOffset, sliceLen);
std::span bonusSlice(bonusesSpan.begin() + patternCharOffset, textSlice.size());
std::span<int16_t> consecutiveCharMatrixSlice = consecutiveCharMatrixSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size());
std::span<int16_t> consecutiveCharMatrixDiagonalSlice = consecutiveCharMatrixSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size());
std::span<int16_t> scoreMatrixSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar, textSlice.size());
std::span<int16_t> scoreMatrixDiagonalSlice = scoreSpan.subspan(row + patternCharOffset - firstOccurrenceOfFirstChar - 1 - width, textSlice.size());
std::span<int16_t> 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<MatchResult> 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<size_t> allUtf32Pos;
for (const auto& term : pattern.terms)
{
std::vector<size_t> 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<TextRun> 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) };
}

View File

@ -0,0 +1,27 @@
#pragma once
#include <vector>
#include <icu.h>
namespace fzf::matcher
{
struct TextRun
{
size_t Start;
size_t End;
};
struct MatchResult
{
int32_t Score = 0;
std::vector<TextRun> Runs;
};
struct Pattern
{
std::vector<std::vector<UChar32>> terms;
};
Pattern ParsePattern(std::wstring_view patternStr);
std::optional<MatchResult> Match(std::wstring_view text, const Pattern& pattern);
}

View File

@ -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<fzf::matcher::TextRun>& 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);
}
}

View File

@ -24,6 +24,7 @@
<ClCompile Include="ColorHelperTests.cpp" />
<ClCompile Include="JsonUtilsTests.cpp" />
<ClCompile Include="FzfTests.cpp" />
<ClCompile Include="precomp.cpp">
<PrecompiledHeader>Create</PrecompiledHeader>

View File

@ -57,7 +57,7 @@ namespace til // Terminal Implementation Library. Also: "Today I Learned"
template<typename T>
as_view_t<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<typename T>
as_view_t<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 };

View File

@ -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<T, Traits>;
};
template<>
struct as_view<winrt::hstring>
{
using type = std::wstring_view;
};
}
template<typename T>