diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs index 6aa3f6f..0c53750 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/AdvancedTypePopup.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using UnityEditor; using UnityEditor.IMGUI.Controls; @@ -28,34 +29,52 @@ public class AdvancedTypePopup : AdvancedDropdown private const int MaxNamespaceNestCount = 16; - public static void AddTo (AdvancedDropdownItem root, IEnumerable types) + private readonly struct TypeMenuItem { - int itemCount = 0; - // Add null item. - var nullItem = new AdvancedTypePopupItem(null, TypeMenuUtility.NullDisplayName) + public Type Type { get; } + public string[] Path { get; } + public string Name { get; } + + public TypeMenuItem (Type type) { - id = itemCount++ - }; - root.AddChild(nullItem); + Type = type; + Path = TypeMenuUtility.GetSplittedTypePath(type); + Name = Path.Length > 0 ? ObjectNames.NicifyVariableName(Path[Path.Length - 1]) : string.Empty; + } + + } + + private sealed class TypeMenu + { + + public CultureInfo Culture { get; } + public TypeMenuItem[] Items { get; } + public bool IsSingleNamespace { get; } + + public TypeMenu (IEnumerable types) + { + Culture = CultureInfo.CurrentCulture; + Items = types.OrderByType().Select(type => new TypeMenuItem(type)).ToArray(); + IsSingleNamespace = HasSingleNamespace(Items); + } - Type[] typeArray = types.OrderByType().ToArray(); + } - // Single namespace if the root has one namespace and the nest is unbranched. - bool isSingleNamespace = true; + private static bool HasSingleNamespace (TypeMenuItem[] items) + { + // Flatten an unbranched namespace unless an explicit menu requests a category. string[] namespaces = new string[MaxNamespaceNestCount]; - foreach (Type type in typeArray) + foreach (TypeMenuItem item in items) { - string[] splittedTypePath = TypeMenuUtility.GetSplittedTypePath(type); + string[] splittedTypePath = item.Path; if (splittedTypePath.Length <= 1) { continue; } - // If they explicitly want sub category, let them do. - if (TypeMenuUtility.GetAttribute(type) != null) + if (TypeMenuUtility.GetAttribute(item.Type) != null) { - isSingleNamespace = false; - break; + return false; } for (int k = 0; (splittedTypePath.Length - 1) > k; k++) { @@ -66,21 +85,34 @@ public static void AddTo (AdvancedDropdownItem root, IEnumerable types) } else if (ns != splittedTypePath[k]) { - isSingleNamespace = false; - break; + return false; } } - - if (!isSingleNamespace) - { - break; - } } + return true; + } + + public static void AddTo (AdvancedDropdownItem root, IEnumerable types) + { + // Direct callers still get a freshly sorted menu for their input. + AddTo(root, new TypeMenu(types)); + } + + private static void AddTo (AdvancedDropdownItem root, TypeMenu menu) + { + int itemCount = 0; + + // Add null item. + var nullItem = new AdvancedTypePopupItem(null, TypeMenuUtility.NullDisplayName) + { + id = itemCount++ + }; + root.AddChild(nullItem); // Add type items. - foreach (Type type in typeArray) + foreach (TypeMenuItem menuItem in menu.Items) { - string[] splittedTypePath = TypeMenuUtility.GetSplittedTypePath(type); + string[] splittedTypePath = menuItem.Path; if (splittedTypePath.Length == 0) { continue; @@ -89,7 +121,7 @@ public static void AddTo (AdvancedDropdownItem root, IEnumerable types) AdvancedDropdownItem parent = root; // Add namespace items. - if (!isSingleNamespace) + if (!menu.IsSingleNamespace) { for (int k = 0; (splittedTypePath.Length - 1) > k; k++) { @@ -111,7 +143,7 @@ public static void AddTo (AdvancedDropdownItem root, IEnumerable types) } // Add type item. - var item = new AdvancedTypePopupItem(type, ObjectNames.NicifyVariableName(splittedTypePath[splittedTypePath.Length - 1])) + var item = new AdvancedTypePopupItem(menuItem.Type, menuItem.Name) { id = itemCount++ }; @@ -138,6 +170,7 @@ private static AdvancedDropdownItem GetItem (AdvancedDropdownItem parent, string private static readonly float HeaderHeight = EditorGUIUtility.singleLineHeight * 2f; private Type[] types; + private TypeMenu menu; public event Action OnItemSelected; @@ -150,12 +183,19 @@ public AdvancedTypePopup (IEnumerable types, int maxLineCount, AdvancedDro public void SetTypes (IEnumerable types) { this.types = types.ToArray(); + menu = null; } protected override AdvancedDropdownItem BuildRoot () { var root = new AdvancedDropdownItem("Select Type"); - AddTo(root, types); + + // Keep display metadata private to this snapshot. Ordering follows the current culture. + if (menu == null || !ReferenceEquals(menu.Culture, CultureInfo.CurrentCulture)) + { + menu = new TypeMenu(types); + } + AddTo(root, menu); return root; } @@ -169,4 +209,4 @@ protected override void ItemSelected (AdvancedDropdownItem item) } } -} \ No newline at end of file +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs new file mode 100644 index 0000000..3537154 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using MackySoft.SerializeReferenceExtensions.Editor; +using NUnit.Framework; +using UnityEditor.IMGUI.Controls; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + [TestFixture] + public sealed class AdvancedTypePopupTests + { + + private sealed class Popup : AdvancedTypePopup + { + + public Popup (IEnumerable types) : base(types, 13, new AdvancedDropdownState()) + { + } + + public AdvancedDropdownItem Build () => BuildRoot(); + + public void Select (AdvancedDropdownItem item) => ItemSelected(item); + + } + + [AddTypeMenu("Commands/Alpha", 10)] + private sealed class Alpha { } + + [AddTypeMenu("Commands/Zeta", -10)] + private sealed class Zeta { } + + [AddTypeMenu("Renamed")] + private sealed class RenamedType { } + + [AddTypeMenu("m_Apple")] + private sealed class PrefixedApple { } + + [AddTypeMenu("Banana")] + private sealed class PlainBanana { } + + [AddTypeMenu("Same")] + private sealed class FirstTie { } + + [AddTypeMenu("Same")] + private sealed class SecondTie { } + + [AddTypeMenu("ä")] + private sealed class Accent { } + + [AddTypeMenu("z")] + private sealed class Latin { } + + [AddTypeMenu(null)] + private sealed class NullMenu { } + + [AddTypeMenu("")] + private sealed class EmptyMenu { } + + [AddTypeMenu(" ")] + private sealed class WhitespaceMenu { } + + [AddTypeMenu("///")] + private sealed class SeparatorsMenu { } + + [Test] + public void AddTo_AppendsNullAndOrderedTypesToExistingRoot () + { + var root = new AdvancedDropdownItem("Existing root"); + root.AddChild(new AdvancedDropdownItem("Existing item")); + + AdvancedTypePopup.AddTo(root, new[] { typeof(Zeta), typeof(RenamedType), typeof(Alpha) }); + + Assert.That(Children(root).Select(item => item.name), Is.EqualTo(new[] { "Existing item", "", "Commands", "Renamed" })); + Assert.That(Children(Children(root)[2]).Select(item => item.name), Is.EqualTo(new[] { "Zeta", "Alpha" })); + Assert.That(Leaves(root).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(Zeta), typeof(Alpha), typeof(RenamedType) })); + } + + [Test] + public void RepeatedBuilds_PreserveMenuAndIsolateMutableDropdownItems () + { + var popup = new Popup(new[] { typeof(Zeta), typeof(RenamedType), typeof(Alpha) }); + AdvancedDropdownItem first = popup.Build(); + var initialIds = Leaves(first).Select(item => item.id).ToArray(); + + Children(first)[1].name = "Changed category"; + Leaves(first).First(item => item.Type == typeof(Alpha)).name = "Changed label"; + first.AddChild(new AdvancedDropdownItem("Extra item")); + AdvancedDropdownItem second = popup.Build(); + + Assert.That(Children(second).Select(item => item.name), Is.EqualTo(new[] { "", "Commands", "Renamed" })); + Assert.That(Children(Children(second)[1]).Select(item => item.name), Is.EqualTo(new[] { "Zeta", "Alpha" })); + Assert.That(Leaves(second).Select(item => item.id), Is.EqualTo(initialIds)); + } + + [Test] + public void SetTypes_ReplacesPreviouslyDisplayedCandidatesAndSnapshotsInput () + { + var types = new List { typeof(Alpha) }; + var popup = new Popup(types); + types.Add(typeof(Zeta)); + + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(Alpha) })); + + popup.SetTypes(new[] { typeof(RenamedType), typeof(Zeta) }); + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EquivalentTo(new[] { null, typeof(RenamedType), typeof(Zeta) })); + + popup.SetTypes(Array.Empty()); + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EqualTo(new Type[] { null })); + } + + [Test] + public void SingleNamespace_IsFlattenedWithSingleSegmentMenuOverride () + { + var popup = new Popup(new[] { typeof(SerializableStruct), typeof(RenamedType), typeof(PublicSerializableClass) }); + + AdvancedDropdownItem root = popup.Build(); + + Assert.That(Children(root).Select(item => item.name), Is.EqualTo(new[] { "", "Public Serializable Class", "Renamed", "Serializable Struct" })); + Assert.That(Children(root).All(item => !Children(item).Any()), Is.True); + } + + [Test] + public void DifferentNamespaces_RemainSeparateCategories () + { + var popup = new Popup(new[] { typeof(string), typeof(PublicSerializableClass) }); + + AdvancedDropdownItem root = popup.Build(); + + Assert.That(Children(root).Select(item => item.name), Is.EqualTo(new[] { "", "MackySoft.SerializeReferenceExtensions.Tests", "System" })); + Assert.That(Leaves(root).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(PublicSerializableClass), typeof(string) })); + } + + [Test] + public void EmptyMenuPaths_AreNotDisplayed () + { + var popup = new Popup(new[] { typeof(NullMenu), typeof(EmptyMenu), typeof(WhitespaceMenu), typeof(SeparatorsMenu), typeof(RenamedType) }); + + Assert.That(Children(popup.Build()).Select(item => item.name), Is.EqualTo(new[] { "", "Renamed" })); + Assert.That(Children(popup.Build()).Select(item => item.name), Is.EqualTo(new[] { "", "Renamed" })); + } + + [Test] + public void SortOrder_UsesMenuNameBeforeNicifyingTheLabel () + { + var popup = new Popup(new[] { typeof(PrefixedApple), typeof(PlainBanana) }); + + AdvancedDropdownItem root = popup.Build(); + + Assert.That(Children(root).Select(item => item.name), Is.EqualTo(new[] { "", "Banana", "Apple" })); + Assert.That(Leaves(root).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(PlainBanana), typeof(PrefixedApple) })); + } + + [Test] + public void EqualSortKeys_PreserveInputOrderAfterReplacement () + { + var popup = new Popup(new[] { typeof(SecondTie), typeof(FirstTie) }); + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(SecondTie), typeof(FirstTie) })); + + popup.SetTypes(new[] { typeof(FirstTie), typeof(SecondTie) }); + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(FirstTie), typeof(SecondTie) })); + } + + [Test] + public void CultureChange_UsesCurrentCultureOrdering () + { + CultureInfo previous = CultureInfo.CurrentCulture; + try + { + var popup = new Popup(new[] { typeof(Latin), typeof(Accent) }); + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(Accent), typeof(Latin) })); + + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("sv-SE"); + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(Latin), typeof(Accent) })); + + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + Assert.That(Leaves(popup.Build()).Select(item => item.Type), Is.EqualTo(new[] { null, typeof(Accent), typeof(Latin) })); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Test] + public void PublicPathArrays_CannotChangeLaterMenus () + { + var popup = new Popup(new[] { typeof(Alpha) }); + popup.Build(); + + string[] path = TypeMenuUtility.GetSplittedTypePath(typeof(Alpha)); + path[0] = "Changed category"; + path[1] = "Changed label"; + + Assert.That(TypeMenuUtility.GetSplittedTypePath(typeof(Alpha)), Is.EqualTo(new[] { "Commands", "Alpha" })); + Assert.That(Children(popup.Build())[1].name, Is.EqualTo("Commands")); + Assert.That(Children(Children(popup.Build())[1])[0].name, Is.EqualTo("Alpha")); + + var otherPopup = new Popup(new[] { typeof(Alpha) }); + Assert.That(Children(otherPopup.Build())[1].name, Is.EqualTo("Commands")); + } + + [Test] + public void SelectionAfterRebuild_ReportsTypeAndNullItem () + { + var popup = new Popup(new[] { typeof(Alpha), typeof(Zeta) }); + popup.Build(); + var selectedTypes = new List(); + popup.OnItemSelected += item => selectedTypes.Add(item.Type); + + AdvancedDropdownItem root = popup.Build(); + popup.Select(Leaves(root).Single(item => item.Type == typeof(Zeta))); + popup.Select(Leaves(root).Single(item => item.Type == null)); + + Assert.That(selectedTypes, Is.EqualTo(new[] { typeof(Zeta), null })); + } + + private static AdvancedDropdownItem[] Children (AdvancedDropdownItem parent) + { +#if UNITY_6000_5_OR_NEWER + return parent.childList.ToArray(); +#else + return parent.children.ToArray(); +#endif + } + + private static IEnumerable Leaves (AdvancedDropdownItem root) + { + foreach (AdvancedDropdownItem child in Children(root)) + { + if (child is AdvancedTypePopupItem item) + { + yield return item; + } + foreach (AdvancedTypePopupItem descendant in Leaves(child)) + { + yield return descendant; + } + } + } + + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs.meta new file mode 100644 index 0000000..595ab76 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 121a564eab8334a339d26f6eb136d5bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: