# Gum Localization

> Gum's localization — ILocalizationService, CSV/RESX loading (tool + runtime), Text vs TextNoTranslate paths, Forms control localization.

- Skill: `vchelaru/gum-localization` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vchelaru/gum-localization`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vchelaru/gum-localization/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- Author: vchelaru (https://skillmd.com/u/vchelaru)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vchelaru/gum-localization

---


# Gum Localization

## Architecture Overview

Localization is opt-in via a nullable static property. When set, text assigned through the `"Text"` property name is translated; text assigned through `"TextNoTranslate"` bypasses translation entirely.

**Entry point:** `CustomSetPropertyOnRenderable.LocalizationService` (static, nullable `ILocalizationService?`)

**Default initialization:** `SystemManagers` lazily creates a `LocalizationService` instance using `??=`, so assigning your own service *before* initialization preserves it.

**Access at runtime:** `GumService.Default.LocalizationService` forwards to the static property above.

**Runtime language switching:** `ILocalizationService.CurrentLanguageChanged` fires when `CurrentLanguage` is reassigned to a different value. `GumService` subscribes and walks `Root`/`PopupRoot`/`ModalRoot` re-translating every text-bearing element that was assigned via the localized path. Manual entry point: `GumService.Default.RefreshLocalization()`. Per-element entry: `GraphicalUiElement.RefreshLocalization()` recurses into `Children`. The per-element re-translate is delegated through `GraphicalUiElement.RefreshLocalizationOnElementAction` (wired by `GumService` since `GumRuntime` cannot reference `CustomSetPropertyOnRenderable`). Originating string IDs live in a static `ConditionalWeakTable<GraphicalUiElement, string>` on `CustomSetPropertyOnRenderable`, populated whenever `TrySetPropertyOnText` runs the localization path and cleared by `SetTextNoTranslate`.

## ILocalizationService

`GumCommon/Localization/ILocalizationService.cs` — six members:

- `CurrentLanguage` (int) — index into the translation arrays (0 = default/source language)
- `Languages` (`IReadOnlyList<string>`) — language names populated after loading; empty until a database is loaded
- `CurrentLanguageChanged` (`event Action?`) — fires when `CurrentLanguage` is reassigned to a different value; subscribed by `GumService` to drive automatic re-translation of live visuals
- `AddDatabase(Dictionary<string, string[]>, List<string>)` — loads translations; key = string ID, value = array where `[0]` is the ID and `[1..N]` are translations per language
- `Clear()` — resets the database and Languages list
- `Translate(string stringId)` — returns the translated string for `CurrentLanguage`

## LocalizationService (default implementation)

`GumCommon/Localization/LocalizationService.cs`

Translation logic in `TranslateForLanguage`:
1. If database is empty → return string as-is (no translation, no suffix)
2. If string ID is found → return `mStringDatabase[stringId][language]`
3. If string has no letters (numbers/punctuation/whitespace only) → return as-is (excluded from translation)
4. Otherwise → return `stringId + "(loc)"` — the "(loc)" suffix signals a missing translation key

## Loading Data — LocalizationServiceExtensions

`GumCommon/Localization/LocalizationServiceExtensions.cs` — extension methods on `ILocalizationService`:

**CSV:** `AddCsvDatabase(Stream)` — uses CsvHelper. First column = string ID, subsequent columns = translations. First row = language headers. Languages list populated from header row.

**RESX:** Four overloads — single or multi, path-based or stream-based. All accept an optional `Action<string> onWarning` callback (used on cross-file key collisions; runtime never logs on its own).

- `AddResxDatabase(string baseResxFilePath)` — single base file, auto-discovers satellites (`Strings.resx` + `Strings.es.resx`, `Strings.fr.resx`). Base labeled `"Default"`; satellites use their culture code.
- `AddResxDatabase(IEnumerable<string> baseResxFilePaths, Action<string> onWarning = null)` — **multi-file.** Merges keys across all base files. Language set is the union; missing keys fall back to the string ID. Collision policy: last-write-wins; `onWarning` fires once per colliding key and names all prior sources.
- `AddResxDatabase(IEnumerable<(string languageName, Stream stream)>)` — single-file stream variant for mobile/web.
- `AddResxDatabase(IEnumerable<(string? groupName, IEnumerable<(string languageName, Stream stream)>)> fileGroups, Action<string> onWarning = null)` — multi-group stream variant with explicit group names used in collision warnings.

All formats produce the same internal structure: `Dictionary<string, string[]>` where index 0 = string ID, 1+ = per-language translations.

## Gum Tool Localization Support

The tool stores `LocalizationFiles` — a `List<string>` of project-relative paths — on `GumProjectSave`. A legacy single-string `LocalizationFile` property is kept as a back-compat serialization shim (reads/writes index 0) so `.gumx` files written by the new tool can still be partially loaded by older tool versions. See `gum-project-versioning` skill for why no version bump was needed.

**Policy in `FileCommands.LoadLocalizationFile()`:**
- 0 paths → no-op.
- 1 RESX or multiple RESX → routed through the multi-file `AddResxDatabase(IEnumerable<string>, onWarning)` overload. `onWarning` is wired to `IOutputManager.AddOutput` so collisions appear in the Output tab.
- 1 CSV → single-file CSV path.
- Mixed CSV+RESX or multiple CSVs → `AddError` and skip (no multi-CSV overload by design; `AddDatabase` replaces rather than merges).

**UI:** `ProjectPropertiesViewModel` exposes `LocalizationFiles` with `PreferredDisplayer = typeof(MultiFileDisplay)` — a list editor with Add/Remove/Up/Down buttons that composes `FilePickingLogic`.

**Runtime auto-load:** `GumService.InitializeInternal` applies the same policy and exposes collision warnings on `GumService.Default.LastLoadResult.Warnings` (no Output tab available in games).

**File watching:** `FileChangeReactionLogic.IsLocalizationFileThatShouldTriggerReload(changedFile, IEnumerable<FilePath> baseFiles)` returns true if the changed file matches any base path in the list OR any base's satellite (`{BaseName}.*.resx` in the same directory). A single-file overload is preserved as the inner loop body.

**Language dropdown:** After loading, `ILocalizationService.Languages` is populated. `ProjectPropertiesViewModel.LanguageName` (string) replaces the raw `LanguageIndex` int in the UI. The plugin syncs `LanguageName` ↔ `LanguageIndex` via `IFileCommands.LocalizationLoaded` event (fired at the end of every `LoadLocalizationFile()` call).

**Variable grid refresh:** `LoadLocalizationFile()` calls `_guiCommands.RefreshVariables()` at the end, so the Text property displayer updates from plain textbox to localization combo box without requiring re-selection.

## Translation Flow in CustomSetPropertyOnRenderable

`Gum/Wireframe/CustomSetPropertyOnRenderable.cs`, `TrySetPropertyOnText` method:

When `SetProperty` is called with property name `"Text"` or `"TextNoTranslate"`:

1. If the raw value contains `[` → treated as BBCode markup, applied directly (stored as `StoredMarkupText`)
2. If property is `"Text"` AND `LocalizationService != null` → `rawText = LocalizationService.Translate(rawText)`
3. If the *translated* result contains `[` → treated as BBCode (translation can produce BBCode)
4. If property is `"TextNoTranslate"` → no translation call, value used as-is

**Key detail:** BBCode in the *original* string is checked first (step 1). If there's no BBCode in the original, translation runs, then BBCode is checked again on the result (step 3). This means a translated value can contain BBCode markup even if the string ID didn't.

## TextRuntime

`MonoGameGum/GueDeriving/TextRuntime.cs`:

- `Text` property (get/set) — calls `SetProperty("Text", value)` → goes through localization
- `SetTextNoTranslate(string?)` method — calls `SetProperty("TextNoTranslate", value)` → bypasses localization

`SetTextNoTranslate` is a method, not a property, because the underlying renderable only stores the final string — there's no way to distinguish translated from untranslated text after assignment, so a getter would be misleading.

## Forms Controls Pattern

All Forms controls with displayable text follow the same pattern:

| Control | Localized property | No-translate method |
|---|---|---|
| Button | `Text` | `SetTextNoTranslate()` |
| Label | `Text` | `SetTextNoTranslate()` |
| CheckBox | `Text` | `SetTextNoTranslate()` |
| RadioButton | `Text` | `SetTextNoTranslate()` |
| TextBox | `Text` | `SetTextNoTranslate()` |
| TextBoxBase | `Placeholder` | `SetPlaceholderNoTranslate()` |
| MenuItem | `Header` | `SetHeaderNoTranslate()` |

Internally, all no-translate methods call `SetProperty("TextNoTranslate", value)` on the underlying text component.

### Data-Driven Controls — Intentionally No Localization

**ComboBox** — `Text` property sets `coreTextObject.RawText` directly (bypasses `SetProperty` entirely). This is because ComboBox text comes from `SelectedItem.ToString()`, which is data-driven.

**ListBoxItem** — `UpdateToObject(object o)` sets `coreText.RawText = o?.ToString()` directly. Same reason: items come from a data collection.

To localize data-driven controls, pre-translate values before adding them to the `Items` collection.

### TextBox and PasswordBox — User Input

TextBox internally uses `SetTextNoTranslate` for all user-initiated editing: typing (`HandleCharEntered`), pasting, and deleting. This prevents accidental translation of user-typed content.

PasswordBox uses `TextNoTranslate` for mask characters (e.g., "●●●●") since those should never be translated.

## Gotchas

1. **Language selection is always index-driven.** `CurrentLanguage` (int) is the only way to select a language; `Languages`/`LanguageName` (tool VM) is a display-string wrapper around that index, not a separate string-based selection mechanism.

2. **"(loc)" suffix is intentional** — When a database is loaded but a string ID isn't found, `Translate()` appends "(loc)". This is a debugging feature, not a bug. Empty databases return strings unchanged (no suffix).

3. **Translation happens at assignment time, not read time** — The renderable stores only the final translated string. Live UI is kept in sync by a separate path: `CustomSetPropertyOnRenderable` records the original raw value in a `ConditionalWeakTable<GraphicalUiElement, string>` whenever the localized `Text` path runs, and `GumService` subscribes to `ILocalizationService.CurrentLanguageChanged` to walk the live tree and re-call `SetProperty("Text", storedKey)` on every tracked element. `SetTextNoTranslate` clears the entry, so user input and explicit literals survive language switches. Programmatic dynamic strings assigned via the localized `Text` property still get re-translated on language change and will pick up the `(loc)` suffix — use `SetTextNoTranslate` for those. Bound `Text` is overwritten by refresh; the design assumes bindings and runtime language switching aren't combined.

4. **Null service = no localization** — If `LocalizationService` is null, all text passes through unchanged. This is the expected state when localization isn't needed.

5. **BBCode interaction** — If the original string contains `[`, BBCode is parsed *before* translation (and translation is skipped for that value). If the original has no BBCode but the translated result does, BBCode is parsed on the translated result. Be careful: a string ID with `[` in it won't be translated.

6. **CurrentLanguage is a raw array index** — No bounds checking. Index 0 in the translation array is the string ID itself (not a translation). Actual translations start at index 1. Setting `CurrentLanguage = 0` returns the string ID.

7. **RESX satellite ordering and naming** — Satellites are sorted alphabetically by file path, so `de` comes before `es` comes before `fr`. The base file is always first and labeled `"Default"`. If you need a specific order or names, use the stream-based overload.

8. **ShouldExcludeFromTranslation** — Strings with no letters (pure numbers, punctuation, whitespace, or empty) are silently excluded from translation and returned as-is, with no "(loc)" suffix. This prevents false positives on numeric display values.

## Key Files

- `GumCommon/Localization/ILocalizationService.cs` — interface (`CurrentLanguage`, `Languages`, `AddDatabase`, `Clear`, `Translate`)
- `GumCommon/Localization/LocalizationService.cs` — default implementation
- `GumCommon/Localization/LocalizationServiceExtensions.cs` — CSV/RESX loaders
- `Gum/Wireframe/CustomSetPropertyOnRenderable.cs` — static `LocalizationService` property (with `LocalizationServiceChanged` event), `_localizationKeys` `ConditionalWeakTable`, `TryGetLocalizationKey`, and translation logic in `TrySetPropertyOnText`
- `Gum/Commands/FileCommands.cs` — `LoadLocalizationFile()` (CSV/RESX branch, `LocalizationLoaded` event)
- `Gum/Commands/IFileCommands.cs` — `LocalizationLoaded` event declaration
- `Gum/Managers/FileChangeReactionLogic.cs` — `IsLocalizationFileThatShouldTriggerReload()` (list + satellite matching)
- `Gum/Plugins/InternalPlugins/ProjectPropertiesWindowPlugin/` — Language dropdown + `LocalizationFiles` list editor UI
- `WpfDataUi/Controls/MultiFileDisplay.xaml(.cs)` — `IDataUi` control for `List<string>` file-path lists; composes `FilePickingLogic`
- `WpfDataUi/Controls/FilePickingLogic.cs` — shared file-dialog/relative-path plumbing (pattern like `TextBoxDisplayLogic`)
- `MonoGameGum/GumService.cs` — runtime auto-load of `.gumx` `LocalizationFiles`; collision warnings surface on `GumLoadResult.Warnings`; `RefreshLocalization()` walks the three roots; constructor wires the `RefreshLocalizationOnElementAction` delegate and subscribes to `LocalizationServiceChanged`
- `GumRuntime/GraphicalUiElement.cs` — `RefreshLocalization()` recursion + `RefreshLocalizationOnElementAction` static delegate hook
- `MonoGameGum.Tests/Localization/RefreshLocalizationTests.cs` — runtime language-switch tests (Forms controls, BBCode-from-translation, TextNoTranslate survival, popup/modal roots)
- `MonoGameGum/GueDeriving/TextRuntime.cs` — `Text` property and `SetTextNoTranslate` method
- `MonoGameGum/Forms/Controls/` — Forms control localization pattern
- `MonoGameGum.Tests/Localization/LocalizationServiceExtensionsTests.cs` — CSV/RESX loader tests
- `MonoGameGum.Tests/Localization/LocalizationServiceLanguagesTests.cs` — `ILocalizationService.Languages` interface contract tests
- `Tool/Tests/GumToolUnitTests/Managers/FileChangeReactionLogicTests.cs` — satellite matching tests

