Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d2f8663
feat(search): add unicode character removal for fuzzy matching
4yinn Mar 15, 2026
03164fb
Fix typos
Jack251970 Mar 15, 2026
f905530
Check query nullability
Jack251970 Mar 15, 2026
17b2970
feat: add accent normalization with optimized memory usage and toggle…
4yinn Mar 27, 2026
0588fa6
Merge branch 'feature/4149-diacritics-insensitive-search' of https://…
4yinn Mar 27, 2026
c270c9d
feat: add restart button when toggling Sensitive Accents
4yinn Mar 28, 2026
6dfb208
feat: add translations for more languages
4yinn Mar 28, 2026
0059f3e
remove empty space
4yinn Mar 28, 2026
352fdc7
Revert "feat: add translations for more languages"
4yinn Mar 28, 2026
bb10b1f
refactor: remove translations
4yinn Mar 28, 2026
99e7810
refactor: rename to IgnoreAccents for better clarity
4yinn Mar 28, 2026
56973ed
fix: update tests
4yinn Mar 28, 2026
b6028dc
fix: remove emptyspace
4yinn Mar 28, 2026
a52a6af
feat: optimize and stabilize string normalization in StringMatcher
4yinn Mar 28, 2026
839b978
Revert "feat: optimize and stabilize string normalization in StringMa…
4yinn Mar 28, 2026
c2df52e
feat: optimize and stabilize string normalization in StringMatcher
4yinn Mar 28, 2026
99f2b3b
Merge branch 'dev' into feature/4149-diacritics-insensitive-search
4yinn Mar 28, 2026
54a458d
test: provide Settings via constructor for StringMatcher in tests
4yinn Mar 28, 2026
2c53648
refactor: adjust IgnoreAccents handling logic in StringMatcher
4yinn Mar 30, 2026
52bb34f
feat: translate text to English
4yinn Mar 30, 2026
0559145
refactor: replace IPublicApi instance calls with App.API
4yinn Mar 30, 2026
7732442
Refactor: Improved code readability and clarity
4yinn Mar 31, 2026
b7bd1cc
Improve code quality
Jack251970 Mar 31, 2026
2228633
Reorder "Ignore Accents" settings card and InfoBar
Jack251970 Mar 31, 2026
4cbe223
Update ignoreAccents text to refer to all results
Jack251970 Mar 31, 2026
c945d08
Update StringMatcher to react to QuerySearchPrecision changes
Jack251970 Apr 3, 2026
e0f7722
Add StringMatcherBehaviorChanged event to Settings
Jack251970 Apr 3, 2026
459a369
feat: remove btn restart
4yinn Apr 3, 2026
0228f7a
Update ignoreAccents tooltip and add UpdateApp method
Jack251970 Apr 3, 2026
7607844
Add icon to "Ignore Accents" SettingsCard
Jack251970 Apr 4, 2026
2ab6405
feat: add more characters to support additional languages
4yinn Apr 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 165 additions & 23 deletions Flow.Launcher.Infrastructure/StringMatcher.cs
Original file line number Diff line number Diff line change
@@ -1,30 +1,41 @@
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Plugin.SharedModels;
using System;
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.UserSettings;
using Flow.Launcher.Plugin.SharedModels;

