gum-localization
GitHubGum UI框架的本地化技能,支持CSV/RESX加载、运行时语言切换及文本翻译。通过ILocalizationService接口管理多语言资源,提供自动重翻译机制,并优化了CSV解析规则与警告处理,确保UI文本的正确国际化展示。
Trigger Scenarios
Install
npx skills add vchelaru/Gum --skill gum-localization -g -y
SKILL.md
Frontmatter
{
"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 loadedCurrentLanguageChanged(event Action?) — fires whenCurrentLanguageis reassigned to a different value; subscribed byGumServiceto drive automatic re-translation of live visualsAddDatabase(Dictionary<string, string[]>, List<string>)— loads translations; key = string ID, value = array where[0]is the ID and[1..N]are translations per languageClear()— resets the database and Languages listTranslate(string stringId)— returns the translated string forCurrentLanguage
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;onWarningfires 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.
Load policy lives in GumCommon/Localization/ProjectLocalizationLoader.cs, shared by the tool (FileCommands.LoadLocalizationFile()), gumcli (HeadlessLocalizationLoader) and the runtime GumService. Each host passes only a ProjectLocalizationLoadOptions (bundle provider, where skips and warnings go); CSV always goes through AddCsvDatabase, so tool and game parse a file the same way.
- 0 paths → no-op.
- 1 RESX or multiple RESX → routed through the multi-file
AddResxDatabase(IEnumerable<string>, onWarning)overload.onWarningis wired toIOutputManager.AddOutputso collisions appear in the Output tab. - 1 CSV → single-file CSV path.
- Mixed CSV+RESX or multiple CSVs →
AddErrorand skip (no multi-CSV overload by design;AddDatabasereplaces 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: the MonoGame and Skia GumService call the same ProjectLocalizationLoader.Load; collision warnings surface 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 asStoredMarkupText) - If property is
"Text"ANDLocalizationService != 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:
Textproperty (get/set) — callsSetProperty("Text", value)→ goes through localizationSetTextNoTranslate(string?)method — callsSetProperty("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:
CustomSetPropertyOnRenderablerecords the original raw value in aConditionalWeakTable<GraphicalUiElement, string>whenever the localizedTextpath runs, andGumServicesubscribes toILocalizationService.CurrentLanguageChangedto walk the live tree and re-callSetProperty("Text", storedKey)on every tracked element.SetTextNoTranslateclears the entry, so user input and explicit literals survive language switches. Programmatic dynamic strings assigned via the localizedTextproperty still get re-translated on language change and will pick up the(loc)suffix — useSetTextNoTranslatefor those. BoundTextis overwritten by refresh; the design assumes bindings and runtime language switching aren't combined. -
Null service = no localization — If
LocalizationServiceis 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 = 0returns the string ID. -
RESX satellite ordering and naming — Satellites are sorted alphabetically by file path, so
decomes beforeescomes beforefr. The base file is always first and labeled"Default". If you need a specific order or names, use the stream-based overload. -
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 implementationGumCommon/Localization/LocalizationServiceExtensions.cs— CSV/RESX loadersGum/Wireframe/CustomSetPropertyOnRenderable.cs— staticLocalizationServiceproperty (withLocalizationServiceChangedevent),_localizationKeysConditionalWeakTable,TryGetLocalizationKey, and translation logic inTrySetPropertyOnTextTools/Gum.Presentation/Commands/FileCommands.cs—LoadLocalizationFile()(CSV/RESX branch,LocalizationLoadedevent)Tools/Gum.Presentation/Commands/IFileCommands.cs—LocalizationLoadedevent declarationTools/Gum.Presentation/Managers/FileChangeReactionLogic.cs—IsLocalizationFileThatShouldTriggerReload()(list + satellite matching)Gum/Plugins/InternalPlugins/ProjectPropertiesWindowPlugin/— Language dropdown +LocalizationFileslist editor UIWpfDataUi/Controls/MultiFileDisplay.xaml(.cs)—IDataUicontrol forList<string>file-path lists; composesFilePickingLogicWpfDataUi/Controls/FilePickingLogic.cs— shared file-dialog/relative-path plumbing (pattern likeTextBoxDisplayLogic)GumCommon/Localization/ProjectLocalizationLoader.cs— load policy for.gumxLocalizationFiles, called by the tool'sFileCommands, gumcli'sHeadlessLocalizationLoader,MonoGameGum/GumService.csandRuntimes/SkiaGum/GumServiceSkiaBase.csMonoGameGum/GumService.cs—RefreshLocalization()walks the three roots; constructor wires theRefreshLocalizationOnElementActiondelegate and subscribes toLocalizationServiceChangedGumRuntime/GraphicalUiElement.cs—RefreshLocalization()recursion +RefreshLocalizationOnElementActionstatic delegate hookMonoGameGum.Tests/Localization/RefreshLocalizationTests.cs— runtime language-switch tests (Forms controls, BBCode-from-translation, TextNoTranslate survival, popup/modal roots)MonoGameGum/GueDeriving/TextRuntime.cs—Textproperty andSetTextNoTranslatemethodMonoGameGum/Forms/Controls/— Forms control localization patternMonoGameGum.Tests/Localization/LocalizationServiceExtensionsTests.cs— CSV/RESX loader testsMonoGameGum.Tests/Localization/LocalizationServiceLanguagesTests.cs—ILocalizationService.Languagesinterface contract testsTests/Gum.Presentation.Tests/FileChangeReactionLogicTests.cs— satellite matching tests
Version History
-
7fc2261
Current 2026-09-28 09:48
统一使用运行时AddCsvDatabase方法解析CSV,移除独立工具解析器;增强CSV解析兼容性(修剪引号外空格、保留未引用单元格内引号、跳过注释行);新增重复String ID警告功能。
- 78a2f53 2026-09-22 22:52
- c93866f 2026-08-20 09:20


