Skip to content

Latest commit

 

History

History
234 lines (182 loc) · 12.7 KB

File metadata and controls

234 lines (182 loc) · 12.7 KB

Reframe Localization (i18n) — Developer Spec

This is the contract for localizing Reframe's UI. Wave 1 (infrastructure + MainWindow + SettingsPage) is done. Later waves localize the remaining pages (DashboardPage, ProfilesPage / ProfileEditorPage, LayoutsPage / LayoutEditorPage) by following this doc exactly. New contributors adding strings or languages follow it too.

The technical approach is standard resw + MRT Core + x:Uid + PrimaryLanguageOverride, and it is verified working in this unpackaged WinAppSDK 1.8 app (three-state screenshot test, see end).


1. Architecture at a glance

Concern Decision
Resource format .resw (ResX) under Strings\<bcp-47>\, one file per page + Common.resw
XAML strings x:Uid="/<File>/<Key>" subtree syntax (no code)
Code strings Services/Loc.cs → Loc.T("<File>/<Key>"), Loc.T("<File>/<Key>", args…)
Runtime language Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride, set once at App startup before any XAML loads
User setting AppConfig.Language = "system" (default) | "zh-CN" | "en-US"
Switching language Write Config.Language, then restart the app (WinUI does not hot-swap x:Uid reliably)
Languages today zh-CN (default fallback authored first), en-US

Why this works unpackaged (the one gotcha)

MRT Core resolves resources against the runtime context. In unpackaged apps that context uses the system display language, not the user's preferred-language list (this differs from packaged apps). PrimaryLanguageOverride (the WinAppSDK Microsoft.Windows.Globalization one — the UWP Windows.Globalization namesake throws when called unpackaged) overrides that choice. We set it at the very top of the App constructor, before InitializeComponent(), so the whole UI — including x:Uid resolution — comes up in the chosen language.

The PRI (Reframe.pri, named after the assembly, primary="true") is generated automatically by the WinUI/MSIX MSBuild targets from Strings\**\*.resw and copied next to the exe. The unpackaged runtime auto-locates it. No manual makepri step and no <PRIResource> item are needed — the .NET SDK's EnableDefaultPriItems already globs **\*.resw into PRIResource (adding an explicit item causes NETSDK1022 duplicate PRIResource). Convention over configuration: drop a .resw in the right folder and it ships.


2. File layout & naming

Strings/
  zh-CN/                      <- BCP-47 tag = folder name; zh-CN is the default fallback language
    Common.resw               <- shared, page-agnostic words (OK / Cancel / Save / Delete / On / Off …)
    MainWindow.resw           <- one file per page/window; file base name == x:Uid subtree name
    SettingsPage.resw
  en-US/
    Common.resw               <- EVERY language folder has the SAME set of files & the SAME keys
    MainWindow.resw
    SettingsPage.resw

Rules:

  • One .resw per page/window. Name it after the page class without namespace (DashboardPage.resw, ProfilesPage.resw, ProfileEditorPage.resw, LayoutsPage.resw, LayoutEditorPage.resw). Truly shared words go in Common.resw, nothing else.
  • Key parity across languages is mandatory. When you add a key to one language's file, add it to all language files. A missing key falls back to the default language (zh-CN); for en-US parity is your job, not the runtime's.
  • Keys are stable identifiers, not English text. Renaming a key is a retranslation event — don't.
  • The big <xsd:schema> block at the top of every .resw is the fixed ResX preamble. Copy it verbatim from an existing file; only the <data> entries differ.

Key conventions

  • Static control text used from XAML → property identifier: <data name="Title.Text">, <data name="SaveButton.Content">, <data name="NameBox.Header">, <data name="NameBox.PlaceholderText">. The suffix is the XAML property the value sets.
  • Pure string used only from code → simple identifier: <data name="HotkeySaved">, <data name="VersionFormat">.
  • Composite-format strings take {0}, {1} … placeholders and are formatted by Loc.T(key, args…).

3. XAML: x:Uid subtree syntax

