// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils { #if UNITY_EDITOR using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Attributes; using WallstopStudios.UnityHelpers.Core.DataStructure; using WallstopStudios.UnityHelpers.Editor.Core.Helper; using WallstopStudios.UnityHelpers.Editor.Settings; using WallstopStudios.UnityHelpers.Editor.Utils; using Object = UnityEngine.Object; /// /// Provides shared constants, state management, and helper methods for inline editor drawers. /// /// /// This utility class consolidates common code used by both the standard PropertyDrawer /// () and the Odin Inspector drawer implementations /// of WInLineEditor. By centralizing these elements, we ensure consistent behavior /// and eliminate code duplication. /// public static class InLineEditorShared { /// /// Height of the foldout header in pixels. /// public const float HeaderHeight = 20f; /// /// Standard vertical spacing between elements. /// public const float Spacing = 2f; /// /// Padding around content areas. /// public const float ContentPadding = 2f; /// /// Minimum width required for the foldout label before ping button is hidden. /// public const float MinimumFoldoutLabelWidth = 40f; /// /// Internal padding added to the ping button width calculation. /// public const float PingButtonPadding = 6f; /// /// Right margin for the ping button. /// public const float PingButtonRightMargin = 2f; /// /// Property path of the Unity script field to exclude from drawing. /// public const string ScriptPropertyPath = "m_Script"; /// /// Separator used when building foldout keys. /// public const string FoldoutKeySeparator = "::"; /// /// Prefix used for scroll position keys. /// public const string ScrollKeyPrefix = "scroll"; /// /// Spacing between the header label and ping button. /// public const float HeaderPingSpacing = 4f; /// /// Ratio of the total content width allocated to label widths in inline editor UI. /// Labels display property names on the left side of the inspector. /// public const float DefaultLabelWidthRatio = 0.4f; /// /// Maximum number of foldout states to cache. /// Prevents unbounded memory growth in projects with many inline editors. /// private const int MaxFoldoutStatesCacheSize = 5000; /// /// Maximum number of scroll positions to cache. /// Prevents unbounded memory growth in projects with many scrollable inline editors. /// private const int MaxScrollPositionsCacheSize = 5000; /// /// Maximum number of editor instances to cache. /// Editor instances consume significant memory, so this limit is more conservative. /// private const int MaxEditorCacheSize = 500; /// /// Cache for foldout expansion states, keyed by a unique foldout identifier. /// Limited to entries to prevent unbounded memory growth. /// private static readonly Dictionary FoldoutStates = new Dictionary< string, bool >(StringComparer.Ordinal); /// /// Cache for scroll positions, keyed by a unique scroll identifier. /// Limited to entries to prevent unbounded memory growth. /// private static readonly Dictionary ScrollPositions = new Dictionary< string, Vector2 >(StringComparer.Ordinal); /// /// Backing field for lazy-initialized EditorCache. /// Lazy initialization is CRITICAL to prevent Unity Editor hangs during static initialization. /// Eager initialization would trigger Cache construction during domain reload, /// which can cause deadlocks during Unity's "Open Project: Open Scene" phase. /// private static Cache _editorCache; /// /// Cache for Unity Editor instances, keyed by object instance ID. /// Uses with LRU eviction and eviction callback /// to properly destroy Editor instances when they are evicted from the cache. /// Lazy-initialized to prevent Unity Editor hangs during static initialization. /// private static Cache EditorCache => _editorCache ??= CacheBuilder .NewBuilder() .MaximumSize(MaxEditorCacheSize) .OnEviction(OnEditorEvicted) .Build(); /// /// Callback invoked when an Editor is evicted from the cache. /// Properly destroys the Editor instance to prevent memory leaks. /// /// The instance ID of the evicted editor. /// The Editor instance being evicted. /// The reason for eviction. private static void OnEditorEvicted(int key, Editor editor, EvictionReason reason) { if (editor != null) { Object.DestroyImmediate(editor); } } /// /// Reusable GUIContent for ping button to avoid allocations. /// public static readonly GUIContent PingButtonContent = new GUIContent( "Ping", "Ping object in the Project window" ); /// /// Reusable GUIContent for header labels to avoid allocations. /// public static readonly GUIContent ReusableHeaderContent = new GUIContent(); /// /// Resolves the effective mode for an inline editor attribute. /// /// The attribute to resolve the mode for. /// The resolved . /// /// If the attribute mode is , /// the setting from is used. /// public static WInLineEditorMode ResolveMode(WInLineEditorAttribute inlineAttribute) { if (inlineAttribute == null) { return WInLineEditorMode.FoldoutExpanded; } if (inlineAttribute.Mode != WInLineEditorMode.UseSettings) { return inlineAttribute.Mode; } UnityHelpersSettings.InlineEditorFoldoutBehavior behavior = UnityHelpersSettings.GetInlineEditorFoldoutBehavior(); return behavior switch { UnityHelpersSettings.InlineEditorFoldoutBehavior.AlwaysOpen => WInLineEditorMode.AlwaysExpanded, UnityHelpersSettings.InlineEditorFoldoutBehavior.StartCollapsed => WInLineEditorMode.FoldoutCollapsed, _ => WInLineEditorMode.FoldoutExpanded, }; } /// /// Gets the foldout state for a given key and mode. /// /// The unique key for the foldout. /// The resolved mode to determine initial state. /// True if the foldout is expanded; false otherwise. public static bool GetFoldoutState(string foldoutKey, WInLineEditorMode resolvedMode) { if (string.IsNullOrEmpty(foldoutKey)) { return true; } if ( EditorCacheHelper.TryGetFromBoundedLRUCache( FoldoutStates, foldoutKey, out bool value ) ) { return value; } bool initialState = resolvedMode switch { WInLineEditorMode.AlwaysExpanded => true, WInLineEditorMode.FoldoutExpanded => true, WInLineEditorMode.FoldoutCollapsed => false, _ => true, }; EditorCacheHelper.AddToBoundedCache( FoldoutStates, foldoutKey, initialState, MaxFoldoutStatesCacheSize ); return initialState; } /// /// Sets the foldout state for a given key. /// /// The unique key for the foldout. /// Whether the foldout should be expanded. public static void SetFoldoutState(string foldoutKey, bool expanded) { if (string.IsNullOrEmpty(foldoutKey)) { return; } EditorCacheHelper.AddToBoundedCache( FoldoutStates, foldoutKey, expanded, MaxFoldoutStatesCacheSize ); } /// /// Gets the scroll position for a given key. /// /// The unique key for the scroll position. /// The stored scroll position, or if not found. public static Vector2 GetScrollPosition(string scrollKey) { if (string.IsNullOrEmpty(scrollKey)) { return Vector2.zero; } if ( EditorCacheHelper.TryGetFromBoundedLRUCache( ScrollPositions, scrollKey, out Vector2 position ) ) { return position; } return Vector2.zero; } /// /// Sets the scroll position for a given key. /// /// The unique key for the scroll position. /// The scroll position to store. public static void SetScrollPosition(string scrollKey, Vector2 position) { if (string.IsNullOrEmpty(scrollKey)) { return; } EditorCacheHelper.AddToBoundedCache( ScrollPositions, scrollKey, position, MaxScrollPositionsCacheSize ); } /// /// Gets or creates a cached editor for the given object. /// Uses with LRU eviction to manage editor instances. /// When editors are evicted from the cache, they are properly destroyed via . /// /// The object to get or create an editor for. /// The cached editor, or null if the value is null. public static Editor GetOrCreateEditor(Object value) { if (value == null) { return null; } int key = value.GetInstanceID(); // Check if we have a valid cached editor (TryGet marks it as accessed for LRU) if (EditorCache.TryGet(key, out Editor cachedEditor) && cachedEditor != null) { return cachedEditor; } // Create a new editor Editor newEditor = null; Editor.CreateCachedEditor(value, null, ref newEditor); // Add to cache (will evict LRU entry if at capacity, triggering OnEditorEvicted) EditorCache.Set(key, newEditor); return newEditor; } /// /// Gets a cached string representation of an integer. /// Delegates to the centralized for shared LRU caching. /// /// The integer value to convert. /// The cached string representation. public static string GetCachedIntString(int value) { return EditorCacheHelper.GetCachedIntString(value); } /// /// Calculates the width of the ping button. /// /// The calculated width including padding. public static float GetPingButtonWidth() { GUIStyle style = EditorStyles.miniButton; if (style == null) { return 0f; } Vector2 contentSize = style.CalcSize(PingButtonContent); return Mathf.Ceil(contentSize.x + PingButtonPadding); } /// /// Determines whether the ping button should be shown for the given object. /// /// The object to check. /// True if the ping button should be shown; false otherwise. public static bool ShouldShowPingButton(Object value) { if (value == null) { return false; } return ProjectBrowserVisibilityUtility.IsProjectBrowserVisible(); } /// /// Determines whether a standalone header should be drawn. /// /// The inline editor attribute. /// True if a standalone header should be drawn; false otherwise. /// /// A standalone header is drawn when the object field is not being drawn, /// meaning the header needs to provide the foldout functionality. /// public static bool ShouldDrawStandaloneHeader(WInLineEditorAttribute inlineAttribute) { if (inlineAttribute == null) { return false; } return !inlineAttribute.DrawObjectField; } /// /// Builds a foldout key from instance ID and property path. /// /// The instance ID of the parent object. /// The property path. /// A unique foldout key string. public static string BuildFoldoutKey(int parentInstanceId, string propertyPath) { return GetCachedIntString(parentInstanceId) + FoldoutKeySeparator + propertyPath; } /// /// Builds a scroll key from a foldout key. /// /// The foldout key to base the scroll key on. /// A unique scroll key string. public static string BuildScrollKey(string foldoutKey) { return ScrollKeyPrefix + FoldoutKeySeparator + foldoutKey; } /// /// Builds a scroll key from instance ID and property path. /// /// The instance ID of the parent object. /// The property path. /// A unique scroll key string. public static string BuildScrollKey(int parentInstanceId, string propertyPath) { return ScrollKeyPrefix + FoldoutKeySeparator + GetCachedIntString(parentInstanceId) + FoldoutKeySeparator + propertyPath; } /// /// Prepares header content for display, combining label and object name. /// /// The object being displayed. /// Optional label to prepend. /// The prepared header content. public static GUIContent PrepareHeaderContent(Object value, GUIContent label) { if (value == null) { return label ?? GUIContent.none; } GUIContent headerContent = EditorGUIUtility.ObjectContent(value, value.GetType()); if (headerContent == null || string.IsNullOrEmpty(headerContent.text)) { ReusableHeaderContent.text = value.name; ReusableHeaderContent.image = headerContent != null ? headerContent.image : null; ReusableHeaderContent.tooltip = headerContent != null ? headerContent.tooltip ?? string.Empty : string.Empty; headerContent = ReusableHeaderContent; } if (label != null && !string.IsNullOrEmpty(label.text)) { ReusableHeaderContent.text = label.text + " (" + headerContent.text + ")"; ReusableHeaderContent.image = headerContent.image; ReusableHeaderContent.tooltip = headerContent.tooltip ?? string.Empty; headerContent = ReusableHeaderContent; } return headerContent; } /// /// Draws the serialized object's properties, skipping the script field. /// /// The serialized object to draw. /// /// This uses EditorGUILayout for automatic layout. For IMGUI-based drawing /// with explicit rects, use the overload that accepts a rect parameter. /// public static void DrawSerializedObject(SerializedObject serializedObject) { if (serializedObject == null) { return; } serializedObject.UpdateIfRequiredOrScript(); SerializedProperty iterator = serializedObject.GetIterator(); bool enterChildren = true; while (iterator.NextVisible(enterChildren)) { if ( string.Equals( iterator.propertyPath, ScriptPropertyPath, StringComparison.Ordinal ) ) { enterChildren = false; continue; } EditorGUILayout.PropertyField(iterator, true); enterChildren = false; } serializedObject.ApplyModifiedProperties(); } /// /// Draws the serialized object's properties within a specific rect, skipping the script field. /// /// The rect to draw within. /// The serialized object to draw. /// /// This version uses EditorGUI with explicit rects for IMGUI-based drawing. /// public static void DrawSerializedObjectInRect(Rect rect, SerializedObject serializedObject) { if (serializedObject == null) { return; } float previousLabelWidth = EditorGUIUtility.labelWidth; EditorGUIUtility.labelWidth = rect.width * DefaultLabelWidthRatio; try { serializedObject.UpdateIfRequiredOrScript(); SerializedProperty iterator = serializedObject.GetIterator(); bool enterChildren = true; Rect currentRect = new Rect(rect.x, rect.y, rect.width, 0f); bool firstPropertyDrawn = false; while (iterator.NextVisible(enterChildren)) { if ( string.Equals( iterator.propertyPath, ScriptPropertyPath, StringComparison.Ordinal ) ) { enterChildren = false; continue; } if (firstPropertyDrawn) { currentRect.y += EditorGUIUtility.standardVerticalSpacing; } float propertyHeight = EditorGUI.GetPropertyHeight(iterator, true); currentRect.height = propertyHeight; EditorGUI.PropertyField(currentRect, iterator, true); currentRect.y += propertyHeight; enterChildren = false; firstPropertyDrawn = true; } serializedObject.ApplyModifiedProperties(); } finally { EditorGUIUtility.labelWidth = previousLabelWidth; } } /// /// Clears all cached state. Called during domain reload to prevent stale references. /// Note: IntToString cache is managed centrally by EditorCacheHelper. /// The EditorCache.Clear() method will trigger for each cached editor, /// which properly destroys the Editor instances. /// internal static void ClearCache() { FoldoutStates.Clear(); ScrollPositions.Clear(); // Only clear if the cache has been initialized (avoid triggering lazy initialization during cache clear) // Clear() triggers OnEviction callback for each entry, which calls DestroyImmediate _editorCache?.Clear(); } /// /// Clears all cached state. Primarily for testing purposes. /// internal static void ClearCachedStateForTesting() { ClearCache(); } /// /// Test hook to set the foldout state for a given key. /// /// The foldout key. /// Whether the foldout should be expanded. internal static void SetFoldoutStateForTesting(string key, bool expanded) { if (!string.IsNullOrEmpty(key)) { FoldoutStates[key] = expanded; } } /// /// Test hook to get the foldout state for a given key. /// /// The foldout key. /// True if expanded; false otherwise. internal static bool GetFoldoutStateForTesting(string key) { if (string.IsNullOrEmpty(key)) { return false; } return FoldoutStates.TryGetValue(key, out bool value) && value; } /// /// Test hook to get the number of cached editors. /// /// The number of cached editors. internal static int GetEditorCacheCountForTesting() { return EditorCache.Count; } /// /// Test hook to get the number of cached foldout states. /// /// The number of cached foldout states. internal static int GetFoldoutStateCacheCountForTesting() { return FoldoutStates.Count; } /// /// Test hook to get the number of cached scroll positions. /// /// The number of cached scroll positions. internal static int GetScrollPositionCacheCountForTesting() { return ScrollPositions.Count; } } #endif }