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">
-
-
+
+
-
+
+
+
+
+
+
+