From a5f85849620eca3ff04f4734c52e3e7dd86b3756 Mon Sep 17 00:00:00 2001 From: Dirk Date: Mon, 28 Sep 2026 22:59:24 +0200 Subject: [PATCH] Update translations in place when the language changes --- .../Localization/TranslationFunctionsTests.cs | 30 +++++++ .../ApplicationCompositionTests.cs | 20 +++++ .../LocalizationBindingTests.cs | 37 +++++++++ .../EmbeddedYamlLocalizationService.cs | 7 ++ .../Localization/ILocalizationService.cs | 1 + .../Localization/LocalizationExtensions.cs | 7 +- .../Localization/LocalizationProvider.cs | 23 +++++- WheelWizard/Features/Localization/T.cs | 43 ++++++++-- .../Settings/SettingsLocalizationService.cs | 2 +- WheelWizard/Views/Layout.axaml.cs | 6 ++ .../Pages/Settings/WhWzSettings.axaml.cs | 82 +++++++++++-------- 11 files changed, 209 insertions(+), 49 deletions(-) create mode 100644 WheelWizard.UI.Test/LocalizationBindingTests.cs diff --git a/WheelWizard.Test/Features/Localization/TranslationFunctionsTests.cs b/WheelWizard.Test/Features/Localization/TranslationFunctionsTests.cs index c2b58cf5..c6132c48 100644 --- a/WheelWizard.Test/Features/Localization/TranslationFunctionsTests.cs +++ b/WheelWizard.Test/Features/Localization/TranslationFunctionsTests.cs @@ -4,6 +4,36 @@ namespace WheelWizard.Test.Features.Localization; public class TranslationFunctionsTests { + [Fact] + public void ProviderForwardsOnlyActiveServiceChangesOnce() + { + var previous = LocalizationProvider.Current; + var first = new EmbeddedYamlLocalizationService(); + var second = new EmbeddedYamlLocalizationService(); + var notifications = 0; + EventHandler handler = (_, _) => notifications++; + LocalizationProvider.Use(first); + LocalizationProvider.LanguageChanged += handler; + try + { + first.SetLanguage("nl"); + first.SetLanguage("NL"); + Assert.Equal(1, notifications); + LocalizationProvider.Use(second); + Assert.Equal(2, notifications); + first.SetLanguage("fr"); + Assert.Equal(2, notifications); + second.SetLanguage("fr"); + Assert.Equal(3, notifications); + Assert.Equal(second.Translate("action.cancel"), TranslationFunctions.t("action.cancel")); + } + finally + { + LocalizationProvider.LanguageChanged -= handler; + LocalizationProvider.Use(previous); + } + } + [Fact(DisplayName = "Format with no params returns default string")] public void FormatWithNoParams_ShouldReturnDefaultString() { diff --git a/WheelWizard.UI.Test/ApplicationCompositionTests.cs b/WheelWizard.UI.Test/ApplicationCompositionTests.cs index c72d1a1b..22c21a06 100644 --- a/WheelWizard.UI.Test/ApplicationCompositionTests.cs +++ b/WheelWizard.UI.Test/ApplicationCompositionTests.cs @@ -1,6 +1,7 @@ using System.IO.Abstractions; using System.Linq.Expressions; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Headless.XUnit; using Avalonia.Threading; @@ -8,6 +9,7 @@ using NSubstitute; using Testably.Abstractions.Testing; using WheelWizard.ApplicationData; +using WheelWizard.Localization; using WheelWizard.Settings; using WheelWizard.Shared; using WheelWizard.Shared.Services; @@ -96,6 +98,24 @@ public async Task MainWindowAndPages_ConstructWithoutStaticServiceInitialization original.UpdateLayout(); Assert.IsType(navigation.CurrentPage); } + navigation.NavigateTo(typeof(WhWzSettings)); + original.UpdateLayout(); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + var settingsPage = Assert.IsType(navigation.CurrentPage); + var content = settingsPage.FindControl("SettingsContent")!; + var wizard = Assert.IsType(content.Content); + var helper = wizard.FindControl("MarioKartHelperText")!; + var before = helper.Text; + Assert.True(settings.Set(settings.WW_LANGUAGE, "nl")); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + Assert.Same(original, desktop.MainWindow); + Assert.Same(settingsPage, navigation.CurrentPage); + Assert.Same(wizard, content.Content); + Assert.NotEqual(before, helper.Text); + Assert.StartsWith(TranslationFunctions.t("helper_text.end_with_x"), helper.Text); + var label = wizard.FindControl("DolphinUserFolderLabel")!; + Assert.Equal(TranslationFunctions.t("option.dolphin_user_path"), label.Text); + settings.Set(settings.WW_LANGUAGE, "en"); windows.Refresh(); Assert.NotSame(original, desktop.MainWindow); Assert.False(original.IsVisible); diff --git a/WheelWizard.UI.Test/LocalizationBindingTests.cs b/WheelWizard.UI.Test/LocalizationBindingTests.cs new file mode 100644 index 00000000..dbd059f9 --- /dev/null +++ b/WheelWizard.UI.Test/LocalizationBindingTests.cs @@ -0,0 +1,37 @@ +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using WheelWizard.Localization; + +namespace WheelWizard.UI.Test; + +public class LocalizationBindingTests +{ + [AvaloniaFact] + public async Task ExistingBindingUpdatesOnUiThreadAndStopsAfterDisposal() + { + var previous = LocalizationProvider.Current; + var service = new EmbeddedYamlLocalizationService(); + LocalizationProvider.Use(service); + try + { + var text = new TextBlock(); + var binding = (BindingBase)new T("action.cancel").ProvideValue(null!); + var subscription = text.Bind(TextBlock.TextProperty, binding); + Assert.Equal(service.Translate("action.cancel"), text.Text); + await Task.Run(() => service.SetLanguage("nl")); + await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background); + Assert.Equal(service.Translate("action.cancel"), text.Text); + Assert.NotEqual(service.TranslateForLanguage("action.cancel", "en"), text.Text); + subscription.Dispose(); + var afterDisposal = text.Text; + service.SetLanguage("fr"); + Assert.Equal(afterDisposal, text.Text); + } + finally + { + LocalizationProvider.Use(previous); + } + } +} diff --git a/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs b/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs index 6f8de6f8..4718dcb5 100644 --- a/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs +++ b/WheelWizard/Features/Localization/EmbeddedYamlLocalizationService.cs @@ -5,6 +5,8 @@ namespace WheelWizard.Localization; public sealed class EmbeddedYamlLocalizationService : ILocalizationService { + public event EventHandler? LanguageChanged; + private const string DefaultLanguage = "en"; private readonly Dictionary> _translations; private readonly object _languageLock = new(); @@ -41,7 +43,12 @@ public void SetLanguage(string languageCode) normalizedLanguage = DefaultLanguage; lock (_languageLock) + { + if (_currentLanguage == normalizedLanguage) + return; _currentLanguage = normalizedLanguage; + } + LanguageChanged?.Invoke(this, EventArgs.Empty); } public string Translate(string key) diff --git a/WheelWizard/Features/Localization/ILocalizationService.cs b/WheelWizard/Features/Localization/ILocalizationService.cs index 5c3540af..972d1653 100644 --- a/WheelWizard/Features/Localization/ILocalizationService.cs +++ b/WheelWizard/Features/Localization/ILocalizationService.cs @@ -2,6 +2,7 @@ namespace WheelWizard.Localization; public interface ILocalizationService { + event EventHandler? LanguageChanged; string CurrentLanguage { get; } IReadOnlyCollection AvailableLanguages { get; } diff --git a/WheelWizard/Features/Localization/LocalizationExtensions.cs b/WheelWizard/Features/Localization/LocalizationExtensions.cs index 01217676..db7ded92 100644 --- a/WheelWizard/Features/Localization/LocalizationExtensions.cs +++ b/WheelWizard/Features/Localization/LocalizationExtensions.cs @@ -4,12 +4,7 @@ public static class LocalizationExtensions { public static IServiceCollection AddLocalization(this IServiceCollection services) { - services.AddSingleton(_ => - { - var service = new EmbeddedYamlLocalizationService(); - LocalizationProvider.Use(service); - return service; - }); + services.AddSingleton(); return services; } diff --git a/WheelWizard/Features/Localization/LocalizationProvider.cs b/WheelWizard/Features/Localization/LocalizationProvider.cs index 1c6321a7..e23713f9 100644 --- a/WheelWizard/Features/Localization/LocalizationProvider.cs +++ b/WheelWizard/Features/Localization/LocalizationProvider.cs @@ -2,7 +2,7 @@ namespace WheelWizard.Localization; public static class LocalizationProvider { - // #todo: let the app own the localization service and language events instead of sharing mutable state globally. + // Compatibility facade for global t() and XAML; the injected service owns the language. private static readonly object ServiceLock = new(); private static ILocalizationService? _service; @@ -13,7 +13,14 @@ public static ILocalizationService Current get { lock (ServiceLock) - return _service ??= new EmbeddedYamlLocalizationService(); + { + if (_service == null) + { + _service = new EmbeddedYamlLocalizationService(); + _service.LanguageChanged += ForwardLanguageChanged; + } + return _service; + } } } @@ -22,7 +29,14 @@ public static void Use(ILocalizationService service) ArgumentNullException.ThrowIfNull(service); lock (ServiceLock) + { + if (ReferenceEquals(_service, service)) + return; + if (_service != null) + _service.LanguageChanged -= ForwardLanguageChanged; _service = service; + _service.LanguageChanged += ForwardLanguageChanged; + } NotifyLanguageChanged(); } @@ -30,10 +44,11 @@ public static void Use(ILocalizationService service) public static void SetLanguage(string languageCode) { Current.SetLanguage(languageCode); - NotifyLanguageChanged(); } - public static void NotifyLanguageChanged() + private static void ForwardLanguageChanged(object? sender, EventArgs args) => NotifyLanguageChanged(); + + private static void NotifyLanguageChanged() { LanguageChanged?.Invoke(null, EventArgs.Empty); } diff --git a/WheelWizard/Features/Localization/T.cs b/WheelWizard/Features/Localization/T.cs index 638e9c59..da678548 100644 --- a/WheelWizard/Features/Localization/T.cs +++ b/WheelWizard/Features/Localization/T.cs @@ -1,4 +1,6 @@ +using Avalonia; using Avalonia.Markup.Xaml; +using Avalonia.Threading; namespace WheelWizard.Localization; @@ -6,15 +8,44 @@ public sealed class T : MarkupExtension { public T() { } - public T(string key) - { - Key = key; - } + public T(string key) => Key = key; public string Key { get; set; } = string.Empty; - public override object ProvideValue(IServiceProvider serviceProvider) + public override object ProvideValue(IServiceProvider serviceProvider) => new TranslationObservable(Key).ToBinding(); + + private sealed class TranslationObservable(string key) : IObservable + { + public IDisposable Subscribe(IObserver observer) => new Subscription(key, observer); + } + + private sealed class Subscription : IDisposable { - return TranslationFunctions.t(Key); + private readonly string _key; + private IObserver? _observer; + + public Subscription(string key, IObserver observer) + { + _key = key; + _observer = observer; + LocalizationProvider.LanguageChanged += OnLanguageChanged; + Publish(); + } + + private void OnLanguageChanged(object? sender, EventArgs args) + { + if (Dispatcher.UIThread.CheckAccess()) + Publish(); + else + Dispatcher.UIThread.Post(Publish); + } + + private void Publish() => _observer?.OnNext(TranslationFunctions.t(_key)); + + public void Dispose() + { + LocalizationProvider.LanguageChanged -= OnLanguageChanged; + _observer = null; + } } } diff --git a/WheelWizard/Features/Settings/SettingsLocalizationService.cs b/WheelWizard/Features/Settings/SettingsLocalizationService.cs index 6e2614c5..d5738fd0 100644 --- a/WheelWizard/Features/Settings/SettingsLocalizationService.cs +++ b/WheelWizard/Features/Settings/SettingsLocalizationService.cs @@ -20,6 +20,7 @@ public void Initialize() return; _subscription = settingsSignalBus.Subscribe(OnSignal); + LocalizationProvider.Use(localizationService); ApplyCurrentLanguage(); _initialized = true; } @@ -47,6 +48,5 @@ public void ApplyCurrentLanguage() CultureInfo.CurrentUICulture = newCulture; localizationService.SetLanguage(languageCode); - LocalizationProvider.Use(localizationService); } } diff --git a/WheelWizard/Views/Layout.axaml.cs b/WheelWizard/Views/Layout.axaml.cs index 76614889..9fea5f4c 100644 --- a/WheelWizard/Views/Layout.axaml.cs +++ b/WheelWizard/Views/Layout.axaml.cs @@ -144,6 +144,12 @@ protected override void OnClosed(EventArgs e) private void OnLanguageChanged(object? sender, EventArgs e) { + if (!Avalonia.Threading.Dispatcher.UIThread.CheckAccess()) + { + Avalonia.Threading.Dispatcher.UIThread.Post(() => OnLanguageChanged(sender, e)); + return; + } + UpdatePlayerAndRoomCount(); UpdateModsButtonText(); UpdateMadeByText(); UpdateLiveAlert(); diff --git a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs index fffa0a47..9101d1c6 100644 --- a/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/WhWzSettings.axaml.cs @@ -7,6 +7,7 @@ using WheelWizard.ApplicationLifecycle.Logging; using WheelWizard.Dolphin.Discovery; using WheelWizard.Dolphin.Paths; +using WheelWizard.Localization; using WheelWizard.Settings; using WheelWizard.Settings.Types; using WheelWizard.Shared.IO; @@ -21,7 +22,6 @@ namespace WheelWizard.Views.Pages.Settings; public partial class WhWzSettings : UserControl { // #todo: move settings state and location-change workflows into a view model so this page mostly handles the controls. - private readonly IMainWindowService _mainWindow; private readonly IApplicationLogFiles _logFiles; private sealed record LanguageDropdownItem(string Key, string DisplayName) @@ -40,22 +40,15 @@ private sealed record LanguageDropdownItem(string Key, string DisplayName) private ISettingsManager SettingsService { get; } - private ISettingsLocalizationService LocalizationService { get; } - - private IDolphinSettingManager DolphinSettingsService { get; } - private IDolphinDiscoveryService DolphinDiscovery { get; } private IApplicationDataLocation ApplicationData { get; } public WhWzSettings( IApplicationLogFiles logFiles, - IMainWindowService mainWindow, IFilePickerService filePicker, IDolphinPaths dolphinPaths, ISettingsManager settingsService, - ISettingsLocalizationService localizationService, - IDolphinSettingManager dolphinSettingsService, IDolphinDiscoveryService dolphinDiscovery, IApplicationDataLocation applicationData ) @@ -63,11 +56,8 @@ IApplicationDataLocation applicationData FilePicker = filePicker; DolphinPaths = dolphinPaths; SettingsService = settingsService; - LocalizationService = localizationService; - DolphinSettingsService = dolphinSettingsService; DolphinDiscovery = dolphinDiscovery; ApplicationData = applicationData; - _mainWindow = mainWindow; _logFiles = logFiles; InitializeComponent(); ConfigureLocationFieldsForActiveFrontend(); @@ -79,6 +69,33 @@ IApplicationDataLocation applicationData WhWzLanguageDropdown.SelectionChanged += WhWzLanguageDropdown_OnSelectionChanged; } + protected override void OnLoaded(RoutedEventArgs e) + { + base.OnLoaded(e); + LocalizationProvider.LanguageChanged += OnLanguageChanged; + } + + protected override void OnUnloaded(RoutedEventArgs e) + { + LocalizationProvider.LanguageChanged -= OnLanguageChanged; + base.OnUnloaded(e); + } + + private void OnLanguageChanged(object? sender, EventArgs args) + { + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(() => OnLanguageChanged(sender, args)); + return; + } + RefreshLanguageDropdown(); + RefreshLocalizedCodeText(); + RefreshScaleDropdown(); + ConfigureLocationFieldsForActiveFrontend(); + UpdateLocationRows(); + UpdateAppDataLocationUi(); + } + private void ConfigureLocationFieldsForActiveFrontend() { var recompEnabled = SettingsService.IsRecompModeActive(); @@ -98,23 +115,29 @@ private void LoadSettings() RefreshLanguageDropdown(); RefreshLocalizedCodeText(); - // ----------------- - // Window Scale settings - // ----------------- - // IMPORTANT: Make sure that the number and percentage is always the last word in the string, - // If you don't want this, you should change the code below that parses the string back to an actual value + RefreshScaleDropdown(); - foreach (var scale in SettingValues.WindowScales) + EnableAnimations.IsChecked = (bool)SettingsService.ENABLE_ANIMATIONS.Get(); + } + + private void RefreshScaleDropdown() + { + var wasEditing = _editingScale; + _editingScale = true; + try { - WindowScaleDropdown.Items.Add(ScaleToString(scale)); + WindowScaleDropdown.Items.Clear(); + foreach (var scale in SettingValues.WindowScales) + WindowScaleDropdown.Items.Add(ScaleToString(scale)); + var selected = ScaleToString(SettingsService.WINDOW_SCALE.Get()); + if (!WindowScaleDropdown.Items.Contains(selected)) + WindowScaleDropdown.Items.Add(selected); + WindowScaleDropdown.SelectedItem = selected; + } + finally + { + _editingScale = wasEditing; } - - var selectedItemText = ScaleToString((double)SettingsService.WINDOW_SCALE.Get()); - if (!WindowScaleDropdown.Items.Contains(selectedItemText)) - WindowScaleDropdown.Items.Add(selectedItemText); - WindowScaleDropdown.SelectedItem = selectedItemText; - - EnableAnimations.IsChecked = (bool)SettingsService.ENABLE_ANIMATIONS.Get(); } private void RefreshLanguageDropdown() @@ -833,13 +856,8 @@ private async void WhWzLanguageDropdown_OnSelectionChanged(object? sender, Selec return; // We only want to change the setting if we really apply this change } - if (SettingsEditing.Set(SettingsService, SettingsService.WW_LANGUAGE, selectedLanguage.Key)) - { - LocalizationService.ApplyCurrentLanguage(); - RefreshLanguageDropdown(); - RefreshLocalizedCodeText(); - _mainWindow.Refresh(); - } + SettingsEditing.Set(SettingsService, SettingsService.WW_LANGUAGE, selectedLanguage.Key); + RefreshLanguageDropdown(); } private void EnableAnimations_OnClick(object sender, RoutedEventArgs e) =>