Localise the UI into Russian and English, switchable without a restart

Strings move into Localization/Strings.resx plus a Russian satellite. XAML uses
a {l:Loc Key} markup extension that yields a binding through the localizer's
indexer rather than a resolved string, so changing the language raises
PropertyChanged for the indexer and every caption in the app re-reads at once.
Resolving strings once at load would have been simpler and would have needed an
app restart to take effect.

Three places where this is more than a string swap:

Russian has three plural forms, so counted messages are assembled from .One /
.Few / .Many keys via Localizer.Plural instead of "{0} records" with an English
plural glued on. "1 запись", "3 записи", "7 записей".

Enum captions in pickers go through LocalizedOption<T>. A value converter would
resolve the caption once and never notice a language change; the wrapper keeps
identity on the enum value so the selection survives, while the label follows
the localizer. It needed a non-generic base for DataTemplate x:DataType, since
Avalonia 12 compiles bindings by default and cannot infer one for an open
generic.

Text originating in the domain would otherwise have stayed English under a
Russian UI — the screenshots showed exactly that. AvParser.Core still knows
nothing about languages: ParseError now carries a Code and Arguments, and the UI
translates Parse.Error.{Code} with a fallback to the English message. Parser
names work the same way (Parser.{id}.Name falling back to DisplayName), which
keeps "add a parser = one registration line" true — an untranslated parser shows
its own name rather than a missing-key marker.

