// 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
}