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
+19
View File
@@ -5,6 +5,25 @@ namespace AvParser.Core.Parsing;
/// <param name="Message">What went wrong, phrased for a user rather than a developer.</param>
public sealed record ParseError(int LineNumber, string Message)
{
/// <summary>
/// Stable identifier for the kind of failure, or <see langword="null"/> when the message is
/// the only thing on offer.
/// </summary>
/// <remarks>
/// The domain stays language-free: it produces an English message plus a code, and the UI
/// translates <c>Parse.Error.{Code}</c> with <see cref="Arguments"/>, falling back to
/// <see cref="Message"/>. Without this, a Russian UI would still print English error text —
/// and moving the strings themselves into the domain would drag localisation down there.
/// </remarks>
public string? Code { get; init; }
/// <summary>Values to substitute into the translated message.</summary>
public IReadOnlyList<object?> Arguments { get; init; } = [];
/// <summary>Creates an error carrying a translation code.</summary>
public static ParseError Create(int lineNumber, string code, string message, params object?[] arguments) =>
new(lineNumber, message) { Code = code, Arguments = arguments };
/// <inheritdoc />
public override string ToString() => $"Line {LineNumber}: {Message}";
}