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;