diff --git a/PCL.Core.Test/LocalizationTest.cs b/PCL.Core.Test/LocalizationTest.cs new file mode 100644 index 000000000..bc97a8475 --- /dev/null +++ b/PCL.Core.Test/LocalizationTest.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using PCL.Core.App.Localization; + +namespace PCL.Core.Test; + +[TestClass] +public class LocalizationTest +{ + private static readonly string[] LanguageFiles = LocalizationService.SupportedLanguages + .Select(language => language.Code) + .ToArray(); + + [TestMethod] + public void AllLanguageDictionariesShouldContainBaseKeys() + { + var baseKeys = LoadKeys("zh-CN"); + + foreach (var language in LanguageFiles.Where(language => language != "zh-CN")) + { + var keys = LoadKeys(language); + var missing = baseKeys.Except(keys).ToArray(); + + Assert.IsEmpty(missing, $"{language} 缺少语言键:{string.Join(", ", missing)}"); + } + } + + + [TestMethod] + public void LanguageDictionariesShouldNotContainDuplicateKeys() + { + foreach (var language in LanguageFiles) + { + var keys = LoadKeyList(language); + var duplicated = keys + .GroupBy(key => key) + .Where(group => group.Count() > 1) + .Select(group => group.Key) + .ToArray(); + + Assert.IsEmpty(duplicated, $"{language} 存在重复语言键:{string.Join(", ", duplicated)}"); + } + } + + [TestMethod] + public void LanguageKeysShouldUseDotNaming() + { + foreach (var language in LanguageFiles) + foreach (var key in LoadKeys(language)) + { + Assert.IsFalse(string.IsNullOrWhiteSpace(key), $"{language} 存在空语言键"); + Assert.IsTrue(key.Contains('.'), $"{language} 的语言键缺少分组分隔符:{key}"); + Assert.IsFalse(key.Contains(' '), $"{language} 的语言键不应包含空格:{key}"); + } + } + + + [TestMethod] + public void SupportedLanguagesShouldHaveValidCultureAndResourceDictionary() + { + foreach (var language in LocalizationService.SupportedLanguages) + { + CultureInfo.GetCultureInfo(language.CultureName); + + var filePath = Path.Combine(GetRepositoryRoot(), "PCL.Core", "App", "Localization", "Languages", + language.Code + ".xaml"); + Assert.IsTrue(File.Exists(filePath), $"{language.Code} 缺少语言资源文件"); + } + } + + + [TestMethod] + public void FontProfileShouldFollowCultureGlyphStandard() + { + Assert.AreEqual(LocalizationFontProfile.SimplifiedChinese, + LocalizationFontService.ResolveProfileFromCultureName("zh-CN")); + Assert.AreEqual(LocalizationFontProfile.SimplifiedChinese, + LocalizationFontService.ResolveProfileFromCultureName("zh-Hans")); + Assert.AreEqual(LocalizationFontProfile.TraditionalChinese, + LocalizationFontService.ResolveProfileFromCultureName("zh-TW")); + Assert.AreEqual(LocalizationFontProfile.TraditionalChinese, + LocalizationFontService.ResolveProfileFromCultureName("zh_HK")); + Assert.AreEqual(LocalizationFontProfile.TraditionalChinese, + LocalizationFontService.ResolveProfileFromCultureName("zh-Hant-HK")); + Assert.AreEqual(LocalizationFontProfile.Japanese, + LocalizationFontService.ResolveProfileFromCultureName("ja-JP")); + Assert.AreEqual(LocalizationFontProfile.Korean, + LocalizationFontService.ResolveProfileFromCultureName("ko-KR")); + Assert.AreEqual(LocalizationFontProfile.English, + LocalizationFontService.ResolveProfileFromCultureName("en-US")); + Assert.AreEqual(LocalizationFontProfile.English, + LocalizationFontService.ResolveProfileFromCultureName("en-GB")); + Assert.AreEqual(LocalizationFontProfile.Other, + LocalizationFontService.ResolveProfileFromCultureName("fr-FR")); + Assert.AreEqual(LocalizationFontProfile.Other, + LocalizationFontService.ResolveProfileFromCultureName("es-ES")); + Assert.AreEqual(LocalizationFontProfile.Other, + LocalizationFontService.ResolveProfileFromCultureName("pt-BR")); + } + + [TestMethod] + public void FontProfileAliasesShouldNotMakeLanguageResourceSupported() + { + Assert.IsFalse(LocalizationService.IsLanguageSupported("zh-HK")); + Assert.AreEqual(LocalizationService.DefaultLanguageCode, LocalizationService.ResolveLanguage("zh-HK").Code); + } + + private static HashSet LoadKeys(string language) + { + return LoadKeyList(language).ToHashSet(); + } + + private static string[] LoadKeyList(string language) + { + var filePath = Path.Combine(GetRepositoryRoot(), "PCL.Core", "App", "Localization", "Languages", + language + ".xaml"); + var document = XDocument.Load(filePath); + var keyAttributeName = XName.Get("Key", "http://schemas.microsoft.com/winfx/2006/xaml"); + return document.Descendants() + .Select(element => element.Attribute(keyAttributeName)?.Value) + .Where(key => !string.IsNullOrWhiteSpace(key)) + .Select(key => key!) + .ToArray(); + } + + private static string GetRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (Directory.Exists(Path.Combine(directory.FullName, "PCL.Core"))) return directory.FullName; + directory = directory.Parent; + } + + Assert.Fail("无法定位仓库根目录"); + return string.Empty; + } +} \ No newline at end of file diff --git a/PCL.Core/App/Config.cs b/PCL.Core/App/Config.cs index 1ff4c17ef..426c4d2e5 100644 --- a/PCL.Core/App/Config.cs +++ b/PCL.Core/App/Config.cs @@ -1,4 +1,4 @@ -using PCL.Core.App.Configuration; +using PCL.Core.App.Configuration; namespace PCL.Core.App; @@ -226,6 +226,27 @@ public static partial class Config /// [ConfigItem("DetailedInstanceClassification", false, ConfigSource.Local)] public partial bool DetailedInstanceClassification { get; set; } + /// + /// 本地化配置。 + /// + [ConfigGroup("Localization", ConfigSource.Local)] partial class LocalizationConfigGroup + { + /// + /// UI 语言。auto 表示跟随系统语言。 + /// + [ConfigItem("UiLanguage", "auto")] public partial string Language { get; set; } + + /// + /// UI 展示格式所使用的区域性。auto 表示跟随系统区域格式。 + /// + [ConfigItem("UiFormatCulture", "auto")] public partial string FormatCulture { get; set; } + + /// + /// 区域覆盖。auto 表示自动判断。 + /// + [ConfigItem("UiRegion", "auto")] public partial string Region { get; set; } + } + /// /// 界面主题配置。 /// @@ -410,6 +431,7 @@ partial class HideConfigGroup // 子页面 设置 [ConfigItem("UiHiddenSetupLaunch", false, ConfigSource.Local)] public partial bool SetupLaunch { get; set; } [ConfigItem("UiHiddenSetupUi", false, ConfigSource.Local)] public partial bool SetupUi { get; set; } + [ConfigItem("UiHiddenSetupLauncherLanguage", false, ConfigSource.Local)] public partial bool SetupLauncherLanguage { get; set; } [ConfigItem("UiHiddenSetupLauncherMisc", false, ConfigSource.Local)] public partial bool SetupLauncherMisc { get; set; } [ConfigItem("UiHiddenSetupGameManage", false, ConfigSource.Local)] public partial bool SetupGameManage { get; set; } [ConfigItem("UiHiddenSetupJava", false, ConfigSource.Local)] public partial bool SetupJava { get; set; } diff --git a/PCL.Core/App/Localization/Lang.cs b/PCL.Core/App/Localization/Lang.cs new file mode 100644 index 000000000..207f3704d --- /dev/null +++ b/PCL.Core/App/Localization/Lang.cs @@ -0,0 +1,129 @@ +using System; +using System.Globalization; +using System.Windows; +using PCL.Core.App.IoC; + +namespace PCL.Core.App.Localization; + +/// +///

+/// 本地化文本与展示格式访问辅助。 +///

+///

