Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added: Localization Backend & Documentation (fixes #452) #563

Merged
merged 12 commits into from
Aug 21, 2023
Merged
156 changes: 156 additions & 0 deletions docs/LocalizationAndTranslation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Localization and Translation in the Nexus App

For more details, see [0007: Localization and Internationalisation](./decisions/backend/0007-localization-and-internationalisation.md).

In the Nexus App we use the industry standard `Resource` files for porting the App to different languages and cultures.

This involves creating a relevant `.resx` file in every project for the base language, and additional `.resx` files for any additional language you want to support.

Working with these files requires a supported IDE such as Rider or Visual Studio

## Creating a New Language File

- Create a folder called `Resources`, if it does not already exist.
- Create a Resource file (`Add` -> `Resources`), name it `Language.resx`.

```
Resources/Language.resx
```

Doubleclicking this file in a supported IDE will bring you to the `Resource Editor`, which will allow you to edit the resources file.

### Additional Steps

For elements which may be referenced from the UI (Avalonia), an additional step is required.

When you create a Resource file from Within Rider, it will be set to `internal` by default. This is okay for code, but is not okay for XAML; as XAML needs those elements public.

To make these elements public, open the project's `.csproj` file; find the `.resx` file and change `ResXFileCodeGenerator` to `PublicResXFileCodeGenerator` such that you will see:

```
<EmbeddedResource Update="Resources\Language.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Language.Designer.cs</LastGenOutput>
</EmbeddedResource>
```

You will then need to perform an action that would trigger a rebuild of `Language.Designer.cs`. This could be temporarily adding or renaming a key. These files are (unfortunately), not autogenerated as part of MSBuild.

## Adding a new Language

This should be supported as a button in your IDE (in Rider it is top left of 'Localization Manager', above the project listing).

If it is not natively supported, create a resource file using the naming convention:

```
Language.{culture}.resx
```

Manually. For example `Language.de.resx`. Both shorthands e.g. `de` (German) and full localized cultures e.g. `de-de` (German, Germany) are supported.

## Using Localized Strings

### Reference Text from C#

#### Basic Referencing

The items are exposed as static fields of the `Language` class; i.e.

```csharp
Language.MyGames
```

If you are in a context where the language cannot be dynamically changed (for example: in a dialog box that must be closed before the user can change the language again), using the static property is sufficient.


#### Formatted Text

In some cases, you might have to use `string.Format` to inject parameters into the text:

```
// Language.Hello is "Hello {0}"
string.Format(Language.Hello, user)
```

#### Updating Strings Dynamically

If you have a string in C# which is long lived, and will be live when the language is changed; it must be updated dynamically.

For this purpose, the `LocalizedStringUpdater` class is provided; it is used like this:

```csharp
_localizable = new LocalizedStringUpdater(() => ReactiveField = Language.MyGames);
```

To give an example:

```csharp
public class SomeViewModel : AViewModel<ISomeViewModel>, ISomeViewModel, IDisposable
{
[Reactive]
public string Name { get; set; } = "";

private readonly LocalizedStringUpdater _nameUpdater;

public SomeViewModel(Func<string> getName)
{
_nameUpdater = new LocalizedStringUpdater(() => Name = getName());
}

// Note: Multi-dispose guard not needed; LocalizedStringUpdater.Dispose by contract only unsubscribes from an event.
public void Dispose() => _nameUpdater.Dispose();
}
```

When you call `new LocalizedStringUpdater` and every time the language is changed
through the use [Localizer.Instance.LoadLanguage](#switching-a-language), the callback given in the `LocalizedStringUpdater`
will be executed.

As for the `[Reactive]` fields, the autogenerated `INotifyPropertyChanged` handlers will always
do a comparison on the string in the setter. So if a string remains unchanged, no UI redraw will occur
for the affected element as `PropertyChanged` will not fire.

!!! note "The API is specifically designed like this to ensure compile time safety."

!!! danger "`LocalizedStringUpdater` must be properly disposed when no longer in use. Due to nature of .NET events, lack of proper disposal will lead to memory leak as the class subscribes to `Localizer` which has singleton lifetime."

##### Use within Reactive Code

When possible, use the `WhenActivated` ReactiveUI method; alongside `DisposeWith`. This will ensure that `LocalizedStringUpdater(s)` and structures which use them will be disposed safely.

```csharp
this.WhenActivated(disposable =>
{
var items = new ILeftMenuItemViewModel[]
{
new IconViewModel(() => Language.Newsfeed) { ... }.DisposeWith(disposable),
new IconViewModel(() => Language.MyGames) { ... }.DisposeWith(disposable),
new IconViewModel(() => Language.BrowseGames) { ... }.DisposeWith(disposable)
};
});
```

### Reference from XAML

Use the custom `LocalizedExtension` markup extension to reference a localizable string.

```xaml
<TextBlock Text="{localization:Localized HelloWorld}"/>
```

If formatting is required, please do so in the code behind for the element to be consistent with the existing Reactive based code.

### Overriding the Language at Boot Time

You can change the locale in `AppConfig.json` file.
This file is in the `NexusMods.App` project at time of writing and is copied to build directory.

### Switching a Language

Language can be switched at runtime with the following code:

```
Localizer.Instance.LoadLanguage(new CultureInfo(/* locale */));
```

We run this code at startup in `OnFrameworkInitializationCompleted` to set the language at startup.
105 changes: 105 additions & 0 deletions docs/decisions/backend/0007-localization-and-internationalisation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
```
status: accepted
date: {2023-08-15 when the decision was last updated}
deciders: App Team @ 2023-07-19 Meeting
```

# Internationalisation and Localisation

## Context and Problem Statement

Internationalisation and Localisation, referred to as `i18n` and `l10n` onwards, describe the process of adapting our code to be locale agnostic.

Currently, our UI contains hard-coded strings, meaning the values are constant and part of our code:

![Strings](./images/0007_unlocalised_strings.png)

Although hardcoding strings makes designing and implementing user interfaces straightforward, it also means that we force English as a UI language.

### Additional Details

`i18n` and `l10n` also encompass formatting rules for various constructs like numbers, date and time, currencies, as well as text layouts. English is left-to-right and top-to-bottom horizontally, but other languages read from right-to-left and some even vertically.

Text can also sometimes greatly vary in length. If a button has a text of length `10` in English, it might only contain two characters in Japanese or `20` in German. If the UI isn't designed or able to adapt to drastic changes in text, it will lead to text cutoff, undesired wrapping behavior or make the text completely illegible.

Another issue are fonts. Most fonts used on the web only support a limited amount of characters, typically based on Roman alphabet. CJK fonts (**C**hinese, **J**apanese, and **K**orean), like [Noto by Google](https://fonts.google.com/noto), are special fonts that contain a vast amount of characters from all parts of the world.

These are usually not required by websites, since browsers have fallback fonts, however, for applications that render text directly, such as ours, rendering an unsupported character might result in white boxes: https://github.com/jellyfin/jellyfin/issues/5008

![](https://user-images.githubusercontent.com/39822140/104345555-d5045c00-5541-11eb-953d-f3943a6c63ee.png)

## Decision Drivers

* Support Common Languages (EN, SP, CN) etc.
* Make Modding Easier (TM) [for non-native speakers].
* Editing translations for people must be easy.

During discussion, we decided that we only intend to support Left To Right languages (LTR), which makes it a non-driver in decision making.

This is down to the design overhead involved; as adding RTL support would require entirely new UI layouts/designs.

## Considered Options

* DIY Solution involving Loading ResourceDictionary per language.
* .resx files.
* Custom code-behind.

## Decision Outcome

Chosen option: `.resx files`.

### Consequences

* Good, because the solution is compile time safe.
* Good, because translators can use existing translation services (like [Weblate](https://docs.weblate.org/en/latest/formats/resx.html)).
* Bad, because translators cannot see the changes within the UI [without installing dev tools & recompiling].
* Bad, because `.resx` files can introduce minimal stutter as changing locale requires a new DLL load every load.

## Pros and Cons of the Options

### DIY Solution

This solution involves loading language translation through Avalonia ResourceDictionaries via `.axaml` files.

* Good, because translators can see new translations immediately.
* Good, because making translations requires no specialised tooling or recompilation (just a text editor).
* Good, because translation can be effortlessly changed on the fly.
* Bad, because this solution is not compile time safe.

### RESX files

We use resource `.resx` files, comprised of 1 default file, then 1 language per locale.

* Good, because this is familiar to many developers [industry standard], making it easier for external contributors.
* Good, because there is a lot of external 3rd party tooling to allow editing resource files for non-technical users.
* Good, because the solution is compile time safe.
* Bad, because translators cannot see their changes inside actual app when translating.

### Custom Code-Behind

A custom implementation that involves dynamically fetching string values which are read from either a file or embedded resource.

* Neutral, because this is basically doing the `.resx` solution, but with some manually created tooling.
* Bad because no convenient user tooling exists for this one.

## Common Drawbacks

In cases where we originally used string interpolation, such as

```csharp
$"Hello {user}"
```

This code would have to be changed to:

```csharp
// using resources (or equivalent)
// Resources.Hello is "Hello {0}"
string.Format(Resources.Hello, user);
```

The issue here is that `Hello {0}` injects a parameter into the `string.Format` function. When people PR/submit translations, we need to ensure that people correctly insert the format placeholders `{0}`, `{1}`, etc.

## More Information

- We will need to seek alternative fonts down the road for CJK support; since our design system with Roboto and Montserrat do not support such fonts.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/add_resx_file.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 10 additions & 2 deletions src/NexusMods.App.UI/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NexusMods.App.UI.Localization;
using NexusMods.App.UI.Resources;
using NexusMods.App.UI.Windows;
using ReactiveUI;
using Splat;
Expand All @@ -13,19 +16,24 @@ namespace NexusMods.App.UI;
public class App : Application
{
private readonly IServiceProvider _provider;
private readonly ILauncherSettings _launcherSettings;

public App(IServiceProvider provider)
public App(IServiceProvider provider, ILauncherSettings launcherSettings)
{
_provider = provider;
_launcherSettings = launcherSettings;
}

public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}

public override void OnFrameworkInitializationCompleted()
{
if (!string.IsNullOrEmpty(_launcherSettings.LocaleOverride))
Localizer.Instance.LoadLanguage(_launcherSettings.LocaleOverride);

Locator.CurrentMutable.UnregisterCurrent(typeof(IViewLocator));
Locator.CurrentMutable.Register(() => _provider.GetRequiredService<InjectedViewLocator>(), typeof(IViewLocator));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<reactiveUi:ReactiveUserControl
<reactiveUi:ReactiveUserControl
x:TypeArguments="download:IDownloadButtonViewModel"

xmlns="https://github.com/avaloniaui"
Expand All @@ -19,8 +19,8 @@
<Arc x:Name="ProgressArc"></Arc>
<avalonia:Icon Classes="Download"/>
<StackPanel>
<TextBlock Text="48" Classes="Number" x:Name="NumberTextBlock"/>
<TextBlock Text="MINS" Classes="Units" x:Name="UnitsTextBlock"/>
<TextBlock Classes="Number" x:Name="NumberTextBlock"/>
<TextBlock Classes="Units" x:Name="UnitsTextBlock"/>
</StackPanel>
</Grid>
</Button>
Expand Down
22 changes: 22 additions & 0 deletions src/NexusMods.App.UI/ILauncherSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using JetBrains.Annotations;

namespace NexusMods.App.UI;

[PublicAPI]
public interface ILauncherSettings
{
/// <summary>
/// Overrides the current locale of the application at startup.
/// </summary>
/// <remarks>If this value is empty, the locale will not be overwritten.</remarks>
public string LocaleOverride { get; set; }
}

[PublicAPI]
public class LauncherSettings : ILauncherSettings
{
public string LocaleOverride { get; set; } = string.Empty;

// ReSharper disable once EmptyConstructor
public LauncherSettings() { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using NexusMods.App.UI.Extensions;
using NexusMods.App.UI.Icons;
using NexusMods.App.UI.LeftMenu.Items;
using NexusMods.App.UI.Resources;
using NexusMods.App.UI.RightContent;
using NexusMods.DataModel.Games;
using ReactiveUI.Fody.Helpers;
Expand Down Expand Up @@ -32,9 +33,9 @@ public GameLeftMenuDesignViewModel()

var items = new ILeftMenuItemViewModel[]
{
new IconViewModel { Name = "Newsfeed", Icon = IconType.News},
new IconViewModel { Name = "My loadout 1", Icon = IconType.ChevronRight },
new IconViewModel { Name = "My other loadout", Icon = IconType.ChevronRight },
new IconViewModel(() => "Newsfeed") { Icon = IconType.News},
new IconViewModel(() => "My loadout 1") { Icon = IconType.ChevronRight },
new IconViewModel(() => "My other loadout") { Icon = IconType.ChevronRight },
};
Items = items.ToReadOnlyObservableCollection();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.ObjectModel;
using NexusMods.App.UI.Icons;
using NexusMods.App.UI.LeftMenu.Items;
using NexusMods.App.UI.Resources;
using NexusMods.App.UI.RightContent;

namespace NexusMods.App.UI.LeftMenu.Home;
Expand All @@ -14,9 +15,9 @@ public HomeLeftMenuDesignViewModel()
{
var items = new ILeftMenuItemViewModel[]
{
new IconViewModel { Name = "Newsfeed", Icon = IconType.News},
new IconViewModel { Name = "My Games", Icon = IconType.Bookmark },
new IconViewModel { Name = "Browse Games", Icon = IconType.Game }
new IconViewModel(() => Language.Newsfeed) { Icon = IconType.News},
new IconViewModel(() => Language.MyGames) { Icon = IconType.Bookmark },
new IconViewModel(() => Language.BrowseGames) { Icon = IconType.Game }
};
Items = new ReadOnlyObservableCollection<ILeftMenuItemViewModel>(new ObservableCollection<ILeftMenuItemViewModel>(items));
}
Expand Down
Loading
Loading