From 46da667739fca8df5a63b3b1543aadcdf01a2484 Mon Sep 17 00:00:00 2001 From: NimaHp Date: Fri, 9 Oct 2026 20:18:28 +0330 Subject: [PATCH] fix(ui): mirror the optimization card layout for RTL languages WPF mirrors text direction and TextAlignment, but no layout: HorizontalAlignment is absolute, Grid keeps its column order and StackPanel/WrapPanel/DockPanel keep their child order and Dock values. The optimization card template hardcoded all three, so in ar-SA, he-IL and fa-IR the tag row hugged the left edge, the tag icon sat on the wrong side of its label, and the footer kept badges left with the toggle and buttons right. Adds two one-way converters in Common/Converters and binds them to Loc.Instance.Direction: ReadingStartAlignmentConverter / ReadingEndAlignmentAlignmentConverter for the edge, ReadingStartColumnConverter / ReadingEndColumnConverter for swapping the two footer groups. OptimizationPage.xaml is the worked example. The tag chip swaps its icon and label through Grid.Column instead of a horizontal StackPanel, and the icon gap became a symmetric Margin because Grid has no ColumnSpacing in WPF. Verified by replicating the card footer and chip with the real converters and measuring the arranged coordinates: LTR is unchanged (badges x=0, actions x=320 of 400, icon left of the label), RTL mirrors (badges x=250, actions x=0, icon x=372 right of the label). Also documents the rule in CONTRIBUTING.md and AGENTS.md, and corrects the locale count that went stale when fa-IR merged. --- AGENTS.md | 3 +- CONTRIBUTING.md | 34 +++++++- .../FlowDirectionToColumnConverterTests.cs | 80 +++++++++++++++++++ ...tionToHorizontalAlignmentConverterTests.cs | 76 ++++++++++++++++++ .../FlowDirectionToColumnConverter.cs | 49 ++++++++++++ ...DirectionToHorizontalAlignmentConverter.cs | 51 ++++++++++++ .../Optimize/Categories/OptimizationPage.xaml | 38 +++++---- optimizerDuck/UI/Styles/Converters.xaml | 6 ++ 8 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 optimizerDuck.Test/Common/Converters/FlowDirectionToColumnConverterTests.cs create mode 100644 optimizerDuck.Test/Common/Converters/FlowDirectionToHorizontalAlignmentConverterTests.cs create mode 100644 optimizerDuck/Common/Converters/FlowDirectionToColumnConverter.cs create mode 100644 optimizerDuck/Common/Converters/FlowDirectionToHorizontalAlignmentConverter.cs diff --git a/AGENTS.md b/AGENTS.md index 4350eb49..ae9c68dc 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 0feeb6a1..a6ad78ca 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 00000000..94624cfb --- /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 00000000..ce1d86da --- /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 00000000..7eb48fe7 --- /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 00000000..545c4889 --- /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 44767351..ffdb5f45 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"> - - + + - + + + + + + +