| name | gum-localization |
| description | Gum's localization — ILocalizationService, CSV/RESX loading (tool + runtime), Text vs TextNoTranslate paths, Forms control 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:
- If database is empty → return string as-is (no translation, no suffix)
- If string ID is found → return
mStringDatabase[stringId][language]
- If string has no letters (numbers/punctuation/whitespace only) → return as-is (excluded from translation)
- 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":
- If the raw value contains
[ → treated as BBCode markup, applied directly (stored as StoredMarkupText)
- If property is
"Text" AND LocalizationService != null → rawText = LocalizationService.Translate(rawText)
- If the translated result contains
[ → treated as BBCode (translation can produce BBCode)
- 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
-
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.
-
"(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).
-
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.
-
Null service = no localization — If LocalizationService is null, all text passes through unchanged. This is the expected state when localization isn't needed.
-
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.
-
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.
-
RESX satellite ordering and naming — Satellites are sorted alphabetically by file path, so comes before comes before . The base file is always first and labeled . If you need a specific order or names, use the stream-based overload.
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)