diff --git a/Editor/Windows/LocaleExplorerTreeView.cs b/Editor/Windows/LocaleExplorerTreeView.cs new file mode 100644 index 0000000..9bcbf40 --- /dev/null +++ b/Editor/Windows/LocaleExplorerTreeView.cs @@ -0,0 +1,182 @@ +using Audune.Utils.UnityEditor.Editor; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; + +namespace Audune.Localization.Editor +{ + // Class that defines a tree view for the locale explorer window + public class LocaleExplorerTreeView : ItemsTreeView + { + // Default strings + private const string _missingDisplayName = "Strings with missing values"; + private const string _stringsDisplayName = "Strings"; + + + // Default options for the tree view + private static readonly Options _options = new Options { + displayNameSelector = key => key.Split('.')[^1], + iconSelector = key => EditorIcons.text, + groupIconSelector = (path, expanded) => { + if (path.Length > 1) + return expanded ? EditorIcons.folderOpened : EditorIcons.folder; + else if (path[0] == _missingDisplayName) + return EditorIcons.errorMark; + else if (path[0] == _stringsDisplayName) + return EditorIcons.font; + else + return null; + }, + }; + + + // The locales used in the tree view + private List _locales; + private Dictionary> _values; + + + // Constructor + public LocaleExplorerTreeView(IEnumerable locales) : base(LocalesToKeys(locales), _options) + { + _locales = new List(locales ?? Enumerable.Empty()); + _values = LocalesToKeys(_locales).ToDictionary(key => key, key => _locales.Select(locale => locale.strings.Find(key)).Where(value => value != null).ToList()); + + multiColumnHeader = new MultiColumnHeader(new MultiColumnHeaderState(Enumerable + .Repeat(new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Key"), width = 275, canSort = false, allowToggleVisibility = false }, 1) + .Concat(LocalesToColumms(locales)) + .ToArray())); + } + + + // Build the items of the tree view + protected override void Build(ref TreeViewItem rootItem, ref int id) + { + // Create root items for different types of items + var missingRootItem = CreateGroupItem(id++, new[] { _missingDisplayName }); + var stringsRootItem = CreateGroupItem(id++, new[] { _stringsDisplayName }); + + // Iterate over the items and create data items for them + var stringsPathItems = new Dictionary(); + foreach (var item in items) + { + // Create the data item + var dataItem = CreateDataItem(id++, item); + var hasMissingValues = _locales.ContainsMissingString(item); + if (hasMissingValues) + dataItem.icon = EditorIcons.errorMark; + + // Create a separate data item if the localized string of the item contains a missing value + if (hasMissingValues) + missingRootItem.AddChild(CreateDataItem(id++, item, displayName: item, icon: EditorIcons.errorMark)); + + // Create the path items for the string item + var path = item.Split('.'); + var pathItem = stringsRootItem; + for (int i = 0; i < path.Length - 1; i++) + { + var joinedPath = path[..(i + 1)]; + var joinedPathString = string.Join("/", path[..(i + 1)]); + if (!stringsPathItems.TryGetValue(joinedPathString, out var newPathItem)) + { + newPathItem = CreateGroupItem(id++, new[] { _stringsDisplayName }.Concat(joinedPath).ToArray(), path[i]); + pathItem.AddChild(newPathItem); + stringsPathItems.Add(joinedPathString, newPathItem); + } + pathItem = newPathItem; + } + + // Add the data item to the correct path item + pathItem.AddChild(dataItem); + } + + // Add root items + if (missingRootItem.hasChildren) + rootItem.AddChild(missingRootItem); + rootItem.AddChild(stringsRootItem); + } + + // Draw a cell that represents a data item in the tree view + protected override void OnDataCellGUI(DataItem item, int columnIndex, Rect columnRect) + { + if (columnIndex == 0) + { + // Key column + if (!isSearching) + columnRect = columnRect.ContractLeft(GetContentIndent(item)); + + var hasMissingValues = !string.IsNullOrEmpty(item.data) && _locales.ContainsMissingString(item.data); + EditorGUI.LabelField(columnRect, HighlightSearchString(new GUIContent(isSearching ? item.data : item.displayName, hasMissingValues ? EditorIcons.errorMark : item.icon)), label); + } + else if (!string.IsNullOrEmpty(item.data)) + { + // Locale column + EditorGUI.LabelField(columnRect, HighlightSearchString(_locales[columnIndex - 1].strings.TryFind(item.data, out var value) ? value.Replace("\n", " ") : ""), label); + } + } + + // Draw a cell that represents a group item in the tree view + protected override void OnGroupCellGUI(GroupItem item, int columnIndex, Rect columnRect) + { + if (columnIndex == 0) + { + // Key column + if (!isSearching) + { + columnRect = columnRect.ContractLeft(GetContentIndent(item)); + EditorGUI.LabelField(columnRect, item, boldLabel); + } + } + } + + // Handler for when an item is double clicked + protected override void OnDoubleClicked(DataItem item) + { + LocalizedStringExplorerWindow.ShowWindow(searchString: item.data); + } + + // Return a context menu for a data item + protected override GenericMenu GetDataItemContextMenu(DataItem item) + { + var menu = new GenericMenu(); + + menu.AddItem(new GUIContent("Find References"), false, () => LocalizedStringExplorerWindow.ShowWindow(searchString: item.data)); + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("Expand All"), false, () => ExpandAll()); + menu.AddItem(new GUIContent("Collapse All"), false, () => CollapseAll()); + + return menu; + } + + // Return if an item matches the specified search query + protected override bool Matches(string data, string search) + { + if (string.IsNullOrEmpty(data)) + return false; + if (string.IsNullOrEmpty(search)) + return true; + + return data.Contains(search, StringComparison.InvariantCultureIgnoreCase) + || (_values.TryGetValue(data, out var values) && values.Any(value => value.Contains(search, StringComparison.InvariantCultureIgnoreCase))); + } + + + #region Convert locales to keys and columns + // Convert a list of locales to keys + private static IEnumerable LocalesToKeys(IEnumerable locales) + { + return locales?.SelectMany(locale => locale.strings.Keys).Distinct() ?? Enumerable.Empty(); + } + + // Convert a list of locales to tree view columns + private static IEnumerable LocalesToColumms(IEnumerable locales) + { + return locales?.Select(locale => new MultiColumnHeaderState.Column() { headerContent = new GUIContent($"{locale.englishName} Value"), width = 150, canSort = false }) ?? Enumerable.Empty(); + } + #endregion + } +} \ No newline at end of file diff --git a/Editor/Windows/LocaleExplorerTreeView.cs.meta b/Editor/Windows/LocaleExplorerTreeView.cs.meta new file mode 100644 index 0000000..e217926 --- /dev/null +++ b/Editor/Windows/LocaleExplorerTreeView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 396514f97c91c154e80ee19f89b1684e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Windows/LocaleExplorerWindow.cs b/Editor/Windows/LocaleExplorerWindow.cs new file mode 100644 index 0000000..1cb1a41 --- /dev/null +++ b/Editor/Windows/LocaleExplorerWindow.cs @@ -0,0 +1,114 @@ +using Audune.Utils.UnityEditor.Editor; +using UnityEditor; +using UnityEngine; + +namespace Audune.Localization.Editor +{ + // Class that defines an editor window for exploring locales in the project + [EditorWindowTitle(title = "Locale Explorer")] + public sealed class LocaleExplorerWindow : EditorWindow + { + // Show the window + public static void ShowWindow(string searchString = null, string selected = null) + { + var window = GetWindow(); + window.minSize = new Vector2(800, 600); + window.Refresh(searchString, selected); + } + + // Show the window without context + [MenuItem("Window/Audune Localization/Locale Explorer _%#&L", secondaryPriority = 0)] + public static void ShowWindow() + { + ShowWindow(null, null); + } + + + // Tree view for displaying the localized strings + private LocaleExplorerTreeView _treeView; + + + // Refresh the window + private void Refresh(string searchString = null, string selected = null, bool forceRebuild = false) + { + Rebuild(forceRebuild); + _treeView.Reload(); + + if (!string.IsNullOrEmpty(searchString)) + _treeView.searchString = searchString; + else if (selected != null) + _treeView.SetSelectionData(selected); + } + + // Rebuild the tree view + private void Rebuild(bool forceRebuild = false) + { + if (forceRebuild || _treeView == null) + _treeView = new LocaleExplorerTreeView(Locale.GetAllLocaleAssets()); + } + + + // OnGUI is called when the editor is drawn + private void OnGUI() + { + Rebuild(); + + // Draw the search box + GUILayout.BeginHorizontal(EditorStyles.toolbar); + OnToolbarGUI(); + GUILayout.EndHorizontal(); + + // Draw the tree view + OnTreeViewGUI(); + } + + + // OnToolbarGUI is called when the toolbar is drawn + private void OnToolbarGUI() + { + // Rescan project button + if (GUILayout.Button(new GUIContent("Rescan Project", EditorIcons.refresh, "Rescan the project for locales"), EditorStyles.toolbarButton, GUILayout.Width(111))) + Refresh(searchString: _treeView.searchString, selected: _treeView.GetSelectionData(), forceRebuild: true); + + // Locales dropdown + if (GUILayout.Button(new GUIContent("Locales", EditorIcons.folderOpened, "Edit one of the locales in the project"), EditorStyles.toolbarDropDown, GUILayout.Width(80))) + { + var menu = new GenericMenu(); + foreach (var locale in Locale.GetAllLocaleAssets()) + { + menu.AddItem(new GUIContent($"{locale.englishName}/Show Asset"), false, () => { + Selection.SetActiveObjectWithContext(locale, locale); + EditorGUIUtility.PingObject(locale); + }); + menu.AddItem(new GUIContent($"{locale.englishName}/Copy Path"), false, () => GUIUtility.systemCopyBuffer = AssetDatabase.GetAssetPath(locale)); + menu.AddSeparator($"{locale.englishName}/"); + menu.AddItem(new GUIContent($"{locale.englishName}/Edit Source"), false, () => AssetDatabase.OpenAsset(locale)); + } + + var rect = GUILayoutUtility.GetLastRect(); + rect.x += 111; + rect.y += EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; + menu.DropDown(rect); + } + + GUILayout.FlexibleSpace(); + + // Search bar + _treeView.searchString = EditorGUILayout.TextField(_treeView.searchString, EditorStyles.toolbarSearchField, GUILayout.MaxWidth(300)); + + // Tree view buttons + if (GUILayout.Button(new GUIContent("Expand All", "Expand all tree view items"), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) + _treeView.ExpandAll(); + if (GUILayout.Button(new GUIContent("Collapse All", "Collapse all tree view items"), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) + _treeView.CollapseAll(); + } + + // OnTreeViewGUI is called when the tree view is drawn + private void OnTreeViewGUI() + { + var rect = EditorGUILayout.GetControlRect(GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); + _treeView.Reload(); + _treeView.OnGUI(rect); + } + } +} \ No newline at end of file diff --git a/Editor/Windows/LocaleExplorerWindow.cs.meta b/Editor/Windows/LocaleExplorerWindow.cs.meta new file mode 100644 index 0000000..39ebd50 --- /dev/null +++ b/Editor/Windows/LocaleExplorerWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 38924b87599d79a4fb20457ad7cd6149 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Windows/LocalizedStringExplorerTreeView.cs b/Editor/Windows/LocalizedStringExplorerTreeView.cs new file mode 100644 index 0000000..1eb9727 --- /dev/null +++ b/Editor/Windows/LocalizedStringExplorerTreeView.cs @@ -0,0 +1,282 @@ +using Audune.Utils.UnityEditor.Editor; +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace Audune.Localization.Editor +{ + // Class that defines a tree view for a localized string explorer editor window + public class LocalizedStringExplorerTreeView : ItemsTreeView + { + // Default strings + private const string _missingDisplayName = "Strings with missing values"; + private const string _prefabsDisplayName = "Prefabs"; + private const string _scriptableObjectsDisplayName = "Scriptable objects"; + private const string _scenesDisplayName = "Scene objects"; + + + // Default options for the tree view + private static readonly Options _options = new Options { + displayNameSelector = data => data.component.ToString(), + iconSelector = data => data.asset.type.IsScene() ? EditorIcons.gameObject : data.asset.assetIcon, + groupIconSelector = (path, expanded) => { + if (path.Length > 1) + return expanded ? EditorIcons.folderOpened : EditorIcons.folder; + else if (path[0] == _missingDisplayName) + return EditorIcons.errorMark; + else if (path[0] == _prefabsDisplayName) + return EditorIcons.prefab; + else if (path[0] == _scriptableObjectsDisplayName) + return EditorIcons.scriptableObject; + else if (path[0] == _scenesDisplayName) + return EditorIcons.gameObject; + else + return null; + }, + }; + + + // The locales used in the tree view + private List _locales; + + + // Constructor + public LocalizedStringExplorerTreeView(IEnumerable items, IEnumerable locales) : base(items, _options) + { + _locales = new List(locales ?? Enumerable.Empty()); + + multiColumnHeader = new MultiColumnHeader(new MultiColumnHeaderState(new[] { + new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Object"), width = 250, canSort = false, allowToggleVisibility = false }, + new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Component"), width = 150, canSort = false, allowToggleVisibility = true }, + new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Property"), width = 150, canSort = false, allowToggleVisibility = true }, + new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Localized String"), width = 200, canSort = false, allowToggleVisibility = false }, + new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Non-Localized Value"), width = 200, canSort = false, allowToggleVisibility = true }, + })); + } + + // Build the items of the tree view + protected override void Build(ref TreeViewItem rootItem, ref int id) + { + // Create root items for different types of items + var missingRootItem = CreateGroupItem(id++, new[] { _missingDisplayName }); + var prefabsRootItem = CreateGroupItem(id++, new[] { _prefabsDisplayName }); + var scriptableObjectsRootItem = CreateGroupItem(id++, new[] { _scriptableObjectsDisplayName }); + var scenesRootItem = CreateGroupItem(id++, new[] { _scenesDisplayName }); + + // Iterate over the items and create data items for them + var prefabsPathItems = new Dictionary(); + var scriptableObjectsPathItems = new Dictionary(); + foreach (var item in items) + { + // Create the data item + var dataItem = CreateDataItem(id++, item); + var localizedString = item.propertyValue as LocalizedString; + var hasMissingValues = localizedString != null && localizedString.isLocalized && _locales.ContainsMissingString(localizedString.path); + if (hasMissingValues) + dataItem.icon = EditorIcons.errorMark; + + // Create a separate data item if the localized string of the item contains a missing value + if (hasMissingValues) + missingRootItem.AddChild(CreateDataItem(id++, item, icon: EditorIcons.errorMark)); + + // Check the asset type of the item + if (item.asset.type.IsPrefab()) + { + // Create the path items for the prefab item + var path = item.asset.assetDirectoryName.Split('/', 2)[1]; + if (!prefabsPathItems.TryGetValue(path, out var pathItem)) + { + pathItem = CreateGroupItem(id++, new[] { _prefabsDisplayName, path }); + prefabsRootItem.AddChild(pathItem); + prefabsPathItems.Add(path, pathItem); + } + + // Add the data item to the correct path item + pathItem.AddChild(dataItem); + } + else if (item.asset.type.IsScriptableObject()) + { + // Create the path items for the scriptable object item + var path = item.asset.assetDirectoryName.Split('/', 2)[1]; + if (!scriptableObjectsPathItems.TryGetValue(path, out var pathItem)) + { + pathItem = CreateGroupItem(id++, new[] { _scriptableObjectsDisplayName, path }); + scriptableObjectsRootItem.AddChild(pathItem); + scriptableObjectsPathItems.Add(path, pathItem); + } + + // Add the data item to the correct path item + pathItem.AddChild(dataItem); + } + else if (item.asset.type.IsScene()) + scenesRootItem.AddChild(dataItem); + } + + // Add root items + if (missingRootItem.hasChildren) + rootItem.AddChild(missingRootItem); + if (prefabsRootItem.hasChildren) + rootItem.AddChild(prefabsRootItem); + if (scriptableObjectsRootItem.hasChildren) + rootItem.AddChild(scriptableObjectsRootItem); + if (scenesRootItem.hasChildren) + rootItem.AddChild(scenesRootItem); + } + + // Draw a cell that represents a data item in the tree view + protected override void OnDataCellGUI(DataItem item, int columnIndex, Rect columnRect) + { + if (columnIndex == 0) + { + // Object column + if (!isSearching) + columnRect = columnRect.ContractLeft(GetContentIndent(item)); + + EditorGUI.LabelField(columnRect, HighlightSearchString(item), label); + } + else if (columnIndex == 1) + { + // Component column + EditorGUI.LabelField(columnRect, HighlightSearchString(new GUIContent(ObjectNames.NicifyVariableName(item.data.component.componentScript.name), item.data.component.componentIcon)), label); + } + else if (columnIndex == 2) + { + // Property column + var serializedProperty = item.data.targetSerializedProperty; + EditorGUI.LabelField(columnRect, HighlightSearchString(item.data.propertyDisplayName), serializedProperty != null && serializedProperty.prefabOverride ? boldLabel : label); + } + else if (columnIndex == 3) + { + // Localized string column + var serializedProperty = item.data.targetSerializedProperty; + if (serializedProperty != null) + { + var propertyRect = columnRect.ContractTop(EditorGUIUtility.standardVerticalSpacing * 0.5f).ContractBottom(EditorGUIUtility.standardVerticalSpacing * 0.5f); + + serializedProperty.serializedObject.Update(); + LocalizationEditorGUIExtensions.LocalizedStringSearchDropdown(propertyRect, GUIContent.none, serializedProperty); + serializedProperty.serializedObject.ApplyModifiedProperties(); + } + else + { + EditorGUI.LabelField(columnRect, new GUIContent("Could not find target object", EditorIcons.errorMark), label); + } + } + else if (columnIndex == 4) + { + // Non-Localized Value column + var serializedProperty = item.data.targetSerializedProperty; + if (serializedProperty != null && string.IsNullOrEmpty(serializedProperty.FindPropertyRelative("_path").stringValue)) + { + var propertyRect = columnRect.ContractTop(EditorGUIUtility.standardVerticalSpacing * 0.5f).ContractBottom(EditorGUIUtility.standardVerticalSpacing * 0.5f); + + serializedProperty.serializedObject.Update(); + LocalizationEditorGUIExtensions.LocalizedStringValueField(propertyRect, GUIContent.none, serializedProperty); + serializedProperty.serializedObject.ApplyModifiedProperties(); + } + } + } + + // Draw a cell that represents a group item in the tree view + protected override void OnGroupCellGUI(GroupItem item, int columnIndex, Rect columnRect) + { + if (columnIndex == 0) + { + // Object column + if (!isSearching) + { + columnRect = columnRect.ContractLeft(GetContentIndent(item)); + EditorGUI.LabelField(columnRect, item, boldLabel); + } + } + } + + // Handler for when an item is double clicked + protected override void OnDoubleClicked(DataItem item) + { + SetAsSelection(item); + } + + // Return a context menu for a data item + protected override GenericMenu GetDataItemContextMenu(DataItem item) + { + var menu = new GenericMenu(); + + menu.AddItem(new GUIContent("Show Component"), false, () => SetAsSelection(item)); + menu.AddItem(new GUIContent("Show Asset"), false, () => item.data.asset.SetAsSelection()); + menu.AddItem(new GUIContent("Copy Path"), false, () => GUIUtility.systemCopyBuffer = item.data.asset.assetPath); + + if (item.data.asset.type.IsPrefab()) + { + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("Open Containing Prefab"), false, () => AssetDatabase.OpenAsset(item.data.asset.GetAsset())); + } + else if (item.data.asset.type.IsScene()) + { + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("Open Containing Scene"), false, () => EditorSceneManager.OpenScene(item.data.asset.assetPath, OpenSceneMode.Single)); + menu.AddItem(new GUIContent("Open Containing Scene Additive"), false, () => EditorSceneManager.OpenScene(item.data.asset.assetPath, OpenSceneMode.Additive)); + } + + if (item.data.component.componentScript != null) + { + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("Edit Script"), false, () => AssetDatabase.OpenAsset(item.data.component.componentScript)); + } + + menu.AddSeparator(""); + + if (item.data.propertyValue is LocalizedString localizedString && localizedString.isLocalized) + menu.AddItem(new GUIContent("Find Definition"), false, () => LocaleExplorerWindow.ShowWindow(selected: localizedString.path)); + else + menu.AddDisabledItem(new GUIContent("Find Definition"), false); + + menu.AddSeparator(""); + + menu.AddItem(new GUIContent("Expand All"), false, () => ExpandAll()); + menu.AddItem(new GUIContent("Collapse All"), false, () => CollapseAll()); + + return menu; + } + + // Return if the data of an item matches the specified search query + protected override bool Matches(PropertySearchResult data, string search) + { + return data.asset.assetName.Contains(search, StringComparison.InvariantCultureIgnoreCase) + || (data.component.componentPath?.Contains(search, StringComparison.InvariantCultureIgnoreCase) ?? false) + || (data.component.componentScript?.name.Contains(search, StringComparison.InvariantCultureIgnoreCase) ?? false) + || data.propertyDisplayName.Contains(search, StringComparison.InvariantCultureIgnoreCase) + || data.propertyValue is LocalizedString localizedString && (localizedString.isLocalized + ? localizedString.path.Contains(search, StringComparison.InvariantCultureIgnoreCase) + : localizedString.value.Contains(search, StringComparison.InvariantCultureIgnoreCase)); + } + + // Set the data item as the selectiom in the editor + private void SetAsSelection(DataItem item) + { + // Check the type of the asset + if (item.data.asset.type.IsPrefab()) + { + // Open the prefab + AssetDatabase.OpenAsset(item.data.asset.GetAsset()); + } + else if (item.data.asset.type.IsScene()) + { + // Open the scene if not done already + var scene = item.data.asset.GetScene(); + if (!scene.IsValid()) + EditorSceneManager.OpenScene(item.data.asset.assetPath, OpenSceneMode.Single); + } + + // Select the component + item.data.component.SetAsSelection(); + } + } +} \ No newline at end of file diff --git a/Editor/Windows/LocalizedStringExplorerTreeView.cs.meta b/Editor/Windows/LocalizedStringExplorerTreeView.cs.meta new file mode 100644 index 0000000..7698cd9 --- /dev/null +++ b/Editor/Windows/LocalizedStringExplorerTreeView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95dc19ed6a2073b4fbb5b97ce6338681 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Windows/LocalizedStringExplorerWindow.cs b/Editor/Windows/LocalizedStringExplorerWindow.cs new file mode 100644 index 0000000..86145b2 --- /dev/null +++ b/Editor/Windows/LocalizedStringExplorerWindow.cs @@ -0,0 +1,97 @@ +using Audune.Utils.UnityEditor.Editor; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace Audune.Localization.Editor +{ + // Class that defines an editor window for exploring localized strings in the project + [EditorWindowTitle(title = "Localized String Explorer")] + public sealed class LocalizedStringExplorerWindow : EditorWindow + { + // Show the window + public static void ShowWindow(string searchString = null) + { + var window = GetWindow(); + window.minSize = new Vector2(800, 600); + window.Refresh(searchString); + } + + // Show the window without context + [MenuItem("Window/Audune Localization/Localized String Explorer _%#&S", secondaryPriority = 1)] + public static void ShowWindow() + { + ShowWindow(null); + } + + + // Tree view for displaying the localized strings + private LocalizedStringExplorerTreeView _treeView; + + + // Refresh the window + private void Refresh(string searchString = null, bool forceRebuild = false) + { + + Rebuild(forceRebuild); + _treeView.Reload(); + + if (!string.IsNullOrEmpty(searchString)) + _treeView.searchString = searchString; + } + + // Rebuild the tree view + private void Rebuild(bool forceRebuild = false) + { + if (forceRebuild || _treeView == null) + { + var properties = PropertySearch.SearchInProject("Assets").OrderBy(r => r.asset.assetDirectoryName); + _treeView = new LocalizedStringExplorerTreeView(properties, Locale.GetAllLocaleAssets()); + } + } + + + // OnGUI is called when the editor is drawn + private void OnGUI() + { + if (_treeView == null) + Refresh(); + + // Draw the search box + GUILayout.BeginHorizontal(EditorStyles.toolbar); + OnToolbarGUI(); + GUILayout.EndHorizontal(); + + // Draw the tree view + OnTreeViewGUI(); + } + + + // OnToolbarGUI is called when the toolbar is drawn + private void OnToolbarGUI() + { + // Rescan project button + if (GUILayout.Button(new GUIContent("Rescan Project", EditorIcons.refresh, "Rescan the project for assets that contain localized strings"), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) + Refresh(_treeView.searchString); + + GUILayout.FlexibleSpace(); + + // Search bar + _treeView.searchString = EditorGUILayout.TextField(_treeView.searchString, EditorStyles.toolbarSearchField, GUILayout.MaxWidth(300)); + + // Tree view buttons + if (GUILayout.Button(new GUIContent("Expand All", "Expand all tree view items"), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) + _treeView.ExpandAll(); + if (GUILayout.Button(new GUIContent("Collapse All", "Collapse all tree view items"), EditorStyles.toolbarButton, GUILayout.ExpandWidth(false))) + _treeView.CollapseAll(); + } + + // OnTreeViewGUI is called when the tree view is drawn + private void OnTreeViewGUI() + { + var rect = EditorGUILayout.GetControlRect(GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); + _treeView.Reload(); + _treeView.OnGUI(rect); + } + } +} \ No newline at end of file diff --git a/Editor/Windows/LocalizedStringExplorerWindow.cs.meta b/Editor/Windows/LocalizedStringExplorerWindow.cs.meta new file mode 100644 index 0000000..94815df --- /dev/null +++ b/Editor/Windows/LocalizedStringExplorerWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: afbaf8ce404270947965f9b41ccf51b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Windows/LocalizedStringSearchTreeView.cs b/Editor/Windows/LocalizedStringSearchTreeView.cs index 233b141..b77045b 100644 --- a/Editor/Windows/LocalizedStringSearchTreeView.cs +++ b/Editor/Windows/LocalizedStringSearchTreeView.cs @@ -11,10 +11,12 @@ namespace Audune.Localization.Editor // Class that defines a tree view for selecting a localized string reference public class LocalizedStringSearchTreeView : SearchTreeView { - // Default options for the localized reference search tree view - public static readonly Options LocalizedReferenceOptions = new Options { - pathSelector = key => key.Replace('.', '/'), + // Default options for the tree view + private static readonly Options _options = new Options { displayNameSelector = key => key.Split('.')[^1], + iconSelector = key => EditorIcons.text, + groupIconSelector = (path, expanded) => expanded ? EditorIcons.folderOpened : EditorIcons.folder, + pathSelector = key => key.Split('.'), addDefaultItem = true, defaultItemData = null, defaultItemDisplayName = "", @@ -27,29 +29,15 @@ public class LocalizedStringSearchTreeView : SearchTreeView // Constructor - public LocalizedStringSearchTreeView(IEnumerable locales) : base(LocalesToKeys(locales), LocalizedReferenceOptions) + public LocalizedStringSearchTreeView(IEnumerable locales) : base(LocalesToKeys(locales), _options) { _locales = new List(locales ?? Enumerable.Empty()); _values = LocalesToKeys(_locales).ToDictionary(key => key, key => _locales.Select(locale => locale.strings.Find(key)).Where(value => value != null).ToList()); multiColumnHeader = new MultiColumnHeader(new MultiColumnHeaderState(Enumerable - .Repeat(new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Key"), width = 300, allowToggleVisibility = false }, 1) + .Repeat(new MultiColumnHeaderState.Column() { headerContent = new GUIContent("Key"), width = 275, canSort = false, allowToggleVisibility = false }, 1) .Concat(LocalesToColumms(locales)) .ToArray())); - showAlternatingRowBackgrounds = true; - } - - - // Return if an item matches the specified search query - protected override bool Matches(string data, string search) - { - if (string.IsNullOrEmpty(data)) - return false; - if (string.IsNullOrEmpty(search)) - return true; - - return data.Contains(search, StringComparison.InvariantCultureIgnoreCase) - || (_values.TryGetValue(data, out var values) && values.Any(value => value.Contains(search, StringComparison.InvariantCultureIgnoreCase))); } @@ -58,24 +46,18 @@ protected override void OnDataCellGUI(DataItem item, int columnIndex, Rect colum { if (columnIndex == 0) { - var label = string.IsNullOrEmpty(searchString) ? item.displayName : item.data; - if (!string.IsNullOrEmpty(item.data) && _locales.ContainsMissingString(item.data)) - label = $"⚠ {label}"; + // Key column + if (!isSearching) + columnRect = columnRect.ContractLeft(GetContentIndent(item)); - if (string.IsNullOrEmpty(searchString)) - DefaultGUI.Label(columnRect.ContractLeft(GetContentIndent(item)), label, IsSelected(item.id), false); - else - DefaultGUI.Label(columnRect, HighlightSearchString(label), IsSelected(item.id), false); + var hasMissingValues = !string.IsNullOrEmpty(item.data) && _locales.ContainsMissingString(item.data); + EditorGUI.LabelField(columnRect, HighlightSearchString(new GUIContent(isSearching ? item.data : item.displayName, hasMissingValues ? EditorIcons.errorMark : item.icon)), label); } else if (!string.IsNullOrEmpty(item.data)) { - var label = _locales[columnIndex - 1].strings.TryFind(item.data, out var value) ? value.Replace("\n", " ") : ""; - - if (string.IsNullOrEmpty(searchString)) - DefaultGUI.Label(columnRect, label, IsSelected(item.id), false); - else - DefaultGUI.Label(columnRect, HighlightSearchString(label), IsSelected(item.id), false); - } + // Locale column + EditorGUI.LabelField(columnRect, HighlightSearchString(_locales[columnIndex - 1].strings.TryFind(item.data, out var value) ? value.Replace("\n", " ") : ""), label); + } } // Draw a cell that represents a group item in the tree view @@ -83,12 +65,29 @@ protected override void OnGroupCellGUI(GroupItem item, int columnIndex, Rect col { if (columnIndex == 0) { - if (string.IsNullOrEmpty(searchString)) - DefaultGUI.FoldoutLabel(columnRect.ContractLeft(GetContentIndent(item)), item.displayName, IsSelected(item.id), false); + // Key column + if (!isSearching) + { + columnRect = columnRect.ContractLeft(GetContentIndent(item)); + EditorGUI.LabelField(columnRect, item, boldLabel); + } } } + // Return if an item matches the specified search query + protected override bool Matches(string data, string search) + { + if (string.IsNullOrEmpty(data)) + return false; + if (string.IsNullOrEmpty(search)) + return true; + + return data.Contains(search, StringComparison.InvariantCultureIgnoreCase) + || (_values.TryGetValue(data, out var values) && values.Any(value => value.Contains(search, StringComparison.InvariantCultureIgnoreCase))); + } + + #region Convert locales to keys and columns // Convert a list of locales to keys private static IEnumerable LocalesToKeys(IEnumerable locales) { @@ -98,7 +97,8 @@ private static IEnumerable LocalesToKeys(IEnumerable locales) // Convert a list of locales to tree view columns private static IEnumerable LocalesToColumms(IEnumerable locales) { - return locales?.Select(locale => new MultiColumnHeaderState.Column() { headerContent = new GUIContent(locale.nativeName), width = 150 }) ?? Enumerable.Empty(); + return locales?.Select(locale => new MultiColumnHeaderState.Column() { headerContent = new GUIContent($"{locale.englishName} Value"), canSort = false, width = 150 }) ?? Enumerable.Empty(); } + #endregion } } \ No newline at end of file diff --git a/Editor/Windows/LocalizedStringSearchWindow.cs b/Editor/Windows/LocalizedStringSearchWindow.cs index f419a91..66be78c 100644 --- a/Editor/Windows/LocalizedStringSearchWindow.cs +++ b/Editor/Windows/LocalizedStringSearchWindow.cs @@ -1,40 +1,14 @@ using Audune.Utils.UnityEditor.Editor; -using UnityEditor; -using UnityEngine; namespace Audune.Localization.Editor { // Class that defines a search window for selecting a localized string reference public class LocalizedStringSearchWindow : SearchWindow { - // Reference to the localization system - private LocalizationSystem _localizationSystem; - - - // Refresh the search window - public void Refresh() - { - _localizationSystem = FindObjectOfType(); - if (_localizationSystem != null) - _localizationSystem.InitializeIfNoLocaleSelected(); - } - - - // OnToolbarGUI is called when the toolbar is drawn - protected override void OnToolbarGUI() - { - if (GUILayout.Button("Refresh", EditorStyles.toolbarButton, GUILayout.Width(80))) - Refresh(); - - base.OnToolbarGUI(); - } - - // Create the tree view public override SearchTreeView CreateTreeView() { - Refresh(); - return new LocalizedStringSearchTreeView(_localizationSystem.loadedLocales); + return new LocalizedStringSearchTreeView(Locale.GetAllLocaleAssets("Assets")); } // Get the property value