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
This commit is contained in:
Hongtao Zhang 2026-01-15 01:10:53 -08:00
parent 192eb4d1c0
commit 36e3530a59
7 changed files with 400 additions and 2 deletions

View file

@ -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; }
/// <summary>
/// Gets the internationalization service for translations.
/// </summary>
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<IPublicAPI>();
@ -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();

View file

@ -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<PluginPair> GetAllPlugins() => PluginManager.AllPlugins;
public MatchResult FuzzySearch(string query, string stringToCompare) =>
Ioc.Default.GetRequiredService<StringMatcher>().FuzzyMatch(query, stringToCompare);

View file

@ -0,0 +1,28 @@
using Avalonia.Data.Converters;
using System;
using System.Globalization;
namespace Flow.Launcher.Avalonia.Resource;
/// <summary>
/// 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
/// </summary>
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();
}
}

View file

@ -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 @@
<TextBox Name="QueryTextBox"
Classes="queryBox"
Text="{Binding QueryText, Mode=TwoWay}"
Watermark="Type to search..."
Watermark="{i18n:Localize queryTextBoxPlaceholder, Fallback='Type here to search'}"
AcceptsReturn="False"
TextWrapping="NoWrap" />
</Border>

View file

@ -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;
/// <summary>
/// Internationalization service for Avalonia that parses WPF XAML language files.
/// </summary>
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<string, string> _translations = new();
private readonly List<string> _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;
}
/// <summary>
/// Initialize language resources based on settings.
/// </summary>
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);
}
}
}
/// <summary>
/// Parse a WPF XAML ResourceDictionary file and extract string resources.
/// </summary>
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}");
}
/// <summary>
/// Get a translated string by key.
/// </summary>
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}]";
}
/// <summary>
/// Check if a translation exists for the given key.
/// </summary>
public bool HasTranslation(string key) => _translations.ContainsKey(key);
/// <summary>
/// Get all available translations (for debugging).
/// </summary>
public IReadOnlyDictionary<string, string> 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}");
}
}
/// <summary>
/// Change language at runtime.
/// </summary>
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}");
}
}

View file

@ -0,0 +1,94 @@
using Avalonia.Data;
using Avalonia.Markup.Xaml;
using Avalonia.Markup.Xaml.MarkupExtensions;
using System;
namespace Flow.Launcher.Avalonia.Resource;
/// <summary>
/// Markup extension for accessing localized strings in XAML.
/// Usage: Text="{i18n:Localize queryTextBoxPlaceholder}"
/// </summary>
public class LocalizeExtension : MarkupExtension
{
public LocalizeExtension()
{
}
public LocalizeExtension(string key)
{
Key = key;
}
/// <summary>
/// The translation key to look up.
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Fallback value if translation is not found.
/// </summary>
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}]";
}
}
/// <summary>
/// Static helper class for accessing translations from code-behind.
/// </summary>
public static class Translator
{
/// <summary>
/// Get a translated string by key.
/// </summary>
/// <param name="key">The translation key</param>
/// <returns>The translated string or the key in brackets if not found</returns>
public static string GetString(string key)
{
var i18n = App.I18n;
if (i18n == null)
{
return $"[{key}]";
}
return i18n.GetTranslation(key);
}
/// <summary>
/// Get a translated string with format arguments.
/// </summary>
/// <param name="key">The translation key</param>
/// <param name="args">Format arguments</param>
/// <returns>The formatted translated string</returns>
public static string GetString(string key, params object[] args)
{
var template = GetString(key);
try
{
return string.Format(template, args);
}
catch
{
return template;
}
}
}

View file

@ -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;