From 8b552470b71c4b96862395da43d1bd0d93ec2386 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Thu, 13 Aug 2026 18:15:01 +0300 Subject: [PATCH] Localise the UI into Russian and English, switchable without a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 --- .csharpierignore | 3 + CLAUDE.md | 20 + README.md | 35 ++ src/AvParser.Core/Parsing/ParseError.cs | 19 + src/AvParser.Core/Parsing/ParseOutcome.cs | 4 + .../Parsing/Samples/DelimitedTextParser.cs | 7 +- .../Parsing/Samples/KeyValueTextParser.cs | 8 +- src/AvParser.Core/Settings/AppLanguage.cs | 45 ++ src/AvParser.Core/Settings/AppSettings.cs | 3 + src/AvParser.Desktop/App.axaml.cs | 4 +- src/AvParser.UI/AvParser.UI.csproj | 8 + .../UiServiceCollectionExtensions.cs | 4 +- src/AvParser.UI/Localization/LocExtension.cs | 33 ++ .../Localization/LocalizedOption.cs | 66 +++ src/AvParser.UI/Localization/Localizer.cs | 137 +++++ src/AvParser.UI/Localization/Strings.resx | 490 ++++++++++++++++++ src/AvParser.UI/Localization/Strings.ru.resx | 490 ++++++++++++++++++ .../Services/ILocalizationService.cs | 16 + .../Services/LocalizationService.cs | 67 +++ src/AvParser.UI/ViewModels/AboutViewModel.cs | 36 +- .../ViewModels/DashboardViewModel.cs | 6 +- .../ViewModels/ParseErrorViewModel.cs | 40 ++ src/AvParser.UI/ViewModels/ParseViewModel.cs | 62 ++- src/AvParser.UI/ViewModels/ParserViewModel.cs | 43 ++ .../ViewModels/ProxiesViewModel.cs | 79 ++- .../ViewModels/ProxyRowViewModel.cs | 20 +- .../ViewModels/SettingsViewModel.cs | 97 ++-- src/AvParser.UI/ViewModels/ShellViewModel.cs | 18 +- src/AvParser.UI/ViewModels/ViewModelBase.cs | 22 +- src/AvParser.UI/Views/AboutView.axaml | 9 +- src/AvParser.UI/Views/DashboardView.axaml | 22 +- src/AvParser.UI/Views/ParseView.axaml | 38 +- src/AvParser.UI/Views/ProxiesView.axaml | 50 +- src/AvParser.UI/Views/SettingsView.axaml | 90 ++-- src/AvParser.UI/Views/ShellView.axaml | 7 +- tests/AvParser.UI.HeadlessTests/Fakes.cs | 4 +- .../LocalizationViewTests.cs | 90 ++++ .../ShellViewTests.cs | 8 +- tests/AvParser.UI.Tests/Fakes/FakePage.cs | 10 +- tests/AvParser.UI.Tests/LocalizationTests.cs | 221 ++++++++ .../ProxiesViewModelTests.cs | 2 +- .../AvParser.UI.Tests/ShellViewModelTests.cs | 9 +- 42 files changed, 2216 insertions(+), 226 deletions(-) create mode 100644 .csharpierignore create mode 100644 src/AvParser.Core/Settings/AppLanguage.cs create mode 100644 src/AvParser.UI/Localization/LocExtension.cs create mode 100644 src/AvParser.UI/Localization/LocalizedOption.cs create mode 100644 src/AvParser.UI/Localization/Localizer.cs create mode 100644 src/AvParser.UI/Localization/Strings.resx create mode 100644 src/AvParser.UI/Localization/Strings.ru.resx create mode 100644 src/AvParser.UI/Services/ILocalizationService.cs create mode 100644 src/AvParser.UI/Services/LocalizationService.cs create mode 100644 src/AvParser.UI/ViewModels/ParseErrorViewModel.cs create mode 100644 src/AvParser.UI/ViewModels/ParserViewModel.cs create mode 100644 tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs create mode 100644 tests/AvParser.UI.Tests/LocalizationTests.cs diff --git a/.csharpierignore b/.csharpierignore new file mode 100644 index 0000000..4e72eea --- /dev/null +++ b/.csharpierignore @@ -0,0 +1,3 @@ +# Generated from a single source table (see the localization section of CLAUDE.md); reformatting +# them by hand only creates diff noise. +**/Localization/Strings*.resx diff --git a/CLAUDE.md b/CLAUDE.md index 60ac4ae..138a8b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,26 @@ dotnet csharpier check . значит карантинить здоровые прокси на каждый Cancel. - **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`. +## Добавить строку в UI + +1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт + `LocalizationTests.Russian_translates_every_english_key`). +2. В XAML — `{l:Loc Ключ}`, во ViewModel — `Localizer.Instance[...]` / `.Format(...)`. + +Никакого хардкода в `Views/` кроме имени продукта и примеров адресов. + +- **Счётчики — только через `Localizer.Plural`** с ключами `.One` / `.Few` / `.Many`. У русского + три формы; «{0} records» с приклеенным окончанием непереводимо. +- **Перечисления в списках — `LocalizedOption`**, не сырые значения. Конвертер разрешил бы + подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы + выбор не слетал. +- **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и + `Arguments`, UI ищет `Parse.Error.{Code}` с откатом на `Message`. Имена парсеров — так же: + `Parser.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить парсер = одна строка» + остаётся в силе. +- **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт + `base`. Без этого заголовок страницы останется на прежнем языке. + ## Грабли, уже оплаченные - **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView` diff --git a/README.md b/README.md index a89a491..e94e972 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,41 @@ using (lease) SOCKS работает штатно: .NET понимает схемы `socks4/socks4a/socks5` в `WebProxy`. Учтите, что proxifly-запись с `"protocol": "https"` — это всё равно HTTP-прокси с CONNECT, а не схема `https://`. +## Локализация + +Русский и английский, переключение **без перезапуска** — язык выбирается в настройках +(«Системный» берёт язык ОС, если для него есть перевод, иначе английский). + +Строки лежат в `UI/Localization/Strings.resx` и `Strings.ru.resx`; русский собирается в +сателлитную сборку `ru/AvParser.UI.resources.dll`. + +В XAML — разметочное расширение: + +```xml + +``` + +Оно возвращает **биндинг** через индексатор `Localizer`, а не готовую строку: смена языка +поднимает `PropertyChanged` для индексатора, и все такие биндинги перечитываются разом. Строка, +разрешённая один раз при загрузке, потребовала бы перезапуска. + +Три места, где локализация упирается в грамматику или в слои: + +- **Множественные числа.** У русского три формы, поэтому счётчики собираются не из «{0} records» + с приклеенным окончанием, а из ключей `.One` / `.Few` / `.Many` через `Localizer.Plural`. + «1 запись», «3 записи», «7 записей». +- **Значения перечислений.** Конвертер разрешил бы подпись один раз и не заметил смены языка, + поэтому в списках лежат обёртки `LocalizedOption`: идентичность — значение перечисления + (выбор не слетает), подпись следует за локализатором. +- **Текст из домена.** `AvParser.Core` о языках не знает. Парсеры отдают английское сообщение + **и код**, а UI переводит `Parse.Error.{Code}` с откатом на сообщение. Так же и с именами + парсеров: `Parser.{id}.Name` с откатом на `DisplayName`, поэтому новый парсер работает + непереведённым, а не показывает `!ключ!`. + +Оба `.resx` генерируются из одной таблицы, чтобы ключ не мог существовать в одном файле и +отсутствовать в другом; тесты проверяют совпадение ключей, отсутствие пустых переводов и +одинаковый набор плейсхолдеров `{0}`. + ## Дизайн-токены Все цвета, отступы, радиусы и типографика — в `Styles/Tokens.axaml`, с отдельными словарями diff --git a/src/AvParser.Core/Parsing/ParseError.cs b/src/AvParser.Core/Parsing/ParseError.cs index 0fe2042..843ad88 100644 --- a/src/AvParser.Core/Parsing/ParseError.cs +++ b/src/AvParser.Core/Parsing/ParseError.cs @@ -5,6 +5,25 @@ namespace AvParser.Core.Parsing; /// What went wrong, phrased for a user rather than a developer. public sealed record ParseError(int LineNumber, string Message) { + /// + /// Stable identifier for the kind of failure, or when the message is + /// the only thing on offer. + /// + /// + /// The domain stays language-free: it produces an English message plus a code, and the UI + /// translates Parse.Error.{Code} with , falling back to + /// . Without this, a Russian UI would still print English error text — + /// and moving the strings themselves into the domain would drag localisation down there. + /// + public string? Code { get; init; } + + /// Values to substitute into the translated message. + public IReadOnlyList Arguments { get; init; } = []; + + /// Creates an error carrying a translation code. + public static ParseError Create(int lineNumber, string code, string message, params object?[] arguments) => + new(lineNumber, message) { Code = code, Arguments = arguments }; + /// public override string ToString() => $"Line {LineNumber}: {Message}"; } diff --git a/src/AvParser.Core/Parsing/ParseOutcome.cs b/src/AvParser.Core/Parsing/ParseOutcome.cs index f930c54..a70a87a 100644 --- a/src/AvParser.Core/Parsing/ParseOutcome.cs +++ b/src/AvParser.Core/Parsing/ParseOutcome.cs @@ -37,4 +37,8 @@ public readonly record struct ParseOutcome /// Creates a failed outcome from its parts. public static ParseOutcome Failure(int lineNumber, string message) => Failure(new ParseError(lineNumber, message)); + + /// Creates a failed outcome carrying a translation code. + public static ParseOutcome Failure(int lineNumber, string code, string message, params object?[] arguments) => + Failure(ParseError.Create(lineNumber, code, message, arguments)); } diff --git a/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs b/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs index df647bd..f0c66d5 100644 --- a/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs +++ b/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs @@ -69,7 +69,10 @@ public sealed class DelimitedTextParser : ITextParser { yield return ParseOutcome.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.Failure(1, "Input contains no header row."); + yield return ParseOutcome.Failure(1, "NoHeader", "Input contains no header row."); } progress?.Report(new ParseProgress(total, total)); diff --git a/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs b/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs index 6719c9f..826ffed 100644 --- a/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs +++ b/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs @@ -51,7 +51,11 @@ public sealed class KeyValueTextParser : ITextParser if (separatorIndex <= 0) { - yield return ParseOutcome.Failure(lineNumber, "No '=' or ':' separator found."); + yield return ParseOutcome.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.Failure(lineNumber, "Key is empty.") + ? ParseOutcome.Failure(lineNumber, "EmptyKey", "Key is empty.") : ParseOutcome.Success( new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)]) ); diff --git a/src/AvParser.Core/Settings/AppLanguage.cs b/src/AvParser.Core/Settings/AppLanguage.cs new file mode 100644 index 0000000..03b90b2 --- /dev/null +++ b/src/AvParser.Core/Settings/AppLanguage.cs @@ -0,0 +1,45 @@ +using System.Globalization; + +namespace AvParser.Core.Settings; + +/// UI language. follows the operating system. +public enum AppLanguage +{ + /// Follow the operating system, falling back to English. + System = 0, + + /// English. + English = 1, + + /// Russian. + Russian = 2, +} + +/// Maps onto cultures. +public static class AppLanguages +{ + /// Languages the app ships translations for. + public static IReadOnlyList All { get; } = + [AppLanguage.System, AppLanguage.English, AppLanguage.Russian]; + + /// + /// Resolves a language to the culture the resource lookup should use. + /// + /// + /// 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. + /// + 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"); +} diff --git a/src/AvParser.Core/Settings/AppSettings.cs b/src/AvParser.Core/Settings/AppSettings.cs index 39227ac..95d66d2 100644 --- a/src/AvParser.Core/Settings/AppSettings.cs +++ b/src/AvParser.Core/Settings/AppSettings.cs @@ -27,6 +27,9 @@ public sealed record AppSettings /// Chosen theme variant. public AppTheme Theme { get; init; } = AppTheme.System; + /// Chosen UI language. + public AppLanguage Language { get; init; } = AppLanguage.System; + /// Id of the parser selected last time; resolved leniently on load. public string? LastParserId { get; init; } diff --git a/src/AvParser.Desktop/App.axaml.cs b/src/AvParser.Desktop/App.axaml.cs index 3664d14..ed2c1ac 100644 --- a/src/AvParser.Desktop/App.axaml.cs +++ b/src/AvParser.Desktop/App.axaml.cs @@ -43,7 +43,9 @@ public partial class App : Application DataTemplates.Add(_services.GetRequiredService()); - // Resolving the theme service applies the persisted variant as a side effect of construction. + // Both of these apply the persisted choice as a side effect of construction, so they have + // to be resolved before the first window is built rather than lazily on the Settings page. + _ = _services.GetRequiredService(); _ = _services.GetRequiredService(); if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) diff --git a/src/AvParser.UI/AvParser.UI.csproj b/src/AvParser.UI/AvParser.UI.csproj index 9b4fdb7..572ce97 100644 --- a/src/AvParser.UI/AvParser.UI.csproj +++ b/src/AvParser.UI/AvParser.UI.csproj @@ -10,6 +10,14 @@ + + + + + + diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs index 51867b6..cc632e5 100644 --- a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -26,6 +26,7 @@ public static class UiServiceCollectionExtensions services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -39,7 +40,8 @@ public static class UiServiceCollectionExtensions sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), - sp.GetRequiredService() + sp.GetRequiredService(), + sp.GetRequiredService() )); services.AddSingleton(static sp => new ProxiesViewModel( sp.GetRequiredService(), diff --git a/src/AvParser.UI/Localization/LocExtension.cs b/src/AvParser.UI/Localization/LocExtension.cs new file mode 100644 index 0000000..aee64a7 --- /dev/null +++ b/src/AvParser.UI/Localization/LocExtension.cs @@ -0,0 +1,33 @@ +using Avalonia.Data; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Localization; + +/// +/// XAML shorthand for a translated string: Text="{l:Loc Parse.Title}". +/// +/// +/// Returns a through 's indexer rather than the +/// string itself. A plain string would be resolved once at load and would never notice a language +/// change; the binding re-reads when the localizer invalidates its indexer. +/// +public sealed class LocExtension : MarkupExtension +{ + /// Creates the extension with no key. Used by XAML before the key is assigned. + public LocExtension() => Key = string.Empty; + + /// Creates the extension for a resource key. + public LocExtension(string key) => Key = key; + + /// Resource key to look up. + public string Key { get; set; } + + /// + public override object ProvideValue(IServiceProvider serviceProvider) => + new Binding + { + Mode = BindingMode.OneWay, + Source = Localizer.Instance, + Path = $"[{Key}]", + }; +} diff --git a/src/AvParser.UI/Localization/LocalizedOption.cs b/src/AvParser.UI/Localization/LocalizedOption.cs new file mode 100644 index 0000000..f528217 --- /dev/null +++ b/src/AvParser.UI/Localization/LocalizedOption.cs @@ -0,0 +1,66 @@ +using ReactiveUI; + +namespace AvParser.UI.Localization; + +/// +/// An enum value paired with its translated label, for pickers. +/// +/// +/// Combo boxes cannot bind a raw enum to a translated caption: a value converter would resolve the +/// text once and never notice a language change. Wrapping the value keeps the selection stable +/// (identity is the enum value) while the label follows the localizer. +/// +public abstract class LocalizedOption : ReactiveObject +{ + /// Translated caption. + /// + /// Declared on a non-generic base so a DataTemplate can name a concrete + /// x:DataType: Avalonia 12 compiles bindings by default and cannot infer one for an + /// open generic. + /// + public abstract string Label { get; } +} + +/// +public sealed class LocalizedOption : LocalizedOption, IEquatable> + where TValue : struct, Enum +{ + private readonly string _key; + + /// Creates an option for a value, reading its label from Enum.{Type}.{Value}. + public LocalizedOption(TValue value) + { + Value = value; + _key = $"Enum.{typeof(TValue).Name}.{value}"; + + Localizer.Instance.LanguageChanged += OnLanguageChanged; + } + + /// The underlying value. + public TValue Value { get; } + + /// + public override string Label => Localizer.Instance[_key]; + + /// Builds options for every value of the enum. + public static IReadOnlyList> ForAll() => + [.. Enum.GetValues().Select(value => new LocalizedOption(value))]; + + /// Builds options for a specific set of values, in that order. + public static IReadOnlyList> For(params TValue[] values) => + [.. values.Select(value => new LocalizedOption(value))]; + + /// + public bool Equals(LocalizedOption? other) => other is not null && Value.Equals(other.Value); + + /// + public override bool Equals(object? obj) => Equals(obj as LocalizedOption); + + /// + public override int GetHashCode() => Value.GetHashCode(); + + /// + public override string ToString() => Label; + + private void OnLanguageChanged(object? sender, EventArgs e) => this.RaisePropertyChanged(nameof(Label)); +} diff --git a/src/AvParser.UI/Localization/Localizer.cs b/src/AvParser.UI/Localization/Localizer.cs new file mode 100644 index 0000000..db91fed --- /dev/null +++ b/src/AvParser.UI/Localization/Localizer.cs @@ -0,0 +1,137 @@ +using System.ComponentModel; +using System.Globalization; +using System.Resources; +using AvParser.Core.Settings; + +namespace AvParser.UI.Localization; + +/// +/// The single source of translated strings, and the thing bindings listen to. +/// +/// +/// +/// Exposed as an indexer so XAML can bind through it: changing the language raises +/// PropertyChanged for the indexer, which makes every +/// {l:Loc Key} binding in the application re-read at once. That is what buys language +/// switching without a restart — the alternative, resolving strings once at load, would need one. +/// +/// +/// A singleton rather than an injected service because XAML markup extensions have no container +/// to ask, and a second instance would simply be a second set of stale bindings. +/// +/// +public sealed class Localizer : INotifyPropertyChanged +{ + /// WPF/Avalonia convention: this property name invalidates every indexer binding. + private const string IndexerName = "Item[]"; + + private static readonly ResourceManager Resources = new( + "AvParser.UI.Localization.Strings", + typeof(Localizer).Assembly + ); + + private CultureInfo _culture = CultureInfo.GetCultureInfo("en"); + + private Localizer() { } + + /// The instance every binding goes through. + public static Localizer Instance { get; } = new(); + + /// Culture currently used for lookups. + public CultureInfo Culture => _culture; + + /// The language setting currently applied. + public AppLanguage Language { get; private set; } = AppLanguage.System; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Raised after the language changes, for consumers that cache derived text. + public event EventHandler? LanguageChanged; + + /// Looks a key up. Unknown keys come back as !key! rather than blank. + /// + /// A visible marker beats an empty label: a missing translation should be obvious in a + /// screenshot, not silently render as nothing. + /// + public string this[string key] => Get(key); + + /// Looks a key up. + public string Get(string key) + { + if (string.IsNullOrEmpty(key)) + { + return string.Empty; + } + + return Resources.GetString(key, _culture) ?? $"!{key}!"; + } + + /// Looks a key up, returning when it is not translated. + /// + /// Used for text that plugs into the app from outside the resource files — a newly added + /// parser should show its own English name rather than !Parser.x.Name! until someone + /// gets round to translating it. + /// + public string GetOrDefault(string key, string fallback) => Resources.GetString(key, _culture) ?? fallback; + + /// Looks a key up and formats it with . + public string Format(string key, params object?[] arguments) => string.Format(_culture, Get(key), arguments); + + /// + /// Picks the plural form of for and formats it. + /// + /// + /// Resource keys are suffixed .One / .Few / .Many. English only ever + /// needs One and Many; Russian needs all three, which is why the counted messages cannot just + /// be "{0} records" with an English-shaped plural glued on. + /// + public string Plural(string key, int count) => + string.Format(_culture, Get($"{key}.{SuffixFor(count, _culture)}"), count); + + /// Applies a language and notifies every binding. + public void SetLanguage(AppLanguage language, CultureInfo? systemCulture = null) + { + var culture = AppLanguages.ToCulture(language, systemCulture); + var changed = !Equals(culture, _culture) || language != Language; + + _culture = culture; + Language = language; + + if (!changed) + { + return; + } + + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(IndexerName)); + LanguageChanged?.Invoke(this, EventArgs.Empty); + } + + /// Chooses the plural suffix for a count under a culture. + /// Public so the rules can be tested directly rather than through resource lookups. + public static string SuffixFor(int count, CultureInfo culture) + { + ArgumentNullException.ThrowIfNull(culture); + + if (!string.Equals(culture.TwoLetterISOLanguageName, "ru", StringComparison.OrdinalIgnoreCase)) + { + return count == 1 ? "One" : "Many"; + } + + var absolute = Math.Abs(count); + var lastTwo = absolute % 100; + var last = absolute % 10; + + if (lastTwo is >= 11 and <= 14) + { + return "Many"; + } + + return last switch + { + 1 => "One", + 2 or 3 or 4 => "Few", + _ => "Many", + }; + } +} diff --git a/src/AvParser.UI/Localization/Strings.resx b/src/AvParser.UI/Localization/Strings.resx new file mode 100644 index 0000000..affb37c --- /dev/null +++ b/src/AvParser.UI/Localization/Strings.resx @@ -0,0 +1,490 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Toggle navigation + + + Back + + + Switch light / dark + + + Dashboard + + + Parse + + + Proxies + + + Settings + + + About + + + A parser shell with an adaptive layout. Drag the window narrower to watch the navigation collapse to an icon rail and then to an overlay drawer. + + + Registered parsers + + + Get started + + + Open the parser + + + DATA DIRECTORY + + + PARSER + + + ACTIONS + + + Parse + + + Cancel + + + Sample + + + Fill the input with a small example + + + 50k rows + + + Generate 50 000 rows, so progress and cancellation are observable + + + Clear input and results + + + INPUT + + + Paste text here, or press Sample + + + RECORDS + + + ERRORS + + + {0} record + + + {0} records + + + {0} records + + + {0} error + + + {0} errors + + + {0} errors + + + Parsed {0} in {1} ms. + + + Cancelled after {0} in {1} ms. + + + {0}, {1}. + + + Showing the first {0} only. + + + Parse failed: {0} + + + POOL + + + Refresh + + + Reload every source + + + Check all + + + Probe every proxy in the pool + + + SEARCH + + + address or country + + + PROTOCOL + + + STATUS + + + alive + + + total + + + matched + + + PROXIES + + + CUSTOM PROXIES + + + Add + + + Remove + + + Remove the selected custom proxy + + + Clear the custom list + + + One per line. scheme://host:port, or host:port for plain HTTP. user:pass@ is supported. + + + alive + + + dead + + + unchecked + + + feed + + + custom + + + {0} proxy + + + {0} proxies + + + {0} proxies + + + Pool holds {0}. + + + Nothing to add. + + + Added {0} of {1}. + + + Could not parse: {0} + + + and {0} more + + + {0} of {1} answered. + + + Check cancelled. + + + Removed {0}. + + + Custom list cleared. + + + Failed: {0} + + + Appearance + + + THEME + + + System follows the operating system's light/dark setting. + + + LANGUAGE + + + Applies immediately — no restart needed. + + + Diagnostics + + + MINIMUM LOG LEVEL + + + Applies immediately — no restart needed. + + + SETTINGS FILE + + + LOG DIRECTORY + + + Proxies + + + ROTATION + + + LIVENESS CHECK + + + Use the public proxifly feed + + + PROBE URL + + + Plain HTTP by default: requiring TLS would fail every proxy that cannot do CONNECT, not just the dead ones. + + + PROBE TIMEOUT (SEC) + + + PARALLEL PROBES + + + One proxy per session, replaced only when it fails. Keeps site sessions intact. + + + 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. + + + 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. + + + Layout breakpoints + + + Window widths at which the navigation changes shape. Resize the window to see it happen. + + + COMPACT + + + MEDIUM + + + EXPANDED + + + below {0} px — overlay drawer + + + {0} – {1} px — icon rail + + + from {0} px — full sidebar + + + Built with + + + On disk + + + DATA + + + LOGS + + + .NET + + + Operating system + + + Avalonia + + + ReactiveUI + + + Semi.Avalonia + + + not loaded + + + System + + + Light + + + Dark + + + System + + + English + + + Русский + + + Sticky + + + Round-robin + + + Weighted random + + + Whole pool + + + On hand-out + + + All + + + HTTP + + + HTTPS + + + SOCKS4 + + + SOCKS5 + + + All + + + Alive + + + Dead + + + Unchecked + + + Delimited text + + + First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |). + + + Key / value pairs + + + One pair per line, separated by '=' or ':'. Lines starting with '#' are comments. + + + Expected {0} field(s) but found {1}. + + + Input contains no header row. + + + No '=' or ':' separator found. + + + Key is empty. + + diff --git a/src/AvParser.UI/Localization/Strings.ru.resx b/src/AvParser.UI/Localization/Strings.ru.resx new file mode 100644 index 0000000..e63b44a --- /dev/null +++ b/src/AvParser.UI/Localization/Strings.ru.resx @@ -0,0 +1,490 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Показать или скрыть навигацию + + + Назад + + + Переключить светлую и тёмную тему + + + Обзор + + + Разбор + + + Прокси + + + Настройки + + + О программе + + + Оболочка парсера с адаптивной раскладкой. Сузьте окно — навигация свернётся сначала в рельс из иконок, а затем в выезжающую панель. + + + Доступные парсеры + + + С чего начать + + + Открыть парсер + + + КАТАЛОГ ДАННЫХ + + + ПАРСЕР + + + ДЕЙСТВИЯ + + + Разобрать + + + Отменить + + + Пример + + + Подставить небольшой пример + + + 50k строк + + + Сгенерировать 50 000 строк, чтобы были видны прогресс и отмена + + + Очистить ввод и результаты + + + ВХОД + + + Вставьте текст или нажмите «Пример» + + + ЗАПИСИ + + + ОШИБКИ + + + {0} запись + + + {0} записи + + + {0} записей + + + {0} ошибка + + + {0} ошибки + + + {0} ошибок + + + Разобрано: {0} за {1} мс. + + + Отменено: {0} за {1} мс. + + + {0}, {1}. + + + Показаны только первые {0}. + + + Разбор не удался: {0} + + + ПУЛ + + + Обновить + + + Перечитать все источники + + + Проверить все + + + Проверить каждую прокси в пуле + + + ПОИСК + + + адрес или страна + + + ПРОТОКОЛ + + + СОСТОЯНИЕ + + + живых + + + всего + + + найдено + + + ПРОКСИ + + + СВОИ ПРОКСИ + + + Добавить + + + Удалить + + + Удалить выбранную свою прокси + + + Очистить свой список + + + По одной в строке. scheme://host:port или host:port для обычного HTTP. Поддерживается user:pass@. + + + живая + + + мёртвая + + + не проверена + + + фид + + + своя + + + {0} прокси + + + {0} прокси + + + {0} прокси + + + В пуле {0}. + + + Нечего добавлять. + + + Добавлено {0} из {1}. + + + Не удалось разобрать: {0} + + + и ещё {0} + + + Ответили {0} из {1}. + + + Проверка отменена. + + + Удалено: {0}. + + + Свой список очищен. + + + Ошибка: {0} + + + Внешний вид + + + ТЕМА + + + «Системная» следует настройке светлой или тёмной темы в ОС. + + + ЯЗЫК + + + Применяется сразу, перезапуск не нужен. + + + Диагностика + + + МИНИМАЛЬНЫЙ УРОВЕНЬ ЛОГА + + + Применяется сразу, перезапуск не нужен. + + + ФАЙЛ НАСТРОЕК + + + КАТАЛОГ ЛОГОВ + + + Прокси + + + РОТАЦИЯ + + + ПРОВЕРКА ЖИВОСТИ + + + Использовать публичный фид proxifly + + + URL ДЛЯ ПРОВЕРКИ + + + По умолчанию обычный HTTP: требование TLS отбраковало бы все прокси без CONNECT, а не только нерабочие. + + + ТАЙМАУТ ПРОВЕРКИ (СЕК) + + + ПРОВЕРОК ПАРАЛЛЕЛЬНО + + + Одна прокси на сессию, смена только при отказе. Не рвёт сессии сайтов. + + + Новая прокси на каждый запрос. Размазывает рейт-лимиты, но ломает сессионные cookie. + + + Случайно, с весом по оценке из фида и по доле реально удачных запросов. + + + Проверить весь пул заранее и параллельно. Один прогон — и дальше без задержек. + + + Проверять прокси в момент выдачи. Без прогона, но каждая выдача стоит одного запроса. + + + Точки перелома раскладки + + + Ширины окна, на которых меняется форма навигации. Измените размер окна, чтобы увидеть. + + + КОМПАКТНАЯ + + + СРЕДНЯЯ + + + ШИРОКАЯ + + + меньше {0} px — выезжающая панель + + + {0} – {1} px — рельс из иконок + + + от {0} px — полная боковая панель + + + Собрано на + + + На диске + + + ДАННЫЕ + + + ЛОГИ + + + .NET + + + Операционная система + + + Avalonia + + + ReactiveUI + + + Semi.Avalonia + + + не загружено + + + Системная + + + Светлая + + + Тёмная + + + Системный + + + English + + + Русский + + + Липкая + + + По кругу + + + Случайная с весами + + + Весь пул + + + При выдаче + + + Все + + + HTTP + + + HTTPS + + + SOCKS4 + + + SOCKS5 + + + Все + + + Живые + + + Мёртвые + + + Не проверены + + + Текст с разделителями + + + Первая непустая строка — заголовок. Строки режутся по разделителю, преобладающему в нём (, ; таб |). + + + Пары ключ / значение + + + По паре в строке, разделитель «=» или «:». Строки с «#» — комментарии. + + + Ожидалось полей: {0}, найдено: {1}. + + + Во входных данных нет строки заголовка. + + + Не найден разделитель «=» или «:». + + + Пустой ключ. + + diff --git a/src/AvParser.UI/Services/ILocalizationService.cs b/src/AvParser.UI/Services/ILocalizationService.cs new file mode 100644 index 0000000..7e8d0a6 --- /dev/null +++ b/src/AvParser.UI/Services/ILocalizationService.cs @@ -0,0 +1,16 @@ +using AvParser.Core.Settings; + +namespace AvParser.UI.Services; + +/// Applies and persists the UI language. +public interface ILocalizationService +{ + /// The language currently in effect. + AppLanguage Current { get; } + + /// Emits the language, starting with the present value. + IObservable Changes { get; } + + /// Applies a language to the running application and persists the choice. + void Apply(AppLanguage language); +} diff --git a/src/AvParser.UI/Services/LocalizationService.cs b/src/AvParser.UI/Services/LocalizationService.cs new file mode 100644 index 0000000..903ab6b --- /dev/null +++ b/src/AvParser.UI/Services/LocalizationService.cs @@ -0,0 +1,67 @@ +using System.Globalization; +using Avalonia; +using AvParser.Core.Settings; +using AvParser.UI.Localization; +using ReactiveUI.Primitives.Signals; +using Semi.Avalonia; + +namespace AvParser.UI.Services; + +/// +public sealed class LocalizationService : ILocalizationService, IDisposable +{ + private readonly ISettingsService _settings; + private readonly BehaviorSignal _current; + + /// Restores the persisted language and applies it immediately. + public LocalizationService(ISettingsService settings) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _current = new BehaviorSignal(settings.Current.Language); + + ApplyToApplication(settings.Current.Language); + } + + /// + public AppLanguage Current => _current.Value; + + /// + public IObservable Changes => _current; + + /// + public void Apply(AppLanguage language) + { + if (language == _current.Value) + { + return; + } + + ApplyToApplication(language); + _current.OnNext(language); + _settings.Update(current => current with { Language = language }); + } + + /// + public void Dispose() => _current.Dispose(); + + private static void ApplyToApplication(AppLanguage language) + { + var culture = AppLanguages.ToCulture(language); + + Localizer.Instance.SetLanguage(language); + + // Number and date formatting has to move with the language, or a Russian UI would still + // print "50,000" with an English group separator. + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = culture; + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + + // Semi ships its own strings for built-in controls; without this its dialogs and pickers + // stay English while everything around them changes. + if (Application.Current is { } app) + { + SemiTheme.OverrideLocaleResources(app, culture); + } + } +} diff --git a/src/AvParser.UI/ViewModels/AboutViewModel.cs b/src/AvParser.UI/ViewModels/AboutViewModel.cs index 7065fcd..d129017 100644 --- a/src/AvParser.UI/ViewModels/AboutViewModel.cs +++ b/src/AvParser.UI/ViewModels/AboutViewModel.cs @@ -1,5 +1,7 @@ using System.Reflection; using AvParser.Infrastructure.Storage; +using AvParser.UI.Localization; +using ReactiveUI; namespace AvParser.UI.ViewModels; @@ -11,6 +13,8 @@ public sealed record ComponentInfo(string Name, string Detail); /// Version, runtime and stack information. public sealed class AboutViewModel : PageViewModel { + private readonly IReadOnlyList<(string Key, string Detail)> _components; + /// Creates the page. public AboutViewModel(IAppPaths paths) { @@ -33,18 +37,20 @@ public sealed class AboutViewModel : PageViewModel DataDirectory = paths.DataDirectory; LogDirectory = paths.LogDirectory; - Components = + _components = [ - new ComponentInfo(".NET", Environment.Version.ToString()), - new ComponentInfo("Operating system", Environment.OSVersion.ToString()), - new ComponentInfo("Avalonia", VersionOf("Avalonia.Base")), - new ComponentInfo("ReactiveUI", VersionOf("ReactiveUI")), - new ComponentInfo("Semi.Avalonia", VersionOf("Semi.Avalonia")), + ("About.Component.Dotnet", Environment.Version.ToString()), + ("About.Component.Os", Environment.OSVersion.ToString()), + ("About.Component.Avalonia", VersionOf("Avalonia.Base")), + ("About.Component.ReactiveUI", VersionOf("ReactiveUI")), + ("About.Component.Semi", VersionOf("Semi.Avalonia")), ]; + + Components = Build(_components); } /// - public override string Title => "About"; + public override string TitleKey => "Page.About"; /// public override string IconKey => "IconInfo"; @@ -59,7 +65,21 @@ public sealed class AboutViewModel : PageViewModel public string LogDirectory { get; } /// The stack this build is running on. - public IReadOnlyList Components { get; } + public IReadOnlyList Components { get; private set; } + + /// + protected override void OnLanguageChanged() + { + base.OnLanguageChanged(); + + // Component names are translated but their values are not, so rebuild the pairs rather + // than making ComponentInfo itself language-aware. + Components = Build(_components); + this.RaisePropertyChanged(nameof(Components)); + } + + private static IReadOnlyList Build(IReadOnlyList<(string Key, string Detail)> components) => + [.. components.Select(component => new ComponentInfo(Localizer.Instance[component.Key], component.Detail))]; private static string VersionOf(string assemblyName) { diff --git a/src/AvParser.UI/ViewModels/DashboardViewModel.cs b/src/AvParser.UI/ViewModels/DashboardViewModel.cs index a208fc3..5e25bf8 100644 --- a/src/AvParser.UI/ViewModels/DashboardViewModel.cs +++ b/src/AvParser.UI/ViewModels/DashboardViewModel.cs @@ -27,7 +27,7 @@ public sealed class DashboardViewModel : PageViewModel _services = services ?? throw new ArgumentNullException(nameof(services)); - Parsers = catalog.Parsers; + Parsers = [.. catalog.Parsers.Select(parser => new ParserViewModel(parser))]; DataDirectory = paths.DataDirectory; GoToParseCommand = ReactiveCommand.Create(() => Navigate()); @@ -35,13 +35,13 @@ public sealed class DashboardViewModel : PageViewModel } /// - public override string Title => "Dashboard"; + public override string TitleKey => "Page.Dashboard"; /// public override string IconKey => "IconHome"; /// Registered parsers, shown as cards. - public IReadOnlyList Parsers { get; } + public IReadOnlyList Parsers { get; } /// Where settings and logs are written. public string DataDirectory { get; } diff --git a/src/AvParser.UI/ViewModels/ParseErrorViewModel.cs b/src/AvParser.UI/ViewModels/ParseErrorViewModel.cs new file mode 100644 index 0000000..7e0c60c --- /dev/null +++ b/src/AvParser.UI/ViewModels/ParseErrorViewModel.cs @@ -0,0 +1,40 @@ +using AvParser.Core.Parsing; +using AvParser.UI.Localization; +using ReactiveUI; + +namespace AvParser.UI.ViewModels; + +/// One row of the parse error list, with its message translated. +/// +/// Parsers are part of the domain and produce English text plus a code. This resolves +/// Parse.Error.{Code} and falls back to the parser's own wording, so a parser that has not +/// been translated yet still says something useful instead of showing a missing-key marker. +/// +public sealed class ParseErrorViewModel(ParseError error) : ReactiveObject +{ + /// The underlying error. + public ParseError Error { get; } = error ?? throw new ArgumentNullException(nameof(error)); + + /// 1-based position of the offending record. + public int LineNumber => Error.LineNumber; + + /// Translated message. + public string Text + { + get + { + if (Error.Code is not { Length: > 0 } code) + { + return Error.Message; + } + + var template = Localizer.Instance.GetOrDefault($"Parse.Error.{code}", Error.Message); + return Error.Arguments.Count == 0 + ? template + : string.Format(Localizer.Instance.Culture, template, [.. Error.Arguments]); + } + } + + /// Re-reads the translated message. + public void Refresh() => this.RaisePropertyChanged(nameof(Text)); +} diff --git a/src/AvParser.UI/ViewModels/ParseViewModel.cs b/src/AvParser.UI/ViewModels/ParseViewModel.cs index 980fc58..1e3c235 100644 --- a/src/AvParser.UI/ViewModels/ParseViewModel.cs +++ b/src/AvParser.UI/ViewModels/ParseViewModel.cs @@ -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 /// Parser applied by . [Reactive] - public partial ITextParser SelectedParser { get; set; } + public partial ParserViewModel SelectedParser { get; set; } /// Completion of the running parse, 0.0 to 1.0. [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 } /// - public override string Title => "Parse"; + public override string TitleKey => "Page.Parse"; /// public override string IconKey => "IconDocument"; /// Every registered parser, for the picker. - public IReadOnlyList Parsers => _catalog.Parsers; + public IReadOnlyList Parsers { get; } /// Successfully parsed records, capped at . public ObservableCollection Records { get; } = []; /// Per-line failures. A failure never aborts the parse. - public ObservableCollection Errors { get; } = []; + public ObservableCollection Errors { get; } = []; /// Whether a parse is currently running. 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) + /// + /// Builds the outcome line out of translated fragments. + /// + /// + /// 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. + /// + 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 records, List errors) @@ -275,11 +281,25 @@ public partial class ParseViewModel : PageViewModel foreach (var error in errorBatch) { - Errors.Add(error); + Errors.Add(new ParseErrorViewModel(error)); } }); } + /// + 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)); } /// Marshals a mutation onto the UI thread; the parse loop runs on the thread pool. diff --git a/src/AvParser.UI/ViewModels/ParserViewModel.cs b/src/AvParser.UI/ViewModels/ParserViewModel.cs new file mode 100644 index 0000000..26b20c8 --- /dev/null +++ b/src/AvParser.UI/ViewModels/ParserViewModel.cs @@ -0,0 +1,43 @@ +using AvParser.Core.Parsing; +using AvParser.UI.Localization; +using ReactiveUI; + +namespace AvParser.UI.ViewModels; + +/// A parser paired with its translated name and description. +/// +/// The domain deliberately knows nothing about languages, so carries +/// English text. This looks the id up as Parser.{id}.Name and falls back to what the parser +/// itself says, which keeps the "add a parser = one registration line" promise intact: a new +/// parser works untranslated instead of rendering a missing-key marker. +/// +public sealed class ParserViewModel : ReactiveObject +{ + /// Wraps a parser. + public ParserViewModel(ITextParser parser) + { + Parser = parser ?? throw new ArgumentNullException(nameof(parser)); + Localizer.Instance.LanguageChanged += OnLanguageChanged; + } + + /// The parser itself. + public ITextParser Parser { get; } + + /// Stable identifier. + public string Id => Parser.Id; + + /// Translated name, or the parser's own when untranslated. + public string Name => Localizer.Instance.GetOrDefault($"Parser.{Id}.Name", Parser.DisplayName); + + /// Translated description, or the parser's own when untranslated. + public string Description => Localizer.Instance.GetOrDefault($"Parser.{Id}.Description", Parser.Description); + + /// + public override string ToString() => Name; + + private void OnLanguageChanged(object? sender, EventArgs e) + { + this.RaisePropertyChanged(nameof(Name)); + this.RaisePropertyChanged(nameof(Description)); + } +} diff --git a/src/AvParser.UI/ViewModels/ProxiesViewModel.cs b/src/AvParser.UI/ViewModels/ProxiesViewModel.cs index 8f854a1..94f6ad9 100644 --- a/src/AvParser.UI/ViewModels/ProxiesViewModel.cs +++ b/src/AvParser.UI/ViewModels/ProxiesViewModel.cs @@ -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 /// Protocol filter; means no filtering. [Reactive] - public partial ProxyProtocolFilter ProtocolFilter { get; set; } + public partial LocalizedOption ProtocolFilter { get; set; } /// Health filter. [Reactive] - public partial ProxyHealthFilter HealthFilter { get; set; } + public partial LocalizedOption HealthFilter { get; set; } /// Text box contents for adding custom proxies. [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 } /// - public override string Title => "Proxies"; + public override string TitleKey => "Page.Proxies"; /// public override string IconKey => "IconShield"; @@ -156,18 +157,18 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable public ObservableCollection Proxies { get; } = []; /// Protocol filter options. - public IReadOnlyList ProtocolFilters { get; } = - [ - ProxyProtocolFilter.All, - ProxyProtocolFilter.Http, - ProxyProtocolFilter.Https, - ProxyProtocolFilter.Socks4, - ProxyProtocolFilter.Socks5, - ]; + public IReadOnlyList> ProtocolFilters { get; } = + LocalizedOption.For( + ProxyProtocolFilter.All, + ProxyProtocolFilter.Http, + ProxyProtocolFilter.Https, + ProxyProtocolFilter.Socks4, + ProxyProtocolFilter.Socks5 + ); /// Health filter options. - public IReadOnlyList HealthFilters { get; } = - [ProxyHealthFilter.All, ProxyHealthFilter.Alive, ProxyHealthFilter.Dead, ProxyHealthFilter.Unchecked]; + public IReadOnlyList> HealthFilters { get; } = + LocalizedOption.ForAll(); /// Reloads every source into the pool. public ReactiveCommand 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(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"]); + } + + /// + 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(); + } } /// @@ -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); diff --git a/src/AvParser.UI/ViewModels/ProxyRowViewModel.cs b/src/AvParser.UI/ViewModels/ProxyRowViewModel.cs index 724f4e7..b53ef7d 100644 --- a/src/AvParser.UI/ViewModels/ProxyRowViewModel.cs +++ b/src/AvParser.UI/ViewModels/ProxyRowViewModel.cs @@ -1,5 +1,6 @@ using System.Globalization; using AvParser.Core.Proxies; +using AvParser.UI.Localization; using ReactiveUI; namespace AvParser.UI.ViewModels; @@ -35,16 +36,18 @@ public sealed class ProxyRowViewModel(ProxyEntry entry) : ReactiveObject public bool IsCustom => Entry.Source == ProxySourceKind.Custom; /// Source label. - public string Source => IsCustom ? "custom" : "feed"; + public string Source => Localizer.Instance[IsCustom ? "Proxies.Source.Custom" : "Proxies.Source.Feed"]; /// Last known health, as a word. public string HealthText => - Entry.Health switch - { - ProxyHealthState.Alive => "alive", - ProxyHealthState.Dead => "dead", - _ => "unchecked", - }; + Localizer.Instance[ + Entry.Health switch + { + ProxyHealthState.Alive => "Proxies.Health.Alive", + ProxyHealthState.Dead => "Proxies.Health.Dead", + _ => "Proxies.Health.Unchecked", + } + ]; /// Whether the last check succeeded. Drives the row's accent. public bool IsAlive => Entry.Health == ProxyHealthState.Alive; @@ -68,9 +71,10 @@ public sealed class ProxyRowViewModel(ProxyEntry entry) : ReactiveObject /// Whether the entry is sidelined right now. public bool IsQuarantined => Entry.QuarantinedUntilUtc is { } until && until > DateTimeOffset.UtcNow; - /// Re-reads everything that the pool can change behind our back. + /// Re-reads everything that the pool — or the language — can change behind our back. public void Refresh() { + this.RaisePropertyChanged(nameof(Source)); this.RaisePropertyChanged(nameof(HealthText)); this.RaisePropertyChanged(nameof(IsAlive)); this.RaisePropertyChanged(nameof(IsDead)); diff --git a/src/AvParser.UI/ViewModels/SettingsViewModel.cs b/src/AvParser.UI/ViewModels/SettingsViewModel.cs index 5e17d2f..2fd20f3 100644 --- a/src/AvParser.UI/ViewModels/SettingsViewModel.cs +++ b/src/AvParser.UI/ViewModels/SettingsViewModel.cs @@ -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; /// Selected theme. Applied immediately, not on an OK button. [Reactive] - public partial AppTheme SelectedTheme { get; set; } + public partial LocalizedOption SelectedTheme { get; set; } + + /// Selected UI language. Applied immediately. + [Reactive] + public partial LocalizedOption SelectedLanguage { get; set; } /// Selected Serilog level name. Takes effect immediately. [Reactive] @@ -30,11 +36,11 @@ public partial class SettingsViewModel : PageViewModel /// How the pool picks the next proxy. [Reactive] - public partial ProxyRotation SelectedRotation { get; set; } + public partial LocalizedOption SelectedRotation { get; set; } /// When proxy liveness is verified. [Reactive] - public partial ProxyHealthCheck SelectedHealthCheck { get; set; } + public partial LocalizedOption SelectedHealthCheck { get; set; } /// Whether the remote proxy feed is consulted. [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 } /// - public override string Title => "Settings"; + public override string TitleKey => "Page.Settings"; /// public override string IconKey => "IconSettings"; - /// Theme options offered by the radio group. - public IReadOnlyList Themes { get; } = [AppTheme.System, AppTheme.Light, AppTheme.Dark]; + /// Theme options offered by the picker. + public IReadOnlyList> Themes { get; } = LocalizedOption.ForAll(); + + /// Language options offered by the picker. + public IReadOnlyList> Languages { get; } = LocalizedOption.ForAll(); /// Serilog level names, most to least verbose. public IReadOnlyList LogLevels => AppLogging.AvailableLevels; /// Rotation strategies offered by the picker. - public IReadOnlyList Rotations { get; } = - [ProxyRotation.Sticky, ProxyRotation.RoundRobin, ProxyRotation.WeightedRandom]; + public IReadOnlyList> Rotations { get; } = LocalizedOption.ForAll(); /// Liveness policies offered by the picker. - public IReadOnlyList HealthChecks { get; } = [ProxyHealthCheck.Pool, ProxyHealthCheck.Lazy]; + public IReadOnlyList> HealthChecks { get; } = + LocalizedOption.ForAll(); /// Explains the selected rotation in one line. - 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}"]; /// Explains the selected health-check policy in one line. - 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}"]; + + /// Describes the compact breakpoint band. + public string CompactHint => Localizer.Instance.Format("Settings.Breakpoint.CompactHint", MediumBreakpoint); + + /// Describes the medium breakpoint band. + public string MediumHint => + Localizer.Instance.Format("Settings.Breakpoint.MediumHint", MediumBreakpoint, ExpandedBreakpoint); + + /// Describes the expanded breakpoint band. + public string ExpandedHint => Localizer.Instance.Format("Settings.Breakpoint.ExpandedHint", ExpandedBreakpoint); /// Full path of the settings file. public string SettingsFile { get; } @@ -163,6 +179,29 @@ public partial class SettingsViewModel : PageViewModel /// Width in pixels at which the shell switches to the full sidebar. public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth; + /// + 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 Option(IReadOnlyList> 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, diff --git a/src/AvParser.UI/ViewModels/ShellViewModel.cs b/src/AvParser.UI/ViewModels/ShellViewModel.cs index af2bb31..03db58b 100644 --- a/src/AvParser.UI/ViewModels/ShellViewModel.cs +++ b/src/AvParser.UI/ViewModels/ShellViewModel.cs @@ -22,7 +22,6 @@ public partial class ShellViewModel : ViewModelBase private readonly IThemeService _theme; private readonly ObservableAsPropertyHelper _paneDisplayMode; private readonly ObservableAsPropertyHelper _currentPage; - private readonly ObservableAsPropertyHelper _title; private readonly ObservableAsPropertyHelper _canGoBack; private readonly ObservableAsPropertyHelper _themeIconKey; @@ -58,10 +57,6 @@ public partial class ShellViewModel : ViewModelBase _currentPage = navigation.CurrentChanges.ToProperty(this, nameof(CurrentPage), navigation.Current, scheduler); - _title = navigation - .CurrentChanges.Select(static page => page.Title) - .ToProperty(this, nameof(Title), navigation.Current.Title, scheduler); - _canGoBack = navigation.CanGoBack.ToProperty(this, nameof(CanGoBack), false, scheduler); _paneDisplayMode = this.WhenAnyValue(x => x.Breakpoint) @@ -85,6 +80,12 @@ public partial class ShellViewModel : ViewModelBase .Select(static breakpoint => breakpoint == Breakpoint.Expanded) .Subscribe(open => IsPaneOpen = open); + // The title tracks the page, and the page tracks the language. + this.WhenAnyValue(x => x.CurrentPage) + .Select(static page => page.WhenAnyValue(inner => inner.Title)) + .Switch() + .Subscribe(_ => this.RaisePropertyChanged(nameof(Title))); + // Rail selection drives navigation... this.WhenAnyValue(x => x.SelectedPage).Subscribe(_navigation.NavigateTo); @@ -113,7 +114,12 @@ public partial class ShellViewModel : ViewModelBase public PageViewModel CurrentPage => _currentPage.Value; /// Title of the current page. - public string Title => _title.Value; + /// + /// Read straight off the page rather than snapshotted into a derived property: the page + /// re-raises its own title when the language changes, and a snapshot would keep showing the + /// caption from the previous language until the user navigated somewhere else. + /// + public string Title => CurrentPage.Title; /// Whether the back button is enabled. public bool CanGoBack => _canGoBack.Value; diff --git a/src/AvParser.UI/ViewModels/ViewModelBase.cs b/src/AvParser.UI/ViewModels/ViewModelBase.cs index cfbb1da..4ce16c0 100644 --- a/src/AvParser.UI/ViewModels/ViewModelBase.cs +++ b/src/AvParser.UI/ViewModels/ViewModelBase.cs @@ -1,3 +1,4 @@ +using AvParser.UI.Localization; using ReactiveUI; namespace AvParser.UI.ViewModels; @@ -17,11 +18,28 @@ public abstract class ViewModelBase : ReactiveObject, IActivatableViewModel /// A view model that appears as a top-level destination in the navigation rail. public abstract class PageViewModel : ViewModelBase { - /// Label shown in the sidebar and the title bar. - public abstract string Title { get; } + /// Creates the page and keeps its title in step with the language. + protected PageViewModel() => Localizer.Instance.LanguageChanged += OnLanguageChanged; + + /// Resource key of the label shown in the sidebar and the title bar. + public abstract string TitleKey { get; } + + /// Translated label. + public string Title => Localizer.Instance[TitleKey]; /// /// Key of a StreamGeometry in Styles/Icons.axaml used as the rail icon. /// public abstract string IconKey { get; } + + /// + /// Re-reads anything derived from the current language. + /// + /// + /// Overriders must call the base: the rail and the title bar bind to , and + /// without the notification they would keep the caption from the previous language. + /// + protected virtual void OnLanguageChanged() => this.RaisePropertyChanged(nameof(Title)); + + private void OnLanguageChanged(object? sender, EventArgs e) => OnLanguageChanged(); } diff --git a/src/AvParser.UI/Views/AboutView.axaml b/src/AvParser.UI/Views/AboutView.axaml index 43b48f8..1c92e94 100644 --- a/src/AvParser.UI/Views/AboutView.axaml +++ b/src/AvParser.UI/Views/AboutView.axaml @@ -1,6 +1,7 @@ - + @@ -34,13 +35,13 @@ - + - + - + diff --git a/src/AvParser.UI/Views/DashboardView.axaml b/src/AvParser.UI/Views/DashboardView.axaml index 80daa9f..7f273df 100644 --- a/src/AvParser.UI/Views/DashboardView.axaml +++ b/src/AvParser.UI/Views/DashboardView.axaml @@ -1,8 +1,8 @@ @@ -10,15 +10,11 @@ - + - + @@ -26,10 +22,10 @@ - + - + @@ -42,18 +38,18 @@ - + @@ -61,7 +57,7 @@ - + diff --git a/src/AvParser.UI/Views/ParseView.axaml b/src/AvParser.UI/Views/ParseView.axaml index 91a879d..cc8197a 100644 --- a/src/AvParser.UI/Views/ParseView.axaml +++ b/src/AvParser.UI/Views/ParseView.axaml @@ -1,6 +1,7 @@ - + - - + + - + - - - @@ -94,7 +92,7 @@ BorderThickness="0,0,0,1" BorderBrush="{DynamicResource AppBorderBrush}" > - + - + @@ -190,7 +188,7 @@ Foreground="{DynamicResource AppDangerBrush}" VerticalAlignment="Center" /> - + @@ -199,12 +197,12 @@ - + - + diff --git a/src/AvParser.UI/Views/ProxiesView.axaml b/src/AvParser.UI/Views/ProxiesView.axaml index 18c1cae..ef45802 100644 --- a/src/AvParser.UI/Views/ProxiesView.axaml +++ b/src/AvParser.UI/Views/ProxiesView.axaml @@ -1,53 +1,62 @@ + + + + + + - + - - - - + + - + - + @@ -57,19 +66,19 @@ - + - + - + @@ -102,7 +111,7 @@ BorderThickness="0,0,0,1" BorderBrush="{DynamicResource AppBorderBrush}" > - + - + @@ -181,24 +190,21 @@ - - + + + + + + + + - + - + - + + + + + + + - + - + - + - + - + @@ -53,42 +74,41 @@ - + - + - + - + - + - + - + - + - - + + - - - - - - + + - - - - - - - + + - - - - - - + + diff --git a/src/AvParser.UI/Views/ShellView.axaml b/src/AvParser.UI/Views/ShellView.axaml index 697fd04..e8e3eb5 100644 --- a/src/AvParser.UI/Views/ShellView.axaml +++ b/src/AvParser.UI/Views/ShellView.axaml @@ -1,6 +1,7 @@ @@ -70,7 +71,7 @@ Classes="icon" Command="{Binding GoBackCommand}" IsVisible="{Binding CanGoBack}" - ToolTip.Tip="Back" + ToolTip.Tip="{l:Loc Shell.Back}" Margin="0,0,8,0" > @@ -90,7 +91,7 @@ Grid.Column="3" Classes="icon" Command="{Binding ToggleThemeCommand}" - ToolTip.Tip="Switch light / dark" + ToolTip.Tip="{l:Loc Shell.ToggleTheme}" > tests/ conventions (self-executing xUnit exe) to build at all. /// -internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel +internal sealed class FakePage(string titleKey, string iconKey = "IconHome") : PageViewModel { - public override string Title { get; } = title; + public override string TitleKey { get; } = titleKey; public override string IconKey { get; } = iconKey; } diff --git a/tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs b/tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs new file mode 100644 index 0000000..e8299f2 --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/LocalizationViewTests.cs @@ -0,0 +1,90 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvParser.Core.Settings; +using AvParser.UI.Localization; +using AvParser.UI.Navigation; +using AvParser.UI.ViewModels; +using AvParser.UI.Views; +using ReactiveUI.Primitives.Concurrency; + +namespace AvParser.UI.HeadlessTests; + +public sealed class LocalizationViewTests : IDisposable +{ + /// Leaves the localizer where the rest of the assembly expects it. + public void Dispose() => Localizer.Instance.SetLanguage(AppLanguage.English); + + private static (ShellView View, ShellViewModel ViewModel) ShowShell() + { + var navigation = new NavigationService([new FakePage("Page.Dashboard"), new FakePage("Page.Parse")]); + var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance); + var view = new ShellView { DataContext = viewModel }; + + new Window + { + Width = 1400, + Height = 800, + Content = view, + }.Show(); + + Dispatcher.UIThread.RunJobs(); + return (view, viewModel); + } + + private static IReadOnlyList RailLabels(Visual view) => + [ + .. view.GetVisualDescendants() + .OfType() + .Where(block => block.Classes.Contains("navLabel")) + .Select(block => block.Text ?? string.Empty), + ]; + + [AvaloniaFact] + public void Rendered_text_follows_the_language_without_rebuilding_the_view() + { + Localizer.Instance.SetLanguage(AppLanguage.English); + var (view, _) = ShowShell(); + + var english = RailLabels(view); + english.ShouldBe(["Dashboard", "Parse"]); + + Localizer.Instance.SetLanguage(AppLanguage.Russian); + Dispatcher.UIThread.RunJobs(); + + // The same controls, not a rebuilt tree: this is the whole point of binding through the + // localizer's indexer rather than resolving strings once at load. + RailLabels(view).ShouldBe(["Обзор", "Разбор"]); + } + + [AvaloniaFact] + public void The_title_bar_follows_the_language_too() + { + Localizer.Instance.SetLanguage(AppLanguage.English); + var (view, viewModel) = ShowShell(); + + var title = view.GetVisualDescendants().OfType().Single(block => block.Name == "ShellTitle"); + title.Text.ShouldBe("Dashboard"); + + Localizer.Instance.SetLanguage(AppLanguage.Russian); + Dispatcher.UIThread.RunJobs(); + + viewModel.Title.ShouldBe("Обзор"); + title.Text.ShouldBe("Обзор"); + } + + [AvaloniaFact] + public void Loc_markup_resolves_a_static_caption() + { + Localizer.Instance.SetLanguage(AppLanguage.Russian); + var (view, _) = ShowShell(); + + // Tooltips come from {l:Loc} rather than from a view model, so they exercise the markup + // extension itself. + var toggle = view.GetVisualDescendants().OfType