From 36e3530a59a4273fecb3e02b82f53d508f97a623 Mon Sep 17 00:00:00 2001 From: Hongtao Zhang Date: Thu, 15 Jan 2026 01:10:53 -0800 Subject: [PATCH] Add internationalization support for Avalonia UI - Create Internationalization service that parses WPF XAML language files - Load translations from main Languages/ folder and all plugin Languages/ folders - Add LocalizeExtension markup extension and Translator helper for XAML/code - Fix IPublicAPI.GetTranslation to use the i18n service for plugin context menus - Update MainWindow to use localized placeholder text --- Flow.Launcher.Avalonia/App.axaml.cs | 23 ++ Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs | 12 +- .../Converters/TranslationConverter.cs | 28 ++ Flow.Launcher.Avalonia/MainWindow.axaml | 3 +- .../Resource/Internationalization.cs | 241 ++++++++++++++++++ .../Resource/LocalizeExtension.cs | 94 +++++++ .../ViewModel/MainViewModel.cs | 1 + 7 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 Flow.Launcher.Avalonia/Converters/TranslationConverter.cs create mode 100644 Flow.Launcher.Avalonia/Resource/Internationalization.cs create mode 100644 Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs diff --git a/Flow.Launcher.Avalonia/App.axaml.cs b/Flow.Launcher.Avalonia/App.axaml.cs index 7f217c3e5..caedad3f7 100644 --- a/Flow.Launcher.Avalonia/App.axaml.cs +++ b/Flow.Launcher.Avalonia/App.axaml.cs @@ -4,6 +4,7 @@ using Avalonia.Markup.Xaml; using Avalonia.Threading; using CommunityToolkit.Mvvm.DependencyInjection; using Flow.Launcher.Avalonia.Helper; +using Flow.Launcher.Avalonia.Resource; using Flow.Launcher.Avalonia.ViewModel; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure; @@ -22,8 +23,14 @@ public partial class App : Application private static readonly string ClassName = nameof(App); private Settings? _settings; private MainViewModel? _mainVM; + private Internationalization? _i18n; public static IPublicAPI? API { get; private set; } + + /// + /// Gets the internationalization service for translations. + /// + public static Internationalization? I18n { get; private set; } public override void Initialize() => AvaloniaXamlLoader.Load(this); @@ -32,6 +39,7 @@ public partial class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { LoadSettings(); + InitializeInternationalization(); ConfigureDI(); API = Ioc.Default.GetRequiredService(); @@ -69,6 +77,21 @@ public partial class App : Application } } + private void InitializeInternationalization() + { + try + { + _i18n = new Internationalization(_settings!); + _i18n.Initialize(); + I18n = _i18n; + Log.Info(ClassName, "Internationalization initialized"); + } + catch (Exception e) + { + Log.Exception(ClassName, "Failed to initialize internationalization", e); + } + } + private void ConfigureDI() { var services = new ServiceCollection(); diff --git a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs index 6b984dc69..da1cdc618 100644 --- a/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs +++ b/Flow.Launcher.Avalonia/AvaloniaPublicAPI.cs @@ -13,6 +13,7 @@ using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.SharedModels; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Avalonia.ViewModel; +using Flow.Launcher.Avalonia.Resource; using CommunityToolkit.Mvvm.DependencyInjection; namespace Flow.Launcher.Avalonia; @@ -38,7 +39,16 @@ public class AvaloniaPublicAPI : IPublicAPI // Essential for plugins public void ChangeQuery(string query, bool requery = false) => _getMainViewModel().QueryText = query; - public string GetTranslation(string key) => key; + + public string GetTranslation(string key) + { + var i18n = App.I18n; + if (i18n == null) + return key; + + return i18n.GetTranslation(key); + } + public List GetAllPlugins() => PluginManager.AllPlugins; public MatchResult FuzzySearch(string query, string stringToCompare) => Ioc.Default.GetRequiredService().FuzzyMatch(query, stringToCompare); diff --git a/Flow.Launcher.Avalonia/Converters/TranslationConverter.cs b/Flow.Launcher.Avalonia/Converters/TranslationConverter.cs new file mode 100644 index 000000000..dd24076af --- /dev/null +++ b/Flow.Launcher.Avalonia/Converters/TranslationConverter.cs @@ -0,0 +1,28 @@ +using Avalonia.Data.Converters; +using System; +using System.Globalization; + +namespace Flow.Launcher.Avalonia.Resource; + +/// +/// Converter to translate a key to its localized string in XAML bindings. +/// Usage: Text="{Binding Key, Converter={StaticResource TranslationConverter}}" +/// Or simpler: Use the Translator.GetString(key) helper from code-behind +/// +public class TranslationConverter : IValueConverter +{ + public object Convert(object? value, Type targetType, object? parameter, CultureInfo? culture) + { + if (value is string key && !string.IsNullOrEmpty(key)) + { + return Translator.GetString(key); + } + + return parameter?.ToString() ?? "[No Translation]"; + } + + public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo? culture) + { + throw new NotSupportedException(); + } +} diff --git a/Flow.Launcher.Avalonia/MainWindow.axaml b/Flow.Launcher.Avalonia/MainWindow.axaml index 5e603fe44..1d8a6ef32 100644 --- a/Flow.Launcher.Avalonia/MainWindow.axaml +++ b/Flow.Launcher.Avalonia/MainWindow.axaml @@ -6,6 +6,7 @@ xmlns:views="using:Flow.Launcher.Avalonia.Views" xmlns:vm="using:Flow.Launcher.Avalonia.ViewModel" xmlns:converters="using:Flow.Launcher.Avalonia.Converters" + xmlns:i18n="using:Flow.Launcher.Avalonia.Resource" mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="400" x:Class="Flow.Launcher.Avalonia.MainWindow" x:DataType="vm:MainViewModel" @@ -50,7 +51,7 @@ diff --git a/Flow.Launcher.Avalonia/Resource/Internationalization.cs b/Flow.Launcher.Avalonia/Resource/Internationalization.cs new file mode 100644 index 000000000..3c71969fb --- /dev/null +++ b/Flow.Launcher.Avalonia/Resource/Internationalization.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Xml.Linq; +using Avalonia; +using Avalonia.Controls; +using Flow.Launcher.Infrastructure; +using Flow.Launcher.Infrastructure.Logger; +using Flow.Launcher.Infrastructure.UserSettings; + +namespace Flow.Launcher.Avalonia.Resource; + +/// +/// Internationalization service for Avalonia that parses WPF XAML language files. +/// +public class Internationalization +{ + private static readonly string ClassName = nameof(Internationalization); + private const string LanguagesFolder = "Languages"; + private const string DefaultLanguageCode = "en"; + private const string Extension = ".xaml"; + + private readonly Settings _settings; + private readonly Dictionary _translations = new(); + private readonly List _languageDirectories = []; + + // WPF XAML namespace for system:String + private static readonly XNamespace SystemNs = "clr-namespace:System;assembly=mscorlib"; + private static readonly XNamespace XNs = "http://schemas.microsoft.com/winfx/2006/xaml"; + + public Internationalization(Settings settings) + { + _settings = settings; + } + + /// + /// Initialize language resources based on settings. + /// + public void Initialize() + { + try + { + // Add Flow Launcher language directory + AddFlowLauncherLanguageDirectory(); + + // Add plugin language directories + AddPluginLanguageDirectories(); + + // Load English as base/fallback + LoadLanguageFile(DefaultLanguageCode); + + // Load the configured language on top if different from English + var languageCode = GetActualLanguageCode(); + if (!string.Equals(languageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + LoadLanguageFile(languageCode); + } + + // Update culture info + ChangeCultureInfo(languageCode); + + Log.Info(ClassName, $"Loaded {_translations.Count} translations for language '{languageCode}'"); + } + catch (Exception e) + { + Log.Exception(ClassName, "Failed to initialize internationalization", e); + } + } + + private string GetActualLanguageCode() + { + var languageCode = _settings.Language; + + // Handle "system" language setting + if (languageCode == Constant.SystemLanguageCode) + { + languageCode = CultureInfo.CurrentCulture.TwoLetterISOLanguageName; + } + + return languageCode ?? DefaultLanguageCode; + } + + private void AddFlowLauncherLanguageDirectory() + { + var directory = Path.Combine(Constant.ProgramDirectory, LanguagesFolder); + if (Directory.Exists(directory)) + { + _languageDirectories.Add(directory); + Log.Debug(ClassName, $"Added language directory: {directory}"); + } + else + { + Log.Warn(ClassName, $"Language directory not found: {directory}"); + } + } + + private void AddPluginLanguageDirectories() + { + // Add plugin language directories (similar to WPF version) + var pluginsDir = Path.Combine(Constant.ProgramDirectory, "Plugins"); + if (!Directory.Exists(pluginsDir)) return; + + foreach (var dir in Directory.GetDirectories(pluginsDir)) + { + var pluginLanguageDir = Path.Combine(dir, LanguagesFolder); + if (Directory.Exists(pluginLanguageDir)) + { + _languageDirectories.Add(pluginLanguageDir); + Log.Debug(ClassName, $"Added plugin language directory: {pluginLanguageDir}"); + } + } + } + + private void LoadLanguageFile(string languageCode) + { + var filename = $"{languageCode}{Extension}"; + + foreach (var dir in _languageDirectories) + { + var filePath = Path.Combine(dir, filename); + if (!File.Exists(filePath)) + { + // Try fallback to English if specific language not found + if (!string.Equals(languageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + filePath = Path.Combine(dir, $"{DefaultLanguageCode}{Extension}"); + } + + if (!File.Exists(filePath)) + { + continue; + } + } + + try + { + ParseWpfXamlFile(filePath); + } + catch (Exception e) + { + Log.Exception(ClassName, $"Failed to parse language file: {filePath}", e); + } + } + } + + /// + /// Parse a WPF XAML ResourceDictionary file and extract string resources. + /// + private void ParseWpfXamlFile(string filePath) + { + var doc = XDocument.Load(filePath); + var root = doc.Root; + if (root == null) return; + + var count = 0; + // Find all system:String elements - WPF XAML uses clr-namespace:System;assembly=mscorlib + foreach (var element in root.Descendants()) + { + // Check if this is a system:String element (namespace doesn't matter, just check local name) + if (element.Name.LocalName == "String") + { + // Get the x:Key attribute + var keyAttr = element.Attribute(XNs + "Key"); + if (keyAttr != null) + { + var key = keyAttr.Value; + var value = element.Value; + _translations[key] = value; + count++; + } + } + } + + Log.Debug(ClassName, $"Parsed {count} strings from {filePath}"); + } + + /// + /// Get a translated string by key. + /// + public string GetTranslation(string key) + { + if (_translations.TryGetValue(key, out var translation)) + { + Log.Debug(ClassName, $"Translation found for '{key}': '{translation}'"); + return translation; + } + + Log.Warn(ClassName, $"Translation not found for key: {key}"); + Log.Debug(ClassName, $"Available keys (first 20): {string.Join(", ", _translations.Keys.Take(20))}"); + return $"[{key}]"; + } + + /// + /// Check if a translation exists for the given key. + /// + public bool HasTranslation(string key) => _translations.ContainsKey(key); + + /// + /// Get all available translations (for debugging). + /// + public IReadOnlyDictionary GetAllTranslations() => _translations; + + private static void ChangeCultureInfo(string languageCode) + { + try + { + var culture = CultureInfo.CreateSpecificCulture(languageCode); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + Thread.CurrentThread.CurrentCulture = culture; + Thread.CurrentThread.CurrentUICulture = culture; + } + catch (CultureNotFoundException) + { + Log.Warn(ClassName, $"Culture not found for language code: {languageCode}"); + } + } + + /// + /// Change language at runtime. + /// + public void ChangeLanguage(string languageCode) + { + _translations.Clear(); + + // Reload English as base + LoadLanguageFile(DefaultLanguageCode); + + // Load new language on top + if (!string.Equals(languageCode, DefaultLanguageCode, StringComparison.OrdinalIgnoreCase)) + { + LoadLanguageFile(languageCode); + } + + ChangeCultureInfo(languageCode); + Log.Info(ClassName, $"Language changed to: {languageCode}"); + } +} diff --git a/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs new file mode 100644 index 000000000..9b6c673be --- /dev/null +++ b/Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs @@ -0,0 +1,94 @@ +using Avalonia.Data; +using Avalonia.Markup.Xaml; +using Avalonia.Markup.Xaml.MarkupExtensions; +using System; + +namespace Flow.Launcher.Avalonia.Resource; + +/// +/// Markup extension for accessing localized strings in XAML. +/// Usage: Text="{i18n:Localize queryTextBoxPlaceholder}" +/// +public class LocalizeExtension : MarkupExtension +{ + public LocalizeExtension() + { + } + + public LocalizeExtension(string key) + { + Key = key; + } + + /// + /// The translation key to look up. + /// + public string Key { get; set; } = string.Empty; + + /// + /// Fallback value if translation is not found. + /// + public string? Fallback { get; set; } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + if (string.IsNullOrEmpty(Key)) + { + return Fallback ?? "[No Key]"; + } + + var i18n = App.I18n; + if (i18n == null) + { + return Fallback ?? $"[{Key}]"; + } + + if (i18n.HasTranslation(Key)) + { + return i18n.GetTranslation(Key); + } + + return Fallback ?? $"[{Key}]"; + } +} + +/// +/// Static helper class for accessing translations from code-behind. +/// +public static class Translator +{ + /// + /// Get a translated string by key. + /// + /// The translation key + /// The translated string or the key in brackets if not found + public static string GetString(string key) + { + var i18n = App.I18n; + if (i18n == null) + { + return $"[{key}]"; + } + + return i18n.GetTranslation(key); + } + + /// + /// Get a translated string with format arguments. + /// + /// The translation key + /// Format arguments + /// The formatted translated string + public static string GetString(string key, params object[] args) + { + var template = GetString(key); + try + { + return string.Format(template, args); + } + catch + { + return template; + } + } +} diff --git a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs index be9c86170..898c2a019 100644 --- a/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs +++ b/Flow.Launcher.Avalonia/ViewModel/MainViewModel.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; +using Flow.Launcher.Avalonia.Resource; using Flow.Launcher.Core.Plugin; using Flow.Launcher.Infrastructure.Logger; using Flow.Launcher.Infrastructure.UserSettings;