mirror of
https://github.com/microsoft/terminal.git
synced 2025-12-10 00:48:23 -06:00
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:
parent
37d5aec1cf
commit
74b5a0c975
1
.github/actions/spelling/excludes.txt
vendored
1
.github/actions/spelling/excludes.txt
vendored
@ -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$
|
||||
|
||||
4
.github/actions/spelling/expect/expect.txt
vendored
4
.github/actions/spelling/expect/expect.txt
vendored
@ -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
|
||||
|
||||
32
NOTICE.md
32
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
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -12,10 +12,7 @@ namespace TerminalApp
|
||||
FilteredCommand(PaletteItem item);
|
||||
|
||||
PaletteItem Item { get; };
|
||||
String Filter;
|
||||
HighlightedText HighlightedName { get; };
|
||||
Int32 Weight;
|
||||
|
||||
void UpdateFilter(String filter);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
22
src/cascadia/TerminalApp/fzf/LICENSE
Normal file
22
src/cascadia/TerminalApp/fzf/LICENSE
Normal 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.
|
||||
432
src/cascadia/TerminalApp/fzf/fzf.cpp
Normal file
432
src/cascadia/TerminalApp/fzf/fzf.cpp
Normal 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) };
|
||||
}
|
||||
27
src/cascadia/TerminalApp/fzf/fzf.h
Normal file
27
src/cascadia/TerminalApp/fzf/fzf.h
Normal 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);
|
||||
}
|
||||
568
src/cascadia/ut_app/FzfTests.cpp
Normal file
568
src/cascadia/ut_app/FzfTests.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@
|
||||
<ClCompile Include="ColorHelperTests.cpp" />
|
||||
|
||||
<ClCompile Include="JsonUtilsTests.cpp" />
|
||||
<ClCompile Include="FzfTests.cpp" />
|
||||
|
||||
<ClCompile Include="precomp.cpp">
|
||||
<PrecompiledHeader>Create</PrecompiledHeader>
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user