Merge pull request #2427 from Flow-Launcher/double-pin

Double pinyin query
This commit is contained in:
Jeremy Wu 2025-07-14 00:35:57 +10:00 committed by GitHub
commit 354e5ecf99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 345 additions and 187 deletions

View file

@ -0,0 +1,22 @@
namespace Flow.Launcher.Infrastructure
{
/// <summary>
/// Translate a language to English letters using a given rule.
/// </summary>
public interface IAlphabet
{
/// <summary>
/// Translate a string to English letters, using a given rule.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public (string translation, TranslationMapping map) Translate(string stringToTranslate);
/// <summary>
/// Determine if a string should be translated to English letter with this Alphabet.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public bool ShouldTranslate(string stringToTranslate);
}
}

View file

@ -1,209 +1,148 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Collections.ObjectModel;
using System.IO;
using System.Text;
using JetBrains.Annotations;
using System.Text.Json;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.UserSettings;
using ToolGood.Words.Pinyin;
using CommunityToolkit.Mvvm.DependencyInjection;
using Flow.Launcher.Infrastructure.Logger;
namespace Flow.Launcher.Infrastructure
{
public class TranslationMapping
{
private bool constructed;
private List<int> originalIndexs = new List<int>();
private List<int> translatedIndexs = new List<int>();
private int translatedLength = 0;
public string key { get; private set; }
public void setKey(string key)
{
this.key = key;
}
public void AddNewIndex(int originalIndex, int translatedIndex, int length)
{
if (constructed)
throw new InvalidOperationException("Mapping shouldn't be changed after constructed");
originalIndexs.Add(originalIndex);
translatedIndexs.Add(translatedIndex);
translatedIndexs.Add(translatedIndex + length);
translatedLength += length - 1;
}
public int MapToOriginalIndex(int translatedIndex)
{
if (translatedIndex > translatedIndexs.Last())
return translatedIndex - translatedLength - 1;
int lowerBound = 0;
int upperBound = originalIndexs.Count - 1;
int count = 0;
// Corner case handle
if (translatedIndex < translatedIndexs[0])
return translatedIndex;
if (translatedIndex > translatedIndexs.Last())
{
int indexDef = 0;
for (int k = 0; k < originalIndexs.Count; k++)
{
indexDef += translatedIndexs[k * 2 + 1] - translatedIndexs[k * 2];
}
return translatedIndex - indexDef - 1;
}
// Binary Search with Range
for (int i = originalIndexs.Count / 2;; count++)
{
if (translatedIndex < translatedIndexs[i * 2])
{
// move to lower middle
upperBound = i;
i = (i + lowerBound) / 2;
}
else if (translatedIndex > translatedIndexs[i * 2 + 1] - 1)
{
lowerBound = i;
// move to upper middle
// due to floor of integer division, move one up on corner case
i = (i + upperBound + 1) / 2;
}
else
return originalIndexs[i];
if (upperBound - lowerBound <= 1 &&
translatedIndex > translatedIndexs[lowerBound * 2 + 1] &&
translatedIndex < translatedIndexs[upperBound * 2])
{
int indexDef = 0;
for (int j = 0; j < upperBound; j++)
{
indexDef += translatedIndexs[j * 2 + 1] - translatedIndexs[j * 2];
}
return translatedIndex - indexDef - 1;
}
}
}
public void endConstruct()
{
if (constructed)
throw new InvalidOperationException("Mapping has already been constructed");
constructed = true;
}
}
/// <summary>
/// Translate a language to English letters using a given rule.
/// </summary>
public interface IAlphabet
{
/// <summary>
/// Translate a string to English letters, using a given rule.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public (string translation, TranslationMapping map) Translate(string stringToTranslate);
/// <summary>
/// Determine if a string can be translated to English letter with this Alphabet.
/// </summary>
/// <param name="stringToTranslate">String to translate.</param>
/// <returns></returns>
public bool CanBeTranslated(string stringToTranslate);
}
public class PinyinAlphabet : IAlphabet
{
private ConcurrentDictionary<string, (string translation, TranslationMapping map)> _pinyinCache =
new ConcurrentDictionary<string, (string translation, TranslationMapping map)>();
new();
private Settings _settings;
private readonly Settings _settings;
private ReadOnlyDictionary<string, string> currentDoublePinyinTable;
public PinyinAlphabet()
{
Initialize(Ioc.Default.GetRequiredService<Settings>());
_settings = Ioc.Default.GetRequiredService<Settings>();
LoadDoublePinyinTable();
_settings.PropertyChanged += (sender, e) =>
{
if (e.PropertyName == nameof(Settings.UseDoublePinyin) ||
e.PropertyName == nameof(Settings.DoublePinyinSchema))
{
Reload();
}
};
}
private void Initialize([NotNull] Settings settings)
public void Reload()
{
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
LoadDoublePinyinTable();
_pinyinCache.Clear();
}
public bool CanBeTranslated(string stringToTranslate)
private void CreateDoublePinyinTableFromStream(Stream jsonStream)
{
return WordsHelper.HasChinese(stringToTranslate);
Dictionary<string, Dictionary<string, string>> table = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string>>>(jsonStream);
string schemaKey = _settings.DoublePinyinSchema.ToString(); // Convert enum to string
if (!table.TryGetValue(schemaKey, out var value))
{
throw new ArgumentException("DoublePinyinSchema is invalid or double pinyin table is broken.");
}
currentDoublePinyinTable = new ReadOnlyDictionary<string, string>(value);
}
private void LoadDoublePinyinTable()
{
if (_settings.UseDoublePinyin)
{
var tablePath = Path.Join(AppContext.BaseDirectory, "Resources", "double_pinyin.json");
try
{
using var fs = File.OpenRead(tablePath);
CreateDoublePinyinTableFromStream(fs);
}
catch (System.Exception e)
{
Log.Exception(nameof(PinyinAlphabet), "Failed to load double pinyin table from file: " + tablePath, e);
currentDoublePinyinTable = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
}
}
else
{
currentDoublePinyinTable = new ReadOnlyDictionary<string, string>(new Dictionary<string, string>());
}
}
public bool ShouldTranslate(string stringToTranslate)
{
// If a string has Chinese characters, we don't need to translate it to pinyin.
return _settings.ShouldUsePinyin && !WordsHelper.HasChinese(stringToTranslate);
}
public (string translation, TranslationMapping map) Translate(string content)
{
if (_settings.ShouldUsePinyin)
{
if (!_pinyinCache.ContainsKey(content))
{
return BuildCacheFromContent(content);
}
else
{
return _pinyinCache[content];
}
}
return (content, null);
if (!_settings.ShouldUsePinyin || !WordsHelper.HasChinese(content))
return (content, null);
return _pinyinCache.TryGetValue(content, out var value)
? value
: BuildCacheFromContent(content);
}
private (string translation, TranslationMapping map) BuildCacheFromContent(string content)
{
if (WordsHelper.HasChinese(content))
var resultList = WordsHelper.GetPinyinList(content);
var resultBuilder = new StringBuilder();
var map = new TranslationMapping();
var previousIsChinese = false;
for (var i = 0; i < resultList.Length; i++)
{
var resultList = WordsHelper.GetPinyinList(content);
StringBuilder resultBuilder = new StringBuilder();
TranslationMapping map = new TranslationMapping();
bool pre = false;
for (int i = 0; i < resultList.Length; i++)
if (content[i] >= 0x3400 && content[i] <= 0x9FD5)
{
if (content[i] >= 0x3400 && content[i] <= 0x9FD5)
string translated = _settings.UseDoublePinyin ? ToDoublePin(resultList[i]) : resultList[i];
if (i > 0)
{
map.AddNewIndex(i, resultBuilder.Length, resultList[i].Length + 1);
resultBuilder.Append(' ');
resultBuilder.Append(resultList[i]);
pre = true;
}
else
{
if (pre)
{
pre = false;
resultBuilder.Append(' ');
}
resultBuilder.Append(resultList[i]);
}
map.AddNewIndex(resultBuilder.Length, translated.Length);
resultBuilder.Append(translated);
previousIsChinese = true;
}
else
{
if (previousIsChinese)
{
previousIsChinese = false;
resultBuilder.Append(' ');
}
map.AddNewIndex(resultBuilder.Length, resultList[i].Length);
resultBuilder.Append(resultList[i]);
}
map.endConstruct();
var key = resultBuilder.ToString();
map.setKey(key);
return _pinyinCache[content] = (key, map);
}
else
{
return (content, null);
}
map.endConstruct();
var key = resultBuilder.ToString();
return _pinyinCache[content] = (key, map);
}
#region Double Pinyin
private string ToDoublePin(string fullPinyin)
{
if (currentDoublePinyinTable.TryGetValue(fullPinyin, out var doublePinyinValue))
{
return doublePinyinValue;
}
return fullPinyin;
}
#endregion
}
}

