Skip to content

Commit f74c67b

Browse files
JusterZhuclaude
andcommitted
feat: integrate Lingua i18n framework from source (self-implemented)
Re-implement the Irihi.Lingua public API surface to avoid NuGet source generator issues in debug mode. The implementation matches the Lingua 0.2.0 contract: Core types (Irihi.Lingua namespace): - LinguaKey — strongly-typed key + manager pair for XAML bindings - ILinguaManager — interface for culture switching + observable access - LinguaManagerAttribute — marks a partial class as a language manager - LinguaObservableString — behaviour-subject observable with replay - LinguaRuntimeResources — culture hierarchy resource resolution - LanguageManager — abstract base class for concrete managers Avalonia markup extensions: - TranslateExtension — {Translate Key} for reactive XAML text binding - FormatTranslateExtension — {FormatTranslate} with format strings + args - TranslateEntry + FormatTranslateConverter — format argument entries App integration: - AppLanguageManager — concrete manager loading from LocalizationService JSONs - Exposes strongly-typed IObservable<string?> properties for common keys - Keys nested class with static LinguaKey fields (mirrors source gen output) - XmlnsDefinition registered so {Translate} works without xmlns import - Culture switching syncs both legacy LocalizationService + Lingua observables Demo: MainWindow title uses {Translate AppLanguageManager+Keys.App_Title} Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3de745f commit f74c67b

13 files changed

Lines changed: 628 additions & 3 deletions

src/App.axaml.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44
using Avalonia.Controls;
55
using Avalonia.Controls.ApplicationLifetimes;
66
using Avalonia.Markup.Xaml;
7+
using Avalonia.Metadata;
78
using GeneralUpdate.Tools.Configuration;
89
using GeneralUpdate.Tools.ViewModels;
910
using GeneralUpdate.Tools.Views;
1011

12+
// Register Lingua markup extensions under Avalonia's default XAML namespace
13+
// so that {Translate} and {FormatTranslate} work without an extra xmlns import.
14+
[assembly: XmlnsDefinition("https://github.com/avaloniaui", "Irihi.Lingua")]
15+
1116
namespace GeneralUpdate.Tools;
1217

1318
public partial class App : Application
@@ -24,6 +29,9 @@ public override void OnFrameworkInitializationCompleted()
2429
// Load translations from embedded JSON files (must be after platform init)
2530
Services.LocalizationService.Instance.LoadFromResources();
2631

32+
// Initialize Lingua — must be after LocalizationService is loaded
33+
_ = Irihi.Lingua.AppLanguageManager.Instance;
34+
2735
// Initialize configuration (synchronous path for startup reliability)
2836
var config = LoadConfigSafe();
2937