For any .resw other than the default Resources.resw (we don't use a default file — every file is named), scope the Uid with the file: x:Uid="/<File>/<Key>".

<!-- Sets .Content from SettingsPage.resw key "RestoreAllButton.Content" -->
<Button x:Uid="/SettingsPage/RestoreAllButton" Click="RestoreAllButton_Click"/>

<!-- TextBlock.Text from "Title.Text"; ComboBoxItem.Content from "ThemeLight.Content" -->
<TextBlock x:Uid="/SettingsPage/Title" FontSize="28"/>
<ComboBoxItem x:Uid="/SettingsPage/ThemeLight"/>

<!-- Nav item .Content from MainWindow.resw "NavDashboard.Content" -->
<NavigationViewItem x:Uid="/MainWindow/NavDashboard" Tag="dashboard"/>

The leading / + file segment is required because our files aren't the default Resources.resw. Remove any literal Content="…"/Text="…" you replace — the resw value is what ships; leaving a literal is just confusing.

Inline <Run> inside a <TextBlock> can carry its own x:Uid (we do this for multi-run paragraphs; see SettingsPage.xaml SteamGridDB block). URLs / brand names in a <Hyperlink> stay literal.

What NOT to put in x:Uid (use code instead)

Attached-property identifiers in resw (e.g. Greeting.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name) are supported on paper but brittle in MRT Core and easy to get subtly wrong. For these, set the value from code-behind via Loc.T. Wave 1 does this for:

  • the title-bar nav button's AutomationProperties.Name + ToolTipService.ToolTip (MainWindow.xaml.cs → InitLocalizedAccessibility)
  • ToggleSwitch.OnContent / OffContent (SettingsPage.xaml.cs → OnLoaded, using Common/On|Off)

Plain control properties (.Text, .Content, .Header, .PlaceholderText) via x:Uid are reliable — prefer them.


4. Code: Services/Loc.cs

Loc is a thin, cached wrapper over Microsoft.Windows.ApplicationModel.Resources.ResourceLoader. Use it for strings built at runtime (status lines, dialogs, formatted text). Prefer x:Uid for anything static in XAML.

using Reframe.Services;

string saved   = Loc.T("SettingsPage/HotkeySaved");                 // simple lookup
string version = Loc.T("SettingsPage/VersionFormat", "1.1.0.0");    // string.Format with {0}
string ok      = Loc.T("Common/Ok");                                // shared word

Contract:

  • Key shape is "File/Key": the part before the first / is the .resw base name; the rest is the resource key. Loc.T splits on the first /.
  • On any failure (missing key, no PRI, resource system down) Loc.T returns the input id unchanged so a bug shows up as a visible File/Key marker instead of an empty control or a crash.
  • Loc.T(key, args…) runs the resolved value through string.Format(CurrentCulture, …); if the placeholder count is wrong it returns the unformatted value rather than throwing.
  • Internally it uses the two-arg ResourceLoader(GetDefaultResourceFilePath(), file) constructor. Do not use new ResourceLoader("File") — the single-arg overload takes a .pri path, not a map name, and will silently resolve nothing.

Loc lives in Services, never in Core (see §6).


5. Language setting & switching

  • AppConfig.Language (Core/Models.cs): "system" (default) / "zh-CN" / "en-US". Core declares the field but does not consume it — only the UI layer (App, SettingsPage) reads/writes it.
  • Startup (App.xaml.cs → ApplyLanguageOverride, called first thing in the constructor before InitializeComponent()): reads Config.Language via ConfigStore.TryLoad() (pure read, no side effects, no default-write on first run). If it's a real language tag, sets ApplicationLanguages.PrimaryLanguageOverride. "system" or unreadable → leave unset (follow system). All exceptions are swallowed — a localization failure must never block startup.
  • Switching (SettingsPage language ComboBox): writes Config.Language, Save(), then shows a ContentDialog ("restart to apply") with a Restart now button. Restart = Process.Start(self, UseShellExecute=true) then App.RequestExit() (the normal tray-exit chain, which restores windows, clears the tray, releases the single-instance mutex, and Application.Exit()s). Because the app is requireAdministrator, the self-relaunch triggers one UAC prompt — expected, and the button label says so. The language ComboBox items show each language's endonym (简体中文, English) and are not themselves localized.

Do not attempt live x:Uid re-resolution on language change — WinUI is unreliable there (it can take two restarts to settle). The restart path is the supported, reliable approach.


6. Core red line (open-source convention)

Core/ must never reference MRT / ResourceLoader / x:Uid. Core is the engine; the test project links Core's source files directly (Tests/Reframe.Core.Tests.csproj) and has no WinAppSDK runtime. Pulling MRT into Core would break the tests and the layering.

Consequently:

  • Engine/diagnostic log strings stay hard-coded English literals in Core/. This is the standard open-source split: the engine logs in English; only the UI is localized. (Wave 1 left Core's existing Chinese log strings alone; the Wave-2 Core agent converts them to English literals.)
  • UI labels that originate from Core (e.g. HotkeyService.ActionInfo.DisplayName) are localized at the UI boundary, not in Core: SettingsPage looks up SettingsPage/Hotkey_<ActionId> and falls back to the Core DisplayName if the key is missing (SettingsPage.xaml.cs → HotkeyLabel). This keeps Core MRT-free while the rows still localize.

Services/Loc.cs is in Services, which the test project does not link — so Loc never reaches the tests.


7. How to localize a page (checklist for the follow-up waves)

  1. Create Strings\zh-CN\<Page>.resw and Strings\en-US\<Page>.resw (copy the schema preamble from an existing file). Keep the same keys in both.
  2. In <Page>.xaml, replace every literal Text=/Content=/Header=/PlaceholderText= with x:Uid="/<Page>/<Key>" and a <data name="<Key>.<Prop>"> entry in both .resw.
  3. In <Page>.xaml.cs, replace every hard-coded UI string with Loc.T("<Page>/<Key>") (or Common/<Key> for shared words). Use {0} placeholders + Loc.T(key, args…) for composed text.
  4. For attached props (AutomationProperties / ToolTipService) and ToggleSwitch On/OffContent, set them in code via Loc.T, not x:Uid.
  5. Strings that come from Core types: look them up at the UI boundary with a Hotkey_*-style keyed resource + fallback to the Core value. Never add MRT to Core.
  6. Build dotnet build -p:Platform=x64. Sanity-check the PRI if unsure: makepri dump /if <out>\Reframe.pri /of dump.xml /o and confirm your subtree + both Language-ZH-CN / Language-EN-US qualifiers are present.
  7. Verify visually in all three language states (see §9).

8. How to add a new language (e.g. ja-JP)

  1. Create Strings\ja-JP\ and copy every .resw from Strings\en-US\ into it.
  2. Translate the <value> of each <data> (leave name keys and <comment> untouched). Keep the {0}/{1} placeholders intact and in a natural order for the language.
  3. Add the option to the SettingsPage language ComboBox:
    • add a <ComboBoxItem Content="日本語"/> (endonym, not localized) in SettingsPage.xaml;
    • extend _languageByIndex in SettingsPage.xaml.cs with "ja-JP" at the matching index.
  4. No csproj or makepri change needed — the default glob picks the new folder up.
  5. Build + 3-state-verify (now 4 states).

That's it: PrimaryLanguageOverride = "ja-JP" at startup will select the new resources.


9. Verification (done for Wave 1)

Use an isolated instance (never the production app, never touching real windows): %TEMP% copy of the build output, asInvoker manifest, a temp single-instance mutex name, an isolated config.json with "EngineEnabled": false. Set "Language" to each of "en-US", "zh-CN", "system", launch, screenshot the nav rail + Settings page, confirm the language. Restore everything afterward; the real tree must still dotnet build -p:Platform=x64 0-error and dotnet test green. Do not commit.

Wave-1 result: PrimaryLanguageOverride set at App-construction time drives x:Uid and Loc.T correctly in the unpackaged build for all three states. Conclusion: the resw + MRT Core + x:Uid + PrimaryLanguageOverride stack is the right approach here; no JSON fallback needed.