+/// 用于代码中读取本地化文本资源,以及按照当前展示区域性格式化文本参数、日期时间和数值。 +/// 它面向 C# 代码侧调用;XAML 中的静态文本优先使用 DynamicResource, +/// XAML 绑定值的格式化优先使用 。 +///

+///

+/// 文本资源来自当前应用的资源字典。正常运行时优先通过 +/// 查找资源;在应用生命周期早期或测试环境中, +/// 会回退到 进行一次安全查找。 +///

+///

+/// 该类中的格式化方法使用 , +/// 因此会跟随 当前设置的展示格式区域性。 +/// 它们只适合生成展示给用户看的文本,不应用于配置文件、日志、协议、缓存键、文件名等 +/// 需要稳定格式的场景;这些场景应显式使用 。 +///

+///
+public static class Lang +{ + /// + /// 获取指定资源键对应的本地化文本。 + /// + /// + /// 资源键。不能为空、空字符串或空白字符串。 + /// + /// + /// 找到资源时返回本地化文本; + /// 未找到资源时,调试构建返回 !key!,发布构建返回 本身。 + /// + /// + /// 为空、空字符串或空白字符串。 + /// + public static string Text(string key) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + + if (Application.Current?.TryFindResource(key) is string text) return text; + if (_LifecycleSafeFindResource(key) is string fallbackText) return fallbackText; + +#if DEBUG + return $"!{key}!"; +#else + return key; +#endif + } + + /// + /// 获取指定资源键对应的本地化格式文本,并使用当前展示区域性格式化参数。 + /// 资源文本应使用标准 .NET 复合格式字符串,例如 {0}{1:N2}。 + /// 该方法适合代码中生成用户可见句子,例如提示、说明、状态文本。 + /// + /// + /// 资源键。不能为空、空字符串或空白字符串。 + /// + /// + /// 用于填充资源文本中格式占位符的参数。 + /// + /// + /// 使用当前展示区域性格式化后的本地化文本。 + /// + public static string Text(string key, params object?[] args) + { + return string.Format(CultureInfo.CurrentCulture, Text(key), args); + } + + /// + ///

使用当前展示区域性格式化日期时间。

+ ///
+ /// + /// 要格式化的日期时间。 + /// + /// + /// 标准或自定义日期时间格式字符串,默认使用 G。 + /// + /// + /// 使用当前展示区域性格式化后的日期时间文本。 + /// + public static string Date(DateTime value, string format = "G") + { + return value.ToString(format, CultureInfo.CurrentCulture); + } + + /// + /// 使用当前展示区域性格式化数值。 + /// + /// + /// 实现 的数值或可格式化类型。 + /// + /// + /// 要格式化的值。 + /// + /// + /// 标准或自定义格式字符串。为 时使用类型默认格式。 + /// + /// + /// 使用当前展示区域性格式化后的文本。 + /// + public static string Number(T value, string? format = null) where T : IFormattable + { + return value.ToString(format, CultureInfo.CurrentCulture); + } + + private static object? _LifecycleSafeFindResource(string key) + { + try + { + return Lifecycle.CurrentApplication.TryFindResource(key); + } + catch (InvalidOperationException) + { + return null; + } + catch (NullReferenceException) + { + return null; + } + } +} \ No newline at end of file diff --git a/PCL.Core/App/Localization/Languages/en-GB.xaml b/PCL.Core/App/Localization/Languages/en-GB.xaml new file mode 100644 index 000000000..8ae767387 --- /dev/null +++ b/PCL.Core/App/Localization/Languages/en-GB.xaml @@ -0,0 +1,32 @@ + + + en-GB + English (United Kingdom) + + Follow system ({0}) + + Follow UI language + Language + Language + Display language + Regional format + Multi-language support is currently limited to certain new interfaces. Others will be updated in upcoming migrations. + Language settings updated + + Follow system regional format + + OK + Cancel + Close + Search + Refresh + Reset + + Launch + Download + Settings + Tools + diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml new file mode 100644 index 000000000..ede4474c0 --- /dev/null +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -0,0 +1,32 @@ + + + en-US + English (US) + + Follow system ({0}) + + Follow UI language + Language + Language + Display language + Regional format + Multi-language support is currently limited to certain new interfaces. Others will be updated in upcoming migrations. + Language settings updated + + Follow system regional format + + OK + Cancel + Close + Search + Refresh + Reset + + Launch + Download + Settings + Tools + diff --git a/PCL.Core/App/Localization/Languages/es-ES.xaml b/PCL.Core/App/Localization/Languages/es-ES.xaml new file mode 100644 index 000000000..e44deccc3 --- /dev/null +++ b/PCL.Core/App/Localization/Languages/es-ES.xaml @@ -0,0 +1,32 @@ + + + es-ES + Español (España) + + Seguir sistema ({0}) + + Seguir idioma de la interfaz + Idioma + Idioma + Idioma de interfaz + Formato regional + El soporte multiidioma está limitado actualmente a ciertas interfaces nuevas. El resto se actualizará en las próximas migraciones. + Configuración de idioma actualizada + + Seguir formato regional del sistema + + Aceptar + Cancelar + Cerrar + Buscar + Actualizar + Restablecer + + Iniciar + Descargar + Configuración + Herramientas + diff --git a/PCL.Core/App/Localization/Languages/fr-FR.xaml b/PCL.Core/App/Localization/Languages/fr-FR.xaml new file mode 100644 index 000000000..4ac22141b --- /dev/null +++ b/PCL.Core/App/Localization/Languages/fr-FR.xaml @@ -0,0 +1,32 @@ + + + fr-FR + Français (France) + + Suivre le système ({0}) + + Suivre la langue de l’interface + Langue + Langue + Langue d’affichage + Format régional + Le support multilingue est actuellement limité à certaines nouvelles interfaces. Les autres seront mises à jour lors des prochaines migrations. + Paramètres de langue mis à jour + + Suivre le format régional du système + + OK + Annuler + Fermer + Rechercher + Actualiser + Réinitialiser + + Lancer + Télécharger + Paramètres + Outils + diff --git a/PCL.Core/App/Localization/Languages/ja-JP.xaml b/PCL.Core/App/Localization/Languages/ja-JP.xaml new file mode 100644 index 000000000..615792c3c --- /dev/null +++ b/PCL.Core/App/Localization/Languages/ja-JP.xaml @@ -0,0 +1,32 @@ + + + ja-JP + 日本語(日本) + + システム設定に従う({0}) + + UI 言語に従う + 言語 + 言語 + 表示言語 + 地域形式 + 現在、一部の新規追加画面のみが多言語対応しており、その他の画面については今後の移行に伴い順次反映される予定です。 + 言語設定を更新しました + + システムの地域形式に従う + + OK + キャンセル + 閉じる + 検索 + 更新 + リセット + + 起動 + ダウンロード + 設定 + ツール + diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml new file mode 100644 index 000000000..866ab54e6 --- /dev/null +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -0,0 +1,32 @@ + + + zh-CN + 简体中文(中国大陆) + + 跟随系统({0}) + + 同步界面语言 + 语言 + 语言 + 界面语言 + 区域格式 + 目前仅部分新增界面支持多语言功能,其余界面将在后续迁移中逐步完成生效。 + 语言设置已更新 + + 跟随系统区域格式 + + 确定 + 取消 + 关闭 + 搜索 + 刷新 + 重置 + + 启动 + 下载 + 设置 + 工具 + diff --git a/PCL.Core/App/Localization/Languages/zh-TW.xaml b/PCL.Core/App/Localization/Languages/zh-TW.xaml new file mode 100644 index 000000000..9442107c2 --- /dev/null +++ b/PCL.Core/App/Localization/Languages/zh-TW.xaml @@ -0,0 +1,32 @@ + + + zh-TW + 繁體中文(台灣) + + 跟隨系統({0}) + + 同步介面語言 + 語言 + 語言 + 介面語言 + 地區格式 + 目前僅部分新增介面支援多語言功能,其餘介面將在後續遷移中逐步完成生效。 + 語言設定已更新 + + 跟隨系統地區格式 + + 確定 + 取消 + 關閉 + 搜尋 + 重新整理 + 重設 + + 啟動 + 下載 + 設定 + 工具 + diff --git a/PCL.Core/App/Localization/LocalizationFontProfile.cs b/PCL.Core/App/Localization/LocalizationFontProfile.cs new file mode 100644 index 000000000..194158a8a --- /dev/null +++ b/PCL.Core/App/Localization/LocalizationFontProfile.cs @@ -0,0 +1,37 @@ +namespace PCL.Core.App.Localization; + +/// +/// UI 字体策略。 +/// +public enum LocalizationFontProfile +{ + /// + /// 英文与拉丁文字优先。 + /// + English, + + /// + /// 简体中文标准字形。 + /// + SimplifiedChinese, + + /// + /// 繁体中文标准字形。 + /// + TraditionalChinese, + + /// + /// 日文标准字形。 + /// + Japanese, + + /// + /// 韩文标准字形。 + /// + Korean, + + /// + /// 其他语言。 + /// + Other +} \ No newline at end of file diff --git a/PCL.Core/App/Localization/LocalizationFontService.cs b/PCL.Core/App/Localization/LocalizationFontService.cs new file mode 100644 index 000000000..ce59f2715 --- /dev/null +++ b/PCL.Core/App/Localization/LocalizationFontService.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using PCL.Core.App.IoC; + +namespace PCL.Core.App.Localization; + +/// +/// 启动器 UI 字体服务。 +/// +public static class LocalizationFontService +{ + private const string PclEnglishFont = "./Resources/#PCL English"; + private static readonly Uri _ApplicationPackUri = new("pack://application:,,,/"); + + private static readonly IReadOnlyDictionary _ExactCultureProfiles = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["zh-CN"] = LocalizationFontProfile.SimplifiedChinese, + ["zh-SG"] = LocalizationFontProfile.SimplifiedChinese, + ["zh-TW"] = LocalizationFontProfile.TraditionalChinese, + ["zh-HK"] = LocalizationFontProfile.TraditionalChinese, + ["zh-MO"] = LocalizationFontProfile.TraditionalChinese + }; + + private static readonly IReadOnlyDictionary _ScriptProfiles = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Hans"] = LocalizationFontProfile.SimplifiedChinese, + ["Hant"] = LocalizationFontProfile.TraditionalChinese + }; + + private static readonly IReadOnlyDictionary _LanguageProfiles = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["zh"] = LocalizationFontProfile.SimplifiedChinese, + ["ja"] = LocalizationFontProfile.Japanese, + ["ko"] = LocalizationFontProfile.Korean, + ["en"] = LocalizationFontProfile.English + }; + + /// + /// 生成启动器 UI 使用的 FontFamily。 + /// + /// 用户自定义字体。为空时使用当前语言默认字体链。 + /// 目标语言。为空时使用当前 UI 语言。 + public static FontFamily BuildLaunchFontFamily(string? customFontName = null, LocalizationLanguage? language = null) + { + language ??= LocalizationService.CurrentLanguage; + + var familyNames = string.IsNullOrWhiteSpace(customFontName) + ? _GetDefaultFamilyNames(language.FontProfile) + : _GetCustomFamilyNames(customFontName, language.FontProfile); + + return new FontFamily(_ApplicationPackUri, string.Join(", ", familyNames)); + } + + /// + /// 生成用于代表某种语言的 FontFamily。 + /// + public static FontFamily BuildRepresentativeFontFamily(LocalizationLanguage language) + { + return BuildRepresentativeFontFamily(language.FontProfile); + } + + /// + /// 生成用于代表某种字体策略的 FontFamily。 + /// + public static FontFamily BuildRepresentativeFontFamily(LocalizationFontProfile profile) + { + return new FontFamily(_ApplicationPackUri, string.Join(", ", _GetDefaultFamilyNames(profile))); + } + + /// + /// 应用启动器 UI 字体。 + /// + public static void ApplyLaunchFont(string? customFontName = null, LocalizationLanguage? language = null) + { + var app = Application.Current ?? Lifecycle.CurrentApplication; + + var fontFamily = BuildLaunchFontFamily(customFontName, language); + if (!app.Dispatcher.CheckAccess()) + { + app.Dispatcher.Invoke(() => app.Resources["LaunchFontFamily"] = fontFamily); + return; + } + + app.Resources["LaunchFontFamily"] = fontFamily; + } + + /// + /// 根据区域性名称解析字体策略。 + /// + public static LocalizationFontProfile ResolveProfileFromCultureName(string? cultureName) + { + var code = _NormalizeCultureCode(cultureName); + if (string.IsNullOrEmpty(code)) return LocalizationFontProfile.Other; + + if (_ExactCultureProfiles.TryGetValue(code, out var exactProfile)) return exactProfile; + + var cultureParts = code.Split('-', StringSplitOptions.RemoveEmptyEntries); + foreach (var part in cultureParts.Skip(1)) + if (_ScriptProfiles.TryGetValue(part, out var scriptProfile)) + return scriptProfile; + + var languageCode = cultureParts.FirstOrDefault(); + return languageCode is not null && _LanguageProfiles.TryGetValue(languageCode, out var languageProfile) + ? languageProfile + : LocalizationFontProfile.Other; + } + + private static string _NormalizeCultureCode(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Replace('_', '-').Trim(); + } + + private static IReadOnlyList _GetDefaultFamilyNames(LocalizationFontProfile profile) + { + return profile switch + { + LocalizationFontProfile.English => + [PclEnglishFont, "Segoe UI", "Microsoft YaHei UI"], + + LocalizationFontProfile.SimplifiedChinese => + [PclEnglishFont, "Microsoft YaHei UI", "Segoe UI"], + + LocalizationFontProfile.TraditionalChinese => + [PclEnglishFont, "Microsoft JhengHei UI", "Microsoft YaHei UI", "Segoe UI"], + + LocalizationFontProfile.Japanese => + [PclEnglishFont, "Yu Gothic UI", "Microsoft YaHei UI", "Segoe UI"], + + LocalizationFontProfile.Korean => + [PclEnglishFont, "Malgun Gothic", "Microsoft YaHei UI", "Segoe UI"], + + _ => + ["Segoe UI", "Microsoft YaHei UI"] + }; + } + + private static IReadOnlyList _GetCustomFamilyNames(string customFontName, LocalizationFontProfile profile) + { + var familyNames = _GetDefaultFamilyNames(profile) + .Where(name => !string.Equals(name, PclEnglishFont, StringComparison.OrdinalIgnoreCase)) + .ToList(); + familyNames.Insert(0, _EscapeFontFamilyName(customFontName)); + return familyNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private static string _EscapeFontFamilyName(string name) + { + return name.Replace(",", ",,"); + } +} \ No newline at end of file diff --git a/PCL.Core/App/Localization/LocalizationFormatConverter.cs b/PCL.Core/App/Localization/LocalizationFormatConverter.cs new file mode 100644 index 000000000..f49f3e6f8 --- /dev/null +++ b/PCL.Core/App/Localization/LocalizationFormatConverter.cs @@ -0,0 +1,101 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace PCL.Core.App.Localization; + +/// +///

将 XAML 绑定值按照当前展示区域性格式化为字符串。

+///

+/// 该转换器用于界面绑定中的数字、日期、时间等 值展示。 +/// 格式化时使用 , +/// 因此会跟随 当前设置的展示格式区域性。 +///

+///

+/// 该转换器只处理“值的显示格式”,例如数字分隔符、日期顺序、时间格式等。 +/// 它不负责翻译文本、不负责切换语言资源,也不应参与配置、日志、协议、缓存键等 +/// 需要稳定格式的内容生成。 +///

+///

+/// 使用时需要通过 ConverterParameter 提供标准 .NET 格式字符串。 +/// 如果未提供格式字符串,则返回原始绑定值。 +///

+///
+/// +/// XAML 示例: +/// +/// +/// +/// +/// ]]> +/// +/// +public sealed class LocalizationFormatConverter : IValueConverter +{ + /// + /// 将绑定值按照当前展示区域性格式化。 + /// + /// + /// 需要格式化的绑定值。通常是数字、日期、时间等实现了 的类型。 + /// + /// + /// 绑定目标属性的类型。本转换器不依赖该参数。 + /// + /// + /// 标准 .NET 格式字符串,例如 N2Gd。 + /// 如果该参数不是有效字符串,或为空白字符串,则直接返回原始值。 + /// + /// + /// WPF 绑定传入的区域性。本转换器不直接使用该参数, + /// 而是统一使用 , + /// 以确保格式化结果跟随应用当前展示格式区域性。 + /// + /// + /// 如果 ,返回 ; + /// 如果未提供格式字符串,返回原始值; + /// 否则返回使用当前展示区域性格式化后的字符串。 + /// + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is null) return null; + if (parameter is not string format || string.IsNullOrWhiteSpace(format)) return value; + return value switch + { + IFormattable formattable => formattable.ToString(format, CultureInfo.CurrentCulture), + _ => string.Format(CultureInfo.CurrentCulture, "{0}", value) + }; + } + + /// + /// 不支持从格式化后的显示文本反向转换回原始值。 + /// + /// + /// 绑定目标传回的值。 + /// + /// + /// 绑定源目标类型。 + /// + /// + /// 转换参数。 + /// + /// + /// WPF 绑定传入的区域性。 + /// + /// + /// 该方法不会返回值。 + /// + /// + /// 始终抛出。该转换器仅用于单向显示格式化。 + /// + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } +} \ No newline at end of file diff --git a/PCL.Core/App/Localization/LocalizationLanguage.cs b/PCL.Core/App/Localization/LocalizationLanguage.cs new file mode 100644 index 000000000..730a61b54 --- /dev/null +++ b/PCL.Core/App/Localization/LocalizationLanguage.cs @@ -0,0 +1,15 @@ +namespace PCL.Core.App.Localization; + +/// +/// 表示一个受支持的 UI 语言。 +/// +/// 语言配置值。 +/// 语言的本地名称。 +/// 用于 的区域性名称。 +/// 语言对应的 UI 字体策略。 +/// +public sealed record LocalizationLanguage( + string Code, + string NativeName, + string CultureName, + LocalizationFontProfile FontProfile); \ No newline at end of file diff --git a/PCL.Core/App/Localization/LocalizationService.cs b/PCL.Core/App/Localization/LocalizationService.cs new file mode 100644 index 000000000..d7de7ad4a --- /dev/null +++ b/PCL.Core/App/Localization/LocalizationService.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Windows; +using PCL.Core.App.Configuration; +using PCL.Core.App.IoC; + +namespace PCL.Core.App.Localization; + +/// +/// UI 本地化服务。 +/// +[LifecycleScope("localization", "本地化", false)] +[LifecycleService(LifecycleState.Loaded, Priority = 114514)] +public sealed partial class LocalizationService +{ + /// + /// 跟随系统设置的配置值。 + /// + public const string Auto = "auto"; + + /// + /// 展示格式同步 UI 语言的配置值。 + /// + public const string FormatCultureFollowLanguage = "ui-language"; + + /// + /// 默认语言,也是语言资源的完整兜底。 + /// + public const string DefaultLanguageCode = "zh-CN"; + + private const string AssemblyResourcePrefix = "/PCL.Core;component/App/Localization/Languages/"; + + private static readonly LocalizationLanguage _DefaultLanguage = new( + DefaultLanguageCode, + "简体中文(中国大陆)", + "zh-CN", + LocalizationFontProfile.SimplifiedChinese); + + private static ResourceDictionary? _baseLanguageDictionary; + private static ResourceDictionary? _currentLanguageDictionary; + private static CultureInfo _systemFormatCulture = CultureInfo.CurrentCulture; + private static CultureInfo _systemUiCulture = CultureInfo.CurrentUICulture; + + /// + /// 当前 UI 语言。 + /// + public static LocalizationLanguage CurrentLanguage { get; private set; } = _DefaultLanguage; + + /// + /// 当前 UI 展示格式所使用的区域性。 + /// + public static CultureInfo CurrentFormatCulture { get; private set; } = CultureInfo.CurrentCulture; + + /// + /// 受支持的 UI 语言。 + /// + public static IReadOnlyList SupportedLanguages { get; } = + [ + _DefaultLanguage, + new("zh-TW", "繁體中文(台灣)", "zh-TW", LocalizationFontProfile.TraditionalChinese), + new("en-US", "English (US)", "en-US", LocalizationFontProfile.English), + new("en-GB", "English (United Kingdom)", "en-GB", LocalizationFontProfile.English), + new("ja-JP", "日本語(日本)", "ja-JP", LocalizationFontProfile.Japanese), + new("fr-FR", "Français (France)", "fr-FR", LocalizationFontProfile.Other), + new("es-ES", "Español (España)", "es-ES", LocalizationFontProfile.Other) + ]; + + [RegisterConfigEvent] + public static ConfigEventRegistry OnLanguageConfigChanged => new( + [ + Config.Preference.Localization.LanguageConfig, + Config.Preference.Localization.FormatCultureConfig + ], + trigger: ConfigEvent.Update, + handler: _ => ApplyFromConfig() + ); + + /// + /// 语言或展示格式更改后触发。 + /// + public static event Action? LanguageChanged; + + [LifecycleStart] + private static void _Start() + { + _systemFormatCulture = CultureInfo.CurrentCulture; + _systemUiCulture = CultureInfo.CurrentUICulture; + ApplyFromConfig(); + } + + /// + /// 按当前配置应用 UI 语言与展示格式。 + /// + public static void ApplyFromConfig(bool save = false) + { + if (!ConfigService.IsInitialized) + { + Apply(Auto, Auto, false); + return; + } + + Apply( + Config.Preference.Localization.Language, + Config.Preference.Localization.FormatCulture, + save); + } + + /// + /// 应用 UI 语言与展示格式。 + /// + /// UI 语言代码,auto 表示跟随系统语言。 + /// 展示格式区域性,auto 表示跟随系统区域格式。 + /// 是否写回配置。 + public static void Apply(string languageCode, string formatCultureCode = Auto, bool save = true) + { + var normalizedLanguageCode = _NormalizeConfigValue(languageCode); + var language = ResolveLanguage(normalizedLanguageCode); + var uiCulture = CultureInfo.GetCultureInfo(language.CultureName); + var formatCulture = _ResolveFormatCulture(formatCultureCode, uiCulture, out var normalizedFormatCultureCode); + + var isLanguageChanged = !string.Equals(CurrentLanguage.Code, language.Code, StringComparison.OrdinalIgnoreCase); + var isFormatCultureChanged = !string.Equals(CurrentFormatCulture.Name, formatCulture.Name, + StringComparison.OrdinalIgnoreCase); + if (_baseLanguageDictionary is not null && !isLanguageChanged && !isFormatCultureChanged) + { + _SaveConfigIfNeeded(save, normalizedLanguageCode, language, normalizedFormatCultureCode); + return; + } + + _ApplyCultures(uiCulture, formatCulture); + _ApplyLanguageResources(language.Code, uiCulture, formatCulture); + + CurrentLanguage = language; + LocalizationFontService.ApplyLaunchFont( + ConfigService.IsInitialized ? Config.Preference.Font : null, + language); + CurrentFormatCulture = formatCulture; + + _SaveConfigIfNeeded(save, normalizedLanguageCode, language, normalizedFormatCultureCode); + + _LogInfo($"当前 UI 语言: {language.Code}, 展示格式: {formatCulture.Name}"); + LanguageChanged?.Invoke(); + } + + /// + /// 判断语言代码是否受支持。 + /// + public static bool IsLanguageSupported(string? languageCode) + { + if (string.IsNullOrWhiteSpace(languageCode)) return true; + if (string.Equals(languageCode, Auto, StringComparison.OrdinalIgnoreCase)) return true; + var normalizedCode = _NormalizeCultureCode(languageCode); + return SupportedLanguages.Any(language => + string.Equals(language.Code, normalizedCode, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// 按配置值解析语言。 + /// + public static LocalizationLanguage ResolveLanguage(string? languageCode) + { + if (string.IsNullOrWhiteSpace(languageCode) || + string.Equals(languageCode, Auto, StringComparison.OrdinalIgnoreCase)) return _ResolveSystemLanguage(); + + var normalizedCode = _NormalizeCultureCode(languageCode); + return SupportedLanguages.FirstOrDefault(language => + string.Equals(language.Code, normalizedCode, StringComparison.OrdinalIgnoreCase)) + ?? _DefaultLanguage; + } + + private static void _SaveConfigIfNeeded(bool save, string normalizedLanguageCode, LocalizationLanguage language, + string normalizedFormatCultureCode) + { + if (!save || !ConfigService.IsInitialized) return; + + var configLanguageCode = string.Equals(normalizedLanguageCode, Auto, StringComparison.OrdinalIgnoreCase) + ? Auto + : language.Code; + if (Config.Preference.Localization.Language != configLanguageCode) + Config.Preference.Localization.Language = configLanguageCode; + if (Config.Preference.Localization.FormatCulture != normalizedFormatCultureCode) + Config.Preference.Localization.FormatCulture = normalizedFormatCultureCode; + } + + private static LocalizationLanguage _ResolveSystemLanguage() + { + var systemLanguage = _NormalizeCultureCode(_systemUiCulture.Name); + var exact = SupportedLanguages.FirstOrDefault(language => + string.Equals(language.Code, systemLanguage, StringComparison.OrdinalIgnoreCase)); + if (exact is not null) return exact; + + var neutral = _systemUiCulture.TwoLetterISOLanguageName; + return SupportedLanguages.FirstOrDefault(language => + language.Code.StartsWith(neutral + "-", StringComparison.OrdinalIgnoreCase)) + ?? _DefaultLanguage; + } + + private static CultureInfo _ResolveFormatCulture(string? formatCultureCode, CultureInfo uiCulture, + out string normalizedCode) + { + if (string.IsNullOrWhiteSpace(formatCultureCode) || + string.Equals(formatCultureCode, Auto, StringComparison.OrdinalIgnoreCase)) + { + normalizedCode = Auto; + return _systemFormatCulture; + } + + if (string.Equals(formatCultureCode, FormatCultureFollowLanguage, StringComparison.OrdinalIgnoreCase)) + { + normalizedCode = FormatCultureFollowLanguage; + return uiCulture; + } + + try + { + var culture = CultureInfo.GetCultureInfo(formatCultureCode); + normalizedCode = culture.Name; + return culture; + } + catch (CultureNotFoundException) + { + _LogWarn($"无法识别展示格式区域性: {formatCultureCode},已回退系统区域格式"); + normalizedCode = Auto; + return _systemFormatCulture; + } + } + + private static void _ApplyCultures(CultureInfo uiCulture, CultureInfo formatCulture) + { + CultureInfo.CurrentUICulture = uiCulture; + CultureInfo.DefaultThreadCurrentUICulture = uiCulture; + Thread.CurrentThread.CurrentUICulture = uiCulture; + + CultureInfo.CurrentCulture = formatCulture; + CultureInfo.DefaultThreadCurrentCulture = formatCulture; + Thread.CurrentThread.CurrentCulture = formatCulture; + } + + private static void _ApplyLanguageResources(string languageCode, CultureInfo uiCulture, CultureInfo formatCulture) + { + var app = Application.Current ?? Lifecycle.CurrentApplication; + + if (!app.Dispatcher.CheckAccess()) + { + app.Dispatcher.Invoke(() => + { + _ApplyCultures(uiCulture, formatCulture); + _ApplyLanguageResourcesCore(app, languageCode); + }); + return; + } + + _ApplyLanguageResourcesCore(app, languageCode); + } + + private static void _ApplyLanguageResourcesCore(Application app, string languageCode) + { + var dictionaries = app.Resources.MergedDictionaries; + + if (_baseLanguageDictionary is not null) dictionaries.Remove(_baseLanguageDictionary); + if (_currentLanguageDictionary is not null) dictionaries.Remove(_currentLanguageDictionary); + + _baseLanguageDictionary = _LoadLanguageDictionary(DefaultLanguageCode); + dictionaries.Add(_baseLanguageDictionary); + + if (string.Equals(languageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + _currentLanguageDictionary = null; + } + else + { + _currentLanguageDictionary = _LoadLanguageDictionary(languageCode); + dictionaries.Add(_currentLanguageDictionary); + } + } + + private static ResourceDictionary _LoadLanguageDictionary(string languageCode) + { + return new ResourceDictionary + { + Source = new Uri($"{AssemblyResourcePrefix}{languageCode}.xaml", UriKind.Relative) + }; + } + + private static string _NormalizeConfigValue(string? value) + { + return string.IsNullOrWhiteSpace(value) ? Auto : _NormalizeCultureCode(value); + } + + private static string _NormalizeCultureCode(string? value) + { + return string.IsNullOrWhiteSpace(value) ? Auto : value.Replace('_', '-').Trim(); + } + + private static void _LogInfo(string message) + { + try + { + Context.Info(message); + } + catch (InvalidOperationException) + { + // 静态 API 可在生命周期服务创建前被测试调用,此时无需写入生命周期日志。 + } + } + + private static void _LogWarn(string message) + { + try + { + Context.Warn(message); + } + catch (InvalidOperationException) + { + // 静态 API 可在生命周期服务创建前被测试调用,此时无需写入生命周期日志。 + } + } +} \ No newline at end of file diff --git a/Plain Craft Launcher 2/Application.xaml b/Plain Craft Launcher 2/Application.xaml index 138a25548..6d4039bb4 100644 --- a/Plain Craft Launcher 2/Application.xaml +++ b/Plain Craft Launcher 2/Application.xaml @@ -25,7 +25,7 @@ - Resources/#PCL English, Microsoft YaHei UI + Resources/#PCL English, Microsoft YaHei UI, Segoe UI #343d4a diff --git a/Plain Craft Launcher 2/Controls/FontSelector.xaml.cs b/Plain Craft Launcher 2/Controls/FontSelector.xaml.cs index 1084b918b..59203e8b9 100644 --- a/Plain Craft Launcher 2/Controls/FontSelector.xaml.cs +++ b/Plain Craft Launcher 2/Controls/FontSelector.xaml.cs @@ -1,7 +1,10 @@ using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; using System.Windows.Media; +using PCL.Core.App.Localization; using PCL.Core.Logging; using PCL.Core.Utils.Exts; @@ -11,44 +14,41 @@ public partial class FontSelector { public delegate void SelectionChangedEventHandler(object sender, SelectionChangedEventArgs e); - public new static readonly DependencyProperty TooltipProperty = DependencyProperty.Register("Tooltip", + public static readonly DependencyProperty TooltipProperty = DependencyProperty.Register(nameof(Tooltip), typeof(string), typeof(FontSelector), new PropertyMetadata(null, OnTooltipChanged)); private bool _isInitializing; - private string _pendingFontTag; + private bool _isListeningLanguageChanged; + private string? _pendingFontTag; public FontSelector() { InitializeComponent(); Loaded += FontSelector_Loaded; + Unloaded += FontSelector_Unloaded; ComboFont.SelectionChanged += ComboFont_SelectionChanged; } - - public new string Tooltip + public string Tooltip { get => (string)GetValue(TooltipProperty); set => SetValue(TooltipProperty, value); } - public ObservableCollection CustomFontCollection { get; } = new(); + public ObservableCollection CustomFontCollection { get; } = []; public string SelectedFontTag { get { - if (ComboFont.SelectedItem is null) - return ""; - var selectedFont = ComboFont.SelectedItem as CustomFontProperties; - if (selectedFont is null) - return ""; - return selectedFont.Tag; + if (ComboFont.SelectedItem is null) return ""; + return ComboFont.SelectedItem is not CustomFontProperties selectedFont ? "" : selectedFont.Tag; } set { // 如果字体还在加载中,延迟设置 if (CustomFontCollection.Count == 0 || - (CustomFontCollection.Count == 1 && CustomFontCollection[0].Name == "加载中...")) + CustomFontCollection is [{ Name: "加载中..." }]) { _pendingFontTag = value; return; @@ -56,7 +56,7 @@ public string SelectedFontTag _isInitializing = true; - var targetSelection = CustomFontCollection.FirstOrDefault(i => (i.Tag ?? "") == (value ?? "")); + var targetSelection = CustomFontCollection.FirstOrDefault(i => i.Tag == value); if (targetSelection is null) ComboFont.SelectedIndex = 0; else @@ -80,15 +80,55 @@ public int SelectedIndex private static void OnTooltipChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - var control = d as FontSelector; - if (control is not null) control.ComboFont.ToolTip = e.NewValue; + if (d is FontSelector control) control.ComboFont.ToolTip = e.NewValue; } public event SelectionChangedEventHandler? SelectionChanged; private void FontSelector_Loaded(object sender, RoutedEventArgs e) { - if (CustomFontCollection.Count == 0) LoadFonts(); + StartListeningLanguageChanged(); + + if (CustomFontCollection.Count == 0) + LoadFonts(); + else + RefreshDefaultFont(); + } + + private void FontSelector_Unloaded(object sender, RoutedEventArgs e) + { + StopListeningLanguageChanged(); + } + + private void StartListeningLanguageChanged() + { + if (_isListeningLanguageChanged) return; + LocalizationService.LanguageChanged += LocalizationService_LanguageChanged; + _isListeningLanguageChanged = true; + } + + private void StopListeningLanguageChanged() + { + if (!_isListeningLanguageChanged) return; + LocalizationService.LanguageChanged -= LocalizationService_LanguageChanged; + _isListeningLanguageChanged = false; + } + + private void LocalizationService_LanguageChanged() + { + RefreshDefaultFont(); + } + + private void RefreshDefaultFont() + { + if (!Dispatcher.CheckAccess()) + { + Dispatcher.Invoke(RefreshDefaultFont); + return; + } + + var defaultFont = CustomFontCollection.FirstOrDefault(i => string.IsNullOrEmpty(i.Tag)); + defaultFont?.Font = LocalizationFontService.BuildLaunchFontFamily(); } private void LoadFonts() @@ -132,8 +172,7 @@ await Task.Run(() => CustomFontCollection.Add(new CustomFontProperties { Name = "默认", - Font = new FontFamily(new Uri("pack://application:,,,/"), - "./Resources/#PCL English, Segoe UI, Microsoft YaHei UI"), + Font = LocalizationFontService.BuildLaunchFontFamily(), Tag = "" }); @@ -159,10 +198,33 @@ private void ComboFont_SelectionChanged(object sender, SelectionChangedEventArgs if (!_isInitializing) SelectionChanged?.Invoke(sender, e); } - public class CustomFontProperties + public class CustomFontProperties : INotifyPropertyChanged { - public string Name { get; set; } - public FontFamily Font { get; set; } - public string Tag { get; set; } + public string Name + { + get; + init => SetField(ref field, value); + } = string.Empty; + + public FontFamily Font + { + get; + set => SetField(ref field, value); + } = LocalizationFontService.BuildLaunchFontFamily(); + + public string Tag + { + get; + set => SetField(ref field, value); + } = string.Empty; + + public event PropertyChangedEventHandler? PropertyChanged; + + private void SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) return; + field = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } } } \ No newline at end of file diff --git a/Plain Craft Launcher 2/FormMain.xaml b/Plain Craft Launcher 2/FormMain.xaml index 924209414..80ac23720 100644 --- a/Plain Craft Launcher 2/FormMain.xaml +++ b/Plain Craft Launcher 2/FormMain.xaml @@ -156,19 +156,19 @@ - - - - diff --git a/Plain Craft Launcher 2/FormMain.xaml.cs b/Plain Craft Launcher 2/FormMain.xaml.cs index 5304abc47..5ec04909d 100644 --- a/Plain Craft Launcher 2/FormMain.xaml.cs +++ b/Plain Craft Launcher 2/FormMain.xaml.cs @@ -1468,6 +1468,7 @@ public enum PageSubType SetupUpdate = 8, SetupJava = 9, SetupLauncherMisc = 10, + SetupLauncherLanguage = 11, ToolsGameLink = 1, ToolsLauncherHelp = 2, diff --git a/Plain Craft Launcher 2/Modules/Base/ModBase.cs b/Plain Craft Launcher 2/Modules/Base/ModBase.cs index 0f8bcd8a4..6a8ea050e 100644 --- a/Plain Craft Launcher 2/Modules/Base/ModBase.cs +++ b/Plain Craft Launcher 2/Modules/Base/ModBase.cs @@ -23,6 +23,7 @@ using Microsoft.Win32; using Newtonsoft.Json; using PCL.Core.App; +using PCL.Core.App.Localization; using PCL.Core.IO; using PCL.Core.Logging; using PCL.Core.Utils; @@ -33,7 +34,6 @@ using Brush = System.Windows.Media.Brush; using Color = System.Windows.Media.Color; using ColorConverter = System.Windows.Media.ColorConverter; -using FontFamily = System.Windows.Media.FontFamily; using Size = System.Windows.Size; namespace PCL; @@ -3304,13 +3304,7 @@ public static void SetLaunchFont(string FontName = null) { try { - FontFamily TargetFont; - if (string.IsNullOrEmpty(FontName)) - TargetFont = new FontFamily(new Uri("pack://application:,,,/"), - "./Resources/#PCL English, Segoe UI, Microsoft YaHei UI"); - else - TargetFont = new FontFamily($"{FontName}, Segoe UI, Microsoft YaHei UI"); - System.Windows.Application.Current.Resources["LaunchFontFamily"] = TargetFont; + LocalizationFontService.ApplyLaunchFont(FontName, LocalizationService.CurrentLanguage); } catch (Exception ex) { diff --git a/Plain Craft Launcher 2/Modules/Base/ModSetup.cs b/Plain Craft Launcher 2/Modules/Base/ModSetup.cs index 60fbd9552..491a54563 100644 --- a/Plain Craft Launcher 2/Modules/Base/ModSetup.cs +++ b/Plain Craft Launcher 2/Modules/Base/ModSetup.cs @@ -612,6 +612,11 @@ public void UiHiddenSetupUi(bool Value) PageSetupUI.HiddenRefresh(); } + public void UiHiddenSetupLauncherLanguage(bool Value) + { + PageSetupUI.HiddenRefresh(); + } + public void UiHiddenSetupLauncherMisc(bool Value) { PageSetupUI.HiddenRefresh(); diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs index 8caa2e69a..62ed6d8e6 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModLaunch.cs @@ -12,6 +12,7 @@ using Microsoft.VisualBasic.CompilerServices; using Newtonsoft.Json.Linq; using PCL.Core.App; +using PCL.Core.App.Localization; using PCL.Core.Minecraft; using PCL.Core.Minecraft.Launch.Utils; using PCL.Core.Utils; @@ -3254,61 +3255,29 @@ private static void McLaunchPrerun() // 1.6 ~ 10 :zh_CN 时正常,zh_cn 时自动切换为英文 // 1.11 ~ 12:zh_cn 时正常,zh_CN 时虽然显示了中文但语言设置会错误地显示选择英文 // 1.13+ :zh_cn 时正常,zh_CN 时自动切换为英文 - var CurrentLang = ModBase.ReadIni(SetupFileAddress, "lang", "none"); - string RequiredLang; // 需要的语言 + var currentLang = ModBase.ReadIni(SetupFileAddress, "lang", "none"); + var isLanguageUnconfigured = string.Equals(currentLang, "none", StringComparison.OrdinalIgnoreCase); var hasExistingSaves = Directory.Exists(ModMinecraft.McInstanceSelected.PathIndie + "saves"); - var shouldUseDefault = CurrentLang == "none" || !hasExistingSaves; - - // 获取 Minecraft 版本信息 - DateTime? mcReleaseTime = ModMinecraft.McInstanceSelected.ReleaseTime; - var isUnder1dot1 = - (bool)((new DateTime(2000, 1, 1) is var arg3 && mcReleaseTime.HasValue - ? mcReleaseTime.Value > arg3 - : (bool?)null) is var arg5 && arg5.HasValue && !arg5.Value ? false : - !((new DateTime(2011, 11, 18) is var arg4 && mcReleaseTime.HasValue - ? mcReleaseTime.Value <= arg4 - : (bool?)null) is { } arg6) ? null : - arg6 ? arg5 : false); // 1.11 发布日期 - - // 对于 1.0 及以下版本,没有语言选项,返回 "none" - if (isUnder1dot1) - { - RequiredLang = "none"; - } - else - { - // 根据配置确定默认语言 - var defaultLang = "zh_cn"; - RequiredLang = shouldUseDefault ? defaultLang : CurrentLang.ToLower(); - - // 应用版本特定的语言格式规则 - if (((new DateTime(2012, 1, 12) is var arg7 && mcReleaseTime.HasValue - ? mcReleaseTime.Value >= arg7 - : (bool?)null) is var arg9 && arg9.HasValue && !arg9.Value ? false : - !((new DateTime(2016, 6, 8) is var arg8 && mcReleaseTime.HasValue - ? mcReleaseTime.Value <= arg8 - : (bool?)null) is { } arg10) ? null : - arg10 ? arg9 : false) == true) - // 1.1~1.10:最后两位字母必须大写(zh_CN) - RequiredLang = "zh_CN"; - } + var shouldUseDefault = isLanguageUnconfigured || !hasExistingSaves; + var requiredLang = _ResolveMinecraftLanguage(currentLang, shouldUseDefault, + ModMinecraft.McInstanceSelected.ReleaseTime); - if ((CurrentLang ?? "") == (RequiredLang ?? "")) + if (currentLang == requiredLang) { - McLaunchLog($"需要的语言为 {RequiredLang},当前语言为 {CurrentLang},无需修改"); + McLaunchLog($"需要的语言为 {requiredLang},当前语言为 {currentLang},无需修改"); } else { ModBase.WriteIni(SetupFileAddress, "lang", "-"); // 触发缓存更改,避免删除后重新下载残留缓存 - ModBase.WriteIni(SetupFileAddress, "lang", RequiredLang); - McLaunchLog($"已将语言从 {CurrentLang} 修改为 {RequiredLang}"); + ModBase.WriteIni(SetupFileAddress, "lang", requiredLang); + McLaunchLog($"已将语言从 {currentLang} 修改为 {requiredLang}"); } - // 如果是初次设置,一并修改 forceUnicodeFont,确保中文能正常显示 - if (CurrentLang == "none" || !Directory.Exists(ModMinecraft.McInstanceSelected.PathIndie + "saves")) + // 如果是初次设置,一并按启动器语言需要修改 forceUnicodeFont,确保 CJK 字符正常显示 + if ((isLanguageUnconfigured || !hasExistingSaves) && _ShouldEnableForceUnicodeFont()) { ModBase.WriteIni(SetupFileAddress, "forceUnicodeFont", "true"); - McLaunchLog("已开启 forceUnicodeFont,确保中文字体正常显示"); + McLaunchLog("已开启 forceUnicodeFont,确保当前启动器语言字体正常显示"); } } catch (Exception ex) @@ -3339,6 +3308,55 @@ private static void McLaunchPrerun() } } + private static string _ResolveMinecraftLanguage(string? currentLanguage, bool shouldUseLauncherLanguage, + DateTime? mcReleaseTime) + { + if (_IsMinecraftVersionUnder1Dot1(mcReleaseTime)) return "none"; + + var useLegacyRegionCase = _ShouldUseLegacyMinecraftLanguageCode(mcReleaseTime); + var languageCode = shouldUseLauncherLanguage + ? LocalizationService.CurrentLanguage.Code + : currentLanguage; + return _NormalizeMinecraftLanguageCode(languageCode, useLegacyRegionCase); + } + + private static string _NormalizeMinecraftLanguageCode(string? languageCode, bool useLegacyRegionCase) + { + var normalizedCode = string.IsNullOrWhiteSpace(languageCode) + ? "none" + : languageCode.Replace('-', '_').Trim(); + if (string.Equals(normalizedCode, "none", StringComparison.OrdinalIgnoreCase)) return "none"; + + var segments = normalizedCode.Split('_', 2, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length < 2) return normalizedCode.ToLowerInvariant(); + + var language = segments[0].ToLowerInvariant(); + var region = useLegacyRegionCase ? segments[1].ToUpperInvariant() : segments[1].ToLowerInvariant(); + return $"{language}_{region}"; + } + + private static bool _IsMinecraftVersionUnder1Dot1(DateTime? releaseTime) + { + return releaseTime.HasValue && + releaseTime.Value > new DateTime(2000, 1, 1) && + releaseTime.Value <= new DateTime(2011, 11, 18); + } + + private static bool _ShouldUseLegacyMinecraftLanguageCode(DateTime? releaseTime) + { + return releaseTime.HasValue && + releaseTime.Value >= new DateTime(2012, 1, 12) && + releaseTime.Value <= new DateTime(2016, 6, 8); + } + + private static bool _ShouldEnableForceUnicodeFont() + { + return LocalizationService.CurrentLanguage.FontProfile is LocalizationFontProfile.SimplifiedChinese + or LocalizationFontProfile.TraditionalChinese + or LocalizationFontProfile.Japanese + or LocalizationFontProfile.Korean; + } + private static void McLaunchCustom(ModLoader.LoaderTask Loader) { // 获取自定义命令 diff --git a/Plain Craft Launcher 2/Modules/ModMain.cs b/Plain Craft Launcher 2/Modules/ModMain.cs index 9ee8222e6..1eb94b56d 100644 --- a/Plain Craft Launcher 2/Modules/ModMain.cs +++ b/Plain Craft Launcher 2/Modules/ModMain.cs @@ -64,6 +64,7 @@ public static class ModMain public static PageSetupLog? FrmSetupLog; public static PageSetupFeedback? FrmSetupFeedback; public static PageSetupGameLink? FrmSetupGameLink; + public static PageSetupLauncherLanguage? FrmSetupLauncherLanguage; public static PageSetupLauncherMisc? FrmSetupLauncherMisc; public static PageLoginAuth? FrmLoginAuth; public static PageLoginMs? FrmLoginMs; diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml index 82a8b2ac6..b757e339f 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml @@ -1,4 +1,4 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLauncherLanguage.xaml.cs b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLauncherLanguage.xaml.cs new file mode 100644 index 000000000..5ba7da69a --- /dev/null +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLauncherLanguage.xaml.cs @@ -0,0 +1,221 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using PCL.Core.App; +using PCL.Core.App.Localization; + +namespace PCL; + +public partial class PageSetupLauncherLanguage +{ + private bool _isLoaded; + + public PageSetupLauncherLanguage() + { + InitializeComponent(); + Loaded += PageSetupLauncherLanguage_Loaded; + } + + private void PageSetupLauncherLanguage_Loaded(object sender, RoutedEventArgs e) + { + // 重复加载部分 + PanBack.ScrollToHome(); + + // 非重复加载部分 + if (_isLoaded) + return; + _isLoaded = true; + + ModAnimation.AniControlEnabled += 1; + Reload(); + ModAnimation.AniControlEnabled -= 1; + } + + public void Reload() + { + ModAnimation.AniControlEnabled += 1; + try + { + ReloadLanguageCombo(); + ReloadFormatCultureCombo(); + } + finally + { + ModAnimation.AniControlEnabled -= 1; + } + } + + public void Reset() + { + try + { + Config.Preference.Localization.LanguageConfig.SetDefaultValue(); + Config.Preference.Localization.FormatCultureConfig.SetDefaultValue(); + LocalizationService.ApplyFromConfig(); + ModBase.Log("[Setup] 已初始化启动器-语言页设置"); + ModMain.Hint("已初始化语言页设置!", ModMain.HintType.Finish, false); + Reload(); + } + catch (Exception ex) + { + ModBase.Log(ex, "初始化启动器-语言页设置失败", ModBase.LogLevel.Msgbox); + } + } + + private void ReloadLanguageCombo() + { + ComboUiLanguage.Items.Clear(); + var autoLanguage = LocalizationService.ResolveLanguage(LocalizationService.Auto); + ComboUiLanguage.Items.Add(CreateLanguageComboItem( + Lang.Text("Localization.Language.Auto", GetLanguageDisplay(autoLanguage)), + LocalizationService.Auto, + autoLanguage)); + + foreach (var language in LocalizationService.SupportedLanguages) + ComboUiLanguage.Items.Add(CreateLanguageComboItem( + GetLanguageDisplay(language), + language.Code, + language)); + + var configValue = NormalizeConfigValue(Config.Preference.Localization.Language); + var selectedLanguageTag = LocalizationService.Auto; + if (LocalizationService.IsLanguageSupported(configValue)) + selectedLanguageTag = + string.Equals(configValue, LocalizationService.Auto, StringComparison.OrdinalIgnoreCase) + ? LocalizationService.Auto + : LocalizationService.ResolveLanguage(configValue).Code; + SelectComboItem(ComboUiLanguage, selectedLanguageTag); + } + + private void ReloadFormatCultureCombo() + { + ComboUiFormatCulture.Items.Clear(); + ComboUiFormatCulture.Items.Add(new MyComboBoxItem + { + Content = Lang.Text("Localization.FormatCulture.Auto"), + Tag = LocalizationService.Auto + }); + ComboUiFormatCulture.Items.Add(new MyComboBoxItem + { + Content = Lang.Text("Localization.FormatCulture.FollowLanguage"), + Tag = LocalizationService.FormatCultureFollowLanguage + }); + + foreach (var culture in GetBuiltInFormatCultures()) + ComboUiFormatCulture.Items.Add(new MyComboBoxItem + { + Content = GetCultureDisplay(culture), + Tag = culture.Name + }); + + var configValue = NormalizeConfigValue(Config.Preference.Localization.FormatCulture); + if (!IsFormatCultureItemExisting(configValue) && TryGetCulture(configValue, out var customCulture)) + ComboUiFormatCulture.Items.Add(new MyComboBoxItem + { + Content = GetCultureDisplay(customCulture), + Tag = customCulture.Name + }); + + SelectComboItem(ComboUiFormatCulture, + IsFormatCultureItemExisting(configValue) ? configValue : LocalizationService.Auto); + } + + private void ComboUiLanguage_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ModAnimation.AniControlEnabled != 0) + return; + if (ComboUiLanguage.SelectedItem is not MyComboBoxItem item) + return; + + var value = item.Tag?.ToString() ?? LocalizationService.Auto; + if (Config.Preference.Localization.Language == value) + return; + + Config.Preference.Localization.Language = value; + ModMain.Hint(Lang.Text("Setup.LauncherLanguage.Changed"), ModMain.HintType.Finish, false); + Reload(); + } + + private void ComboUiFormatCulture_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (ModAnimation.AniControlEnabled != 0) + return; + if (ComboUiFormatCulture.SelectedItem is not MyComboBoxItem item) + return; + + var value = item.Tag?.ToString() ?? LocalizationService.Auto; + if (Config.Preference.Localization.FormatCulture == value) + return; + + Config.Preference.Localization.FormatCulture = value; + ModMain.Hint(Lang.Text("Setup.LauncherLanguage.Changed"), ModMain.HintType.Finish, false); + Reload(); + } + + private static IEnumerable GetBuiltInFormatCultures() + { + var used = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var language in LocalizationService.SupportedLanguages) + { + var culture = CultureInfo.GetCultureInfo(language.CultureName); + if (used.Add(culture.Name)) yield return culture; + } + } + + private static MyComboBoxItem CreateLanguageComboItem(string content, string tag, LocalizationLanguage language) + { + return new MyComboBoxItem + { + Content = content, + Tag = tag, + FontFamily = LocalizationFontService.BuildRepresentativeFontFamily(language) + }; + } + + private static string GetLanguageDisplay(LocalizationLanguage language) + { + return $"{language.NativeName}"; + } + + private static string GetCultureDisplay(CultureInfo culture) + { + return $"{culture.NativeName}"; + } + + private static string NormalizeConfigValue(string? value) + { + return string.IsNullOrWhiteSpace(value) ? LocalizationService.Auto : value; + } + + private static bool TryGetCulture(string value, out CultureInfo culture) + { + try + { + culture = CultureInfo.GetCultureInfo(value); + return true; + } + catch (CultureNotFoundException) + { + culture = CultureInfo.InvariantCulture; + return false; + } + } + + private static void SelectComboItem(MyComboBox comboBox, string tag) + { + foreach (var item in comboBox.Items.OfType()) + { + if (!string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase)) continue; + comboBox.SelectedItem = item; + return; + } + + comboBox.SelectedIndex = comboBox.Items.Count > 0 ? 0 : -1; + } + + private bool IsFormatCultureItemExisting(string tag) + { + return ComboUiFormatCulture.Items.OfType() + .Any(item => string.Equals(item.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase)); + } +} \ No newline at end of file diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLeft.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLeft.xaml index 39e94eaa9..15cd1c65e 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLeft.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupLeft.xaml @@ -1,4 +1,4 @@ - + + + + + + + + + @@ -425,19 +426,22 @@ - + - - - - diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupUI.xaml.cs b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupUI.xaml.cs index 002ef2bd9..c35c4e862 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupUI.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupUI.xaml.cs @@ -126,6 +126,7 @@ public void Reload() // 子页面 设置 CheckHiddenSetupLaunch.Checked = uiHidden.SetupLaunch; CheckHiddenSetupUI.Checked = uiHidden.SetupUi; + CheckHiddenSetupLauncherLanguage.Checked = uiHidden.SetupLauncherLanguage; CheckHiddenSetupGameManage.Checked = uiHidden.SetupGameManage; CheckHiddenSetupJava.Checked = uiHidden.SetupJava; CheckHiddenLauncherMisc.Checked = uiHidden.SetupLauncherMisc; @@ -738,6 +739,9 @@ public static void HiddenRefresh() !HiddenForceShow && conf.SetupLaunch ? Visibility.Collapsed : Visibility.Visible; ModMain.FrmSetupLeft.ItemUI.Visibility = !HiddenForceShow && conf.SetupUi ? Visibility.Collapsed : Visibility.Visible; + ModMain.FrmSetupLeft.ItemLauncherLanguage.Visibility = !HiddenForceShow && conf.SetupLauncherLanguage + ? Visibility.Collapsed + : Visibility.Visible; ModMain.FrmSetupLeft.ItemGameManage.Visibility = !HiddenForceShow && conf.SetupGameManage ? Visibility.Collapsed : Visibility.Visible; @@ -764,7 +768,7 @@ public static void HiddenRefresh() (ModMain.FrmSetupLeft.TextGameCategory, !(conf.SetupLaunch && conf.SetupJava && conf.SetupGameManage)), (ModMain.FrmSetupLeft.TextToolsCategory, !conf.SetupGameLink), - (ModMain.FrmSetupLeft.TextLauncherCategory, !(conf.SetupUi && conf.SetupLauncherMisc)), + (ModMain.FrmSetupLeft.TextLauncherCategory, !(conf.SetupUi && conf.SetupLauncherLanguage && conf.SetupLauncherMisc)), (ModMain.FrmSetupLeft.TextAboutCategory, !(conf.SetupAbout && conf.SetupUpdate && conf.SetupFeedback && conf.SetupLog)) }; @@ -784,6 +788,8 @@ public static void HiddenRefresh() SetupCount += 1; if (!conf.SetupUi) SetupCount += 1; + if (!conf.SetupLauncherLanguage) + SetupCount += 1; if (!conf.SetupGameManage) SetupCount += 1; if (!conf.SetupLauncherMisc) @@ -858,6 +864,7 @@ private void HiddenSetupMain() var IsChecked = (bool)CheckHiddenPageSetup.Checked; CheckHiddenSetupLaunch.Checked = IsChecked; CheckHiddenSetupUI.Checked = IsChecked; + CheckHiddenSetupLauncherLanguage.Checked = IsChecked; CheckHiddenSetupGameManage.Checked = IsChecked; CheckHiddenLauncherMisc.Checked = IsChecked; CheckHiddenSetupJava.Checked = IsChecked; @@ -876,6 +883,7 @@ private void HiddenSetupMain(object sender, bool user) var IsChecked = (bool)CheckHiddenPageSetup.Checked; CheckHiddenSetupLaunch.Checked = IsChecked; CheckHiddenSetupUI.Checked = IsChecked; + CheckHiddenSetupLauncherLanguage.Checked = IsChecked; CheckHiddenSetupGameManage.Checked = IsChecked; CheckHiddenLauncherMisc.Checked = IsChecked; CheckHiddenSetupJava.Checked = IsChecked; @@ -892,9 +900,9 @@ private void HiddenSetupSub(object sender, bool user) return; var conf = Config.Preference.Hide; // 判断是否全部勾选 - var AllChecked = conf.SetupLaunch && conf.SetupUi && conf.SetupJava && conf.SetupUpdate && conf.SetupGameLink && - conf.SetupAbout && conf.SetupFeedback && conf.SetupLog && conf.SetupLauncherMisc && - conf.SetupGameManage; + var AllChecked = conf.SetupLaunch && conf.SetupUi && conf.SetupLauncherLanguage && conf.SetupJava && + conf.SetupUpdate && conf.SetupGameLink && conf.SetupAbout && conf.SetupFeedback && + conf.SetupLog && conf.SetupLauncherMisc && conf.SetupGameManage; CheckHiddenPageSetup.Checked = AllChecked; }