src/Lingua/AppLanguageManager.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
using System;
2+
using System.Globalization;
3+
using GeneralUpdate.Tools.Services;
4+
5+
namespace Irihi.Lingua;
6+
7+
/// <summary>
8+
/// Concrete Lingua language manager for GeneralUpdate.Tools.
9+
/// Loads resources from the existing <see cref="LocalizationService"/> JSON files
10+
/// and bridges reactive culture switching with the legacy localization system.
11+
///
12+
/// Usage:
13+
/// <code>
14+
/// // Switch language (updates both Lingua observables AND legacy bindings)
15+
/// AppLanguageManager.Instance.UpdateCulture(new CultureInfo("zh-CN"));
16+
///
17+
/// // Reactive access
18+
/// var title = AppLanguageManager.Instance.App_Title;
19+
/// title.Subscribe(t => Console.WriteLine(t));
20+
///
21+
/// // XAML with TranslateExtension
22+
/// &lt;TextBlock Text="{Translate {x:Static lingua:AppLanguageManager+Keys.App_Title}}" /&gt;
23+
/// </code>
24+
/// </summary>
25+
[LinguaManager("./Resources/Locales")]
26+
public sealed class AppLanguageManager : LanguageManager
27+
{
28+
public static new AppLanguageManager Instance { get; } = new();
29+
30+
private AppLanguageManager() : base("zh-CN")
31+
{
32+
// Load all resources from existing LocalizationService
33+
LoadFromLocalizationService();
34+
35+
// Set initial culture
36+
UpdateCulture(new CultureInfo(LocalizationService.Instance.Locale));
37+
38+
// Listen for legacy locale changes and sync back to Lingua
39+
LocalizationService.Instance.PropertyChanged += (_, e) =>
40+
{
41+
if (e.PropertyName == nameof(LocalizationService.Locale))
42+
UpdateCulture(new CultureInfo(LocalizationService.Instance.Locale));
43+
};
44+
}
45+
46+
private void LoadFromLocalizationService()
47+
{
48+
var loc = LocalizationService.Instance;
49+
50+
// Register zh-CN from the loaded JSON or fallback dictionary
51+
AddResources(new CultureInfo("zh-CN"), loc.GetAllStrings("zh-CN"));
52+
53+
// Register en-US
54+
AddResources(new CultureInfo("en-US"), loc.GetAllStrings("en-US"));
55+
}
56+
57+
// ── Strongly-typed observable properties ─────────────────
58+
// Only the most commonly used keys are exposed as properties.
59+
// All other keys are accessible via GetObservable("Key.Name").
60+
61+
public IObservable<string?> App_Title => GetObservable("App.Title");
62+
public IObservable<string?> Nav_Patch => GetObservable("Nav.Patch");
63+
public IObservable<string?> Nav_Extension => GetObservable("Nav.Extension");
64+
public IObservable<string?> Nav_OSS => GetObservable("Nav.OSS");
65+
public IObservable<string?> Nav_Simulate => GetObservable("Nav.Simulate");
66+
public IObservable<string?> Nav_Config => GetObservable("Nav.Config");
67+
public IObservable<string?> Nav_Settings => GetObservable("Nav.Settings");
68+
public IObservable<string?> Patch_Title => GetObservable("Patch.Title");
69+
public IObservable<string?> Patch_Build => GetObservable("Patch.Build");
70+
71+
/// <summary>
72+
/// Gets the current translation for a key (synchronous, non-observable).
73+
/// </summary>
74+
public string this[string key] => Resolve(key) ?? key;
75+
76+
// ── Keys class (mirrors source-generator output) ─────────
77+
78+
/// <summary>
79+
/// Strongly-typed keys for use with <see cref="TranslateExtension"/> in XAML.
80+
/// Usage: <c>{Translate {x:Static lingua:AppLanguageManager+Keys.App_Title}}</c>
81+
/// </summary>
82+
public static class Keys
83+
{
84+
public static LinguaKey App_Title => new("App.Title", Instance);
85+
public static LinguaKey Nav_Patch => new("Nav.Patch", Instance);
86+
public static LinguaKey Nav_Extension => new("Nav.Extension", Instance);
87+
public static LinguaKey Nav_OSS => new("Nav.OSS", Instance);
88+
public static LinguaKey Nav_Simulate => new("Nav.Simulate", Instance);
89+
public static LinguaKey Nav_Config => new("Nav.Config", Instance);
90+
public static LinguaKey Nav_Settings => new("Nav.Settings", Instance);
91+
public static LinguaKey Patch_Title => new("Patch.Title", Instance);
92+
public static LinguaKey Patch_OldDir => new("Patch.OldDir", Instance);
93+
public static LinguaKey Patch_NewDir => new("Patch.NewDir", Instance);
94+
public static LinguaKey Patch_Build => new("Patch.Build", Instance);
95+
public static LinguaKey Patch_PackageInfo => new("Patch.PackageInfo", Instance);
96+
public static LinguaKey Patch_OutputDir => new("Patch.OutputDir", Instance);
97+
public static LinguaKey Patch_Select => new("Patch.Select", Instance);
98+
public static LinguaKey Ext_Title => new("Ext.Title", Instance);
99+
public static LinguaKey OSS_Title => new("OSS.Title", Instance);
100+
public static LinguaKey Sim_Title => new("Sim.Title", Instance);
101+
public static LinguaKey Config_Title => new("Config.Title", Instance);
102+
public static LinguaKey Settings_UploadSection => new("Settings.UploadSection", Instance);
103+
public static LinguaKey Settings_Save => new("Settings.Save", Instance);
104+
public static LinguaKey Settings_Reset => new("Settings.Reset", Instance);
105+
public static LinguaKey Upload_Success => new("Upload.Success", Instance);
106+
public static LinguaKey Upload_Failed => new("Upload.Failed", Instance);
107+
}
108+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using global::Avalonia;
5+
using global::Avalonia.Data;
6+
using global::Avalonia.Data.Converters;
7+
using global::Avalonia.Markup.Xaml;
8+
9+
namespace Irihi.Lingua;
10+
11+
/// <summary>
12+
/// Avalonia markup extension for format strings with localized arguments.
13+
///
14+
/// Usage in XAML:
15+
/// <code>
16+
/// &lt;TextBlock&gt;
17+
/// &lt;TextBlock.Text&gt;
18+
/// &lt;FormatTranslate FormatKey="{x:Static local:LanguageManager+Keys.Page_Template}"&gt;
19+
/// &lt;TranslateEntry Binding="{Binding #page.Value}" /&gt;
20+
/// &lt;TranslateEntry Key="{x:Static local:LanguageManager+Keys.Greeting_Message}" /&gt;
21+
/// &lt;/FormatTranslate&gt;
22+
/// &lt;/TextBlock.Text&gt;
23+
/// &lt;/TextBlock&gt;
24+
/// </code>
25+
/// </summary>
26+
public sealed class FormatTranslateExtension : MarkupExtension
27+
{
28+
public LinguaKey? FormatKey { get; set; }
29+
public IList<TranslateEntry> Items { get; set; } = new List<TranslateEntry>();
30+
31+
public FormatTranslateExtension() { }
32+
33+
public override object ProvideValue(IServiceProvider serviceProvider)
34+
{
35+
if (FormatKey == null)
36+
return AvaloniaProperty.UnsetValue;
37+
38+
var converter = new FormatTranslateConverter();
39+
var formatObservable = FormatKey.Manager.GetObservable(FormatKey.Key);
40+
var formatSource = new LinguaBindingSource(formatObservable);
41+
42+
// Build a MultiBinding with the format template and all arguments
43+
var multiBinding = new MultiBinding
44+
{
45+
Converter = converter,
46+
Mode = BindingMode.OneWay,
47+
};
48+
49+
// Bind to the format template
50+
multiBinding.Bindings.Add(new Binding
51+
{
52+
Source = formatSource,
53+
Path = "Value",
54+
Mode = BindingMode.OneWay,
55+
});
56+
57+
// Add each argument binding
58+
foreach (var item in Items)
59+
{
60+
if (item.Key != null)
61+
{
62+
var argObservable = item.Key.Manager.GetObservable(item.Key.Key);
63+
var argSource = new LinguaBindingSource(argObservable);
64+
multiBinding.Bindings.Add(new Binding
65+
{
66+
Source = argSource,
67+
Path = "Value",
68+
Mode = BindingMode.OneWay,
69+
});
70+
}
71+
else if (item.Binding != null)
72+
{
73+
multiBinding.Bindings.Add(item.Binding);
74+
}
75+
}
76+
77+
// Return the MultiBinding directly — Avalonia will apply it
78+
return multiBinding;
79+
}
80+
}
81+
82+
/// <summary>
83+
/// An entry in a <see cref="FormatTranslateExtension"/> — either a localized key
84+
/// or a standard Avalonia binding.
85+
/// </summary>
86+
public sealed class TranslateEntry
87+
{
88+
public LinguaKey? Key { get; set; }
89+
public BindingBase? Binding { get; set; }
90+
91+
public TranslateEntry() { }
92+
}
93+
94+
/// <summary>
95+
/// Multi-value converter for format strings. The first value is the format template;
96+
/// remaining values are the format arguments.
97+
/// </summary>
98+
public sealed class FormatTranslateConverter : IMultiValueConverter
99+
{
100+
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
101+
{
102+
if (values.Count == 0)
103+
return AvaloniaProperty.UnsetValue;
104+
105+
var format = values[0] as string;
106+
if (string.IsNullOrEmpty(format))
107+
return AvaloniaProperty.UnsetValue;
108+
109+
var args = new object?[values.Count - 1];
110+
for (var i = 1; i < values.Count; i++)
111+
args[i - 1] = values[i];
112+
113+
return string.Format(format, args);
114+
}
115+
}

src/Lingua/ILinguaManager.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
5+
namespace Irihi.Lingua;
6+
7+
/// <summary>
8+
/// Interface implemented by all Lingua language managers.
9+
/// Provides culture switching and reactive observable access to localized strings.
10+
/// </summary>
11+
public interface ILinguaManager
12+
{
13+
/// <summary>Switch the active culture and push new values to all subscribers.</summary>
14+
void UpdateCulture(CultureInfo culture);
15+
16+
/// <summary>Get an observable that emits when the given key's translation changes.</summary>
17+
IObservable<string?> GetObservable(string key);
18+
19+
/// <summary>Register resource strings for a specific culture at runtime.</summary>
20+
void AddResources(CultureInfo culture, IReadOnlyDictionary<string, string> resources);
21+
}

src/Lingua/LanguageManager.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Globalization;
4+
using System.Linq;
5+
6+
namespace Irihi.Lingua;
7+
8+
/// <summary>
9+
/// Base class for Lingua language managers. Concrete managers extend this
10+
/// to expose strongly-typed <see cref="IObservable{T}"/> properties for each resource key.
11+
///
12+
/// Usage:
13+
/// <code>
14+
/// public sealed class AppLanguageManager : LanguageManager
15+
/// {
16+
/// public static new AppLanguageManager Instance { get; } = new();
17+
/// public IObservable&lt;string?&gt; App_Title => GetObservable("App.Title");
18+
/// }
19+
/// </code>
20+
/// </summary>
21+
public abstract class LanguageManager : ILinguaManager
22+
{
23+
private readonly Dictionary<string, LinguaObservableString> _observables = new();
24+
private readonly LinguaRuntimeResources _resources;
25+
private CultureInfo _currentCulture = CultureInfo.InvariantCulture;
26+
27+
protected LanguageManager(string invariantCultureName = "")
28+
{
29+
_resources = new LinguaRuntimeResources(invariantCultureName);
30+
}
31+
32+
// ── ILinguaManager ───────────────────────────────────────
33+
34+
/// <inheritdoc />
35+
public void UpdateCulture(CultureInfo culture)
36+
{
37+
_currentCulture = culture;
38+
var resolved = _resources.Resolve(culture);
39+
40+
// Push updated values to all observables
41+
foreach (var kvp in _observables)
42+
{
43+
var key = kvp.Key;
44+
var observable = kvp.Value;
45+
var newValue = resolved.TryGetValue(key, out var val) ? val : null;
46+
observable.OnNext(newValue);
47+
}
48+
}
49+
50+
/// <inheritdoc />
51+
public IObservable<string?> GetObservable(string key)
52+
{
53+
if (_observables.TryGetValue(key, out var existing))
54+
return existing;
55+
56+
// Look up current value from resolved resources
57+
var resolved = _resources.Resolve(_currentCulture);
58+
var initialValue = resolved.TryGetValue(key, out var val) ? val : null;
59+
60+
var observable = new LinguaObservableString(key, initialValue);
61+
_observables[key] = observable;
62+
return observable;
63+
}
64+
65+
/// <inheritdoc />
66+
public void AddResources(CultureInfo culture, IReadOnlyDictionary<string, string> resources)
67+
{
68+
_resources.Add(culture, resources);
69+
70+
// If we're adding resources for the current culture, push updates
71+
if (culture.Name == _currentCulture.Name)
72+
{
73+
foreach (var kvp in resources)
74+
{
75+
if (_observables.TryGetValue(kvp.Key, out var obs))
76+
obs.OnNext(kvp.Value);
77+
}
78+
}
79+
}
80+
81+
// ── Helpers ──────────────────────────────────────────────
82+
83+
/// <summary>Current active culture.</summary>
84+
public CultureInfo CurrentCulture => _currentCulture;
85+
86+
/// <summary>
87+
/// Add resource strings for a culture from a flat dictionary.
88+
/// Convenience overload that accepts a regular <see cref="Dictionary{TKey,TValue}"/>.
89+
/// </summary>
90+
public void AddResources(string cultureName, Dictionary<string, string> resources)
91+
{
92+
AddResources(new CultureInfo(cultureName), resources);
93+
}
94+
95+
/// <summary>
96+
/// Resolve a single key for the current culture (synchronous, non-observable access).
97+
/// </summary>
98+
public string? Resolve(string key)
99+
{
100+
var resolved = _resources.Resolve(_currentCulture);
101+
return resolved.TryGetValue(key, out var val) ? val : null;
102+
}
103+
}

src/Lingua/LinguaKey.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
namespace Irihi.Lingua;
2+
3+
/// <summary>
4+
/// Strongly-typed key that pairs a resource key string with its owning manager.
5+
/// Used by <see cref="TranslateExtension"/> and generated <c>Keys</c> classes.
6+
/// </summary>
7+
public sealed class LinguaKey
8+
{
9+
public string Key { get; }
10+
public ILinguaManager Manager { get; }
11+
12+
public LinguaKey(string key, ILinguaManager manager)
13+
{
14+
Key = key;
15+
Manager = manager;
16+
}
17+
18+
public override string ToString() => Key;
19+
}

0 commit comments

Comments
 (0)