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
@@ -4,6 +4,7 @@ using System.Globalization;
|
||||
using System.Text;
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Primitives;
|
||||
@@ -42,7 +43,7 @@ public partial class ParseViewModel : PageViewModel
|
||||
|
||||
/// <summary>Parser applied by <see cref="ParseCommand"/>.</summary>
|
||||
[Reactive]
|
||||
public partial ITextParser SelectedParser { get; set; }
|
||||
public partial ParserViewModel SelectedParser { get; set; }
|
||||
|
||||
/// <summary>Completion of the running parse, 0.0 to 1.0.</summary>
|
||||
[Reactive]
|
||||
@@ -73,7 +74,8 @@ public partial class ParseViewModel : PageViewModel
|
||||
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
|
||||
|
||||
InputText = string.Empty;
|
||||
SelectedParser = catalog.FindOrDefault(settings.Current.LastParserId);
|
||||
Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))];
|
||||
SelectedParser = Parsers.First(parser => parser.Id == catalog.FindOrDefault(settings.Current.LastParserId).Id);
|
||||
|
||||
var canParse = this.WhenAnyValue(x => x.InputText)
|
||||
.Select(static text => !string.IsNullOrWhiteSpace(text))
|
||||
@@ -118,19 +120,19 @@ public partial class ParseViewModel : PageViewModel
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Title => "Parse";
|
||||
public override string TitleKey => "Page.Parse";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string IconKey => "IconDocument";
|
||||
|
||||
/// <summary>Every registered parser, for the picker.</summary>
|
||||
public IReadOnlyList<ITextParser> Parsers => _catalog.Parsers;
|
||||
public IReadOnlyList<ParserViewModel> Parsers { get; }
|
||||
|
||||
/// <summary>Successfully parsed records, capped at <see cref="MaxDisplayedRecords"/>.</summary>
|
||||
public ObservableCollection<ParsedRecord> Records { get; } = [];
|
||||
|
||||
/// <summary>Per-line failures. A failure never aborts the parse.</summary>
|
||||
public ObservableCollection<ParseError> Errors { get; } = [];
|
||||
public ObservableCollection<ParseErrorViewModel> Errors { get; } = [];
|
||||
|
||||
/// <summary>Whether a parse is currently running.</summary>
|
||||
public bool IsBusy => _isBusy.Value;
|
||||
@@ -155,7 +157,7 @@ public partial class ParseViewModel : PageViewModel
|
||||
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken);
|
||||
_cancellation = cancellation;
|
||||
|
||||
var parser = SelectedParser;
|
||||
var parser = SelectedParser.Parser;
|
||||
var input = InputText;
|
||||
var token = cancellation.Token;
|
||||
|
||||
@@ -228,29 +230,33 @@ public partial class ParseViewModel : PageViewModel
|
||||
);
|
||||
}
|
||||
|
||||
private static string BuildSummary(int succeeded, int failed, TimeSpan elapsed, bool truncated, bool cancelled)
|
||||
/// <summary>
|
||||
/// Builds the outcome line out of translated fragments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Assembled from plural-aware pieces rather than one format string per case: Russian needs
|
||||
/// three forms for a counted noun, so "{0} records" with an English plural glued on cannot be
|
||||
/// translated correctly.
|
||||
/// </remarks>
|
||||
internal static string BuildSummary(int succeeded, int failed, TimeSpan elapsed, bool truncated, bool cancelled)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
text.Append(cancelled ? "Cancelled after " : "Parsed ");
|
||||
text.Append(succeeded.ToString("N0", CultureInfo.CurrentCulture));
|
||||
text.Append(succeeded == 1 ? " record" : " records");
|
||||
var loc = Localizer.Instance;
|
||||
var records = loc.Plural("Parse.Count.Records", succeeded);
|
||||
var milliseconds = elapsed.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture);
|
||||
|
||||
var text = loc.Format(cancelled ? "Parse.Status.Cancelled" : "Parse.Status.Done", records, milliseconds);
|
||||
|
||||
if (failed > 0)
|
||||
{
|
||||
text.Append(", ").Append(failed.ToString("N0", CultureInfo.CurrentCulture));
|
||||
text.Append(failed == 1 ? " error" : " errors");
|
||||
text = loc.Format("Parse.Status.WithErrors", text.TrimEnd('.'), loc.Plural("Parse.Count.Errors", failed));
|
||||
}
|
||||
|
||||
text.Append(" in ").Append(elapsed.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture)).Append(" ms");
|
||||
|
||||
if (truncated)
|
||||
{
|
||||
text.Append(" — showing the first ")
|
||||
.Append(MaxDisplayedRecords.ToString("N0", CultureInfo.CurrentCulture))
|
||||
.Append(" only");
|
||||
text += " " + loc.Format("Parse.Status.Truncated", loc.Plural("Parse.Count.Records", MaxDisplayedRecords));
|
||||
}
|
||||
|
||||
return text.Append('.').ToString();
|
||||
return text;
|
||||
}
|
||||
|
||||
private void FlushBuffers(List<ParsedRecord> records, List<ParseError> errors)
|
||||
@@ -275,11 +281,25 @@ public partial class ParseViewModel : PageViewModel
|
||||
|
||||
foreach (var error in errorBatch)
|
||||
{
|
||||
Errors.Add(error);
|
||||
Errors.Add(new ParseErrorViewModel(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnLanguageChanged()
|
||||
{
|
||||
base.OnLanguageChanged();
|
||||
|
||||
// The summary and the listed errors were both rendered in the previous language.
|
||||
StatusMessage = null;
|
||||
|
||||
foreach (var error in Errors)
|
||||
{
|
||||
error.Refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearResults()
|
||||
{
|
||||
Records.Clear();
|
||||
@@ -289,7 +309,7 @@ public partial class ParseViewModel : PageViewModel
|
||||
private void OnCommandFailed(Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "Parse failed");
|
||||
OnUi(() => StatusMessage = $"Parse failed: {exception.Message}");
|
||||
OnUi(() => StatusMessage = Localizer.Instance.Format("Parse.Status.Failed", exception.Message));
|
||||
}
|
||||
|
||||
/// <summary>Marshals a mutation onto the UI thread; the parse loop runs on the thread pool.</summary>
|
||||
|
||||
Reference in New Issue
Block a user