Both .resx files are generated from one table so a key cannot exist in one and
be missing from the other, and the tests assert that, plus no blank translations
and identical {0} placeholder sets — a translation that drops a placeholder
throws at runtime rather than merely reading oddly. A headless test switches
language on a live shell and asserts the rendered text changes without the tree
being rebuilt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 18:15:01 +03:00
co-authored by Claude Opus 5
parent 9bf2ea5532
commit 8b552470b7
42 changed files with 2216 additions and 226 deletions
+68 -29
View File
@@ -2,6 +2,7 @@ using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Logging;
using AvParser.Infrastructure.Storage;
using AvParser.UI.Localization;
using AvParser.UI.Responsive;
using AvParser.UI.Services;
using ReactiveUI;
@@ -19,10 +20,15 @@ public partial class SettingsViewModel : PageViewModel
private readonly IThemeService _theme;
private readonly LoggingLevelSwitch _levelSwitch;
private readonly IProxyPool _proxyPool;
private readonly ILocalizationService _localization;
/// <summary>Selected theme. Applied immediately, not on an OK button.</summary>
[Reactive]
public partial AppTheme SelectedTheme { get; set; }
public partial LocalizedOption<AppTheme> SelectedTheme { get; set; }
/// <summary>Selected UI language. Applied immediately.</summary>
[Reactive]
public partial LocalizedOption<AppLanguage> SelectedLanguage { get; set; }
/// <summary>Selected Serilog level name. Takes effect immediately.</summary>
[Reactive]
@@ -30,11 +36,11 @@ public partial class SettingsViewModel : PageViewModel
/// <summary>How the pool picks the next proxy.</summary>
[Reactive]
public partial ProxyRotation SelectedRotation { get; set; }
public partial LocalizedOption<ProxyRotation> SelectedRotation { get; set; }
/// <summary>When proxy liveness is verified.</summary>
[Reactive]
public partial ProxyHealthCheck SelectedHealthCheck { get; set; }
public partial LocalizedOption<ProxyHealthCheck> SelectedHealthCheck { get; set; }
/// <summary>Whether the remote proxy feed is consulted.</summary>
[Reactive]
@@ -59,6 +65,7 @@ public partial class SettingsViewModel : PageViewModel
IAppPaths paths,
LoggingLevelSwitch levelSwitch,
IProxyPool proxyPool,
ILocalizationService localization,
ISequencer? mainThread = null
)
{
@@ -66,6 +73,7 @@ public partial class SettingsViewModel : PageViewModel
_theme = theme ?? throw new ArgumentNullException(nameof(theme));
_levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch));
_proxyPool = proxyPool ?? throw new ArgumentNullException(nameof(proxyPool));
_localization = localization ?? throw new ArgumentNullException(nameof(localization));
ArgumentNullException.ThrowIfNull(paths);
var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler;
@@ -73,26 +81,31 @@ public partial class SettingsViewModel : PageViewModel
SettingsFile = paths.SettingsFile;
LogDirectory = paths.LogDirectory;
SelectedTheme = theme.Current;
SelectedTheme = Option(Themes, theme.Current);
SelectedLanguage = Option(Languages, localization.Current);
SelectedLogLevel = settings.Current.MinimumLogLevel;
var current = settings.Current;
SelectedRotation = current.ProxyRotation;
SelectedHealthCheck = current.ProxyHealthCheck;
SelectedRotation = Option(Rotations, current.ProxyRotation);
SelectedHealthCheck = Option(HealthChecks, current.ProxyHealthCheck);
UseProxyFeed = current.ProxyUseFeed;
ProxyProbeUrl = current.ProxyProbeUrl;
ProxyProbeTimeoutSeconds = current.ProxyProbeTimeoutSeconds;
ProxyProbeConcurrency = current.ProxyProbeConcurrency;
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(_theme.Apply);
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(option => _theme.Apply(option.Value));
this.WhenAnyValue(x => x.SelectedLanguage)
.ObserveOn(scheduler)
.Subscribe(option => _localization.Apply(option.Value));
this.WhenAnyValue(x => x.SelectedLogLevel)
.Where(static level => !string.IsNullOrEmpty(level))
.DistinctUntilChanged()
.Subscribe(ApplyLogLevel);
// Keep the radio group honest when the theme is flipped from the title-bar button.
theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = value);
// Keep the picker honest when the theme is flipped from the title-bar button.
theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = Option(Themes, value));
// Every proxy knob funnels through one handler: they all end up rebuilding the same
// ProxyOptions, and applying them one at a time would reconfigure the pool six times.
@@ -117,39 +130,42 @@ public partial class SettingsViewModel : PageViewModel
}
/// <inheritdoc />
public override string Title => "Settings";
public override string TitleKey => "Page.Settings";
/// <inheritdoc />
public override string IconKey => "IconSettings";
/// <summary>Theme options offered by the radio group.</summary>
public IReadOnlyList<AppTheme> Themes { get; } = [AppTheme.System, AppTheme.Light, AppTheme.Dark];
/// <summary>Theme options offered by the picker.</summary>
public IReadOnlyList<LocalizedOption<AppTheme>> Themes { get; } = LocalizedOption<AppTheme>.ForAll();
/// <summary>Language options offered by the picker.</summary>
public IReadOnlyList<LocalizedOption<AppLanguage>> Languages { get; } = LocalizedOption<AppLanguage>.ForAll();
/// <summary>Serilog level names, most to least verbose.</summary>
public IReadOnlyList<string> LogLevels => AppLogging.AvailableLevels;
/// <summary>Rotation strategies offered by the picker.</summary>
public IReadOnlyList<ProxyRotation> Rotations { get; } =
[ProxyRotation.Sticky, ProxyRotation.RoundRobin, ProxyRotation.WeightedRandom];
public IReadOnlyList<LocalizedOption<ProxyRotation>> Rotations { get; } = LocalizedOption<ProxyRotation>.ForAll();
/// <summary>Liveness policies offered by the picker.</summary>
public IReadOnlyList<ProxyHealthCheck> HealthChecks { get; } = [ProxyHealthCheck.Pool, ProxyHealthCheck.Lazy];
public IReadOnlyList<LocalizedOption<ProxyHealthCheck>> HealthChecks { get; } =
LocalizedOption<ProxyHealthCheck>.ForAll();
/// <summary>Explains the selected rotation in one line.</summary>
public string RotationDescription =>
SelectedRotation switch
{
ProxyRotation.Sticky => "One proxy per session, replaced only when it fails. Keeps site sessions intact.",
ProxyRotation.RoundRobin =>
"A different proxy on every request. Spreads rate limits, but breaks session cookies.",
_ => "Random, weighted by feed score and how often the proxy has actually worked here.",
};
public string RotationDescription => Localizer.Instance[$"Settings.Rotation.{SelectedRotation.Value}"];
/// <summary>Explains the selected health-check policy in one line.</summary>
public string HealthCheckDescription =>
SelectedHealthCheck == ProxyHealthCheck.Pool
? "Probe the whole pool up front, in parallel. One sweep, then no per-request delay."
: "Probe each proxy as it is handed out. No sweep, but every acquisition pays a round trip.";
public string HealthCheckDescription => Localizer.Instance[$"Settings.HealthCheck.{SelectedHealthCheck.Value}"];
/// <summary>Describes the compact breakpoint band.</summary>
public string CompactHint => Localizer.Instance.Format("Settings.Breakpoint.CompactHint", MediumBreakpoint);
/// <summary>Describes the medium breakpoint band.</summary>
public string MediumHint =>
Localizer.Instance.Format("Settings.Breakpoint.MediumHint", MediumBreakpoint, ExpandedBreakpoint);
/// <summary>Describes the expanded breakpoint band.</summary>
public string ExpandedHint => Localizer.Instance.Format("Settings.Breakpoint.ExpandedHint", ExpandedBreakpoint);
/// <summary>Full path of the settings file.</summary>
public string SettingsFile { get; }
@@ -163,6 +179,29 @@ public partial class SettingsViewModel : PageViewModel
/// <summary>Width in pixels at which the shell switches to the full sidebar.</summary>
public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth;
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
foreach (
var name in new[]
{
nameof(RotationDescription),
nameof(HealthCheckDescription),
nameof(CompactHint),
nameof(MediumHint),
nameof(ExpandedHint),
}
)
{
this.RaisePropertyChanged(name);
}
}
private static LocalizedOption<TValue> Option<TValue>(IReadOnlyList<LocalizedOption<TValue>> options, TValue value)
where TValue : struct, Enum => options.First(option => option.Value.Equals(value));
private void ApplyLogLevel(string level)
{
_levelSwitch.MinimumLevel = AppLogging.ParseLevel(level);
@@ -177,8 +216,8 @@ public partial class SettingsViewModel : PageViewModel
{
applied = current with
{
ProxyRotation = SelectedRotation,
ProxyHealthCheck = SelectedHealthCheck,
ProxyRotation = SelectedRotation.Value,
ProxyHealthCheck = SelectedHealthCheck.Value,
ProxyUseFeed = UseProxyFeed,
ProxyProbeUrl = ProxyProbeUrl,
ProxyProbeTimeoutSeconds = ProxyProbeTimeoutSeconds,