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 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,
|
||||
|
||||
Reference in New Issue
Block a user