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:
co-authored by
Claude Opus 5
parent
9bf2ea5532
commit
8b552470b7
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user