// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
// ReSharper disable ArrangeRedundantParentheses
namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers
{
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Serialization;
using System.Text;
using UnityEditor;
using UnityEditor.AnimatedValues;
using UnityEditorInternal;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.DataStructure.Adapters;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Settings;
using WallstopStudios.UnityHelpers.Editor.Utils;
using WallstopStudios.UnityHelpers.Utils;
using Debug = UnityEngine.Debug;
using Object = UnityEngine.Object;
///
/// Diagnostics helper for debugging SerializableDictionary/Set foldout tweening issues,
/// especially within WGroups.
///
///
/// Enable logging by setting to true. Logs are written to the Unity console
/// with the prefix "[DictTween]" for easy filtering.
///
internal static class SerializableCollectionTweenDiagnostics
{
///
/// When true, enables diagnostic logging for tweening-related calculations.
///
internal static bool Enabled { get; set; } = false;
///
/// When set, only logs for properties matching this path (substring match).
/// Leave null to log all properties.
///
internal static string PropertyPathFilter { get; set; } = null;
///
/// When true, only logs when inside a WGroup context (scope depth > 0).
///
internal static bool OnlyInWGroup { get; set; } = false;
private const string LogPrefix = "[DictTween] ";
private static bool ShouldLog(string propertyPath)
{
if (!Enabled)
{
return false;
}
if (OnlyInWGroup && GroupGUIWidthUtility.CurrentScopeDepth <= 0)
{
return false;
}
if (!string.IsNullOrEmpty(PropertyPathFilter))
{
if (string.IsNullOrEmpty(propertyPath))
{
return false;
}
if (
propertyPath.IndexOf(PropertyPathFilter, StringComparison.OrdinalIgnoreCase) < 0
)
{
return false;
}
}
return true;
}
internal static void LogAnimBoolState(
string context,
string propertyPath,
bool isExpanded,
float animFaded,
float animTarget,
float animSpeed,
bool animIsAnimating
)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}{context}: path={propertyPath}, "
+ $"expanded={isExpanded}, faded={animFaded:F4}, target={animTarget:F1}, "
+ $"speed={animSpeed:F2}, isAnimating={animIsAnimating}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogFoldoutProgressCalculation(
string context,
string propertyPath,
bool shouldTween,
bool isExpanded,
float computedProgress,
bool hasAnimBool
)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}{context}: path={propertyPath}, "
+ $"shouldTween={shouldTween}, expanded={isExpanded}, "
+ $"progress={computedProgress:F4}, hasAnimBool={hasAnimBool}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogPendingSectionHeightCalc(
string propertyPath,
float collapsedHeight,
float expandedExtraHeight,
float foldoutProgress,
float finalHeight
)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}PendingSectionHeight: path={propertyPath}, "
+ $"collapsed={collapsedHeight:F2}, extraExpanded={expandedExtraHeight:F2}, "
+ $"progress={foldoutProgress:F4}, final={finalHeight:F2}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogTweenSettingsQuery(
string context,
string propertyPath,
bool isSorted,
bool tweenEnabled,
float tweenSpeed
)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}{context}: path={propertyPath}, isSorted={isSorted}, "
+ $"tweenEnabled={tweenEnabled}, speed={tweenSpeed:F2}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogAnimBoolCreation(
string propertyPath,
bool initialValue,
bool isSorted,
float speed
)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}AnimBool Created: path={propertyPath}, "
+ $"initial={initialValue}, isSorted={isSorted}, speed={speed:F2}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogAnimBoolDestroyed(string propertyPath, string reason)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}AnimBool Destroyed: path={propertyPath}, reason={reason}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogExpandedStateChange(
string propertyPath,
bool oldExpanded,
bool newExpanded,
float currentProgress
)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}ExpandedChange: path={propertyPath}, "
+ $"old={oldExpanded}, new={newExpanded}, currentProgress={currentProgress:F4}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogRepaintRequest(string propertyPath, string source)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}RepaintRequested: path={propertyPath}, source={source}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogContentFadeApplication(
string propertyPath,
float contentFade,
bool isVisible,
bool skipContentDraw
)
{
if (!ShouldLog(propertyPath))
{
return;
}
Debug.Log(
$"{LogPrefix}ContentFade: path={propertyPath}, "
+ $"fade={contentFade:F4}, visible={isVisible}, skipDraw={skipContentDraw}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
///
/// Logs a comprehensive dump of all tween-related settings from UnityHelpersSettings.
/// Call this once per session or when debugging settings issues.
///
internal static void LogAllTweenSettings(string context)
{
if (!Enabled)
{
return;
}
bool dictEnabled = UnityHelpersSettings.ShouldTweenSerializableDictionaryFoldouts();
float dictSpeed = UnityHelpersSettings.GetSerializableDictionaryFoldoutSpeed();
bool sortedDictEnabled =
UnityHelpersSettings.ShouldTweenSerializableSortedDictionaryFoldouts();
float sortedDictSpeed =
UnityHelpersSettings.GetSerializableSortedDictionaryFoldoutSpeed();
bool setEnabled = UnityHelpersSettings.ShouldTweenSerializableSetFoldouts();
float setSpeed = UnityHelpersSettings.GetSerializableSetFoldoutSpeed();
bool sortedSetEnabled = UnityHelpersSettings.ShouldTweenSerializableSortedSetFoldouts();
float sortedSetSpeed = UnityHelpersSettings.GetSerializableSortedSetFoldoutSpeed();
bool wgroupEnabled = UnityHelpersSettings.ShouldTweenWGroupFoldouts();
float wgroupSpeed = UnityHelpersSettings.GetWGroupFoldoutSpeed();
Debug.Log(
$"{LogPrefix}AllSettings ({context}): "
+ $"dict=[enabled={dictEnabled}, speed={dictSpeed:F2}], "
+ $"sortedDict=[enabled={sortedDictEnabled}, speed={sortedDictSpeed:F2}], "
+ $"set=[enabled={setEnabled}, speed={setSpeed:F2}], "
+ $"sortedSet=[enabled={sortedSetEnabled}, speed={sortedSetSpeed:F2}], "
+ $"wgroup=[enabled={wgroupEnabled}, speed={wgroupSpeed:F2}], "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
///
/// Logs detailed AnimBool timing information for debugging animation state.
///
internal static void LogAnimBoolTiming(
string context,
string propertyPath,
AnimBool anim,
bool expectedTarget
)
{
if (!ShouldLog(propertyPath))
{
return;
}
if (anim == null)
{
Debug.Log(
$"{LogPrefix}{context}: path={propertyPath}, AnimBool=null, "
+ $"expectedTarget={expectedTarget}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
return;
}
bool targetMismatch = anim.target != expectedTarget;
bool valueMismatch = !Mathf.Approximately(anim.faded, expectedTarget ? 1f : 0f);
Debug.Log(
$"{LogPrefix}{context}: path={propertyPath}, "
+ $"target={anim.target}, faded={anim.faded:F4}, "
+ $"expectedTarget={expectedTarget}, speed={anim.speed:F2}, "
+ $"isAnimating={anim.isAnimating}, "
+ $"targetMismatch={targetMismatch}, valueMismatch={valueMismatch}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
///
/// Logs mouse event information for debugging click handling.
///
internal static void LogMouseEvent(
string context,
string propertyPath,
EventType eventType,
Vector2 mousePosition,
Rect hitRect,
bool isInside
)
{
if (!ShouldLog(propertyPath))
{
return;
}
// Only log relevant mouse events to avoid spam
if (
eventType != EventType.MouseDown
&& eventType != EventType.MouseUp
&& eventType != EventType.Used
)
{
return;
}
Debug.Log(
$"{LogPrefix}{context}: path={propertyPath}, "
+ $"eventType={eventType}, mouse=({mousePosition.x:F1},{mousePosition.y:F1}), "
+ $"hitRect=({hitRect.x:F1},{hitRect.y:F1},{hitRect.width:F1},{hitRect.height:F1}), "
+ $"isInside={isInside}, "
+ $"wgroupDepth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
}
///
/// Diagnostics helper for debugging SerializableDictionary indentation issues,
/// especially within WGroups and UnityHelpersSettings.
///
///
/// Enable logging by setting to true. Logs are written to the Unity console
/// with the prefix "[DictIndent]" for easy filtering.
///
internal static class SerializableDictionaryIndentDiagnostics
{
///
/// When true, enables diagnostic logging for indentation-related calculations.
///
internal static bool Enabled { get; set; } = false;
///
/// When set, only logs for properties matching this path (substring match).
/// Leave null to log all properties.
///
internal static string PropertyPathFilter { get; set; } = null;
///
/// When true, only logs for properties targeting UnityHelpersSettings.
///
internal static bool OnlyUnityHelpersSettings { get; set; } = false;
private const string LogPrefix = "[DictIndent] ";
private static bool ShouldLog(string propertyPath, bool isSettings)
{
if (!Enabled)
{
return false;
}
if (OnlyUnityHelpersSettings && !isSettings)
{
return false;
}
if (!string.IsNullOrEmpty(PropertyPathFilter))
{
if (string.IsNullOrEmpty(propertyPath))
{
return false;
}
if (
propertyPath.IndexOf(PropertyPathFilter, StringComparison.OrdinalIgnoreCase) < 0
)
{
return false;
}
}
return true;
}
internal static void LogOnGUIEntry(
Rect originalPosition,
SerializedProperty property,
bool targetsSettings,
int indentLevel
)
{
string propertyPath = property?.propertyPath ?? "(null)";
if (!ShouldLog(propertyPath, targetsSettings))
{
return;
}
Debug.Log(
$"{LogPrefix}OnGUI Entry: path={propertyPath}, targetsSettings={targetsSettings}, "
+ $"indentLevel={indentLevel}, originalPos={FormatRect(originalPosition)}"
);
}
internal static void LogResolveContentRect(
Rect inputRect,
Rect outputRect,
bool skipIndentation,
float leftPadding,
float rightPadding,
int scopeDepth,
int indentLevel
)
{
if (!Enabled)
{
return;
}
Debug.Log(
$"{LogPrefix}ResolveContentRect: skip={skipIndentation}, "
+ $"leftPad={leftPadding:F2}, rightPad={rightPadding:F2}, scopeDepth={scopeDepth}, "
+ $"indentLevel={indentLevel}, input={FormatRect(inputRect)}, output={FormatRect(outputRect)}"
);
}
internal static void LogResolveContentRectSteps(
Rect original,
Rect afterPadding,
Rect afterIndent,
Rect final,
bool skipIndentation,
float leftPadding,
float rightPadding,
int indentLevel
)
{
if (!Enabled)
{
return;
}
Debug.Log(
$"{LogPrefix}ResolveContentRect Steps: skip={skipIndentation}, "
+ $"leftPad={leftPadding:F2}, rightPad={rightPadding:F2}, indent={indentLevel}\n"
+ $" original = {FormatRect(original)}\n"
+ $" afterPad = {FormatRect(afterPadding)}\n"
+ $" afterIndent= {FormatRect(afterIndent)}\n"
+ $" final = {FormatRect(final)}"
);
}
internal static void LogDrawPendingEntryUI(
string propertyPath,
Rect position,
float pendingY,
bool targetsSettings,
int indentLevel
)
{
if (!ShouldLog(propertyPath, targetsSettings))
{
return;
}
Debug.Log(
$"{LogPrefix}DrawPendingEntryUI: path={propertyPath}, targetsSettings={targetsSettings}, "
+ $"indentLevel={indentLevel}, pendingY={pendingY:F2}, position={FormatRect(position)}"
);
}
internal static void LogListDoList(
string propertyPath,
Rect listRect,
bool targetsSettings,
int indentLevelBefore,
int indentLevelDuring
)
{
if (!ShouldLog(propertyPath, targetsSettings))
{
return;
}
Debug.Log(
$"{LogPrefix}DoList: path={propertyPath}, targetsSettings={targetsSettings}, "
+ $"indentBefore={indentLevelBefore}, indentDuring={indentLevelDuring}, "
+ $"listRect={FormatRect(listRect)}"
);
}
internal static void LogGroupPaddingState(string context)
{
if (!Enabled)
{
return;
}
Debug.Log(
$"{LogPrefix}GroupPadding ({context}): "
+ $"left={GroupGUIWidthUtility.CurrentLeftPadding:F2}, "
+ $"right={GroupGUIWidthUtility.CurrentRightPadding:F2}, "
+ $"total={GroupGUIWidthUtility.CurrentHorizontalPadding:F2}, "
+ $"depth={GroupGUIWidthUtility.CurrentScopeDepth}"
);
}
internal static void LogDrawRowElement(
string propertyPath,
int index,
int globalIndex,
Rect rect,
float keyWidth,
float valueWidth,
bool targetsSettings
)
{
if (!ShouldLog(propertyPath, targetsSettings))
{
return;
}
Debug.Log(
$"{LogPrefix}DrawRow: path={propertyPath}, index={index}, globalIdx={globalIndex}, "
+ $"rect={FormatRect(rect)}, keyW={keyWidth:F2}, valueW={valueWidth:F2}"
);
}
private static string FormatRect(Rect r)
{
return $"(x={r.x:F1}, y={r.y:F1}, w={r.width:F1}, h={r.height:F1})";
}
}
[CustomPropertyDrawer(typeof(SerializableDictionary<,>), true)]
[CustomPropertyDrawer(typeof(SerializableSortedDictionary<,>), true)]
[CustomPropertyDrawer(typeof(SerializableSortedDictionary<,,>), true)]
public sealed class SerializableDictionaryPropertyDrawer : PropertyDrawer
{
private readonly Dictionary _lists = new();
private readonly Dictionary _pendingEntries = new();
private readonly Dictionary _paginationStates = new();
private readonly Dictionary _pageCaches = new();
private readonly Dictionary _keyIndexCaches = new();
private readonly Dictionary _duplicateStates = new();
private readonly Dictionary _nullKeyStates = new();
private readonly Dictionary _rowValueFoldoutStates = new();
private readonly Dictionary _valueTypes = new();
private readonly Dictionary _sortedOrderHashes = new();
private readonly Dictionary _heightCache = new();
private readonly Dictionary _rowRenderCache = new();
private readonly Dictionary _cachedPropertyPairs = new();
private readonly HashSet _primedFoldoutCaches = new();
private string _cachedPropertyPath;
private string _cachedListKey;
private SerializedObject _cachedSerializedObject;
private int _lastDuplicateRefreshFrame = -1;
private int _lastNullKeyRefreshFrame = -1;
private int _lastRowRenderCacheFrame = -1;
private int _lastPropertyPairCacheFrame = -1;
private int _lastPrimedFoldoutFrame = -1;
private sealed class CachedPropertyPair
{
public SerializedProperty keysProperty;
public SerializedProperty valuesProperty;
}
private sealed class HeightCacheEntry
{
public float height;
public int arraySize;
public int pageIndex;
public bool isExpanded;
public bool hasNullKeys;
public bool hasDuplicates;
public bool pendingIsExpanded;
public float pendingFoldoutProgress;
public float mainFoldoutProgress;
public int frameNumber;
}
private readonly struct RowRenderKey : IEquatable
{
public readonly string listKey;
public readonly int globalIndex;
public RowRenderKey(string listKey, int globalIndex)
{
this.listKey = listKey;
this.globalIndex = globalIndex;
}
public bool Equals(RowRenderKey other)
{
return globalIndex == other.globalIndex && listKey == other.listKey;
}
public override bool Equals(object obj)
{
return obj is RowRenderKey other && Equals(other);
}
public override int GetHashCode()
{
return Objects.HashCode(listKey, globalIndex);
}
}
private sealed class RowRenderData
{
public SerializedProperty keyProperty;
public SerializedProperty valueProperty;
public float rowHeight;
public float keyHeight;
public float valueHeight;
public bool valueSupportsFoldout;
public bool isValid;
}
internal Rect LastResolvedPosition { get; private set; }
internal Rect LastListRect { get; private set; }
internal bool HasLastListRect { get; private set; }
// Tracks whether the current OnGUI call was initiated inside a WGroup context.
// Used to apply custom backgrounds over Unity's default ReorderableList styling.
private bool _currentDrawInsideWGroup;
private static readonly BindingFlags ReflectionBindingFlags =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private static readonly char[] PropertyPathSeparators = { '.' };
private const float PendingSectionPadding = 6f;
private const float PendingSectionPaddingProjectSettings = 2f;
internal const float PendingFoldoutToggleOffset = 17.5f;
internal const float PendingFoldoutToggleOffsetProjectSettings = 7.5f;
internal const float PendingFoldoutLabelPadding = 0f;
internal const float PendingFoldoutLabelContentOffset = -3f;
private const float PendingFoldoutInspectorLabelShift = 2.5f;
internal const float WGroupFoldoutAlignmentOffset = 2.5f;
internal const float DictionaryRowFieldPadding = 4f;
internal const float DictionaryRowKeyColumnMinWidth = 110f;
internal const float DictionaryRowValueColumnMinWidth = 150f;
internal const float DictionaryRowComplexValueMinWidth = 230f;
internal const float DictionaryRowKeyValueGap = 8f;
internal const float DictionaryRowFoldoutGapBoost = 4f;
private const float DictionaryRowSimpleValueWidthRatio = 0.54f;
private const float DictionaryRowComplexValueWidthRatio = 0.64f;
private const float DictionaryRowChildLabelWidthRatio = 0.3f;
private const float DictionaryRowChildLabelWidthMin = 32f;
private const float DictionaryRowChildLabelWidthMax = 96f;
private const float DictionaryRowChildHorizontalPadding = 2f;
private const float DictionaryRowChildLabelTextPadding = 6f;
internal const float PendingFieldLabelWidth = 72f;
internal const float PendingValueContentLeftShift = 8.5f;
internal const float PendingFoldoutValueLeftShiftReduction = 3f;
internal const float PendingFoldoutValueRightShift = 5.5f;
internal const float RowValueFoldoutLabelWidth = 2f;
internal const float ExpandableValueFoldoutLabelWidth = 16f;
internal const float PendingExpandableValueFoldoutGutter = 7f;
internal const float RowExpandableValueFoldoutGutter = 24f;
private const float PendingAddButtonWidth = 110f;
private const int DefaultPageSize =
UnityHelpersSettings.DefaultSerializableDictionaryPageSize;
private const int MaxPageSize = UnityHelpersSettings.MaxSerializableDictionaryPageSize;
private const int DuplicateSummaryDisplayLimit = 5;
private const float PaginationButtonWidth = 28f;
private const float PaginationLabelWidth = 80f;
private const float PaginationControlSpacing = 4f;
private const float DuplicateShakeAmplitude = 2f;
private const float DuplicateShakeFrequency = 7f;
private const float DuplicateBorderThickness = 1f;
private static readonly Color LightRowColor = new(0.97f, 0.97f, 0.97f, 1f);
private static readonly Color DarkRowColor = new(0.16f, 0.16f, 0.16f, 0.45f);
// Opaque versions for header/footer backgrounds to fully cover Unity's default styling
private static readonly Color LightHeaderColor = new(0.85f, 0.85f, 0.85f, 1f);
private static readonly Color DarkHeaderColor = new(0.22f, 0.22f, 0.22f, 1f);
private static readonly Color LightSelectionColor = new(0.33f, 0.62f, 0.95f, 0.65f);
private static readonly Color DarkSelectionColor = new(0.2f, 0.45f, 0.85f, 0.7f);
private static readonly ConcurrentDictionary<
Type,
Func