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
+51 -28
View File
@@ -2,6 +2,7 @@ using System.Collections.ObjectModel;
using System.Globalization;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.UI.Localization;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using ReactiveUI.Primitives;
@@ -49,11 +50,11 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
/// <summary>Protocol filter; <see cref="ProxyProtocolFilter.All"/> means no filtering.</summary>
[Reactive]
public partial ProxyProtocolFilter ProtocolFilter { get; set; }
public partial LocalizedOption<ProxyProtocolFilter> ProtocolFilter { get; set; }
/// <summary>Health filter.</summary>
[Reactive]
public partial ProxyHealthFilter HealthFilter { get; set; }
public partial LocalizedOption<ProxyHealthFilter> HealthFilter { get; set; }
/// <summary>Text box contents for adding custom proxies.</summary>
[Reactive]
@@ -106,8 +107,8 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
SearchText = string.Empty;
NewProxies = string.Empty;
ProtocolFilter = ProxyProtocolFilter.All;
HealthFilter = ProxyHealthFilter.All;
ProtocolFilter = ProtocolFilters[0];
HealthFilter = HealthFilters[0];
var idle = this.WhenAnyValue(x => x.IsSweeping).Select(static sweeping => !sweeping);
@@ -147,7 +148,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
}
/// <inheritdoc />
public override string Title => "Proxies";
public override string TitleKey => "Page.Proxies";
/// <inheritdoc />
public override string IconKey => "IconShield";
@@ -156,18 +157,18 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
public ObservableCollection<ProxyRowViewModel> Proxies { get; } = [];
/// <summary>Protocol filter options.</summary>
public IReadOnlyList<ProxyProtocolFilter> ProtocolFilters { get; } =
[
ProxyProtocolFilter.All,
ProxyProtocolFilter.Http,
ProxyProtocolFilter.Https,
ProxyProtocolFilter.Socks4,
ProxyProtocolFilter.Socks5,
];
public IReadOnlyList<LocalizedOption<ProxyProtocolFilter>> ProtocolFilters { get; } =
LocalizedOption<ProxyProtocolFilter>.For(
ProxyProtocolFilter.All,
ProxyProtocolFilter.Http,
ProxyProtocolFilter.Https,
ProxyProtocolFilter.Socks4,
ProxyProtocolFilter.Socks5
);
/// <summary>Health filter options.</summary>
public IReadOnlyList<ProxyHealthFilter> HealthFilters { get; } =
[ProxyHealthFilter.All, ProxyHealthFilter.Alive, ProxyHealthFilter.Dead, ProxyHealthFilter.Unchecked];
public IReadOnlyList<LocalizedOption<ProxyHealthFilter>> HealthFilters { get; } =
LocalizedOption<ProxyHealthFilter>.ForAll();
/// <summary>Reloads every source into the pool.</summary>
public ReactiveCommand<RxVoid, RxVoid> RefreshCommand { get; }
@@ -187,7 +188,12 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
private async Task RefreshAsync(CancellationToken cancellationToken)
{
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = $"Pool holds {Format(count)} prox{(count == 1 ? "y" : "ies")}.");
var message = Localizer.Instance.Format(
"Proxies.Status.PoolHolds",
Localizer.Instance.Plural("Proxies.Count.Proxies", count)
);
OnUi(() => StatusMessage = message);
}
private async Task SweepAsync(CancellationToken cancellationToken)
@@ -204,12 +210,13 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
var progress = new Progress<ProxySweepProgress>(value => OnUi(() => SweepProgress = value.Fraction));
var alive = await _pool.SweepAsync(progress, cancellationToken).ConfigureAwait(false);
var total = _pool.Entries.Count;
var message = Localizer.Instance.Format("Proxies.Status.Answered", Format(alive), Format(total));
OnUi(() => StatusMessage = $"{Format(alive)} of {Format(total)} answered.");
OnUi(() => StatusMessage = message);
}
catch (OperationCanceledException)
{
OnUi(() => StatusMessage = "Check cancelled.");
OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.CheckCancelled"]);
}
finally
{
@@ -225,24 +232,28 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
{
var (parsed, rejected) = CustomProxySource.ParseList(NewProxies);
var loc = Localizer.Instance;
if (parsed.Count == 0 && rejected.Count == 0)
{
OnUi(() => StatusMessage = "Nothing to add.");
OnUi(() => StatusMessage = loc["Proxies.Status.Nothing"]);
return;
}
var added = await _customSource.AddAsync(parsed, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
var message = $"Added {Format(added)} of {Format(parsed.Count)}.";
var message = loc.Format("Proxies.Status.Added", Format(added), Format(parsed.Count));
if (rejected.Count > 0)
{
// Naming the first few beats "3 lines were invalid" when a paste is hundreds long.
message += $" Could not parse: {string.Join(", ", rejected.Take(3))}";
var sample = string.Join(", ", rejected.Take(3));
if (rejected.Count > 3)
{
message += $" and {Format(rejected.Count - 3)} more";
sample += " " + loc.Format("Proxies.Status.RejectedMore", Format(rejected.Count - 3));
}
message += " " + loc.Format("Proxies.Status.Rejected", sample);
}
OnUi(() =>
@@ -265,7 +276,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
await _customSource.RemoveAsync(row.Entry.Endpoint, cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = $"Removed {row.Address}.");
OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Removed", row.Address));
}
private async Task ClearCustomAsync(CancellationToken cancellationToken)
@@ -273,7 +284,19 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
await _customSource.ClearAsync(cancellationToken).ConfigureAwait(false);
await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);
OnUi(() => StatusMessage = "Custom list cleared.");
OnUi(() => StatusMessage = Localizer.Instance["Proxies.Status.Cleared"]);
}
/// <inheritdoc />
protected override void OnLanguageChanged()
{
base.OnLanguageChanged();
// Health and source captions live on the rows, so they need the nudge individually.
foreach (var row in _rows.Values)
{
row.Refresh();
}
}
/// <inheritdoc />
@@ -333,14 +356,14 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
private bool PassesFilters(ProxyRowViewModel row)
{
if (
ProtocolFilter != ProxyProtocolFilter.All
&& !ProtocolFilter.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol))
ProtocolFilter.Value != ProxyProtocolFilter.All
&& !ProtocolFilter.Value.HasFlag(ProxyOptions.ToFlag(row.Entry.Endpoint.Protocol))
)
{
return false;
}
var healthOk = HealthFilter switch
var healthOk = HealthFilter.Value switch
{
ProxyHealthFilter.Alive => row.Entry.Health == ProxyHealthState.Alive,
ProxyHealthFilter.Dead => row.Entry.Health == ProxyHealthState.Dead,
@@ -354,7 +377,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
private void OnCommandFailed(Exception exception)
{
_logger.LogError(exception, "Proxy action failed");
OnUi(() => StatusMessage = $"Failed: {exception.Message}");
OnUi(() => StatusMessage = Localizer.Instance.Format("Proxies.Status.Failed", exception.Message));
}
private static string Format(int value) => value.ToString("N0", CultureInfo.CurrentCulture);