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}";
}
@@ -37,4 +37,8 @@ public readonly record struct ParseOutcome<T>
/// <summary>Creates a failed outcome from its parts.</summary>
public static ParseOutcome<T> Failure(int lineNumber, string message) =>
Failure(new ParseError(lineNumber, message));
/// <summary>Creates a failed outcome carrying a translation code.</summary>
public static ParseOutcome<T> Failure(int lineNumber, string code, string message, params object?[] arguments) =>
Failure(ParseError.Create(lineNumber, code, message, arguments));
}
@@ -69,7 +69,10 @@ public sealed class DelimitedTextParser : ITextParser
{
yield return ParseOutcome<ParsedRecord>.Failure(
lineNumber,
$"Expected {header.Length} field(s) but found {values.Length}."
"FieldCount",
$"Expected {header.Length} field(s) but found {values.Length}.",
header.Length,
values.Length
);
}
else
@@ -93,7 +96,7 @@ public sealed class DelimitedTextParser : ITextParser
if (header is null)
{
yield return ParseOutcome<ParsedRecord>.Failure(1, "Input contains no header row.");
yield return ParseOutcome<ParsedRecord>.Failure(1, "NoHeader", "Input contains no header row.");
}
progress?.Report(new ParseProgress(total, total));
@@ -51,7 +51,11 @@ public sealed class KeyValueTextParser : ITextParser
if (separatorIndex <= 0)
{
yield return ParseOutcome<ParsedRecord>.Failure(lineNumber, "No '=' or ':' separator found.");
yield return ParseOutcome<ParsedRecord>.Failure(
lineNumber,
"NoSeparator",
"No '=' or ':' separator found."
);
}
else
{
@@ -59,7 +63,7 @@ public sealed class KeyValueTextParser : ITextParser
var value = line[(separatorIndex + 1)..].Trim();
yield return key.Length == 0
? ParseOutcome<ParsedRecord>.Failure(lineNumber, "Key is empty.")
? ParseOutcome<ParsedRecord>.Failure(lineNumber, "EmptyKey", "Key is empty.")
: ParseOutcome<ParsedRecord>.Success(
new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)])
);
+45
View File
@@ -0,0 +1,45 @@
using System.Globalization;
namespace AvParser.Core.Settings;
/// <summary>UI language. <see cref="System"/> follows the operating system.</summary>
public enum AppLanguage
{
/// <summary>Follow the operating system, falling back to English.</summary>
System = 0,
/// <summary>English.</summary>
English = 1,
/// <summary>Russian.</summary>
Russian = 2,
}
/// <summary>Maps <see cref="AppLanguage"/> onto cultures.</summary>
public static class AppLanguages
{
/// <summary>Languages the app ships translations for.</summary>
public static IReadOnlyList<AppLanguage> All { get; } =
[AppLanguage.System, AppLanguage.English, AppLanguage.Russian];
/// <summary>
/// Resolves a language to the culture the resource lookup should use.
/// </summary>
/// <remarks>
/// <see cref="AppLanguage.System"/> resolves to Russian only when the OS is actually Russian;
/// anything else lands on English, because those are the two translations that exist and a
/// half-translated German would be worse than plain English.
/// </remarks>
public static CultureInfo ToCulture(AppLanguage language, CultureInfo? systemCulture = null) =>
language switch
{
AppLanguage.English => CultureInfo.GetCultureInfo("en"),
AppLanguage.Russian => CultureInfo.GetCultureInfo("ru"),
_ => FromSystem(systemCulture ?? CultureInfo.CurrentUICulture),
};
private static CultureInfo FromSystem(CultureInfo culture) =>
string.Equals(culture.TwoLetterISOLanguageName, "ru", StringComparison.OrdinalIgnoreCase)
? CultureInfo.GetCultureInfo("ru")
: CultureInfo.GetCultureInfo("en");
}
@@ -27,6 +27,9 @@ public sealed record AppSettings
/// <summary>Chosen theme variant.</summary>
public AppTheme Theme { get; init; } = AppTheme.System;
/// <summary>Chosen UI language.</summary>
public AppLanguage Language { get; init; } = AppLanguage.System;
/// <summary>Id of the parser selected last time; resolved leniently on load.</summary>
public string? LastParserId { get; init; }