Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 32 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
<!-- hug the reading start edge -->
<WrapPanel HorizontalAlignment="{Binding Direction, Source={x:Static config:Loc.Instance}, Converter={StaticResource ReadingStartAlignmentConverter}}" />

<!-- swap two children: badges keep the reading start edge, actions move to the far one -->
<StackPanel Grid.Column="{Binding Direction, Source={x:Static config:Loc.Instance}, Converter={StaticResource ReadingStartColumnConverter}}" />
<StackPanel
Grid.Column="{Binding Direction, Source={x:Static config:Loc.Instance}, Converter={StaticResource ReadingEndColumnConverter}}"
HorizontalAlignment="{Binding Direction, Source={x:Static config:Loc.Instance}, Converter={StaticResource ReadingEndAlignmentConverter}}" />
```

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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NotSupportedException>(() =>
ReadingStart.ConvertBack(0, typeof(FlowDirection), null, CultureInfo.InvariantCulture)
);
}
}
Original file line number Diff line number Diff line change
@@ -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<NotSupportedException>(() =>
ReadingStart.ConvertBack(
HorizontalAlignment.Left,
typeof(FlowDirection),
null,
CultureInfo.InvariantCulture
)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace optimizerDuck.Common.Converters;

/// <summary>
/// Maps the UI flow direction to a grid column index, so two children can swap sides per language.
/// </summary>
/// <remarks>
/// WPF does not mirror <see cref="Grid" /> 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 <see cref="Invert" /> for the child that follows it.
/// </remarks>
public class FlowDirectionToColumnConverter : IValueConverter
{
/// <summary>
/// When <see langword="true" />, returns the complementary column for the child that moves the other way.
/// </summary>
public bool Invert { get; set; }

/// <summary>
/// Converts a <see cref="FlowDirection" /> to the column index the child belongs in.
/// </summary>
/// <param name="value">The flow direction, normally <c>Loc.Instance.Direction</c>.</param>
/// <param name="targetType">Unused.</param>
/// <param name="parameter">Unused.</param>
/// <param name="culture">Unused.</param>
/// <returns>0 for LTR and 1 for RTL, or the complementary column when <see cref="Invert" /> is set.</returns>
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
var column = value is FlowDirection.RightToLeft ? 1 : 0;
return Invert ? 1 - column : column;
}

/// <summary>Not supported; the converter is one way.</summary>
/// <param name="value">Unused.</param>
/// <param name="targetType">Unused.</param>
/// <param name="parameter">Unused.</param>
/// <param name="culture">Unused.</param>
/// <returns>Never returns.</returns>
/// <exception cref="NotSupportedException">Always thrown.</exception>
public object ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace optimizerDuck.Common.Converters;

/// <summary>
/// Maps the UI flow direction to the horizontal edge that content lines up with.
/// </summary>
/// <remarks>
/// WPF mirrors text direction and <see cref="TextBlock.TextAlignment" />, but it never mirrors layout:
/// <see cref="HorizontalAlignment" /> is absolute. A row that has to hug the reading start edge has to be told
/// which edge that is for the current language.
/// </remarks>
public class FlowDirectionToHorizontalAlignmentConverter : IValueConverter
{
/// <summary>
/// When <see langword="true" />, maps to the far edge instead of the reading start edge.
/// </summary>
public bool ToEndEdge { get; set; }

/// <summary>
/// Converts a <see cref="FlowDirection" /> to the matching <see cref="HorizontalAlignment" />.
/// </summary>
/// <param name="value">The flow direction, normally <c>Loc.Instance.Direction</c>.</param>
/// <param name="targetType">Unused.</param>
/// <param name="parameter">Unused.</param>
/// <param name="culture">Unused.</param>
/// <returns>Left for LTR and Right for RTL, or the opposite when <see cref="ToEndEdge" /> is set.</returns>
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;
}

/// <summary>Not supported; the converter is one way.</summary>
/// <param name="value">Unused.</param>
/// <param name="targetType">Unused.</param>
/// <param name="parameter">Unused.</param>
/// <param name="culture">Unused.</param>
/// <returns>Never returns.</returns>
/// <exception cref="NotSupportedException">Always thrown.</exception>
public object ConvertBack(
object? value,
Type targetType,
object? parameter,
CultureInfo culture
) => throw new NotSupportedException();
}
Loading