diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 7a4c00a..ae8bd31 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -129,9 +129,11 @@ jobs:
- unityVersion: 6000.5.11f1
targetPlatform: StandaloneOSX
samplePackages: '{"com.unity.ugui": "2.0.0"}'
+ # This build also compiles the samples for Addressables, Unity Localization and VContainer. R3 stays out, since
+ # it needs DLLs from NuGet in Assets.
- unityVersion: 6000.5.11f1
targetPlatform: StandaloneLinux64
- samplePackages: '{"com.unity.ugui": "2.0.0"}'
+ samplePackages: '{"com.unity.ugui": "2.0.0", "com.unity.addressables": "2.9.1", "com.unity.localization": "1.5.13", "jp.hadashikick.vcontainer": "https://github.com/hadashiA/VContainer.git?path=VContainer/Assets/VContainer#1.18.0"}'
steps:
- name: Free disk space for the Unity image
uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 232b57c..699a7d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,30 +14,35 @@
10. An LLM handoff: missing or outdated entries copied as a text bundle with rules for the model, its plural forms included, and the model's reply imported from the clipboard, with no API keys.
11. Pseudo-localization: a `Pseudo` language made from the source text, accented, a third longer and bracketed, so hardcoded text, truncation and overflow show before any translation exists. The editor previews it, Play Mode and development builds list it, and release builds never ship it.
12. Character sets, `Tools/ReactiveLocalizer/Character Sets`: every character a language can show, the fallback text of missing entries and number symbols included, copied or saved for TextMeshPro's Font Asset Creator.
-13. Bindings that update text on every language switch without allocating, and messages that are formatted again only when their arguments change.
-14. Live reload: saving a table file updates bound text at once, in Play Mode and Edit Mode.
-15. A Scene view overlay that picks the preview language. Components preview their text live in Edit Mode through `EditModePreview`, and scenes and prefabs always save the text they were authored with.
-16. Rename, move and delete entries across every language at once. Moved entries keep working through `@formerly Table.Entry`, at runtime and in generated code.
-17. Tables loaded on demand: `@loading OnDemand` tables stay in memory only while bindings or `HoldTable` handles use them, and follow every language switch while they do.
-18. Streaming delivery: `@delivery Streaming` tables ship in StreamingAssets and load without blocking the main thread, through UnityWebRequest on Android and WebGL.
-19. Fan translations and mods: `FolderTableSource` compiles `.lang` files from a folder at runtime, `RegisterLanguage` adds new languages, and sources asked first patch shipped languages entry by entry.
-20. Plural rules and number formats for every CLDR 48 locale, built in without `CultureInfo`, with digits and separators each language can override.
-21. Fallback languages loaded only for tables a translation leaves gaps in, so a complete translation costs no extra memory.
-22. `ReactiveText` for view models and data binding, and `BindCharacters` for text that updates without allocating, such as TextMeshPro's.
-23. Import checks that report problems at their line and column, check every translation against its source text and the plural forms of its language, and suggest the key an orphan most likely meant.
-24. *Show source change* finds the source text an outdated translation was made from in git, and marks the words that changed.
-25. One canonical layout for table files, written by every tool, with keys in natural order, such as `Line9` before `Line10`.
-26. Creating a catalog asks for its source language and culture, and checks the culture against CLDR.
-27. Lock-free lookups from any thread, with language switches applied all at once on the main thread, and rapid switches sharing what they load.
-28. Missing keys shown as `[Table.Key]` in every build and reported once, and failures that never throw into game code.
-29. Unity integration: `UnityHost`, tables packed into builds, the editor reading table files directly, and `EntryReference` and `CatalogReference` Inspector fields.
-30. Quick Start and TextMeshPro samples, the TextMeshPro component previewing its text in Edit Mode.
+13. Samples for every common integration: TextMeshPro with a font per language and a check of which characters each font can't draw, UI Toolkit with a UXML data binding on Unity 6 and code binding on 2022.3, uGUI Text, Addressables, VContainer, R3, Google Sheets two-way sync, mods, a localized editor tool, and a migration from Unity Localization.
+14. Migration from Unity Localization: locales become languages and string tables become .lang files, with keys, comments and plain placeholders kept, and a report of what needs converting by hand.
+15. `@delivery Custom` tables that builds don't pack, for a project's own source such as Addressables, and sources that answer asynchronously can decline a request so the next source is asked with nothing reported.
+16. Bindings that update text on every language switch without allocating, and messages that are formatted again only when their arguments change.
+17. Live reload: saving a table file updates bound text at once, in Play Mode and Edit Mode.
+18. A Scene view overlay that picks the preview language. Components preview their text live in Edit Mode through `EditModePreview`, and scenes and prefabs always save the text they were authored with.
+19. Rename, move and delete entries across every language at once. Moved entries keep working through `@formerly Table.Entry`, at runtime and in generated code.
+20. Tables loaded on demand: `@loading OnDemand` tables stay in memory only while bindings or `HoldTable` handles use them, and follow every language switch while they do.
+21. Streaming delivery: `@delivery Streaming` tables ship in StreamingAssets and load without blocking the main thread, through UnityWebRequest on Android and WebGL.
+22. Fan translations and mods: `FolderTableSource` compiles `.lang` files from a folder at runtime, `RegisterLanguage` adds new languages, and sources asked first patch shipped languages entry by entry.
+23. Plural rules and number formats for every CLDR 48 locale, built in without `CultureInfo`, with digits and separators each language can override.
+24. Fallback languages loaded only for tables a translation leaves gaps in, so a complete translation costs no extra memory.
+25. `ReactiveText` for view models and data binding, and `BindCharacters` for text that updates without allocating, such as TextMeshPro's.
+26. Import checks that report problems at their line and column, check every translation against its source text and the plural forms of its language, and suggest the key an orphan most likely meant.
+27. *Show source change* finds the source text an outdated translation was made from in git, and marks the words that changed.
+28. One canonical layout for table files, written by every tool, with keys in natural order, such as `Line9` before `Line10`.
+29. Creating a catalog asks for its source language and culture, and checks the culture against CLDR.
+30. Lock-free lookups from any thread, with language switches applied all at once on the main thread, and rapid switches sharing what they load.
+31. Missing keys shown as `[Table.Key]` in every build and reported once, and failures that never throw into game code.
+32. Unity integration: `UnityHost`, tables packed into builds, the editor reading table files directly, and `EntryReference` and `CatalogReference` Inspector fields.
+33. Public helpers for project tools: `NameRules`, `MessageText`, `TranslationExchange` and `CharacterSets`.
### Known issues
-1. The remaining samples, the TextMeshPro font coverage check and Google Sheets sync among them, aren't available yet.
-2. Undo keeps whole copies of the table files each edit changes, so many edits of one very large table take a lot of memory.
-3. Spreadsheet applications take CSV texts starting with = for formulas, so exchange XLSX with spreadsheets; formula errors in an imported CSV are rejected.
-4. XLIFF 1.2 can't hold control characters, so entries with one are left out of it and reported; XLIFF 2.0 and XLSX keep them.
-5. Tables delivered by custom sources, such as Addressables, have no pseudo version, so pseudo-localization shows their source text.
+1. The README and the guides in Documentation/ aren't written yet.
+2. Tables delivered by custom sources, such as Addressables, have no pseudo version, so pseudo-localization shows their source text.
+3. Undo keeps whole copies of the table files each edit changes, so many edits of one very large table take a lot of memory.
+4. Spreadsheet applications take CSV texts starting with = for formulas, so exchange XLSX with spreadsheets; formula errors in an imported CSV are rejected.
+5. XLIFF 1.2 can't hold control characters, so entries with one are left out of it and reported; XLIFF 2.0 and XLSX keep them.
6. Character sets can't know text that arguments bring at runtime, such as player names; the window adds printable ASCII or extra characters for it.
+7. The Google Sheets sample was checked offline only, since a live check needs a Google account; its requests follow Apps Script's documented web app behavior.
+8. The migration converts Smart Format's plain placeholders only; its plural, choose and other formatters are kept as written and marked for converting by hand.
diff --git a/Concept/ReactiveLocalizer (design specification).md b/Concept/ReactiveLocalizer (design specification).md
index ac83a3a..2658f86 100644
--- a/Concept/ReactiveLocalizer (design specification).md
+++ b/Concept/ReactiveLocalizer (design specification).md
@@ -265,7 +265,8 @@ Purchase [3fa2c1] = Купить
1. **In the editor,** tables are read straight from the imported assets. There is no build step, and they're always current.
2. **`Embedded` (default):** a build step packs the table inside the build, readable synchronously on every platform.
3. **`Streaming`:** a build step packs the table into StreamingAssets, read on demand by `StreamingTableSource`, which keeps huge content out of memory and, on WebGL, out of the initial download. Files are read on a thread pool thread where StreamingAssets is a folder, and through UnityWebRequest where it's a URL (Android, WebGL), which needs the built-in Unity Web Request module. A list of the packed files sits next to the catalog, so a table a language doesn't have is never requested.
- 4. **Anything else** (Addressables, a CDN) plugs into the same table-source interface. A source can attach warnings to what it delivers, which reach the host's diagnostics.
+ 4. **`Custom`:** builds don't pack the table, since a table source of the project delivers it, such as the Addressables sample's. The editor still reads its files directly.
+ 5. **Anything else** (Addressables, a CDN) plugs into the same table-source interface. A source can attach warnings to what it delivers, which reach the host's diagnostics. A source that learns only later that it doesn't have a table, as an asynchronous lookup does, declines the request, and the next source is asked with nothing reported.
7. **Mods and downloaded translations** come from `FolderTableSource`: `
..lang` files anywhere under a folder, compiled when they're asked for, with their problems reported by file and line. The language sections of the folder's `.catalog` files define new languages, which `localizer.RegisterLanguage(...)` adds at runtime, before or after initialization.
## 9. Reactive layer
@@ -467,21 +468,22 @@ Each sample is a thin adapter with its own folder and asmdef. Code that integrat
| Sample | Contents |
|---|---|
-| Quick Start | The `GlobalLocalizer` access point, which a DI setup can also hand its own instance; saving the chosen language; a template for mapping the OS language; a language picker; a demo scene |
-| TextMeshPro | A component with the zero-allocation path, a *Localize* context-menu action that turns the current text into an entry and wires the component, fonts per language, the font coverage check |
-| uGUI Text | A component for legacy `Text` |
-| UI Toolkit | A data binding declared in UXML (Unity 6), plus a code-only binding for 2022.3 |
-| Addressables | `AddressablesTableSource` |
-| VContainer | An installer with async initialization |
-| R3 | Observables built from `ReactiveText` and bindings |
-| Google Sheets | Two-way sync through Apps Script |
+| Quick Start | The `GlobalLocalizer` access point, which a DI setup can also hand its own instance, and which other samples extend with table sources and runtime languages; saving the chosen language; a template for mapping the OS language; a language picker; `LocalizedBehaviour`, the base of the UI components; a demo scene |
+| TextMeshPro | `LocalizedTextMeshPro` with the zero-allocation path; `LanguageFonts`, a font per language that the Edit Mode preview switches too; the font coverage check in its Inspector; a *Localize* context-menu action that turns the current text into an entry and wires the component |
+| UI Toolkit | `LocalizedTextBinding`, a data binding declared in UXML or UI Builder (Unity 6); `BindText` from code and `LocalizedDocument`, which binds a UIDocument's elements by name, on every version |
+| uGUI Text | `LocalizedLegacyText` for legacy `Text`, with its own *Localize* action |
+| Addressables | `AddressablesTableSource` for `@delivery Custom` tables, and a menu that makes their files Addressables at the addresses it loads |
+| VContainer | `LocalizerInstaller`, which registers a catalog's localizer and initializes it asynchronously as the container starts |
+| R3 | `LocalizerObservables`: text, messages fed by other observables, the language, and `ReactiveText` as observables |
+| Google Sheets | Two-way sync through Apps Script, with a README that sets it up |
| Editor Tool | An `EditorWindow` localized with its own catalog, for tool authors |
-| Mods | Fan translations read from a mods folder through the core's `FolderTableSource`, with their languages registered at runtime |
-| Unity Localization Migration | Converts locales to languages and string table collections to `.lang` files, converts simple Smart Format placeholders to ICU, and flags complex ones for review |
+| Mods | `ModTranslations`: fan translations read from a mods folder through the core's `FolderTableSource`, with their languages registered at runtime, and an example mod |
+| Unity Localization Migration | Converts locales to languages and string table collections to `.lang` files, converts simple placeholders to ICU, and flags the rest for review |
1. **UniTask needs no sample:** it can already await `Task` directly.
2. **UI samples find the localizer through Quick Start's access point,** so import Quick Start first.
-3. **In Edit Mode,** the access point returns the editor's preview instance, so components show the preview language live.
+3. **In Edit Mode,** the access point returns the editor's preview instance, so components show the preview language live. Scenes and prefabs still save the text and font they were authored with.
+4. **The migration keeps meaning, not syntax:** a numbered placeholder such as `{0}`, which projects fill with `string.Format`, becomes a named argument such as `{arg0}`, listed for renaming. Smart Format's plural and choose formatters and nested selectors stay as written, marked with a `MIGRATE` comment. Names become PascalCase, and the catalog is named `Localization`.
## 16. Naming reference
@@ -500,7 +502,9 @@ Each sample is a thin adapter with its own folder and asmdef. Code that integrat
| Runtime languages | `RegisterLanguage` |
| Table sources | `EmbeddedTableSource`, `StreamingTableSource`, `FolderTableSource`, and in samples `AddressablesTableSource` |
| Inspector fields | `EntryReference`, `CatalogReference`, `[EntryCatalog(typeof(...))]` |
-| Editor authoring API | `EntryAuthoring`, `CharacterSets` |
+| Editor authoring API | `EntryAuthoring`, `CharacterSets`, `TranslationExchange` |
+| Message text for tools | `MessageText`, `NameRules` |
+| Sample components | `LocalizedBehaviour`, `LocalizedTextMeshPro`, `LocalizedLegacyText`, `LocalizedTextBinding`, `LocalizedDocument` |
| Developer tools (authoring) | `PseudoLocalization`, `CharacterSet` |
| Exchange (authoring) | `ExportBook`, `CsvExchange`, `XlsxExchange`, `XliffExchange`, `LlmHandoff`, `ImportedFile`, `ImportPlanner`, `ImportPlan` |
| Generated code | `LocalizationKeys`, `LocalizationLanguages` |
@@ -517,7 +521,7 @@ Each sample is a thin adapter with its own folder and asmdef. Code that integrat
2. **Unity lane,** mirroring Wireframes:
1. GameCI tests in Edit and Play Mode on the oldest (2022.3) and newest Unity;
2. compiler warnings from the package fail the run;
- 3. player builds for WebGL, Android, iOS, macOS and Linux catch problems that only show up in IL2CPP builds or after code stripping.
+ 3. player builds for WebGL, Android, iOS, macOS and Linux catch problems that only show up in IL2CPP builds or after code stripping. The Linux build also compiles the samples for Addressables, Unity Localization and VContainer.
3. **What the tests cover:**
1. reading then writing a file gives byte-identical output, and randomized input (escapes, invisible characters, merge markers) never crashes the parser and always reports its position;
2. plural tests are generated from CLDR's sample numbers for every plural form of every language;
diff --git a/UnityPackage/Authoring/MessageText.cs b/UnityPackage/Authoring/MessageText.cs
new file mode 100644
index 0000000..e97e214
--- /dev/null
+++ b/UnityPackage/Authoring/MessageText.cs
@@ -0,0 +1,41 @@
+using reromanlee.ReactiveLocalizer.Messages;
+
+namespace reromanlee.ReactiveLocalizer.Authoring
+{
+ ///
+ /// ICU message text for tools that write table files themselves, such as a migration from another localization
+ /// system: plain text quoted so it shows as it is, and messages checked the way imports check them.
+ ///
+ public static class MessageText
+ {
+ ///
+ /// Returns a message that shows exactly: braces, and apostrophes next to them, go in
+ /// quotes, so Press {0} stays text instead of becoming an argument.
+ ///
+ public static string Quote(string text) => MessageQuoting.Quote(text);
+
+ ///
+ /// Returns message text that shows exactly where a message continues after it, such as
+ /// before an argument: a final apostrophe is doubled too, so it can't join the brace that follows.
+ ///
+ public static string Quote(string text, bool isFollowedByArgument) => MessageQuoting.Quote(text, false, isFollowedByArgument);
+
+ /// Returns whether is a valid ICU message, and the first error when it isn't.
+ /// The message, with its escapes resolved.
+ /// The first error and the index it's at; null for a valid message.
+ public static bool IsValid(string message, out string problem)
+ {
+ ParsedMessage parsed = MessageParser.Parse(message);
+ for (int i = 0; i < parsed.Issues.Count; i++)
+ {
+ if (parsed.Issues[i].Severity == Documents.IssueSeverity.Error)
+ {
+ problem = $"at {parsed.Issues[i].Index}: {parsed.Issues[i].Message}";
+ return false;
+ }
+ }
+ problem = null;
+ return true;
+ }
+ }
+}
diff --git a/UnityPackage/Authoring/MessageText.cs.meta b/UnityPackage/Authoring/MessageText.cs.meta
new file mode 100644
index 0000000..c217ce5
--- /dev/null
+++ b/UnityPackage/Authoring/MessageText.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 0b2002ec97973d341bf632f2bb2a021a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Authoring/TableSettings.cs b/UnityPackage/Authoring/TableSettings.cs
index c42e36f..fa4a834 100644
--- a/UnityPackage/Authoring/TableSettings.cs
+++ b/UnityPackage/Authoring/TableSettings.cs
@@ -49,34 +49,36 @@ public static TableSettings Read(TableDocument document, ICollectionReturns which of two allowed values a setting names, ignoring case, or reports it and keeps .
- private static T ReadChoice(DocumentProperty setting, T first, T second, T fallback, ICollection issues)
+ /// Returns which of the allowed values a setting names, ignoring case, or reports it and keeps .
+ private static T ReadChoice(DocumentProperty setting, T[] choices, T fallback, ICollection issues)
{
- string firstName = ToSettingText(first);
- string secondName = ToSettingText(second);
- if (string.Equals(setting.Value, firstName, StringComparison.OrdinalIgnoreCase))
+ string[] names = new string[choices.Length];
+ for (int i = 0; i < choices.Length; i++)
{
- return first;
- }
- if (string.Equals(setting.Value, secondName, StringComparison.OrdinalIgnoreCase))
- {
- return second;
+ names[i] = ToSettingText(choices[i]);
+ if (string.Equals(setting.Value, names[i], StringComparison.OrdinalIgnoreCase))
+ {
+ return choices[i];
+ }
}
+ string allowed = names.Length == 2
+ ? $"'{names[0]}' or '{names[1]}'"
+ : $"'{string.Join("', '", names, 0, names.Length - 1)}' or '{names[names.Length - 1]}'";
issues?.Add(new DocumentIssue(IssueSeverity.Error, setting.Line, 1,
- $"'@{setting.Name}' is '{firstName}' or '{secondName}', not '{setting.Value}'; it stays '{ToSettingText(fallback)}'."));
+ $"'@{setting.Name}' is {allowed}, not '{setting.Value}'; it stays '{ToSettingText(fallback)}'."));
return fallback;
}
diff --git a/UnityPackage/Core/Documents/DocumentNames.cs b/UnityPackage/Core/Documents/DocumentNames.cs
index 03294fc..4823633 100644
--- a/UnityPackage/Core/Documents/DocumentNames.cs
+++ b/UnityPackage/Core/Documents/DocumentNames.cs
@@ -21,7 +21,7 @@ public static class DocumentNames
/// @loading Preload or OnDemand: when the table is in memory.
public const string Loading = "loading";
- /// @delivery Embedded or Streaming: how the table ships in builds.
+ /// @delivery Embedded, Streaming or Custom: how the table ships in builds.
public const string Delivery = "delivery";
/// @generateCode false: the table gets no generated keys.
diff --git a/UnityPackage/Core/Hosting/ITableSource.cs b/UnityPackage/Core/Hosting/ITableSource.cs
index 40993ee..11a10ab 100644
--- a/UnityPackage/Core/Hosting/ITableSource.cs
+++ b/UnityPackage/Core/Hosting/ITableSource.cs
@@ -11,8 +11,8 @@ public interface ITableSource
/// doesn't have it, so the next source is asked.
///
///
- /// After returning true, the source calls or
- /// exactly once, from any thread, either before returning or later.
+ /// After returning true, the source calls ,
+ /// or exactly once, from any thread, either before returning or later.
///
bool TryLoad(in TableRequest request, TableReceiver receiver);
}
diff --git a/UnityPackage/Core/Hosting/TableReceiver.cs b/UnityPackage/Core/Hosting/TableReceiver.cs
index a1b7eea..79a6c2d 100644
--- a/UnityPackage/Core/Hosting/TableReceiver.cs
+++ b/UnityPackage/Core/Hosting/TableReceiver.cs
@@ -6,7 +6,8 @@ namespace reromanlee.ReactiveLocalizer.Hosting
{
///
/// Takes the answer to one : a table source calls with the compiled
- /// data, or with the reason it couldn't deliver it.
+ /// data, with the reason it couldn't deliver it, or when it turns out not
+ /// to have it.
///
///
/// The first call counts and every later one is ignored. Either can come from any thread, before
@@ -40,6 +41,9 @@ internal TableReceiver(TableRequest request, Action onCompleted,
internal string FailureReason { get; private set; }
+ /// Whether the source answered that it doesn't have what was asked after all.
+ internal bool IsDeclined { get; private set; }
+
///
/// Delivers the compiled data asked for. Empty data counts as a failure. The data must stay unchanged until it
/// is read, which happens on the host thread: right away when delivered there, otherwise at the host's next
@@ -88,6 +92,22 @@ internal IReadOnlyList TakeWarnings()
}
}
+ ///
+ /// Answers that the source doesn't have what was asked after all, as when a lookup it had to wait for finds
+ /// nothing, so the next source is asked as if had returned false, and nothing
+ /// is reported. A catalog that no source delivers still fails initialization.
+ ///
+ public void Decline()
+ {
+ if (Interlocked.Exchange(ref _isCompleted, 1) != 0)
+ {
+ return;
+ }
+ IsDeclined = true;
+ FailureReason = "The source doesn't have it.";
+ _onCompleted(this);
+ }
+
/// Reports that the data can't be delivered, and why.
public void Fail(string reason)
{
diff --git a/UnityPackage/Core/Internal/TableLoader.cs b/UnityPackage/Core/Internal/TableLoader.cs
index 72ff0da..5d1c254 100644
--- a/UnityPackage/Core/Internal/TableLoader.cs
+++ b/UnityPackage/Core/Internal/TableLoader.cs
@@ -156,11 +156,7 @@ private void Advance(LanguageLoad load)
}
TableReceiver receiver = new(load.Request, _onReceived, load);
load.IsAwaiting = true;
- if (TryStart(source, load.Request, receiver))
- {
- load.HasAnySource = true;
- }
- else
+ if (!TryStart(source, load.Request, receiver))
{
load.IsAwaiting = false;
}
@@ -190,6 +186,11 @@ private bool TryStart(ITableSource source, in TableRequest request, TableReceive
/// Checks a delivered table and adds it to the load, reporting whatever is wrong with it.
private void Accept(LanguageLoad load, TableReceiver receiver)
{
+ if (receiver.IsDeclined)
+ {
+ return;
+ }
+ load.HasAnySource = true;
TableRequest request = receiver.Request;
ReportSeverity severity = load.IsSource ? ReportSeverity.Error : ReportSeverity.Warning;
if (!receiver.HasData)
@@ -315,7 +316,7 @@ public LanguageLoad(TableRequest request, TableInfo table, bool isSource)
public bool IsFinished { get; set; }
- /// Whether any source took the request.
+ /// Whether any source answered the request without declining it.
public bool HasAnySource { get; set; }
public bool HasLiveWaiter()
diff --git a/UnityPackage/Core/NameRules.cs b/UnityPackage/Core/NameRules.cs
index 157c116..0fc78d6 100644
--- a/UnityPackage/Core/NameRules.cs
+++ b/UnityPackage/Core/NameRules.cs
@@ -7,7 +7,7 @@ namespace reromanlee.ReactiveLocalizer
/// starting with a letter, at most characters. It keeps every name usable as a C#
/// identifier and inside a file name on every platform.
///
- internal static class NameRules
+ public static class NameRules
{
/// Longest name accepted: far above any real name, and short enough for every file system.
public const int MaximumLength = 128;
@@ -36,7 +36,7 @@ public static bool IsValid(ReadOnlySpan name)
/// Throws when breaks the rule, naming the parameter it came from.
/// breaks the rule.
- public static void ThrowIfInvalid(string name, string parameterName)
+ internal static void ThrowIfInvalid(string name, string parameterName)
{
if (!IsValid(name))
{
diff --git a/UnityPackage/Core/TableDelivery.cs b/UnityPackage/Core/TableDelivery.cs
index e79c5ed..43286df 100644
--- a/UnityPackage/Core/TableDelivery.cs
+++ b/UnityPackage/Core/TableDelivery.cs
@@ -7,6 +7,12 @@ public enum TableDelivery
Embedded = 0,
/// Shipped as separate files read on demand, which keeps huge content out of memory.
- Streaming = 1
+ Streaming = 1,
+
+ ///
+ /// Delivered by a table source of the project's own, such as Addressables or a server, so builds don't pack
+ /// it. The editor still reads its files directly.
+ ///
+ Custom = 2
}
}
diff --git a/UnityPackage/Core/Tables/CompiledCatalog.cs b/UnityPackage/Core/Tables/CompiledCatalog.cs
index 382dce0..fdc1f95 100644
--- a/UnityPackage/Core/Tables/CompiledCatalog.cs
+++ b/UnityPackage/Core/Tables/CompiledCatalog.cs
@@ -139,7 +139,7 @@ public static bool TryRead(ReadOnlySpan data, out CatalogInfo catalog, out
!reader.TryReadUInt64(out ulong keysHash) ||
!NameRules.IsValid(tableName) ||
loading > (byte)TableLoading.OnDemand ||
- delivery > (byte)TableDelivery.Streaming)
+ delivery > (byte)TableDelivery.Custom)
{
error = "A table of the catalog is damaged.";
return false;
diff --git a/UnityPackage/Editor/CharacterSetWindow.cs b/UnityPackage/Editor/CharacterSetWindow.cs
index 040be63..af09442 100644
--- a/UnityPackage/Editor/CharacterSetWindow.cs
+++ b/UnityPackage/Editor/CharacterSetWindow.cs
@@ -96,7 +96,7 @@ private void Rebuild()
});
root.Add(catalogField);
- IReadOnlyList languages = CharacterSets.GetLanguages(catalog.Key);
+ IReadOnlyList languages = CharacterSets.GetCatalog(catalog.Key)?.Languages ?? Array.Empty();
List languageNames = new(languages.Count);
for (int i = 0; i < languages.Count; i++)
{
@@ -209,7 +209,7 @@ private void UpdateCharacters()
}
CharacterSet characters = new();
_pickedLanguages.Clear();
- IReadOnlyList languages = CharacterSets.GetLanguages(catalog.Key);
+ IReadOnlyList languages = CharacterSets.GetCatalog(catalog.Key)?.Languages ?? Array.Empty();
for (int i = 0; i < languages.Count; i++)
{
if (!_excludedLanguages.Contains(languages[i].Name))
diff --git a/UnityPackage/Editor/CharacterSets.cs b/UnityPackage/Editor/CharacterSets.cs
index 58fddc5..eda30ab 100644
--- a/UnityPackage/Editor/CharacterSets.cs
+++ b/UnityPackage/Editor/CharacterSets.cs
@@ -19,14 +19,14 @@ namespace reromanlee.ReactiveLocalizer.Editor
public static class CharacterSets
{
///
- /// Returns the languages of a set can be collected for: its own, then the pseudo
- /// language unless it defines one itself. Empty when the project has no usable catalog of that name.
+ /// Returns as its sets are collected: its languages and their fallbacks, then the
+ /// pseudo language unless it defines one itself. Null when the project has no usable catalog of that name.
///
/// The catalog; empty for the default catalog.
- public static IReadOnlyList GetLanguages(CatalogKey catalog)
+ public static CatalogInfo GetCatalog(CatalogKey catalog)
{
IndexedCatalog indexed = Find(catalog);
- return indexed != null ? PseudoTables.GetCatalog(indexed, true).Languages : Array.Empty();
+ return indexed != null ? PseudoTables.GetCatalog(indexed, true) : null;
}
///
diff --git a/UnityPackage/Editor/TableBuildStep.cs b/UnityPackage/Editor/TableBuildStep.cs
index 8ba261e..37bf663 100644
--- a/UnityPackage/Editor/TableBuildStep.cs
+++ b/UnityPackage/Editor/TableBuildStep.cs
@@ -12,8 +12,9 @@ namespace reromanlee.ReactiveLocalizer.Editor
/// Packs every runtime catalog and its tables into the build: tables into the
/// Resources reads synchronously on every platform, and
/// tables into the StreamingAssets reads
- /// on demand. Development builds also get the pseudo language, unless the project settings turn it off. The packed
- /// files exist only while a build runs.
+ /// on demand. tables stay out, for the project's own source to deliver.
+ /// Development builds also get the pseudo language, unless the project settings turn it off. The packed files exist
+ /// only while a build runs.
///
///
/// Catalogs in Editor folders stay out. A failed build never reaches its postprocess step, so the first editor
@@ -73,6 +74,10 @@ public static void Pack(string resourcesRoot, string streamingRoot, bool isPseud
List<(string Language, string Table)> streamed = new();
foreach (IndexedTable table in catalog.Tables)
{
+ if (table.Settings.Delivery == TableDelivery.Custom)
+ {
+ continue;
+ }
foreach (LanguageInfo language in packed.Languages)
{
byte[] data = ReadTable(catalog, table, language.Key);
diff --git a/UnityPackage/Editor/TranslationExchange.cs b/UnityPackage/Editor/TranslationExchange.cs
new file mode 100644
index 0000000..de17a42
--- /dev/null
+++ b/UnityPackage/Editor/TranslationExchange.cs
@@ -0,0 +1,38 @@
+using reromanlee.ReactiveLocalizer.Authoring.Exchange;
+using System.Collections.Generic;
+
+namespace reromanlee.ReactiveLocalizer.Editor
+{
+ ///
+ /// The editor's exchange with translators, for project tools and samples that move translations in their own way,
+ /// such as the Google Sheets sample: what an export holds, and the import preview every format goes through.
+ ///
+ public static class TranslationExchange
+ {
+ ///
+ /// Returns what an export of holds with : its tables,
+ /// rows and the state of every translation, which , and the
+ /// other formats write. Null when the project has no usable catalog of that name.
+ ///
+ /// The catalog; empty for the default catalog.
+ /// What to export; its defaults export every table, language and entry.
+ public static ExportBook Collect(CatalogKey catalog, ExportOptions options)
+ {
+ IndexedCatalog indexed = catalog.IsEmpty ? CatalogIndex.DefaultCatalog : CatalogIndex.Find(catalog);
+ return indexed?.Info != null ? LocalizationExchange.Collect(indexed, options ?? new ExportOptions()) : null;
+ }
+
+ ///
+ /// Opens the import preview for , read with any format's reader, such as
+ /// : it lists every change before anything is written, and applies them as one
+ /// step Undo can take back.
+ ///
+ public static void PreviewImport(IReadOnlyList files)
+ {
+ if (files != null && files.Count > 0)
+ {
+ ImportWindow.Show(files);
+ }
+ }
+ }
+}
diff --git a/UnityPackage/Editor/TranslationExchange.cs.meta b/UnityPackage/Editor/TranslationExchange.cs.meta
new file mode 100644
index 0000000..2e7e543
--- /dev/null
+++ b/UnityPackage/Editor/TranslationExchange.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: c4f2eacc4c735e846b80bc85653552e0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/Addressables/AddressablesTableSource.cs b/UnityPackage/Samples~/Addressables/AddressablesTableSource.cs
new file mode 100644
index 0000000..3afee6d
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/AddressablesTableSource.cs
@@ -0,0 +1,84 @@
+using reromanlee.ReactiveLocalizer.Hosting;
+using reromanlee.ReactiveLocalizer.Samples.QuickStart;
+using reromanlee.ReactiveLocalizer.Unity;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.ResourceManagement.AsyncOperations;
+using UnityEngine.ResourceManagement.ResourceLocations;
+
+namespace reromanlee.ReactiveLocalizer.Samples.Addressables
+{
+ ///
+ /// Delivers the tables a source-language file marks @delivery Custom through Addressables, so they load,
+ /// download and update with the project's other Addressables content. Builds don't pack such tables.
+ ///
+ ///
+ /// Each table file is an Addressable at , which Tools/ReactiveLocalizer/Samples/Mark Custom
+ /// Tables Addressable sets up. A table Addressables don't have is passed on to the next source without a problem,
+ /// so a language that lacks a file falls back as usual. The source installs itself into Quick Start's
+ /// before the first scene loads; in the editor, tables nobody marked still load from
+ /// their files.
+ ///
+ public sealed class AddressablesTableSource : ITableSource
+ {
+ /// The folder every table's address starts with.
+ public const string AddressRoot = "ReactiveLocalizer";
+
+ private static readonly AddressablesTableSource Shared = new();
+
+ /// Returns the address of one table in one language, such as ReactiveLocalizer/Game/Russian/Dialogue.
+ public static string GetAddress(string catalogName, string languageName, string tableName) => $"{AddressRoot}/{catalogName}/{languageName}/{tableName}";
+
+ ///
+ /// Main thread only, like Addressables; the answer comes once Addressables found and loaded the table.
+ public bool TryLoad(in TableRequest request, TableReceiver receiver)
+ {
+ if (request.IsCatalog || request.Delivery != TableDelivery.Custom || receiver == null)
+ {
+ return false;
+ }
+ string address = GetAddress(request.Catalog.Name, request.Language.Name, request.Table.Name);
+ // Whether Addressables have the table is known only once they initialized, so the answer waits for them.
+ AsyncOperationHandle> locations = UnityEngine.AddressableAssets.Addressables.LoadResourceLocationsAsync(address, typeof(TableAsset));
+ locations.Completed += handle => Load(handle, receiver, address);
+ return true;
+ }
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
+ private static void Install()
+ {
+ GlobalLocalizer.AddTableSources(_ => Shared);
+ }
+
+ private static void Load(AsyncOperationHandle> located, TableReceiver receiver, string address)
+ {
+ IResourceLocation location = located.Status == AsyncOperationStatus.Succeeded && located.Result != null && located.Result.Count > 0
+ ? located.Result[0]
+ : null;
+ UnityEngine.AddressableAssets.Addressables.Release(located);
+ if (location == null)
+ {
+ receiver.Decline();
+ return;
+ }
+ AsyncOperationHandle loading = UnityEngine.AddressableAssets.Addressables.LoadAssetAsync(location);
+ loading.Completed += handle => Deliver(handle, receiver, address);
+ }
+
+ private static void Deliver(AsyncOperationHandle loaded, TableReceiver receiver, string address)
+ {
+ TableAsset asset = loaded.Status == AsyncOperationStatus.Succeeded ? loaded.Result : null;
+ if (asset != null && !asset.Data.IsEmpty)
+ {
+ // Read on the spot, since Addressables answer on the main thread, so the asset can go right after.
+ receiver.Receive(asset.Data);
+ }
+ else
+ {
+ receiver.Fail($"Addressables couldn't load {address}: {loaded.OperationException?.Message ?? "its table has no data; reimport its file."}");
+ }
+ UnityEngine.AddressableAssets.Addressables.Release(loaded);
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/Addressables/AddressablesTableSource.cs.meta b/UnityPackage/Samples~/Addressables/AddressablesTableSource.cs.meta
new file mode 100644
index 0000000..c4f39f3
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/AddressablesTableSource.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2a12611cd8da83b4c81fd579fd71888e
\ No newline at end of file
diff --git a/UnityPackage/Samples~/Addressables/Editor.meta b/UnityPackage/Samples~/Addressables/Editor.meta
new file mode 100644
index 0000000..ea9c620
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 2b9cca3ac09d44e4eaf8f95dc4aa2717
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/Addressables/Editor/AddressableTables.cs b/UnityPackage/Samples~/Addressables/Editor/AddressableTables.cs
new file mode 100644
index 0000000..f9e2acc
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/Editor/AddressableTables.cs
@@ -0,0 +1,94 @@
+using reromanlee.ReactiveLocalizer.Authoring;
+using reromanlee.ReactiveLocalizer.Documents;
+using reromanlee.ReactiveLocalizer.Unity;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using UnityEditor;
+using UnityEditor.AddressableAssets;
+using UnityEditor.AddressableAssets.Settings;
+using UnityEditor.AddressableAssets.Settings.GroupSchemas;
+using UnityEngine;
+
+namespace reromanlee.ReactiveLocalizer.Samples.Addressables.Editor
+{
+ ///
+ /// Tools/ReactiveLocalizer/Samples/Mark Custom Tables Addressable: makes every file of every table its
+ /// source-language file marks @delivery Custom an Addressable at the address
+ /// loads it from, in the group .
+ ///
+ ///
+ /// Run it after adding a Custom table or one of its languages, then build the Addressables content as usual. Files
+ /// already in another group move to this one; the group's other settings are the project's to choose.
+ ///
+ internal static class AddressableTables
+ {
+ /// The Addressables group the tables go to, created on first use.
+ public const string GroupName = "ReactiveLocalizer Tables";
+
+ [MenuItem("Tools/ReactiveLocalizer/Samples/Mark Custom Tables Addressable")]
+ private static void Mark()
+ {
+ AddressableAssetSettings settings = AddressableAssetSettingsDefaultObject.GetSettings(true);
+ AddressableAssetGroup group = settings.FindGroup(GroupName) ??
+ settings.CreateGroup(GroupName, false, false, true, null, typeof(BundledAssetGroupSchema), typeof(ContentUpdateGroupSchema));
+
+ // Every imported table file, grouped by its catalog and table; only the source file says how its table ships.
+ Dictionary<(string Catalog, string Table), List<(string Guid, TableAsset Asset)>> tables = new();
+ HashSet<(string Catalog, string Table)> custom = new();
+ foreach (string guid in AssetDatabase.FindAssets($"t:{nameof(TableAsset)}"))
+ {
+ string path = AssetDatabase.GUIDToAssetPath(guid);
+ TableAsset asset = AssetDatabase.LoadAssetAtPath(path);
+ if (asset == null || string.IsNullOrEmpty(asset.CatalogName) || string.IsNullOrEmpty(asset.TableName))
+ {
+ continue;
+ }
+ (string, string) table = (asset.CatalogName, asset.TableName);
+ if (!tables.TryGetValue(table, out List<(string Guid, TableAsset Asset)> files))
+ {
+ files = new List<(string Guid, TableAsset Asset)>();
+ tables.Add(table, files);
+ }
+ files.Add((guid, asset));
+ if (IsCustom(path))
+ {
+ custom.Add(table);
+ }
+ }
+
+ int marked = 0;
+ foreach (KeyValuePair<(string Catalog, string Table), List<(string Guid, TableAsset Asset)>> table in tables)
+ {
+ if (!custom.Contains(table.Key))
+ {
+ continue;
+ }
+ foreach ((string guid, TableAsset asset) in table.Value)
+ {
+ AddressableAssetEntry entry = settings.CreateOrMoveEntry(guid, group, false, false);
+ entry.SetAddress(AddressablesTableSource.GetAddress(asset.CatalogName, asset.LanguageName, asset.TableName), false);
+ marked++;
+ }
+ }
+ settings.SetDirty(AddressableAssetSettings.ModificationEvent.BatchModification, null, true, true);
+ AssetDatabase.SaveAssets();
+ Debug.Log(marked > 0
+ ? $"[ReactiveLocalizer] Marked {marked} table file(s) of {custom.Count} Custom table(s) Addressable, in the group '{GroupName}'."
+ : "[ReactiveLocalizer] No table is marked '@delivery Custom', so nothing was made Addressable.");
+ }
+
+ private static bool IsCustom(string path)
+ {
+ try
+ {
+ return TableSettings.Read(TableDocument.Parse(File.ReadAllText(path)), null).Delivery == TableDelivery.Custom;
+ }
+ catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
+ {
+ Debug.LogWarning($"[ReactiveLocalizer] {path} couldn't be read: {exception.Message}");
+ return false;
+ }
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/Addressables/Editor/AddressableTables.cs.meta b/UnityPackage/Samples~/Addressables/Editor/AddressableTables.cs.meta
new file mode 100644
index 0000000..0975096
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/Editor/AddressableTables.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 711927799d93a2740a8bf44f94405913
\ No newline at end of file
diff --git a/UnityPackage/Samples~/Addressables/Editor/reromanlee.ReactiveLocalizer.Samples.Addressables.Editor.asmdef b/UnityPackage/Samples~/Addressables/Editor/reromanlee.ReactiveLocalizer.Samples.Addressables.Editor.asmdef
new file mode 100644
index 0000000..56ac761
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/Editor/reromanlee.ReactiveLocalizer.Samples.Addressables.Editor.asmdef
@@ -0,0 +1,31 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.Addressables.Editor",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.Addressables.Editor",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Authoring",
+ "reromanlee.ReactiveLocalizer.Samples.Addressables",
+ "Unity.Addressables",
+ "Unity.Addressables.Editor"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "REACTIVE_LOCALIZER_ADDRESSABLES"
+ ],
+ "versionDefines": [
+ {
+ "name": "com.unity.addressables",
+ "expression": "1.19.0",
+ "define": "REACTIVE_LOCALIZER_ADDRESSABLES"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/Addressables/Editor/reromanlee.ReactiveLocalizer.Samples.Addressables.Editor.asmdef.meta b/UnityPackage/Samples~/Addressables/Editor/reromanlee.ReactiveLocalizer.Samples.Addressables.Editor.asmdef.meta
new file mode 100644
index 0000000..cc6795b
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/Editor/reromanlee.ReactiveLocalizer.Samples.Addressables.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: ee1da3ca17526b54988cd64d96f2b368
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/Addressables/reromanlee.ReactiveLocalizer.Samples.Addressables.asmdef b/UnityPackage/Samples~/Addressables/reromanlee.ReactiveLocalizer.Samples.Addressables.asmdef
new file mode 100644
index 0000000..a7c6f94
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/reromanlee.ReactiveLocalizer.Samples.Addressables.asmdef
@@ -0,0 +1,28 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.Addressables",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.Addressables",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Samples.QuickStart",
+ "Unity.Addressables",
+ "Unity.ResourceManager"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "REACTIVE_LOCALIZER_ADDRESSABLES"
+ ],
+ "versionDefines": [
+ {
+ "name": "com.unity.addressables",
+ "expression": "1.19.0",
+ "define": "REACTIVE_LOCALIZER_ADDRESSABLES"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/Addressables/reromanlee.ReactiveLocalizer.Samples.Addressables.asmdef.meta b/UnityPackage/Samples~/Addressables/reromanlee.ReactiveLocalizer.Samples.Addressables.asmdef.meta
new file mode 100644
index 0000000..7d526ed
--- /dev/null
+++ b/UnityPackage/Samples~/Addressables/reromanlee.ReactiveLocalizer.Samples.Addressables.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 0dc3868d34def814d9ccc0fab10e5ee3
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/EditorTool/Editor.meta b/UnityPackage/Samples~/EditorTool/Editor.meta
new file mode 100644
index 0000000..b0a1cf3
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 0187303d0ac984a429eb1389f14ed444
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization.meta b/UnityPackage/Samples~/EditorTool/Editor/Localization.meta
new file mode 100644
index 0000000..3a54449
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d11e16f55d0b061459d9285b29d14ccd
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindow.catalog b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindow.catalog
new file mode 100644
index 0000000..ba57cd1
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindow.catalog
@@ -0,0 +1,12 @@
+# The languages of the Editor Tool sample's window. Its own catalog keeps the tool's texts apart from the game's, and
+# being inside an Editor folder keeps them out of player builds.
+
+@source English
+
+[English]
+DisplayName = English
+Culture = en
+
+[Russian]
+DisplayName = Русский
+Culture = ru
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindow.catalog.meta b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindow.catalog.meta
new file mode 100644
index 0000000..b23dcd1
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindow.catalog.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: ba2bb144e9007564b83e6572ffeb23da
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 11500000, guid: 7cbf7e137af99d14b901ec6fe199bcea, type: 3}
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindowKeys.cs b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindowKeys.cs
new file mode 100644
index 0000000..b96d531
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindowKeys.cs
@@ -0,0 +1,47 @@
+//------------------------------------------------------------------------------
+//
+// Generated by ReactiveLocalizer from the catalog 'ToolWindow'. It changes
+// when a key, alias, argument, table or language changes, never when only a text does.
+// Edits here are lost: edit the catalog and its .lang files instead.
+//
+//------------------------------------------------------------------------------
+
+#pragma warning disable 1591
+
+namespace reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor
+{
+ ///
+ /// The tables and entries of the catalog 'ToolWindow', as keys the compiler checks.
+ ///
+ public static class ToolWindowKeys
+ {
+ /// The catalog itself, for new Localizer(ToolWindowKeys.CatalogKey, host).
+ public static readonly global::reromanlee.ReactiveLocalizer.CatalogKey CatalogKey = new("ToolWindow");
+
+ /// The entries of the table 'Window'.
+ public static class Window
+ {
+ /// The table itself.
+ public static readonly global::reromanlee.ReactiveLocalizer.TableKey TableKey = new("Window");
+
+ public static readonly global::reromanlee.ReactiveLocalizer.EntryKey Click = new(TableKey, "Click");
+ public static readonly global::reromanlee.ReactiveLocalizer.EntryKey Description = new(TableKey, "Description");
+ public static readonly global::reromanlee.ReactiveLocalizer.EntryKey Language = new(TableKey, "Language");
+ public static readonly global::reromanlee.ReactiveLocalizer.EntryKey Title = new(TableKey, "Title");
+
+ public static global::reromanlee.ReactiveLocalizer.EntryMessage Clicks(global::reromanlee.ReactiveLocalizer.MessageNumber count) =>
+ new(_Clicks, new global::reromanlee.ReactiveLocalizer.MessageArgument("count", count));
+
+ private static readonly global::reromanlee.ReactiveLocalizer.EntryKey _Clicks = new(TableKey, "Clicks");
+ }
+ }
+
+ ///
+ /// The languages of the catalog 'ToolWindow'.
+ ///
+ public static class ToolWindowLanguages
+ {
+ public static readonly global::reromanlee.ReactiveLocalizer.LanguageKey English = new("English");
+ public static readonly global::reromanlee.ReactiveLocalizer.LanguageKey Russian = new("Russian");
+ }
+}
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindowKeys.cs.meta b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindowKeys.cs.meta
new file mode 100644
index 0000000..20cbce3
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/ToolWindowKeys.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ac9e7bd2e2b6122458d4a117049c9c75
\ No newline at end of file
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.English.lang b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.English.lang
new file mode 100644
index 0000000..9a08f19
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.English.lang
@@ -0,0 +1,14 @@
+# The window's title on its tab.
+Title = Localized Tool
+
+# Explains what the window shows.
+Description = This window is localized with a catalog of its own inside an Editor folder, so its texts never mix with the game's and never ship in a player build.
+
+# Labels the language picker in the toolbar.
+Language = Language
+
+# The button that counts its clicks.
+Click = Click me
+
+# How many times the button was clicked.
+Clicks = {count, plural, =0 {Not clicked yet} one {Clicked # time} other {Clicked # times}}
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.English.lang.meta b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.English.lang.meta
new file mode 100644
index 0000000..927665e
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.English.lang.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: f38e6af24b903bb49acd0e1377cbdfc2
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 11500000, guid: 8dc18ef216580b44e911cbe1cc1005fb, type: 3}
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.Russian.lang b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.Russian.lang
new file mode 100644
index 0000000..5c0af62
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.Russian.lang
@@ -0,0 +1,5 @@
+Click [b9f779] = Нажми меня
+Clicks [3a7db0] = {count, plural, =0 {Ещё не нажата} one {Нажата # раз} few {Нажата # раза} many {Нажата # раз} other {Нажата # раза}}
+Description [f130f5] = Это окно локализовано собственным каталогом в папке Editor, поэтому его тексты не смешиваются с текстами игры и не попадают в сборку.
+Language [23ec49] = Язык
+Title [09d1a1] = Локализованный инструмент
diff --git a/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.Russian.lang.meta b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.Russian.lang.meta
new file mode 100644
index 0000000..c38216b
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/Localization/Window.Russian.lang.meta
@@ -0,0 +1,10 @@
+fileFormatVersion: 2
+guid: 7dcfa072e3158c246b68eb376deeec9f
+ScriptedImporter:
+ internalIDToNameTable: []
+ externalObjects: {}
+ serializedVersion: 2
+ userData:
+ assetBundleName:
+ assetBundleVariant:
+ script: {fileID: 11500000, guid: 8dc18ef216580b44e911cbe1cc1005fb, type: 3}
diff --git a/UnityPackage/Samples~/EditorTool/Editor/LocalizedToolWindow.cs b/UnityPackage/Samples~/EditorTool/Editor/LocalizedToolWindow.cs
new file mode 100644
index 0000000..e05e2d3
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/LocalizedToolWindow.cs
@@ -0,0 +1,111 @@
+using reromanlee.ReactiveLocalizer.Unity;
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor
+{
+ ///
+ /// An editor window localized with a catalog of its own, as a package or tool author would ship it: the catalog
+ /// sits in an Editor folder, so its texts never mix with the game's and never reach a player build.
+ ///
+ ///
+ /// The window owns its localizer, created as it opens and disposed as it closes or scripts reload. The language
+ /// picked in its toolbar is remembered per machine.
+ ///
+ internal sealed class LocalizedToolWindow : EditorWindow
+ {
+ private const string SavedLanguageKey = "reromanlee.ReactiveLocalizer.Samples.EditorTool.Language";
+
+ private Localizer _localizer;
+ private TextBinding _clicksBinding;
+ private int _clicks;
+
+ [MenuItem("Tools/ReactiveLocalizer/Samples/Localized Tool Window")]
+ private static void Open()
+ {
+ GetWindow();
+ }
+
+ private void CreateGUI()
+ {
+ _localizer?.Dispose();
+ _localizer = new Localizer(ToolWindowKeys.CatalogKey, new UnityHost());
+ // Editor tables load within the call, so the window's language can be picked right away.
+ _localizer.InitializeAsync();
+ IReadOnlyList languages = _localizer.Languages;
+ string saved = EditorPrefs.GetString(SavedLanguageKey, string.Empty);
+ for (int i = 0; i < languages.Count; i++)
+ {
+ if (string.Equals(languages[i].Name, saved, System.StringComparison.OrdinalIgnoreCase))
+ {
+ _localizer.SetLanguageAsync(languages[i].Key);
+ }
+ }
+
+ VisualElement root = rootVisualElement;
+ Toolbar toolbar = new();
+ List names = new(languages.Count);
+ for (int i = 0; i < languages.Count; i++)
+ {
+ names.Add(languages[i].DisplayName);
+ }
+ int current = _localizer.CurrentLanguage != null ? IndexOf(languages, _localizer.CurrentLanguage) : 0;
+ PopupField picker = new(names, Mathf.Max(current, 0));
+ picker.RegisterValueChangedCallback(change =>
+ {
+ LanguageInfo picked = languages[names.IndexOf(change.newValue)];
+ EditorPrefs.SetString(SavedLanguageKey, picked.Name);
+ _localizer.SetLanguageAsync(picked.Key);
+ });
+ toolbar.Add(picker);
+ root.Add(toolbar);
+
+ Label description = new();
+ description.style.whiteSpace = WhiteSpace.Normal;
+ description.style.marginTop = 8f;
+ description.style.marginLeft = 6f;
+ description.style.marginRight = 6f;
+ root.Add(description);
+ Label clicks = new();
+ clicks.style.marginLeft = 6f;
+ Button click = new(() =>
+ {
+ _clicks++;
+ _clicksBinding.SetMessage(ToolWindowKeys.Window.Clicks(_clicks));
+ });
+ click.style.alignSelf = Align.FlexStart;
+ click.style.marginTop = 8f;
+ root.Add(click);
+ root.Add(clicks);
+
+ // Every text follows the language: bindings apply right away and again on every switch.
+ _localizer.Bind(ToolWindowKeys.Window.Title, this, static (window, text) => window.titleContent = new GUIContent(text));
+ _localizer.Bind(ToolWindowKeys.Window.Language, picker, static (field, text) => field.label = text);
+ _localizer.Bind(ToolWindowKeys.Window.Description, description, static (label, text) => label.text = text);
+ _localizer.Bind(ToolWindowKeys.Window.Click, click, static (button, text) => button.text = text);
+ _clicksBinding = _localizer.Bind(ToolWindowKeys.Window.Clicks(_clicks), clicks, static (label, text) => label.text = text);
+ }
+
+ private void OnDisable()
+ {
+ // Disposing the localizer releases every binding it made.
+ _localizer?.Dispose();
+ _localizer = null;
+ }
+
+ private static int IndexOf(IReadOnlyList languages, LanguageInfo language)
+ {
+ for (int i = 0; i < languages.Count; i++)
+ {
+ if (languages[i] == language)
+ {
+ return i;
+ }
+ }
+ return -1;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/EditorTool/Editor/LocalizedToolWindow.cs.meta b/UnityPackage/Samples~/EditorTool/Editor/LocalizedToolWindow.cs.meta
new file mode 100644
index 0000000..288ff06
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/LocalizedToolWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 07af57d52fdce104b95a9c7266f73e33
\ No newline at end of file
diff --git a/UnityPackage/Samples~/EditorTool/Editor/reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor.asmdef b/UnityPackage/Samples~/EditorTool/Editor/reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor.asmdef
new file mode 100644
index 0000000..f7b8f04
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor.asmdef
@@ -0,0 +1,19 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/EditorTool/Editor/reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor.asmdef.meta b/UnityPackage/Samples~/EditorTool/Editor/reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor.asmdef.meta
new file mode 100644
index 0000000..99aefc9
--- /dev/null
+++ b/UnityPackage/Samples~/EditorTool/Editor/reromanlee.ReactiveLocalizer.Samples.EditorTool.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: fa303b9ca95380f468542cd59ca7882d
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/GoogleSheets/AppsScript.gs.txt b/UnityPackage/Samples~/GoogleSheets/AppsScript.gs.txt
new file mode 100644
index 0000000..b9038b6
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/AppsScript.gs.txt
@@ -0,0 +1,80 @@
+/**
+ * ReactiveLocalizer sync for Google Sheets: the Unity editor pushes a catalog's tables here, one sheet per table, and
+ * pulls translators' edits back. Paste this file into Extensions > Apps Script of the spreadsheet, then follow the
+ * README of the Google Sheets sample.
+ *
+ * The spreadsheet stays private: it's never published to the web. Only requests carrying the token stored in the
+ * script's TOKEN property are answered, so keep the web app URL and the token as secret as a password.
+ */
+
+function doPost(e) {
+ try {
+ var request = JSON.parse(e.postData.contents);
+ var token = PropertiesService.getScriptProperties().getProperty('TOKEN');
+ if (!token || token.length < 16) {
+ return reply({ ok: false, error: 'The script has no TOKEN property of at least 16 characters yet; add one under Project Settings > Script Properties.' });
+ }
+ if (request.token !== token) {
+ return reply({ ok: false, error: 'The token is wrong.' });
+ }
+ var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
+ if (request.action === 'pull') {
+ return reply({ ok: true, error: '', sheets: pull(spreadsheet) });
+ }
+ if (request.action === 'push') {
+ push(spreadsheet, request.sheets || []);
+ return reply({ ok: true, error: '', sheets: [] });
+ }
+ return reply({ ok: false, error: 'Unknown action: ' + request.action });
+ } catch (error) {
+ return reply({ ok: false, error: String(error) });
+ }
+}
+
+/** Returns every sheet's cells as the text they show. */
+function pull(spreadsheet) {
+ return spreadsheet.getSheets().map(function (sheet) {
+ var values = sheet.getDataRange().getDisplayValues();
+ return { name: sheet.getName(), rows: values.map(function (cells) { return { cells: cells }; }) };
+ });
+}
+
+/** Replaces the contents of each pushed sheet, creating sheets that don't exist yet; other sheets stay as they are. */
+function push(spreadsheet, sheets) {
+ var lock = LockService.getScriptLock();
+ lock.waitLock(30000);
+ try {
+ sheets.forEach(function (data) {
+ var sheet = spreadsheet.getSheetByName(data.name) || spreadsheet.insertSheet(data.name);
+ sheet.clearContents();
+ var rows = (data.rows || []).map(function (row) { return (row.cells || []).map(asText); });
+ if (rows.length === 0) {
+ return;
+ }
+ var width = rows.reduce(function (most, row) { return Math.max(most, row.length); }, 1);
+ rows.forEach(function (row) {
+ while (row.length < width) {
+ row.push('');
+ }
+ });
+ var range = sheet.getRange(1, 1, rows.length, width);
+ // Plain text format keeps a translation typed as 100 a text; a leading apostrophe keeps = from starting a formula.
+ range.setNumberFormat('@');
+ range.setValues(rows);
+ range.setWrap(true);
+ sheet.setFrozenRows(1);
+ sheet.getRange(1, 1, 1, width).setFontWeight('bold');
+ });
+ } finally {
+ lock.releaseLock();
+ }
+}
+
+function asText(value) {
+ var text = String(value);
+ return text.charAt(0) === '=' ? "'" + text : text;
+}
+
+function reply(body) {
+ return ContentService.createTextOutput(JSON.stringify(body)).setMimeType(ContentService.MimeType.JSON);
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/AppsScript.gs.txt.meta b/UnityPackage/Samples~/GoogleSheets/AppsScript.gs.txt.meta
new file mode 100644
index 0000000..11b2a65
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/AppsScript.gs.txt.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 2dac12f77e5542e4980c037b86b6eeb2
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor.meta b/UnityPackage/Samples~/GoogleSheets/Editor.meta
new file mode 100644
index 0000000..1caac12
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6c6348f84afebaf4c89c3ed853a7648d
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsClient.cs b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsClient.cs
new file mode 100644
index 0000000..b5513a4
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsClient.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Text;
+using UnityEngine;
+using UnityEngine.Networking;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ ///
+ /// Talks to the sample's Apps Script web app: pulls every sheet of the spreadsheet, or pushes sheets into it, each
+ /// request carrying the secret token the script checks.
+ ///
+ ///
+ /// An Apps Script web app answers a POST with a redirect to where its reply waits, which is fetched with a GET.
+ /// Redirects are followed here rather than by the platform, which may resend the POST instead.
+ ///
+ internal static class GoogleSheetsClient
+ {
+ private const int TimeoutSeconds = 120;
+ private const int RedirectLimit = 5;
+
+ /// Pulls every sheet; gets them, or null and the reason it failed.
+ public static void Pull(string url, string token, Action done)
+ {
+ Send(url, new SyncRequest { token = token, action = "pull", sheets = new SheetData[0] }, done);
+ }
+
+ /// Replaces the contents of each sheet in ; gets null on success, else the reason it failed.
+ public static void Push(string url, string token, SheetData[] sheets, Action done)
+ {
+ Send(url, new SyncRequest { token = token, action = "push", sheets = sheets }, (_, problem) => done(problem));
+ }
+
+ private static void Send(string url, SyncRequest request, Action done)
+ {
+ UnityWebRequest post = new(url, UnityWebRequest.kHttpVerbPOST)
+ {
+ uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(JsonUtility.ToJson(request))) { contentType = "application/json" },
+ downloadHandler = new DownloadHandlerBuffer(),
+ redirectLimit = 0,
+ timeout = TimeoutSeconds
+ };
+ Run(post, 0, done);
+ }
+
+ private static void Run(UnityWebRequest web, int redirects, Action done)
+ {
+ web.SendWebRequest().completed += _ =>
+ {
+ long code = web.responseCode;
+ string location = web.GetResponseHeader("Location");
+ string body = web.downloadHandler?.text;
+ string error = web.error;
+ web.Dispose();
+ if (code >= 300 && code < 400 && !string.IsNullOrEmpty(location))
+ {
+ if (redirects >= RedirectLimit)
+ {
+ done(null, "The web app redirected too many times.");
+ return;
+ }
+ UnityWebRequest get = UnityWebRequest.Get(location);
+ get.redirectLimit = 0;
+ get.timeout = TimeoutSeconds;
+ Run(get, redirects + 1, done);
+ return;
+ }
+ Finish(code, body, error, done);
+ };
+ }
+
+ private static void Finish(long code, string body, string error, Action done)
+ {
+ if (code != 200 || string.IsNullOrEmpty(body))
+ {
+ done(null, $"The web app answered {code}{(string.IsNullOrEmpty(error) ? string.Empty : $" ({error})")}. Check its URL, and that it's deployed for anyone to call.");
+ return;
+ }
+ SyncResponse response;
+ try
+ {
+ response = JsonUtility.FromJson(body);
+ }
+ catch (ArgumentException)
+ {
+ // Google answers with an HTML page when the deployment needs a sign-in, which isn't a reply of the script.
+ done(null, "The web app didn't answer with the script's reply. Deploy it with 'Who has access: Anyone', and use the URL ending in /exec.");
+ return;
+ }
+ if (response == null || !response.ok)
+ {
+ done(null, response?.error ?? "The web app sent an empty reply.");
+ return;
+ }
+ done(response.sheets ?? new SheetData[0], null);
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsClient.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsClient.cs.meta
new file mode 100644
index 0000000..45e93b2
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsClient.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 2003beed97f2da245a77f771080deeaa
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsSettings.cs b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsSettings.cs
new file mode 100644
index 0000000..43aabfe
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsSettings.cs
@@ -0,0 +1,49 @@
+using UnityEditor;
+using UnityEngine;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ ///
+ /// Where the Google Sheets sync goes: the web app URL, its token and the catalog synced. Saved in
+ /// UserSettings/, which version control leaves out, since the URL and token together let anyone edit the sheet.
+ ///
+ [FilePath("UserSettings/ReactiveLocalizerGoogleSheets.asset", FilePathAttribute.Location.ProjectFolder)]
+ internal sealed class GoogleSheetsSettings : ScriptableSingleton
+ {
+ [SerializeField] private string _webAppUrl = string.Empty;
+ [SerializeField] private string _token = string.Empty;
+ [SerializeField] private string _catalogName = string.Empty;
+
+ /// The URL of the deployed Apps Script web app, ending in /exec.
+ public string WebAppUrl
+ {
+ get => _webAppUrl ?? string.Empty;
+ set => Change(ref _webAppUrl, value);
+ }
+
+ /// The secret the script's TOKEN property holds.
+ public string Token
+ {
+ get => _token ?? string.Empty;
+ set => Change(ref _token, value);
+ }
+
+ /// The catalog synced, by name; empty for the default catalog.
+ public string CatalogName
+ {
+ get => _catalogName ?? string.Empty;
+ set => Change(ref _catalogName, value);
+ }
+
+ private void Change(ref string field, string value)
+ {
+ value = value?.Trim() ?? string.Empty;
+ if (field == value)
+ {
+ return;
+ }
+ field = value;
+ Save(true);
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsSettings.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsSettings.cs.meta
new file mode 100644
index 0000000..cb87be8
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsSettings.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5e46745c6d70d2346acac111755ce0eb
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsWindow.cs b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsWindow.cs
new file mode 100644
index 0000000..fadbbe8
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsWindow.cs
@@ -0,0 +1,196 @@
+using reromanlee.ReactiveLocalizer.Authoring.Exchange;
+using reromanlee.ReactiveLocalizer.Editor;
+using reromanlee.ReactiveLocalizer.Unity;
+using System;
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ ///
+ /// Tools/ReactiveLocalizer/Samples/Google Sheets Sync: pushes a catalog's tables into a private Google
+ /// spreadsheet, one sheet per table, and pulls translators' edits back through the import preview.
+ ///
+ ///
+ /// Pull, then push: a push replaces the contents of each table's sheet with the project's, so edits made in the
+ /// sheet since the last pull would be lost. A pull changes translations only, as every import does.
+ ///
+ internal sealed class GoogleSheetsWindow : EditorWindow
+ {
+ private Label _status;
+ private Button _pull;
+ private Button _push;
+ private bool _isBusy;
+
+ [MenuItem("Tools/ReactiveLocalizer/Samples/Google Sheets Sync")]
+ private static void Open()
+ {
+ GoogleSheetsWindow window = GetWindow(true, "Google Sheets Sync");
+ window.minSize = new Vector2(420f, 220f);
+ }
+
+ private void CreateGUI()
+ {
+ GoogleSheetsSettings settings = GoogleSheetsSettings.instance;
+ VisualElement root = rootVisualElement;
+ root.style.paddingLeft = 10f;
+ root.style.paddingRight = 10f;
+ root.style.paddingTop = 8f;
+ TextField url = new("Web app URL") { value = settings.WebAppUrl, tooltip = "The URL of the deployed Apps Script, ending in /exec. Keep it secret." };
+ url.RegisterValueChangedCallback(change => settings.WebAppUrl = change.newValue);
+ root.Add(url);
+ TextField token = new("Token") { value = settings.Token, isPasswordField = true, tooltip = "The script's TOKEN property: at least 16 random characters." };
+ token.RegisterValueChangedCallback(change => settings.Token = change.newValue);
+ root.Add(token);
+ TextField catalog = new("Catalog") { value = settings.CatalogName, tooltip = "The catalog to sync; empty for the default catalog." };
+ catalog.RegisterValueChangedCallback(change => settings.CatalogName = change.newValue);
+ root.Add(catalog);
+ Label note = new("The URL and token are saved in UserSettings, which version control leaves out. Pull before pushing: a push replaces each table's sheet.");
+ note.style.whiteSpace = WhiteSpace.Normal;
+ note.style.marginTop = 6f;
+ note.style.color = new Color(0.6f, 0.6f, 0.6f);
+ root.Add(note);
+
+ VisualElement buttons = new();
+ buttons.style.flexDirection = FlexDirection.Row;
+ buttons.style.justifyContent = Justify.FlexEnd;
+ buttons.style.marginTop = 8f;
+ _pull = new Button(Pull) { text = "Pull Translations" };
+ _push = new Button(Push) { text = "Push Tables..." };
+ buttons.Add(_pull);
+ buttons.Add(_push);
+ root.Add(buttons);
+ _status = new Label();
+ _status.style.whiteSpace = WhiteSpace.Normal;
+ _status.style.marginTop = 6f;
+ root.Add(_status);
+ }
+
+ private void Pull()
+ {
+ if (!TryStart(out string url, out string token))
+ {
+ return;
+ }
+ Show("Pulling...", false);
+ GoogleSheetsClient.Pull(url, token, (sheets, problem) =>
+ {
+ Stop();
+ if (problem != null)
+ {
+ Show(problem, true);
+ return;
+ }
+ ExportBook book = TranslationExchange.Collect(CatalogKey, null);
+ List files = new();
+ for (int i = 0; i < sheets.Length; i++)
+ {
+ // Sheets of the spreadsheet's own, such as notes, are no tables of the catalog and stay out.
+ if (sheets[i] != null && book != null && HasTable(book, sheets[i].name))
+ {
+ files.Add(SheetRows.ToImportedFile(sheets[i]));
+ }
+ }
+ if (files.Count == 0)
+ {
+ Show("The spreadsheet has no sheet named after a table of the catalog. Push the tables first.", true);
+ return;
+ }
+ Show($"Pulled {files.Count} sheet(s); the import preview lists what changes.", false);
+ TranslationExchange.PreviewImport(files);
+ });
+ }
+
+ private void Push()
+ {
+ if (!TryStart(out string url, out string token))
+ {
+ return;
+ }
+ ExportBook book = TranslationExchange.Collect(CatalogKey, null);
+ if (book == null || book.Tables.Count == 0)
+ {
+ Stop();
+ Show("The project has no such catalog, or its tables have no entries.", true);
+ return;
+ }
+ if (!EditorUtility.DisplayDialog("Push Tables",
+ $"Replace the sheets of {book.Tables.Count} table(s) with the project's texts? Edits made in those sheets since the last pull are lost.",
+ "Push", "Cancel"))
+ {
+ Stop();
+ return;
+ }
+ Show("Pushing...", false);
+ GoogleSheetsClient.Push(url, token, SheetRows.FromBook(book).ToArray(), problem =>
+ {
+ Stop();
+ Show(problem ?? $"Pushed {book.Tables.Count} table(s), {book.RowCount} entries.", problem != null);
+ });
+ }
+
+ /// The catalog synced; empty for the default catalog, which a name that isn't valid never falls back to.
+ private static CatalogKey CatalogKey => new CatalogReference(GoogleSheetsSettings.instance.CatalogName).ToKey();
+
+ private bool TryStart(out string url, out string token)
+ {
+ GoogleSheetsSettings settings = GoogleSheetsSettings.instance;
+ url = settings.WebAppUrl;
+ token = settings.Token;
+ if (_isBusy)
+ {
+ return false;
+ }
+ if (!url.StartsWith("https://script.google.com/", StringComparison.OrdinalIgnoreCase))
+ {
+ Show("The web app URL starts with https://script.google.com/ and ends in /exec; the sample's README tells where to find it.", true);
+ return false;
+ }
+ if (token.Length < 16)
+ {
+ Show("The token is at least 16 characters, the same as the script's TOKEN property.", true);
+ return false;
+ }
+ if (settings.CatalogName.Length > 0 && CatalogKey.IsEmpty)
+ {
+ Show($"'{settings.CatalogName}' isn't a catalog name: ASCII letters, digits and underscores, starting with a letter.", true);
+ return false;
+ }
+ _isBusy = true;
+ _pull.SetEnabled(false);
+ _push.SetEnabled(false);
+ return true;
+ }
+
+ private void Stop()
+ {
+ _isBusy = false;
+ _pull?.SetEnabled(true);
+ _push?.SetEnabled(true);
+ }
+
+ private void Show(string message, bool isProblem)
+ {
+ if (_status == null)
+ {
+ return;
+ }
+ _status.text = message;
+ _status.style.color = isProblem ? new Color(0.9f, 0.6f, 0.2f) : new Color(0.6f, 0.6f, 0.6f);
+ }
+
+ private static bool HasTable(ExportBook book, string name)
+ {
+ for (int i = 0; i < book.Tables.Count; i++)
+ {
+ if (string.Equals(book.Tables[i].Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsWindow.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsWindow.cs.meta
new file mode 100644
index 0000000..6d48103
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/GoogleSheetsWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ec8076449db0dcf40ae34521985690d6
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/RowData.cs b/UnityPackage/Samples~/GoogleSheets/Editor/RowData.cs
new file mode 100644
index 0000000..07495af
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/RowData.cs
@@ -0,0 +1,11 @@
+using System;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ /// One row of a sheet: its cells, left to right.
+ [Serializable]
+ public sealed class RowData
+ {
+ public string[] cells;
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/RowData.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/RowData.cs.meta
new file mode 100644
index 0000000..ffe9ca2
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/RowData.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 6fd3cd35f2388a44f957ff138bd6d12e
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SheetData.cs b/UnityPackage/Samples~/GoogleSheets/Editor/SheetData.cs
new file mode 100644
index 0000000..96a010f
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SheetData.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ /// One sheet as the Apps Script sends and takes it: its name and its rows of cells.
+ /// Fields are named as the JSON the script reads and writes, which JsonUtility maps by name.
+ [Serializable]
+ public sealed class SheetData
+ {
+ public string name;
+ public RowData[] rows;
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SheetData.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/SheetData.cs.meta
new file mode 100644
index 0000000..12fff02
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SheetData.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b1590868888393a49a716579fd0462e8
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SheetRows.cs b/UnityPackage/Samples~/GoogleSheets/Editor/SheetRows.cs
new file mode 100644
index 0000000..4c09630
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SheetRows.cs
@@ -0,0 +1,112 @@
+using reromanlee.ReactiveLocalizer.Authoring.Exchange;
+using System.Collections.Generic;
+using System.Text;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ ///
+ /// Turns exported tables into sheets and sheets back into imported files, through the package's CSV format both
+ /// ways, so a sheet has exactly the columns, keys and escaping a CSV export has, and pulls are read and checked like
+ /// any CSV import.
+ ///
+ public static class SheetRows
+ {
+ /// Returns every table of as a sheet named after it.
+ public static List FromBook(ExportBook book)
+ {
+ List sheets = new(book.Tables.Count);
+ for (int t = 0; t < book.Tables.Count; t++)
+ {
+ ExportTable table = book.Tables[t];
+ List> records = ParseCsv(Encoding.UTF8.GetString(CsvExchange.Write(book, table)));
+ RowData[] rows = new RowData[records.Count];
+ for (int r = 0; r < rows.Length; r++)
+ {
+ rows[r] = new RowData { cells = records[r].ToArray() };
+ }
+ sheets.Add(new SheetData { name = table.Name, rows = rows });
+ }
+ return sheets;
+ }
+
+ /// Returns read as the CSV file of the table it's named after.
+ public static ImportedFile ToImportedFile(SheetData sheet)
+ {
+ StringBuilder csv = new();
+ RowData[] rows = sheet.rows ?? new RowData[0];
+ for (int r = 0; r < rows.Length; r++)
+ {
+ string[] cells = rows[r]?.cells ?? new string[0];
+ for (int c = 0; c < cells.Length; c++)
+ {
+ if (c > 0)
+ {
+ csv.Append(',');
+ }
+ // Every cell quoted, so separators, quotes and line breaks in it stay text.
+ csv.Append('"').Append((cells[c] ?? string.Empty).Replace("\"", "\"\"")).Append('"');
+ }
+ csv.Append("\r\n");
+ }
+ return CsvExchange.Read($"{sheet.name}.csv", Encoding.UTF8.GetBytes(csv.ToString()));
+ }
+
+ /// Reads the CSV the package writes: comma separated, quoted where needed, rows ending with CRLF, after a byte order mark.
+ private static List> ParseCsv(string text)
+ {
+ List> records = new();
+ List record = new();
+ StringBuilder field = new();
+ bool isQuoted = false;
+ int start = text.Length > 0 && text[0] == '\uFEFF' ? 1 : 0;
+ for (int i = start; i < text.Length; i++)
+ {
+ char character = text[i];
+ if (isQuoted)
+ {
+ if (character != '"')
+ {
+ field.Append(character);
+ }
+ else if (i + 1 < text.Length && text[i + 1] == '"')
+ {
+ field.Append('"');
+ i++;
+ }
+ else
+ {
+ isQuoted = false;
+ }
+ continue;
+ }
+ switch (character)
+ {
+ case '"':
+ isQuoted = true;
+ break;
+ case ',':
+ record.Add(field.ToString());
+ field.Clear();
+ break;
+ case '\r':
+ break;
+ case '\n':
+ record.Add(field.ToString());
+ field.Clear();
+ records.Add(record);
+ record = new List();
+ break;
+ default:
+ field.Append(character);
+ break;
+ }
+ }
+ if (field.Length > 0 || record.Count > 0)
+ {
+ record.Add(field.ToString());
+ records.Add(record);
+ }
+ return records;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SheetRows.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/SheetRows.cs.meta
new file mode 100644
index 0000000..b9cf349
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SheetRows.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 214458ea69fdc344f9da18e59c9088c1
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SyncRequest.cs b/UnityPackage/Samples~/GoogleSheets/Editor/SyncRequest.cs
new file mode 100644
index 0000000..af334e7
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SyncRequest.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ /// What the editor asks the Apps Script: to pull every sheet, or to push the sheets it sends.
+ [Serializable]
+ internal sealed class SyncRequest
+ {
+ public string token;
+ public string action;
+ public SheetData[] sheets;
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SyncRequest.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/SyncRequest.cs.meta
new file mode 100644
index 0000000..fdc3505
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SyncRequest.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 51b9ba5d58946a2438eb9b820f9e5cc5
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SyncResponse.cs b/UnityPackage/Samples~/GoogleSheets/Editor/SyncResponse.cs
new file mode 100644
index 0000000..9d686fc
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SyncResponse.cs
@@ -0,0 +1,13 @@
+using System;
+
+namespace reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor
+{
+ /// What the Apps Script answers: whether it worked, why not, and the sheets a pull asked for.
+ [Serializable]
+ internal sealed class SyncResponse
+ {
+ public bool ok;
+ public string error;
+ public SheetData[] sheets;
+ }
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/SyncResponse.cs.meta b/UnityPackage/Samples~/GoogleSheets/Editor/SyncResponse.cs.meta
new file mode 100644
index 0000000..32752c2
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/SyncResponse.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 4375c6fca765c104c8d15fadbf743ce6
\ No newline at end of file
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor.asmdef b/UnityPackage/Samples~/GoogleSheets/Editor/reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor.asmdef
new file mode 100644
index 0000000..392911d
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor.asmdef
@@ -0,0 +1,21 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Authoring",
+ "reromanlee.ReactiveLocalizer.Editor"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/GoogleSheets/Editor/reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor.asmdef.meta b/UnityPackage/Samples~/GoogleSheets/Editor/reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor.asmdef.meta
new file mode 100644
index 0000000..474054b
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/Editor/reromanlee.ReactiveLocalizer.Samples.GoogleSheets.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 90138a34689cba848992836348f29b0d
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/GoogleSheets/README.md b/UnityPackage/Samples~/GoogleSheets/README.md
new file mode 100644
index 0000000..e781e99
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/README.md
@@ -0,0 +1,28 @@
+# Google Sheets sync
+
+Two-way sync of a catalog with a private Google spreadsheet: one sheet per table, with the same columns as a CSV export. Translators edit the language columns, and the editor pulls their work back through the import preview.
+
+### Set up the spreadsheet
+
+1. Create a spreadsheet in Google Sheets and keep it private. Share it only with the people who translate.
+2. Open *Extensions > Apps Script*, replace the contents of `Code.gs` with `AppsScript.gs.txt` from this sample, and save.
+3. Open *Project Settings > Script Properties* and add the property `TOKEN`. Its value is a secret of at least 16 random characters, such as one a password manager makes.
+4. Choose *Deploy > New deployment*, with the type *Web app*, *Execute as: Me* and *Who has access: Anyone*. Deploy it, allow the access it asks for, and copy the web app URL, which ends in `/exec`.
+
+### Sync from Unity
+
+1. Open *Tools > ReactiveLocalizer > Samples > Google Sheets Sync*, and paste the URL and the token. Leave *Catalog* empty for the default catalog.
+2. *Push Tables* writes every table of the catalog into its own sheet, replacing what that sheet held.
+3. *Pull Translations* reads every sheet named after a table and opens the import preview, which lists every change before anything is written.
+
+Pull before you push, since a push replaces each table's sheet, and edits made since the last pull would be lost.
+
+### Security
+
+1. *Who has access: Anyone* lets anyone with the URL call the script, but it answers only requests carrying the token. The spreadsheet is never published to the web.
+2. The editor saves the URL and token in `UserSettings/`, which version control leaves out. Keep them out of commits, chats and screenshots.
+3. To revoke access, change the `TOKEN` property; the old token stops working at once.
+
+### What a pull changes
+
+A pull is an import like any other. It changes translations only, never keys. Rows with unknown keys are rejected as orphans, empty cells never erase a translation, and changes to the source column are ignored unless *Import source changes* is ticked in the preview.
diff --git a/UnityPackage/Samples~/GoogleSheets/README.md.meta b/UnityPackage/Samples~/GoogleSheets/README.md.meta
new file mode 100644
index 0000000..adc7e0d
--- /dev/null
+++ b/UnityPackage/Samples~/GoogleSheets/README.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: f3d402c2957f437418a738f6a28f8d84
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/Mods/Editor.meta b/UnityPackage/Samples~/Mods/Editor.meta
new file mode 100644
index 0000000..61bfa8c
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 11aefaa1af5ad504cb3ac8f356ce9903
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/Mods/Editor/ExampleModInstaller.cs b/UnityPackage/Samples~/Mods/Editor/ExampleModInstaller.cs
new file mode 100644
index 0000000..f44a4bf
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/Editor/ExampleModInstaller.cs
@@ -0,0 +1,80 @@
+using System;
+using System.IO;
+using UnityEditor;
+using UnityEngine;
+
+namespace reromanlee.ReactiveLocalizer.Samples.Mods.Editor
+{
+ ///
+ /// Tools/ReactiveLocalizer/Samples/Install Example Mod: copies the sample's example fan translation, an
+ /// invented Elvish for the Quick Start scene plus a patch of its English, into the mods folder players use.
+ ///
+ internal static class ExampleModInstaller
+ {
+ private const string ExampleFolder = "ExampleMod~";
+
+ [MenuItem("Tools/ReactiveLocalizer/Samples/Install Example Mod")]
+ private static void Install()
+ {
+ string source = FindExample();
+ if (source == null)
+ {
+ Debug.LogWarning($"[ReactiveLocalizer] The Mods sample's {ExampleFolder} folder wasn't found next to its scripts.");
+ return;
+ }
+ try
+ {
+ Copy(source, ModTranslations.Root);
+ }
+ catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
+ {
+ Debug.LogWarning($"[ReactiveLocalizer] The example mod couldn't be copied to {ModTranslations.Root}: {exception.Message}");
+ return;
+ }
+ Debug.Log($"[ReactiveLocalizer] Installed the example mod in {ModTranslations.Root}. Enter Play Mode in the Quick Start scene and pick Elvish.");
+ EditorUtility.RevealInFinder(ModTranslations.Root);
+ }
+
+ [MenuItem("Tools/ReactiveLocalizer/Samples/Remove Mods")]
+ private static void Remove()
+ {
+ if (Directory.Exists(ModTranslations.Root) &&
+ EditorUtility.DisplayDialog("Remove Mods", $"Delete every mod in {ModTranslations.Root}?", "Delete", "Cancel"))
+ {
+ Directory.Delete(ModTranslations.Root, true);
+ }
+ }
+
+ /// Returns the example mod's folder, found next to the sample's own scripts wherever it was imported.
+ private static string FindExample()
+ {
+ foreach (string guid in AssetDatabase.FindAssets($"t:{nameof(MonoScript)} {nameof(ModTranslations)}"))
+ {
+ string scriptPath = AssetDatabase.GUIDToAssetPath(guid);
+ if (Path.GetFileNameWithoutExtension(scriptPath) != nameof(ModTranslations))
+ {
+ continue;
+ }
+ string folder = Path.Combine(Path.GetDirectoryName(Path.GetFullPath(scriptPath)) ?? string.Empty, ExampleFolder);
+ if (Directory.Exists(folder))
+ {
+ return folder;
+ }
+ }
+ return null;
+ }
+
+ private static void Copy(string source, string target)
+ {
+ Directory.CreateDirectory(target);
+ foreach (string file in Directory.GetFiles(source))
+ {
+ File.Copy(file, Path.Combine(target, Path.GetFileName(file)), true);
+ }
+ foreach (string folder in Directory.GetDirectories(source))
+ {
+ Copy(folder, Path.Combine(target, Path.GetFileName(folder)));
+ }
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/Mods/Editor/ExampleModInstaller.cs.meta b/UnityPackage/Samples~/Mods/Editor/ExampleModInstaller.cs.meta
new file mode 100644
index 0000000..cdb92f8
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/Editor/ExampleModInstaller.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 75f0806378669c9488bbee50393e8307
\ No newline at end of file
diff --git a/UnityPackage/Samples~/Mods/Editor/reromanlee.ReactiveLocalizer.Samples.Mods.Editor.asmdef b/UnityPackage/Samples~/Mods/Editor/reromanlee.ReactiveLocalizer.Samples.Mods.Editor.asmdef
new file mode 100644
index 0000000..cdef5fe
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/Editor/reromanlee.ReactiveLocalizer.Samples.Mods.Editor.asmdef
@@ -0,0 +1,19 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.Mods.Editor",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.Mods.Editor",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Samples.Mods"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/Mods/Editor/reromanlee.ReactiveLocalizer.Samples.Mods.Editor.asmdef.meta b/UnityPackage/Samples~/Mods/Editor/reromanlee.ReactiveLocalizer.Samples.Mods.Editor.asmdef.meta
new file mode 100644
index 0000000..df5906e
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/Editor/reromanlee.ReactiveLocalizer.Samples.Mods.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: e13277dc2826f49458dafd9729858752
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Demo.English.lang b/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Demo.English.lang
new file mode 100644
index 0000000..91afacd
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Demo.English.lang
@@ -0,0 +1,2 @@
+# A mod can patch a language the game ships: these entries replace the game's, and every other entry stays as it was.
+Hint = This hint comes from the example mod, which patches English too. Remove the mods folder to get the game's own text back.
diff --git a/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Elvish/Demo.Elvish.lang b/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Elvish/Demo.Elvish.lang
new file mode 100644
index 0000000..a74fa93
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Elvish/Demo.Elvish.lang
@@ -0,0 +1,3 @@
+Title = ReactiveLocalizer: i-minna
+Greeting = Mae govannen! This greeting comes from a fan translation in the mods folder.
+Coins = Govad {coins, plural, one {# mirian} other {# miriain}}.
diff --git a/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Elvish/Elvish.catalog b/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Elvish/Elvish.catalog
new file mode 100644
index 0000000..e61ea11
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/ExampleMod~/QuickStart/Elvish/Elvish.catalog
@@ -0,0 +1,6 @@
+# A fan translation's language. It isn't in the game's catalog, so the Mods sample registers it at runtime. What it
+# doesn't translate comes from English.
+
+[Elvish]
+DisplayName = Elvish (fan translation)
+Fallback = English
diff --git a/UnityPackage/Samples~/Mods/ModTranslations.cs b/UnityPackage/Samples~/Mods/ModTranslations.cs
new file mode 100644
index 0000000..916829e
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/ModTranslations.cs
@@ -0,0 +1,98 @@
+using reromanlee.ReactiveLocalizer.Hosting;
+using reromanlee.ReactiveLocalizer.Samples.QuickStart;
+using System.Collections.Generic;
+using System.IO;
+using UnityEngine;
+
+namespace reromanlee.ReactiveLocalizer.Samples.Mods
+{
+ ///
+ /// Reads fan translations a player drops into the mods folder, Mods/<Catalog>/ under
+ /// , for every localizer creates.
+ ///
+ ///
+ /// The folder holds .lang files named like the game's, in any folders below it. A file for a language the
+ /// game ships patches the entries it has. A new language is defined by a .catalog file with its section,
+ /// such as [Elvish] with Fallback = English, and becomes a language like any other: language pickers
+ /// list it, and the player's choice of it is remembered. Problems in a file go to the Console with its path and line.
+ ///
+ public static class ModTranslations
+ {
+ private static readonly Dictionary Sources = new();
+
+ /// The folder mods go in, with one folder per catalog inside it.
+ public static string Root => Path.Combine(Application.persistentDataPath, "Mods");
+
+ ///
+ /// Reads the mods folder again, as after a player added or removed a mod while playing, and registers the
+ /// languages that are new. Text already shown changes with the next language switch.
+ ///
+ public static void Refresh()
+ {
+ foreach (FolderTableSource source in Sources.Values)
+ {
+ source.Refresh();
+ ILocalizer localizer = GlobalLocalizer.For(source.Catalog);
+ if (localizer is Localizer created)
+ {
+ RegisterLanguages(created, source, localizer.Languages);
+ }
+ }
+ }
+
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
+ private static void Install()
+ {
+ Sources.Clear();
+ GlobalLocalizer.AddTableSources(CreateSource);
+ GlobalLocalizer.Created += OnCreated;
+ }
+
+ private static ITableSource CreateSource(CatalogKey catalog)
+ {
+ // A folder that doesn't exist yet has no tables, and Refresh finds what a player puts there later.
+ string folder = Path.Combine(Root, catalog.Name);
+ // Read within each request, so the first frame already shows modded text. A game with huge mods can pass
+ // true to read them on a thread pool thread instead, except on WebGL, which has none.
+ FolderTableSource source = new(catalog, folder, false);
+ Sources[catalog.Hash] = source;
+ return source;
+ }
+
+ private static void OnCreated(Localizer localizer)
+ {
+ if (Sources.TryGetValue(localizer.CatalogKey.Hash, out FolderTableSource source))
+ {
+ RegisterLanguages(localizer, source, null);
+ }
+ }
+
+ private static void RegisterLanguages(Localizer localizer, FolderTableSource source, IReadOnlyList known)
+ {
+ List problems = new();
+ foreach (LanguageInfo language in source.ReadLanguages(problems))
+ {
+ if (known == null || !Contains(known, language))
+ {
+ localizer.RegisterLanguage(language);
+ }
+ }
+ for (int i = 0; i < problems.Count; i++)
+ {
+ Debug.LogWarning($"[ReactiveLocalizer] Mod: {problems[i]}");
+ }
+ }
+
+ private static bool Contains(IReadOnlyList languages, LanguageInfo language)
+ {
+ for (int i = 0; i < languages.Count; i++)
+ {
+ if (languages[i].Key == language.Key)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/Mods/ModTranslations.cs.meta b/UnityPackage/Samples~/Mods/ModTranslations.cs.meta
new file mode 100644
index 0000000..fde51ef
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/ModTranslations.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5b0d8788d2289644f9009b925ca14f85
\ No newline at end of file
diff --git a/UnityPackage/Samples~/Mods/reromanlee.ReactiveLocalizer.Samples.Mods.asmdef b/UnityPackage/Samples~/Mods/reromanlee.ReactiveLocalizer.Samples.Mods.asmdef
new file mode 100644
index 0000000..a89b027
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/reromanlee.ReactiveLocalizer.Samples.Mods.asmdef
@@ -0,0 +1,18 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.Mods",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.Mods",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Samples.QuickStart"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/Mods/reromanlee.ReactiveLocalizer.Samples.Mods.asmdef.meta b/UnityPackage/Samples~/Mods/reromanlee.ReactiveLocalizer.Samples.Mods.asmdef.meta
new file mode 100644
index 0000000..15879f0
--- /dev/null
+++ b/UnityPackage/Samples~/Mods/reromanlee.ReactiveLocalizer.Samples.Mods.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 1302061154cfe2e488ab6a64fb71d5e9
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/QuickStart/GlobalLocalizer.cs b/UnityPackage/Samples~/QuickStart/GlobalLocalizer.cs
index 8d74401..3afc75c 100644
--- a/UnityPackage/Samples~/QuickStart/GlobalLocalizer.cs
+++ b/UnityPackage/Samples~/QuickStart/GlobalLocalizer.cs
@@ -1,4 +1,6 @@
+using reromanlee.ReactiveLocalizer.Hosting;
using reromanlee.ReactiveLocalizer.Unity;
+using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
@@ -12,14 +14,22 @@ namespace reromanlee.ReactiveLocalizer.Samples.QuickStart
///
///
/// Main thread only. A DI setup hands it the instances it created with , before anything asks
- /// for one, so components built on this access point use those instead.
+ /// for one, so components built on this access point use those instead. Other samples add table sources with
+ /// and languages through .
///
public static class GlobalLocalizer
{
private const string SavedLanguagePrefix = "reromanlee.ReactiveLocalizer.Language.";
private static readonly Dictionary Localizers = new();
- private static readonly List Created = new();
+ private static readonly List CreatedLocalizers = new();
+ private static readonly List> SourceFactories = new();
+
+ ///
+ /// Raised for each localizer created here, before it initializes, so languages can be registered with it, as
+ /// the Mods sample does for fan translations.
+ ///
+ public static event Action Created;
///
/// The catalog and references without a catalog mean: the package's default catalog,
@@ -47,9 +57,10 @@ public static ILocalizer For(CatalogKey catalog)
{
return existing;
}
- Localizer localizer = new(key, new UnityHost());
+ Localizer localizer = new(key, new UnityHost(CreateSources(key)));
Localizers.Add(key.Hash, localizer);
- Created.Add(localizer);
+ CreatedLocalizers.Add(localizer);
+ RaiseCreated(localizer);
Task initialization = localizer.InitializeAsync();
// Embedded tables load within the call; any other source finishes later, on the main thread.
if (initialization.IsCompleted)
@@ -63,6 +74,19 @@ public static ILocalizer For(CatalogKey catalog)
return localizer;
}
+ ///
+ /// Adds table sources that every localizer created from now on asks before the built-in ones:
+ /// returns the source for a catalog, or null for none. Add them before anything asks for
+ /// a localizer, as a RuntimeInitializeOnLoadMethod running before the first scene loads does.
+ ///
+ public static void AddTableSources(Func create)
+ {
+ if (create != null && !SourceFactories.Contains(create))
+ {
+ SourceFactories.Add(create);
+ }
+ }
+
/// Makes the one handed out for its catalog, instead of creating one here.
public static void Use(CatalogKey catalog, ILocalizer localizer)
{
@@ -81,9 +105,55 @@ public static void SetLanguage(LanguageKey language)
}
}
- private static void StartInPreferredLanguage(Localizer localizer)
+ private static ITableSource[] CreateSources(CatalogKey catalog)
+ {
+ List sources = new(SourceFactories.Count);
+ for (int i = 0; i < SourceFactories.Count; i++)
+ {
+ // A sample failing to make its source must not cost the game its text.
+ try
+ {
+ ITableSource source = SourceFactories[i](catalog);
+ if (source != null)
+ {
+ sources.Add(source);
+ }
+ }
+ catch (Exception exception)
+ {
+ Debug.LogException(exception);
+ }
+ }
+ return sources.ToArray();
+ }
+
+ private static void RaiseCreated(Localizer localizer)
+ {
+ if (Created == null)
+ {
+ return;
+ }
+ foreach (Delegate handler in Created.GetInvocationList())
+ {
+ try
+ {
+ ((Action)handler)(localizer);
+ }
+ catch (Exception exception)
+ {
+ Debug.LogException(exception);
+ }
+ }
+ }
+
+ ///
+ /// Switches an initialized to the language the player chose last time, else to the
+ /// OS language when its catalog has it, and remembers every switch from then on. Localizers created here get
+ /// it on their own; a DI setup calls it for the ones it hands over with .
+ ///
+ public static void StartInPreferredLanguage(Localizer localizer)
{
- if (!localizer.IsInitialized)
+ if (localizer == null || !localizer.IsInitialized)
{
return;
}
@@ -105,7 +175,7 @@ internal static bool TryFind(IReadOnlyList languages, string name,
{
for (int i = 0; i < languages.Count; i++)
{
- if (string.Equals(languages[i].Name, name, System.StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(languages[i].Name, name, StringComparison.OrdinalIgnoreCase))
{
language = languages[i].Key;
return true;
@@ -119,12 +189,14 @@ internal static bool TryFind(IReadOnlyList languages, string name,
private static void ResetForPlayMode()
{
// With domain reload disabled, statics survive between Play Mode sessions; each session starts clean.
- for (int i = 0; i < Created.Count; i++)
+ for (int i = 0; i < CreatedLocalizers.Count; i++)
{
- Created[i].Dispose();
+ CreatedLocalizers[i].Dispose();
}
- Created.Clear();
+ CreatedLocalizers.Clear();
Localizers.Clear();
+ SourceFactories.Clear();
+ Created = null;
}
}
}
diff --git a/UnityPackage/Samples~/QuickStart/LocalizedBehaviour.cs b/UnityPackage/Samples~/QuickStart/LocalizedBehaviour.cs
new file mode 100644
index 0000000..9736fc0
--- /dev/null
+++ b/UnityPackage/Samples~/QuickStart/LocalizedBehaviour.cs
@@ -0,0 +1,194 @@
+using reromanlee.ReactiveLocalizer.Unity;
+using UnityEngine;
+
+namespace reromanlee.ReactiveLocalizer.Samples.QuickStart
+{
+ ///
+ /// The base of components that show an entry in a text of their GameObject, in the current language, through
+ /// every switch, such as the TextMeshPro and uGUI Text samples' components. A subclass only binds its own kind of
+ /// text.
+ ///
+ ///
+ /// It binds while enabled only, so a language switch never touches hidden UI, and text enabled later arrives in
+ /// the current language as it binds. In Edit Mode it previews the entry in the language the Scene view's overlay
+ /// picks, and the text it was authored with is what scenes and prefabs save.
+ ///
+ [ExecuteAlways]
+ public abstract class LocalizedBehaviour : MonoBehaviour
+ {
+ [Tooltip("The entry this text shows.")]
+ [SerializeField] private EntryReference _entry;
+
+ private TextBinding _binding;
+ private EntryMessage _message;
+ // The text the component had before previewing, which it gets back whenever the preview stops.
+ private string _authoredText;
+ private ILocalizer _preview;
+ private bool _isWaitingForPreview;
+
+ /// The entry shown. Setting it while enabled shows the new entry right away, without the arguments of an earlier message.
+ public EntryReference Entry
+ {
+ get => _entry;
+ set
+ {
+ _entry = value;
+ _message = default;
+ Rebind();
+ }
+ }
+
+ /// The localizer the text comes from: the game's in Play Mode, the editor's preview outside it. Null while unbound.
+ protected ILocalizer Localizer { get; private set; }
+
+ /// The text the GameObject shows now, read and given back around the Edit Mode preview.
+ protected abstract string Text { get; set; }
+
+ ///
+ /// Shows , such as QuickStartKeys.Demo.Coins(coins), instead of the entry alone,
+ /// and keeps showing it after the component is enabled again. Calling it every frame with equal arguments costs
+ /// nothing.
+ ///
+ public void SetMessage(in EntryMessage message)
+ {
+ _message = message;
+ _binding.SetMessage(in message);
+ }
+
+ /// Binds of to the text this component shows.
+ protected abstract TextBinding Bind(ILocalizer localizer, in EntryKey key);
+
+ /// Keeps whatever else the preview may change besides the text, such as a font, when the preview first shows.
+ protected virtual void KeepAuthoredLook()
+ {
+ }
+
+ /// Gives back what kept, as the preview stops or before the scene saves.
+ protected virtual void RestoreAuthoredLook()
+ {
+ }
+
+ protected virtual void OnEnable()
+ {
+ if (Application.isPlaying)
+ {
+ BindToGame();
+ }
+ else
+ {
+ ShowPreview();
+ }
+ }
+
+ protected virtual void OnDisable()
+ {
+ _binding.Dispose();
+ Localizer = null;
+ if (!Application.isPlaying)
+ {
+ EditModePreview.Untrack(this);
+ RestoreAuthoredText();
+ }
+ }
+
+ protected virtual void OnValidate()
+ {
+ // Picking another entry in the Inspector shows it at once. Text can't change during validation itself.
+#if UNITY_EDITOR
+ UnityEditor.EditorApplication.delayCall += Rebind;
+#else
+ Rebind();
+#endif
+ }
+
+ /// Binds again, as after the entry changed.
+ protected void Rebind()
+ {
+ if (this == null || !isActiveAndEnabled)
+ {
+ return;
+ }
+ _binding.Dispose();
+ if (Application.isPlaying)
+ {
+ BindToGame();
+ }
+ else
+ {
+ ShowPreview();
+ }
+ }
+
+ private void BindToGame()
+ {
+ _binding.Dispose();
+ Localizer = _entry.IsEmpty ? null : GlobalLocalizer.For(_entry.ToCatalogKey());
+ if (Localizer != null)
+ {
+ Start(Localizer);
+ }
+ }
+
+ /// Shows the entry in the preview language, keeping the authored text to give back.
+ private void ShowPreview()
+ {
+ _binding.Dispose();
+#if UNITY_EDITOR
+ // Scripts reloading can enable this before the editor's preview starts, which it does right after.
+ if (!EditModePreview.IsActive)
+ {
+ if (!_isWaitingForPreview)
+ {
+ _isWaitingForPreview = true;
+ UnityEditor.EditorApplication.delayCall += Rebind;
+ }
+ return;
+ }
+ _isWaitingForPreview = false;
+#endif
+ ILocalizer preview = _entry.IsEmpty ? null : GlobalLocalizer.For(_entry.ToCatalogKey());
+ if (preview == null)
+ {
+ RestoreAuthoredText();
+ return;
+ }
+ if (_authoredText == null)
+ {
+ _authoredText = Text ?? string.Empty;
+ KeepAuthoredLook();
+ }
+ _preview = preview;
+ Localizer = preview;
+ Start(preview);
+ EditModePreview.Track(this, RestoreAuthoredText, ShowPreview);
+ }
+
+ private void Start(ILocalizer localizer)
+ {
+ _binding = Bind(localizer, _entry.ToKey());
+ if (!_message.IsEmpty)
+ {
+ _binding.SetMessage(in _message);
+ }
+ }
+
+ private void RestoreAuthoredText()
+ {
+ _binding.Dispose();
+ // Text typed over the preview is the new authored text, and stays.
+ if (_authoredText != null && _preview != null && Text == ShownPreview())
+ {
+ Text = _authoredText;
+ }
+ if (_authoredText != null)
+ {
+ RestoreAuthoredLook();
+ }
+ _authoredText = null;
+ _preview = null;
+ Localizer = null;
+ }
+
+ private string ShownPreview() => _message.IsEmpty ? _preview.Get(_entry.ToKey()) : _preview.Get(in _message);
+ }
+}
diff --git a/UnityPackage/Samples~/QuickStart/LocalizedBehaviour.cs.meta b/UnityPackage/Samples~/QuickStart/LocalizedBehaviour.cs.meta
new file mode 100644
index 0000000..097539a
--- /dev/null
+++ b/UnityPackage/Samples~/QuickStart/LocalizedBehaviour.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 839f6dff8400ffb4bb14ed7d6353801a
\ No newline at end of file
diff --git a/UnityPackage/Samples~/R3/LocalizerObservables.cs b/UnityPackage/Samples~/R3/LocalizerObservables.cs
new file mode 100644
index 0000000..3bb595e
--- /dev/null
+++ b/UnityPackage/Samples~/R3/LocalizerObservables.cs
@@ -0,0 +1,133 @@
+using R3;
+using System;
+
+namespace reromanlee.ReactiveLocalizer.Samples.R3
+{
+ ///
+ /// R3 observables of localized text: each subscription is a binding, so it emits the current text right away and
+ /// again on every language switch, live file edit or argument change, on the localizer's host thread.
+ ///
+ /// localizer.ObserveText(QuickStartKeys.Demo.Title).Subscribe(text => _title.text = text).AddTo(this);
+ /// coins.SelectText(localizer, count => QuickStartKeys.Demo.Coins(count)).Subscribe(text => _coins.text = text).AddTo(this);
+ ///
+ ///
+ ///
+ /// Disposing a subscription releases its binding, and with it any table loaded on demand for it.
+ ///
+ public static class LocalizerObservables
+ {
+ /// Observes the text of : the current text when subscribed, then every change.
+ public static Observable ObserveText(this ILocalizer localizer, EntryKey key)
+ {
+ return Observable.Create((localizer, key), static (observer, state) =>
+ {
+ TextBinding binding = state.Localizer.Bind(in state.Key, observer, static (target, text) => target.OnNext(text));
+ return Disposable.Create(binding, static subscription => subscription.Dispose());
+ });
+ }
+
+ /// Observes the text of : the current text when subscribed, then every change.
+ public static Observable ObserveText(this ILocalizer localizer, EntryMessage message)
+ {
+ return Observable.Create((localizer, message), static (observer, state) =>
+ {
+ TextBinding binding = state.Localizer.Bind(in state.Message, observer, static (target, text) => target.OnNext(text));
+ return Disposable.Create(binding, static subscription => subscription.Dispose());
+ });
+ }
+
+ ///
+ /// Observes the text of the message each value of makes, such as a coin counter
+ /// shown as You have 5 coins.: one binding for the whole subscription, formatted again only when a
+ /// value changes the message's arguments.
+ ///
+ public static Observable SelectText(this Observable source, ILocalizer localizer, Func toMessage)
+ {
+ if (toMessage == null)
+ {
+ throw new ArgumentNullException(nameof(toMessage));
+ }
+ return Observable.Create Source, ILocalizer Localizer, Func ToMessage)>((source, localizer, toMessage),
+ static (observer, state) =>
+ {
+ MessageSubscription subscription = new(observer, state.Localizer, state.ToMessage);
+ subscription.Source = state.Source.Subscribe(subscription,
+ static (value, target) => target.Show(value),
+ static (error, target) => target.Observer.OnErrorResume(error),
+ static (result, target) => target.Observer.OnCompleted(result));
+ return subscription;
+ });
+ }
+
+ /// Observes the language: the current one when subscribed, if initialization finished, then every switch once applied.
+ public static Observable ObserveLanguage(this ILocalizer localizer)
+ {
+ return Observable.Create(localizer, static (observer, source) =>
+ {
+ if (source.CurrentLanguage != null)
+ {
+ observer.OnNext(source.CurrentLanguage);
+ }
+ Action handler = observer.OnNext;
+ source.LanguageChanged += handler;
+ return Disposable.Create((Source: source, Handler: handler), static state => state.Source.LanguageChanged -= state.Handler);
+ });
+ }
+
+ /// Observes the value of : the current value when subscribed, then every change, until it's disposed.
+ public static Observable AsObservable(this ReactiveText text)
+ {
+ return Observable.Create(text, static (observer, source) =>
+ {
+ observer.OnNext(source.Value);
+ Action handler = observer.OnNext;
+ source.Changed += handler;
+ return Disposable.Create((Source: source, Handler: handler), static state => state.Source.Changed -= state.Handler);
+ });
+ }
+
+ /// One subscription of : the source's subscription and the binding its values feed.
+ private sealed class MessageSubscription : IDisposable
+ {
+ private readonly ILocalizer _localizer;
+ private readonly Func _toMessage;
+ private TextBinding _binding;
+ private bool _isBound;
+ private bool _isDisposed;
+
+ public MessageSubscription(Observer observer, ILocalizer localizer, Func toMessage)
+ {
+ Observer = observer;
+ _localizer = localizer;
+ _toMessage = toMessage;
+ }
+
+ public Observer Observer { get; }
+
+ public IDisposable Source { get; set; }
+
+ public void Show(T value)
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+ EntryMessage message = _toMessage(value);
+ if (_isBound)
+ {
+ _binding.SetMessage(in message);
+ return;
+ }
+ _binding = _localizer.Bind(in message, Observer, static (target, text) => target.OnNext(text));
+ _isBound = true;
+ }
+
+ public void Dispose()
+ {
+ _isDisposed = true;
+ Source?.Dispose();
+ _binding.Dispose();
+ }
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/R3/LocalizerObservables.cs.meta b/UnityPackage/Samples~/R3/LocalizerObservables.cs.meta
new file mode 100644
index 0000000..ebe556d
--- /dev/null
+++ b/UnityPackage/Samples~/R3/LocalizerObservables.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3e0b77ae4b0a82041ba81cef54545674
\ No newline at end of file
diff --git a/UnityPackage/Samples~/R3/reromanlee.ReactiveLocalizer.Samples.R3.asmdef b/UnityPackage/Samples~/R3/reromanlee.ReactiveLocalizer.Samples.R3.asmdef
new file mode 100644
index 0000000..9aac9fc
--- /dev/null
+++ b/UnityPackage/Samples~/R3/reromanlee.ReactiveLocalizer.Samples.R3.asmdef
@@ -0,0 +1,24 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.R3",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.R3",
+ "references": [
+ "reromanlee.ReactiveLocalizer"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "REACTIVE_LOCALIZER_R3"
+ ],
+ "versionDefines": [
+ {
+ "name": "com.cysharp.r3",
+ "expression": "1.0.0",
+ "define": "REACTIVE_LOCALIZER_R3"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/R3/reromanlee.ReactiveLocalizer.Samples.R3.asmdef.meta b/UnityPackage/Samples~/R3/reromanlee.ReactiveLocalizer.Samples.R3.asmdef.meta
new file mode 100644
index 0000000..31f049b
--- /dev/null
+++ b/UnityPackage/Samples~/R3/reromanlee.ReactiveLocalizer.Samples.R3.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: f46b96ace70078642a427e3d4e897eb6
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverage.cs b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverage.cs
new file mode 100644
index 0000000..1e89f43
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverage.cs
@@ -0,0 +1,152 @@
+using reromanlee.ReactiveLocalizer.Authoring;
+using reromanlee.ReactiveLocalizer.Editor;
+using System.Collections.Generic;
+using System.Text;
+using TMPro;
+using UnityEngine;
+using UnityEngine.TextCore.LowLevel;
+
+namespace reromanlee.ReactiveLocalizer.Samples.TextMeshPro.Editor
+{
+ ///
+ /// Checks which characters each language's font would draw as an empty box: every character the language can
+ /// show, looked up in its font, the font's fallbacks and TextMeshPro's global fallbacks.
+ ///
+ ///
+ /// A dynamic font counts the characters of its source font file, which it adds to its atlas when they're first
+ /// shown; a static font counts only its atlas. Text that arguments bring at runtime, such as player names, isn't
+ /// in the files, so it isn't checked.
+ ///
+ public static class FontCoverage
+ {
+ internal const int ListedLimit = 200;
+
+ private static Font _loadedFont;
+
+ /// Checks every language of ' catalog that has a font, the pseudo language included.
+ public static List Check(LanguageFonts fonts)
+ {
+ List results = new();
+ if (fonts == null)
+ {
+ return results;
+ }
+ CatalogKey catalog = fonts.Catalog.ToKey();
+ CatalogInfo info = CharacterSets.GetCatalog(catalog);
+ if (info == null)
+ {
+ results.Add(new FontCoverageResult(null, null, 0, 0, string.Empty,
+ fonts.Catalog.IsEmpty ? "The project has no usable default catalog." : $"The project has no usable catalog '{fonts.Catalog.CatalogName}'."));
+ return results;
+ }
+ IReadOnlyList languages = info.Languages;
+ HashSet visited = new();
+ StringBuilder missing = new();
+ _loadedFont = null;
+ for (int l = 0; l < languages.Count; l++)
+ {
+ LanguageInfo language = languages[l];
+ TMP_FontAsset font = fonts.GetFont(info, language, out _);
+ if (font == null)
+ {
+ results.Add(new FontCoverageResult(language.Name, null, 0, 0, string.Empty, "No font: each text keeps its own, which this check can't know."));
+ continue;
+ }
+ CharacterSet characters = CharacterSets.Collect(catalog, language.Key);
+ int[] codePoints = characters.ToCodePoints();
+ int missingCount = 0;
+ missing.Clear();
+ for (int i = 0; i < codePoints.Length; i++)
+ {
+ visited.Clear();
+ if (IsDrawn(font, (uint)codePoints[i], visited) || IsGloballyDrawn((uint)codePoints[i], visited))
+ {
+ continue;
+ }
+ missingCount++;
+ if (missingCount <= ListedLimit)
+ {
+ missing.Append(char.ConvertFromUtf32(codePoints[i]));
+ }
+ }
+ string note = font.atlasPopulationMode != AtlasPopulationMode.Static && font.sourceFontFile == null
+ ? "This dynamic font has no source font file in the project, so only its atlas was checked."
+ : null;
+ results.Add(new FontCoverageResult(language.Name, font, codePoints.Length, missingCount, missing.ToString(), note));
+ }
+ return results;
+ }
+
+ /// Logs of to the Console, a warning for each language its font can't fully draw.
+ public static void Log(IReadOnlyList results, LanguageFonts fonts)
+ {
+ foreach (FontCoverageResult result in results)
+ {
+ if (result.MissingCount > 0)
+ {
+ Debug.LogWarning($"[ReactiveLocalizer] {result}", result.Font);
+ }
+ else
+ {
+ Debug.Log($"[ReactiveLocalizer] {result}", (Object)result.Font ?? fonts);
+ }
+ }
+ }
+
+ private static bool IsDrawn(TMP_FontAsset font, uint unicode, HashSet visited)
+ {
+ if (font == null || !visited.Add(font))
+ {
+ return false;
+ }
+ if (font.characterLookupTable != null && font.characterLookupTable.ContainsKey(unicode))
+ {
+ return true;
+ }
+ if (font.atlasPopulationMode != AtlasPopulationMode.Static && HasGlyph(font.sourceFontFile, unicode))
+ {
+ return true;
+ }
+ List fallbacks = font.fallbackFontAssetTable;
+ for (int i = 0; fallbacks != null && i < fallbacks.Count; i++)
+ {
+ if (IsDrawn(fallbacks[i], unicode, visited))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static bool IsGloballyDrawn(uint unicode, HashSet visited)
+ {
+ List fallbacks = TMP_Settings.instance != null ? TMP_Settings.fallbackFontAssets : null;
+ for (int i = 0; fallbacks != null && i < fallbacks.Count; i++)
+ {
+ if (IsDrawn(fallbacks[i], unicode, visited))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /// Whether the font file has a glyph for , read without adding it to any atlas.
+ private static bool HasGlyph(Font font, uint unicode)
+ {
+ if (font == null)
+ {
+ return false;
+ }
+ if (font != _loadedFont)
+ {
+ if (FontEngine.LoadFontFace(font) != FontEngineError.Success)
+ {
+ return false;
+ }
+ _loadedFont = font;
+ }
+ return FontEngine.TryGetGlyphIndex(unicode, out uint glyphIndex) && glyphIndex != 0;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverage.cs.meta b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverage.cs.meta
new file mode 100644
index 0000000..6aa4710
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverage.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9c7c54db8623f074393d0e3fd0a9d6bc
\ No newline at end of file
diff --git a/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverageResult.cs b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverageResult.cs
new file mode 100644
index 0000000..2f8c118
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverageResult.cs
@@ -0,0 +1,49 @@
+using TMPro;
+
+namespace reromanlee.ReactiveLocalizer.Samples.TextMeshPro.Editor
+{
+ /// What found for one language: its font, and the characters the font can't draw.
+ public sealed class FontCoverageResult
+ {
+ public FontCoverageResult(string language, TMP_FontAsset font, int characterCount, int missingCount, string missing, string note)
+ {
+ Language = language;
+ Font = font;
+ CharacterCount = characterCount;
+ MissingCount = missingCount;
+ Missing = missing ?? string.Empty;
+ Note = note;
+ }
+
+ /// The language checked; null when the check couldn't start.
+ public string Language { get; }
+
+ /// The font the language shows its text with; null when it has none.
+ public TMP_FontAsset Font { get; }
+
+ /// How many characters the language can show.
+ public int CharacterCount { get; }
+
+ /// How many of them the font draws as an empty box.
+ public int MissingCount { get; }
+
+ /// The characters the font can't draw, in code point order; the first 200 when there are more.
+ public string Missing { get; }
+
+ /// Why the check couldn't be done, or what it couldn't tell; null when there's nothing to add.
+ public string Note { get; }
+
+ ///
+ public override string ToString()
+ {
+ if (Language == null || Font == null)
+ {
+ return Language == null ? Note : $"{Language}: {Note}";
+ }
+ string coverage = MissingCount == 0
+ ? $"{Font.name} draws all {CharacterCount} characters."
+ : $"{Font.name} can't draw {MissingCount} of {CharacterCount} characters: {Missing}{(MissingCount > FontCoverage.ListedLimit ? "\u2026" : string.Empty)}";
+ return Note == null ? $"{Language}: {coverage}" : $"{Language}: {coverage} {Note}";
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverageResult.cs.meta b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverageResult.cs.meta
new file mode 100644
index 0000000..b12698a
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/Editor/FontCoverageResult.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 803db6311837fee408bb70d810894700
\ No newline at end of file
diff --git a/UnityPackage/Samples~/TextMeshPro/Editor/LanguageFontsEditor.cs b/UnityPackage/Samples~/TextMeshPro/Editor/LanguageFontsEditor.cs
new file mode 100644
index 0000000..3a0e817
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/Editor/LanguageFontsEditor.cs
@@ -0,0 +1,40 @@
+using System.Collections.Generic;
+using UnityEditor;
+using UnityEditor.UIElements;
+using UnityEngine.UIElements;
+
+namespace reromanlee.ReactiveLocalizer.Samples.TextMeshPro.Editor
+{
+ /// The Inspector of , with a check of which characters each language's font can't draw.
+ [CustomEditor(typeof(LanguageFonts))]
+ internal sealed class LanguageFontsEditor : UnityEditor.Editor
+ {
+ public override VisualElement CreateInspectorGUI()
+ {
+ VisualElement root = new();
+ InspectorElement.FillDefaultInspector(root, serializedObject, this);
+ VisualElement results = new();
+ Button check = new(() => ShowCoverage(results))
+ {
+ text = "Check Font Coverage",
+ tooltip = "Lists every character a language can show that its font, the font's fallbacks and TextMeshPro's global fallbacks can't draw. The Console gets the same list to copy from."
+ };
+ check.style.marginTop = 8f;
+ root.Add(check);
+ root.Add(results);
+ return root;
+ }
+
+ private void ShowCoverage(VisualElement results)
+ {
+ results.Clear();
+ LanguageFonts fonts = (LanguageFonts)target;
+ List coverage = FontCoverage.Check(fonts);
+ foreach (FontCoverageResult result in coverage)
+ {
+ results.Add(new HelpBox(result.ToString(), result.MissingCount > 0 ? HelpBoxMessageType.Warning : HelpBoxMessageType.Info));
+ }
+ FontCoverage.Log(coverage, fonts);
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/TextMeshPro/Editor/LanguageFontsEditor.cs.meta b/UnityPackage/Samples~/TextMeshPro/Editor/LanguageFontsEditor.cs.meta
new file mode 100644
index 0000000..96f4695
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/Editor/LanguageFontsEditor.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: dcffe3b69910f434f8f13eaf3e20a98a
\ No newline at end of file
diff --git a/UnityPackage/Samples~/TextMeshPro/Editor/reromanlee.ReactiveLocalizer.Samples.TextMeshPro.Editor.asmdef b/UnityPackage/Samples~/TextMeshPro/Editor/reromanlee.ReactiveLocalizer.Samples.TextMeshPro.Editor.asmdef
index 8c15565..c4a7169 100644
--- a/UnityPackage/Samples~/TextMeshPro/Editor/reromanlee.ReactiveLocalizer.Samples.TextMeshPro.Editor.asmdef
+++ b/UnityPackage/Samples~/TextMeshPro/Editor/reromanlee.ReactiveLocalizer.Samples.TextMeshPro.Editor.asmdef
@@ -4,7 +4,9 @@
"references": [
"reromanlee.ReactiveLocalizer",
"reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Authoring",
"reromanlee.ReactiveLocalizer.Editor",
+ "reromanlee.ReactiveLocalizer.Samples.QuickStart",
"reromanlee.ReactiveLocalizer.Samples.TextMeshPro",
"Unity.TextMeshPro"
],
diff --git a/UnityPackage/Samples~/TextMeshPro/LanguageFonts.cs b/UnityPackage/Samples~/TextMeshPro/LanguageFonts.cs
new file mode 100644
index 0000000..d2240c6
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/LanguageFonts.cs
@@ -0,0 +1,93 @@
+using reromanlee.ReactiveLocalizer.Unity;
+using System;
+using System.Collections.Generic;
+using TMPro;
+using UnityEngine;
+
+namespace reromanlee.ReactiveLocalizer.Samples.TextMeshPro
+{
+ ///
+ /// The TextMeshPro font each language of a catalog shows its text with, such as a font with Cyrillic letters for
+ /// Russian, picked by every that uses this asset as the language switches.
+ ///
+ ///
+ /// A language without a font of its own takes the font of its fallback language, then the default font. Its
+ /// Inspector checks which characters each language's font can't draw.
+ ///
+ [CreateAssetMenu(menuName = "ReactiveLocalizer/Language Fonts", fileName = "LanguageFonts", order = 100)]
+ public sealed class LanguageFonts : ScriptableObject
+ {
+ [Tooltip("The catalog whose languages these fonts are for; empty for the default catalog. The coverage check reads its texts.")]
+ [SerializeField] private CatalogReference _catalog;
+
+ [Tooltip("The font of languages that have none of their own; empty to keep each text's own font.")]
+ [SerializeField] private TMP_FontAsset _defaultFont;
+
+ [Tooltip("The fonts of the languages that need their own.")]
+ [SerializeField] private List _fonts = new();
+
+ /// The catalog whose languages these fonts are for; empty for the default catalog.
+ public CatalogReference Catalog => _catalog;
+
+ /// The font of languages that have none of their own; null to keep each text's own font.
+ public TMP_FontAsset DefaultFont => _defaultFont;
+
+ /// The fonts of the languages that need their own.
+ public IReadOnlyList Fonts => _fonts;
+
+ ///
+ /// Returns the font of shows text with: its own, else
+ /// its fallback language's, else the default font. Null when there is none, which keeps a text's own font.
+ ///
+ public TMP_FontAsset GetFont(CatalogInfo catalog, LanguageInfo language, out Material material)
+ {
+ material = null;
+ if (language == null)
+ {
+ return _defaultFont;
+ }
+ // The chain ends with the source language, which counts like any other.
+ IReadOnlyList chain = catalog != null ? catalog.GetFallbackChain(language) : new[] { language };
+ for (int c = 0; c < chain.Count; c++)
+ {
+ for (int i = 0; i < _fonts.Count; i++)
+ {
+ if (_fonts[i].Font != null && string.Equals(_fonts[i].Language, chain[c].Name, StringComparison.OrdinalIgnoreCase))
+ {
+ material = _fonts[i].Material;
+ return _fonts[i].Font;
+ }
+ }
+ }
+ return _defaultFont;
+ }
+ }
+
+ /// The font one language shows its text with.
+ [Serializable]
+ public struct LanguageFont
+ {
+ [Tooltip("The language's name, as its catalog writes it, such as Russian.")]
+ [SerializeField] private string _language;
+
+ [SerializeField] private TMP_FontAsset _font;
+
+ [Tooltip("A material preset of the font, such as one with an outline; empty for the font's own material.")]
+ [SerializeField] private Material _material;
+
+ public LanguageFont(string language, TMP_FontAsset font, Material material = null)
+ {
+ _language = language;
+ _font = font;
+ _material = material;
+ }
+
+ /// The language's name, as its catalog writes it.
+ public string Language => _language;
+
+ public TMP_FontAsset Font => _font;
+
+ /// A material preset of the font; null for the font's own material.
+ public Material Material => _material;
+ }
+}
diff --git a/UnityPackage/Samples~/TextMeshPro/LanguageFonts.cs.meta b/UnityPackage/Samples~/TextMeshPro/LanguageFonts.cs.meta
new file mode 100644
index 0000000..2ac7f22
--- /dev/null
+++ b/UnityPackage/Samples~/TextMeshPro/LanguageFonts.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 36435ac89ed733a46bec3f5fb17cc7e8
\ No newline at end of file
diff --git a/UnityPackage/Samples~/TextMeshPro/LocalizedTextMeshPro.cs b/UnityPackage/Samples~/TextMeshPro/LocalizedTextMeshPro.cs
index 91a2048..db73c67 100644
--- a/UnityPackage/Samples~/TextMeshPro/LocalizedTextMeshPro.cs
+++ b/UnityPackage/Samples~/TextMeshPro/LocalizedTextMeshPro.cs
@@ -1,5 +1,4 @@
using reromanlee.ReactiveLocalizer.Samples.QuickStart;
-using reromanlee.ReactiveLocalizer.Unity;
using System;
using System.Runtime.InteropServices;
using TMPro;
@@ -8,152 +7,134 @@
namespace reromanlee.ReactiveLocalizer.Samples.TextMeshPro
{
///
- /// Shows an entry in the TextMeshPro text of its GameObject, in the current language, through every switch.
+ /// Shows an entry in the TextMeshPro text of its GameObject, in the current language, through every switch, in
+ /// the font its picks for the language.
///
///
- /// It binds while enabled only, so a language switch never touches hidden UI, and text enabled later arrives in
- /// the current language as it binds. In Edit Mode it previews the entry in the language the Scene view's overlay
- /// picks, and the text it was authored with is what scenes and prefabs save.
+ /// Text arrives without creating strings: the characters go straight into the text's own buffer. In Edit Mode it
+ /// previews the entry, font included, in the language the Scene view's overlay picks, and scenes and prefabs save
+ /// the text and font it was authored with.
///
- [ExecuteAlways]
[RequireComponent(typeof(TMP_Text))]
[AddComponentMenu("ReactiveLocalizer/Localized TextMeshPro")]
- public sealed class LocalizedTextMeshPro : MonoBehaviour
+ public sealed class LocalizedTextMeshPro : LocalizedBehaviour
{
- [Tooltip("The entry this text shows.")]
- [SerializeField] private EntryReference _entry;
+ [Tooltip("The font each language shows this text with; empty to keep the text's own font in every language.")]
+ [SerializeField] private LanguageFonts _fonts;
private TMP_Text _text;
- private TextBinding _binding;
- // The text the component had before previewing, which it gets back whenever the preview stops.
- private string _authoredText;
- private ILocalizer _preview;
- private bool _isWaitingForPreview;
+ private LanguageInfo _fontLanguage;
+ private TMP_FontAsset _authoredFont;
+ private Material _authoredMaterial;
+ private TMP_FontAsset _shownFont;
- /// The entry shown. Setting it while enabled shows the new entry right away.
- public EntryReference Entry
+ /// The fonts the languages show this text with; null keeps the text's own font. Setting it applies it at the next text.
+ public LanguageFonts Fonts
{
- get => _entry;
+ get => _fonts;
set
{
- _entry = value;
+ _fonts = value;
+ _fontLanguage = null;
Rebind();
}
}
- private void OnEnable()
+ private TMP_Text Target => _text != null ? _text : _text = GetComponent();
+
+ protected override string Text
{
- if (Application.isPlaying)
- {
- Bind();
- }
- else
+ get => Target != null ? Target.text : null;
+ set
{
- ShowPreview();
+ if (Target != null)
+ {
+ Target.text = value;
+ }
}
}
- private void OnDisable()
+ protected override TextBinding Bind(ILocalizer localizer, in EntryKey key)
{
- _binding.Dispose();
- if (!Application.isPlaying)
+ _fontLanguage = null;
+ return localizer.BindCharacters(in key, this, static (component, characters) => component.Show(characters));
+ }
+
+ protected override void KeepAuthoredLook()
+ {
+ if (Target != null)
{
- EditModePreview.Untrack(this);
- RestoreAuthoredText();
+ _authoredFont = Target.font;
+ _authoredMaterial = Target.fontSharedMaterial;
}
}
- private void OnValidate()
+ protected override void RestoreAuthoredLook()
{
- // Picking another entry in the Inspector shows it at once. Text can't change during validation itself.
-#if UNITY_EDITOR
- UnityEditor.EditorApplication.delayCall += Rebind;
-#else
- Rebind();
-#endif
+ // A font picked in the Inspector during the preview is the new authored font, and stays.
+ if (Target != null && _authoredFont != null && Target.font == _shownFont)
+ {
+ Target.font = _authoredFont;
+ Target.fontSharedMaterial = _authoredMaterial;
+ }
+ _authoredFont = null;
+ _authoredMaterial = null;
+ _shownFont = null;
+ _fontLanguage = null;
}
- private void Rebind()
+ /// Shows characters without creating a string: the text component copies them into its own buffer.
+ private void Show(ReadOnlyMemory characters)
{
- if (this == null || !isActiveAndEnabled)
+ TMP_Text text = Target;
+ if (text == null)
{
return;
}
- _binding.Dispose();
- if (Application.isPlaying)
- {
- Bind();
- }
- else
+ ApplyFont(text);
+ if (MemoryMarshal.TryGetArray(characters, out ArraySegment segment))
{
- ShowPreview();
+ text.SetCharArray(segment.Array, segment.Offset, segment.Count);
+ return;
}
+ // Characters a string already holds, such as a missing key's marker.
+ text.SetText(characters.ToString());
}
- /// Shows the entry in the preview language, keeping the authored text to give back.
- private void ShowPreview()
+ /// Picks the font of the language the text is in, once per language.
+ private void ApplyFont(TMP_Text text)
{
- _binding.Dispose();
-#if UNITY_EDITOR
- // Scripts reloading can enable this before the editor's preview starts, which it does right after.
- if (!EditModePreview.IsActive)
+ LanguageInfo language = Localizer?.CurrentLanguage;
+ if (_fonts == null || language == null || language == _fontLanguage)
{
- if (!_isWaitingForPreview)
- {
- _isWaitingForPreview = true;
- UnityEditor.EditorApplication.delayCall += Rebind;
- }
return;
}
- _isWaitingForPreview = false;
-#endif
- ILocalizer preview = _entry.IsEmpty ? null : GlobalLocalizer.For(_entry.ToCatalogKey());
- if (preview == null)
+ _fontLanguage = language;
+ if (_authoredFont == null)
{
- RestoreAuthoredText();
- return;
+ // The text's own font is what languages without a font of their own show.
+ KeepAuthoredLook();
}
- if (_text == null)
+ TMP_FontAsset font = _fonts.GetFont(Localizer.Catalog, language, out Material material);
+ if (font == null)
{
- _text = GetComponent();
+ font = _authoredFont;
+ material = _authoredMaterial;
}
- _authoredText ??= _text.text;
- _preview = preview;
- _binding = preview.BindCharacters(_entry.ToKey(), _text, static (text, characters) => Show(text, characters));
- EditModePreview.Track(this, RestoreAuthoredText, ShowPreview);
- }
-
- private void RestoreAuthoredText()
- {
- _binding.Dispose();
- // Text typed over the preview is the new authored text, and stays.
- if (_authoredText != null && _text != null && _preview != null && _text.text == _preview.Get(_entry.ToKey()))
+ if (font == null)
{
- _text.text = _authoredText;
+ return;
}
- _authoredText = null;
- _preview = null;
- }
-
- private void Bind()
- {
- if (_text == null)
+ if (text.font != font)
{
- _text = GetComponent();
+ text.font = font;
}
- _binding = GlobalLocalizer.For(_entry.ToCatalogKey()).BindCharacters(_entry.ToKey(), _text, static (text, characters) => Show(text, characters));
- }
-
- /// Shows characters without creating a string: the text component copies them into its own buffer.
- private static void Show(TMP_Text text, ReadOnlyMemory characters)
- {
- if (MemoryMarshal.TryGetArray(characters, out ArraySegment segment))
+ Material shared = material != null ? material : font.material;
+ if (text.fontSharedMaterial != shared)
{
- text.SetCharArray(segment.Array, segment.Offset, segment.Count);
- return;
+ text.fontSharedMaterial = shared;
}
- // Characters a string already holds, such as a missing key's marker.
- text.SetText(characters.ToString());
+ _shownFont = font;
}
}
}
diff --git a/UnityPackage/Samples~/UIToolkit/ElementTextBinding.cs b/UnityPackage/Samples~/UIToolkit/ElementTextBinding.cs
new file mode 100644
index 0000000..20756af
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/ElementTextBinding.cs
@@ -0,0 +1,99 @@
+using reromanlee.ReactiveLocalizer.Samples.QuickStart;
+using reromanlee.ReactiveLocalizer.Unity;
+using System;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UIToolkit
+{
+ ///
+ /// Shows an entry in a UI Toolkit text element while the element is in a panel, in the current language, through
+ /// every switch. Made with ; disposing it stops it.
+ ///
+ ///
+ /// It binds while the element is attached only, so a language switch never touches UI that isn't shown, and an
+ /// element attached later arrives in the current language. Outside Play Mode it shows the editor's preview language.
+ ///
+ public sealed class ElementTextBinding : IDisposable
+ {
+ // One instance each, so unregistering finds the callbacks registered.
+ private static readonly EventCallback OnAttached = static (_, binding) => binding.Start();
+ private static readonly EventCallback OnDetached = static (_, binding) => binding.Stop();
+
+ private readonly TextElement _element;
+ private readonly EntryKey _key;
+ private readonly CatalogKey _catalog;
+ private TextBinding _binding;
+ private EntryMessage _message;
+ private bool _isDisposed;
+
+ internal ElementTextBinding(TextElement element, in EntryKey key, CatalogKey catalog)
+ {
+ _element = element;
+ _key = key;
+ _catalog = catalog;
+ element.RegisterCallback(OnAttached, this);
+ element.RegisterCallback(OnDetached, this);
+ if (element.panel != null)
+ {
+ Start();
+ }
+ }
+
+ /// The element whose text is shown.
+ public TextElement Element => _element;
+
+ ///
+ /// Shows , such as QuickStartKeys.Demo.Coins(coins), instead of the entry alone,
+ /// and keeps showing it when the element is attached again. Calling it every frame with equal arguments costs nothing.
+ ///
+ public void SetMessage(in EntryMessage message)
+ {
+ _message = message;
+ _binding.SetMessage(in message);
+ }
+
+ /// Stops showing the entry. The element keeps the text it shows.
+ public void Dispose()
+ {
+ if (_isDisposed)
+ {
+ return;
+ }
+ _isDisposed = true;
+ Stop();
+ _element.UnregisterCallback(OnAttached);
+ _element.UnregisterCallback(OnDetached);
+ ElementTextBindings.Forget(this);
+ }
+
+ private void Start()
+ {
+ if (_isDisposed || _binding.IsActive)
+ {
+ return;
+ }
+ ILocalizer localizer = GlobalLocalizer.For(_catalog);
+ if (localizer == null)
+ {
+ // Right after scripts reload, the editor's preview starts a moment later.
+ if (!Application.isPlaying && !EditModePreview.IsActive)
+ {
+ _element.schedule.Execute(Start);
+ }
+ return;
+ }
+ _binding = localizer.Bind(in _key, _element, static (element, text) => element.text = text);
+ if (!_message.IsEmpty)
+ {
+ _binding.SetMessage(in _message);
+ }
+ }
+
+ private void Stop()
+ {
+ _binding.Dispose();
+ _binding = default;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UIToolkit/ElementTextBinding.cs.meta b/UnityPackage/Samples~/UIToolkit/ElementTextBinding.cs.meta
new file mode 100644
index 0000000..c0988f2
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/ElementTextBinding.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c8ee01e0adb5bb446b4f096015e05238
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UIToolkit/ElementTextBindings.cs b/UnityPackage/Samples~/UIToolkit/ElementTextBindings.cs
new file mode 100644
index 0000000..554eefa
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/ElementTextBindings.cs
@@ -0,0 +1,62 @@
+using reromanlee.ReactiveLocalizer.Unity;
+using System;
+using System.Runtime.CompilerServices;
+using UnityEngine.UIElements;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UIToolkit
+{
+ ///
+ /// Binds UI Toolkit text elements to entries from code, on every Unity version:
+ ///
+ /// root.Q<Label>("Title").BindText(QuickStartKeys.Demo.Title, QuickStartKeys.CatalogKey);
+ ///
+ ///
+ public static class ElementTextBindings
+ {
+ // An element shows one entry at a time; the table forgets elements nothing else holds.
+ private static readonly ConditionalWeakTable Bound = new();
+
+ ///
+ /// Shows of in while it's in a
+ /// panel, replacing an entry it was bound to before. An empty catalog means Quick Start's default catalog.
+ ///
+ /// is null.
+ public static ElementTextBinding BindText(this TextElement element, in EntryKey key, CatalogKey catalog = default)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+ if (Bound.TryGetValue(element, out ElementTextBinding previous))
+ {
+ previous.Dispose();
+ }
+ ElementTextBinding binding = new(element, in key, catalog);
+ Bound.AddOrUpdate(element, binding);
+ return binding;
+ }
+
+ /// Shows in while it's in a panel, as an Inspector field picked it.
+ public static ElementTextBinding BindText(this TextElement element, EntryReference entry)
+ {
+ return BindText(element, entry.ToKey(), entry.ToCatalogKey());
+ }
+
+ /// Stops the entry shows, if any.
+ public static void UnbindText(this TextElement element)
+ {
+ if (element != null && Bound.TryGetValue(element, out ElementTextBinding binding))
+ {
+ binding.Dispose();
+ }
+ }
+
+ internal static void Forget(ElementTextBinding binding)
+ {
+ if (Bound.TryGetValue(binding.Element, out ElementTextBinding current) && current == binding)
+ {
+ Bound.Remove(binding.Element);
+ }
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UIToolkit/ElementTextBindings.cs.meta b/UnityPackage/Samples~/UIToolkit/ElementTextBindings.cs.meta
new file mode 100644
index 0000000..3423aaa
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/ElementTextBindings.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 83eeb876ee1a7ff438fe819dc34c2a02
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UIToolkit/LocalizedDocument.cs b/UnityPackage/Samples~/UIToolkit/LocalizedDocument.cs
new file mode 100644
index 0000000..f80f342
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/LocalizedDocument.cs
@@ -0,0 +1,121 @@
+using reromanlee.ReactiveLocalizer.Unity;
+using System;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UIToolkit
+{
+ ///
+ /// Shows entries in the text elements of its GameObject's , found by name, with no code and
+ /// on every Unity version: each pair names an element of the document's UXML and the entry it shows.
+ ///
+ ///
+ /// The document rebuilds its elements whenever it's enabled or its UXML changes; the new elements are found and
+ /// bound again on their first layout. In Edit Mode it previews the entries in the editor's preview language, which
+ /// never reaches a file, since UXML keeps its own text.
+ ///
+ [ExecuteAlways]
+ [RequireComponent(typeof(UIDocument))]
+ [AddComponentMenu("ReactiveLocalizer/Localized Document")]
+ public sealed class LocalizedDocument : MonoBehaviour
+ {
+ [Tooltip("Each text element, by name, and the entry it shows.")]
+ [SerializeField] private List _elements = new();
+
+ private readonly List _bindings = new();
+ private UIDocument _document;
+ private VisualElement _root;
+
+ private void OnEnable()
+ {
+ _document = GetComponent();
+ Watch();
+ BindElements();
+ }
+
+ private void OnDisable()
+ {
+ if (_root != null)
+ {
+ _root.UnregisterCallback(OnLayout);
+ _root = null;
+ }
+ Unbind();
+ }
+
+ private void OnValidate()
+ {
+ if (isActiveAndEnabled)
+ {
+ Unbind();
+ BindElements();
+ }
+ }
+
+ /// Finds the document's root, which is new after every rebuild, and binds its elements on their first layout.
+ private void Watch()
+ {
+ VisualElement root = _document != null ? _document.rootVisualElement : null;
+ if (root == _root)
+ {
+ return;
+ }
+ _root?.UnregisterCallback(OnLayout);
+ _root = root;
+ _root?.RegisterCallback(OnLayout);
+ }
+
+ private void OnLayout(GeometryChangedEvent change)
+ {
+ Watch();
+ BindElements();
+ }
+
+ private void BindElements()
+ {
+ if (_root == null)
+ {
+ return;
+ }
+ for (int i = 0; i < _elements.Count; i++)
+ {
+ TextElement element = string.IsNullOrEmpty(_elements[i].Element) ? null : _root.Q(_elements[i].Element);
+ bool isBound = i < _bindings.Count && _bindings[i] != null && _bindings[i].Element == element;
+ if (isBound || element == null || _elements[i].Entry.IsEmpty)
+ {
+ continue;
+ }
+ while (_bindings.Count <= i)
+ {
+ _bindings.Add(null);
+ }
+ _bindings[i]?.Dispose();
+ _bindings[i] = element.BindText(_elements[i].Entry);
+ }
+ }
+
+ private void Unbind()
+ {
+ for (int i = 0; i < _bindings.Count; i++)
+ {
+ _bindings[i]?.Dispose();
+ }
+ _bindings.Clear();
+ }
+
+ /// A text element of the document, by name, and the entry it shows.
+ [Serializable]
+ private struct ElementEntry
+ {
+ [Tooltip("The element's name in the UXML.")]
+ [SerializeField] private string _element;
+
+ [SerializeField] private EntryReference _entry;
+
+ public string Element => _element;
+
+ public EntryReference Entry => _entry;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UIToolkit/LocalizedDocument.cs.meta b/UnityPackage/Samples~/UIToolkit/LocalizedDocument.cs.meta
new file mode 100644
index 0000000..67d0292
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/LocalizedDocument.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1f5519478bda9db4e8db6441152e24cb
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UIToolkit/LocalizedTextBinding.cs b/UnityPackage/Samples~/UIToolkit/LocalizedTextBinding.cs
new file mode 100644
index 0000000..71c1af7
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/LocalizedTextBinding.cs
@@ -0,0 +1,159 @@
+#if UNITY_2023_3_OR_NEWER
+using reromanlee.ReactiveLocalizer.Samples.QuickStart;
+using reromanlee.ReactiveLocalizer.Unity;
+using Unity.Properties;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UIToolkit
+{
+ ///
+ /// A data binding that shows an entry in any string property of an element, declared in UXML or added in UI
+ /// Builder, in the current language, through every switch:
+ ///
+ /// <ui:Label>
+ /// <Bindings>
+ /// <reromanlee.ReactiveLocalizer.Samples.UIToolkit.LocalizedTextBinding property="text" catalog="QuickStart" entry="Demo.Title" />
+ /// </Bindings>
+ /// </ui:Label>
+ ///
+ ///
+ ///
+ /// Unity 6 and newer; does the same from code on every version. It binds
+ /// while its element is in a panel only, and UI Builder previews the entry in the editor's preview language.
+ ///
+ [UxmlObject]
+ public partial class LocalizedTextBinding : CustomBinding
+ {
+ private string _catalog = string.Empty;
+ private string _entry = string.Empty;
+ private TextBinding _binding;
+ private EntryMessage _message;
+ private string _text;
+ private int _activeCount;
+
+ public LocalizedTextBinding()
+ {
+ updateTrigger = BindingUpdateTrigger.WhenDirty;
+ }
+
+ /// The catalog the entry belongs to; empty for Quick Start's default catalog.
+ [UxmlAttribute("catalog")]
+ public string Catalog
+ {
+ get => _catalog;
+ set
+ {
+ _catalog = value ?? string.Empty;
+ Restart();
+ }
+ }
+
+ /// The entry shown, written Table.Entry, such as Demo.Title.
+ [UxmlAttribute("entry")]
+ public string Entry
+ {
+ get => _entry;
+ set
+ {
+ _entry = value ?? string.Empty;
+ _message = default;
+ Restart();
+ }
+ }
+
+ ///
+ /// Shows , such as QuickStartKeys.Demo.Coins(coins), instead of the entry alone.
+ /// Calling it every frame with equal arguments costs nothing.
+ ///
+ public void SetMessage(in EntryMessage message)
+ {
+ _message = message;
+ _binding.SetMessage(in message);
+ }
+
+ protected override void OnActivated(in BindingActivationContext context)
+ {
+ base.OnActivated(in context);
+ _activeCount++;
+ if (_activeCount == 1)
+ {
+ Start();
+ }
+ }
+
+ protected override void OnDeactivated(in BindingActivationContext context)
+ {
+ base.OnDeactivated(in context);
+ _activeCount--;
+ if (_activeCount == 0)
+ {
+ Stop();
+ }
+ }
+
+ protected override BindingResult Update(in BindingContext context)
+ {
+ if (_text == null)
+ {
+ return new BindingResult(BindingStatus.Pending);
+ }
+ VisualElement element = context.targetElement;
+ if (ConverterGroups.TrySetValueGlobal(ref element, context.bindingId, _text, out VisitReturnCode problem))
+ {
+ return new BindingResult(BindingStatus.Success);
+ }
+ return new BindingResult(BindingStatus.Failure, $"{nameof(LocalizedTextBinding)} can't set '{context.bindingId}' of {element.GetType().Name}: {problem}.");
+ }
+
+ private void Restart()
+ {
+ if (_activeCount > 0)
+ {
+ Stop();
+ Start();
+ }
+ }
+
+ private void Start()
+ {
+ int dot = _entry.IndexOf('.');
+ EntryReference reference = new(_catalog, dot > 0 ? _entry.Substring(0, dot) : string.Empty, dot > 0 ? _entry.Substring(dot + 1) : string.Empty);
+ EntryKey key = reference.ToKey();
+ CatalogKey catalog = reference.ToCatalogKey();
+ if (key.IsEmpty || (_catalog.Length > 0 && catalog.IsEmpty))
+ {
+ // As loud as a missing key, so a typo in UXML never passes for text.
+ Show(this, _entry.Length == 0 ? string.Empty : $"[{_entry}]");
+ if (_entry.Length > 0)
+ {
+ Debug.LogWarning($"[ReactiveLocalizer] '{_entry}' of catalog '{_catalog}' isn't an entry written Table.Entry, with names of ASCII letters, digits and underscores that start with a letter.");
+ }
+ return;
+ }
+ ILocalizer localizer = GlobalLocalizer.For(catalog);
+ if (localizer == null)
+ {
+ return;
+ }
+ _binding = localizer.Bind(in key, this, static (binding, text) => Show(binding, text));
+ if (!_message.IsEmpty)
+ {
+ _binding.SetMessage(in _message);
+ }
+ }
+
+ private void Stop()
+ {
+ _binding.Dispose();
+ _binding = default;
+ }
+
+ private static void Show(LocalizedTextBinding binding, string text)
+ {
+ binding._text = text;
+ binding.MarkDirty();
+ }
+ }
+}
+#endif
diff --git a/UnityPackage/Samples~/UIToolkit/LocalizedTextBinding.cs.meta b/UnityPackage/Samples~/UIToolkit/LocalizedTextBinding.cs.meta
new file mode 100644
index 0000000..8d97382
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/LocalizedTextBinding.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: ae1242eab8515214c859affcd5707c2e
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UIToolkit/reromanlee.ReactiveLocalizer.Samples.UIToolkit.asmdef b/UnityPackage/Samples~/UIToolkit/reromanlee.ReactiveLocalizer.Samples.UIToolkit.asmdef
new file mode 100644
index 0000000..b02bc75
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/reromanlee.ReactiveLocalizer.Samples.UIToolkit.asmdef
@@ -0,0 +1,18 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.UIToolkit",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.UIToolkit",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Samples.QuickStart"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/UIToolkit/reromanlee.ReactiveLocalizer.Samples.UIToolkit.asmdef.meta b/UnityPackage/Samples~/UIToolkit/reromanlee.ReactiveLocalizer.Samples.UIToolkit.asmdef.meta
new file mode 100644
index 0000000..a146438
--- /dev/null
+++ b/UnityPackage/Samples~/UIToolkit/reromanlee.ReactiveLocalizer.Samples.UIToolkit.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 5d93f70ce7115c948817e43a3c5e1709
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/UguiText/Editor.meta b/UnityPackage/Samples~/UguiText/Editor.meta
new file mode 100644
index 0000000..279c35a
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 621c7117419eac34097d9a4e3e72de89
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/UguiText/Editor/LocalizeLegacyText.cs b/UnityPackage/Samples~/UguiText/Editor/LocalizeLegacyText.cs
new file mode 100644
index 0000000..ec3fe88
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/Editor/LocalizeLegacyText.cs
@@ -0,0 +1,46 @@
+using reromanlee.ReactiveLocalizer.Editor;
+using reromanlee.ReactiveLocalizer.Unity;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UguiText.Editor
+{
+ ///
+ /// Adds Localize to the context menu of every legacy uGUI text: it turns the current text into a new entry
+ /// of the default catalog, in the table named after the scene or prefab, keyed after the GameObject's role, and
+ /// adds a showing it.
+ ///
+ internal static class LocalizeLegacyText
+ {
+ private const string MenuPath = "CONTEXT/Text/Localize";
+
+ [MenuItem(MenuPath)]
+ private static void Localize(MenuCommand command)
+ {
+ Text text = (Text)command.context;
+ string table = EntryAuthoring.SuggestTable(text.gameObject);
+ string key = EntryAuthoring.SuggestKey(text.gameObject.name);
+ if (!EntryAuthoring.TryCreateEntry(null, table, key, text.text, out EntryReference reference, out string problem))
+ {
+ Debug.LogWarning($"[ReactiveLocalizer] Couldn't localize '{text.name}': {problem}", text);
+ return;
+ }
+ LocalizedLegacyText localized = text.GetComponent();
+ if (localized == null)
+ {
+ localized = Undo.AddComponent(text.gameObject);
+ }
+ Undo.RecordObject(localized, "Localize Text");
+ localized.Entry = reference;
+ EditorUtility.SetDirty(localized);
+ Debug.Log($"[ReactiveLocalizer] '{text.name}' now shows {reference}.", text);
+ }
+
+ [MenuItem(MenuPath, true)]
+ private static bool CanLocalize(MenuCommand command)
+ {
+ return command.context is Text text && text.GetComponent() == null;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UguiText/Editor/LocalizeLegacyText.cs.meta b/UnityPackage/Samples~/UguiText/Editor/LocalizeLegacyText.cs.meta
new file mode 100644
index 0000000..76f978d
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/Editor/LocalizeLegacyText.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0303f96eaa3be9f448ca4429fc6637a6
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UguiText/Editor/reromanlee.ReactiveLocalizer.Samples.UguiText.Editor.asmdef b/UnityPackage/Samples~/UguiText/Editor/reromanlee.ReactiveLocalizer.Samples.UguiText.Editor.asmdef
new file mode 100644
index 0000000..01b740e
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/Editor/reromanlee.ReactiveLocalizer.Samples.UguiText.Editor.asmdef
@@ -0,0 +1,31 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.UguiText.Editor",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.UguiText.Editor",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Editor",
+ "reromanlee.ReactiveLocalizer.Samples.QuickStart",
+ "reromanlee.ReactiveLocalizer.Samples.UguiText",
+ "UnityEngine.UI"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "REACTIVE_LOCALIZER_UGUI"
+ ],
+ "versionDefines": [
+ {
+ "name": "com.unity.ugui",
+ "expression": "1.0.0",
+ "define": "REACTIVE_LOCALIZER_UGUI"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/UguiText/Editor/reromanlee.ReactiveLocalizer.Samples.UguiText.Editor.asmdef.meta b/UnityPackage/Samples~/UguiText/Editor/reromanlee.ReactiveLocalizer.Samples.UguiText.Editor.asmdef.meta
new file mode 100644
index 0000000..ffe9569
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/Editor/reromanlee.ReactiveLocalizer.Samples.UguiText.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 07ca0357cf9eb2a4da9ed10550190f65
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/UguiText/LocalizedLegacyText.cs b/UnityPackage/Samples~/UguiText/LocalizedLegacyText.cs
new file mode 100644
index 0000000..4f5c625
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/LocalizedLegacyText.cs
@@ -0,0 +1,41 @@
+using reromanlee.ReactiveLocalizer.Samples.QuickStart;
+using UnityEngine;
+using UnityEngine.UI;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UguiText
+{
+ ///
+ /// Shows an entry in the legacy uGUI of its GameObject, in the current language,
+ /// through every switch.
+ ///
+ ///
+ /// Legacy text takes strings, so each new text costs the one string the localizer caches anyway. In Edit Mode it
+ /// previews the entry in the language the Scene view's overlay picks, and scenes and prefabs save the text it was
+ /// authored with. Prefer the TextMeshPro sample for new UI.
+ ///
+ [RequireComponent(typeof(Text))]
+ [AddComponentMenu("ReactiveLocalizer/Localized Text (Legacy)")]
+ public sealed class LocalizedLegacyText : LocalizedBehaviour
+ {
+ private Text _text;
+
+ private Text Target => _text != null ? _text : _text = GetComponent();
+
+ protected override string Text
+ {
+ get => Target != null ? Target.text : null;
+ set
+ {
+ if (Target != null)
+ {
+ Target.text = value;
+ }
+ }
+ }
+
+ protected override TextBinding Bind(ILocalizer localizer, in EntryKey key)
+ {
+ return localizer.Bind(in key, this, static (component, text) => component.Text = text);
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UguiText/LocalizedLegacyText.cs.meta b/UnityPackage/Samples~/UguiText/LocalizedLegacyText.cs.meta
new file mode 100644
index 0000000..b79839d
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/LocalizedLegacyText.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: edce4c9de74d7ca4eae6e016603afdeb
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UguiText/reromanlee.ReactiveLocalizer.Samples.UguiText.asmdef b/UnityPackage/Samples~/UguiText/reromanlee.ReactiveLocalizer.Samples.UguiText.asmdef
new file mode 100644
index 0000000..67d82c1
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/reromanlee.ReactiveLocalizer.Samples.UguiText.asmdef
@@ -0,0 +1,27 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.UguiText",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.UguiText",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Samples.QuickStart",
+ "UnityEngine.UI"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "REACTIVE_LOCALIZER_UGUI"
+ ],
+ "versionDefines": [
+ {
+ "name": "com.unity.ugui",
+ "expression": "1.0.0",
+ "define": "REACTIVE_LOCALIZER_UGUI"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/UguiText/reromanlee.ReactiveLocalizer.Samples.UguiText.asmdef.meta b/UnityPackage/Samples~/UguiText/reromanlee.ReactiveLocalizer.Samples.UguiText.asmdef.meta
new file mode 100644
index 0000000..326f61f
--- /dev/null
+++ b/UnityPackage/Samples~/UguiText/reromanlee.ReactiveLocalizer.Samples.UguiText.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 29349e8020043ab46a91e04a7a2b2dd4
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor.meta b/UnityPackage/Samples~/UnityLocalizationMigration/Editor.meta
new file mode 100644
index 0000000..3cb5b59
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: abde1f68eed90214396da065e862d05e
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/LocalizationMigration.cs b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/LocalizationMigration.cs
new file mode 100644
index 0000000..df07995
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/LocalizationMigration.cs
@@ -0,0 +1,311 @@
+using reromanlee.ReactiveLocalizer.Authoring;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using UnityEditor;
+using UnityEditor.Localization;
+using UnityEngine;
+using UnityEngine.Localization;
+using UnityEngine.Localization.Metadata;
+using UnityEngine.Localization.Settings;
+using UnityEngine.Localization.Tables;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor
+{
+ ///
+ /// Tools/ReactiveLocalizer/Samples/Migrate from Unity Localization: turns the project's locales into a
+ /// catalog's languages and its string table collections into .lang files, keeping keys, comments and every
+ /// translation. Unity Localization's own assets stay as they are.
+ ///
+ ///
+ /// The catalog is named Localization, so its generated class is LocalizationKeys, and call sites such
+ /// as LocalizationKeys.SettingsPage.Title keep compiling once a project's own key wrapper is gone. Plain
+ /// placeholders carry over (see ); what needs a person is listed in the Console
+ /// and marked in the files with a MIGRATE comment.
+ ///
+ public static class LocalizationMigration
+ {
+ /// The name of the catalog the migration creates.
+ public const string CatalogName = "Localization";
+
+ private const string MigrateComment = "MIGRATE: Smart Format text kept as written; convert it to ICU: ";
+
+ [MenuItem("Tools/ReactiveLocalizer/Samples/Migrate from Unity Localization...")]
+ private static void Migrate()
+ {
+ MigrationPlan plan = CreatePlan(out string problem);
+ if (plan == null)
+ {
+ EditorUtility.DisplayDialog("Migrate from Unity Localization", problem, "OK");
+ return;
+ }
+ string chosen = EditorUtility.SaveFolderPanel("Migrate into Folder", Application.dataPath, CatalogName);
+ if (string.IsNullOrEmpty(chosen))
+ {
+ return;
+ }
+ if (!TryGetAssetFolder(chosen, out string folder, out problem) ||
+ !EditorUtility.DisplayDialog("Migrate from Unity Localization",
+ $"Write {plan.Tables.Count} table(s) with {plan.TextCount} text(s) in {plan.Languages.Count} language(s), and {CatalogName}.catalog, into {folder}?",
+ "Migrate", "Cancel"))
+ {
+ if (problem != null)
+ {
+ EditorUtility.DisplayDialog("Migrate from Unity Localization", problem, "OK");
+ }
+ return;
+ }
+ Write(plan, folder);
+ AssetDatabase.Refresh();
+ Debug.Log("[ReactiveLocalizer] " + plan.WriteReport(folder));
+ }
+
+ ///
+ /// Plans the migration of the project's locales and string table collections, without writing anything. Null,
+ /// with saying why, when there's nothing to migrate.
+ ///
+ public static MigrationPlan CreatePlan(out string problem)
+ {
+ IReadOnlyList locales = LocalizationEditorSettings.GetLocales();
+ IReadOnlyList collections = LocalizationEditorSettings.GetStringTableCollections();
+ if (locales.Count == 0 || collections.Count == 0)
+ {
+ problem = "The project has no locales or no string table collections to migrate.";
+ return null;
+ }
+ Dictionary byCode = CreateLanguages(locales);
+ Locale sourceLocale = FindSourceLocale(locales);
+ MigrationPlan plan = new(CatalogName, byCode[sourceLocale.Identifier.Code]);
+ plan.Languages.Add(plan.Source);
+ foreach (Locale locale in locales)
+ {
+ MigrationLanguage language = byCode[locale.Identifier.Code];
+ if (language != plan.Source)
+ {
+ plan.Languages.Add(language);
+ }
+ Locale fallback = locale.Metadata?.GetMetadata()?.Locale;
+ if (fallback != null && byCode.TryGetValue(fallback.Identifier.Code, out MigrationLanguage target) && target != language && target != plan.Source)
+ {
+ language.Fallback = target.Name;
+ }
+ }
+
+ HashSet tableNames = MigrationNames.CreateTakenSet();
+ foreach (StringTableCollection collection in collections)
+ {
+ string tableName = MigrationNames.MakeUnique(MigrationNames.ToName(collection.TableCollectionName, "Table"), tableNames);
+ Note(plan, collection.TableCollectionName, tableName);
+ plan.Tables.Add(CreateTable(plan, collection, tableName, locales, byCode, sourceLocale));
+ }
+ problem = null;
+ return plan;
+ }
+
+ /// Writes the catalog and every table file into , a folder of the project.
+ public static void Write(MigrationPlan plan, string folder)
+ {
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, plan.CatalogName + ".catalog"), plan.WriteCatalog());
+ foreach (TableFileSet table in plan.Tables)
+ {
+ foreach (string language in table.Languages)
+ {
+ File.WriteAllText(Path.Combine(folder, $"{table.TableName}.{language}.lang"), table.Get(language).Write());
+ }
+ }
+ }
+
+ private static TableFileSet CreateTable(MigrationPlan plan, StringTableCollection collection, string tableName, IReadOnlyList locales,
+ Dictionary byCode, Locale sourceLocale)
+ {
+ TableFileSet table = new(tableName, plan.Source.Name);
+ StringTable sourceTable = collection.GetTable(sourceLocale.Identifier) as StringTable;
+ // Generated code names the table and catalog with these two, so no entry can.
+ HashSet keys = MigrationNames.CreateTakenSet("TableKey", "CatalogKey");
+ foreach (SharedTableData.SharedTableEntry shared in collection.SharedData.Entries)
+ {
+ string key = MigrationNames.MakeUnique(MigrationNames.ToName(shared.Key, "Entry"), keys);
+ Note(plan, $"{collection.TableCollectionName}/{shared.Key}", $"{tableName}.{key}");
+ string where = $"{tableName}.{key}";
+ StringTableEntry sourceEntry = sourceTable?.GetEntry(shared.Id);
+ string sourceText = Convert(plan, where, plan.Source.Name, sourceEntry, out string sourceNote);
+ if (!table.TryAddEntry(key, sourceText, out string problem))
+ {
+ plan.Unconverted.Add($"{where}: {problem}");
+ continue;
+ }
+ plan.TextCount++;
+ TableFileEntry written = Find(table, plan.Source.Name, key);
+ string comment = shared.Metadata?.GetMetadata()?.CommentText;
+ if (!string.IsNullOrWhiteSpace(comment))
+ {
+ written.Comments.AddRange(comment.Replace("\r\n", "\n").Split('\n'));
+ }
+ if (sourceNote != null)
+ {
+ written.Comments.Add(sourceNote);
+ }
+ foreach (Locale locale in locales)
+ {
+ if (locale == sourceLocale || !(collection.GetTable(locale.Identifier) is StringTable translations))
+ {
+ continue;
+ }
+ StringTableEntry entry = translations.GetEntry(shared.Id);
+ // An empty translation is a missing one in Unity Localization, which then shows its fallback.
+ if (entry == null || string.IsNullOrEmpty(entry.Value))
+ {
+ continue;
+ }
+ string language = byCode[locale.Identifier.Code].Name;
+ string text = Convert(plan, where, language, entry, out string note);
+ if (table.TrySetText(language, key, text, out _))
+ {
+ plan.TextCount++;
+ if (note != null)
+ {
+ Find(table, language, key)?.Comments.Add(note);
+ }
+ }
+ }
+ }
+ return table;
+ }
+
+ private static string Convert(MigrationPlan plan, string where, string language, StringTableEntry entry, out string note)
+ {
+ note = null;
+ string text = entry?.Value ?? string.Empty;
+ bool isSmart = entry != null && entry.IsSmart;
+ if (!SmartFormatConversion.TryConvert(text, isSmart, out string message, out bool hasArguments))
+ {
+ plan.Unconverted.Add($"{where} in {language}: {text}");
+ note = MigrateComment + text.Replace("\n", " ");
+ return message;
+ }
+ if (hasArguments && message.Contains("{arg"))
+ {
+ plan.Converted.Add($"{where} in {language}: {message}");
+ }
+ return message;
+ }
+
+ ///
+ /// Names each locale after its language in English, such as English. Locales sharing a language are
+ /// named after their region too, such as PortugueseBrazil; a locale without a known culture after its own name.
+ ///
+ private static Dictionary CreateLanguages(IReadOnlyList locales)
+ {
+ Dictionary neutralCounts = new(StringComparer.OrdinalIgnoreCase);
+ foreach (Locale locale in locales)
+ {
+ string neutral = GetCulture(locale)?.TwoLetterISOLanguageName ?? locale.Identifier.Code;
+ neutralCounts[neutral] = neutralCounts.TryGetValue(neutral, out int count) ? count + 1 : 1;
+ }
+ HashSet names = MigrationNames.CreateTakenSet();
+ Dictionary byCode = new(StringComparer.OrdinalIgnoreCase);
+ foreach (Locale locale in locales)
+ {
+ CultureInfo culture = GetCulture(locale);
+ string englishName;
+ if (culture == null)
+ {
+ englishName = locale.LocaleName;
+ }
+ else
+ {
+ bool isShared = neutralCounts[culture.TwoLetterISOLanguageName] > 1;
+ englishName = isShared || culture.IsNeutralCulture ? culture.EnglishName : culture.Parent.EnglishName;
+ }
+ string name = MigrationNames.MakeUnique(MigrationNames.ToName(englishName, "Language"), names);
+ string displayName = culture != null ? culture.NativeName : locale.LocaleName;
+ byCode[locale.Identifier.Code] = new MigrationLanguage(name, displayName, locale.Identifier.Code);
+ }
+ return byCode;
+ }
+
+ private static CultureInfo GetCulture(Locale locale)
+ {
+ try
+ {
+ CultureInfo culture = locale.Identifier.CultureInfo;
+ // An invented locale gets a culture with no language behind it.
+ return culture != null && culture.ThreeLetterISOLanguageName != "ivl" ? culture : null;
+ }
+ catch (CultureNotFoundException)
+ {
+ return null;
+ }
+ }
+
+ private static Locale FindSourceLocale(IReadOnlyList locales)
+ {
+ Locale project = null;
+ try
+ {
+ project = LocalizationSettings.ProjectLocale;
+ }
+ catch (Exception exception) when (exception is NullReferenceException || exception is InvalidOperationException)
+ {
+ // Without Localization Settings there is no project locale, and the first locale is the source.
+ }
+ foreach (Locale locale in locales)
+ {
+ if (project != null && locale.Identifier == project.Identifier)
+ {
+ return locale;
+ }
+ }
+ return locales[0];
+ }
+
+ private static TableFileEntry Find(TableFileSet table, string language, string key)
+ {
+ TableFile file = table.Get(language);
+ return file != null && file.TryGetEntry(key, out TableFileEntry entry) ? entry : null;
+ }
+
+ private static void Note(MigrationPlan plan, string original, string migrated)
+ {
+ string originalName = original.Substring(original.LastIndexOf('/') + 1);
+ string migratedName = migrated.Substring(migrated.LastIndexOf('.') + 1);
+ if (!string.Equals(originalName, migratedName, StringComparison.Ordinal))
+ {
+ plan.Renamed.Add($"{original} \u2192 {migrated}");
+ }
+ }
+
+ /// Turns a chosen folder into a project path, refusing folders outside Assets and ones that already hold localization files.
+ private static bool TryGetAssetFolder(string chosen, out string folder, out string problem)
+ {
+ string full = Path.GetFullPath(chosen).Replace('\\', '/');
+ string assets = Path.GetFullPath(Application.dataPath).Replace('\\', '/');
+ folder = null;
+ if (!full.Equals(assets, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(assets + "/", StringComparison.OrdinalIgnoreCase))
+ {
+ problem = "Pick a folder inside the project's Assets folder.";
+ return false;
+ }
+ folder = "Assets" + full.Substring(assets.Length);
+ if (Directory.Exists(folder) && (Directory.GetFiles(folder, "*.catalog").Length > 0 || Directory.GetFiles(folder, "*.lang").Length > 0))
+ {
+ problem = $"{folder} already holds localization files. Pick an empty folder, so nothing is overwritten.";
+ return false;
+ }
+ foreach (string guid in AssetDatabase.FindAssets(CatalogName))
+ {
+ // Two catalogs with one name would claim the same generated class.
+ string path = AssetDatabase.GUIDToAssetPath(guid);
+ if (path.EndsWith("/" + CatalogName + ".catalog", StringComparison.OrdinalIgnoreCase))
+ {
+ problem = $"The project already has {path}, and two catalogs can't share a name. Rename or remove it first.";
+ return false;
+ }
+ }
+ problem = null;
+ return true;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/LocalizationMigration.cs.meta b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/LocalizationMigration.cs.meta
new file mode 100644
index 0000000..9900120
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/LocalizationMigration.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a87d26b8d870fd04b93711b8207d8cf5
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationLanguage.cs b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationLanguage.cs
new file mode 100644
index 0000000..d365501
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationLanguage.cs
@@ -0,0 +1,25 @@
+namespace reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor
+{
+ /// A Unity Localization locale as the language it becomes in the migrated catalog.
+ public sealed class MigrationLanguage
+ {
+ public MigrationLanguage(string name, string displayName, string culture)
+ {
+ Name = name;
+ DisplayName = displayName;
+ Culture = culture;
+ }
+
+ /// The language's name, such as English, or PortugueseBrazil beside another Portuguese.
+ public string Name { get; }
+
+ /// What a language picker shows, such as Português (Brasil).
+ public string DisplayName { get; }
+
+ /// The locale's code, which plural rules and number formats follow, such as pt-BR.
+ public string Culture { get; }
+
+ /// The language missing entries come from, by name; null for none.
+ public string Fallback { get; set; }
+ }
+}
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationLanguage.cs.meta b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationLanguage.cs.meta
new file mode 100644
index 0000000..bb6a680
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationLanguage.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 7cb03dbe37787aa42baf8fb9199c9ee5
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationNames.cs b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationNames.cs
new file mode 100644
index 0000000..9a2513e
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationNames.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor
+{
+ ///
+ /// Turns Unity Localization's table, key and locale names into names the naming rule accepts, in PascalCase:
+ /// Settings Page becomes SettingsPage and menu/play_button becomes MenuPlayButton.
+ /// A name that already follows the rule stays as it is.
+ ///
+ public static class MigrationNames
+ {
+ ///
+ /// Returns as a valid name: words joined, each starting upper case, everything but
+ /// ASCII letters and digits dropped, and in front when nothing or a digit is left first.
+ ///
+ public static string ToName(string text, string prefix)
+ {
+ if (NameRules.IsValid(text) && text.IndexOf('_') < 0 && !char.IsLower(text[0]))
+ {
+ return text;
+ }
+ StringBuilder name = new(text?.Length ?? 0);
+ bool isWordStart = true;
+ foreach (char character in text ?? string.Empty)
+ {
+ bool isLetter = (uint)((character | 0x20) - 'a') <= 'z' - 'a';
+ bool isDigit = (uint)(character - '0') <= 9;
+ if (!isLetter && !isDigit)
+ {
+ isWordStart = true;
+ continue;
+ }
+ name.Append(isWordStart ? char.ToUpperInvariant(character) : character);
+ isWordStart = false;
+ }
+ if (name.Length == 0 || char.IsDigit(name[0]))
+ {
+ name.Insert(0, prefix);
+ }
+ if (name.Length > NameRules.MaximumLength)
+ {
+ name.Length = NameRules.MaximumLength;
+ }
+ return name.ToString();
+ }
+
+ ///
+ /// Returns , or it with the lowest number appended that no name in
+ /// has, ignoring case as names do, and adds the result to .
+ ///
+ public static string MakeUnique(string name, HashSet taken)
+ {
+ string unique = name;
+ for (int number = 2; taken.Contains(unique); number++)
+ {
+ string suffix = number.ToString(System.Globalization.CultureInfo.InvariantCulture);
+ unique = name.Length + suffix.Length > NameRules.MaximumLength
+ ? name.Substring(0, NameRules.MaximumLength - suffix.Length) + suffix
+ : name + suffix;
+ }
+ taken.Add(unique);
+ return unique;
+ }
+
+ /// Returns a set for that compares names as the naming rule does, ignoring case.
+ public static HashSet CreateTakenSet(params string[] reserved)
+ {
+ return new HashSet(reserved ?? Array.Empty(), StringComparer.OrdinalIgnoreCase);
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationNames.cs.meta b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationNames.cs.meta
new file mode 100644
index 0000000..e9a5799
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationNames.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f294d31d8ea2c8a4a8985ff63e73e5eb
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationPlan.cs b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationPlan.cs
new file mode 100644
index 0000000..d71d7fb
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationPlan.cs
@@ -0,0 +1,97 @@
+using reromanlee.ReactiveLocalizer.Authoring;
+using System.Collections.Generic;
+using System.Text;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor
+{
+ ///
+ /// What a migration writes, made before anything is written: the catalog's languages, every table in every
+ /// language, and what needs a person's attention afterwards.
+ ///
+ public sealed class MigrationPlan
+ {
+ public MigrationPlan(string catalogName, MigrationLanguage source)
+ {
+ CatalogName = catalogName;
+ Source = source;
+ }
+
+ /// The catalog's name, which its file and generated classes take, such as Localization.
+ public string CatalogName { get; }
+
+ /// The language the tables are written in.
+ public MigrationLanguage Source { get; }
+
+ /// Every language, the source first.
+ public List Languages { get; } = new();
+
+ /// Every table, with a file per language that has any of its texts.
+ public List Tables { get; } = new();
+
+ /// Names that changed to follow the naming rule, written old → new.
+ public List Renamed { get; } = new();
+
+ /// Entries whose numbered placeholders became named arguments, such as {0} into {arg0}.
+ public List Converted { get; } = new();
+
+ /// Entries kept as their written text, which need converting to ICU by hand.
+ public List Unconverted { get; } = new();
+
+ /// How many texts the tables hold, in every language.
+ public int TextCount { get; set; }
+
+ /// Returns the catalog file's text: the source language, then a section per language.
+ public string WriteCatalog()
+ {
+ StringBuilder text = new();
+ text.Append("# Migrated from Unity Localization. Each language was a locale; its code is the culture.\n\n");
+ text.Append("@source ").Append(Source.Name).Append('\n');
+ for (int i = 0; i < Languages.Count; i++)
+ {
+ MigrationLanguage language = Languages[i];
+ text.Append('\n').Append('[').Append(language.Name).Append("]\n");
+ text.Append("DisplayName = ").Append(Escape(language.DisplayName)).Append('\n');
+ if (!string.IsNullOrEmpty(language.Culture))
+ {
+ text.Append("Culture = ").Append(language.Culture).Append('\n');
+ }
+ if (!string.IsNullOrEmpty(language.Fallback))
+ {
+ text.Append("Fallback = ").Append(language.Fallback).Append('\n');
+ }
+ }
+ return text.ToString();
+ }
+
+ /// Returns the report the Console shows once the files are written.
+ public string WriteReport(string folder)
+ {
+ StringBuilder report = new();
+ report.Append($"Migrated {Tables.Count} table(s) and {TextCount} text(s) in {Languages.Count} language(s) into {folder}/{CatalogName}.catalog.");
+ Append(report, "Renamed to follow the naming rule; update the code that names them", Renamed);
+ Append(report, "Numbered placeholders became named arguments; rename them after what they show, in every language", Converted);
+ Append(report, "Kept as written, marked with a MIGRATE comment; convert these Smart Format strings to ICU by hand", Unconverted);
+ return report.ToString();
+ }
+
+ private static void Append(StringBuilder report, string title, List lines)
+ {
+ if (lines.Count == 0)
+ {
+ return;
+ }
+ report.Append("\n\n").Append(title).Append(':');
+ for (int i = 0; i < lines.Count; i++)
+ {
+ report.Append("\n ").Append(lines[i]);
+ }
+ }
+
+ /// Escapes what a catalog value can't hold as is: backslashes, and spaces at its ends.
+ private static string Escape(string value)
+ {
+ string text = (value ?? string.Empty).Replace("\\", "\\\\").Trim();
+ return text;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationPlan.cs.meta b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationPlan.cs.meta
new file mode 100644
index 0000000..97748f6
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/MigrationPlan.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 65c2a06c8b7ab574983ce4ed60a7cbff
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/SmartFormatConversion.cs b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/SmartFormatConversion.cs
new file mode 100644
index 0000000..5733a3c
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/SmartFormatConversion.cs
@@ -0,0 +1,139 @@
+using reromanlee.ReactiveLocalizer.Authoring;
+using System.Text;
+
+namespace reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor
+{
+ ///
+ /// Converts Unity Localization strings to ICU messages that show the same text with the same arguments: plain
+ /// placeholders such as {0} and {name} carry over, and anything Smart Format writes differently from
+ /// ICU, such as its plural and choose formatters, is left for a person to convert.
+ ///
+ ///
+ /// A numbered placeholder {0} becomes {arg0}, since ICU arguments have names, which generated code
+ /// turns into parameters. Plain strings take it from string.Format, which projects often apply to them; Smart
+ /// strings also take named placeholders, {{ and }} as braces, and character escapes such as \n.
+ ///
+ public static class SmartFormatConversion
+ {
+ ///
+ /// Converts . Returns false when it needs converting by hand;
+ /// then shows the text as it is written, braces included, so nothing is lost meanwhile.
+ ///
+ /// The string as Unity Localization stores it.
+ /// Whether the entry is a Smart string.
+ /// The ICU message.
+ /// Whether the message has any argument.
+ public static bool TryConvert(string text, bool isSmart, out string message, out bool hasArguments)
+ {
+ text ??= string.Empty;
+ StringBuilder output = new(text.Length + 8);
+ StringBuilder literal = new();
+ hasArguments = false;
+ int index = 0;
+ while (index < text.Length)
+ {
+ char character = text[index];
+ char next = index + 1 < text.Length ? text[index + 1] : '\0';
+ if (isSmart && (character == '{' || character == '}') && next == character)
+ {
+ literal.Append(character);
+ index += 2;
+ continue;
+ }
+ if (isSmart && character == '\\' && TryUnescape(next, out char unescaped))
+ {
+ literal.Append(unescaped);
+ index += 2;
+ continue;
+ }
+ if (character == '{')
+ {
+ int close = text.IndexOf('}', index + 1);
+ string name = close > index ? ToArgument(text.Substring(index + 1, close - index - 1), isSmart) : null;
+ if (name != null)
+ {
+ output.Append(MessageText.Quote(literal.ToString(), true)).Append('{').Append(name).Append('}');
+ literal.Clear();
+ hasArguments = true;
+ index = close + 1;
+ continue;
+ }
+ // Any other placeholder is Smart Format syntax ICU writes another way, or a format string's.
+ if (isSmart || (close > index && IsFormatPlaceholder(text.Substring(index + 1, close - index - 1))))
+ {
+ return Keep(text, out message, out hasArguments);
+ }
+ }
+ literal.Append(character);
+ index++;
+ }
+ output.Append(MessageText.Quote(literal.ToString()));
+ message = output.ToString();
+ return MessageText.IsValid(message, out _) || Keep(text, out message, out hasArguments);
+ }
+
+ /// Returns the ICU argument a simple placeholder becomes, or null when it isn't a simple one.
+ private static string ToArgument(string placeholder, bool isSmart)
+ {
+ if (placeholder.Length > 0 && placeholder.Length <= 3 && IsDigits(placeholder))
+ {
+ return "arg" + placeholder;
+ }
+ return isSmart && NameRules.IsValid(placeholder) ? placeholder : null;
+ }
+
+ /// Whether a placeholder of a plain string is one string.Format reads, such as {0:N2} or {1,8}.
+ private static bool IsFormatPlaceholder(string placeholder)
+ {
+ int end = 0;
+ while (end < placeholder.Length && char.IsDigit(placeholder[end]))
+ {
+ end++;
+ }
+ return end > 0 && end < placeholder.Length && (placeholder[end] == ':' || placeholder[end] == ',');
+ }
+
+ private static bool Keep(string text, out string message, out bool hasArguments)
+ {
+ message = MessageText.Quote(text);
+ hasArguments = false;
+ return false;
+ }
+
+ private static bool TryUnescape(char escaped, out char character)
+ {
+ switch (escaped)
+ {
+ case 'n':
+ character = '\n';
+ return true;
+ case 't':
+ character = '\t';
+ return true;
+ case 'r':
+ character = '\r';
+ return true;
+ case '\\':
+ case '\'':
+ case '"':
+ character = escaped;
+ return true;
+ default:
+ character = '\0';
+ return false;
+ }
+ }
+
+ private static bool IsDigits(string text)
+ {
+ for (int i = 0; i < text.Length; i++)
+ {
+ if (!char.IsDigit(text[i]))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/SmartFormatConversion.cs.meta b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/SmartFormatConversion.cs.meta
new file mode 100644
index 0000000..fc23a2d
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/SmartFormatConversion.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 28879cfaf73cd5645bc9cb83e4bd944b
\ No newline at end of file
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor.asmdef b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor.asmdef
new file mode 100644
index 0000000..649daf6
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor.asmdef
@@ -0,0 +1,29 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Authoring",
+ "Unity.Localization",
+ "Unity.Localization.Editor"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "REACTIVE_LOCALIZER_UNITY_LOCALIZATION"
+ ],
+ "versionDefines": [
+ {
+ "name": "com.unity.localization",
+ "expression": "1.4.0",
+ "define": "REACTIVE_LOCALIZER_UNITY_LOCALIZATION"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/UnityLocalizationMigration/Editor/reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor.asmdef.meta b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor.asmdef.meta
new file mode 100644
index 0000000..054a40e
--- /dev/null
+++ b/UnityPackage/Samples~/UnityLocalizationMigration/Editor/reromanlee.ReactiveLocalizer.Samples.UnityLocalizationMigration.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: c3e317ba53f987c4a8c9acb2111f89ce
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Samples~/VContainer/LocalizerInstaller.cs b/UnityPackage/Samples~/VContainer/LocalizerInstaller.cs
new file mode 100644
index 0000000..a4d3544
--- /dev/null
+++ b/UnityPackage/Samples~/VContainer/LocalizerInstaller.cs
@@ -0,0 +1,42 @@
+using reromanlee.ReactiveLocalizer.Samples.QuickStart;
+using reromanlee.ReactiveLocalizer.Unity;
+using VContainer;
+using VContainer.Unity;
+
+namespace reromanlee.ReactiveLocalizer.Samples.VContainer
+{
+ ///
+ /// Registers the localizer of one catalog with VContainer, as and ,
+ /// and initializes it asynchronously as the container starts:
+ ///
+ /// protected override void Configure(IContainerBuilder builder)
+ /// {
+ /// new LocalizerInstaller(LocalizationKeys.CatalogKey).Install(builder);
+ /// }
+ ///
+ ///
+ ///
+ /// The container creates and disposes the localizer. It is also handed to Quick Start's
+ /// as the container builds, so components built on it, such as the TextMeshPro
+ /// sample's, show text from this instance, and it starts in the player's saved language.
+ ///
+ public sealed class LocalizerInstaller : IInstaller
+ {
+ private readonly CatalogKey _catalog;
+
+ /// The catalog to localize.
+ public LocalizerInstaller(CatalogKey catalog)
+ {
+ _catalog = catalog;
+ }
+
+ public void Install(IContainerBuilder builder)
+ {
+ CatalogKey catalog = _catalog;
+ builder.Register(_ => new Localizer(catalog, new UnityHost()), Lifetime.Singleton).As().AsSelf();
+ builder.RegisterEntryPoint();
+ // Handed over as soon as the container is built, before any component asks the access point for one.
+ builder.RegisterBuildCallback(container => GlobalLocalizer.Use(catalog, container.Resolve()));
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/VContainer/LocalizerInstaller.cs.meta b/UnityPackage/Samples~/VContainer/LocalizerInstaller.cs.meta
new file mode 100644
index 0000000..f07f939
--- /dev/null
+++ b/UnityPackage/Samples~/VContainer/LocalizerInstaller.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f4155045b3678d842ba1df51c57ee9d3
\ No newline at end of file
diff --git a/UnityPackage/Samples~/VContainer/LocalizerStartup.cs b/UnityPackage/Samples~/VContainer/LocalizerStartup.cs
new file mode 100644
index 0000000..e60c062
--- /dev/null
+++ b/UnityPackage/Samples~/VContainer/LocalizerStartup.cs
@@ -0,0 +1,40 @@
+#if VCONTAINER_UNITASK_INTEGRATION
+using Awaitable = Cysharp.Threading.Tasks.UniTask;
+#elif UNITY_2023_1_OR_NEWER
+using Awaitable = UnityEngine.Awaitable;
+#else
+using Awaitable = System.Threading.Tasks.Task;
+#endif
+using reromanlee.ReactiveLocalizer.Samples.QuickStart;
+using System.Threading;
+using VContainer.Unity;
+
+namespace reromanlee.ReactiveLocalizer.Samples.VContainer
+{
+ ///
+ /// Initializes the registered localizer as the container starts, then switches it to the player's saved language.
+ /// Initialization never fails: a catalog that can't be loaded is reported, and the localizer stays uninitialized.
+ ///
+ ///
+ /// VContainer's async entry points return UniTask when UniTask is installed, Unity's Awaitable on Unity 2023.1 and
+ /// newer, and Task before that; this one returns whichever it expects.
+ ///
+ internal sealed class LocalizerStartup : IAsyncStartable
+ {
+ private readonly Localizer _localizer;
+
+ public LocalizerStartup(Localizer localizer)
+ {
+ _localizer = localizer;
+ }
+
+ public async Awaitable StartAsync(CancellationToken cancellation = default)
+ {
+ await _localizer.InitializeAsync();
+ if (!cancellation.IsCancellationRequested)
+ {
+ GlobalLocalizer.StartInPreferredLanguage(_localizer);
+ }
+ }
+ }
+}
diff --git a/UnityPackage/Samples~/VContainer/LocalizerStartup.cs.meta b/UnityPackage/Samples~/VContainer/LocalizerStartup.cs.meta
new file mode 100644
index 0000000..e61c053
--- /dev/null
+++ b/UnityPackage/Samples~/VContainer/LocalizerStartup.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 69656a58e67b3b94ab4cdbb2b25f6b47
\ No newline at end of file
diff --git a/UnityPackage/Samples~/VContainer/reromanlee.ReactiveLocalizer.Samples.VContainer.asmdef b/UnityPackage/Samples~/VContainer/reromanlee.ReactiveLocalizer.Samples.VContainer.asmdef
new file mode 100644
index 0000000..b22e916
--- /dev/null
+++ b/UnityPackage/Samples~/VContainer/reromanlee.ReactiveLocalizer.Samples.VContainer.asmdef
@@ -0,0 +1,33 @@
+{
+ "name": "reromanlee.ReactiveLocalizer.Samples.VContainer",
+ "rootNamespace": "reromanlee.ReactiveLocalizer.Samples.VContainer",
+ "references": [
+ "reromanlee.ReactiveLocalizer",
+ "reromanlee.ReactiveLocalizer.Unity",
+ "reromanlee.ReactiveLocalizer.Samples.QuickStart",
+ "VContainer",
+ "UniTask"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [
+ "REACTIVE_LOCALIZER_VCONTAINER"
+ ],
+ "versionDefines": [
+ {
+ "name": "jp.hadashikick.vcontainer",
+ "expression": "1.13.0",
+ "define": "REACTIVE_LOCALIZER_VCONTAINER"
+ },
+ {
+ "name": "com.cysharp.unitask",
+ "expression": "",
+ "define": "VCONTAINER_UNITASK_INTEGRATION"
+ }
+ ],
+ "noEngineReferences": false
+}
diff --git a/UnityPackage/Samples~/VContainer/reromanlee.ReactiveLocalizer.Samples.VContainer.asmdef.meta b/UnityPackage/Samples~/VContainer/reromanlee.ReactiveLocalizer.Samples.VContainer.asmdef.meta
new file mode 100644
index 0000000..bad9622
--- /dev/null
+++ b/UnityPackage/Samples~/VContainer/reromanlee.ReactiveLocalizer.Samples.VContainer.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: cc52f87b43ba52a43a46b6321362b33f
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Tests/Authoring/MessageTextTests.cs b/UnityPackage/Tests/Authoring/MessageTextTests.cs
new file mode 100644
index 0000000..7e37cf3
--- /dev/null
+++ b/UnityPackage/Tests/Authoring/MessageTextTests.cs
@@ -0,0 +1,43 @@
+using NUnit.Framework;
+using reromanlee.ReactiveLocalizer.Authoring;
+
+namespace reromanlee.ReactiveLocalizer.Tests
+{
+ public class MessageTextTests
+ {
+ [TestCase("Press {0}", "Press '{'0'}'")]
+ [TestCase("Don't", "Don't")]
+ [TestCase("", "")]
+ public void Quote_WritesAMessageThatShowsTheTextAsIs(string text, string expected)
+ {
+ Assert.That(MessageText.Quote(text), Is.EqualTo(expected));
+ Assert.That(MessageText.IsValid(MessageText.Quote(text), out _), Is.True);
+ }
+
+ [Test]
+ public void Quote_BeforeAnArgument_KeepsAFinalApostropheApart()
+ {
+ string message = MessageText.Quote("Players'", true) + "{name}";
+
+ Assert.That(message, Is.EqualTo("Players''{name}"));
+ Assert.That(MessageText.IsValid(message, out _), Is.True);
+ }
+
+ [Test]
+ public void IsValid_TellsTheFirstError()
+ {
+ Assert.That(MessageText.IsValid("You have {coins, plural, one {# coin} other {# coins}}.", out string problem), Is.True);
+ Assert.That(problem, Is.Null);
+ Assert.That(MessageText.IsValid("Add {0}", out problem), Is.False);
+ Assert.That(problem, Does.StartWith("at 5: Arguments have names"));
+ }
+
+ [Test]
+ public void NameRules_AcceptIdentifierSafeNamesOnly()
+ {
+ Assert.That(NameRules.IsValid("SettingsPage"), Is.True);
+ Assert.That(NameRules.IsValid("Settings Page"), Is.False);
+ Assert.That(NameRules.IsValid("1Page"), Is.False);
+ }
+ }
+}
diff --git a/UnityPackage/Tests/Authoring/MessageTextTests.cs.meta b/UnityPackage/Tests/Authoring/MessageTextTests.cs.meta
new file mode 100644
index 0000000..169a404
--- /dev/null
+++ b/UnityPackage/Tests/Authoring/MessageTextTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 48643523c47ea3249bf58b82f7bada25
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/UnityPackage/Tests/Authoring/TableSettingsTests.cs b/UnityPackage/Tests/Authoring/TableSettingsTests.cs
index 64ff142..f02689f 100644
--- a/UnityPackage/Tests/Authoring/TableSettingsTests.cs
+++ b/UnityPackage/Tests/Authoring/TableSettingsTests.cs
@@ -30,6 +30,20 @@ public void Read_ReadsEverySettingIgnoringCase()
Assert.That(settings.GeneratesCode, Is.False);
}
+ [Test]
+ public void Read_ReadsACustomDelivery()
+ {
+ List issues = new();
+
+ TableSettings custom = TableSettings.Read(TableDocument.Parse("@delivery Custom\n\nLine = Text"), issues);
+ TableSettings unknown = TableSettings.Read(TableDocument.Parse("@delivery Cloud\n\nLine = Text"), issues);
+
+ Assert.That(custom.Delivery, Is.EqualTo(TableDelivery.Custom));
+ Assert.That(unknown.Delivery, Is.EqualTo(TableDelivery.Embedded));
+ Assert.That(issues.Count, Is.EqualTo(1));
+ Assert.That(issues[0].Message, Does.Contain("'Embedded', 'Streaming' or 'Custom'"));
+ }
+
[Test]
public void Read_ReportsUnknownValuesAndKeepsTheDefault()
{
diff --git a/UnityPackage/Tests/Core/CatalogInfoTests.cs b/UnityPackage/Tests/Core/CatalogInfoTests.cs
index 6cea563..68623d4 100644
--- a/UnityPackage/Tests/Core/CatalogInfoTests.cs
+++ b/UnityPackage/Tests/Core/CatalogInfoTests.cs
@@ -14,7 +14,8 @@ public class CatalogInfoTests
private static readonly TableInfo[] Tables =
{
new(new TableKey("Shop"), TableLoading.Preload, TableDelivery.Embedded),
- new(new TableKey("Dialogue"), TableLoading.OnDemand, TableDelivery.Streaming, 0x0123456789ABCDEFUL)
+ new(new TableKey("Dialogue"), TableLoading.OnDemand, TableDelivery.Streaming, 0x0123456789ABCDEFUL),
+ new(new TableKey("Voice"), TableLoading.OnDemand, TableDelivery.Custom)
};
private const string Document =
@@ -145,7 +146,8 @@ public void CompiledCatalog_RoundTripsEveryField()
Assert.That(read.Languages[3].Digits, Is.EqualTo("0123456789"));
Assert.That(read.Languages[3].GroupSeparator, Is.Empty);
Assert.That(read.Languages[0].GroupSeparator, Is.Null);
- Assert.That(read.Tables.Count, Is.EqualTo(2));
+ Assert.That(read.Tables.Count, Is.EqualTo(3));
+ Assert.That(read.Tables[2].Delivery, Is.EqualTo(TableDelivery.Custom));
Assert.That(read.Tables[1].Delivery, Is.EqualTo(TableDelivery.Streaming));
Assert.That(read.Tables[1].Loading, Is.EqualTo(TableLoading.OnDemand));
Assert.That(read.Tables[1].KeysHash, Is.EqualTo(0x0123456789ABCDEFUL));
diff --git a/UnityPackage/Tests/Core/TableLoadingTests.cs b/UnityPackage/Tests/Core/TableLoadingTests.cs
index 6b50ff1..b9d5579 100644
--- a/UnityPackage/Tests/Core/TableLoadingTests.cs
+++ b/UnityPackage/Tests/Core/TableLoadingTests.cs
@@ -8,6 +8,35 @@ namespace reromanlee.ReactiveLocalizer.Tests
{
public class TableLoadingTests
{
+ /// Takes every table request, then declines them all when told, like a source whose lookup is asynchronous.
+ private sealed class DecliningSource : ITableSource
+ {
+ private readonly System.Collections.Generic.List _receivers = new();
+
+ public int Count { get; private set; }
+
+ public bool TryLoad(in TableRequest request, TableReceiver receiver)
+ {
+ if (request.IsCatalog)
+ {
+ return false;
+ }
+ _receivers.Add(receiver);
+ return true;
+ }
+
+ public void DeclineAll()
+ {
+ Count += _receivers.Count;
+ TableReceiver[] receivers = _receivers.ToArray();
+ _receivers.Clear();
+ foreach (TableReceiver receiver in receivers)
+ {
+ receiver.Decline();
+ }
+ }
+ }
+
private const string CatalogText =
"@source English\n" +
"[English]\nCulture = en\n" +
@@ -113,6 +142,39 @@ public void SourcesAskedFirst_PatchTheLanguageBehindThem()
Assert.That(host.Reports, Is.Empty);
}
+ [Test]
+ public void SourceDecliningLater_PassesTheRequestOnSilently()
+ {
+ MemoryTableSource game = CreateSource("Purchase = Kupit\nTitle = Magazin");
+ DecliningSource declining = new();
+ TestHost host = new(declining, game);
+ using Localizer localizer = new(game.CatalogKey, host);
+
+ localizer.SetLanguageAsync(Russian);
+ localizer.InitializeAsync();
+ Assert.That(localizer.IsInitialized, Is.False);
+ declining.DeclineAll();
+
+ Assert.That(localizer.Get(Title), Is.EqualTo("Magazin"));
+ Assert.That(declining.Count, Is.EqualTo(1));
+ Assert.That(host.Reports, Is.Empty);
+ }
+
+ [Test]
+ public void SourceLanguageEverySourceDeclines_IsReported()
+ {
+ DecliningSource declining = new();
+ MemoryTableSource catalogOnly = new("Localization", CatalogText, ("Shop", "Russian", "Purchase = Kupit"));
+ TestHost host = new(declining, catalogOnly);
+ using Localizer localizer = new(catalogOnly.CatalogKey, host);
+
+ localizer.InitializeAsync();
+ declining.DeclineAll();
+
+ Assert.That(host.CountReports(ReportSeverity.Error), Is.EqualTo(1));
+ Assert.That(host.Reports[0].Message, Does.Contain("No table source has"));
+ }
+
[Test]
public void SourceDeliveringDamagedData_IsReportedAndTheNextSourceAsked()
{
diff --git a/UnityPackage/Tests/Editor/EditorPipelineTests.cs b/UnityPackage/Tests/Editor/EditorPipelineTests.cs
index 65c5639..98a1c7e 100644
--- a/UnityPackage/Tests/Editor/EditorPipelineTests.cs
+++ b/UnityPackage/Tests/Editor/EditorPipelineTests.cs
@@ -173,6 +173,7 @@ public void Pack_PutsStreamingTablesInTheirOwnFolderWithAList()
{
File.WriteAllText($"{Folder}/Feature/Dialogue.English.lang", "@delivery Streaming\n\nLine1 = Hello\n");
File.WriteAllText($"{Folder}/Feature/Dialogue.Russian.lang", "Line1 = Privet\n");
+ File.WriteAllText($"{Folder}/Feature/Voice.English.lang", "@delivery Custom\n\nLine1 = Hello\n");
CatalogLayout.Refresh();
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
CatalogIndex.Invalidate();
@@ -188,6 +189,8 @@ public void Pack_PutsStreamingTablesInTheirOwnFolderWithAList()
Assert.That(File.Exists($"{output}/Streaming/Game/English/Dialogue.bytes"), Is.True);
Assert.That(File.Exists($"{output}/Streaming/Game/Russian/Dialogue.bytes"), Is.True);
Assert.That(File.ReadAllText($"{output}/Resources/Game/streaming.bytes"), Is.EqualTo("English/Dialogue\nRussian/Dialogue\n"));
+ Assert.That(File.Exists($"{output}/Resources/Game/English/Voice.bytes"), Is.False);
+ Assert.That(File.Exists($"{output}/Streaming/Game/English/Voice.bytes"), Is.False);
}
finally
{
@@ -236,7 +239,7 @@ public void CharacterSets_CollectWhatEachLanguageShows()
CharacterSet russian = CharacterSets.Collect(game, new LanguageKey("Russian"));
CharacterSet pseudo = CharacterSets.Collect(game, PseudoLocalization.Language);
- Assert.That(CharacterSets.GetLanguages(game).Count, Is.EqualTo(3));
+ Assert.That(CharacterSets.GetCatalog(game).Languages.Count, Is.EqualTo(3));
Assert.That(russian.Contains('K'), Is.True, "Its own text");
Assert.That(russian.Contains(0x0423), Is.True, "Its plural forms");
Assert.That(russian.Contains('S') && russian.Contains('w'), Is.True, "The English text of what it lacks");
diff --git a/UnityPackage/package.json b/UnityPackage/package.json
index 336f97d..6f653af 100644
--- a/UnityPackage/package.json
+++ b/UnityPackage/package.json
@@ -28,13 +28,58 @@
"samples": [
{
"displayName": "Quick Start",
- "description": "Everything a prototype needs: GlobalLocalizer, a project-wide access point that remembers the player's language and maps the OS language; a language picker; and a scene whose texts follow every switch, in English, Russian and an invented Pirate language that falls back to English.",
+ "description": "Everything a prototype needs: GlobalLocalizer, a project-wide access point that remembers the player's language and maps the OS language; a language picker; the base of the UI samples' components; and a scene whose texts follow every switch, in English, Russian and an invented Pirate language that falls back to English.",
"path": "Samples~/QuickStart"
},
{
"displayName": "TextMeshPro",
- "description": "A component that shows an entry in a TextMeshPro text through every language switch, and a Localize action in the text's context menu that turns its current text into a new entry in one click. Import Quick Start first.",
+ "description": "A component that shows an entry in a TextMeshPro text through every language switch without creating strings, a font per language with a check of which characters each font can't draw, and a Localize action in the text's context menu that turns its current text into a new entry. Import Quick Start first.",
"path": "Samples~/TextMeshPro"
+ },
+ {
+ "displayName": "UI Toolkit",
+ "description": "Entries in UI Toolkit text: a data binding declared in UXML or UI Builder on Unity 6, a BindText call from code on every version, and a component that localizes a UIDocument's elements by name with no code. Import Quick Start first.",
+ "path": "Samples~/UIToolkit"
+ },
+ {
+ "displayName": "uGUI Text",
+ "description": "A component that shows an entry in a legacy uGUI Text, with a Localize action in its context menu. Import Quick Start first.",
+ "path": "Samples~/UguiText"
+ },
+ {
+ "displayName": "Addressables",
+ "description": "Tables marked @delivery Custom load through Addressables, at addresses a menu sets up, so they download and update with the rest of the content; a table Addressables don't have falls back as usual. Requires Addressables; import Quick Start first.",
+ "path": "Samples~/Addressables"
+ },
+ {
+ "displayName": "VContainer",
+ "description": "An installer that registers a catalog's localizer with VContainer, initializes it asynchronously as the container starts, and hands it to Quick Start's access point. Requires VContainer; import Quick Start first.",
+ "path": "Samples~/VContainer"
+ },
+ {
+ "displayName": "R3",
+ "description": "R3 observables of localized text, of messages fed by other observables, and of the current language, each one a binding released with its subscription. Requires R3.",
+ "path": "Samples~/R3"
+ },
+ {
+ "displayName": "Google Sheets",
+ "description": "Two-way sync of a catalog with a private Google spreadsheet, one sheet per table, through an Apps Script that answers only a secret token and never publishes the sheet; pulls go through the import preview. Its README sets it up.",
+ "path": "Samples~/GoogleSheets"
+ },
+ {
+ "displayName": "Mods",
+ "description": "Fan translations read from a mods folder in the player's data: they patch shipped languages entry by entry and add new languages at runtime. Comes with an example mod to install. Import Quick Start first.",
+ "path": "Samples~/Mods"
+ },
+ {
+ "displayName": "Editor Tool",
+ "description": "An editor window localized with a catalog of its own inside an Editor folder, as package and tool authors ship it: its texts never mix with the game's and never reach a build.",
+ "path": "Samples~/EditorTool"
+ },
+ {
+ "displayName": "Unity Localization Migration",
+ "description": "Converts the project's Unity Localization locales and string table collections into a Localization catalog and .lang files, keys, comments and plain placeholders included, and lists what needs converting by hand. Requires Unity Localization.",
+ "path": "Samples~/UnityLocalizationMigration"
}
]
}