diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index d221afe..b1caec4 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -49,7 +49,7 @@ jobs: matrix: unityVersion: - 2021.3.45f2 - - 2023.2.22f1 + - 6000.6.6f1 steps: - name: Checkout @@ -71,17 +71,31 @@ jobs: key: Library-${{ runner.os }}-${{ matrix.unityVersion }}-EditMode-${{ hashFiles('Packages/manifest.json', 'Packages/packages-lock.json', 'ProjectSettings/ProjectVersion.txt') }} restore-keys: Library-${{ runner.os }}-${{ matrix.unityVersion }}-EditMode- + - name: Install window manager for Editor windows + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends openbox x11-utils + - name: Run EditMode tests env: LIBGL_ALWAYS_SOFTWARE: '1' + UNITY_EDITOR_VERSION: ${{ matrix.unityVersion }} run: | + mkdir -p Logs xvfb-run --auto-servernum --server-args="-screen 0 1280x1024x24" \ - unity --non-interactive test . \ - --editor-version "${{ matrix.unityVersion }}" \ + bash -euo pipefail <<'DISPLAY_SESSION' + # Xvfb supplies a display, but EditorWindow resize/focus also needs a WM. + openbox --sm-disable > Logs/window-manager.log 2>&1 & + window_manager_pid=$! + trap 'kill "$window_manager_pid" 2>/dev/null || true' EXIT + timeout 15 bash -c 'until xprop -root _NET_SUPPORTING_WM_CHECK | grep -q "window id"; do sleep 0.1; done' + unity --non-interactive test . \ + --editor-version "$UNITY_EDITOR_VERSION" \ --mode EditMode \ --output Logs/TestResults/editmode.xml \ --timeout 900 \ -- -force-glcore -logFile Logs/editmode.log + DISPLAY_SESSION - name: Return Unity license if: always() && env.UNITY_LICENSE_ACTIVATION_ATTEMPTED == 'true' diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DecoratorGUI.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DecoratorGUI.cs new file mode 100644 index 0000000..d3538a9 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DecoratorGUI.cs @@ -0,0 +1,87 @@ +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + internal static class DecoratorGUI + { +#if !UNITY_2022_2_OR_NEWER + internal static VisualElement CreateGUI (VisualElement propertyGUI, SerializedProperty property) => CreateOwnedGUI(propertyGUI, property); +#else + internal static VisualElement CreateGUI (VisualElement propertyGUI, SerializedProperty property) => propertyGUI; +#endif + +#if !UNITY_6000_0_OR_NEWER + internal static VisualElement CreateOwnedGUI (VisualElement propertyGUI, SerializedProperty property) + { + DecoratorDrawer[] decorators = UnityPropertyDrawerUtility.GetDecoratorDrawers(property); + if (decorators.Length == 0) + { + return propertyGUI; + } + + var result = new VisualElement(); + int firstIMGUI = 0; + for (int i = 0; i < decorators.Length; i++) + { + VisualElement gui = CreateDecoratorGUI(decorators[i]); + if (gui == null) + { + continue; + } + if (i > firstIMGUI) + { + result.Add(CreateIMGUI(decorators, firstIMGUI, i)); + } + result.Add(gui); + firstIMGUI = i + 1; + } + if (firstIMGUI < decorators.Length) + { + result.Add(CreateIMGUI(decorators, firstIMGUI, decorators.Length)); + } + result.Add(propertyGUI); + return result; + } + + private static IMGUIContainer CreateIMGUI (DecoratorDrawer[] decorators, int first, int end) + { + return new IMGUIContainer(() => + { + float height = 0; + for (int i = first; i < end; i++) + { + height += decorators[i].GetHeight(); + } + Rect position = GUILayoutUtility.GetRect(0, height, GUILayout.ExpandWidth(true)); + for (int i = first; i < end; i++) + { + DecoratorDrawer decorator = decorators[i]; + position.height = decorator.GetHeight(); + float labelWidth = EditorGUIUtility.labelWidth; + float fieldWidth = EditorGUIUtility.fieldWidth; + try + { + decorator.OnGUI(position); + } + finally + { + EditorGUIUtility.labelWidth = labelWidth; + EditorGUIUtility.fieldWidth = fieldWidth; + } + position.y += position.height; + } + }); + } +#else + internal static VisualElement CreateOwnedGUI (VisualElement propertyGUI, SerializedProperty property) => propertyGUI; +#endif + +#if UNITY_2022_2_OR_NEWER && !UNITY_6000_0_OR_NEWER + private static VisualElement CreateDecoratorGUI (DecoratorDrawer drawer) => drawer.CreatePropertyGUI(); +#elif !UNITY_6000_0_OR_NEWER + private static VisualElement CreateDecoratorGUI (DecoratorDrawer drawer) => null; +#endif + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DecoratorGUI.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DecoratorGUI.cs.meta new file mode 100644 index 0000000..f916554 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/DecoratorGUI.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ec4603dc729ba4519ac2044050997e18 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyMenuManipulator.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyMenuManipulator.cs new file mode 100644 index 0000000..7afc2c4 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyMenuManipulator.cs @@ -0,0 +1,183 @@ +using System; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // Route the popup's real right-click to Unity's bound Foldout. Unity retains ownership + // of the complete property menu, including disabled fields and third-party additions. + internal sealed class FoldoutPropertyMenuManipulator : Manipulator + { + private readonly Toggle header; + private readonly Func hasCurrentProperty; + private VisualElement panelRoot; + private EventBase pendingEvent; + private bool sending; + private bool dispatched; + + internal FoldoutPropertyMenuManipulator (Toggle header, Func hasCurrentProperty) + { + this.header = header; + this.hasCurrentProperty = hasCurrentProperty; + } + + protected override void RegisterCallbacksOnTarget () + { + target.RegisterCallback(OnAttach); + target.RegisterCallback(OnDetach); + if (target.panel != null) + { + Connect(); + } + } + + protected override void UnregisterCallbacksFromTarget () + { + Disconnect(); + target.UnregisterCallback(OnAttach); + target.UnregisterCallback(OnDetach); + } + + private void OnAttach (AttachToPanelEvent evt) => Connect(); + private void OnDetach (DetachFromPanelEvent evt) => Disconnect(); + + private void Connect () + { + Disconnect(); + // An enabled panel ancestor also receives events aimed at disabled inputs. + panelRoot = target.panel.visualTree; + RegisterMenuEvents(); + } + + private void Disconnect () + { + CancelPendingEvent(); + if (panelRoot != null) + { + UnregisterMenuEvents(); + panelRoot = null; + } + } + + private bool IsMenuTarget (Vector2 position) + { + if (sending || target.panel == null || header.panel != target.panel + || !target.worldBound.Contains(position) || !hasCurrentProperty()) + { + return false; + } + // Bounds alone would also intercept an unrelated overlay above this input. + // Pick can return the previous element cached at the same pointer position + // after rebinding moves the header. Ask Unity for a fresh hit instead. + VisualElement picked = target.panel.PickAll(position, null); + return picked != null && (picked == target || target.Contains(picked)); + } + + private void Forward (EventBase original, EventBase copy) + { + CancelPendingEvent(); + // SendEvent queues while another event is being dispatched. Keep the pooled + // copy owned until it reaches this callback, so it cannot be pooled and reused. + pendingEvent = copy; + dispatched = false; + copy.target = header; + original.PreventDefault(); + original.StopPropagation(); + sending = true; + try + { + header.SendEvent(copy); + } + finally + { + sending = false; + if (dispatched) + { + ReleasePendingEvent(); + } + } + } + + private bool ConsumeForwardedEvent (EventBase evt) + { + if (evt != pendingEvent) + { + return false; + } + if (sending) + { + // Synchronous dispatch still needs the sender's reference until SendEvent returns. + dispatched = true; + } + else + { + // A queued dispatch retains its own reference through the remaining callbacks. + ReleasePendingEvent(); + } + return true; + } + + private void CancelPendingEvent () + { + if (pendingEvent != null) + { + pendingEvent.StopImmediatePropagation(); + if (sending) + { + dispatched = true; + } + else + { + ReleasePendingEvent(); + } + } + } + + private void ReleasePendingEvent () + { + pendingEvent?.Dispose(); + pendingEvent = null; + } + +#if UNITY_2022_2_OR_NEWER + private void RegisterMenuEvents () + { + panelRoot.RegisterCallback(OnPointerUp, TrickleDown.TrickleDown); + panelRoot.RegisterCallback(OnContextClick, TrickleDown.TrickleDown); + } + + private void UnregisterMenuEvents () + { + panelRoot.UnregisterCallback(OnPointerUp, TrickleDown.TrickleDown); + panelRoot.UnregisterCallback(OnContextClick, TrickleDown.TrickleDown); + } + + private void OnPointerUp (PointerUpEvent evt) + { + if (!ConsumeForwardedEvent(evt) && evt.button == (int)MouseButton.RightMouse && IsMenuTarget(evt.position)) + { + Forward(evt, PointerUpEvent.GetPooled((IPointerEvent)evt)); + } + } + + private void OnContextClick (ContextClickEvent evt) + { + if (IsMenuTarget(evt.mousePosition)) + { + evt.StopImmediatePropagation(); + } + } +#else + private void RegisterMenuEvents () => panelRoot.RegisterCallback(OnMouseUp, TrickleDown.TrickleDown); + private void UnregisterMenuEvents () => panelRoot.UnregisterCallback(OnMouseUp, TrickleDown.TrickleDown); + + private void OnMouseUp (MouseUpEvent evt) + { + if (!ConsumeForwardedEvent(evt) && evt.button == (int)MouseButton.RightMouse && IsMenuTarget(evt.mousePosition)) + { + Forward(evt, MouseUpEvent.GetPooled((IMouseEvent)evt)); + } + } +#endif + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyMenuManipulator.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyMenuManipulator.cs.meta new file mode 100644 index 0000000..ee725f6 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyMenuManipulator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a9f1938e3ee94f608d7b15f4f3dc2a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyNotifications.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyNotifications.cs new file mode 100644 index 0000000..c048fde --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyNotifications.cs @@ -0,0 +1,59 @@ +using System; +using System.Reflection; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + internal static class FoldoutPropertyNotifications + { + private static readonly MethodInfo NativeBooleanChange = FindNativeBooleanChange(); + + internal static void Install (Foldout field) + { + // PropertyField registers its custom-drawer callbacks before attaching the view. + field.RegisterCallback(OnFirstAttach); + } + + private static void OnFirstAttach (AttachToPanelEvent evt) + { + var field = (Foldout)evt.currentTarget; + field.UnregisterCallback(OnFirstAttach); + PropertyField owner = field.parent as PropertyField; + if (owner == null) + { + return; + } + + var native = (EventCallback>)Delegate.CreateDelegate( + typeof(EventCallback>), owner, NativeBooleanChange); + // Delegate identity selects only Unity's value-notification callback. The native + // expansion binding and consumer callbacks on this Foldout remain registered. + field.UnregisterCallback(native); + field.RegisterCallback>(change => + { + if (!(change.target is Foldout)) + { + native(change); + } + }); + } + + private static MethodInfo FindNativeBooleanChange () + { + foreach (MethodInfo method in typeof(PropertyField).GetMethods(BindingFlags.Instance | BindingFlags.NonPublic)) + { + if (!method.Name.StartsWith("", StringComparison.Ordinal)) + { + continue; + } + ParameterInfo[] parameters = method.GetParameters(); + if (parameters.Length == 1 && parameters[0].ParameterType == typeof(ChangeEvent)) + { + return method; + } + } + throw new MissingMethodException(typeof(PropertyField).FullName, "RegisterPropertyChangesOnCustomDrawerElement: bool callback"); + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyNotifications.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyNotifications.cs.meta new file mode 100644 index 0000000..9105f58 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/FoldoutPropertyNotifications.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 740e60ebde77b400c9c3aae0d5791dce +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Editor.asmdef b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Editor.asmdef index c46d84a..fec2353 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Editor.asmdef +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Editor.asmdef @@ -13,6 +13,27 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "Unity", + "expression": "[2022.3.24f1,2022.4)", + "define": "SERIALIZE_REFERENCE_EXTENSIONS_UNITY_2022_3_MR_TRACKER" + }, + { + "name": "Unity", + "expression": "[2022.3.5f1,2022.4)", + "define": "SERIALIZE_REFERENCE_EXTENSIONS_MULTIPLE_SELECTION" + }, + { + "name": "Unity", + "expression": "[2023.1.2f1,2023.2)", + "define": "SERIALIZE_REFERENCE_EXTENSIONS_MULTIPLE_SELECTION" + }, + { + "name": "Unity", + "expression": "[2022.2.16f1,2022.3)", + "define": "SERIALIZE_REFERENCE_EXTENSIONS_COLLECTION_DISABLED_CLICKS" + } + ], "noEngineReferences": false -} \ No newline at end of file +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceSelection.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceSelection.cs new file mode 100644 index 0000000..b9ead75 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceSelection.cs @@ -0,0 +1,34 @@ +using System; +using UnityEditor; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + internal static class ManagedReferenceSelection + { + internal static bool SelectType (DeferredManagedReferenceTarget target, Type type, Action beforeApply) + { + return target.TryExecute(properties => + { + for (int i = 0; i < properties.Length; i++) + { + Type fieldType = ManagedReferenceUtility.GetType(properties[i].managedReferenceFieldTypename.Replace('/', '+')); + if (!properties[i].editable || (type != null && (fieldType == null || !fieldType.IsAssignableFrom(type)))) + { + return false; + } + } + + var values = new object[properties.Length]; + for (int i = 0; i < properties.Length; i++) + { + values[i] = ManagedReferenceUtility.CreateManagedReference(properties[i].managedReferenceValue, type); + } + // Release UI bindings only after every target and replacement is valid, while + // the old child paths still exist. IMGUI has no retained bindings to release. + beforeApply?.Invoke(); + ManagedReferenceUtility.ApplyManagedReferences(properties, values, "Change Managed Reference Type", true); + return true; + }); + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceSelection.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceSelection.cs.meta new file mode 100644 index 0000000..4b6df55 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceSelection.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 988a52e260e604acc93d4857b4287122 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs index 3628175..b435e13 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/ManagedReferenceUtility.cs @@ -47,6 +47,14 @@ internal static void ApplyManagedReferences (SerializedProperty[] properties, ob Undo.SetCurrentGroupName(undoName); try { + // Incremental diffs can lose subtype data after collection reordering or + // Prefab reference-ID reuse. One complete snapshot owns Undo for this edit. + var targets = new UnityEngine.Object[properties.Length]; + for (int i = 0; i < properties.Length; i++) + { + targets[i] = properties[i].serializedObject.targetObject; + } + Undo.RegisterCompleteObjectUndo(targets, undoName); for (int i = 0; i < properties.Length; i++) { properties[i].managedReferenceValue = values[i]; @@ -54,7 +62,7 @@ internal static void ApplyManagedReferences (SerializedProperty[] properties, ob { properties[i].isExpanded = values[i] != null; } - properties[i].serializedObject.ApplyModifiedProperties(); + properties[i].serializedObject.ApplyModifiedPropertiesWithoutUndo(); } } finally diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativeManagedReferenceTracking.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativeManagedReferenceTracking.cs new file mode 100644 index 0000000..1ba45a8 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativeManagedReferenceTracking.cs @@ -0,0 +1,740 @@ +#if SERIALIZE_REFERENCE_EXTENSIONS_UNITY_2022_3_MR_TRACKER || UNITY_2023_2_OR_NEWER +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // Unity rebinds custom managed-reference drawers on every content/mixed-value + // revision. Preserve the native tracker, but restrict its rebuild to changes in UI shape. + internal sealed class NativeManagedReferenceTracking : IDisposable + { + private static readonly ConditionalWeakTable Registrations = new ConditionalWeakTable(); + private static readonly ConditionalWeakTable OwnerEpochs = new ConditionalWeakTable(); + + private readonly VisualElement root; + private readonly IPanel panel; + private readonly Delegate hierarchyChanged; + private readonly Dictionary subscriptions = new Dictionary(); + private readonly HashSet ancestors = new HashSet(); + private readonly HashSet registrations = new HashSet(); + private bool disposed; + + internal NativeManagedReferenceTracking (VisualElement root) + { + this.root = root; + panel = root.panel; + hierarchyChanged = NativeAccess.SubscribeHierarchy(panel, this); + try + { + SubscribeTree(root); + SynchronizeAncestors(); + RepairTree(root); + RepairAncestors(root.parent); + } + catch + { + Dispose(); + throw; + } + } + + public void Dispose () + { + if (disposed) + { + return; + } + disposed = true; + NativeAccess.UnsubscribeHierarchy(panel, hierarchyChanged); + foreach (IDisposable subscription in subscriptions.Values) + { + subscription.Dispose(); + } + subscriptions.Clear(); + ancestors.Clear(); + foreach (Registration registration in registrations) + { + registration.Release(this); + } + registrations.Clear(); + } + + private static bool IsWithin (VisualElement element, VisualElement ancestor) + { + for (; element != null; element = element.parent) + { + if (element == ancestor) + { + return true; + } + } + return false; + } + + private void Subscribe (VisualElement element) + { + if (!subscriptions.ContainsKey(element)) + { + subscriptions.Add(element, NativeAccess.SubscribeBinding(element, this)); + } + } + + private void SubscribeTree (VisualElement element) + { + Subscribe(element); + for (int i = 0; i < element.hierarchy.childCount; i++) + { + SubscribeTree(element.hierarchy[i]); + } + } + + private void RemoveTree (VisualElement element) + { + if (subscriptions.TryGetValue(element, out IDisposable subscription)) + { + subscription.Dispose(); + subscriptions.Remove(element); + } + for (int i = 0; i < element.hierarchy.childCount; i++) + { + RemoveTree(element.hierarchy[i]); + } + } + + private void SynchronizeAncestors () + { + var current = new HashSet(); + for (VisualElement ancestor = root.parent; ancestor != null; ancestor = ancestor.parent) + { + current.Add(ancestor); + } + foreach (VisualElement ancestor in ancestors) + { + if (!current.Contains(ancestor) && !IsWithin(ancestor, root)) + { + subscriptions[ancestor].Dispose(); + subscriptions.Remove(ancestor); + } + } + ancestors.Clear(); + foreach (VisualElement ancestor in current) + { + Subscribe(ancestor); + ancestors.Add(ancestor); + } + ReleaseUnownedRegistrations(); + } + + private void ReleaseUnownedRegistrations () + { + var removed = new List(); + foreach (Registration registration in registrations) + { + if (!Owns(registration.Field, registration.Owner) || !registration.IsCurrent()) + { + registration.Release(this); + removed.Add(registration); + } + } + foreach (Registration registration in removed) + { + registrations.Remove(registration); + } + } + + private void OnHierarchyChanged (VisualElement element, TChange change) + { + if (disposed) + { + return; + } + if (Equals(change, NativeAccess.RemoveHierarchyChange)) + { + // Remove precedes the parent change. Reordering uses the same Remove/Add + // pair, so panel Detach remains the authority for this scope's disposal. + if (!IsWithin(root, element) && IsWithin(element, root)) + { + RemoveTree(element); + var removed = new List(); + foreach (Registration registration in registrations) + { + if (IsWithin(registration.Owner, element)) + { + registration.Release(this); + removed.Add(registration); + } + } + foreach (Registration registration in removed) + { + registrations.Remove(registration); + } + } + } + else if (Equals(change, NativeAccess.AddHierarchyChange) + && (IsWithin(element, root) || IsWithin(root, element))) + { + // Same-panel reparenting must replace the old ancestor ownership before + // BindTree can register or dispatch against the new custom-drawer chain. + SynchronizeAncestors(); + VisualElement added = IsWithin(element, root) ? element : root; + SubscribeTree(added); + RepairTree(added); + RepairAncestors(root.parent); + } + } + + private void OnObjectBind (VisualElement element) + { + if (!disposed) + { + SynchronizeAncestors(); + // ObjectBind is dispatched at the target before Unity can delay child binding. + // The custom root has been added and its extra native tracker is now registered. + RepairAncestors(element); + } + } + + private void RepairTree (VisualElement element) + { + if (element is PropertyField field) + { + Repair(field); + } + for (int i = 0; i < element.hierarchy.childCount; i++) + { + RepairTree(element.hierarchy[i]); + } + } + + private void RepairAncestors (VisualElement element) + { + for (; element != null; element = element.parent) + { + if (element is PropertyField field) + { + Repair(field); + } + } + } + + private bool Owns (PropertyField field, VisualElement owner) + => !disposed && root.panel == panel && owner.panel == panel && IsWithin(owner, field) + && (IsWithin(field, root) || IsWithin(root, owner)); + + private void Repair (PropertyField field) + { + VisualElement owner = NativeAccess.GetTrackedRoot(field); + if (owner == null || !Owns(field, owner)) + { + return; + } + SerializedProperty property = NativeAccess.GetProperty(field); + if (property == null || property.propertyType != SerializedPropertyType.ManagedReference) + { + return; + } + object context = NativeAccess.GetContext(owner); + if (context == null || !ReferenceEquals(NativeAccess.GetSource(context), property.serializedObject)) + { + return; + } + + foreach (object value in NativeAccess.GetCallbackSlots(owner, context, property)) + { + if (!Registrations.TryGetValue(value, out Registration registration) || !registration.TryInstall()) + { + Action callback = registration != null + ? registration.GetNativeCallback(field, owner, context, property.serializedObject, property.propertyPath) + : NativeAccess.GetCallback(value); + if (!NativeAccess.IsNativeCallback(callback, field)) + { + continue; + } + registration = new Registration(value, owner, field, context, property.serializedObject, property.propertyPath, callback); + Registrations.Remove(value); + Registrations.Add(value, registration); + } + if (registrations.Add(registration)) + { + registration.Retain(this); + } + } + } + + // This weak owner metadata outlives individual scopes so a queued callback can + // distinguish a reattached element from the attachment that produced it. The + // marker and its callback retain no registration, SerializedObject, scope or owner. + private sealed class OwnerEpoch + { + internal ulong Value { get; private set; } + + private OwnerEpoch (VisualElement owner) + => owner.RegisterCallback(OnDetach, TrickleDown.TrickleDown); + + internal static OwnerEpoch Create (VisualElement owner) => new OwnerEpoch(owner); + + private void OnDetach (DetachFromPanelEvent evt) + { + if (evt.target == evt.currentTarget) + { + Value++; + } + } + } + + private sealed class Registration + { + private readonly object value; + private readonly PropertyField field; + private readonly object context; + private readonly SerializedObject source; + private readonly string path; + private readonly IPanel panel; + private readonly OwnerEpoch ownerEpoch; + private readonly ulong attachment; + private readonly Action original; + private readonly Action callback; + private readonly HashSet scopes = new HashSet(); + private Shape shape; + + internal VisualElement Owner { get; } + internal PropertyField Field => field; + private bool IsInstalled => NativeAccess.GetCallback(value) == callback; + + internal Registration (object value, VisualElement owner, PropertyField field, object context, SerializedObject source, + string path, Action original) + { + this.value = value; + Owner = owner; + this.field = field; + this.context = context; + this.source = source; + this.path = path; + panel = owner.panel; + ownerEpoch = OwnerEpochs.GetValue(owner, OwnerEpoch.Create); + attachment = ownerEpoch.Value; + this.original = original; + shape = Shape.Read(source, path); + callback = OnChange; + NativeAccess.SetCallback(value, callback); + } + + internal bool IsCurrent () + { + if (ownerEpoch.Value != attachment || Owner.panel != panel || !IsWithin(Owner, field) + || NativeAccess.GetTrackedRoot(field) != Owner) + { + return false; + } + SerializedProperty property = NativeAccess.GetProperty(field); + if (property == null || property.propertyType != SerializedPropertyType.ManagedReference + || property.propertyPath != path || !ReferenceEquals(property.serializedObject, source) + || !ReferenceEquals(NativeAccess.GetContext(Owner), context) + || !ReferenceEquals(NativeAccess.GetSource(context), source) + || !NativeAccess.Contains(Owner, context, property, value)) + { + return false; + } + Action installed = NativeAccess.GetCallback(value); + return installed == callback || installed == original; + } + + internal bool TryInstall () + { + if (!IsCurrent()) + { + return false; + } + // A reorder can release and reacquire this exact native registration before + // its queued callback runs. Keep its shape history and delegate unchanged. + if (!IsInstalled) + { + NativeAccess.SetCallback(value, callback); + } + return true; + } + + internal Action GetNativeCallback (PropertyField currentField, VisualElement currentOwner, + object currentContext, SerializedObject currentSource, string currentPath) + { + Action installed = NativeAccess.GetCallback(value); + return field == currentField && Owner == currentOwner && ReferenceEquals(context, currentContext) + && ReferenceEquals(source, currentSource) && path == currentPath + && (installed == callback || installed == original) ? original : installed; + } + + internal void Retain (NativeManagedReferenceTracking scope) => scopes.Add(scope); + + internal void Release (NativeManagedReferenceTracking scope) + { + scopes.Remove(scope); + if (scopes.Count == 0 && IsInstalled && IsCurrent()) + { + // Restore a still-live native owner. A detached attachment keeps the + // guard in its old closure so a queued native wrapper cannot revive it. + NativeAccess.SetCallback(value, original); + } + } + + private void OnChange (SerializedProperty property) + { + if (!IsCurrent()) + { + return; + } + bool active = false; + foreach (NativeManagedReferenceTracking scope in scopes) + { + if (scope.Owns(field, Owner)) + { + active = true; + break; + } + } + if (!active || !NativeAccess.IsValid(property) + || property.propertyPath != path || !ReferenceEquals(property.serializedObject, source)) + { + original(property); + return; + } + + Shape current = Shape.Read(source, path); + if (!shape.Matches(current)) + { + shape = current; + original(property); + } + } + } + + private sealed class Shape + { + private readonly List entries = new List(); + + private struct Entry : IEquatable + { + internal UnityEngine.Object Target; + internal string Path; + internal SerializedPropertyType Kind; + internal string Type; + internal long Id; + internal int Size; + + public bool Equals (Entry other) + => Target == other.Target && Path == other.Path && Kind == other.Kind + && Type == other.Type && Id == other.Id && Size == other.Size; + } + + internal static Shape Read (SerializedObject source, string path) + { + var shape = new Shape(); + // The binding context owns its cache. Fresh per-target objects keep mixed values + // and shape reads independent without updating or invalidating that cache. + foreach (UnityEngine.Object target in source.targetObjects) + { + shape.entries.Add(new Entry { Target = target, Path = path, Size = -1 }); + if (target == null) + { + continue; + } + using (var individual = new SerializedObject(target, source.context)) + using (SerializedProperty property = individual.FindProperty(path)) + { + if (property == null) + { + continue; + } + var visited = new HashSet(); + using (SerializedProperty end = property.GetEndProperty(true)) + { + bool enterChildren; + do + { + SerializedPropertyType kind = property.propertyType; + bool reference = kind == SerializedPropertyType.ManagedReference; + bool array = property.isArray && kind != SerializedPropertyType.String; + if (reference || array || property.propertyPath == path) + { + shape.entries.Add(new Entry + { + Target = target, + Path = property.propertyPath, + Kind = kind, + Type = reference ? property.managedReferenceFullTypename : null, + Id = reference ? property.managedReferenceId : 0, + Size = array ? property.arraySize : -1 + }); + } + enterChildren = kind != SerializedPropertyType.String + && (!reference || (!string.IsNullOrEmpty(property.managedReferenceFullTypename) + && visited.Add(property.managedReferenceId))); + } + while (property.Next(enterChildren) && !SerializedProperty.EqualContents(property, end)); + } + } + } + return shape; + } + + internal bool Matches (Shape other) + { + if (entries.Count != other.entries.Count) + { + return false; + } + for (int i = 0; i < entries.Count; i++) + { + if (!entries[i].Equals(other.entries[i])) + { + return false; + } + } + return true; + } + } + + // Unity's legacy and data-binding implementations meet at this private boundary. + // Wrap the captured native action, not a registration: the same action is used + // before activation, after activation and by already queued native notifications. + private static class NativeAccess + { + private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + private static readonly Assembly EditorAssembly = typeof(PropertyField).Assembly; + private static readonly Assembly UIAssembly = typeof(VisualElement).Assembly; + private static readonly Assembly BindingAssembly = Assembly.Load("UnityEditor.UIElementsModule"); + private static readonly Type ContextType = BindingAssembly.GetType("UnityEditor.UIElements.Bindings.SerializedObjectBindingContext", true); + private static readonly Type UpdaterType = BindingAssembly.GetType("UnityEditor.UIElements.Bindings.SerializedObjectBindingContextUpdater", true); + private static readonly Type ImplementationType = BindingAssembly.GetType("UnityEditor.UIElements.Bindings.DefaultSerializedObjectBindingImplementation", true); + private static readonly Type TrackedValueType = ContextType.GetNestedType("TrackedValue", BindingFlags.NonPublic); + private static readonly FieldInfo Property = RequireField(typeof(PropertyField), "m_SerializedProperty"); + private static readonly PropertyInfo PropertyIsValid = RequireProperty(typeof(SerializedProperty), "isValid"); + private static readonly FieldInfo CustomRoot = RequireField(typeof(PropertyField), "m_CustomPropertyGUI"); + private static readonly FieldInfo ChildRoot = RequireField(typeof(PropertyField), "m_ChildField"); + private static readonly Func ReadUpdaterOwner = BindUpdaterOwnerReader(); + private static readonly Func ReadUpdater = BindUpdaterReader(); + private static readonly PropertyInfo BindingContext = RequireProperty(UpdaterType.BaseType, "bindingContext"); + private static readonly PropertyInfo Source = RequireProperty(ContextType, "serializedObject"); + private static readonly FieldInfo ValueTracker = RequireField(ContextType, "m_ValueTracker"); + private static readonly FieldInfo TrackedValues = RequireField(ValueTracker.FieldType, "m_TrackedValues"); + private static readonly FieldInfo Cookie = RequireField(TrackedValueType, "cookie"); + private static readonly FieldInfo Kind = RequireField(TrackedValueType, "originalPropType"); + private static readonly FieldInfo OnChange = RequireField(TrackedValueType, "onChangeCallback"); + private static readonly FieldInfo OnUpdate = RequireField(TrackedValueType, "onUpdateCallback"); + private static readonly Func PropertyHash = (Func)Delegate.CreateDelegate( + typeof(Func), RequireProperty(typeof(SerializedProperty), "hashCodeForPropertyPath").GetGetMethod(true)); + private static readonly PropertyInfo PendingTracking = UpdaterType.GetProperty("trackedProperties", Instance); + private static readonly Type PendingRegistrationType = PendingTracking?.PropertyType.GetGenericArguments()[0]; + private static readonly FieldInfo PendingPath = PendingRegistrationType?.GetField("propertyPath", Instance); + private static readonly FieldInfo PendingCallback = PendingRegistrationType?.GetField("callback", Instance); + private static readonly EventInfo HierarchyChanged = UIAssembly.GetType("UnityEngine.UIElements.BaseVisualElementPanel", true) + .GetEvent("hierarchyChanged", Instance); + private static readonly Type ChangeType = HierarchyChanged.EventHandlerType.GetMethod("Invoke").GetParameters()[1].ParameterType; + private static readonly MethodInfo SubscribeBindingMethod = typeof(NativeAccess).GetMethod(nameof(SubscribeBindingEvent), BindingFlags.Static | BindingFlags.NonPublic) + .MakeGenericMethod(EditorAssembly.GetType("UnityEditor.UIElements.SerializedObjectBindEvent", true)); + + internal static readonly object AddHierarchyChange = ReadHierarchyChange("Add", "AddedToParent"); + internal static readonly object RemoveHierarchyChange = ReadHierarchyChange("Remove", "RemovedFromParent"); + + private static FieldInfo RequireField (Type type, string name) + => type.GetField(name, Instance) ?? throw new MissingFieldException(type.FullName, name); + + private static PropertyInfo RequireProperty (Type type, string name) + => type.GetProperty(name, Instance) ?? throw new MissingMemberException(type.FullName, name); + + private static Func BindUpdaterOwnerReader () + { + var updater = Expression.Parameter(typeof(object), "updater"); + var instance = Expression.Convert(updater, UpdaterType); + FieldInfo field = UpdaterType.GetField("owner", Instance); + Expression read = field != null ? Expression.Field(instance, field) + : Expression.Property(instance, RequireProperty(UpdaterType, "owner")); + return Expression.Lambda>(read, updater).Compile(); + } + + private static Func BindUpdaterReader () + { + var element = Expression.Parameter(typeof(VisualElement), "element"); + MethodInfo legacy = UIAssembly.GetType("UnityEngine.UIElements.VisualTreeBindingsUpdater", true) + .GetMethod("GetAdditionalBinding", BindingFlags.Static | BindingFlags.Public); + Expression read; + if (legacy != null && legacy.ReturnType.IsAssignableFrom(UpdaterType)) + { + read = Expression.Call(legacy, element); + } + else + { + Type id = UIAssembly.GetType("UnityEngine.UIElements.BindingId", true); + MethodInfo get = typeof(VisualElement).GetMethod("GetBinding", new[] { id }) + ?? throw new MissingMethodException(typeof(VisualElement).FullName, "GetBinding"); + string key = (string)typeof(BindingExtensions).GetField("s_SerializedBindingContextUpdaterId", + BindingFlags.Static | BindingFlags.NonPublic).GetValue(null); + read = Expression.Call(element, get, Expression.New(id.GetConstructor(new[] { typeof(string) }), Expression.Constant(key))); + } + return Expression.Lambda>(Expression.Convert(read, typeof(object)), element).Compile(); + } + + private static object ReadHierarchyChange (string legacy, string current) + => Enum.Parse(ChangeType, Enum.IsDefined(ChangeType, current) ? current : legacy); + + internal static Delegate SubscribeHierarchy (IPanel panel, NativeManagedReferenceTracking scope) + { + MethodInfo method = typeof(NativeManagedReferenceTracking).GetMethod(nameof(OnHierarchyChanged), Instance).MakeGenericMethod(ChangeType); + ParameterInfo[] signature = HierarchyChanged.EventHandlerType.GetMethod("Invoke").GetParameters(); + var parameters = new ParameterExpression[signature.Length]; + for (int i = 0; i < parameters.Length; i++) + { + parameters[i] = Expression.Parameter(signature[i].ParameterType, signature[i].Name); + } + // Newer hierarchy events also provide batch context; Add/Remove still + // identify the affected element, which is all this scope owns. + Delegate callback = Expression.Lambda(HierarchyChanged.EventHandlerType, + Expression.Call(Expression.Constant(scope), method, parameters[0], parameters[1]), parameters).Compile(); + HierarchyChanged.GetAddMethod(true).Invoke(panel, new object[] { callback }); + return callback; + } + + internal static void UnsubscribeHierarchy (IPanel panel, Delegate callback) + => HierarchyChanged.GetRemoveMethod(true).Invoke(panel, new object[] { callback }); + + internal static IDisposable SubscribeBinding (VisualElement element, NativeManagedReferenceTracking scope) + => (IDisposable)SubscribeBindingMethod.Invoke(null, new object[] { element, scope }); + + private static IDisposable SubscribeBindingEvent (VisualElement element, NativeManagedReferenceTracking scope) + where TEvent : EventBase, new() + => new BindingSubscription(element, scope); + + private sealed class BindingSubscription : IDisposable where TEvent : EventBase, new() + { + private readonly VisualElement element; + private readonly EventCallback callback; + + internal BindingSubscription (VisualElement element, NativeManagedReferenceTracking scope) + { + this.element = element; + callback = evt => scope.OnObjectBind((VisualElement)evt.target); + element.RegisterCallback(callback, TrickleDown.TrickleDown); + } + + public void Dispose () => element.UnregisterCallback(callback, TrickleDown.TrickleDown); + } + + internal static VisualElement GetTrackedRoot (PropertyField field) + => (VisualElement)ChildRoot.GetValue(field) ?? (VisualElement)CustomRoot.GetValue(field); + + internal static bool IsValid (SerializedProperty property) => property != null && (bool)PropertyIsValid.GetValue(property); + + internal static SerializedProperty GetProperty (PropertyField field) + { + var property = (SerializedProperty)Property.GetValue(field); + return IsValid(property) ? property : null; + } + + internal static object GetContext (VisualElement owner) + { + object updater = ReadUpdater(owner); + return updater != null && updater.GetType() == UpdaterType && ReferenceEquals(ReadUpdaterOwner(updater), owner) + ? BindingContext.GetValue(updater) : null; + } + + internal static SerializedObject GetSource (object context) => (SerializedObject)Source.GetValue(context); + internal static IDictionary GetTrackedValues (object context) => (IDictionary)TrackedValues.GetValue(ValueTracker.GetValue(context)); + + internal static IEnumerable GetCallbackSlots (VisualElement owner, object context, SerializedProperty property) + { + var yielded = new HashSet(); + // Modern context updaters defer registration until activation. Inspect + // their existing callbacks without changing the pending list or its order. + if (PendingTracking != null) + { + object updater = ReadUpdater(owner); + if (updater != null && updater.GetType() == UpdaterType) + { + foreach (object registration in (IEnumerable)PendingTracking.GetValue(updater)) + { + if ((string)PendingPath.GetValue(registration) == property.propertyPath) + { + object slot = GetCallbackSlot((Action)PendingCallback.GetValue(registration)); + if (slot != null && yielded.Add(slot)) + { + yield return slot; + } + } + } + } + } + IEnumerable values = (IEnumerable)GetTrackedValues(context)[PropertyHash(property)]; + if (values == null) + { + yield break; + } + foreach (object value in values) + { + if (value.GetType() == TrackedValueType && ReferenceEquals(Cookie.GetValue(value), owner) + && (SerializedPropertyType)Kind.GetValue(value) == SerializedPropertyType.ManagedReference && OnUpdate.GetValue(value) == null) + { + object slot = GetCallbackSlot((Action)OnChange.GetValue(value)); + if (slot != null && yielded.Add(slot)) + { + yield return slot; + } + } + } + } + + internal static bool Contains (VisualElement owner, object context, SerializedProperty property, object value) + { + foreach (object current in GetCallbackSlots(owner, context, property)) + { + if (ReferenceEquals(current, value)) + { + return true; + } + } + return false; + } + + internal static Action GetCallback (object value) + => (Action)RequireField(value.GetType(), "callback").GetValue(value); + + internal static void SetCallback (object value, Action callback) + => RequireField(value.GetType(), "callback").SetValue(value, callback); + + private static object GetCallbackSlot (Action callback) + { + if (callback == null || callback.GetInvocationList().Length != 1 || callback.Target == null) + { + return null; + } + Type closure = callback.Target.GetType(); + if ((closure.DeclaringType != ImplementationType && closure.DeclaringType != typeof(BindingExtensions)) || callback.Method.DeclaringType != closure + || !closure.IsDefined(typeof(CompilerGeneratedAttribute), false)) + { + return null; + } + FieldInfo capturedCallback = closure.GetField("callback", Instance); + if (capturedCallback == null || capturedCallback.FieldType != typeof(Action)) + { + return null; + } + return callback.Target; + } + + internal static bool IsNativeCallback (Action callback, PropertyField field) + { + return callback != null && callback.GetInvocationList().Length == 1 && ReferenceEquals(callback.Target, field) + && callback.Method.DeclaringType == typeof(PropertyField) + && callback.Method.IsDefined(typeof(CompilerGeneratedAttribute), false) + && (callback.Method.Name.StartsWith("", StringComparison.Ordinal) + || callback.Method.Name.StartsWith("", StringComparison.Ordinal)); + } + } + } +} +#endif diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativeManagedReferenceTracking.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativeManagedReferenceTracking.cs.meta new file mode 100644 index 0000000..7df31b6 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativeManagedReferenceTracking.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 171c9712ea86a4e6881dfde1b660e8aa +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyFieldControls.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyFieldControls.cs new file mode 100644 index 0000000..1eb754d --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyFieldControls.cs @@ -0,0 +1,56 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // Match PropertyField's control setup without changing native layout or binding. + internal static class NativePropertyFieldControls + { + private static readonly Action EnableDisabledClicks = BindEnableDisabledClicks(); + + internal static void ConfigureFoldout (Foldout foldout) + => EnableDisabledClicks(foldout.Q(className: Foldout.toggleUssClassName)); + + internal static void ConfigureListView (ListView list) + { + list.selectionType = CollectionSelectionType; + ConfigureCollectionFoldout(list); + } + + // PropertyField first enables multiple selection in 2022.3.5, 2023.1.2 + // and 2023.2. Keep the same patch-level boundaries as Unity's implementation. + private static SelectionType CollectionSelectionType + { + get + { +#if SERIALIZE_REFERENCE_EXTENSIONS_MULTIPLE_SELECTION || UNITY_2023_2_OR_NEWER + return SelectionType.Multiple; +#else + return SelectionType.Single; +#endif + } + } + + private static void ConfigureCollectionFoldout (ListView list) + { +#if SERIALIZE_REFERENCE_EXTENSIONS_COLLECTION_DISABLED_CLICKS || UNITY_2022_3_OR_NEWER + // ConfigureListView enables its header's disabled clicks from 2022.2.16. + ConfigureFoldout(list.Q()); +#endif + } + + private static Action BindEnableDisabledClicks () + { + const BindingFlags members = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + FieldInfo clickable = typeof(Toggle).BaseType.GetField("m_Clickable", members) + ?? throw new MissingFieldException(typeof(Toggle).BaseType.FullName, "m_Clickable"); + PropertyInfo acceptClicks = typeof(Clickable).GetProperty("acceptClicksIfDisabled", members) + ?? throw new MissingMemberException(typeof(Clickable).FullName, "acceptClicksIfDisabled"); + var toggle = Expression.Parameter(typeof(Toggle), "toggle"); + return Expression.Lambda>( + Expression.Assign(Expression.Property(Expression.Field(toggle, clickable), acceptClicks), Expression.Constant(true)), toggle).Compile(); + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyFieldControls.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyFieldControls.cs.meta new file mode 100644 index 0000000..c74172d --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyFieldControls.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f9d2b0a4a783343c687b397b34928c54 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyIMGUIField.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyIMGUIField.cs new file mode 100644 index 0000000..1d7b7fc --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyIMGUIField.cs @@ -0,0 +1,147 @@ +using System; +using System.Reflection; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // Reuse PropertyField's complete IMGUI path, including each Editor version's layout. + // The adapter only owns the writer cache and the continuation past SubclassSelector. + internal sealed class NativePropertyIMGUIField : PropertyField + { + private const BindingFlags NativeInstance = BindingFlags.Instance | BindingFlags.NonPublic; + private static readonly Func CreateContainer = (Func)Delegate.CreateDelegate( + typeof(Func), typeof(PropertyField).GetMethod("CreatePropertyIMGUIContainer", NativeInstance)); + private static readonly FieldInfo NativeProperty = RequireField("m_SerializedProperty"); + private static readonly FieldInfo NativeObject = typeof(PropertyField).GetField("m_SerializedObject", NativeInstance); + private static readonly FieldInfo NativeNestingLevel = RequireField("m_DrawNestingLevel"); + private static readonly FieldInfo NativeIMGUIChild = typeof(PropertyField).GetField("m_imguiChildField", NativeInstance); + private static readonly FieldInfo NativeLabel = typeof(PropertyField).GetField("m_Label", NativeInstance); + private static readonly string NativeIMGUIClass = (string)typeof(PropertyField) + .GetField("imguiContainerPropertyUssClassName", BindingFlags.Static | BindingFlags.NonPublic)?.GetValue(null); + + private readonly SerializedObject source; + private readonly string path; + private readonly Type type; + private readonly Foldout selector; + private readonly Action drawNative; + private SerializedObject writer; + private SerializedProperty property; + private bool published; + + internal NativePropertyIMGUIField (SerializedProperty sourceProperty, string label, int nestingLevel, Foldout selector) + { + source = sourceProperty.serializedObject; + path = sourceProperty.propertyPath; + type = sourceProperty.managedReferenceValue.GetType(); + this.selector = selector; + this.label = label; + NativeNestingLevel.SetValue(this, nestingLevel); + if (NativeIMGUIClass != null) + { + AddToClassList(NativeIMGUIClass); + } + + var container = (IMGUIContainer)CreateContainer(this); + NativeIMGUIChild?.SetValue(this, container); + drawNative = container.onGUIHandler; + container.onGUIHandler = Draw; + hierarchy.Add(container); + RegisterCallback(ForwardChange); + RegisterCallback(OnDetach); + } + + private void Draw () + { + if (panel == null || source.targetObject == null) + { + return; + } + if (writer == null) + { + writer = new SerializedObject(source.targetObjects, source.context); + property = writer.FindProperty(path); + NativeProperty.SetValue(this, property); + NativeObject?.SetValue(this, writer); + published = false; + } + writer.forceChildVisibility = source.forceChildVisibility; + writer.maxArraySizeForMultiEditing = source.maxArraySizeForMultiEditing; + writer.UpdateIfRequiredOrScript(); + if (property == null || property.propertyType != SerializedPropertyType.ManagedReference + || property.managedReferenceValue?.GetType() != type) + { + return; + } + if (label != selector.text) + { + // Updating the native label must not start a new binding at this continuation. + if (NativeLabel != null) + { + NativeLabel.SetValue(this, selector.text); + } + else + { + label = selector.text; + } + } + // Start this IMGUI context at the Inspector's neutral indent. PropertyField + // adds the actual UITK Foldout depth; a separate container's GUI state does not. + using (new EditorGUI.IndentLevelScope(-EditorGUI.indentLevel)) + { + drawNative(); + CompleteIMGUI(); + } + } + + private void ForwardChange (SerializedPropertyChangeEvent evt) + { + if (evt.target != this) + { + return; + } + // The public selector host must expose the same writer cache as its OnGUI drawer. + // Retaining it also keeps edits made by earlier receivers visible after detach. + evt.StopPropagation(); + published = true; + VisualElement target = selector.GetFirstAncestorOfType(); + target ??= selector; + using (var forwarded = SerializedPropertyChangeEvent.GetPooled(evt.changedProperty.Copy())) + { + forwarded.target = target; + target.SendEvent(forwarded); + } + } + + private void OnDetach (DetachFromPanelEvent evt) + { + if (evt.target != this) + { + return; + } + NativeProperty.SetValue(this, null); + NativeObject?.SetValue(this, null); + if (!published) + { + property?.Dispose(); + writer?.Dispose(); + } + // Published properties retain the writer for queued and later event receivers. + property = null; + writer = null; + published = false; + } + +#if UNITY_6000_0_OR_NEWER + private static void CompleteIMGUI () => GUIUtility.GetControlID(FocusType.Passive); +#else + private static void CompleteIMGUI () { } +#endif + + private static FieldInfo RequireField (string name) + => typeof(PropertyField).GetField(name, NativeInstance) + ?? throw new MissingFieldException(typeof(PropertyField).FullName, name); + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyIMGUIField.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyIMGUIField.cs.meta new file mode 100644 index 0000000..9039cd0 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/NativePropertyIMGUIField.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3d48ab78f66284d61a5a5ff365ccc4ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs index c918263..071a4d3 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/PropertyDrawerCache.cs @@ -7,7 +7,6 @@ namespace MackySoft.SerializeReferenceExtensions.Editor { public static class PropertyDrawerCache { - private static readonly Dictionary DrawerTypes = new Dictionary(); /// Creates an unbound custom drawer registered for the specified type. @@ -16,69 +15,72 @@ public static class PropertyDrawerCache /// True when a custom drawer registration exists. /// A type-only lookup supplies neither declaring-field context nor Unity handler ownership. public static bool TryGetPropertyDrawer (Type type, out PropertyDrawer drawer) + { + Type drawerType = GetPropertyDrawerType(type); + drawer = drawerType != null ? (PropertyDrawer)Activator.CreateInstance(drawerType) : null; + return drawer != null; + } + + private static Type GetPropertyDrawerType (Type type) { if (!DrawerTypes.TryGetValue(type, out Type drawerType)) { drawerType = GetCustomPropertyDrawerType(type); + if (drawerType != null && !typeof(PropertyDrawer).IsAssignableFrom(drawerType)) + { + drawerType = null; + } DrawerTypes.Add(type, drawerType); } - drawer = (drawerType != null) ? (PropertyDrawer)Activator.CreateInstance(drawerType) : null; - return (drawer != null); + return drawerType; } private static Type GetCustomPropertyDrawerType (Type type) { - Type[] interfaceTypes = type.GetInterfaces(); + // Resolve the most specific class first, then an inherited interface drawer. + // TypeCache enumeration order must not let a base drawer hide an exact match. + for (Type candidate = type; candidate != null; candidate = candidate.BaseType) + { + Type drawerType = GetExactDrawerType(candidate, candidate != type); + if (drawerType == null && candidate.IsGenericType && !candidate.IsGenericTypeDefinition) + { + drawerType = GetExactDrawerType(candidate.GetGenericTypeDefinition(), candidate != type); + } + if (drawerType != null) + { + return drawerType; + } + } + foreach (Type interfaceType in type.GetInterfaces()) + { + Type drawerType = GetExactDrawerType(interfaceType, true); + if (drawerType != null) + { + return drawerType; + } + } + return null; + } - var types = TypeCache.GetTypesWithAttribute(); - foreach (Type drawerType in types) + private static Type GetExactDrawerType (Type type, bool requireUseForChildren) + { + foreach (Type drawerType in TypeCache.GetTypesWithAttribute()) { - var customPropertyDrawerAttributes = drawerType.GetCustomAttributes(typeof(CustomPropertyDrawer), true); - foreach (CustomPropertyDrawer customPropertyDrawer in customPropertyDrawerAttributes) + foreach (CustomPropertyDrawer attribute in drawerType.GetCustomAttributes(typeof(CustomPropertyDrawer), true)) { - var field = customPropertyDrawer.GetType().GetField("m_Type", BindingFlags.NonPublic | BindingFlags.Instance); - if (field != null) + var typeField = typeof(CustomPropertyDrawer).GetField("m_Type", BindingFlags.NonPublic | BindingFlags.Instance); + if (typeField?.GetValue(attribute) as Type != type) { - var fieldType = field.GetValue(customPropertyDrawer) as Type; - if (fieldType != null) - { - if (fieldType == type) - { - return drawerType; - } - - // If the property drawer also allows for being applied to child classes, check if they match - var useForChildrenField = customPropertyDrawer.GetType().GetField("m_UseForChildren", BindingFlags.NonPublic | BindingFlags.Instance); - if (useForChildrenField != null) - { - object useForChildrenValue = useForChildrenField.GetValue(customPropertyDrawer); - if (useForChildrenValue is bool && (bool)useForChildrenValue) - { - // Check interfaces - if (Array.Exists(interfaceTypes, interfaceType => interfaceType == fieldType)) - { - return drawerType; - } - - // Check derived types - Type baseType = type.BaseType; - while (baseType != null) - { - if (baseType == fieldType) - { - return drawerType; - } - - baseType = baseType.BaseType; - } - } - } - } + continue; + } + var childrenField = typeof(CustomPropertyDrawer).GetField("m_UseForChildren", BindingFlags.NonPublic | BindingFlags.Instance); + if (!requireUseForChildren || childrenField?.GetValue(attribute) is true) + { + return drawerType; } } } return null; } - } } diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Bindings.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Bindings.cs new file mode 100644 index 0000000..f534264 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Bindings.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + internal sealed partial class SubclassSelectorBody + { + private readonly SerializedObject source; + private readonly UnityEngine.Object[] targets; + + private static void UnbindSubtree (VisualElement root) + { + var elements = new List(); + CollectElements(root, elements); + for (int i = elements.Count - 1; i >= 0; i--) + { + elements[i].Unbind(); + } + } + + private static void CollectElements (VisualElement element, List elements) + { + elements.Add(element); + for (int i = 0; i < element.hierarchy.childCount; i++) + { + CollectElements(element.hierarchy[i], elements); + } + } + + private sealed class SourcePass : IDisposable + { + internal readonly SerializedObject[] Objects; + private readonly bool ownsObjects; + + internal SourcePass (SubclassSelectorBody body) + { + ownsObjects = body.targets.Length > 1; + Objects = new SerializedObject[body.targets.Length]; + for (int i = 0; i < Objects.Length; i++) + { + Objects[i] = ownsObjects ? new SerializedObject(body.targets[i], body.source.context) : body.source; + } + } + + public void Dispose () + { + if (ownsObjects) + { + foreach (SerializedObject value in Objects) + { + value?.Dispose(); + } + } + } + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Bindings.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Bindings.cs.meta new file mode 100644 index 0000000..cdda5ec --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Bindings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 28099a9661fc14daf9d0db99233644a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Native.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Native.cs new file mode 100644 index 0000000..3ddb874 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Native.cs @@ -0,0 +1,250 @@ +#if UNITY_6000_0_OR_NEWER +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + internal sealed partial class SubclassSelectorBody + { + private readonly List children = new List(); + private readonly string rootPath; + + internal SubclassSelectorBody (SerializedProperty root, VisualElement container) + { + source = root.serializedObject; + targets = source.targetObjects; + rootPath = root.propertyPath; + using (var pass = new SourcePass(this)) + { + using (SerializedProperty child = root.Copy()) + using (SerializedProperty end = root.GetEndProperty()) + { + bool enterChildren = true; + while (child.NextVisible(enterChildren) && !SerializedProperty.EqualContents(child, end)) + { + var view = new NativeChild(this, child, pass.Objects); + children.Add(view); + container.Add(view.View); + enterChildren = false; + } + } + } + } + + internal void PrepareBindings () + { + using (var pass = new SourcePass(this)) + { + foreach (NativeChild child in children) + { + child.Prepare(pass.Objects); + } + } + } + + internal void Refresh () + { + using (var pass = new SourcePass(this)) + { + foreach (NativeChild child in children) + { + child.Refresh(pass.Objects); + } + } + } + + internal void ReleaseBindings () + { + foreach (NativeChild child in children) + { + child.ReleaseBindings(); + } + } + + private struct ReferenceShape : IEquatable + { + internal string Path; + internal string TypeName; + internal long Id; + + public bool Equals (ReferenceShape other) => Path == other.Path && TypeName == other.TypeName && Id == other.Id; + } + + private sealed class Shape + { + private readonly bool exists; + private readonly SerializedPropertyType kind; + private readonly ReferenceShape[] references; + + private Shape (bool exists, SerializedPropertyType kind, ReferenceShape[] references) + { + this.exists = exists; + this.kind = kind; + this.references = references; + } + + internal static Shape Read (SerializedObject source, string path, string rootPath) + { + using (SerializedProperty property = source.FindProperty(path)) + { + if (property == null) + { + return new Shape(false, default, Array.Empty()); + } + var references = new List(); + var visited = new HashSet(); + // Another target can replace its root while this body remains attached. + using (SerializedProperty root = source.FindProperty(rootPath)) + { + if (root != null) + { + visited.Add(root.managedReferenceId); + } + } + SerializedPropertyType kind = property.propertyType; + // Include hidden serialized descendants: a custom drawer may expose them. + using (SerializedProperty end = property.GetEndProperty(true)) + { + bool enterChildren; + do + { + enterChildren = true; + if (property.propertyType == SerializedPropertyType.ManagedReference) + { + var reference = new ReferenceShape + { + Path = property.propertyPath, + TypeName = property.managedReferenceFullTypename, + Id = property.managedReferenceId + }; + references.Add(reference); + enterChildren = !string.IsNullOrEmpty(reference.TypeName) && visited.Add(reference.Id); + } + } + while (property.Next(enterChildren) && !SerializedProperty.EqualContents(property, end)); + } + return new Shape(true, kind, references.ToArray()); + } + } + + internal bool Matches (Shape other) + { + if (exists != other.exists || kind != other.kind || references.Length != other.references.Length) + { + return false; + } + for (int i = 0; i < references.Length; i++) + { + if (!references[i].Equals(other.references[i])) + { + return false; + } + } + return true; + } + } + + private sealed class NativeChild + { + private readonly SubclassSelectorBody owner; + private readonly string path; + private Shape[] shapes; + private bool dirty; + private bool released; + private PropertyField view; + + internal VisualElement View => view; + + internal NativeChild (SubclassSelectorBody owner, SerializedProperty property, SerializedObject[] objects) + { + this.owner = owner; + path = property.propertyPath; + Build(property, objects); + } + + private Shape[] Capture (SerializedObject[] objects) + { + var result = new Shape[objects.Length]; + for (int i = 0; i < result.Length; i++) + { + result[i] = Shape.Read(objects[i], path, owner.rootPath); + } + return result; + } + + private void Build (SerializedProperty property, SerializedObject[] objects) + { + shapes = Capture(objects); + dirty = false; + released = false; + view = new PropertyField(property.Copy()); + view.RegisterCallback(OnDetach); + } + + internal void Prepare (SerializedObject[] objects) + { + if (!dirty) + { + Shape[] current = Capture(objects); + for (int i = 0; i < current.Length; i++) + { + if (!shapes[i].Matches(current[i])) + { + ReleaseBindings(); + break; + } + } + } + if (dirty) + { + view.Clear(); + } + } + + internal void Refresh (SerializedObject[] objects) + { + if (!dirty || view.panel == null) + { + return; + } + PropertyField previous = view; + VisualElement container = previous.parent; + int index = container.IndexOf(previous); + using (SerializedProperty property = owner.source.FindProperty(path)) + { + if (property == null) + { + return; + } + Build(property, objects); + } + previous.UnregisterCallback(OnDetach); + previous.RemoveFromHierarchy(); + container.Insert(index, view); + view.Bind(owner.source); + } + + internal void ReleaseBindings () + { + dirty = true; + if (!released) + { + released = true; + UnbindSubtree(view); + } + } + + private void OnDetach (DetachFromPanelEvent evt) + { + if (evt.target == view) + { + ReleaseBindings(); + } + } + } + } +} +#endif diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Native.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Native.cs.meta new file mode 100644 index 0000000..cd86c8a --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.Native.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bc743fec95f8c4c9bbee6c276fe0aa95 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.cs new file mode 100644 index 0000000..62b0e12 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.cs @@ -0,0 +1,571 @@ +#if !UNITY_6000_0_OR_NEWER +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // Own the default subtree without taking over a custom drawer's internal hierarchy. + internal sealed partial class SubclassSelectorBody + { + private readonly List roots = new List(); + private readonly string rootPath; + + internal SubclassSelectorBody (SerializedProperty root, VisualElement container) + { + source = root.serializedObject; + targets = source.targetObjects; + rootPath = root.propertyPath; + using (var pass = new SourcePass(this)) + { + CreateChildren(root, root.managedReferenceValue.GetType(), null, container, roots, pass.Objects); + } + } + + internal void PrepareBindings () + { + using (var pass = new SourcePass(this)) + { + foreach (Node root in roots) + { + root.Prepare(pass.Objects); + } + } + } + + internal void Refresh () + { + using (var pass = new SourcePass(this)) + { + foreach (Node root in roots) + { + root.Refresh(pass.Objects); + } + } + } + + internal void ReleaseBindings () + { + foreach (Node root in roots) + { + root.ReleaseBindings(); + } + } + + private void CreateChildren (SerializedProperty property, Type type, Node parent, VisualElement container, List children, SerializedObject[] objects) + { + using (SerializedProperty child = property.Copy()) + using (SerializedProperty end = property.GetEndProperty()) + { + bool enterChildren = true; + while (child.NextVisible(enterChildren) && !SerializedProperty.EqualContents(child, end)) + { + FieldInfo field = GetField(type, child.name); + var node = new Node(this, parent, child.propertyPath, field, field?.FieldType, false); + children.Add(node); + node.Build(child, objects); + container.Add(node.View); + enterChildren = false; + } + } + } + + private static FieldInfo GetField (Type type, string name) + { + for (; type != null; type = type.BaseType) + { + FieldInfo field = type.GetField(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + if (field != null) + { + return field; + } + } + return null; + } + + private struct Observation : IEquatable + { + internal bool Exists; + internal SerializedPropertyType Kind; + internal string TypeName; + internal long Id; + internal int Size; + + internal static Observation Read (SerializedObject source, string path) + { + using (SerializedProperty property = source.FindProperty(path)) + { + if (property == null) + { + return default; + } + return new Observation + { + Exists = true, + Kind = property.propertyType, + TypeName = property.propertyType == SerializedPropertyType.ManagedReference ? property.managedReferenceFullTypename : null, + Id = property.propertyType == SerializedPropertyType.ManagedReference ? property.managedReferenceId : 0, + Size = property.isArray ? property.arraySize : -1 + }; + } + } + + public bool Equals (Observation other) + => Exists == other.Exists && Kind == other.Kind && TypeName == other.TypeName && Id == other.Id && Size == other.Size; + } + + private sealed class Node + { + private readonly SubclassSelectorBody owner; + private readonly Node parent; + private readonly string path; + private readonly FieldInfo field; + private readonly Type declaredType; + private readonly bool isElement; + private readonly List children = new List(); + private Observation[] observations; + private VisualElement view; + private Row row; + private ListView collectionView; + private IVisualElementScheduledItem rebuildRows; + private bool monitorsShape; + private bool reference; + private bool dirty; + private bool released; + private bool disposed; + private bool detached; + + internal VisualElement View => view; + private bool UsesRowRoot => row != null && ReferenceEquals(view, row); + + internal Node (SubclassSelectorBody owner, Node parent, string path, FieldInfo field, Type declaredType, bool isElement) + { + this.owner = owner; + this.parent = parent; + this.path = path; + this.field = field; + this.declaredType = declaredType; + this.isElement = isElement; + } + + internal void Build (SerializedProperty property, SerializedObject[] objects) + => Build(property, objects, null); + + private bool Build (SerializedProperty property, SerializedObject[] objects, Row row) + { + reference = property.propertyType == SerializedPropertyType.ManagedReference; + bool selector = field != null && field.IsDefined(typeof(SubclassSelectorAttribute), true); + bool collection = property.isArray && property.propertyType != SerializedPropertyType.String; + monitorsShape = reference && !selector; + observations = monitorsShape ? Capture(objects) : null; + Type groupType = reference ? (CommonReferenceType() ? property.managedReferenceValue?.GetType() : null) : declaredType; + // Unity's property context includes managed-reference base-drawer fallback; + // a type-only lookup cannot decide whether this child owns a custom drawer. + bool native = selector || field == null || property.isFixedBuffer + || (!collection && (UnityPropertyDrawerUtility.GetPropertyDrawer(property, 0) != null + || (!reference && property.propertyType != SerializedPropertyType.Generic))); + if (row != null && !row.PrepareMode(native)) + { + return false; + } + this.row = row; + dirty = false; + released = false; + + if (native) + { + view = CreateNative(property); + } + else if (collection) + { + monitorsShape = true; + observations = Capture(objects); + view = CreateCollection(property); + } + else + { + view = CreateGroup(property, groupType, objects); + } + view.RegisterCallback(OnDetach); + view.RegisterCallback(OnAttach); + return true; + } + + private Observation[] Capture (SerializedObject[] objects) + { + var result = new Observation[objects.Length]; + for (int i = 0; i < objects.Length; i++) + { + result[i] = Observation.Read(objects[i], path); + } + return result; + } + + private bool CommonReferenceType () + { + if (observations == null || observations.Length == 0 || string.IsNullOrEmpty(observations[0].TypeName)) + { + return false; + } + foreach (Observation value in observations) + { + if (!value.Exists || value.Kind != SerializedPropertyType.ManagedReference || value.TypeName != observations[0].TypeName) + { + return false; + } + } + return true; + } + + private bool IsCycle (SerializedObject[] objects) + { + if (!reference || observations == null) + { + return false; + } + for (int i = 0; i < observations.Length; i++) + { + long id = observations[i].Id; + if (id < 0) + { + continue; + } + // A different target can change its root ID without recreating this body. + using (SerializedProperty root = objects[i].FindProperty(owner.rootPath)) + { + if (root != null && id == root.managedReferenceId) + { + return true; + } + } + for (Node ancestor = parent; ancestor != null; ancestor = ancestor.parent) + { + if (ancestor.reference && ancestor.observations != null && ancestor.observations[i].Id == id) + { + return true; + } + } + } + return false; + } + + private VisualElement CreateNative (SerializedProperty property) + { + PropertyField result = row ?? new PropertyField(property.Copy()); + if (row != null) + { + result.bindingPath = property.propertyPath; + } + // An annotated scalar selector owns its decorators, and an array element + // must never repeat the collection field's decorators. + return isElement || (reference && field != null && field.IsDefined(typeof(SubclassSelectorAttribute), true)) + ? result : DecoratorGUI.CreateGUI(result, property); + } + + private VisualElement CreateGroup (SerializedProperty property, Type type, SerializedObject[] objects) + { + var group = new Foldout { bindingPath = path, text = property.displayName, tooltip = property.tooltip }; + NativePropertyFieldControls.ConfigureFoldout(group); + if (type != null && !IsCycle(objects)) + { + owner.CreateChildren(property, type, this, group, children, objects); + } + return isElement ? group : DecoratorGUI.CreateOwnedGUI(group, property); + } + + private VisualElement CreateCollection (SerializedProperty property) + { + bool reorderable = field == null || !field.IsDefined(typeof(NonReorderableAttribute), true); + var list = new ListView + { + bindingPath = path, + headerTitle = property.displayName, + tooltip = property.tooltip, + viewDataKey = "mackysoft-subclass-selector-" + path, + showBorder = true, + showAddRemoveFooter = true, + showBoundCollectionSize = true, + showFoldoutHeader = true, + virtualizationMethod = CollectionVirtualizationMethod.DynamicHeight, + showAlternatingRowBackgrounds = AlternatingRowBackground.None, + reorderable = reorderable, + reorderMode = reorderable ? ListViewReorderMode.Animated : ListViewReorderMode.Simple + }; + NativePropertyFieldControls.ConfigureListView(list); + Type elementType = declaredType.IsArray ? declaredType.GetElementType() : declaredType.GetGenericArguments()[0]; + list.makeItem = () => new Row(this); + list.bindItem = (element, index) => + { + var row = (Row)element; + row.Release(); + if (disposed || dirty || list.itemsSource == null || index < 0 || index >= list.itemsSource.Count) + { + dirty = !disposed; + return; + } + if (!(list.itemsSource[index] is SerializedProperty item)) + { + return; + } + string itemPath = item.propertyPath; + using (SerializedProperty current = owner.source.FindProperty(itemPath)) + { + if (current == null) + { + dirty = true; + return; + } + using (var pass = new SourcePass(owner)) + { + row.Assign(new Node(owner, this, itemPath, field, elementType, true), current, pass.Objects); + } + } + }; + list.unbindItem = (element, index) => ((Row)element).Release(); + list.destroyItem = element => ((Row)element).Release(); + list.itemsAdded += OnItemsChanged; + list.itemsRemoved += OnItemsChanged; + list.itemIndexChanged += OnItemIndexChanged; + collectionView = list; + return DecoratorGUI.CreateOwnedGUI(list, property); + } + + private void OnItemsChanged (IEnumerable indices) => InvalidateRows(); + private void OnItemIndexChanged (int sourceIndex, int destinationIndex) => InvalidateRows(); + + private void RequestRowsRebuild () + { + if (disposed || dirty || collectionView == null || rebuildRows != null) + { + return; + } + ListView list = collectionView; + // Bind/unbind callbacks can be traversing the active rows. Let the + // public controller recreate their native hosts after that traversal. + rebuildRows = list.schedule.Execute(() => + { + rebuildRows = null; + if (!disposed && !dirty && collectionView == list) + { + list.Rebuild(); + } + }); + } + + private void InvalidateRows () + { + dirty = true; + // The controller still owns its current itemsSource during this callback. + // Stop row binding before its immediate refresh; release the list later. + foreach (Node child in children.ToArray()) + { + child.ReleaseBindings(); + } + } + + internal void Prepare (SerializedObject[] objects) + { + if (disposed) + { + return; + } + if (!dirty && monitorsShape) + { + for (int i = 0; i < objects.Length; i++) + { + if (!observations[i].Equals(Observation.Read(objects[i], path))) + { + dirty = true; + break; + } + } + } + if (dirty) + { + ReleaseBindings(); + children.Clear(); + if (UsesRowRoot) + { + row.RequestRebuild(); + } + else + { + view.Clear(); + } + return; + } + for (int i = 0; i < children.Count; i++) + { + children[i].Prepare(objects); + } + } + + internal void Refresh (SerializedObject[] objects) + { + if (disposed || view.panel == null) + { + return; + } + if (dirty) + { + if (UsesRowRoot) + { + return; + } + ReleaseBindings(); + children.Clear(); + VisualElement previous = view; + VisualElement container = previous.parent; + int index = container.IndexOf(previous); + using (SerializedProperty property = owner.source.FindProperty(path)) + { + if (property == null) + { + return; + } + if (!Build(property, objects, row)) + { + return; + } + } + previous.UnregisterCallback(OnDetach); + previous.UnregisterCallback(OnAttach); + previous.RemoveFromHierarchy(); + container.Insert(index, view); + view.Bind(owner.source); + } + for (int i = 0; i < children.Count; i++) + { + children[i].Refresh(objects); + } + } + + internal void ReleaseBindings () + { + dirty = true; + rebuildRows?.Pause(); + rebuildRows = null; + if (released) + { + return; + } + released = true; + if (collectionView != null) + { + collectionView.itemsAdded -= OnItemsChanged; + collectionView.itemsRemoved -= OnItemsChanged; + collectionView.itemIndexChanged -= OnItemIndexChanged; + collectionView = null; + } + foreach (Node child in children.ToArray()) + { + child.ReleaseBindings(); + } + // A bound root can keep descendant trackers during an ordinary rebind. + // This subtree is being discarded, so release every registration first. + UnbindSubtree(view); + } + + private void Destroy () + { + disposed = true; + ReleaseBindings(); + children.Clear(); + view.UnregisterCallback(OnDetach); + view.UnregisterCallback(OnAttach); + if (!UsesRowRoot) + { + view.RemoveFromHierarchy(); + } + } + + private void OnDetach (DetachFromPanelEvent evt) + { + if (evt.target == view) + { + detached = true; + ReleaseBindings(); + } + } + + private void OnAttach (AttachToPanelEvent evt) + { + if (evt.target == view && detached && !disposed) + { + detached = false; + released = false; + dirty = true; + } + } + + private sealed class Row : PropertyField + { + private readonly Node collection; + private Node current; + private bool? nativeMode; + + internal Row (Node collection) + { + this.collection = collection; + } + + internal void Assign (Node node, SerializedProperty property, SerializedObject[] objects) + { + if (!node.Build(property, objects, this)) + { + return; + } + current = node; + collection.children.Add(node); + if (!ReferenceEquals(node.View, this)) + { + Add(node.View); + } + node.View.Bind(collection.owner.source); + } + + internal bool PrepareMode (bool native) + { + if (nativeMode.HasValue && nativeMode.Value != native) + { + // A native host can retain scheduled notifications and child + // caches. A different renderer needs a fresh host, not Clear(). + RequestRebuild(); + return false; + } + nativeMode = native; + return true; + } + + internal void RequestRebuild () => collection.RequestRowsRebuild(); + + internal void Release () + { + Node previous = current; + current = null; + if (previous != null) + { + collection.children.Remove(previous); + previous.Destroy(); + } + if (nativeMode == true) + { + // Match native ListView unbinding: its PropertyField owns + // cached children and must be allowed to reuse them on Bind. + this.Unbind(); + bindingPath = null; + } + else + { + Clear(); + } + } + } + } + } +} +#endif diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.cs.meta new file mode 100644 index 0000000..d6a2b39 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorBody.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ab606396d741d4958a692db708cca736 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs index 571d147..627f7d2 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorDrawer.cs @@ -3,6 +3,7 @@ using UnityEditor; using UnityEditor.IMGUI.Controls; using UnityEngine; +using UnityEngine.UIElements; namespace MackySoft.SerializeReferenceExtensions.Editor { @@ -36,22 +37,35 @@ private sealed class TypeChangeState private readonly Dictionary typePopups = new Dictionary(); private readonly Dictionary typeNameCaches = new Dictionary(); + public override VisualElement CreatePropertyGUI (SerializedProperty property) + { + if (property.propertyType != SerializedPropertyType.ManagedReference) + { + return new HelpBox(IsNotManagedReferenceLabel.text, HelpBoxMessageType.Error); + } + + // A drawer can be shared by several properties. Each binding owns its view and popup target. + var selector = new SubclassSelectorField(property, GetPropertyLabel(property), ((SubclassSelectorAttribute)attribute).UseToStringAsLabel, + UnityPropertyDrawerUtility.CaptureNextNestingLevel(property)); + return DecoratorGUI.CreateGUI(selector, property); + } + +#if UNITY_2023_2_OR_NEWER + private string GetPropertyLabel (SerializedProperty property) => preferredLabel ?? property.displayName; +#else + private string GetPropertyLabel (SerializedProperty property) => property.displayName; +#endif + public override void OnGUI (Rect position, SerializedProperty property, GUIContent label) { int controlID = GUIUtility.GetControlID(property.propertyPath.GetHashCode(), FocusType.Passive); var typeChangeState = (TypeChangeState)GUIUtility.GetStateObject(typeof(TypeChangeState), controlID); bool isManagedReference = property.propertyType == SerializedPropertyType.ManagedReference; -#if UNITY_2021_3_OR_NEWER // Prepare the label before BeginProperty captures it for rendering. - if (isManagedReference && ((SubclassSelectorAttribute)attribute).UseToStringAsLabel && !property.hasMultipleDifferentValues) + if (isManagedReference) { - object managedReferenceValue = property.managedReferenceValue; - if (managedReferenceValue != null) - { - label.text = managedReferenceValue.ToString(); - } + UpdatePropertyLabel(property, label); } -#endif EditorGUI.BeginProperty(position, label, property); try @@ -88,22 +102,7 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte // Draw the foldout. if (!string.IsNullOrEmpty(property.managedReferenceFullTypename)) { - Rect foldoutRect = new Rect(position); - foldoutRect.height = EditorGUIUtility.singleLineHeight; - -#if UNITY_2022_2_OR_NEWER && !UNITY_6000_0_OR_NEWER && !UNITY_2022_3 - // NOTE: Position x must be adjusted. - // FIXME: Is there a more essential solution...? - // The most promising is UI Toolkit, but it is currently unable to reproduce all of SubclassSelector features. (Complete provision of contextual menu, e.g.) - // 2021.3: No adjustment - // 2022.1: No adjustment - // 2022.2: Adjustment required - // 2022.3: Adjustment required - // 2023.1: Adjustment required - // 2023.2: Adjustment required - // 6000.0: No adjustment - foldoutRect.x -= 12; -#endif + Rect foldoutRect = GetFoldoutRect(position); property.isExpanded = EditorGUI.Foldout(foldoutRect, property.isExpanded, GUIContent.none, true); } @@ -152,11 +151,7 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte } } -#if UNITY_6000_0_OR_NEWER - // TextArea can defer its prefix label during Layout. Resolve it inside - // this drawer's IMGUI context before UI Toolkit starts another one. - GUIUtility.GetControlID(FocusType.Passive); -#endif + CompleteIMGUI(); } } else @@ -170,6 +165,38 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte } } +#if UNITY_2021_3_OR_NEWER + private void UpdatePropertyLabel (SerializedProperty property, GUIContent label) + { + if (((SubclassSelectorAttribute)attribute).UseToStringAsLabel && !property.hasMultipleDifferentValues) + { + object value = property.managedReferenceValue; + if (value != null) + { + label.text = value.ToString(); + } + } + } +#else + private void UpdatePropertyLabel (SerializedProperty property, GUIContent label) { } +#endif + +#if UNITY_2022_2_OR_NEWER && !UNITY_6000_0_OR_NEWER && !UNITY_2022_3 + private static Rect GetFoldoutRect (Rect position) => new Rect(position.x - 12, position.y, position.width, EditorGUIUtility.singleLineHeight); +#else + private static Rect GetFoldoutRect (Rect position) => new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); +#endif + +#if UNITY_6000_0_OR_NEWER + private static void CompleteIMGUI () + { + // Resolve TextArea's deferred prefix label before UI Toolkit starts another context. + GUIUtility.GetControlID(FocusType.Passive); + } +#else + private static void CompleteIMGUI () { } +#endif + private TypePopupCache GetTypePopup (SerializedProperty property) { // Cache this string. This property internally call Assembly.GetName, which result in a large allocation. @@ -194,25 +221,7 @@ private TypePopupCache GetTypePopup (SerializedProperty property) private static void SelectType (DeferredManagedReferenceTarget target, Type type, TypeChangeState changeState) { - if (target.TryExecute(properties => - { - for (int i = 0; i < properties.Length; i++) - { - Type fieldType = ManagedReferenceUtility.GetType(properties[i].managedReferenceFieldTypename.Replace('/', '+')); - if (!properties[i].editable || (type != null && (fieldType == null || !fieldType.IsAssignableFrom(type)))) - { - return false; - } - } - - var values = new object[properties.Length]; - for (int i = 0; i < properties.Length; i++) - { - values[i] = ManagedReferenceUtility.CreateManagedReference(properties[i].managedReferenceValue, type); - } - ManagedReferenceUtility.ApplyManagedReferences(properties, values, "Change Managed Reference Type", true); - return true; - })) + if (ManagedReferenceSelection.SelectType(target, type, null)) { changeState.Pending = true; } @@ -233,24 +242,7 @@ private GUIContent GetTypeName (SerializedProperty property) } Type type = ManagedReferenceUtility.GetType(managedReferenceFullTypename); - string typeName = null; - - AddTypeMenuAttribute typeMenu = TypeMenuUtility.GetAttribute(type); - if (typeMenu != null) - { - typeName = typeMenu.GetTypeNameWithoutPath(); - if (!string.IsNullOrWhiteSpace(typeName)) - { - typeName = ObjectNames.NicifyVariableName(typeName); - } - } - - if (string.IsNullOrWhiteSpace(typeName)) - { - typeName = ObjectNames.NicifyVariableName(type.Name); - } - - GUIContent result = new GUIContent(typeName); + GUIContent result = new GUIContent(TypeMenuUtility.GetTypeDisplayName(type)); typeNameCaches.Add(managedReferenceFullTypename, result); return result; } diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorField.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorField.cs new file mode 100644 index 0000000..f393838 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorField.cs @@ -0,0 +1,471 @@ +using System; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // One view per binding: ListView rebinds and Inspector rebuilds must not share a property iterator. + internal sealed class SubclassSelectorField : Foldout + { + private const string UssClassName = "mackysoft-subclass-selector"; + private readonly SerializedObject serializedObject; + private readonly string defaultLabel; + private readonly bool useToStringAsLabel; + private readonly string propertyPath; + private readonly int drawerNestingLevel; + private readonly Toggle header; + private readonly IVisualElementScheduledItem refresh; +#if !UNITY_6000_0_OR_NEWER + private BindableElement bindingGuard; +#endif +#if SERIALIZE_REFERENCE_EXTENSIONS_UNITY_2022_3_MR_TRACKER || UNITY_2023_2_OR_NEWER + private NativeManagedReferenceTracking nativeReferenceTracking; +#endif + + private SubclassSelectorBody body; + private SubclassSelectorHeader nativeHeader; + private VisualElement headerSlot; + + private AdvancedTypePopup typePopup; + private string displayedType; + private long displayedId; + private bool displayedMixedTypes; + private bool initialized; + + public override VisualElement contentContainer => headerSlot ?? base.contentContainer; + + internal SubclassSelectorField (SerializedProperty property, string label, bool useToStringAsLabel, int drawerNestingLevel) + { + serializedObject = property.serializedObject; + defaultLabel = label; + this.useToStringAsLabel = useToStringAsLabel; + this.drawerNestingLevel = drawerNestingLevel; + propertyPath = property.propertyPath; + bindingPath = propertyPath; + tooltip = property.tooltip; + AddToClassList(UssClassName); + pickingMode = PickingMode.Ignore; + this.RegisterValueChangedCallback(OnExpansionChanged); + FoldoutPropertyNotifications.Install(this); + header = this.Q(className: Foldout.toggleUssClassName); + NativePropertyFieldControls.ConfigureFoldout(this); + // Keep the actual native Toggle. Its binding and Inspector styles are + // established before the header is placed beside the native PopupField. + + RegisterCallback(OnAttach); + RegisterCallback(OnDetach); + Refresh(property, false); + // TrackSerializedObjectValue can retain a released updater during ListView recycling + // in 2021.3. Presentation updates belong to this view's scheduler instead. + refresh = schedule.Execute(UpdateView).Every(0); + } + + private void OnExpansionChanged (ChangeEvent evt) + { + // The native binding owns isExpanded. Ancestor custom drawers must not turn + // this view state into a serialized value-change notification. + if (evt.target is Foldout) + { + evt.StopPropagation(); + } + } + + private void OnAttach (AttachToPanelEvent evt) + { + EditorApplication.delayCall -= ReleaseHeader; + PrepareReferenceTracking(); + CreateChildBindingGuard(); + // Wait until Unity finishes dispatching Attach to the existing descendants before + // replacing them; their queued binding callbacks still refer to the old fields. + refresh.Resume(); + } + + private void OnDetach (DetachFromPanelEvent evt) + { + refresh.Pause(); + CancelReferenceTracking(); + ReleaseChildBindingGuard(); + // A reference can change shape while detached. Release child bindings before their + // property paths disappear, and rebuild them on the next attachment. + ReleaseDefaultBodyBindings(); + contentContainer.Unbind(); + initialized = false; + if (nativeHeader != null) + { + nativeHeader.style.display = DisplayStyle.None; + } + // A parent can be traversing its children during Detach. Release the + // owned header after that traversal, unless this view is reattached. + EditorApplication.delayCall -= ReleaseHeader; + EditorApplication.delayCall += ReleaseHeader; + } + + private void ReleaseHeader () + { + if (nativeHeader != null && (panel == null || nativeHeader.Host != GetFirstAncestorOfType())) + { + hierarchy.Insert(0, header); + nativeHeader.RemoveFromHierarchy(); + nativeHeader = null; + } + } + + private void EnsureHeader () + { + // Native Foldout binding discovers this physical Toggle and installs its + // current property menu before we move the prefix into its header slot. + if (!(header.userData is SerializedProperty)) + { + return; + } + PropertyField host = GetFirstAncestorOfType(); + if (host == null) + { + return; + } + if (nativeHeader?.Host != host) + { + nativeHeader?.RemoveFromHierarchy(); + nativeHeader = new SubclassSelectorHeader(host, header, ShowTypePopup, HasCurrentMenuProperty); + } + // PopupField belongs at the property's depth. Its actual BaseField + // owns the native label/input split, outside this body Foldout. + // Stay beside this Foldout inside any decorator wrapper. The wrapper's + // preceding native decorators must remain above the entire property row. + VisualElement container = hierarchy.parent; + int branchIndex = container.hierarchy.IndexOf(this); + if (nativeHeader.hierarchy.parent != container || container.hierarchy.IndexOf(nativeHeader) != branchIndex - 1) + { + // The same host can clear and reattach this view before delayed + // cleanup runs. Restore its renderer as well as its body. + nativeHeader.RemoveFromHierarchy(); + container.hierarchy.Insert(container.hierarchy.IndexOf(this), nativeHeader); + } + if (header.hierarchy.parent != nativeHeader) + { + // Native Add preserves Foldout as logical owner (including Simple + // ListView's direct-child selectors) while declaring its prefix slot. + headerSlot = nativeHeader; + try + { + Insert(0, header); + } + finally + { + headerSlot = null; + } + nativeHeader.RefreshInspectorStyle(); + } + } + + private bool HasCurrentMenuProperty () + { + if (panel == null || serializedObject.targetObject == null) + { + return false; + } + using (SerializedProperty property = serializedObject.FindProperty(propertyPath)) + { + return property != null && property.propertyType == SerializedPropertyType.ManagedReference; + } + } + +#if !UNITY_6000_0_OR_NEWER + private void CreateChildBindingGuard () + { + // Older Editors synchronize cached child properties in IBinding.Update. All + // PreUpdate calls run first, so invalidate old paths even if a busy scheduler + // postponed this view's presentation update. This binding is owned, never pooled. + bindingGuard = new BindableElement { binding = new ChildBindingGuard(this) }; + hierarchy.Add(bindingGuard); + } + + private void ReleaseChildBindingGuard () + { + // Unbind only releases Unity's SerializedObject bindings, not a custom IBinding. + bindingGuard.binding.Release(); + bindingGuard.binding = null; + bindingGuard.RemoveFromHierarchy(); + bindingGuard = null; + } + + private sealed class ChildBindingGuard : IBinding + { + private SubclassSelectorField owner; + + internal ChildBindingGuard (SubclassSelectorField owner) => this.owner = owner; + + public void PreUpdate () => owner?.PrepareChildBindings(); + public void Update () { } + public void Release () => owner = null; + } + + private void PrepareChildBindings () + { + if (!initialized || panel == null || serializedObject.targetObject == null) + { + return; + } + serializedObject.UpdateIfRequiredOrScript(); + using (SerializedProperty property = serializedObject.FindProperty(propertyPath)) + { + if (property == null) + { + this.Unbind(); + } + else if (property.managedReferenceFullTypename == displayedType && property.managedReferenceId == displayedId + && HasMixedTypes(property) == displayedMixedTypes) + { + PrepareDefaultBodyBindings(); + return; + } + // Do not add new bindings during Unity's traversal. The scheduler builds the new + // body; the old scalar bindings must be released before their next Update. + ReleaseDefaultBodyBindings(); + contentContainer.Unbind(); + contentContainer.Clear(); + initialized = false; + } + } +#else + private void CreateChildBindingGuard () { } + private void ReleaseChildBindingGuard () { } +#endif + +#if SERIALIZE_REFERENCE_EXTENSIONS_UNITY_2022_3_MR_TRACKER || UNITY_2023_2_OR_NEWER + private void PrepareReferenceTracking () + { + nativeReferenceTracking = new NativeManagedReferenceTracking(this); + } + + private void CancelReferenceTracking () + { + nativeReferenceTracking?.Dispose(); + nativeReferenceTracking = null; + } +#else + private void PrepareReferenceTracking () { } + private void CancelReferenceTracking () { } +#endif + + private void UpdateView () + { + if (panel == null || serializedObject.targetObject == null) + { + return; + } + EnsureHeader(); + serializedObject.UpdateIfRequiredOrScript(); + using (SerializedProperty property = serializedObject.FindProperty(propertyPath)) + { + if (property != null && property.propertyType == SerializedPropertyType.ManagedReference) + { + Refresh(property, true); + RefreshDefaultBody(); + } + else if (initialized) + { + // A removed array element can remain in the tree until ListView updates. + ReleaseDefaultBodyBindings(); + this.Unbind(); + contentContainer.Clear(); + initialized = false; + } + } + } + + private void Refresh (SerializedProperty property, bool rebind) + { + string typeName = property.managedReferenceFullTypename; + long id = property.managedReferenceId; + bool mixedTypes = HasMixedTypes(property); + object instance = property.managedReferenceValue; + // PropertyDrawer.preferredLabel was added after our minimum Editor version. + // Read the public host label as well, including an explicitly empty label. + PropertyField host = GetFirstAncestorOfType(); + string label = host != null && host.bindingPath == propertyPath && host.label != null ? host.label : defaultLabel; + text = useToStringAsLabel && !property.hasMultipleDifferentValues && instance != null + ? instance.ToString() : label; + if (nativeHeader != null) + { + nativeHeader.style.display = resolvedStyle.display; + nativeHeader.visible = visible; + nativeHeader.Refresh(property, text, mixedTypes, property.editable && enabledSelf, tooltip); + } + + if (initialized && displayedType == typeName && displayedId == id && displayedMixedTypes == mixedTypes) + { + return; + } + + ReleaseDefaultBodyBindings(); + contentContainer.Unbind(); + contentContainer.Clear(); + displayedType = typeName; + displayedId = id; + displayedMixedTypes = mixedTypes; + initialized = true; + + if (!mixedTypes && instance != null) + { + CreateBody(property); + } + + // The host binds the initial tree. Later replacements bind from the Foldout so custom + // drawers keep the parent property context for their relative binding paths. + if (rebind) + { + // Bind is synchronous for an attached root. Restore the real native + // Toggle for its menu lookup, then return it before the next layout. + hierarchy.Insert(0, header); + try + { + this.Bind(serializedObject); + } + finally + { + EnsureHeader(); + } + } + } + + private bool HasMixedTypes (SerializedProperty property) + { + if (!serializedObject.isEditingMultipleObjects || !property.hasMultipleDifferentValues) + { + return false; + } + + string typeName = property.managedReferenceFullTypename; + foreach (UnityEngine.Object target in serializedObject.targetObjects) + { + using (var individualObject = new SerializedObject(target)) + { + SerializedProperty individualProperty = individualObject.FindProperty(propertyPath); + if (individualProperty == null || individualProperty.managedReferenceFullTypename != typeName) + { + return true; + } + } + } + return false; + } + + private void CreateBody (SerializedProperty property) + { + PropertyDrawer drawer = UnityPropertyDrawerUtility.GetPropertyDrawer(property, drawerNestingLevel); + if (drawer != null) + { + // A dual drawer's OnGUI is the existing SubclassSelector contract. Use UITK for + // UITK-only drawers, and limit the IMGUI bridge to an existing drawer's body. + if (drawer.GetType().GetMethod(nameof(PropertyDrawer.OnGUI), new[] { typeof(Rect), typeof(SerializedProperty), typeof(GUIContent) }).DeclaringType == typeof(PropertyDrawer)) + { + var field = new PropertyField(property, text); + PropertyField host = GetFirstAncestorOfType(); + if (host != null) + { + // Initial construction is propagated by the host PropertyField. A body + // replaced later needs the same native parent context before binding. + UnityPropertyDrawerUtility.SetParentProperty(host, field); + } + Add(field); + return; + } + Add(new NativePropertyIMGUIField(property, text, drawerNestingLevel, this)); + return; + } + + CreateDefaultBody(property); + } + + private void CreateDefaultBody (SerializedProperty property) + { + body = new SubclassSelectorBody(property, this); + } + + private void PrepareDefaultBodyBindings () => body?.PrepareBindings(); + private void ReleaseDefaultBodyBindings () + { + body?.ReleaseBindings(); + body = null; + } +#if UNITY_6000_0_OR_NEWER + private void RefreshDefaultBody () + { + body?.PrepareBindings(); + body?.Refresh(); + } +#else + private void RefreshDefaultBody () => body?.Refresh(); +#endif + + private void ShowTypePopup () + { + if (panel == null || serializedObject.targetObject == null || !enabledInHierarchy + || nativeHeader == null || !nativeHeader.TypeField.enabledInHierarchy) + { + return; + } + using SerializedProperty property = serializedObject.FindProperty(propertyPath); + if (property == null || property.propertyType != SerializedPropertyType.ManagedReference) + { + return; + } + string fieldTypeName = property.managedReferenceFieldTypename; + DeferredManagedReferenceTarget target = DeferredManagedReferenceTarget.Capture(property); + if (target == null) + { + return; + } + Type baseType = ManagedReferenceUtility.GetType(fieldTypeName.Replace('/', '+')); + var types = TypeSearchService.TypeCandiateService.GetDisplayableTypes(baseType); + if (typePopup == null) + { + typePopup = new AdvancedTypePopup(types, 13, new AdvancedDropdownState()); + } + else + { + typePopup.SetTypes(types); + } + typePopup.ShowForSelection(nativeHeader.TypeField.Input.worldBound, item => SelectType(target, item.Type)); + } + + private void SelectType (DeferredManagedReferenceTarget target, Type type) + { + // A popup can outlive its Inspector or ListView row. + if (panel == null || serializedObject.targetObject == null) + { + return; + } + if (!ManagedReferenceSelection.SelectType(target, type, PrepareTypeChange)) + { + return; + } + using (SerializedProperty property = serializedObject.FindProperty(propertyPath)) + { + Refresh(property, true); + NotifyValueChanged(property); + } + } + + private void PrepareTypeChange () + { + ReleaseDefaultBodyBindings(); + contentContainer.Unbind(); + initialized = false; + } + + private void NotifyValueChanged (SerializedProperty property) + { + // PropertyField turns custom-drawer ChangeEvents into its public value-change callback. + using (var evt = ChangeEvent.GetPooled(null, property)) + { + evt.target = this; + SendEvent(evt); + } + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorField.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorField.cs.meta new file mode 100644 index 0000000..dbb8651 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorField.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cd00c23fb47c24aa3881db68e408bf28 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorHeader.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorHeader.cs new file mode 100644 index 0000000..0542915 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorHeader.cs @@ -0,0 +1,108 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // PopupField owns the line's flow and native input column. Foldout's actual + // Toggle owns the prefix; neither control supplies measurements to the other. + internal sealed class SubclassSelectorHeader : VisualElement + { + private static readonly Action UpdatePrefabStyle = BindPrefabStyle(); + private static readonly Action UpdateInspectorStyle = BindInspectorStyle(); + + private readonly Toggle toggle; + private readonly VisualElement checkmark; + + internal PropertyField Host { get; } + internal SubclassSelectorTypeField TypeField { get; } + + internal SubclassSelectorHeader (PropertyField host, Toggle toggle, Action showPopup, Func hasCurrentProperty) + { + Host = host; + this.toggle = toggle; + toggle.showMixedValue = false; + checkmark = toggle.Q(className: Toggle.checkmarkUssClassName); + pickingMode = PickingMode.Ignore; + style.justifyContent = Justify.Center; + TypeField = new SubclassSelectorTypeField(showPopup); + TypeField.SetValueWithoutNotify(TypeMenuUtility.NullDisplayName); + // The popup's real label region still participates in native alignment. + // The Foldout Toggle renders the visible prefix, including its arrow. + TypeField.labelElement.style.visibility = Visibility.Hidden; + TypeField.Input.AddManipulator(new FoldoutPropertyMenuManipulator(toggle, hasCurrentProperty)); + Add(TypeField); + toggle.style.position = Position.Absolute; + toggle.style.left = 0; + toggle.style.right = 0; + RegisterCallback(evt => RefreshInspectorStyle()); + } + + internal void RefreshInspectorStyle () => UpdateInspectorStyle?.Invoke(toggle); + + internal void Refresh (SerializedProperty property, string label, bool mixedTypes, bool enabled, string tooltip) + { + TypeField.label = label; + TypeField.tooltip = tooltip; + toggle.tooltip = tooltip; + TypeField.SetEnabled(enabled); + toggle.SetEnabled(enabled); + // Foldout's bool is expansion state. Mixed serialized child values must + // not replace its label or checkmark with Toggle's mixed-value content. + toggle.showMixedValue = false; + checkmark.style.visibility = mixedTypes || string.IsNullOrEmpty(property.managedReferenceFullTypename) + ? Visibility.Hidden : Visibility.Visible; + bool wasMixed = TypeField.showMixedValue; + TypeField.showMixedValue = mixedTypes; + string displayName = TypeMenuUtility.GetTypeDisplayName(property.managedReferenceValue?.GetType()); + // Clearing native mixed presentation does not restore its text. Recent + // PopupFields retain the value, so the transition also needs a refresh. + if (!mixedTypes && (wasMixed || TypeField.value != displayName)) + { + TypeField.SetValueWithoutNotify(displayName); + } + // Older bindings discover a Foldout header with a physical Q. + // This header is still the bound Toggle, but now occupies the prefix slot. + UpdatePrefabStyle(toggle, property); + } + + private static Action BindPrefabStyle () + { + Assembly assembly = typeof(PropertyField).Assembly; + Type helpers = assembly.GetType("UnityEditor.UIElements.BindingsStyleHelpers") + ?? assembly.GetType("UnityEditor.UIElements.Bindings.BindingsStyleHelpers"); + if (helpers == null) + { + // Some Editors put PropertyField in CoreModule while binding styles + // remain in UIElementsModule. Both are native Editor-owned assemblies. + Assembly bindings = Assembly.Load("UnityEditor.UIElementsModule"); + helpers = bindings.GetType("UnityEditor.UIElements.BindingsStyleHelpers") + ?? bindings.GetType("UnityEditor.UIElements.Bindings.BindingsStyleHelpers", true); + } + MethodInfo method = helpers.GetMethod("UpdatePrefabStateStyle", BindingFlags.Static | BindingFlags.NonPublic, + null, new[] { typeof(VisualElement), typeof(SerializedProperty) }, null) + ?? throw new MissingMethodException(helpers.FullName, "UpdatePrefabStateStyle"); + return (Action)Delegate.CreateDelegate(typeof(Action), method); + } + + private static Action BindInspectorStyle () + { + // Recent Foldout themes select Inspector styles by physical ancestry. + // Reapply the same native hook after its real Toggle changes header slots. + FieldInfo inspectorClass = typeof(Foldout).GetField("toggleInspectorUssClassNameUnique", BindingFlags.Static | BindingFlags.NonPublic); + if (inspectorClass == null) + { + return null; + } + Type utilities = typeof(Foldout).Assembly.GetType("UnityEngine.UIElements.VisualElementUtils", true); + MethodInfo method = utilities.GetMethod("AssignInspectorStyleIfNecessary", BindingFlags.Static | BindingFlags.NonPublic, + null, new[] { typeof(VisualElement), inspectorClass.FieldType }, null) + ?? throw new MissingMethodException(utilities.FullName, "AssignInspectorStyleIfNecessary"); + var element = Expression.Parameter(typeof(VisualElement), "element"); + return Expression.Lambda>(Expression.Call(method, element, Expression.Field(null, inspectorClass)), element).Compile(); + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorHeader.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorHeader.cs.meta new file mode 100644 index 0000000..e32ad9c --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorHeader.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 699b6f5abe3440d19921a07e225d9541 diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorTypeField.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorTypeField.cs new file mode 100644 index 0000000..1af2d69 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorTypeField.cs @@ -0,0 +1,69 @@ +using System; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Editor +{ + // Keep the native PopupField hierarchy and theme; replace only menu activation. + internal sealed class SubclassSelectorTypeField : PopupField + { + private readonly Action showPopup; + + internal VisualElement Input { get; } + + internal SubclassSelectorTypeField (Action showPopup) + { + this.showPopup = showPopup; + // Only the popup input picks. Its prefix belongs to the real Foldout + // Toggle, including Simple ListView arrows inside the popup root bounds. + pickingMode = PickingMode.Ignore; + AddToClassList(alignedFieldUssClassName); + labelElement.AddToClassList(PropertyField.labelUssClassName); + Input = this.Q(className: PopupField.inputUssClassName); + Input.AddToClassList(PropertyField.inputUssClassName); + Input.pickingMode = PickingMode.Position; + RegisterCallback(OnPointerDown, TrickleDown.TrickleDown); + RegisterCallback(OnPointerMove, TrickleDown.TrickleDown); + RegisterCallback(OnKeyDown, TrickleDown.TrickleDown); + RegisterCallback(OpenPopup, TrickleDown.TrickleDown); + } + + private void OnPointerDown (PointerDownEvent evt) + { + if (evt.button == (int)MouseButton.LeftMouse && Input.worldBound.Contains(evt.position)) + { + OpenPopup(evt); + } + } + + private void OnPointerMove (PointerMoveEvent evt) + { + if (evt.button == (int)MouseButton.LeftMouse && (evt.pressedButtons & 1) != 0 + && Input.worldBound.Contains(evt.position)) + { + OpenPopup(evt); + } + } + + private void OnKeyDown (KeyDownEvent evt) + { + if (evt.keyCode == KeyCode.Space || evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter) + { + OpenPopup(evt); + } + } + + private void OpenPopup (EventBase evt) + { + if (!enabledInHierarchy) + { + return; + } + evt.PreventDefault(); + evt.StopImmediatePropagation(); + Input.Focus(); + showPopup(); + } + } +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorTypeField.cs.meta b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorTypeField.cs.meta new file mode 100644 index 0000000..6ad38d9 --- /dev/null +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/SubclassSelectorTypeField.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 772d4e18925c240f1b91ec1c91403dd6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/TypeMenuUtility.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/TypeMenuUtility.cs index 497ca62..208affd 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/TypeMenuUtility.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/TypeMenuUtility.cs @@ -15,6 +15,17 @@ public static AddTypeMenuAttribute GetAttribute (Type type) return Attribute.GetCustomAttribute(type, typeof(AddTypeMenuAttribute)) as AddTypeMenuAttribute; } + internal static string GetTypeDisplayName (Type type) + { + if (type == null) + { + return NullDisplayName; + } + + string typeName = GetAttribute(type)?.GetTypeNameWithoutPath(); + return ObjectNames.NicifyVariableName(!string.IsNullOrWhiteSpace(typeName) ? typeName : type.Name); + } + public static string[] GetSplittedTypePath (Type type) { AddTypeMenuAttribute typeMenu = GetAttribute(type); @@ -56,4 +67,4 @@ public static IEnumerable OrderByType (this IEnumerable source) } } -} \ No newline at end of file +} diff --git a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs index ae5bc22..d75792e 100644 --- a/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs +++ b/Assets/MackySoft/MackySoft.SerializeReferenceExtensions/Editor/UnityPropertyDrawerUtility.cs @@ -1,40 +1,119 @@ using System; +using System.Collections.Generic; using System.Linq.Expressions; using System.Reflection; using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; namespace MackySoft.SerializeReferenceExtensions.Editor { internal static class UnityPropertyDrawerUtility { + private const BindingFlags NativeMembers = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + private static readonly Type UtilityType = typeof(EditorGUI).Assembly.GetType("UnityEditor.ScriptAttributeUtility", true); + private static readonly Type HandlerType = typeof(EditorGUI).Assembly.GetType("UnityEditor.PropertyHandler", true); + private static readonly MethodInfo GetHandler = RequireMethod(UtilityType, "GetHandler", typeof(SerializedProperty)); + private static readonly MethodInfo IncrementContext = RequireMethod(HandlerType, "IncrementNestingContext"); + private static readonly MethodInfo ApplyContext = RequireMethod(HandlerType, "ApplyNestingContext", typeof(int)); + private static readonly MethodInfo DisposeContext = RequireMethod(ApplyContext.ReturnType, nameof(IDisposable.Dispose)); + private static readonly FieldInfo NestingLevel = HandlerType.GetField("m_NestingLevel", NativeMembers) + ?? throw new MissingFieldException(HandlerType.FullName, "m_NestingLevel"); + private static readonly PropertyInfo Drawer = HandlerType.GetProperty("propertyDrawer", NativeMembers) + ?? throw new MissingMemberException(HandlerType.FullName, "propertyDrawer"); + private static readonly Func HasNextDrawer = BindHasNextDrawer(); + private static readonly Func NextLevel = BindNextLevel(); + private static readonly Func DrawerAtLevel = BindDrawerAtLevel(); +#if !UNITY_6000_0_OR_NEWER + private static readonly Func> Decorators = BindDecorators(); +#endif + private static readonly Action PropagateParent = BindPropagateParent(); internal static bool HasNextPropertyDrawer (SerializedProperty property) { return HasNextDrawer(property); } + // Capture while Unity is invoking this selector's CreatePropertyGUI. Later refreshes + // must enter that same native level, including selectors preceded by another drawer. + internal static int CaptureNextNestingLevel (SerializedProperty property) => NextLevel(property); + + internal static PropertyDrawer GetPropertyDrawer (SerializedProperty property, int nestingLevel) + => DrawerAtLevel(property, nestingLevel); + +#if !UNITY_6000_0_OR_NEWER + // PropertyHandler owns ordering and collection-element exclusions. Capture + // its actual drawers for this view instead of rediscovering field attributes. + internal static DecoratorDrawer[] GetDecoratorDrawers (SerializedProperty property) + => Decorators(property)?.ToArray() ?? Array.Empty(); +#endif + + internal static void SetParentProperty (PropertyField parent, PropertyField child) => PropagateParent(parent, child); + private static Func BindHasNextDrawer () { - Assembly editorAssembly = typeof(EditorGUI).Assembly; - Type utilityType = editorAssembly.GetType("UnityEditor.ScriptAttributeUtility", true); - Type handlerType = editorAssembly.GetType("UnityEditor.PropertyHandler", true); - MethodInfo getHandler = utilityType.GetMethod("GetHandler", BindingFlags.Static | BindingFlags.NonPublic, - null, new[] { typeof(SerializedProperty) }, null); - PropertyInfo hasDrawer = handlerType.GetProperty("hasPropertyDrawer", BindingFlags.Instance | BindingFlags.Public); - if (getHandler == null) - { - throw new MissingMethodException(utilityType.FullName, "GetHandler"); - } + PropertyInfo hasDrawer = HandlerType.GetProperty("hasPropertyDrawer", NativeMembers); if (hasDrawer == null) { - throw new MissingMemberException(handlerType.FullName, "hasPropertyDrawer"); + throw new MissingMemberException(HandlerType.FullName, "hasPropertyDrawer"); } // Unity's current nesting level determines the next drawer and keeps its context and lifetime native-owned. var property = Expression.Parameter(typeof(SerializedProperty), "property"); return Expression.Lambda>( - Expression.Property(Expression.Call(getHandler, property), hasDrawer), property).Compile(); + Expression.Property(Expression.Call(GetHandler, property), hasDrawer), property).Compile(); + } + + private static Func BindNextLevel () + { + var property = Expression.Parameter(typeof(SerializedProperty), "property"); + return BindNativeCall>(new[] { property }, + handler => Expression.Call(handler, IncrementContext), + handler => Expression.Field(handler, NestingLevel)); + } + + private static Func BindDrawerAtLevel () + { + var property = Expression.Parameter(typeof(SerializedProperty), "property"); + var level = Expression.Parameter(typeof(int), "nestingLevel"); + return BindNativeCall>(new[] { property, level }, + handler => Expression.Call(handler, ApplyContext, level), + handler => Expression.Property(handler, Drawer)); } + +#if !UNITY_6000_0_OR_NEWER + private static Func> BindDecorators () + { + FieldInfo decorators = HandlerType.GetField("m_DecoratorDrawers", NativeMembers) + ?? throw new MissingFieldException(HandlerType.FullName, "m_DecoratorDrawers"); + var property = Expression.Parameter(typeof(SerializedProperty), "property"); + return Expression.Lambda>>( + Expression.Field(Expression.Call(GetHandler, property), decorators), property).Compile(); + } +#endif + + private static T BindNativeCall (ParameterExpression[] parameters, + Func enterContext, Func operation) where T : Delegate + { + var handler = Expression.Variable(HandlerType, "handler"); + var context = Expression.Variable(ApplyContext.ReturnType, "context"); + return Expression.Lambda(Expression.Block(new[] { handler, context }, + Expression.Assign(handler, Expression.Call(GetHandler, parameters[0])), + Expression.Assign(context, enterContext(handler)), + Expression.TryFinally(operation(handler), Expression.Call(context, DisposeContext))), parameters).Compile(); + } + + private static Action BindPropagateParent () + { + var parent = Expression.Parameter(typeof(PropertyField), "parent"); + var child = Expression.Parameter(typeof(VisualElement), "child"); + MethodInfo propagate = RequireMethod(typeof(PropertyField), "PropagateNestingLevel", typeof(VisualElement)); + return Expression.Lambda>(Expression.Call(parent, propagate, child), parent, child).Compile(); + } + + private static MethodInfo RequireMethod (Type type, string name, params Type[] parameters) + => type.GetMethod(name, NativeMembers, null, parameters, null) + ?? throw new MissingMethodException(type.FullName, name); } } diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs index e9c1cbe..10fa6d2 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/ContextualNodeDrawer.cs @@ -9,8 +9,8 @@ namespace MackySoft.SerializeReferenceExtensions.Tests [CustomPropertyDrawer(typeof(DrawerContextTestAsset.StatefulNode))] public sealed class ContextualNodeDrawer : PropertyDrawer, IDisposable { - public static readonly List<(int TargetId, string PropertyPath)> DisposedContexts = new List<(int, string)>(); - private int observedTargetId; + public static readonly List<(UnityEngine.Object Target, string PropertyPath)> DisposedContexts = new List<(UnityEngine.Object, string)>(); + private UnityEngine.Object observedTarget; private string observedPath; private int edits; @@ -27,7 +27,7 @@ public override void OnGUI (Rect position, SerializedProperty property, GUIConte { if (observedPath == null) { - observedTargetId = property.serializedObject.targetObject.GetInstanceID(); + observedTarget = property.serializedObject.targetObject; observedPath = property.propertyPath; } @@ -61,7 +61,7 @@ public void Dispose () IsDisposed = true; if (observedPath != null) { - DisposedContexts.Add((observedTargetId, observedPath)); + DisposedContexts.Add((observedTarget, observedPath)); } } } diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs index 5f9c567..ed9f000 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/InspectorDrawing.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Linq; +using System.Linq.Expressions; using System.Reflection; using NUnit.Framework; using UnityEditor; @@ -14,15 +15,66 @@ internal readonly struct DrawnContent { public string Text { get; } public Rect Rect { get; } + public string StyleName { get; } + public string Tooltip { get; } - public DrawnContent (string text, Rect rect) + public DrawnContent (string text, Rect rect, string styleName, string tooltip) { Text = text; Rect = rect; + StyleName = styleName; + Tooltip = tooltip; } } public static DrawnContent[] Capture (SelectorInspectorWindow window) + => CaptureAll(window).Where(item => !string.IsNullOrEmpty(item.Text)).ToArray(); + + public static (string Text, bool IsBold)[] CaptureText (EditorWindow window) + { + // A saved GUIStyle does not retain the default-font flag restored by EndProperty. + // Observe the actual text draw before Unity renders it, without suppressing drawing. + const BindingFlags flags = BindingFlags.Static | BindingFlags.NonPublic; + FieldInfo drawHandler = typeof(GUIStyle).GetField("onDraw", flags); + MethodInfo boldFont = typeof(EditorGUIUtility).GetMethod("GetBoldDefaultFont", flags); + Assert.That(drawHandler, Is.Not.Null, "Unity GUI observation adapter: text draw callback unavailable."); + Assert.That(boldFont, Is.Not.Null, "Unity GUI observation adapter: default-font state unavailable."); + var original = (Delegate)drawHandler.GetValue(null); + var drawn = new System.Collections.Generic.List<(string Text, bool IsBold)>(); + Action observe = (style, content) => + { + if (!string.IsNullOrEmpty(content.text)) + { + bool bold = (bool)boldFont.Invoke(null, null) || style.fontStyle == FontStyle.Bold + || style.fontStyle == FontStyle.BoldAndItalic; + drawn.Add((content.text, bold)); + } + }; + // The last callback argument is an internal Unity struct. Match its actual + // delegate signature instead of copying that type into the test assembly. + ParameterExpression[] parameters = drawHandler.FieldType.GetMethod("Invoke").GetParameters() + .Select(parameter => Expression.Parameter(parameter.ParameterType, parameter.Name)).ToArray(); + Expression paint = original == null ? (Expression)Expression.Constant(false) + : Expression.Invoke(Expression.Constant(original, drawHandler.FieldType), parameters); + // Unity's StylePainter may render text itself and return true. Observe + // before that painter as well, then preserve its original return value. + var callback = Expression.Lambda(drawHandler.FieldType, Expression.Block( + Expression.Invoke(Expression.Constant(observe), parameters[0], parameters[2]), paint), + parameters).Compile(); + drawHandler.SetValue(null, callback); + try + { + window.SendEvent(new Event { type = EventType.Layout }); + window.SendEvent(new Event { type = EventType.Repaint }); + return drawn.ToArray(); + } + finally + { + drawHandler.SetValue(null, original); + } + } + + public static DrawnContent[] CaptureAll (EditorWindow window, bool unclipped = false) { // Unity has no public API for reading rendered IMGUI text. Adapt its GUI debugger // here; package drawers and their registrations execute through PropertyField. @@ -37,7 +89,8 @@ public static DrawnContent[] Capture (SelectorInspectorWindow window) debugger.GetMethod("DebugWindow", flags).Invoke(null, new[] { view }); try { - window.DrawNow(); + window.SendEvent(new Event { type = EventType.Layout }); + window.SendEvent(new Event { type = EventType.Repaint }); Type instructionType = editorAssembly.GetType("UnityEditor.IMGUIDrawInstruction", true); Type listType = typeof(System.Collections.Generic.List<>).MakeGenericType(instructionType); var instructions = (IList)Activator.CreateInstance(listType); @@ -49,9 +102,30 @@ public static DrawnContent[] Capture (SelectorInspectorWindow window) getInstructions.Invoke(null, arguments); FieldInfo contentField = instructionType.GetField("usedGUIContent", flags); FieldInfo rectField = instructionType.GetField("rect", flags); + FieldInfo styleField = instructionType.GetField("usedGUIStyle", flags); + var viewRects = new System.Collections.Generic.Dictionary(); + if (unclipped) + { + // Raw draw rectangles belong to separate GUIClip contexts. Unity's + // unified debugger records the actual GUIView-space rectangle. + Type unifiedType = editorAssembly.GetType("UnityEditor.IMGUIInstruction", true); + var unified = (IList)Activator.CreateInstance(typeof(System.Collections.Generic.List<>).MakeGenericType(unifiedType)); + MethodInfo getUnified = debugger.GetMethod("GetUnifiedInstructions", flags); + getUnified.Invoke(null, getUnified.GetParameters().Length == 1 + ? new object[] { unified } : new object[] { unified, false }); + foreach (object instruction in unified) + { + if (unifiedType.GetField("type", flags).GetValue(instruction).ToString() == "kStyleDraw") + { + int index = (int)unifiedType.GetField("typeInstructionIndex", flags).GetValue(instruction); + viewRects[index] = (Rect)unifiedType.GetField("unclippedRect", flags).GetValue(instruction); + } + } + } return instructions.Cast() - .Select(item => new DrawnContent(((GUIContent)contentField.GetValue(item)).text, (Rect)rectField.GetValue(item))) - .Where(item => !string.IsNullOrEmpty(item.Text)) + .Select((item, index) => new DrawnContent(((GUIContent)contentField.GetValue(item)).text, + unclipped ? viewRects[index] : (Rect)rectField.GetValue(item), ((GUIStyle)styleField.GetValue(item)).name, + ((GUIContent)contentField.GetValue(item)).tooltip)) .ToArray(); } finally diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Tests.Editor.asmdef b/Assets/Tests/SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Tests.Editor.asmdef index 8205358..217ba56 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Tests.Editor.asmdef +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/MackySoft.SerializeReferenceExtensions.Tests.Editor.asmdef @@ -5,7 +5,8 @@ "UnityEngine.TestRunner", "UnityEditor.TestRunner", "MackySoft.SerializeReferenceExtensions", - "MackySoft.SerializeReferenceExtensions.Editor" + "MackySoft.SerializeReferenceExtensions.Editor", + "MackySoft.SerializeReferenceExtensions.Tests.Fixtures" ], "includePlatforms": [ "Editor" @@ -22,4 +23,4 @@ ], "versionDefines": [], "noEngineReferences": false -} \ No newline at end of file +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs index 58a37d4..cc02445 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyChildTraversalTests.cs @@ -27,12 +27,11 @@ public void TearDown () } } - [TestCase(false)] - [TestCase(true)] - public void CyclicReferences_ReturnTheRequestedFiniteDepth (bool mutual) + [Test] + public void MutualReferences_ReturnTheRequestedFiniteDepth () { var first = new Asset.Cycle(); - var second = mutual ? new Asset.Cycle() : first; + var second = new Asset.Cycle(); first.next = second; second.next = first; target.reference = first; @@ -51,6 +50,34 @@ public void CyclicReferences_ReturnTheRequestedFiniteDepth (bool mutual) } } + [Test] + public void SelfReference_StaysWithinTheRequestedDepthAndPreservesTheParent () + { + var node = new Asset.Cycle(); + node.next = node; + target.reference = node; + using (var serialized = new SerializedObject(target)) + using (SerializedProperty parent = serialized.FindProperty("reference")) + { + var paths = new HashSet(); + // Native NextVisible can suppress re-entry into the same managed + // reference. Maximum depth does not require that re-entry to occur. + foreach (SerializedProperty child in parent.GetChildProperties(2)) + { + using (child) + { + Assert.That(child.depth, Is.InRange(parent.depth + 1, parent.depth + 2)); + Assert.That(paths.Add(child.propertyPath), Is.True, "Each returned property must be distinct."); + } + } + Assert.That(paths, Does.Contain("reference.visibleValue")); + Assert.That(paths, Does.Contain("reference.next")); + Assert.That(paths, Does.Contain("reference.tailValue")); + Assert.That(parent.propertyPath, Is.EqualTo("reference")); + Assert.That(parent.managedReferenceValue, Is.SameAs(node)); + } + } + [Test] public void DirectChildren_ClosedCollectionDoesNotEnterCyclicElements () { diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyContextMenu.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyContextMenu.cs index ffc0598..a2fd6ad 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyContextMenu.cs +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SerializedPropertyContextMenu.cs @@ -1,8 +1,10 @@ +using System; using System.Collections; using System.Reflection; using NUnit.Framework; using UnityEditor; using UnityEngine; +using UnityEngine.UIElements; namespace MackySoft.SerializeReferenceExtensions.Tests { @@ -46,6 +48,79 @@ public static void Execute (GenericMenu menu, string label) } } + public static IDisposable CaptureNativeRequests (VisualElement root, Action observe) + { + return new NativeRequestCapture(root, observe); + } + +#if UNITY_2022_2_OR_NEWER + public static void RequestFromUi (VisualElement input) + { + var systemEvent = new Event { type = EventType.MouseUp, button = 1, mousePosition = input.worldBound.center }; + using (var evt = PointerUpEvent.GetPooled(systemEvent)) + { + evt.target = input; + input.SendEvent(evt); + } + } +#else + public static void RequestFromUi (VisualElement input) + { + var systemEvent = new Event { type = EventType.MouseUp, button = 1, mousePosition = input.worldBound.center }; + using (var evt = MouseUpEvent.GetPooled(systemEvent)) + { + evt.target = input; + input.SendEvent(evt); + } + } +#endif + + private sealed class NativeRequestCapture : IDisposable + { + private readonly VisualElement root; + private readonly Action observe; + + internal NativeRequestCapture (VisualElement root, Action observe) + { + this.root = root; + this.observe = observe; + Register(); + } + +#if UNITY_2022_2_OR_NEWER + private void Register () => root.RegisterCallback(OnPointerUp, TrickleDown.TrickleDown); + public void Dispose () => root.UnregisterCallback(OnPointerUp, TrickleDown.TrickleDown); + private void OnPointerUp (PointerUpEvent evt) => Capture(evt, evt.button); +#else + private void Register () => root.RegisterCallback(OnMouseUp, TrickleDown.TrickleDown); + public void Dispose () => root.UnregisterCallback(OnMouseUp, TrickleDown.TrickleDown); + private void OnMouseUp (MouseUpEvent evt) => Capture(evt, evt.button); +#endif + + private void Capture (EventBase evt, int button) + { + // Unity's binding stores the current property on its native menu receiver: + // a label in older Editors, or the containing BaseField in recent Editors. + // Observe that handoff before the native callback presents an OS menu; + // menu presentation and command execution have separate GUI coverage. + if (button != (int)MouseButton.RightMouse || !(evt.target is VisualElement element)) + { + return; + } + for (VisualElement receiver = element; receiver != null && receiver != root; receiver = receiver.hierarchy.parent) + { + if (receiver.userData is SerializedProperty property + && (receiver == element || receiver.ClassListContains(BaseField.ussClassName))) + { + evt.PreventDefault(); + evt.StopImmediatePropagation(); + observe(property); + return; + } + } + } + } + private static object FindItem (GenericMenu menu, string label) { foreach (object item in GetField(menu, "m_MenuItems")) diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorChangeCheckTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorChangeCheckTests.cs index bb1a212..b94769c 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorChangeCheckTests.cs +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorChangeCheckTests.cs @@ -1,10 +1,8 @@ using System; using System.Collections; using System.Linq; -using System.Reflection; using NUnit.Framework; using UnityEditor; -using UnityEditor.IMGUI.Controls; using UnityEngine; using UnityEngine.TestTools; @@ -49,7 +47,7 @@ public void TearDown () public IEnumerator SelectingType_NotifiesTheOwningPropertyChangeCheck () { OpenPopup(1); - SelectItem("Beta"); + TypePopupInteraction.SelectVisibleItem(popup, "Beta"); yield return null; window.DrawNow(); @@ -63,7 +61,7 @@ public IEnumerator SelectingType_NotifiesTheOwningPropertyChangeCheck () public IEnumerator SelectingNull_NotifiesTheOwningPropertyChangeCheck () { OpenPopup(1); - SelectItem(""); + TypePopupInteraction.SelectVisibleItem(popup, ""); yield return null; window.DrawNow(); @@ -102,30 +100,6 @@ private void OpenPopup (int index) Assert.That(popup, Is.Not.Null, "Clicking the property selector must open its type popup."); } - private void SelectItem (string name) - { - // Unity exposes popup input through SendEvent, but its visible rows and keyboard selection are internal. - // Read those solely to navigate the real control; do not invoke the drawer's selection callback. - const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; - var tree = (AdvancedDropdownItem)popup.GetType().GetField("m_CurrentlyRenderedTree", flags).GetValue(popup); - object state = popup.GetType().GetField("m_State", flags).GetValue(popup); - MethodInfo getSelectedIndex = state.GetType().GetMethod("GetSelectedIndex", flags); - AdvancedDropdownItem[] rows = tree.children.ToArray(); - int targetIndex = Array.FindIndex(rows, row => row.name == name); - Assert.That(targetIndex, Is.GreaterThanOrEqualTo(0), "Popup item missing: " + name); - - for (int step = 0; step <= rows.Length; step++) - { - if ((int)getSelectedIndex.Invoke(state, new object[] { tree }) == targetIndex) - { - popup.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.Return }); - return; - } - popup.SendEvent(new Event { type = EventType.KeyDown, keyCode = KeyCode.DownArrow }); - } - Assert.Fail("Keyboard navigation did not reach popup item: " + name); - } - public interface IValue { } [Serializable, AddTypeMenu("Alpha")] diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs index ba15cf3..d72f505 100644 --- a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorDrawerContextTests.cs @@ -56,6 +56,7 @@ public void TearDown () UnityEngine.Object.DestroyImmediate(targets[i]); } } + ContextualNodeDrawer.DisposedContexts.Clear(); } [Test] @@ -85,14 +86,14 @@ public void NativeEditorDisposal_ReleasesSelectorAndOrdinaryCustomDrawerResource { AssertContext(0, "native", "Native reference", 0); AssertContext(0, "primary", "Primary selector", 0); - int hostId = targets[0].GetInstanceID(); - Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "native"), Is.False); - Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "primary"), Is.False); + UnityEngine.Object host = targets[0]; + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.Target == host && context.PropertyPath == "native"), Is.False); + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.Target == host && context.PropertyPath == "primary"), Is.False); ReleaseEditor(0); - Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "native"), Is.True); - Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.TargetId == hostId && context.PropertyPath == "primary"), Is.True); + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.Target == host && context.PropertyPath == "native"), Is.True); + Assert.That(ContextualNodeDrawer.DisposedContexts.Any(context => context.Target == host && context.PropertyPath == "primary"), Is.True); } [Test] diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorOnGUIComparisonEditor.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorOnGUIComparisonEditor.cs new file mode 100644 index 0000000..968cdc5 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorOnGUIComparisonEditor.cs @@ -0,0 +1,24 @@ +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine.UIElements; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + // Selected explicitly so unrelated Inspectors keep their normal editor. + internal sealed class SubclassSelectorOnGUIComparisonEditor : UnityEditor.Editor + { + internal string NativePath { get; set; } + internal string SelectorPath { get; set; } + internal string[] Paths { get; set; } + + public override VisualElement CreateInspectorGUI () + { + var root = new VisualElement(); + foreach (string path in Paths ?? new[] { NativePath, SelectorPath }) + { + root.Add(new PropertyField(serializedObject.FindProperty(path))); + } + return root; + } + } +} diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorOnGUIComparisonEditor.cs.meta b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorOnGUIComparisonEditor.cs.meta new file mode 100644 index 0000000..29a2bb0 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorOnGUIComparisonEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f3172256065646c6ba66a5b009d1cd5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorPrefabUIToolkitTests.cs b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorPrefabUIToolkitTests.cs new file mode 100644 index 0000000..de2aa53 --- /dev/null +++ b/Assets/Tests/SerializeReferenceExtensions/Editor/SubclassSelectorPrefabUIToolkitTests.cs @@ -0,0 +1,461 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.UIElements; +using UnityEngine; +using UnityEngine.TestTools; +using UnityEngine.UIElements; +using Component = MackySoft.SerializeReferenceExtensions.Tests.SubclassSelectorPrefabTestComponent; + +namespace MackySoft.SerializeReferenceExtensions.Tests +{ + public sealed class SubclassSelectorPrefabUIToolkitTests + { + private TestWindow window; + private GameObject instance; + private GameObject source; + private SerializedObject boundObject; + private string prefabPath; + private UnityEditor.Editor inspectorEditor; + private string nativeControlPath; + + [TearDown] + public void TearDown () + { + if (window != null) + { + window.rootVisualElement.Unbind(); + window.rootVisualElement.Clear(); + window.Close(); + } + boundObject?.Dispose(); + if (instance != null) UnityEngine.Object.DestroyImmediate(instance); + if (source != null) UnityEngine.Object.DestroyImmediate(source); + if (prefabPath != null) AssetDatabase.DeleteAsset(prefabPath); + if (inspectorEditor != null) UnityEngine.Object.DestroyImmediate(inspectorEditor); + } + + [UnityTest] + public IEnumerator PrefabMenu_ForwardsToTheCurrentNativeReferenceBeforeAndAfterApplyAndRevert () + { + source = new GameObject("Subclass selector menu Prefab"); + source.AddComponent(); + prefabPath = AssetDatabase.GenerateUniqueAssetPath("Assets/SubclassSelectorMenuUIToolkitTest.prefab"); + GameObject prefab = PrefabUtility.SaveAsPrefabAsset(source, prefabPath); + UnityEngine.Object.DestroyImmediate(source); + source = null; + instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab); + Component component = instance.GetComponent(); + using (var edit = new SerializedObject(component)) + { + edit.FindProperty("reference").managedReferenceValue = new Component.SecondValue { number = 91 }; + edit.ApplyModifiedProperties(); + } + boundObject = new SerializedObject(component); + boundObject.FindProperty("reference").isExpanded = true; + var host = new PropertyField(boundObject.FindProperty("reference")); + inspectorEditor = UnityEditor.Editor.CreateEditor(component, typeof(SubclassSelectorOnGUIComparisonEditor)); + ((SubclassSelectorOnGUIComparisonEditor)inspectorEditor).Paths = Array.Empty(); + var inspector = new InspectorElement(inspectorEditor); + inspector.Add(host); + window = ScriptableObject.CreateInstance(); + window.position = new Rect(100, 100, 520, 500); + window.Show(); + window.rootVisualElement.Add(inspector); + host.Bind(boundObject); + + var requests = new List<(string path, UnityEngine.Object target, long id)>(); + void Observe (SerializedProperty property) => requests.Add((property.propertyPath, + property.serializedObject.targetObject, property.propertyType == SerializedPropertyType.ManagedReference ? property.managedReferenceId : -1)); + using (SerializedPropertyContextMenu.CaptureNativeRequests(window.rootVisualElement, Observe)) + { + for (int stage = 0; stage < 4; stage++) + { + if (stage == 1) + { + using (var edit = new SerializedObject(component)) + { + PrefabUtility.ApplyPropertyOverride(edit.FindProperty("reference"), prefabPath, InteractionMode.AutomatedAction); + } + } + else if (stage == 2) + { + using (var edit = new SerializedObject(component)) + { + edit.FindProperty("reference").managedReferenceValue = new Component.FirstValue { number = 97 }; + edit.ApplyModifiedProperties(); + } + } + else if (stage == 3) + { + using (var edit = new SerializedObject(component)) + { + PrefabUtility.RevertPropertyOverride(edit.FindProperty("reference"), InteractionMode.AutomatedAction); + } + } + // A directly bound scalar does not own its parent's reference lifetime. + // Build the control from the current property after each native operation. + boundObject.UpdateIfRequiredOrScript(); + var nativeControl = new PropertyField(boundObject.FindProperty("reference.number"), "Native menu control"); + inspector.Add(nativeControl); + nativeControl.Bind(boundObject); + var current = Read(component); + var savedPrefab = Read(prefab.GetComponent()); + VisualElement HeaderInput () => host.Query>().ToList().FirstOrDefault() + ?.Q(className: PopupField.inputUssClassName); + yield return WaitFor(() => Input(host)?.value == current.number && nativeControl.Q()?.value == current.number + && nativeControl.Q().labelElement.worldBound.width > 0f && HeaderInput()?.worldBound.width > 0f, + "The current Prefab data and menu targets must finish layout before a request. Stage=" + stage); + foreach (bool control in new[] { true, false }) + { + requests.Clear(); + window.Focus(); + if (control) + { + SerializedPropertyContextMenu.RequestFromUi(nativeControl.Q().labelElement); + } + else + { + SerializedPropertyContextMenu.RequestFromUi(HeaderInput()); + } + yield return WaitFor(() => requests.Count > 0, "The native bound Prefab property did not receive the menu request. Control=" + control + "; stage=" + stage); + Assert.That(requests.Count, Is.EqualTo(1), "One request must reach one native bound property."); + Assert.That(requests[0].path, Is.EqualTo(control ? "reference.number" : "reference")); + Assert.That(requests[0].target, Is.SameAs(component)); + if (!control) + { + Assert.That(requests[0].id, Is.EqualTo(current.id), "The menu must use the current reference identity."); + } + Assert.That(Read(component), Is.EqualTo(current), "Opening a menu must not change the instance data."); + Assert.That(Read(prefab.GetComponent()), Is.EqualTo(savedPrefab), "Opening a menu must not change the Prefab asset."); + TestContext.Out.WriteLine("Prefab menu routing: stage=" + stage + "; control=" + control + + "; path=" + requests[0].path + "; rootId=" + current.id + "; rootOverride=" + current.isOverride); + yield return null; + } + nativeControl.Unbind(); + nativeControl.RemoveFromHierarchy(); + } + } + } + + [UnityTest] + public IEnumerator PrefabRevert_UpdatesHeaderWeightAndKeepsChildEditing () + { + source = new GameObject("Subclass selector Prefab"); + source.AddComponent(); + prefabPath = AssetDatabase.GenerateUniqueAssetPath("Assets/SubclassSelectorUIToolkitTest.prefab"); + GameObject prefab = PrefabUtility.SaveAsPrefabAsset(source, prefabPath); + UnityEngine.Object.DestroyImmediate(source); + source = null; + instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab); + Component component = instance.GetComponent(); + + WriteNumber(component, 91); + boundObject = new SerializedObject(component); + boundObject.FindProperty("reference").isExpanded = true; + var host = new PropertyField(boundObject.FindProperty("reference"), "Reference"); + window = ScriptableObject.CreateInstance(); + window.position = new Rect(100, 100, 520, 500); + window.Show(); + window.rootVisualElement.Add(CreateInspector(component, host, "reference.number")); + host.Bind(boundObject); + yield return WaitFor(() => Input(host)?.value == 91 && HeaderHasWeight(true), + "The overridden reference was not displayed with its native bold header.", + () => Describe(host, component, "reference")); + + using (var edit = new SerializedObject(component)) + { + edit.FindProperty("reference").managedReferenceValue = new Component.SecondValue { number = 91 }; + edit.ApplyModifiedProperties(); + } + yield return WaitFor(() => window.rootVisualElement.Query>().ToList().Any(field => field.text == "Second type") + && Input(host)?.value == 91, + "The replacement reference was not displayed."); + + for (int pass = 0; pass < 2 && Read(component).isOverride; pass++) + { + using (var edit = new SerializedObject(component)) + { + PrefabUtility.RevertPropertyOverride(edit.FindProperty("reference"), InteractionMode.AutomatedAction); + } + var after = Read(component); + yield return WaitFor(() => Input(host)?.value == after.number, + "The displayed child did not follow Prefab Revert."); + } + + var reverted = Read(component); + Assert.That(reverted.isOverride, Is.False, "The test precondition did not reach the reverted Prefab state."); + Assert.That(reverted.number, Is.EqualTo(Read(prefab.GetComponent()).number)); + yield return WaitFor(() => HeaderHasWeight(false), + "The header stayed bold after the reference's Prefab override was removed."); + + window.Focus(); + Input(host).Focus(); + yield return WaitFor(() => HasInputFocus(host), "The reverted child input could not receive focus."); + Input(host).value = 17; + yield return WaitFor(() => Read(component).number == 17 && HeaderHasWeight(true) && HasInputFocus(host), + "A new override must update the header and keep the child input bound and focused."); + + host.Unbind(); + host.Bind(boundObject); + yield return WaitFor(() => Input(host)?.value == 17, "The host's full rebind did not restore the child input."); + Input(host).value = 19; + yield return WaitFor(() => Read(component).number == 19, "The child stopped editing after the host's full rebind."); + Assert.That(Read(prefab.GetComponent()).number, Is.EqualTo(7), "Editing the instance changed its Prefab asset."); + } + + [UnityTest] + public IEnumerator PrefabApply_UpdatesHeaderWeightAndKeepsCustomChildEditable () + { + source = new GameObject("Tracked subclass selector Prefab"); + Component sourceComponent = source.AddComponent(); + var original = new Component.CompositeValue(); + sourceComponent.reference = original; + Assert.That(SetReferenceId(sourceComponent, original, 1000), Is.True); + Assert.That(SetReferenceId(sourceComponent, original.child, 2000), Is.True); + prefabPath = AssetDatabase.GenerateUniqueAssetPath("Assets/SubclassSelectorTrackedUIToolkitTest.prefab"); + GameObject prefab = PrefabUtility.SaveAsPrefabAsset(source, prefabPath); + UnityEngine.Object.DestroyImmediate(source); + source = null; + instance = (GameObject)PrefabUtility.InstantiatePrefab(prefab); + Component component = instance.GetComponent(); + var replacement = new Component.CompositeValue(); + component.reference = replacement; + Assert.That(SetReferenceId(component, replacement, 5000), Is.True); + Assert.That(SetReferenceId(component, replacement.child, 6000), Is.True); + EditorUtility.SetDirty(component); + PrefabUtility.RecordPrefabInstancePropertyModifications(component); + + boundObject = new SerializedObject(component); + boundObject.FindProperty("reference").isExpanded = true; + var host = new PropertyField(boundObject.FindProperty("reference"), "Reference"); + window = ScriptableObject.CreateInstance(); + window.position = new Rect(100, 100, 520, 500); + window.Show(); + window.rootVisualElement.Add(CreateInspector(component, host, "reference.child.number")); + host.Bind(boundObject); + yield return WaitFor(() => Input(host)?.value == 7 && Tracks(host, 7) + && HeaderHasWeight(true), + "The overridden reference must display its custom input, tracker and native bold header.", + () => Describe(host, component, "reference.child")); + const string valuePath = "reference.child"; + var initial = Read(component, valuePath); + Assert.That(initial.isOverride, Is.True); + + window.Focus(); + Input(host).Focus(); + yield return WaitFor(() => HasInputFocus(host), "The custom Prefab input could not receive focus."); + Input(host).SelectAll(); + TypeText("9"); + yield return WaitFor(() => Read(component, valuePath).number == 9 && Read(component, valuePath).isOverride + && Tracks(host, 9) && HeaderHasWeight(true), + "The first key must save the value and update the custom drawer's tracker.", + () => Describe(host, component, valuePath)); + yield return null; + var overridden = Read(component, valuePath); + Assert.That(overridden.id, Is.EqualTo(initial.id), "The edit must keep the same reference identity."); + Assert.That(overridden.typeName, Is.EqualTo(initial.typeName)); + Assert.That(overridden.valueId, Is.EqualTo(initial.valueId), "The edit must preserve the child reference identity."); + Assert.That(HasInputFocus(host), Is.True, + "Editing the overridden Prefab must preserve input focus. " + Describe(host, component, valuePath)); + + using (var edit = new SerializedObject(component)) + { + PrefabUtility.ApplyPropertyOverride(edit.FindProperty("reference"), prefabPath, InteractionMode.AutomatedAction); + } + // Some Editors replace reference IDs during Apply. Classify the API result before + // the view's scheduler runs so it cannot determine its own acceptance condition. + var nativeApplied = Read(component, valuePath); + bool sameIdentity = nativeApplied.id == initial.id && nativeApplied.valueId == initial.valueId; + TestContext.Out.WriteLine($"Apply preserved reference identity: {sameIdentity}; root {initial.id}->{nativeApplied.id}, child {initial.valueId}->{nativeApplied.valueId}"); + yield return WaitFor(() => Read(component, valuePath).number == 9 && !Read(component, valuePath).isOverride + && Input(host)?.value == 9 && Tracks(host, 9) && HeaderHasWeight(false), + "Apply must keep the current custom input and tracker and remove the native header weight.", + () => Describe(host, component, valuePath)); + yield return null; + var applied = Read(component, valuePath); + Assert.That(applied.id, Is.EqualTo(nativeApplied.id), "The view must not change the root identity produced by Apply."); + Assert.That(applied.typeName, Is.EqualTo(initial.typeName)); + Assert.That(applied.valueId, Is.EqualTo(nativeApplied.valueId), "The view must not change the child identity produced by Apply."); + if (sameIdentity) + { + Assert.That(HasInputFocus(host), Is.True, + "A metadata-only Apply must preserve the custom input focus. " + Describe(host, component, valuePath)); + TypeText("9"); + } + else + { + // A replaced reference may create a new input. Prepare it for a fresh edit. + Input(host).Focus(); + yield return WaitFor(() => HasInputFocus(host), "The input for Apply's replacement reference must accept focus."); + Input(host).SelectAll(); + TypeText("99"); + } + + yield return WaitFor(() => Read(component, valuePath).number == 99 && Tracks(host, 99), + "The current custom input must save the edit after Apply and update its own tracker."); + yield return null; + Assert.That(HasInputFocus(host), Is.True, "Continued input must retain focus."); + Assert.That(Read(prefab.GetComponent(), valuePath).number, Is.EqualTo(9)); + + using (var edit = new SerializedObject(component)) + { + PrefabUtility.RevertPropertyOverride(edit.FindProperty(valuePath + ".number"), InteractionMode.AutomatedAction); + } + yield return WaitFor(() => Read(component, valuePath).number == 9 && !Read(component, valuePath).isOverride + && Input(host)?.value == 9 && Tracks(host, 9) && HeaderHasWeight(false), + "Reverting the scalar override must restore the applied input and tracker without stale header weight."); + var reverted = Read(component, valuePath); + Assert.That(reverted.id, Is.EqualTo(applied.id)); + Assert.That(reverted.typeName, Is.EqualTo(applied.typeName)); + Assert.That(reverted.valueId, Is.EqualTo(applied.valueId)); + using (var savedInstance = new SerializedObject(component)) + using (var savedPrefab = new SerializedObject(prefab.GetComponent())) + { + Assert.That(savedInstance.FindProperty("reference.nativeValue").intValue, Is.EqualTo(101)); + Assert.That(savedPrefab.FindProperty("reference.nativeValue").intValue, Is.EqualTo(101)); + } + } + + private IEnumerator WaitFor (Func condition, string failure, Func diagnostic = null) + { + double deadline = EditorApplication.timeSinceStartup + 8d; + while (!condition()) + { + if (EditorApplication.timeSinceStartup >= deadline) + { + Assert.Fail(failure + (diagnostic == null ? string.Empty : " " + diagnostic())); + } + window.Repaint(); + yield return null; + } + } + + private static IntegerField Input (VisualElement root) => root.Query().ToList() + .FirstOrDefault(field => field.label == "Number" && field.worldBound.height > 0f); + + private VisualElement CreateInspector (Component component, PropertyField host, string controlPath) + { + nativeControlPath = controlPath; + inspectorEditor = UnityEditor.Editor.CreateEditor(component, typeof(SubclassSelectorOnGUIComparisonEditor)); + ((SubclassSelectorOnGUIComparisonEditor)inspectorEditor).Paths = Array.Empty(); + var inspector = new InspectorElement(inspectorEditor); + inspector.Add(host); + inspector.Add(new IMGUIContainer(() => + { + using (var read = new SerializedObject(component)) + { + EditorGUILayout.PropertyField(read.FindProperty(controlPath), new GUIContent("Native Number")); + } + })); + return inspector; + } + + private bool HeaderHasWeight (bool expected) + { + var drawn = InspectorDrawing.CaptureText(window); + var header = window.rootVisualElement.Query