// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
// Portions of this file are adapted from Unity Editor Toolbox (InlineEditorAttributeDrawer)
// Copyright (c) 2017-2023 arimger
// Licensed under the MIT License: https://github.com/arimger/Unity-Editor-Toolbox/blob/main/LICENSE.md
namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers
{
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.AnimatedValues;
using UnityEditorInternal;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Attributes;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils;
using WallstopStudios.UnityHelpers.Editor.Internal;
using WallstopStudios.UnityHelpers.Editor.Settings;
using WallstopStudios.UnityHelpers.Editor.Utils;
using Object = UnityEngine.Object;
[CustomPropertyDrawer(typeof(WInLineEditorAttribute))]
///
/// Portions of this implementation draw inspiration from Unity Editor Toolbox's Inline Editor drawer.
/// Unity Editor Toolbox is Copyright (c) 2017-2023 arimger and distributed under the MIT License.
/// Source: https://github.com/arimger/Unity-Editor-Toolbox (MIT)
///
public sealed class WInLineEditorDrawer : PropertyDrawer
{
// Inspired by the Unity Editor Toolbox inline editor drawer (MIT):
// https://github.com/arimger/Unity-Editor-Toolbox
private const float FoldoutOffset = 6.5f;
///
/// Ratio of the total content width allocated to field widths in inline editor UI.
/// Fields display property values/controls on the right side of the inspector.
///
private const float DefaultFieldWidthRatio = 0.6f;
private static readonly Dictionary PropertyWidths = new Dictionary<
string,
float
>(System.StringComparer.Ordinal);
private static readonly Dictionary<
(int instanceId, string propertyPath),
string
> FoldoutKeyCache = new Dictionary<(int, string), string>();
private static readonly Dictionary<
(int instanceId, string propertyPath),
string
> ScrollKeyCache = new Dictionary<(int, string), string>();
// Cache for InspectorHeightInfo to avoid redundant calculations within the same frame
private static readonly Dictionary<
(int instanceId, float width),
InspectorHeightInfoCacheEntry
> InspectorHeightCache = new Dictionary<(int, float), InspectorHeightInfoCacheEntry>();
private static int _lastInspectorHeightCacheFrame = -1;
// Animation cache for smooth foldout transitions
private static readonly Dictionary FoldoutAnimations = new Dictionary<
string,
AnimBool
>(System.StringComparer.Ordinal);
private sealed class InspectorHeightInfoCacheEntry
{
public InspectorHeightInfo heightInfo;
}
// Recursion guard to prevent EditorGUI.GetPropertyHeight from triggering
// our GetPropertyHeight recursively
[System.ThreadStatic]
private static bool _isCalculatingHeight;
// Since reflection-based width override is unreliable across Unity versions,
// we use a simpler approach: always use the serialized inspector for inline editors.
// This provides correct layout at the cost of custom editor features like buttons.
// The _forceSerializedInspector flag can be toggled if needed.
private static bool _forceSerializedInspector = true;
private static float GetHorizontalScrollbarHeight()
{
// Avoid accessing GUI.skin outside OnGUI context - it throws an exception
GUIStyle scrollbarStyle = null;
try
{
scrollbarStyle = GUI.skin != null ? GUI.skin.horizontalScrollbar : null;
}
catch (System.ArgumentException)
{
// Occurs when called outside OnGUI - use fallback
}
float height =
scrollbarStyle != null && scrollbarStyle.fixedHeight > 0f
? scrollbarStyle.fixedHeight
: EditorGUIUtility.singleLineHeight;
return Mathf.Max(12f, height);
}
private static void SetPropertyWidth(SerializedProperty property, float width)
{
if (property == null)
{
return;
}
string key = BuildFoldoutKey(property);
PropertyWidths[key] = Mathf.Max(0f, width);
}
private static float GetEstimatedPropertyWidth(SerializedProperty property)
{
if (property != null)
{
string key = BuildFoldoutKey(property);
if (PropertyWidths.TryGetValue(key, out float width) && width > 0f)
{
return width;
}
}
// EditorGUIUtility.currentViewWidth throws when called outside OnGUI context
try
{
return EditorGUIUtility.currentViewWidth;
}
catch (System.ArgumentException)
{
// Fallback for calls outside OnGUI - use a reasonable default
return 300f;
}
}
public override float GetPropertyHeight(SerializedProperty property, GUIContent label)
{
// Guard against recursive calls - if we're already calculating height,
// just return the base property height to prevent infinite recursion
if (_isCalculatingHeight)
{
return EditorGUIUtility.singleLineHeight;
}
WInLineEditorAttribute inlineAttribute = (WInLineEditorAttribute)attribute;
float height;
try
{
_isCalculatingHeight = true;
height = inlineAttribute.DrawObjectField
? EditorGUI.GetPropertyHeight(property, label, false)
: EditorGUIUtility.singleLineHeight;
}
finally
{
_isCalculatingHeight = false;
}
Object value = property.hasMultipleDifferentValues
? null
: property.objectReferenceValue;
if (value == null || property.propertyType != SerializedPropertyType.ObjectReference)
{
return height;
}
float availableWidth = GetEstimatedPropertyWidth(property);
float inlineHeight = CalculateInlineHeight(
property,
inlineAttribute,
value,
availableWidth
);
return height
+ (
inlineHeight <= 0f
? 0f
: EditorGUIUtility.standardVerticalSpacing + inlineHeight
);
}
public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
{
WInLineEditorAttribute inlineAttribute = (WInLineEditorAttribute)attribute;
EditorGUI.BeginProperty(position, label, property);
if (property.propertyType != SerializedPropertyType.ObjectReference)
{
EditorGUI.HelpBox(
position,
"WInLineEditor only supports object references.",
MessageType.Warning
);
EditorGUI.EndProperty();
return;
}
if (property.hasMultipleDifferentValues)
{
if (inlineAttribute.DrawObjectField)
{
EditorGUI.PropertyField(position, property, label, false);
}
else
{
EditorGUI.LabelField(position, label);
}
EditorGUI.EndProperty();
return;
}
SetPropertyWidth(property, position.width);
float fieldHeight = inlineAttribute.DrawObjectField
? EditorGUI.GetPropertyHeight(property, label, false)
: EditorGUIUtility.singleLineHeight;
Rect currentRect = new Rect(position.x, position.y, position.width, fieldHeight);
WInLineEditorMode mode = InLineEditorShared.ResolveMode(inlineAttribute);
string foldoutKey = BuildFoldoutKey(property);
bool foldoutState = GetFoldoutState(property, inlineAttribute, mode);
if (inlineAttribute.DrawObjectField)
{
foldoutState = DrawInlineObjectReferenceField(
currentRect,
property,
label,
foldoutState,
foldoutKey,
mode
);
}
else
{
foldoutState = DrawCompactObjectReferenceField(
currentRect,
property,
label,
foldoutState,
foldoutKey,
mode
);
}
currentRect.y += currentRect.height + EditorGUIUtility.standardVerticalSpacing;
Object value = property.objectReferenceValue;
if (value != null)
{
float inlineHeight = CalculateInlineHeight(
property,
inlineAttribute,
value,
currentRect.width
);
if (inlineHeight > 0f)
{
InspectorHeightInfo inspectorHeightInfo = ResolveInspectorHeightInfo(
value,
inlineAttribute,
currentRect.width
);
Rect inlineRect = new Rect(
currentRect.x,
currentRect.y,
currentRect.width,
inlineHeight
);
DrawInlineInspector(
inlineRect,
property,
inlineAttribute,
label,
value,
foldoutState,
foldoutKey,
mode,
inspectorHeightInfo
);
}
}
EditorGUI.EndProperty();
}
private static float CalculateInlineHeight(
SerializedProperty property,
WInLineEditorAttribute inlineAttribute,
Object value,
float availableWidth
)
{
WInLineEditorMode mode = InLineEditorShared.ResolveMode(inlineAttribute);
bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader(
inlineAttribute
);
bool showHeader =
useStandaloneHeader
&& (inlineAttribute.DrawHeader || mode != WInLineEditorMode.AlwaysExpanded);
bool foldoutState = GetFoldoutState(property, inlineAttribute, mode);
bool isAlwaysExpanded = mode == WInLineEditorMode.AlwaysExpanded;
bool showBody = isAlwaysExpanded || foldoutState;
float height = 0f;
if (showHeader)
{
height += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing;
}
// Calculate body height - when tweening, we need the full height for animation
bool shouldTween = UnityHelpersSettings.ShouldTweenInlineEditorFoldouts();
bool canAnimate = !isAlwaysExpanded && shouldTween;
// If not showing body and not animating, return header-only height
if (!showBody && !canAnimate)
{
return height;
}
// Calculate the full body height
InspectorHeightInfo inspectorHeight = ResolveInspectorHeightInfo(
value,
inlineAttribute,
availableWidth
);
float bodyHeight = inspectorHeight.DisplayHeight;
if (inlineAttribute.DrawPreview)
{
bodyHeight += InLineEditorShared.Spacing + inlineAttribute.PreviewHeight;
}
// Apply animation fade to body height
if (canAnimate)
{
string foldoutKey = BuildFoldoutKey(property);
float fade = GetFadeProgress(foldoutKey, foldoutState);
height += bodyHeight * fade;
}
else
{
height += bodyHeight;
}
return height;
}
private static bool DrawInlineObjectReferenceField(
Rect rect,
SerializedProperty property,
GUIContent label,
bool foldoutState,
string foldoutKey,
WInLineEditorMode mode
)
{
Rect indentedRect = EditorGUI.IndentedRect(rect);
int previousIndent = EditorGUI.indentLevel;
EditorGUI.indentLevel = 0;
float labelWidth = Mathf.Min(EditorGUIUtility.labelWidth, indentedRect.width);
Rect labelRect = new Rect(
indentedRect.x,
indentedRect.y,
labelWidth,
indentedRect.height
);
Rect fieldRect = new Rect(
labelRect.xMax,
indentedRect.y,
Mathf.Max(0f, indentedRect.width - labelWidth),
indentedRect.height
);
EditorGUI.ObjectField(fieldRect, property, GUIContent.none);
Object currentValue = property.objectReferenceValue;
bool showFoldoutToggle =
currentValue != null && mode != WInLineEditorMode.AlwaysExpanded;
bool showPingButton = InLineEditorShared.ShouldShowPingButton(currentValue);
float pingWidth = showPingButton ? InLineEditorShared.GetPingButtonWidth() : 0f;
float pingSpacing = showPingButton ? InLineEditorShared.Spacing : 0f;
float pingRightMargin = showPingButton ? InLineEditorShared.PingButtonRightMargin : 0f;
bool hasSpaceForPing =
showPingButton
&& labelRect.width - pingWidth - pingSpacing - pingRightMargin
>= InLineEditorShared.MinimumFoldoutLabelWidth;
if (!hasSpaceForPing)
{
showPingButton = false;
pingWidth = 0f;
pingSpacing = 0f;
pingRightMargin = 0f;
}
float foldoutWidth = Mathf.Max(
0f,
showPingButton
? labelRect.width - pingWidth - pingSpacing - pingRightMargin
: labelRect.width
);
Rect foldoutRect = new Rect(labelRect.x, labelRect.y, foldoutWidth, labelRect.height);
GUIContent foldoutLabel = label ?? GUIContent.none;
if (showFoldoutToggle)
{
Rect adjustedFoldoutRect = new Rect(
foldoutRect.x + FoldoutOffset,
foldoutRect.y,
Mathf.Max(0f, foldoutRect.width - FoldoutOffset),
foldoutRect.height
);
bool newState = EditorGUI.Foldout(
adjustedFoldoutRect,
foldoutState,
foldoutLabel,
true
);
if (newState != foldoutState)
{
foldoutState = newState;
SetFoldoutState(foldoutKey, foldoutState);
}
}
else
{
EditorGUI.LabelField(foldoutRect, foldoutLabel);
}
if (showPingButton)
{
Rect pingRect = new Rect(
foldoutRect.x + foldoutRect.width + pingSpacing,
labelRect.y,
pingWidth,
labelRect.height
);
using (new EditorGUI.DisabledScope(currentValue == null))
{
if (
GUI.Button(
pingRect,
InLineEditorShared.PingButtonContent,
EditorStyles.miniButton
)
)
{
EditorGUIUtility.PingObject(currentValue);
}
}
}
EditorGUI.indentLevel = previousIndent;
return foldoutState;
}
private static bool DrawCompactObjectReferenceField(
Rect rect,
SerializedProperty property,
GUIContent label,
bool foldoutState,
string foldoutKey,
WInLineEditorMode mode
)
{
// Compact mode: draw label on left, small object picker on right
// This allows object selection while hiding the full object field
Rect indentedRect = EditorGUI.IndentedRect(rect);
int previousIndent = EditorGUI.indentLevel;
EditorGUI.indentLevel = 0;
// Reserve space for a small object picker on the right
const float pickerWidth = 20f;
const float pickerSpacing = 2f;
float availableLabelWidth = Mathf.Max(
0f,
indentedRect.width - pickerWidth - pickerSpacing
);
Rect labelRect = new Rect(
indentedRect.x,
indentedRect.y,
availableLabelWidth,
indentedRect.height
);
Rect pickerRect = new Rect(
labelRect.xMax + pickerSpacing,
indentedRect.y,
pickerWidth,
indentedRect.height
);
Object currentValue = property.objectReferenceValue;
bool showFoldoutToggle =
currentValue != null && mode != WInLineEditorMode.AlwaysExpanded;
GUIContent foldoutLabel = label ?? GUIContent.none;
if (showFoldoutToggle)
{
Rect adjustedFoldoutRect = new Rect(
labelRect.x + FoldoutOffset,
labelRect.y,
Mathf.Max(0f, labelRect.width - FoldoutOffset),
labelRect.height
);
bool newState = EditorGUI.Foldout(
adjustedFoldoutRect,
foldoutState,
foldoutLabel,
true
);
if (newState != foldoutState)
{
foldoutState = newState;
SetFoldoutState(foldoutKey, foldoutState);
}
}
else
{
EditorGUI.LabelField(labelRect, foldoutLabel);
}
// Draw a minimal object picker field (just the circle button)
EditorGUI.ObjectField(pickerRect, property, GUIContent.none);
EditorGUI.indentLevel = previousIndent;
return foldoutState;
}
private static void DrawInlineInspector(
Rect rect,
SerializedProperty property,
WInLineEditorAttribute inlineAttribute,
GUIContent label,
Object value,
bool foldoutState,
string foldoutKey,
WInLineEditorMode mode,
InspectorHeightInfo inspectorHeight
)
{
bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader(
inlineAttribute
);
bool showHeader =
useStandaloneHeader
&& (inlineAttribute.DrawHeader || mode != WInLineEditorMode.AlwaysExpanded);
if (showHeader)
{
Rect headerRect = new Rect(
rect.x,
rect.y,
rect.width,
InLineEditorShared.HeaderHeight
);
bool showFoldoutToggle = mode != WInLineEditorMode.AlwaysExpanded;
foldoutState = DrawHeader(
headerRect,
property,
value,
label,
showFoldoutToggle,
foldoutState
);
SetFoldoutState(foldoutKey, foldoutState);
rect.y += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing;
rect.height -= InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing;
}
bool isAlwaysExpanded = mode == WInLineEditorMode.AlwaysExpanded;
bool shouldTween = UnityHelpersSettings.ShouldTweenInlineEditorFoldouts();
bool canAnimate = !isAlwaysExpanded && shouldTween;
// Determine if we should show the body content
bool showBody = isAlwaysExpanded || foldoutState;
// When animating, use fade group for smooth transitions
if (canAnimate)
{
float fade = GetFadeProgress(foldoutKey, foldoutState);
bool visible = EditorGUILayout.BeginFadeGroup(fade);
if (visible)
{
DrawInlineInspectorBody(
rect,
property,
inlineAttribute,
value,
inspectorHeight
);
}
EditorGUILayout.EndFadeGroup();
}
else if (showBody)
{
DrawInlineInspectorBody(rect, property, inlineAttribute, value, inspectorHeight);
}
}
private static void DrawInlineInspectorBody(
Rect rect,
SerializedProperty property,
WInLineEditorAttribute inlineAttribute,
Object value,
InspectorHeightInfo inspectorHeight
)
{
Editor editor = InLineEditorShared.GetOrCreateEditor(value);
if (editor == null)
{
return;
}
Rect inspectorRect = new Rect(
rect.x,
rect.y,
rect.width,
inspectorHeight.DisplayHeight
);
DrawInspectorBody(property, inspectorRect, editor, inlineAttribute, inspectorHeight);
rect.y += inspectorHeight.DisplayHeight;
if (inlineAttribute.DrawPreview && editor.HasPreviewGUI())
{
rect.y += InLineEditorShared.Spacing;
Rect previewRect = new Rect(
rect.x,
rect.y,
rect.width,
inlineAttribute.PreviewHeight
);
GUI.Box(previewRect, GUIContent.none, EditorStyles.helpBox);
Rect previewContentRect = new Rect(
previewRect.x + 2f,
previewRect.y + 2f,
previewRect.width - 4f,
previewRect.height - 4f
);
editor.OnPreviewGUI(previewContentRect, GUIStyle.none);
}
}
private static void DrawInspectorBody(
SerializedProperty property,
Rect rect,
Editor editor,
WInLineEditorAttribute inlineAttribute,
InspectorHeightInfo inspectorHeight
)
{
Rect backgroundRect = new Rect(rect.x, rect.y, rect.width, rect.height);
GUI.Box(backgroundRect, GUIContent.none, EditorStyles.helpBox);
Rect contentRect = GetInlineContentRect(backgroundRect);
string scrollKey = BuildScrollKey(property);
bool useSerializedInspector = inspectorHeight.UsesSerializedInspector;
bool needsHorizontalScroll =
inlineAttribute.EnableScrolling && inspectorHeight.RequiresHorizontalScrollbar;
bool needsVerticalScroll =
inlineAttribute.EnableScrolling
&& inspectorHeight.ContentHeight > contentRect.height + 0.5f;
bool useScrollView =
inlineAttribute.EnableScrolling && (needsHorizontalScroll || needsVerticalScroll);
// Save editor state - will be restored after drawing
int previousIndentLevel = EditorGUI.indentLevel;
// Reset indent level to 0 since we're starting fresh in the inline area
EditorGUI.indentLevel = 0;
try
{
if (useScrollView)
{
Vector2 scrollPosition = InLineEditorShared.GetScrollPosition(scrollKey);
float viewWidth = needsHorizontalScroll
? Mathf.Max(inlineAttribute.MinInspectorWidth, contentRect.width)
: contentRect.width;
float viewHeight = inspectorHeight.ContentHeight;
// Use absolute coordinates for the scroll view
Rect viewRect = new Rect(0f, 0f, viewWidth, viewHeight);
scrollPosition = GUI.BeginScrollView(
contentRect,
scrollPosition,
viewRect,
needsHorizontalScroll,
needsVerticalScroll
);
// Inside scroll view, coordinates are relative to the view
DrawInspectorContents(editor, useSerializedInspector, viewRect);
GUI.EndScrollView();
InLineEditorShared.SetScrollPosition(scrollKey, scrollPosition);
return;
}
// For non-scrolling content, use GUI.BeginGroup to establish coordinate
// transformation, then call DrawInspectorContents with a rect at origin.
GUI.BeginGroup(contentRect);
Rect drawRect = new Rect(0f, 0f, contentRect.width, inspectorHeight.ContentHeight);
DrawInspectorContents(editor, useSerializedInspector, drawRect);
GUI.EndGroup();
}
finally
{
EditorGUI.indentLevel = previousIndentLevel;
}
}
private static void DrawInspectorContents(
Editor editor,
bool useSerializedInspector,
Rect rect
)
{
if (editor == null)
{
return;
}
// Due to Unity's EditorGUILayout width calculation limitations (it uses the read-only
// currentViewWidth instead of respecting GUILayout.BeginArea bounds), we always use
// the rect-based serialized inspector approach for inline editors.
//
// This provides correct layout and label/field proportions, but means custom editor
// features (like buttons from WButtonInspector) won't be rendered inside inline editors.
// The trade-off is necessary for correct visual layout.
//
// If _forceSerializedInspector is false, we attempt to use the custom editor, but
// it will likely have the 50% width issue.
if (useSerializedInspector || _forceSerializedInspector)
{
DrawSerializedInspector(rect, editor);
return;
}
// Fallback path for custom editors (known to have width issues)
// This path is only taken if _forceSerializedInspector is explicitly set to false
// Save current values
float previousLabelWidth = EditorGUIUtility.labelWidth;
float previousFieldWidth = EditorGUIUtility.fieldWidth;
// Set labelWidth based on our rect width
float contentWidth = rect.width;
EditorGUIUtility.labelWidth = contentWidth * InLineEditorShared.DefaultLabelWidthRatio;
EditorGUIUtility.fieldWidth = contentWidth * DefaultFieldWidthRatio;
try
{
GUILayout.BeginArea(rect);
using (InlineInspectorContext.Enter())
{
editor.OnInspectorGUI();
}
GUILayout.EndArea();
}
finally
{
EditorGUIUtility.labelWidth = previousLabelWidth;
EditorGUIUtility.fieldWidth = previousFieldWidth;
}
}
private static void DrawSerializedInspector(Rect rect, Editor editor)
{
SerializedObject serializedObject = editor.serializedObject;
InLineEditorShared.DrawSerializedObjectInRect(rect, serializedObject);
}
private static InspectorHeightInfo ResolveInspectorHeightInfo(
Object value,
WInLineEditorAttribute inlineAttribute,
float availableWidth
)
{
if (value == null)
{
return InspectorHeightInfo.Empty;
}
// Check frame-based cache to avoid redundant calculations
int currentFrame = Time.frameCount;
if (_lastInspectorHeightCacheFrame != currentFrame)
{
InspectorHeightCache.Clear();
_lastInspectorHeightCacheFrame = currentFrame;
}
int instanceId = value.GetInstanceID();
// Round width to avoid cache misses from floating point variations
float roundedWidth = Mathf.Round(availableWidth);
(int, float) cacheKey = (instanceId, roundedWidth);
if (
InspectorHeightCache.TryGetValue(cacheKey, out InspectorHeightInfoCacheEntry cached)
)
{
return cached.heightInfo;
}
InspectorHeightInfo result = CalculateInspectorHeightInfoUncached(
value,
inlineAttribute,
availableWidth
);
if (!InspectorHeightCache.TryGetValue(cacheKey, out cached))
{
cached = new InspectorHeightInfoCacheEntry();
InspectorHeightCache[cacheKey] = cached;
}
cached.heightInfo = result;
return result;
}
private static InspectorHeightInfo CalculateInspectorHeightInfoUncached(
Object value,
WInLineEditorAttribute inlineAttribute,
float availableWidth
)
{
Editor editor = InLineEditorShared.GetOrCreateEditor(value);
SerializedObject analysisObject = GetSerializedObjectForAnalysis(editor, value);
bool hasSerializedData = analysisObject != null;
bool hasSimpleLayout =
hasSerializedData && SerializedObjectHasOnlySimpleProperties(analysisObject);
bool canUseSerializedInspector =
hasSerializedData && ShouldUseSerializedInspector(editor);
if (TryCalculateSerializedInspectorHeight(analysisObject, out float contentHeight))
{
InspectorHeightInfo info = BuildInspectorHeightInfo(
inlineAttribute,
availableWidth,
contentHeight,
canUseSerializedInspector,
hasSimpleLayout
);
return info;
}
float fallbackHeight = inlineAttribute.InspectorHeight;
return BuildInspectorHeightInfo(
inlineAttribute,
availableWidth,
fallbackHeight,
canUseSerializedInspector,
hasSimpleLayout
);
}
private static InspectorHeightInfo BuildInspectorHeightInfo(
WInLineEditorAttribute inlineAttribute,
float availableWidth,
float contentHeight,
bool usesSerializedInspector,
bool hasSimpleLayout
)
{
float displayHeight = inlineAttribute.EnableScrolling
? Mathf.Min(contentHeight, inlineAttribute.InspectorHeight)
: contentHeight;
float effectiveWidth = Mathf.Max(
0f,
availableWidth - (InLineEditorShared.ContentPadding * 2f)
);
// Enable horizontal scroll when:
// 1. Scrolling is enabled AND
// 2. MinInspectorWidth is set AND
// 3. Either: user explicitly set MinInspectorWidth, OR layout is complex, OR width is very narrow
const float MinimumUsableWidth = 200f;
bool widthIsTooNarrow = effectiveWidth < MinimumUsableWidth;
bool shouldRespectMinWidth =
inlineAttribute.HasExplicitMinInspectorWidth
|| !hasSimpleLayout
|| widthIsTooNarrow;
bool requiresHorizontalScroll =
inlineAttribute.EnableScrolling
&& inlineAttribute.MinInspectorWidth > 0f
&& shouldRespectMinWidth
&& inlineAttribute.MinInspectorWidth - effectiveWidth > 0.5f;
float horizontalScrollbarHeight = requiresHorizontalScroll
? GetHorizontalScrollbarHeight()
: 0f;
float paddingContribution = InLineEditorShared.ContentPadding * 2f;
float finalDisplayHeight =
displayHeight + horizontalScrollbarHeight + paddingContribution;
return new InspectorHeightInfo(
contentHeight,
finalDisplayHeight,
usesSerializedInspector,
horizontalScrollbarHeight,
requiresHorizontalScroll,
paddingContribution
);
}
private static bool TryCalculateSerializedInspectorHeight(
SerializedObject serializedObject,
out float contentHeight
)
{
contentHeight = 0f;
if (serializedObject == null)
{
return false;
}
contentHeight = CalculateSerializedInspectorHeight(serializedObject);
return true;
}
private static bool ShouldUseSerializedInspector(Editor editor)
{
if (editor == null)
{
return true;
}
return editor.GetType() == typeof(Editor);
}
private static float CalculateSerializedInspectorHeight(SerializedObject serializedObject)
{
if (serializedObject == null)
{
return 0f;
}
serializedObject.UpdateIfRequiredOrScript();
SerializedProperty iterator = serializedObject.GetIterator();
bool enterChildren = true;
float height = 0f;
bool firstPropertyMeasured = false;
while (iterator.NextVisible(enterChildren))
{
if (
string.Equals(
iterator.propertyPath,
InLineEditorShared.ScriptPropertyPath,
System.StringComparison.Ordinal
)
)
{
enterChildren = false;
continue;
}
if (firstPropertyMeasured)
{
height += EditorGUIUtility.standardVerticalSpacing;
}
height += EditorGUI.GetPropertyHeight(iterator, true);
enterChildren = false;
firstPropertyMeasured = true;
}
return Mathf.Max(0f, height);
}
private static SerializedObject GetSerializedObjectForAnalysis(Editor editor, Object value)
{
if (!SupportsSerializedInspectorTarget(value))
{
return null;
}
SerializedObject serializedObject =
editor != null ? editor.serializedObject : new SerializedObject(value);
return serializedObject;
}
private static bool SupportsSerializedInspectorTarget(Object value)
{
return value is ScriptableObject || value is MonoBehaviour;
}
private static bool SerializedObjectHasOnlySimpleProperties(
SerializedObject serializedObject
)
{
if (serializedObject == null)
{
return false;
}
SerializedProperty iterator = serializedObject.GetIterator();
bool enterChildren = true;
bool hasAnyProperty = false;
while (iterator.NextVisible(enterChildren))
{
if (
string.Equals(
iterator.propertyPath,
InLineEditorShared.ScriptPropertyPath,
System.StringComparison.Ordinal
)
)
{
enterChildren = false;
continue;
}
hasAnyProperty = true;
if (!IsSimpleSerializedProperty(iterator))
{
return false;
}
enterChildren = false;
}
return hasAnyProperty;
}
private static bool IsSimpleSerializedProperty(SerializedProperty property)
{
if (property == null)
{
return true;
}
// Check property type BEFORE isArray, since strings are arrays internally
// but should be considered simple (they render as single-line text fields)
switch (property.propertyType)
{
case SerializedPropertyType.String:
return true;
case SerializedPropertyType.Generic:
case SerializedPropertyType.AnimationCurve:
case SerializedPropertyType.Gradient:
case SerializedPropertyType.ManagedReference:
return false;
}
if (property.isArray)
{
return false;
}
return true;
}
private readonly struct InspectorHeightInfo
{
public InspectorHeightInfo(
float contentHeight,
float displayHeight,
bool usesSerializedInspector,
float horizontalScrollbarHeight,
bool requiresHorizontalScrollbar,
float paddingHeight
)
{
ContentHeight = Mathf.Max(0f, contentHeight);
DisplayHeight = Mathf.Max(0f, displayHeight);
UsesSerializedInspector = usesSerializedInspector;
HorizontalScrollbarHeight = Mathf.Max(0f, horizontalScrollbarHeight);
RequiresHorizontalScrollbar = requiresHorizontalScrollbar;
PaddingHeight = Mathf.Max(0f, paddingHeight);
}
public float ContentHeight { get; }
public float DisplayHeight { get; }
public bool UsesSerializedInspector { get; }
public float HorizontalScrollbarHeight { get; }
public bool RequiresHorizontalScrollbar { get; }
public float PaddingHeight { get; }
public static InspectorHeightInfo Empty =>
new InspectorHeightInfo(0f, 0f, false, 0f, false, 0f);
}
private static Rect GetInlineContentRect(Rect backgroundRect)
{
return new Rect(
backgroundRect.x + InLineEditorShared.ContentPadding,
backgroundRect.y + InLineEditorShared.ContentPadding,
backgroundRect.width - (InLineEditorShared.ContentPadding * 2f),
Mathf.Max(0f, backgroundRect.height - (InLineEditorShared.ContentPadding * 2f))
);
}
internal static Rect GetInlineContentRectForTesting(Rect backgroundRect)
{
return GetInlineContentRect(backgroundRect);
}
///
/// Test hook to get detailed height calculation info for diagnostics.
///
internal static (
float baseHeight,
float inlineHeight,
bool showHeader,
bool showBody,
float displayHeight
) GetHeightCalculationDetailsForTesting(
SerializedProperty property,
WInLineEditorAttribute inlineAttribute,
Object value,
float availableWidth
)
{
if (value == null || property == null)
{
return (0f, 0f, false, false, 0f);
}
float baseHeight = inlineAttribute.DrawObjectField
? EditorGUI.GetPropertyHeight(property, GUIContent.none, false)
: EditorGUIUtility.singleLineHeight;
WInLineEditorMode mode = InLineEditorShared.ResolveMode(inlineAttribute);
bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader(
inlineAttribute
);
bool showHeader =
useStandaloneHeader
&& (inlineAttribute.DrawHeader || mode != WInLineEditorMode.AlwaysExpanded);
bool foldoutState = GetFoldoutState(property, inlineAttribute, mode);
bool showBody = mode == WInLineEditorMode.AlwaysExpanded || foldoutState;
float inlineHeight = 0f;
float displayHeight = 0f;
if (showHeader)
{
inlineHeight += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing;
}
if (showBody)
{
InspectorHeightInfo inspectorHeightInfo = ResolveInspectorHeightInfo(
value,
inlineAttribute,
availableWidth
);
displayHeight = inspectorHeightInfo.DisplayHeight;
inlineHeight += displayHeight;
}
return (baseHeight, inlineHeight, showHeader, showBody, displayHeight);
}
///
/// Test hook to get extensive diagnostic info for debugging height calculation issues.
///
internal static string GetExtensiveDiagnosticsForTesting(
SerializedProperty property,
WInLineEditorAttribute inlineAttribute,
Object value,
float availableWidth
)
{
if (value == null || property == null)
{
return "null property or value";
}
System.Text.StringBuilder sb = new();
sb.AppendLine($"=== Extensive Diagnostics ===");
sb.AppendLine($"Property path: {property.propertyPath}");
sb.AppendLine($"Value type: {value.GetType().Name}");
sb.AppendLine($"Available width: {availableWidth}");
// Attribute info
sb.AppendLine($"--- Attribute ---");
sb.AppendLine($" Mode: {inlineAttribute.Mode}");
sb.AppendLine($" DrawObjectField: {inlineAttribute.DrawObjectField}");
sb.AppendLine($" DrawHeader: {inlineAttribute.DrawHeader}");
sb.AppendLine($" EnableScrolling: {inlineAttribute.EnableScrolling}");
sb.AppendLine($" InspectorHeight: {inlineAttribute.InspectorHeight}");
sb.AppendLine($" MinInspectorWidth: {inlineAttribute.MinInspectorWidth}");
sb.AppendLine(
$" HasExplicitMinInspectorWidth: {inlineAttribute.HasExplicitMinInspectorWidth}"
);
// Mode resolution
WInLineEditorMode resolvedMode = InLineEditorShared.ResolveMode(inlineAttribute);
sb.AppendLine($"--- Mode Resolution ---");
sb.AppendLine($" Resolved mode: {resolvedMode}");
if (inlineAttribute.Mode == WInLineEditorMode.UseSettings)
{
UnityHelpersSettings.InlineEditorFoldoutBehavior behavior =
UnityHelpersSettings.GetInlineEditorFoldoutBehavior();
sb.AppendLine($" Settings behavior: {behavior}");
}
// Foldout state
string foldoutKey = BuildFoldoutKey(property);
bool foldoutInCache = InLineEditorShared.GetFoldoutStateForTesting(foldoutKey);
bool foldoutState = GetFoldoutState(property, inlineAttribute, resolvedMode);
sb.AppendLine($"--- Foldout State ---");
sb.AppendLine($" Foldout key: {foldoutKey}");
sb.AppendLine($" In cache before GetFoldoutState: {foldoutInCache}");
sb.AppendLine($" GetFoldoutState result: {foldoutState}");
// Header/body visibility
bool useStandaloneHeader = InLineEditorShared.ShouldDrawStandaloneHeader(
inlineAttribute
);
bool showHeader =
useStandaloneHeader
&& (inlineAttribute.DrawHeader || resolvedMode != WInLineEditorMode.AlwaysExpanded);
bool showBody = resolvedMode == WInLineEditorMode.AlwaysExpanded || foldoutState;
sb.AppendLine($"--- Visibility ---");
sb.AppendLine($" useStandaloneHeader: {useStandaloneHeader}");
sb.AppendLine($" showHeader: {showHeader}");
sb.AppendLine($" showBody: {showBody}");
// Inspector height info
sb.AppendLine($"--- Inspector Height ---");
Editor editor = InLineEditorShared.GetOrCreateEditor(value);
SerializedObject analysisObject = GetSerializedObjectForAnalysis(editor, value);
bool hasSerializedData = analysisObject != null;
bool hasSimpleLayout =
hasSerializedData && SerializedObjectHasOnlySimpleProperties(analysisObject);
bool canUseSerializedInspector =
hasSerializedData && ShouldUseSerializedInspector(editor);
sb.AppendLine(
$" Editor type: {(editor != null ? editor.GetType().FullName : "null")}"
);
sb.AppendLine($" hasSerializedData: {hasSerializedData}");
sb.AppendLine($" hasSimpleLayout: {hasSimpleLayout}");
sb.AppendLine($" canUseSerializedInspector: {canUseSerializedInspector}");
if (hasSerializedData)
{
float serializedHeight = CalculateSerializedInspectorHeight(analysisObject);
sb.AppendLine($" Serialized inspector height: {serializedHeight}");
// List all properties
sb.AppendLine($" --- Properties ---");
analysisObject.UpdateIfRequiredOrScript();
SerializedProperty iterator = analysisObject.GetIterator();
bool enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
float propHeight = EditorGUI.GetPropertyHeight(iterator, true);
bool isScript = string.Equals(
iterator.propertyPath,
InLineEditorShared.ScriptPropertyPath,
System.StringComparison.Ordinal
);
sb.AppendLine(
$" {iterator.propertyPath}: {propHeight}px (type: {iterator.propertyType}){(isScript ? " [SCRIPT - skipped]" : "")}"
);
enterChildren = false;
}
}
InspectorHeightInfo heightInfo = ResolveInspectorHeightInfo(
value,
inlineAttribute,
availableWidth
);
sb.AppendLine($"--- Height Info Result ---");
sb.AppendLine($" ContentHeight: {heightInfo.ContentHeight}");
sb.AppendLine($" DisplayHeight: {heightInfo.DisplayHeight}");
sb.AppendLine($" UsesSerializedInspector: {heightInfo.UsesSerializedInspector}");
sb.AppendLine($" HorizontalScrollbarHeight: {heightInfo.HorizontalScrollbarHeight}");
sb.AppendLine(
$" RequiresHorizontalScrollbar: {heightInfo.RequiresHorizontalScrollbar}"
);
sb.AppendLine($" PaddingHeight: {heightInfo.PaddingHeight}");
// Final calculation
float inlineHeight = 0f;
if (showHeader)
{
inlineHeight += InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing;
}
if (showBody)
{
inlineHeight += heightInfo.DisplayHeight;
}
sb.AppendLine($"--- Final Inline Height ---");
sb.AppendLine(
$" Header contribution: {(showHeader ? InLineEditorShared.HeaderHeight + InLineEditorShared.Spacing : 0f)}"
);
sb.AppendLine($" Body contribution: {(showBody ? heightInfo.DisplayHeight : 0f)}");
sb.AppendLine($" Total inline height: {inlineHeight}");
return sb.ToString();
}
private static bool DrawHeader(
Rect rect,
SerializedProperty property,
Object value,
GUIContent label,
bool showFoldoutToggle,
bool foldoutState
)
{
float pingWidth = InLineEditorShared.GetPingButtonWidth();
bool showPingButton = InLineEditorShared.ShouldShowPingButton(value);
float headerSpacing = 0f;
float headerRightMargin = 0f;
if (showPingButton)
{
headerSpacing = InLineEditorShared.HeaderPingSpacing;
headerRightMargin = InLineEditorShared.PingButtonRightMargin;
bool hasSpace =
rect.width - pingWidth - headerSpacing - headerRightMargin
>= InLineEditorShared.MinimumFoldoutLabelWidth;
if (!hasSpace)
{
showPingButton = false;
headerSpacing = 0f;
headerRightMargin = 0f;
}
}
float labelWidth = showPingButton
? Mathf.Max(0f, rect.width - pingWidth - headerSpacing - headerRightMargin)
: rect.width;
Rect labelRect = new Rect(rect.x, rect.y, labelWidth, rect.height);
Rect pingRect = new Rect(
rect.x + labelWidth + (showPingButton ? headerSpacing : 0f),
rect.y,
pingWidth,
rect.height
);
GUIContent headerContent = InLineEditorShared.PrepareHeaderContent(value, label);
if (showFoldoutToggle)
{
bool newState = EditorGUI.Foldout(labelRect, foldoutState, headerContent, true);
if (newState != foldoutState)
{
foldoutState = newState;
}
}
else
{
EditorGUI.LabelField(labelRect, headerContent, EditorStyles.boldLabel);
}
if (showPingButton)
{
using (new EditorGUI.DisabledScope(value == null))
{
if (
GUI.Button(
pingRect,
InLineEditorShared.PingButtonContent,
EditorStyles.miniButton
)
)
{
EditorGUIUtility.PingObject(value);
}
}
}
return foldoutState;
}
private static string BuildFoldoutKey(SerializedProperty property)
{
Object target =
property.serializedObject != null ? property.serializedObject.targetObject : null;
int id = target != null ? target.GetInstanceID() : 0;
string propertyPath = property.propertyPath;
(int, string) cacheKey = (id, propertyPath);
if (!FoldoutKeyCache.TryGetValue(cacheKey, out string key))
{
key = InLineEditorShared.BuildFoldoutKey(id, propertyPath);
FoldoutKeyCache[cacheKey] = key;
}
return key;
}
private static string BuildScrollKey(SerializedProperty property)
{
Object target =
property.serializedObject != null ? property.serializedObject.targetObject : null;
int id = target != null ? target.GetInstanceID() : 0;
string propertyPath = property.propertyPath;
(int, string) cacheKey = (id, propertyPath);
if (!ScrollKeyCache.TryGetValue(cacheKey, out string key))
{
key = InLineEditorShared.BuildScrollKey(id, propertyPath);
ScrollKeyCache[cacheKey] = key;
}
return key;
}
internal static bool ShouldShowPingButton(Object value)
{
return InLineEditorShared.ShouldShowPingButton(value);
}
private static bool GetFoldoutState(
SerializedProperty property,
WInLineEditorAttribute inlineAttribute,
WInLineEditorMode resolvedMode
)
{
string key = BuildFoldoutKey(property);
return InLineEditorShared.GetFoldoutState(key, resolvedMode);
}
private static void SetFoldoutState(string key, bool value)
{
InLineEditorShared.SetFoldoutState(key, value);
}
private static AnimBool GetOrCreateFoldoutAnim(string foldoutKey, bool expanded)
{
float speed = UnityHelpersSettings.GetInlineEditorFoldoutSpeed();
if (!FoldoutAnimations.TryGetValue(foldoutKey, out AnimBool anim) || anim == null)
{
anim = new AnimBool(expanded) { speed = speed };
anim.valueChanged.AddListener(RequestRepaint);
FoldoutAnimations[foldoutKey] = anim;
}
anim.speed = speed;
anim.target = expanded;
return anim;
}
private static float GetFadeProgress(string foldoutKey, bool expanded)
{
if (!UnityHelpersSettings.ShouldTweenInlineEditorFoldouts())
{
return expanded ? 1f : 0f;
}
AnimBool anim = GetOrCreateFoldoutAnim(foldoutKey, expanded);
return anim.faded;
}
private static void RequestRepaint()
{
InternalEditorUtility.RepaintAllViews();
}
internal static void ClearAnimationCacheForTesting()
{
foreach (KeyValuePair kvp in FoldoutAnimations)
{
AnimBool anim = kvp.Value;
if (anim != null)
{
anim.valueChanged.RemoveListener(RequestRepaint);
}
}
FoldoutAnimations.Clear();
}
///
/// Test hook to get the number of cached animation entries.
///
internal static int GetAnimationCacheCountForTesting()
{
return FoldoutAnimations.Count;
}
///
/// Test hook to check if an animation entry exists for a specific key.
///
internal static bool HasAnimationCacheEntryForTesting(string foldoutKey)
{
return FoldoutAnimations.ContainsKey(foldoutKey);
}
///
/// Test hook to get or create a foldout animation for testing purposes.
///
internal static AnimBool GetOrCreateFoldoutAnimForTesting(string foldoutKey, bool expanded)
{
return GetOrCreateFoldoutAnim(foldoutKey, expanded);
}
///
/// Test hook to get the fade progress for a foldout.
///
internal static float GetFadeProgressForTesting(string foldoutKey, bool expanded)
{
return GetFadeProgress(foldoutKey, expanded);
}
///
/// Test hook to build a foldout key from a serialized property.
///
internal static string BuildFoldoutKeyForTesting(SerializedProperty property)
{
return BuildFoldoutKey(property);
}
internal static void ClearCachedStateForTesting()
{
InLineEditorShared.ClearCachedStateForTesting();
PropertyWidths.Clear();
InspectorHeightCache.Clear();
_lastInspectorHeightCacheFrame = -1;
ClearAnimationCacheForTesting();
}
internal static void SetInlineFoldoutStateForTesting(
SerializedProperty property,
bool expanded
)
{
if (property == null)
{
return;
}
string key = BuildFoldoutKey(property);
InLineEditorShared.SetFoldoutState(key, expanded);
}
internal static bool UsesHorizontalScrollbarForTesting(
Object value,
WInLineEditorAttribute inlineAttribute,
float availableWidth
)
{
if (value == null || inlineAttribute == null)
{
return false;
}
InspectorHeightInfo inspectorHeightInfo = ResolveInspectorHeightInfo(
value,
inlineAttribute,
availableWidth
);
return inspectorHeightInfo.RequiresHorizontalScrollbar;
}
///
/// Test hook to directly check if a SerializedObject has only simple properties.
/// This allows unit testing the simple layout detection without full editor integration.
///
internal static bool HasOnlySimplePropertiesForTesting(SerializedObject serializedObject)
{
return SerializedObjectHasOnlySimpleProperties(serializedObject);
}
///
/// Test hook to directly check horizontal scrollbar requirement with explicit parameters.
/// This bypasses editor creation and allows testing the decision logic directly.
///
internal static bool RequiresHorizontalScrollbarForTesting(
bool enableScrolling,
float minInspectorWidth,
bool hasExplicitMinInspectorWidth,
bool hasSimpleLayout,
float availableWidth
)
{
float effectiveWidth = Mathf.Max(
0f,
availableWidth - (InLineEditorShared.ContentPadding * 2f)
);
// Match the production logic: also trigger scroll when width is very narrow
const float MinimumUsableWidth = 200f;
bool widthIsTooNarrow = effectiveWidth < MinimumUsableWidth;
bool shouldRespectMinWidth =
hasExplicitMinInspectorWidth || !hasSimpleLayout || widthIsTooNarrow;
return enableScrolling
&& minInspectorWidth > 0f
&& shouldRespectMinWidth
&& minInspectorWidth - effectiveWidth > 0.5f;
}
///
/// Test hook to check if the force serialized inspector flag is enabled.
///
internal static bool ForceSerializedInspectorForTesting => _forceSerializedInspector;
///
/// Test hook to calculate label width for a given available width.
///
internal static float CalculateLabelWidthForTesting(float availableWidth)
{
return availableWidth * InLineEditorShared.DefaultLabelWidthRatio;
}
}
}