namespace Flow.Launcher.Infrastructure
{
public class StringMatcher
{
private readonly MatchOption _defaultMatchOption = new();

private readonly Settings _settings;
public SearchPrecisionScore UserSettingSearchPrecision { get; set; }

private readonly IAlphabet _alphabet;

public StringMatcher(IAlphabet alphabet, Settings settings)
{
_alphabet = alphabet;
UserSettingSearchPrecision = settings.QuerySearchPrecision;
_settings = settings;
UserSettingSearchPrecision = _settings.QuerySearchPrecision;

_settings.PropertyChanged += (sender, e) =>
{
switch (e.PropertyName)
{
case nameof(Settings.QuerySearchPrecision):
UserSettingSearchPrecision = _settings.QuerySearchPrecision;
break;
}
};
}

// This is a workaround to allow unit tests to set the instance
public StringMatcher(IAlphabet alphabet)
public StringMatcher(IAlphabet alphabet) : this(alphabet, new Settings())
{
_alphabet = alphabet;
}

public static MatchResult FuzzySearch(string query, string stringToCompare)
Expand Down Expand Up @@ -80,10 +91,22 @@ public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption
int acronymsTotalCount = 0;
int acronymsMatched = 0;

var fullStringToCompareWithoutCase = opt.IgnoreCase ? stringToCompare.ToLower() : stringToCompare;
var queryWithoutCase = opt.IgnoreCase ? query.ToLower() : query;
var fullStringToCompare = stringToCompare;
var queryToCompare = query;

if (_settings.IgnoreAccents)
{
fullStringToCompare = Normalize(fullStringToCompare);
Comment thread
Jack251970 marked this conversation as resolved.
queryToCompare = Normalize(queryToCompare);
}

if (opt.IgnoreCase)
{
fullStringToCompare = fullStringToCompare.ToLower();
queryToCompare = queryToCompare.ToLower();
}

var querySubstrings = queryWithoutCase.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
var querySubstrings = queryToCompare.Split([' '], StringSplitOptions.RemoveEmptyEntries);
int currentQuerySubstringIndex = 0;
var currentQuerySubstring = querySubstrings[currentQuerySubstringIndex];
var currentQuerySubstringCharacterIndex = 0;
Expand All @@ -98,7 +121,9 @@ public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption
var indexList = new List<int>();
List<int> spaceIndices = new List<int>();

for (var compareStringIndex = 0; compareStringIndex < fullStringToCompareWithoutCase.Length; compareStringIndex++)
for (var compareStringIndex = 0;
compareStringIndex < fullStringToCompare.Length;
compareStringIndex++)
{
// If acronyms matching successfully finished, this gets the remaining not matched acronyms for score calculation
if (currentAcronymQueryIndex >= query.Length && acronymsMatched == query.Length)
Expand All @@ -114,14 +139,14 @@ public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption

// To maintain a list of indices which correspond to spaces in the string to compare
// To populate the list only for the first query substring
if (fullStringToCompareWithoutCase[compareStringIndex] == ' ' && currentQuerySubstringIndex == 0)
if (fullStringToCompare[compareStringIndex] == ' ' && currentQuerySubstringIndex == 0)
spaceIndices.Add(compareStringIndex);

// Acronym Match
if (IsAcronym(stringToCompare, compareStringIndex))
{
if (fullStringToCompareWithoutCase[compareStringIndex] ==
queryWithoutCase[currentAcronymQueryIndex])
if (fullStringToCompare[compareStringIndex] ==
queryToCompare[currentAcronymQueryIndex])
{
acronymMatchData.Add(compareStringIndex);
acronymsMatched++;
Expand All @@ -133,7 +158,7 @@ public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption
if (IsAcronymCount(stringToCompare, compareStringIndex))
acronymsTotalCount++;

if (allQuerySubstringsMatched || fullStringToCompareWithoutCase[compareStringIndex] !=
if (allQuerySubstringsMatched || fullStringToCompare[compareStringIndex] !=
currentQuerySubstring[currentQuerySubstringCharacterIndex])
{
matchFoundInPreviousLoop = false;
Expand All @@ -160,7 +185,7 @@ public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption
var startIndexToVerify = compareStringIndex - currentQuerySubstringCharacterIndex;

if (AllPreviousCharsMatched(startIndexToVerify, currentQuerySubstringCharacterIndex,
fullStringToCompareWithoutCase, currentQuerySubstring))
fullStringToCompare, currentQuerySubstring))
{
matchFoundInPreviousLoop = true;

Expand Down Expand Up @@ -205,7 +230,8 @@ public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption

if (acronymScore >= (int)UserSettingSearchPrecision)
{
acronymMatchData = acronymMatchData.Select(x => translationMapping?.MapToOriginalIndex(x) ?? x).Distinct().ToList();
acronymMatchData = acronymMatchData.Select(x => translationMapping?.MapToOriginalIndex(x) ?? x)
.Distinct().ToList();
return new MatchResult(true, UserSettingSearchPrecision, acronymMatchData, acronymScore);
}
}
Expand All @@ -218,19 +244,134 @@ public MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption
// firstMatchIndex - nearestSpaceIndex - 1 is to set the firstIndex as the index of the first matched char
// preceded by a space e.g. 'world' matching 'hello world' firstIndex would be 0 not 6
// giving more weight than 'we or donald' by allowing the distance calculation to treat the starting position at after the space.
var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1, spaceIndices,
var score = CalculateSearchScore(query, stringToCompare, firstMatchIndex - nearestSpaceIndex - 1,
spaceIndices,
lastMatchIndex - firstMatchIndex, allSubstringsContainedInCompareString);

var resultList = indexList.Select(x => translationMapping?.MapToOriginalIndex(x) ?? x).Distinct().ToList();
var resultList = indexList.Select(x => translationMapping?.MapToOriginalIndex(x) ?? x).Distinct()
.ToList();
return new MatchResult(true, UserSettingSearchPrecision, resultList, score);
}

return new MatchResult(false, UserSettingSearchPrecision);
}


private static readonly Dictionary<char, char> AccentMap = new()
{
['á'] = 'a',
['à'] = 'a',
['ã'] = 'a',
['â'] = 'a',
['ä'] = 'a',
['å'] = 'a',
['ā'] = 'a',
['ă'] = 'a',
['ą'] = 'a',
['é'] = 'e',
['è'] = 'e',
['ê'] = 'e',
['ë'] = 'e',
['ē'] = 'e',
['ĕ'] = 'e',
['ė'] = 'e',
['ę'] = 'e',
['ě'] = 'e',
['í'] = 'i',
['ì'] = 'i',
['î'] = 'i',
['ï'] = 'i',
['ī'] = 'i',
['ĭ'] = 'i',
['į'] = 'i',
['ı'] = 'i',
['ó'] = 'o',
['ò'] = 'o',
['õ'] = 'o',
['ô'] = 'o',
['ö'] = 'o',
['ø'] = 'o',
['ō'] = 'o',
['ŏ'] = 'o',
['ő'] = 'o',
['ú'] = 'u',
['ù'] = 'u',
['û'] = 'u',
['ü'] = 'u',
['ū'] = 'u',
['ŭ'] = 'u',
['ů'] = 'u',
['ű'] = 'u',
['ų'] = 'u',
['ç'] = 'c',
['ć'] = 'c',
['ĉ'] = 'c',
['ċ'] = 'c',
['č'] = 'c',
['ñ'] = 'n',
['ń'] = 'n',
['ņ'] = 'n',
['ň'] = 'n',
['ŋ'] = 'n',
['ý'] = 'y',
['ÿ'] = 'y',
['ŷ'] = 'y',
['ś'] = 's',
['ŝ'] = 's',
['ş'] = 's',
['š'] = 's',
['ß'] = 's',
['ź'] = 'z',
['ż'] = 'z',
['ž'] = 'z',
['ł'] = 'l',
['ď'] = 'd',
['đ'] = 'd',
['ĝ'] = 'g',
['ğ'] = 'g',
['ġ'] = 'g',
['ģ'] = 'g',
['ĥ'] = 'h',
['ħ'] = 'h',
['ĵ'] = 'j',
['ķ'] = 'k',
['ŕ'] = 'r',
['ř'] = 'r',
['ţ'] = 't',
['ť'] = 't',
['ŧ'] = 't',
['æ'] = 'a',
['œ'] = 'o'
};

public static string Normalize(string value)
{
if (string.IsNullOrEmpty(value)) return value;
char[] arrayFromPool = null;
Span<char> buffer = value.Length <= 512
? stackalloc char[value.Length]
: (arrayFromPool = ArrayPool<char>.Shared.Rent(value.Length));
try
{
for (int i = 0; i < value.Length; i++)
{
var c = char.ToLowerInvariant(value[i]);
buffer[i] = AccentMap.TryGetValue(c, out var mapped) ? mapped : c;
}
Comment on lines +356 to +360
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason this can't be out inside the existing loop? Normalize is still called to loop through both compare strings before the main loop, which also loops through the new strings.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a way, I placed this loop outside to keep it O(n). If I left this loop inside the existing loop, it would have to traverse the same string more than once, resulting in O(n²) complexity. This is my understanding—I could be wrong, but this is the conclusion I reached.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jjw24 I took a closer look to give a clearer answer: I didn’t put the normalization inside the loop because it needs to already be normalized before entering the loop to perform the processing.

Copy link
Copy Markdown
Member

@jjw24 jjw24 Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, mostly true- the queryToCompare string will need to be normalized before entering because of the splitting into substrings, but I still think there are benefits to move the stringToCompare normalization inside the main loop so it's not doubling up.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will create a PR against this one to show and review, leave it with me.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, do the changes in my PR work?

Copy link
Copy Markdown
Author

@4yinn 4yinn Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, do the changes in my PR work?

Sorry for the delay, I tested it now and it didn’t work.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't normalize the character? Can you show me how to reproduce please.

Copy link
Copy Markdown
Author

@4yinn 4yinn Apr 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't normalize the character? Can you show me how to reproduce please.

First test I did was the usual one: I enabled IgnoreAccents and searched for "camera", and the result was:

test1

Second, I tried searching with the accent "câmera", and that didn’t work either:

test2

When I disable it, the query works normally.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will take another look soon.


return new string(buffer.Slice(0, value.Length));
}
finally
{
if (arrayFromPool != null)
ArrayPool<char>.Shared.Return(arrayFromPool);
}
}

private static bool IsAcronym(string stringToCompare, int compareStringIndex)
{
if (IsAcronymChar(stringToCompare, compareStringIndex) || IsAcronymNumber(stringToCompare, compareStringIndex))
if (IsAcronymChar(stringToCompare, compareStringIndex) ||
IsAcronymNumber(stringToCompare, compareStringIndex))
return true;

return false;
Expand Down Expand Up @@ -274,12 +415,12 @@ private static int CalculateClosestSpaceIndex(List<int> spaceIndices, int firstM
}

private static bool AllPreviousCharsMatched(int startIndexToVerify, int currentQuerySubstringCharacterIndex,
string fullStringToCompareWithoutCase, string currentQuerySubstring)
string fullStringToCompare, string currentQuerySubstring)
{
var allMatch = true;
for (int indexToCheck = 0; indexToCheck < currentQuerySubstringCharacterIndex; indexToCheck++)
{
if (fullStringToCompareWithoutCase[startIndexToVerify + indexToCheck] !=
if (fullStringToCompare[startIndexToVerify + indexToCheck] !=
currentQuerySubstring[indexToCheck])
{
allMatch = false;
Expand Down Expand Up @@ -312,7 +453,8 @@ private static bool AllQuerySubstringsMatched(int currentQuerySubstringIndex, in
return currentQuerySubstringIndex >= querySubstringsLength;
}

private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex, List<int> spaceIndices, int matchLen,
private static int CalculateSearchScore(string query, string stringToCompare, int firstIndex,
List<int> spaceIndices, int matchLen,
bool allSubstringsContainedInCompareString)
{
// A match found near the beginning of a string is scored more than a match found near the end
Expand Down
44 changes: 36 additions & 8 deletions Flow.Launcher.Infrastructure/UserSettings/Settings.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Text.Json.Serialization;
using System.Windows;
using System.Windows.Media;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.Hotkey;
using Flow.Launcher.Infrastructure.Logger;
using Flow.Launcher.Infrastructure.Storage;
Expand All @@ -16,7 +17,8 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public class Settings : BaseModel, IHotkeySettings
{
private FlowLauncherJsonStorage<Settings> _storage;
private StringMatcher _stringMatcher = null;

public event EventHandler StringMatcherBehaviorChanged;

public void SetStorage(FlowLauncherJsonStorage<Settings> storage)
{
Expand All @@ -25,13 +27,26 @@ public void SetStorage(FlowLauncherJsonStorage<Settings> storage)

public void Initialize()
{
// Initialize dependency injection instances after Ioc.Default is created
_stringMatcher = Ioc.Default.GetRequiredService<StringMatcher>();

// Initialize application resources after application is created
var settingWindowFont = new FontFamily(SettingWindowFont);
Application.Current.Resources["SettingWindowFont"] = settingWindowFont;
Application.Current.Resources["ContentControlThemeFontFamily"] = settingWindowFont;

PropertyChanged += Settings_PropertyChanged;
}

private void Settings_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(QuerySearchPrecision):
case nameof(ShouldUsePinyin):
case nameof(UseDoublePinyin):
case nameof(DoublePinyinSchema):
case nameof(IgnoreAccents):
StringMatcherBehaviorChanged?.Invoke(this, EventArgs.Empty);
break;
}
}

public void Save()
Expand Down Expand Up @@ -403,8 +418,21 @@ public SearchPrecisionScore QuerySearchPrecision
if (_querySearchPrecision != value)
{
_querySearchPrecision = value;
if (_stringMatcher != null)
_stringMatcher.UserSettingSearchPrecision = value;
OnPropertyChanged();
}
}
}

private bool _IgnoreAccents = false;
public bool IgnoreAccents
{
get => _IgnoreAccents;
set
{
if (_IgnoreAccents != value)
{
_IgnoreAccents = value;
OnPropertyChanged();
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions Flow.Launcher.Plugin/Interfaces/IPublicAPI.cs
Original file line number Diff line number Diff line change
Expand Up @@ -637,5 +637,10 @@ public interface IPublicAPI
/// </summary>
/// <returns></returns>
string GetLogDirectory();

/// <summary>
/// Invoked when the behavior of string matcher has changed which can effect the result of <see cref="MatchResult"/>.
/// </summary>
event EventHandler StringMatcherBehaviorChanged;
}
}
Loading
Loading