View file

@ -68,7 +68,7 @@ namespace Flow.Launcher.Infrastructure
query = query.Trim();
TranslationMapping translationMapping = null;
if (_alphabet is not null && !_alphabet.CanBeTranslated(query))
if (_alphabet is not null && _alphabet.ShouldTranslate(query))
{
// We assume that if a query can be translated (containing characters of a language, like Chinese)
// it actually means user doesn't want it to be translated to English letters.
@ -228,7 +228,7 @@ namespace Flow.Launcher.Infrastructure
return new MatchResult(false, UserSettingSearchPrecision);
}
private bool IsAcronym(string stringToCompare, int compareStringIndex)
private static bool IsAcronym(string stringToCompare, int compareStringIndex)
{
if (IsAcronymChar(stringToCompare, compareStringIndex) || IsAcronymNumber(stringToCompare, compareStringIndex))
return true;
@ -237,7 +237,7 @@ namespace Flow.Launcher.Infrastructure
}
// When counting acronyms, treat a set of numbers as one acronym ie. Visual 2019 as 2 acronyms instead of 5
private bool IsAcronymCount(string stringToCompare, int compareStringIndex)
private static bool IsAcronymCount(string stringToCompare, int compareStringIndex)
{
if (IsAcronymChar(stringToCompare, compareStringIndex))
return true;

View file

@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace Flow.Launcher.Infrastructure
{
public class TranslationMapping
{
private bool constructed;
// Assuming one original item maps to multi translated items
// list[i] is the last translated index + 1 of original index i
private readonly List<int> originalToTranslated = new();
public void AddNewIndex(int translatedIndex, int length)
{
if (constructed)
throw new InvalidOperationException("Mapping shouldn't be changed after constructed");
originalToTranslated.Add(translatedIndex + length);
}
public int MapToOriginalIndex(int translatedIndex)
{
int loc = originalToTranslated.BinarySearch(translatedIndex);
return loc >= 0 ? loc : ~loc;
}
public void endConstruct()
{
if (constructed)
throw new InvalidOperationException("Mapping has already been constructed");
constructed = true;
}
}
}

View file

@ -115,7 +115,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
}
public bool UseDropShadowEffect { get; set; } = true;
public BackdropTypes BackdropType{ get; set; } = BackdropTypes.None;
public BackdropTypes BackdropType { get; set; } = BackdropTypes.None;
public string ReleaseNotesVersion { get; set; } = string.Empty;
/* Appearance Settings. It should be separated from the setting later.*/
@ -228,7 +228,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings
}
}
}
public int MaxHistoryResultsToShowForHomePage { get; set; } = 5;
public bool AutoRestartAfterChanging { get; set; } = false;
@ -330,6 +330,36 @@ namespace Flow.Launcher.Infrastructure.UserSettings
/// </summary>
public bool ShouldUsePinyin { get; set; } = false;
private bool _useDoublePinyin = false;
public bool UseDoublePinyin
{
get => _useDoublePinyin;
set
{
if (_useDoublePinyin != value)
{
_useDoublePinyin = value;
OnPropertyChanged();
}
}
}
private DoublePinyinSchemas _doublePinyinSchema = DoublePinyinSchemas.XiaoHe;
[JsonInclude, JsonConverter(typeof(JsonStringEnumConverter))]
public DoublePinyinSchemas DoublePinyinSchema
{
get => _doublePinyinSchema;
set
{
if (_doublePinyinSchema != value)
{
_doublePinyinSchema = value;
OnPropertyChanged();
}
}
}
public bool AlwaysPreview { get; set; } = false;
public bool AlwaysStartEn { get; set; } = false;
@ -492,7 +522,7 @@ namespace Flow.Launcher.Infrastructure.UserSettings
if (!string.IsNullOrEmpty(SettingWindowHotkey))
list.Add(new(SettingWindowHotkey, "SettingWindowHotkey", () => SettingWindowHotkey = ""));
if (!string.IsNullOrEmpty(OpenHistoryHotkey))
list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = ""));
list.Add(new(OpenHistoryHotkey, "OpenHistoryHotkey", () => OpenHistoryHotkey = ""));
if (!string.IsNullOrEmpty(OpenContextMenuHotkey))
list.Add(new(OpenContextMenuHotkey, "OpenContextMenuHotkey", () => OpenContextMenuHotkey = ""));
if (!string.IsNullOrEmpty(SelectNextPageHotkey))
@ -598,9 +628,22 @@ namespace Flow.Launcher.Infrastructure.UserSettings
public enum BackdropTypes
{
None,
None,
Acrylic,
Mica,
MicaAlt
}
public enum DoublePinyinSchemas
{
XiaoHe,
ZiRanMa,
WeiRuan,
ZhiNengABC,
ZiGuangPinYin,
PinYinJiaJia,
XingKongJianDao,
DaNiu,
XiaoLang
}
}

