mirror of
https://github.com/Flow-Launcher/Flow.Launcher.git
synced 2026-03-11 08:54:32 +00:00
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:
parent
192eb4d1c0
commit
36e3530a59
7 changed files with 400 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
28
Flow.Launcher.Avalonia/Converters/TranslationConverter.cs
Normal file
28
Flow.Launcher.Avalonia/Converters/TranslationConverter.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
241
Flow.Launcher.Avalonia/Resource/Internationalization.cs
Normal file
241
Flow.Launcher.Avalonia/Resource/Internationalization.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
94
Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs
Normal file
94
Flow.Launcher.Avalonia/Resource/LocalizeExtension.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue