diff --git a/AGENTS.md b/AGENTS.md index 4350eb4..ae9c68d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,8 @@ Reflection discovery, no registration array to update. - Setting writes go through `CustomizationExecutor.ApplyWithDebounceAsync(value, applyAction, debounceMs = 400)`: it debounces, then runs one queued apply at a time, so a burst of toggles applies only the last value. `CustomizeItemViewModel` is the caller. ## Localization -- `Domain/UI/SupportedLanguages.cs` is the single source of truth for the language list (17 entries, currently `en-US` … `id-ID`); the UI binds `SupportedLanguages.All`. A language exists only when its `Resources/Languages/Translations.{culture}.resx` also exists, and `LanguageManagerTests.EveryNeutralKey_ResolvesInEverySupportedLanguage` enforces that every neutral key resolves in every locale. Do not hardcode a language list anywhere else. +- `Domain/UI/SupportedLanguages.cs` is the single source of truth for the language list (18 entries, currently `en-US` … `fa-IR`); the UI binds `SupportedLanguages.All`. A language exists only when its `Resources/Languages/Translations.{culture}.resx` also exists, and `LanguageManagerTests.EveryNeutralKey_ResolvesInEverySupportedLanguage` enforces that every neutral key resolves in every locale. Do not hardcode a language list anywhere else. +- **RTL is text only.** `Loc.Direction` flips `FlowDirection` on the root grid (`MainWindow.xaml`), so text and `TextAlignment` mirror for `ar-SA`, `he-IL` and `fa-IR`. WPF mirrors no layout: `HorizontalAlignment`, `Grid` columns, `StackPanel`/`WrapPanel`/`DockPanel` child order and `Dock` all stay LTR. A row that has to hug the reading edge or swap two children needs `ReadingStartAlignmentConverter` / `ReadingEndAlignmentConverter` / `ReadingStartColumnConverter` / `ReadingEndColumnConverter` (`Common/Converters/`, registered in `UI/Styles/Converters.xaml`) bound to `Loc.Instance.Direction`. Never hardcode `HorizontalAlignment="Left"` on a row that must mirror. - `Translations.resx` (English) is the key source. Preserve `{0}` placeholders verbatim and in order, escape XML specials, keep `xml:space="preserve"`. - Goal is natural localization, not word-for-word translation; each locale must read as if written by a native speaker who knows Windows, PC hardware and optimization software. - Keep technical terms in English where that locale's users expect it (e.g. Vietnamese: SSD, HDD, RAM, CPU, GPU, Driver, Registry, BIOS, UEFI, PowerShell — never "Trình điều khiển" for Driver), and translate the concepts with a natural localized equivalent where one exists (e.g. vi "Memory Integrity" → "Tính toàn vẹn bộ nhớ"). Decide per locale; conventions differ per language. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0feeb6a..a6ad78c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,7 +151,7 @@ optimizerDuck.slnx # Solution file (.slnx format) │ │ │ # ScheduledTaskRevertStep, ShellRevertStep, UsbPowerRevertStep │ │ └── UI/ # Enums & helpers: OptimizationRisk, OptimizationTags, │ │ # OptimizationCategoryOrder, CustomizeOrder, -│ │ # LanguageOption, SupportedLanguages (single source of truth, 17 locales), +│ │ # LanguageOption, SupportedLanguages (single source of truth, 18 locales), │ │ # OptimizationState, RiskVisual, ProcessingProgress, ... │ │ │ ├── Common/ # Shared helpers, extensions, converters @@ -1325,7 +1325,7 @@ All user-facing strings live in `Resources/Languages/Translations.resx` (the Eng ### Available Locales -The app ships with **17 languages** and the list keeps growing. Instead of listing them here (which would go stale), check: +The app ships with **18 languages** and the list keeps growing. Instead of listing them here (which would go stale), check: - **The locale files themselves**: `optimizerDuck/Resources/Languages/` — one `Translations.{locale}.resx` per language, plus `Translations.resx` as the English default. - **The single source of truth**: `Domain/UI/SupportedLanguages.cs` — `SupportedLanguages.All` is the authoritative list shown in the UI (used by `SettingsViewModel` and `LegalDialogViewModel`). Do not duplicate the list. @@ -1341,6 +1341,36 @@ new() { DisplayName = "Deutsch", Culture = new CultureInfo("de-DE") }, Settings and dialogs automatically pick it up via `SupportedLanguages.All` — no other file needs updating. +### Right-to-Left Languages + +`ar-SA`, `he-IL` and `fa-IR` read right to left. `Loc.Direction` flips `FlowDirection` on the root grid of `MainWindow.xaml`, and every page inherits it from there. + +What that mirrors automatically, and what does not: + +| Mirrored by WPF | Not mirrored by WPF | +|-----------------|---------------------| +| Text direction, so Persian/Arabic/Hebrew read correctly | `HorizontalAlignment="Left"` / `"Right"` | +| `TextAlignment` | `Grid` column order | +| | `StackPanel`, `WrapPanel`, `DockPanel` child order | +| | `DockPanel.Dock` | + +So a row that must hug the reading start edge, or two children that must swap sides, has to be told which way is which. Bind the converters in `Common/Converters/` to `Loc.Instance.Direction`: + +```xml + + + + + + +``` + +Never hardcode `HorizontalAlignment="Left"` on a row that has to mirror: it is absolute in WPF, so it stays on the left in every language. `Grid.ColumnSpacing` does not exist in WPF, so use a symmetric `Margin` on the leading child when the two sides need a gap. + +`OptimizationPage.xaml` is the worked example: the tag row, the tag chip and the card footer all use these converters. + ### Runtime Language Switching The app supports switching language without restart (`Loc.Instance.ChangeCulture`). Best practices: diff --git a/optimizerDuck.Test/Common/Converters/FlowDirectionToColumnConverterTests.cs b/optimizerDuck.Test/Common/Converters/FlowDirectionToColumnConverterTests.cs new file mode 100644 index 0000000..94624cf --- /dev/null +++ b/optimizerDuck.Test/Common/Converters/FlowDirectionToColumnConverterTests.cs @@ -0,0 +1,80 @@ +using System.Globalization; +using System.Windows; +using optimizerDuck.Common.Converters; + +namespace optimizerDuck.Test.Common.Converters; + +public class FlowDirectionToColumnConverterTests +{ + private static readonly FlowDirectionToColumnConverter ReadingStart = new(); + private static readonly FlowDirectionToColumnConverter ReadingEnd = new() { Invert = true }; + + [Theory] + [InlineData(FlowDirection.LeftToRight, 0)] + [InlineData(FlowDirection.RightToLeft, 1)] + public void Convert_NotInverted_StartsTheRowAtTheReadingEdge( + FlowDirection direction, + int expected + ) + { + var actual = ReadingStart.Convert( + direction, + typeof(int), + null, + CultureInfo.InvariantCulture + ); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(FlowDirection.LeftToRight, 1)] + [InlineData(FlowDirection.RightToLeft, 0)] + public void Convert_Inverted_ReturnsTheComplementaryColumn( + FlowDirection direction, + int expected + ) + { + var actual = ReadingEnd.Convert(direction, typeof(int), null, CultureInfo.InvariantCulture); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Convert_PairedConverters_NeverShareAColumn() + { + foreach (var direction in new[] { FlowDirection.LeftToRight, FlowDirection.RightToLeft }) + { + var start = ReadingStart.Convert( + direction, + typeof(int), + null, + CultureInfo.InvariantCulture + ); + var end = ReadingEnd.Convert( + direction, + typeof(int), + null, + CultureInfo.InvariantCulture + ); + + Assert.NotEqual(start, end); + } + } + + [Fact] + public void Convert_UnknownValue_FallsBackToLeftToRightLayout() + { + var actual = ReadingStart.Convert(null, typeof(int), null, CultureInfo.InvariantCulture); + + Assert.Equal(0, actual); + } + + [Fact] + public void ConvertBack_IsNotSupported() + { + Assert.Throws(() => + ReadingStart.ConvertBack(0, typeof(FlowDirection), null, CultureInfo.InvariantCulture) + ); + } +} diff --git a/optimizerDuck.Test/Common/Converters/FlowDirectionToHorizontalAlignmentConverterTests.cs b/optimizerDuck.Test/Common/Converters/FlowDirectionToHorizontalAlignmentConverterTests.cs new file mode 100644 index 0000000..ce1d86d --- /dev/null +++ b/optimizerDuck.Test/Common/Converters/FlowDirectionToHorizontalAlignmentConverterTests.cs @@ -0,0 +1,76 @@ +using System.Globalization; +using System.Windows; +using optimizerDuck.Common.Converters; + +namespace optimizerDuck.Test.Common.Converters; + +public class FlowDirectionToHorizontalAlignmentConverterTests +{ + private static readonly FlowDirectionToHorizontalAlignmentConverter ReadingStart = new(); + private static readonly FlowDirectionToHorizontalAlignmentConverter ReadingEnd = new() + { + ToEndEdge = true, + }; + + [Theory] + [InlineData(FlowDirection.LeftToRight, HorizontalAlignment.Left)] + [InlineData(FlowDirection.RightToLeft, HorizontalAlignment.Right)] + public void Convert_ToStartEdge_HugsTheReadingEdge( + FlowDirection direction, + HorizontalAlignment expected + ) + { + var actual = ReadingStart.Convert( + direction, + typeof(HorizontalAlignment), + null, + CultureInfo.InvariantCulture + ); + + Assert.Equal(expected, actual); + } + + [Theory] + [InlineData(FlowDirection.LeftToRight, HorizontalAlignment.Right)] + [InlineData(FlowDirection.RightToLeft, HorizontalAlignment.Left)] + public void Convert_ToEndEdge_HugsTheFarEdge( + FlowDirection direction, + HorizontalAlignment expected + ) + { + var actual = ReadingEnd.Convert( + direction, + typeof(HorizontalAlignment), + null, + CultureInfo.InvariantCulture + ); + + Assert.Equal(expected, actual); + } + + [Fact] + public void Convert_UnknownValue_FallsBackToLeftToRightLayout() + { + var actual = ReadingStart.Convert( + "nonsense", + typeof(HorizontalAlignment), + null, + CultureInfo.InvariantCulture + ); + + Assert.Equal(HorizontalAlignment.Left, actual); + } + + [Fact] + public void ConvertBack_IsNotSupported() + { + Assert.Throws(() => + ReadingStart.ConvertBack( + HorizontalAlignment.Left, + typeof(FlowDirection), + null, + CultureInfo.InvariantCulture + ) + ); + } +} diff --git a/optimizerDuck/Common/Converters/FlowDirectionToColumnConverter.cs b/optimizerDuck/Common/Converters/FlowDirectionToColumnConverter.cs new file mode 100644 index 0000000..7eb48fe --- /dev/null +++ b/optimizerDuck/Common/Converters/FlowDirectionToColumnConverter.cs @@ -0,0 +1,49 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace optimizerDuck.Common.Converters; + +/// +/// Maps the UI flow direction to a grid column index, so two children can swap sides per language. +/// +/// +/// WPF does not mirror columns, so a two column row that has to read from the right in a +/// right to left language has to place its children itself. Register this converter twice: once for the child +/// that starts the row and once with for the child that follows it. +/// +public class FlowDirectionToColumnConverter : IValueConverter +{ + /// + /// When , returns the complementary column for the child that moves the other way. + /// + public bool Invert { get; set; } + + /// + /// Converts a to the column index the child belongs in. + /// + /// The flow direction, normally Loc.Instance.Direction. + /// Unused. + /// Unused. + /// Unused. + /// 0 for LTR and 1 for RTL, or the complementary column when is set. + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var column = value is FlowDirection.RightToLeft ? 1 : 0; + return Invert ? 1 - column : column; + } + + /// Not supported; the converter is one way. + /// Unused. + /// Unused. + /// Unused. + /// Unused. + /// Never returns. + /// Always thrown. + public object ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/optimizerDuck/Common/Converters/FlowDirectionToHorizontalAlignmentConverter.cs b/optimizerDuck/Common/Converters/FlowDirectionToHorizontalAlignmentConverter.cs new file mode 100644 index 0000000..545c488 --- /dev/null +++ b/optimizerDuck/Common/Converters/FlowDirectionToHorizontalAlignmentConverter.cs @@ -0,0 +1,51 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace optimizerDuck.Common.Converters; + +/// +/// Maps the UI flow direction to the horizontal edge that content lines up with. +/// +/// +/// WPF mirrors text direction and , but it never mirrors layout: +/// is absolute. A row that has to hug the reading start edge has to be told +/// which edge that is for the current language. +/// +public class FlowDirectionToHorizontalAlignmentConverter : IValueConverter +{ + /// + /// When , maps to the far edge instead of the reading start edge. + /// + public bool ToEndEdge { get; set; } + + /// + /// Converts a to the matching . + /// + /// The flow direction, normally Loc.Instance.Direction. + /// Unused. + /// Unused. + /// Unused. + /// Left for LTR and Right for RTL, or the opposite when is set. + public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + var isRightToLeft = value is FlowDirection.RightToLeft; + if (ToEndEdge) + return isRightToLeft ? HorizontalAlignment.Left : HorizontalAlignment.Right; + return isRightToLeft ? HorizontalAlignment.Right : HorizontalAlignment.Left; + } + + /// Not supported; the converter is one way. + /// Unused. + /// Unused. + /// Unused. + /// Unused. + /// Never returns. + /// Always thrown. + public object ConvertBack( + object? value, + Type targetType, + object? parameter, + CultureInfo culture + ) => throw new NotSupportedException(); +} diff --git a/optimizerDuck/UI/Pages/Optimize/Categories/OptimizationPage.xaml b/optimizerDuck/UI/Pages/Optimize/Categories/OptimizationPage.xaml index 4476735..ffdb5f4 100644 --- a/optimizerDuck/UI/Pages/Optimize/Categories/OptimizationPage.xaml +++ b/optimizerDuck/UI/Pages/Optimize/Categories/OptimizationPage.xaml @@ -3,6 +3,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:controls="clr-namespace:optimizerDuck.UI.Controls" + xmlns:config="clr-namespace:optimizerDuck.Services.Configuration" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:ext="clr-namespace:optimizerDuck.Common.Extensions" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" @@ -297,39 +298,44 @@ ItemsSource="{Binding TagDisplays}"> - + - + - + @@ -348,8 +354,12 @@ Margin="0,10,0,0" VerticalAlignment="Bottom" ColumnDefinitions="*,Auto"> - - + + - + + + + + + +