View file

@ -0,0 +1,56 @@
using Flow.Launcher.Infrastructure;
using NUnit.Framework;
using NUnit.Framework.Legacy;
namespace Flow.Launcher.Test
{
[TestFixture]
public class TranslationMappingTest
{
[Test]
public void AddNewIndex_ShouldAddTranslatedIndexPlusLength()
{
var mapping = new TranslationMapping();
mapping.AddNewIndex(5, 3);
mapping.AddNewIndex(8, 2);
// 5+3=8, 8+2=10
ClassicAssert.AreEqual(2, GetOriginalToTranslatedCount(mapping));
ClassicAssert.AreEqual(8, GetOriginalToTranslatedAt(mapping, 0));
ClassicAssert.AreEqual(10, GetOriginalToTranslatedAt(mapping, 1));
}
[TestCase(0, 0)]
[TestCase(2, 1)]
[TestCase(3, 1)]
[TestCase(5, 2)]
[TestCase(6, 2)]
public void MapToOriginalIndex_ShouldReturnExpectedIndex(int translatedIndex, int expectedOriginalIndex)
{
var mapping = new TranslationMapping();
// a测试
// a Ce Shi
mapping.AddNewIndex(0, 1);
mapping.AddNewIndex(2, 2);
mapping.AddNewIndex(5, 3);
var result = mapping.MapToOriginalIndex(translatedIndex);
ClassicAssert.AreEqual(expectedOriginalIndex, result);
}
private int GetOriginalToTranslatedCount(TranslationMapping mapping)
{
var field = typeof(TranslationMapping).GetField("originalToTranslated", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var list = (System.Collections.Generic.List<int>)field.GetValue(mapping);
return list.Count;
}
private int GetOriginalToTranslatedAt(TranslationMapping mapping, int index)
{
var field = typeof(TranslationMapping).GetField("originalToTranslated", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var list = (System.Collections.Generic.List<int>)field.GetValue(mapping);
return list[index];
}
}
}

View file

@ -127,6 +127,9 @@
<None Update="Resources\dev.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Resources\double_pinyin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">

View file

@ -105,6 +105,19 @@
<system:String x:Key="SearchPrecisionRegular">Regular</system:String>
<system:String x:Key="ShouldUsePinyin">Search with Pinyin</system:String>
<system:String x:Key="ShouldUsePinyinToolTip">Allows using Pinyin to search. Pinyin is the standard system of romanized spelling for translating Chinese.</system:String>
<system:String x:Key="ShouldUseDoublePinyin">Use Double Pinyin</system:String>
<system:String x:Key="ShouldUseDoublePinyinToolTip">Allows using Double Pinyin to search. Double Pinyin is a variation of Pinyin that uses two characters.</system:String>
<system:String x:Key="DoublePinyinSchema">Double Pinyin Schema</system:String>
<system:String x:Key="DoublePinyinSchemasXiaoHe">Xiao He</system:String>
<system:String x:Key="DoublePinyinSchemasZiRanMa">Zi Ran Ma</system:String>
<system:String x:Key="DoublePinyinSchemasWeiRuan">Wei Ruan</system:String>
<system:String x:Key="DoublePinyinSchemasZhiNengABC">Zhi Neng ABC</system:String>
<system:String x:Key="DoublePinyinSchemasZiGuangPinYin">Zi Guang Pin Yin</system:String>
<system:String x:Key="DoublePinyinSchemasPinYinJiaJia">Pin Yin Jia Jia</system:String>
<system:String x:Key="DoublePinyinSchemasXingKongJianDao">Xing Kong Jian Dao</system:String>
<system:String x:Key="DoublePinyinSchemasDaNiu">Da Niu</system:String>
<system:String x:Key="DoublePinyinSchemasXiaoLang">Xiao Lang</system:String>
<system:String x:Key="AlwaysPreview">Always Preview</system:String>
<system:String x:Key="AlwaysPreviewToolTip">Always open preview panel when Flow activates. Press {0} to toggle preview.</system:String>
<system:String x:Key="shadowEffectNotAllowed">Shadow effect is not allowed while current theme has blur effect enabled</system:String>

File diff suppressed because one or more lines are too long

View file

@ -35,6 +35,7 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
public class SearchWindowAlignData : DropdownDataGeneric<SearchWindowAligns> { }
public class SearchPrecisionData : DropdownDataGeneric<SearchPrecisionScore> { }
public class LastQueryModeData : DropdownDataGeneric<LastQueryMode> { }
public class DoublePinyinSchemaData : DropdownDataGeneric<DoublePinyinSchemas> { }
public bool StartFlowLauncherOnSystemStartup
{
@ -177,6 +178,7 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
DropdownDataGeneric<SearchWindowAligns>.UpdateLabels(SearchWindowAligns);
DropdownDataGeneric<SearchPrecisionScore>.UpdateLabels(SearchPrecisionScores);
DropdownDataGeneric<LastQueryMode>.UpdateLabels(LastQueryModes);
DropdownDataGeneric<DoublePinyinSchemas>.UpdateLabels(DoublePinyinSchemas);
// Since we are using Binding instead of DynamicResource, we need to manually trigger the update
OnPropertyChanged(nameof(AlwaysPreviewToolTip));
}
@ -262,9 +264,25 @@ public partial class SettingsPaneGeneralViewModel : BaseModel
public bool ShouldUsePinyin
{
get => Settings.ShouldUsePinyin;
set => Settings.ShouldUsePinyin = value;
set
{
if (value == false && UseDoublePinyin == true)
{
UseDoublePinyin = false;
}
Settings.ShouldUsePinyin = value;
}
}
public bool UseDoublePinyin
{
set => Settings.UseDoublePinyin = value;
get => Settings.UseDoublePinyin;
}
public List<DoublePinyinSchemaData> DoublePinyinSchemas { get; } =
DropdownDataGeneric<DoublePinyinSchemas>.GetValues<DoublePinyinSchemaData>("DoublePinyinSchemas");
public List<Language> Languages => _translater.LoadAvailableLanguages();
public string AlwaysPreviewToolTip => string.Format(

View file

@ -371,16 +371,44 @@
OnContent="{DynamicResource enable}" />
</cc:Card>
<cc:Card
Title="{DynamicResource ShouldUsePinyin}"
Icon="&#xe98a;"
Sub="{DynamicResource ShouldUsePinyinToolTip}">
<ui:ToggleSwitch
IsOn="{Binding Settings.ShouldUsePinyin}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}"
ToolTip="{DynamicResource ShouldUsePinyinToolTip}" />
</cc:Card>
<cc:CardGroup Margin="0 4 0 0">
<cc:Card
Title="{DynamicResource ShouldUsePinyin}"
Icon="&#xe98a;"
Sub="{DynamicResource ShouldUsePinyinToolTip}"
Type="First">
<ui:ToggleSwitch
IsOn="{Binding ShouldUsePinyin}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}"
ToolTip="{DynamicResource ShouldUsePinyinToolTip}" />
</cc:Card>
<cc:Card
Visibility="{ext:VisibleWhen {Binding ShouldUsePinyin},
IsEqualToBool=True}"
Title="{DynamicResource ShouldUseDoublePinyin}"
Icon="&#xf085;"
Sub="{DynamicResource ShouldUseDoublePinyinToolTip}"
Type="Middle">
<ui:ToggleSwitch
IsOn="{Binding UseDoublePinyin}"
OffContent="{DynamicResource disable}"
OnContent="{DynamicResource enable}"
ToolTip="{DynamicResource ShouldUseDoublePinyinToolTip}" />
</cc:Card>
<cc:Card
Visibility="{ext:VisibleWhen {Binding UseDoublePinyin},
IsEqualToBool=True}"
Title="{DynamicResource DoublePinyinSchema}"
Sub="{DynamicResource DoublePinyinSchemaToolTip}"
Type="Last">
<ComboBox
DisplayMemberPath="Display"
ItemsSource="{Binding DoublePinyinSchemas}"
SelectedValue="{Binding Settings.DoublePinyinSchema}"
SelectedValuePath="Value" />
</cc:Card>
</cc:CardGroup>
<cc:Card
Title="{DynamicResource language}"