From 871fa0db23e950b5465bc8d90706b27b9cd6e765 Mon Sep 17 00:00:00 2001 From: Makihiro <13536348+mackysoft@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:02:33 +0900 Subject: [PATCH] perf(editor): reuse popup display metadata between rebuilds Why Reopening a type selector repeats sorting, attribute lookup, path splitting, and label preparation even when its candidate snapshot has not changed. What Changed Prepare private display metadata once per popup snapshot and current culture, then build fresh dropdown items from it. Invalidate metadata on SetTypes or a culture change. Keep public AddTo sorting independently on every call. Add behavior tests for ordering, menu paths, snapshots, replacement, culture, mutable item isolation, public path isolation, and selection notifications. Impact Repeated popup rebuilds avoid redundant metadata work while preserving type filtering and public APIs. No generic support or shared mutable cache is added. --- .../Editor/AdvancedTypePopup.cs | 98 ++++--- .../Tests/Editor/AdvancedTypePopupTests.cs | 245 ++++++++++++++++++ .../Editor/AdvancedTypePopupTests.cs.meta | 11 + 3 files changed, 325 insertions(+), 29 deletions(-) create mode 100644 Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs create mode 100644 Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Tests/Editor/AdvancedTypePopupTests.cs.meta 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: