// MIT License - Copyright (c) 2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE #if UNITY_EDITOR namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers { using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Extension; using WallstopStudios.UnityHelpers.Utils; /// /// Custom property drawer for that validates type names /// and displays the resolved type information. /// [CustomPropertyDrawer(typeof(PoolTypeConfiguration))] public sealed class PoolTypeConfigurationDrawer : PropertyDrawer { internal sealed class DrawerState { public string lastTypeName = string.Empty; public Type resolvedType; public bool isValid; public string statusMessage = string.Empty; public MessageType messageType = MessageType.None; public readonly GUIContent statusContent = new(); } private const string TypeNameFieldName = nameof(PoolTypeConfiguration._typeName); private const string EnabledFieldName = nameof(PoolTypeConfiguration._enabled); private const string IdleTimeoutFieldName = nameof( PoolTypeConfiguration._idleTimeoutSeconds ); private const string MinRetainCountFieldName = nameof( PoolTypeConfiguration._minRetainCount ); private const string MaxPoolSizeFieldName = nameof(PoolTypeConfiguration._maxPoolSize); private const string BufferMultiplierFieldName = nameof( PoolTypeConfiguration._bufferMultiplier ); private const string RollingWindowFieldName = nameof( PoolTypeConfiguration._rollingWindowSeconds ); private const string HysteresisFieldName = nameof(PoolTypeConfiguration._hysteresisSeconds); private const string SpikeThresholdFieldName = nameof( PoolTypeConfiguration._spikeThresholdMultiplier ); private const float StatusBoxMargin = 4f; private const float MinStatusBoxWidth = 200f; private static readonly Dictionary States = new(); static PoolTypeConfigurationDrawer() { // Clear cached states on domain reload to prevent stale references AssemblyReloadEvents.beforeAssemblyReload += ClearCachedStates; } private static readonly GUIContent TypeNameLabel = new( "Type Name", "Type name in any supported format:\n" + "- List (simplified closed generic)\n" + "- List<> (simplified open generic)\n" + "- Dictionary (multiple args)\n" + "- Dictionary<,> (open with multiple args)\n" + "- List> (nested generics)\n" + "- System.Collections.Generic.List`1 (CLR syntax)" ); private static readonly GUIContent EnabledLabel = new( "Enabled", "Whether intelligent pool purging is enabled for this type." ); private static readonly GUIContent IdleTimeoutLabel = new( "Idle Timeout (s)", "Idle timeout in seconds before items become eligible for purging." ); private static readonly GUIContent MinRetainCountLabel = new( "Min Retain", "Minimum number of items to always retain during purge operations." ); private static readonly GUIContent MaxPoolSizeLabel = new( "Max Size", "Maximum pool size (0 = unbounded)." ); private static readonly GUIContent BufferMultiplierLabel = new( "Buffer", "Buffer multiplier for comfortable pool size calculation." ); private static readonly GUIContent RollingWindowLabel = new( "Window (s)", "Rolling window duration in seconds for high water mark tracking." ); private static readonly GUIContent HysteresisLabel = new( "Hysteresis (s)", "Hysteresis duration in seconds. Purging is suppressed after a usage spike." ); private static readonly GUIContent SpikeThresholdLabel = new( "Spike", "Spike threshold multiplier." ); private static readonly GUIContent ReusableHeaderLabel = new(); public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { if (!property.isExpanded) { return EditorGUIUtility.singleLineHeight; } float lineHeight = EditorGUIUtility.singleLineHeight; float spacing = EditorGUIUtility.standardVerticalSpacing; // Header + Type Name + Status box + Enabled + 7 numeric fields int lineCount = 10; float totalHeight = lineHeight + (lineCount * (lineHeight + spacing)); DrawerState state = GetState(property); if (!state.isValid && !string.IsNullOrEmpty(state.statusMessage)) { state.statusContent.text = state.statusMessage; float statusWidth = GetStatusBoxWidth(); float statusHeight = EditorStyles.helpBox.CalcHeight( state.statusContent, statusWidth ); totalHeight += statusHeight + spacing; } else if (state.isValid && !string.IsNullOrEmpty(state.statusMessage)) { // Info message for valid types state.statusContent.text = state.statusMessage; float statusWidth = GetStatusBoxWidth(); float statusHeight = EditorStyles.helpBox.CalcHeight( state.statusContent, statusWidth ); totalHeight += statusHeight + spacing; } return totalHeight; } public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { DrawerState state = GetState(property); SerializedProperty typeNameProp = property.FindPropertyRelative(TypeNameFieldName); SerializedProperty enabledProp = property.FindPropertyRelative(EnabledFieldName); SerializedProperty idleTimeoutProp = property.FindPropertyRelative( IdleTimeoutFieldName ); SerializedProperty minRetainProp = property.FindPropertyRelative( MinRetainCountFieldName ); SerializedProperty maxPoolSizeProp = property.FindPropertyRelative( MaxPoolSizeFieldName ); SerializedProperty bufferProp = property.FindPropertyRelative( BufferMultiplierFieldName ); SerializedProperty rollingWindowProp = property.FindPropertyRelative( RollingWindowFieldName ); SerializedProperty hysteresisProp = property.FindPropertyRelative(HysteresisFieldName); SerializedProperty spikeThresholdProp = property.FindPropertyRelative( SpikeThresholdFieldName ); if (typeNameProp == null) { EditorGUI.PropertyField(position, property, label, true); return; } // Update validation state string currentTypeName = typeNameProp.stringValue; if (!string.Equals(state.lastTypeName, currentTypeName, StringComparison.Ordinal)) { state.lastTypeName = currentTypeName; ValidateTypeName(state, currentTypeName); } EditorGUI.BeginProperty(position, label, property); float lineHeight = EditorGUIUtility.singleLineHeight; float spacing = EditorGUIUtility.standardVerticalSpacing; float currentY = position.y; // Foldout header with validation indicator Rect foldoutRect = new(position.x, currentY, position.width, lineHeight); if (string.IsNullOrWhiteSpace(currentTypeName)) { ReusableHeaderLabel.text = label.text + " (empty)"; ReusableHeaderLabel.tooltip = label.tooltip; } else if (state.isValid) { string displayName = PoolTypeResolver.GetDisplayName(state.resolvedType); string prefix = state.resolvedType.IsGenericTypeDefinition ? "[Open] " : ""; ReusableHeaderLabel.text = $"{label.text}: {prefix}{displayName}"; ReusableHeaderLabel.tooltip = label.tooltip; } else { ReusableHeaderLabel.text = $"{label.text}: {currentTypeName} (invalid)"; ReusableHeaderLabel.tooltip = label.tooltip; } property.isExpanded = EditorGUI.Foldout( foldoutRect, property.isExpanded, ReusableHeaderLabel, true ); currentY += lineHeight + spacing; if (!property.isExpanded) { EditorGUI.EndProperty(); return; } EditorGUI.indentLevel++; // Type Name field Rect typeNameRect = new(position.x, currentY, position.width, lineHeight); EditorGUI.PropertyField(typeNameRect, typeNameProp, TypeNameLabel); currentY += lineHeight + spacing; // Status/validation message if (!string.IsNullOrEmpty(state.statusMessage)) { state.statusContent.text = state.statusMessage; float statusHeight = EditorStyles.helpBox.CalcHeight( state.statusContent, position.width - StatusBoxMargin * 2 ); Rect statusRect = new( position.x + StatusBoxMargin, currentY, position.width - StatusBoxMargin * 2, statusHeight ); EditorGUI.HelpBox(statusRect, state.statusMessage, state.messageType); currentY += statusHeight + spacing; } // Enabled toggle Rect enabledRect = new(position.x, currentY, position.width, lineHeight); if (enabledProp != null) { EditorGUI.PropertyField(enabledRect, enabledProp, EnabledLabel); } currentY += lineHeight + spacing; // Numeric fields in two columns float halfWidth = (position.width - spacing) / 2f; float indent = EditorGUI.indentLevel * 15f; // Row 1: Idle Timeout / Min Retain DrawTwoColumnRow( position, ref currentY, lineHeight, spacing, halfWidth, indent, idleTimeoutProp, IdleTimeoutLabel, minRetainProp, MinRetainCountLabel ); // Row 2: Max Size / Buffer DrawTwoColumnRow( position, ref currentY, lineHeight, spacing, halfWidth, indent, maxPoolSizeProp, MaxPoolSizeLabel, bufferProp, BufferMultiplierLabel ); // Row 3: Rolling Window / Hysteresis DrawTwoColumnRow( position, ref currentY, lineHeight, spacing, halfWidth, indent, rollingWindowProp, RollingWindowLabel, hysteresisProp, HysteresisLabel ); // Row 4: Spike Threshold (single column) Rect spikeRect = new(position.x, currentY, position.width, lineHeight); if (spikeThresholdProp != null) { EditorGUI.PropertyField(spikeRect, spikeThresholdProp, SpikeThresholdLabel); } EditorGUI.indentLevel--; EditorGUI.EndProperty(); } private static void DrawTwoColumnRow( Rect position, ref float currentY, float lineHeight, float spacing, float halfWidth, float indent, SerializedProperty leftProp, GUIContent leftLabel, SerializedProperty rightProp, GUIContent rightLabel ) { Rect leftRect = new(position.x, currentY, halfWidth, lineHeight); Rect rightRect = new(position.x + halfWidth + spacing, currentY, halfWidth, lineHeight); if (leftProp != null) { EditorGUI.PropertyField(leftRect, leftProp, leftLabel); } // Temporarily reset indent for right column int prevIndent = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; Rect adjustedRightRect = new( rightRect.x + indent, rightRect.y, rightRect.width - indent, rightRect.height ); if (rightProp != null) { EditorGUI.PropertyField(adjustedRightRect, rightProp, rightLabel); } EditorGUI.indentLevel = prevIndent; currentY += lineHeight + spacing; } private static void ValidateTypeName(DrawerState state, string typeName) { if (string.IsNullOrWhiteSpace(typeName)) { state.resolvedType = null; state.isValid = false; state.statusMessage = string.Empty; state.messageType = MessageType.None; return; } Type resolved = PoolTypeResolver.ResolveType(typeName); if (resolved == null) { state.resolvedType = null; state.isValid = false; state.statusMessage = $"Unable to resolve type: {typeName}"; state.messageType = MessageType.Warning; return; } state.resolvedType = resolved; state.isValid = true; if (resolved.IsGenericTypeDefinition) { string displayName = PoolTypeResolver.GetDisplayName(resolved); state.statusMessage = $"Open generic pattern: matches all {displayName} types"; state.messageType = MessageType.Info; } else if (resolved.IsGenericType) { string displayName = PoolTypeResolver.GetDisplayName(resolved); state.statusMessage = $"Resolved: {displayName}"; state.messageType = MessageType.Info; } else { state.statusMessage = $"Resolved: {resolved.FullName}"; state.messageType = MessageType.Info; } } internal static DrawerState GetState(SerializedProperty property) { string key = property.propertyPath; return States.GetOrAdd(key); } internal static void ClearCachedStates() { States.Clear(); } private static float GetStatusBoxWidth() { try { return Mathf.Max( MinStatusBoxWidth, EditorGUIUtility.currentViewWidth - StatusBoxMargin * 2 ); } catch (ArgumentException) { return MinStatusBoxWidth; } } } } #endif