// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers { #if UNITY_EDITOR using System; using System.Collections; using System.Collections.Generic; using System.Reflection; using Extensions; using UnityEditor; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Attributes; using WallstopStudios.UnityHelpers.Core.Extension; using WallstopStudios.UnityHelpers.Core.Helper; using WallstopStudios.UnityHelpers.Editor.Core.Helper; using WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils; using WallstopStudios.UnityHelpers.Utils; [CustomPropertyDrawer(typeof(WShowIfAttribute))] public sealed class WShowIfPropertyDrawer : PropertyDrawer { private const string ArrayDataMarker = ".Array.data["; /// /// The standard property name for C# indexers ("Item"). /// private const string IndexerPropertyName = "Item"; /// /// Maximum number of types to cache accessor dictionaries for. /// This prevents unbounded memory growth in large projects with many types. /// private const int MaxCachedAccessorTypes = 500; /// /// Maximum number of accessors to cache per type. /// This prevents unbounded growth for types with many conditional fields. /// private const int MaxAccessorsPerType = 100; /// /// Maximum number of condition property cache entries. /// While this cache is cleared every frame, this limit prevents runaway growth /// within a single frame when many properties are evaluated. /// private const int MaxConditionPropertyCacheSize = 10000; /// /// Maximum number of field info cache entries. /// This cache stores FieldInfo lookups to avoid repeated reflection. /// private const int MaxFieldInfoCacheSize = 2000; /// /// Maximum number of member info cache entries. /// This cache stores MemberInfo (FieldInfo, PropertyInfo, MethodInfo) lookups /// to avoid repeated reflection in ResolveMemberAccessor. /// private const int MaxMemberInfoCacheSize = 2000; private static readonly Dictionary< Type, Dictionary> > CachedAccessors = new(); private static readonly object[] EmptyParameters = Array.Empty(); private static readonly Dictionary< (int serializedObjectHash, int instanceId, string propertyPath, string conditionField), SerializedProperty > ConditionPropertyCache = new(); private static readonly Dictionary< (Type ownerType, string fieldName), FieldInfo > FieldInfoCache = new(); /// /// Cache for MemberInfo lookups (FieldInfo, PropertyInfo, MethodInfo) keyed by type and member name. /// Uses LRU eviction when is reached. /// private static readonly Dictionary< (Type type, string memberName), MemberInfo > MemberInfoCache = new(); [ThreadStatic] private static object[] _singleIndexArgs; private static int _lastConditionCacheFrame = -1; private WShowIfAttribute _overrideAttribute; /// /// Clears all caches used by WShowIfPropertyDrawer. /// Called during domain reload to prevent stale references. /// internal static void ClearCache() { CachedAccessors.Clear(); ConditionPropertyCache.Clear(); FieldInfoCache.Clear(); MemberInfoCache.Clear(); _lastConditionCacheFrame = -1; } /// /// Checks whether a property with [WShowIf] attribute should be visible. /// This method should be called by custom editors before drawing properties /// to properly handle conditional visibility for arrays/lists. /// /// The serialized property to check. /// True if the property should be shown, false if it should be hidden. public static bool ShouldShowProperty(SerializedProperty property) { if (property == null) { return true; } WShowIfAttribute showIfAttribute = GetShowIfAttribute(property); if (showIfAttribute == null) { return true; } return EvaluateShowCondition(property, showIfAttribute); } private static WShowIfAttribute GetShowIfAttribute(SerializedProperty property) { object enclosingObject = property.GetEnclosingObject(out FieldInfo fieldInfo); if (fieldInfo == null) { return null; } return fieldInfo.GetCustomAttribute(); } private static bool EvaluateShowCondition( SerializedProperty property, WShowIfAttribute showIf ) { if ( TryGetConditionProperty( property, showIf.conditionField, out SerializedProperty conditionProperty ) ) { if (TryEvaluateCondition(conditionProperty, showIf, out bool serializedResult)) { return serializedResult; } return true; } object enclosingObject = property.GetEnclosingObject(out _); if (enclosingObject == null) { return true; } Type ownerType = enclosingObject.GetType(); Func accessor = GetAccessor(ownerType, showIf.conditionField); object fieldValue = accessor(enclosingObject); return !ShowIfConditionEvaluator.TryEvaluateCondition( fieldValue, showIf, out bool reflectedResult ) || reflectedResult; } public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { if (!ShouldShowInternal(property)) { return 0f; } return EditorGUI.GetPropertyHeight(property, label, true); } public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { if (ShouldShowInternal(property)) { EditorGUI.PropertyField(position, property, label, true); } } private bool ShouldShowInternal(SerializedProperty property) { return ShouldShow(property); } private static bool IsArrayElement(SerializedProperty property) { string propertyPath = property?.propertyPath; if (string.IsNullOrEmpty(propertyPath)) { return false; } return propertyPath.Contains(ArrayDataMarker); } internal void InitializeForTesting(WShowIfAttribute attributeOverride) { _overrideAttribute = attributeOverride; } private WShowIfAttribute ResolveAttribute() { if (_overrideAttribute != null) { return _overrideAttribute; } return attribute as WShowIfAttribute; } internal bool ShouldShow(SerializedProperty property) { // When WShowIf is applied to an array/list field, Unity's PropertyDrawer system // invokes the drawer for each array *element* (paths like "field.Array.data[0]"), // not for the array field itself. The parent array draws its own container (foldout, // header, etc.) and then asks us for each element's height. If we return 0 for // hidden elements while the array container is still drawn, the layout breaks and // causes visual corruption/overdraw. // // Solution: If this property is an array element, always show it. The visibility // decision should be made at the array level, not the element level. If the drawer // is on the array itself (property.isArray && not an element), we handle it normally. if (IsArrayElement(property)) { return true; } WShowIfAttribute showIf = ResolveAttribute(); if (showIf == null) { return true; } if ( TryGetConditionProperty( property, showIf.conditionField, out SerializedProperty conditionProperty ) ) { if (TryEvaluateCondition(conditionProperty, showIf, out bool serializedResult)) { return serializedResult; } return true; } object enclosingObject = property.GetEnclosingObject(out _); if (enclosingObject == null) { return true; } Type ownerType = enclosingObject.GetType(); Func accessor = GetAccessor(ownerType, showIf.conditionField); object fieldValue = accessor(enclosingObject); return !ShowIfConditionEvaluator.TryEvaluateCondition( fieldValue, showIf, out bool reflectedResult ) || reflectedResult; } private static bool TryEvaluateCondition( SerializedProperty conditionProperty, WShowIfAttribute showIf, out bool shouldShow ) { if (conditionProperty == null) { shouldShow = true; return false; } object conditionValue; if (conditionProperty.propertyType == SerializedPropertyType.Boolean) { conditionValue = conditionProperty.boolValue; } else { conditionValue = conditionProperty.GetTargetObjectWithField(out _); } return ShowIfConditionEvaluator.TryEvaluateCondition( conditionValue, showIf, out shouldShow ); } private static bool TryGetConditionProperty( SerializedProperty property, string conditionField, out SerializedProperty conditionProperty ) { int currentFrame = Time.frameCount; if (_lastConditionCacheFrame != currentFrame) { ConditionPropertyCache.Clear(); _lastConditionCacheFrame = currentFrame; } SerializedObject serializedObject = property.serializedObject; UnityEngine.Object target = serializedObject?.targetObject; int serializedObjectHash = serializedObject?.GetHashCode() ?? 0; int instanceId = target != null ? target.GetInstanceID() : 0; string propertyPath = property.propertyPath ?? string.Empty; (int, int, string, string) cacheKey = ( serializedObjectHash, instanceId, propertyPath, conditionField ); if ( EditorCacheHelper.TryGetFromBoundedLRUCache( ConditionPropertyCache, cacheKey, out conditionProperty ) ) { return conditionProperty != null; } conditionProperty = serializedObject.FindProperty(conditionField); if (conditionProperty != null) { EditorCacheHelper.AddToBoundedCache( ConditionPropertyCache, cacheKey, conditionProperty, MaxConditionPropertyCacheSize ); return true; } if (!string.IsNullOrEmpty(propertyPath)) { int separatorIndex = propertyPath.LastIndexOf('.'); string siblingPath = separatorIndex == -1 ? conditionField : propertyPath.Substring(0, separatorIndex + 1) + conditionField; conditionProperty = serializedObject.FindProperty(siblingPath); if (conditionProperty != null) { EditorCacheHelper.AddToBoundedCache( ConditionPropertyCache, cacheKey, conditionProperty, MaxConditionPropertyCacheSize ); return true; } } EditorCacheHelper.AddToBoundedCache( ConditionPropertyCache, cacheKey, (SerializedProperty)null, MaxConditionPropertyCacheSize ); conditionProperty = null; return false; } private static Func GetAccessor(Type ownerType, string memberPath) { if ( !CachedAccessors.TryGetValue( ownerType, out Dictionary> cachedForType ) ) { if (CachedAccessors.Count >= MaxCachedAccessorTypes) { return BuildAccessor(ownerType, memberPath); } cachedForType = new Dictionary>( StringComparer.Ordinal ); CachedAccessors[ownerType] = cachedForType; } if (!cachedForType.TryGetValue(memberPath, out Func accessor)) { accessor = BuildAccessor(ownerType, memberPath); if (cachedForType.Count < MaxAccessorsPerType) { cachedForType[memberPath] = accessor; } } return accessor; } private static Func BuildAccessor(Type ownerType, string memberPath) { if (string.IsNullOrEmpty(memberPath)) { return static _ => null; } using PooledResource> segmentsLease = Buffers.GetList(4, out List segments); ParseMemberPath(memberPath, segments); if (segments.Count == 0) { return static _ => null; } List> steps = new(segments.Count); Type currentType = ownerType; for (int segmentIndex = 0; segmentIndex < segments.Count; segmentIndex += 1) { MemberPathSegment segment = segments[segmentIndex]; MemberAccessor memberAccessor = ResolveMemberAccessor( currentType, segment.MemberName ); if (!memberAccessor.IsValid) { Debug.LogError( $"Failed to resolve conditional member '{segment.MemberName}' on {currentType.FullName} while evaluating '{memberPath}'." ); return static _ => null; } steps.Add(memberAccessor.Getter); currentType = memberAccessor.ValueType ?? typeof(object); if (segment.Indices.Length == 0) { continue; } for ( int indexPosition = 0; indexPosition < segment.Indices.Length; indexPosition += 1 ) { IndexAccessor indexAccessor = CreateIndexAccessor( currentType, segment.Indices[indexPosition] ); if (!indexAccessor.IsValid) { Debug.LogError( $"Failed to resolve index accessor for '{segment.MemberName}' on {currentType.FullName} while evaluating '{memberPath}'." ); return static _ => null; } steps.Add(indexAccessor.Getter); currentType = indexAccessor.ElementType ?? typeof(object); } } return instance => { object current = instance; for (int stepIndex = 0; stepIndex < steps.Count; stepIndex += 1) { if (current == null) { return null; } current = steps[stepIndex](current); } return current; }; } /// /// Gets a cached MemberInfo (FieldInfo, PropertyInfo, or MethodInfo) for the specified type and member name. /// Uses LRU eviction when cache capacity is reached. /// /// The type to search for the member. /// The name of the member to find. /// The binding flags for the member lookup. /// The cached or newly resolved MemberInfo, or null if not found. private static MemberInfo GetCachedMemberInfo( Type type, string memberName, BindingFlags flags ) { (Type, string) key = (type, memberName); if ( EditorCacheHelper.TryGetFromBoundedLRUCache( MemberInfoCache, key, out MemberInfo cached ) ) { return cached; } MemberInfo memberInfo = type.GetField(memberName, flags); if (memberInfo == null) { memberInfo = type.GetProperty(memberName, flags); } if (memberInfo == null) { memberInfo = type.GetMethod(memberName, flags, null, Type.EmptyTypes, null); } if (memberInfo != null) { EditorCacheHelper.AddToBoundedCache( MemberInfoCache, key, memberInfo, MaxMemberInfoCacheSize ); } return memberInfo; } private static MemberAccessor ResolveMemberAccessor(Type type, string memberName) { BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.FlattenHierarchy; MemberInfo memberInfo = GetCachedMemberInfo(type, memberName, flags); if (memberInfo == null) { return MemberAccessor.Invalid; } if (memberInfo is FieldInfo field) { return new MemberAccessor(ReflectionHelpers.GetFieldGetter(field), field.FieldType); } if (memberInfo is PropertyInfo propertyInfo && propertyInfo.CanRead) { return new MemberAccessor( ReflectionHelpers.GetPropertyGetter(propertyInfo), propertyInfo.PropertyType ); } if (memberInfo is MethodInfo methodInfo) { if (methodInfo.ReturnType == typeof(void)) { Debug.LogWarning( $"WShowIf member '{memberName}' on {type.FullName} returns void and cannot be used as a condition." ); return MemberAccessor.Invalid; } Func invoker = ReflectionHelpers.GetMethodInvoker( methodInfo ); return new MemberAccessor( instance => invoker(instance, EmptyParameters), methodInfo.ReturnType ); } return MemberAccessor.Invalid; } private static IndexAccessor CreateIndexAccessor(Type collectionType, int index) { if (collectionType == null) { return IndexAccessor.Invalid; } if (collectionType.IsArray) { Type elementType = collectionType.GetElementType() ?? typeof(object); return new IndexAccessor( value => { Array array = value as Array; if (array == null || index < 0 || index >= array.Length) { return null; } return array.GetValue(index); }, elementType ); } if (typeof(IList).IsAssignableFrom(collectionType)) { Type elementType = ResolveListElementType(collectionType); return new IndexAccessor( value => { IList list = value as IList; if (list == null || index < 0 || index >= list.Count) { return null; } return list[index]; }, elementType ); } Type readOnlyListInterface = GetGenericInterface( collectionType, typeof(IReadOnlyList<>) ); if (readOnlyListInterface != null) { Type elementType = readOnlyListInterface.GetGenericArguments()[0]; return new IndexAccessor( value => { if (value == null) { return null; } PropertyInfo indexer = readOnlyListInterface.GetProperty( IndexerPropertyName ); if (indexer == null) { return null; } try { _singleIndexArgs ??= new object[1]; _singleIndexArgs[0] = index; return indexer.GetValue(value, _singleIndexArgs); } catch { return null; } }, elementType ); } if (typeof(IEnumerable).IsAssignableFrom(collectionType)) { Type elementType = ResolveEnumerableElementType(collectionType); return new IndexAccessor( value => { IEnumerable enumerable = value as IEnumerable; if (enumerable == null) { return null; } int current = 0; foreach (object item in enumerable) { if (current == index) { return item; } current += 1; } return null; }, elementType ); } return IndexAccessor.Invalid; } private static void ParseMemberPath(string memberPath, List segments) { if (segments == null) { throw new ArgumentNullException(nameof(segments)); } segments.Clear(); if (string.IsNullOrEmpty(memberPath)) { return; } string[] rawSegments = memberPath.Split('.'); using PooledResource> indicesLease = Buffers.List.Get( out List indices ); for (int index = 0; index < rawSegments.Length; index += 1) { string raw = rawSegments[index]; if (string.IsNullOrEmpty(raw)) { continue; } string name = raw; indices.Clear(); int bracket = raw.IndexOf('['); if (bracket >= 0) { name = raw.Substring(0, bracket); int cursor = bracket; while (cursor < raw.Length && (cursor = raw.IndexOf('[', cursor)) != -1) { int endBracket = raw.IndexOf(']', cursor + 1); if (endBracket == -1) { break; } string slice = raw.Substring(cursor + 1, endBracket - cursor - 1); if (int.TryParse(slice, out int parsedIndex)) { indices.Add(parsedIndex); } cursor = endBracket + 1; } } MemberPathSegment segment = new( name, indices.Count > 0 ? indices.ToArray() : Array.Empty() ); segments.Add(segment); } } private static Type ResolveListElementType(Type type) { if (type.IsGenericType) { Type[] args = type.GetGenericArguments(); if (args.Length == 1) { return args[0]; } } return typeof(object); } private static Type ResolveEnumerableElementType(Type type) { Type genericInterface = GetGenericInterface(type, typeof(IEnumerable<>)); if (genericInterface != null) { Type[] args = genericInterface.GetGenericArguments(); if (args.Length == 1) { return args[0]; } } return typeof(object); } private static Type GetGenericInterface(Type type, Type interfaceTemplate) { if ( type.IsInterface && type.IsGenericType && type.GetGenericTypeDefinition() == interfaceTemplate ) { return type; } Type[] interfaces = type.GetInterfaces(); for (int index = 0; index < interfaces.Length; index += 1) { Type candidate = interfaces[index]; if ( candidate.IsGenericType && candidate.GetGenericTypeDefinition() == interfaceTemplate ) { return candidate; } } return null; } private readonly struct MemberAccessor { public static readonly MemberAccessor Invalid = new(null, null); public MemberAccessor(Func getter, Type valueType) { Getter = getter; ValueType = valueType; } public Func Getter { get; } public Type ValueType { get; } public bool IsValid => Getter != null; } private readonly struct IndexAccessor { public static readonly IndexAccessor Invalid = new(null, null); public IndexAccessor(Func getter, Type elementType) { Getter = getter; ElementType = elementType; } public Func Getter { get; } public Type ElementType { get; } public bool IsValid => Getter != null; } private readonly struct MemberPathSegment { public MemberPathSegment(string memberName, int[] indices) { MemberName = memberName; Indices = indices; } public string MemberName { get; } public int[] Indices { get; } } } #endif }