// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Core.Helper { #if UNITY_EDITOR using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using UnityEngine; using WallstopStudios.UnityHelpers.Core.DataStructure; using WallstopStudios.UnityHelpers.Core.Helper; /// /// Internal wrapper for tracking LRU order per-dictionary. /// Uses a LinkedList to maintain access order and a Dictionary for O(1) node lookup. /// /// The type of dictionary key. internal sealed class LRUOrderTracker { private readonly LinkedList _accessOrder = new(); private readonly Dictionary> _nodeMap = new(); /// /// Marks a key as recently accessed by moving it to the end of the access order. /// If the key doesn't exist in tracking, adds it. /// /// The key to mark as accessed. public void MarkAccessed(TKey key) { if (_nodeMap.TryGetValue(key, out LinkedListNode node)) { _accessOrder.Remove(node); _accessOrder.AddLast(node); } else { LinkedListNode newNode = _accessOrder.AddLast(key); _nodeMap[key] = newNode; } } /// /// Removes a key from the LRU tracking. /// /// The key to remove. public void Remove(TKey key) { if (_nodeMap.TryGetValue(key, out LinkedListNode node)) { _accessOrder.Remove(node); _nodeMap.Remove(key); } } /// /// Clears all keys from the LRU tracker. /// This is useful for synchronizing the tracker when the dictionary is cleared externally. /// public void Clear() { _accessOrder.Clear(); _nodeMap.Clear(); } /// /// Gets the least recently used key (first in access order). /// /// The LRU key if found. /// True if there is at least one key being tracked; otherwise, false. public bool TryGetLeastRecentlyUsed(out TKey key) { if (_accessOrder.First != null) { key = _accessOrder.First.Value; return true; } key = default; return false; } } /// /// Provides centralized caching utilities for editor code to avoid repeated allocations. /// /// /// This helper consolidates common caching patterns used across property drawers, inspectors, /// and editor windows. Using a single cache improves memory efficiency and reduces duplication. /// All caches use the unified implementation with LRU eviction. /// public static class EditorCacheHelper { /// /// Default maximum size for bounded UI state caches (foldouts, scroll positions). /// public const int DefaultUIStateCacheSize = 5000; /// /// Default maximum size for bounded reflection caches (accessors, field info). /// public const int DefaultReflectionCacheSize = 2000; /// /// Default maximum size for bounded editor instance caches. /// public const int DefaultEditorCacheSize = 500; private const int MaxIntCacheSize = 10000; private const int MaxPaginationCacheSize = 1000; private const int MaxGUIStyleCacheSize = 500; // Lazy initialization to avoid triggering Cache/PRNG static initialization during // EditorCacheHelper class loading, which can cause deadlocks during Unity's // "Open Project: Open Scene" phase. private static Cache _intToStringCache; private static Cache<(int, int), string> _paginationLabelCache; /// /// LRU cache for integer-to-string conversions. /// Used by GetCachedIntString() and pagination labels across all editor UI. /// private static Cache IntToStringCache => _intToStringCache ??= CacheBuilder .NewBuilder() .MaximumSize(MaxIntCacheSize) .Build(); /// /// LRU cache for pagination labels in format "Page X / Y". /// private static Cache<(int, int), string> PaginationLabelCache => _paginationLabelCache ??= CacheBuilder<(int, int), string> .NewBuilder() .MaximumSize(MaxPaginationCacheSize) .Build(); private static readonly Dictionary SolidTextureCache = new( new ColorComparer() ); private static readonly Dictionary EnumDisplayNameCache = new(); private static readonly Dictionary GUIStyleCache = new(); /// /// Tracks LRU order for bounded caches. Uses ConditionalWeakTable so that /// when a dictionary is garbage collected, its LRU tracker is also collected. /// private static readonly ConditionalWeakTable LRUOrderTracking = new(); /// /// Gets the cached string representation of an integer value. /// Uses a unified LRU cache with automatic eviction when capacity is reached. /// /// The integer value to convert to string. /// The cached string representation. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string GetCachedIntString(int value) { return IntToStringCache.GetOrAdd(value, static v => v.ToString()); } /// /// Gets a cached pagination label in the format "Page X / Y". /// Uses a unified LRU cache with automatic eviction when capacity is reached. /// /// The current page number (1-based). /// The total number of pages. /// The cached pagination label string. public static string GetPaginationLabel(int page, int totalPages) { (int, int) key = (page, totalPages); return PaginationLabelCache.GetOrAdd( key, static k => "Page " + GetCachedIntString(k.Item1) + " / " + GetCachedIntString(k.Item2) ); } /// /// Gets a solid-color texture from cache, creating it if necessary. /// /// The color of the texture. /// A 1x1 texture filled with the specified color. public static Texture2D GetSolidTexture(Color color) { if (SolidTextureCache.TryGetValue(color, out Texture2D cached) && cached != null) { return cached; } Texture2D texture = new(1, 1, TextureFormat.RGBA32, false) { hideFlags = HideFlags.HideAndDontSave, wrapMode = TextureWrapMode.Clamp, filterMode = FilterMode.Point, }; texture.SetPixel(0, 0, color); texture.Apply(false, true); SolidTextureCache[color] = texture; return texture; } /// /// Gets a solid-color texture from cache, creating it if necessary. /// Alias for for backwards compatibility. /// /// The color of the texture. /// A 1x1 texture filled with the specified color. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static Texture2D GetOrCreateTexture(Color color) { return GetSolidTexture(color); } /// /// Gets a solid-color texture from cache using an integer color key (RGBA packed into int). /// /// The color key representing RGBA packed as: (r << 24) | (g << 16) | (b << 8) | a. /// A 1x1 texture filled with the color represented by the key. public static Texture2D GetOrCreateTexture(int colorKey) { float r = ((colorKey >> 24) & 0xFF) / 255f; float g = ((colorKey >> 16) & 0xFF) / 255f; float b = ((colorKey >> 8) & 0xFF) / 255f; float a = (colorKey & 0xFF) / 255f; Color color = new(r, g, b, a); return GetSolidTexture(color); } /// /// Gets the cached display name for an enum value using InspectorName attribute or ObjectNames.NicifyVariableName. /// /// The enum value to get the display name for. /// The cached display name, or the enum's ToString() if value is null or not an enum. public static string GetEnumDisplayName(Enum value) { if (value == null) { return string.Empty; } Type enumType = value.GetType(); string[] displayNames = GetEnumDisplayNames(enumType); try { int index = Array.IndexOf(Enum.GetValues(enumType), value); if (index >= 0 && index < displayNames.Length) { return displayNames[index]; } } catch { // Fall through to default } return value.ToString(); } /// /// Gets all cached display names for an enum type. /// /// The enum type to get display names for. /// An array of display names corresponding to each enum value, or an empty array if enumType is invalid. public static string[] GetEnumDisplayNames(Type enumType) { if (enumType == null || !enumType.IsEnum) { return Array.Empty(); } if (EnumDisplayNameCache.TryGetValue(enumType, out string[] cached)) { return cached; } Array values = Enum.GetValues(enumType); string[] names = new string[values.Length]; for (int i = 0; i < values.Length; i++) { object enumValue = values.GetValue(i); string fieldName = enumValue.ToString(); System.Reflection.FieldInfo field = enumType.GetField(fieldName); string displayName = fieldName; if (field != null) { object[] inspectorNameAttributes = field.GetCustomAttributes( typeof(UnityEngine.InspectorNameAttribute), false ); if (inspectorNameAttributes.Length > 0) { InspectorNameAttribute attr = inspectorNameAttributes[0] as InspectorNameAttribute; if (attr != null && !string.IsNullOrEmpty(attr.displayName)) { displayName = attr.displayName; } } else { displayName = UnityEditor.ObjectNames.NicifyVariableName(fieldName); } } names[i] = displayName; } EnumDisplayNameCache[enumType] = names; return names; } /// /// Gets or creates a cached GUIStyle using the provided key and factory function. /// /// A unique string key identifying the style. /// A factory function that creates the style if not cached. May be null, in which case null is returned if not cached. /// The cached or newly created GUIStyle, or null if factory is null and style is not cached. public static GUIStyle GetOrCreateStyle(string key, Func factory) { if (string.IsNullOrEmpty(key)) { if (factory == null) { return null; } return factory(); } if (GUIStyleCache.TryGetValue(key, out GUIStyle cached)) { return cached; } if (factory == null) { return null; } if (GUIStyleCache.Count >= MaxGUIStyleCacheSize) { return factory(); } GUIStyle style = factory(); if (style != null) { GUIStyleCache[key] = style; } return style; } /// /// Gets or creates the LRU order tracker for a given dictionary. /// /// The type of dictionary key. /// The type of dictionary value. /// The dictionary to get the tracker for. /// The LRU order tracker associated with this dictionary. private static LRUOrderTracker GetOrCreateLRUTracker( Dictionary cache ) { if (!LRUOrderTracking.TryGetValue(cache, out object trackerObj)) { LRUOrderTracker newTracker = new(); LRUOrderTracking.Add(cache, newTracker); return newTracker; } return (LRUOrderTracker)trackerObj; } /// /// Attempts to add or update a value in a bounded dictionary cache using LRU eviction. /// When the cache is at capacity, the least-recently-used entry is evicted. /// /// The type of dictionary key. /// The type of dictionary value. /// The dictionary cache to add to. /// The key to add or update. /// The value to store. /// The maximum number of entries allowed in the cache. /// /// This method uses LRU (Least Recently Used) eviction when the cache is full. /// When updating an existing key, the entry is marked as recently used. /// The least-recently-used entry is evicted when capacity is reached. /// LRU order is tracked using an internal linked list per dictionary instance. /// public static void AddToBoundedCache( Dictionary cache, TKey key, TValue value, int maxSize ) { if (cache == null) { return; } if (maxSize <= 0) { return; } if (key == null) { return; } LRUOrderTracker tracker = GetOrCreateLRUTracker(cache); // Synchronize tracker if dictionary was cleared externally if (cache.Count == 0) { tracker.Clear(); } if (cache.ContainsKey(key)) { cache[key] = value; tracker.MarkAccessed(key); return; } while (cache.Count >= maxSize) { if (tracker.TryGetLeastRecentlyUsed(out TKey lruKey)) { cache.Remove(lruKey); tracker.Remove(lruKey); } else { break; } } cache[key] = value; tracker.MarkAccessed(key); } /// /// Attempts to get a value from a bounded LRU cache, updating the access order if found. /// /// The type of dictionary key. /// The type of dictionary value. /// The dictionary cache to get from. /// The key to look up. /// The value if found; otherwise, the default value. /// True if the key was found; otherwise, false. /// /// When a key is found, it is marked as recently used in the LRU tracking. /// This ensures LRU behavior where frequently accessed items are less likely to be evicted. /// public static bool TryGetFromBoundedLRUCache( Dictionary cache, TKey key, out TValue value ) { if (cache == null) { value = default; return false; } if (key == null) { value = default; return false; } if (cache.TryGetValue(key, out value)) { LRUOrderTracker tracker = GetOrCreateLRUTracker(cache); tracker.MarkAccessed(key); return true; } return false; } /// /// Clears all caches. Useful for freeing memory during domain reload. /// public static void ClearAllCaches() { // Only clear caches if they've been initialized (avoid triggering lazy init just to clear) _intToStringCache?.Clear(); _paginationLabelCache?.Clear(); EnumDisplayNameCache.Clear(); GUIStyleCache.Clear(); foreach (Texture2D texture in SolidTextureCache.Values) { if (texture != null) { UnityEngine.Object.DestroyImmediate(texture); } } SolidTextureCache.Clear(); } /// /// Gets the current count of entries in the IntToString cache. /// /// The number of cached integer-to-string conversions. internal static int GetIntToStringCacheCount() { return IntToStringCache.Count; } /// /// Gets the current count of entries in the PaginationLabel cache. /// /// The number of cached pagination labels. internal static int GetPaginationLabelCacheCount() { return PaginationLabelCache.Count; } /// /// Compares two colors for approximate equality. /// /// The first color. /// The second color. /// True if the colors are approximately equal; otherwise, false. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool AreColorsEqual(Color x, Color y) { return Mathf.Approximately(x.r, y.r) && Mathf.Approximately(x.g, y.g) && Mathf.Approximately(x.b, y.b) && Mathf.Approximately(x.a, y.a); } /// /// Gets the hash code for a color suitable for use in dictionaries. /// /// The color to hash. /// A hash code for the color. [MethodImpl(MethodImplOptions.AggressiveInlining)] public static int GetColorHashCode(Color color) { return Objects.HashCode( Mathf.RoundToInt(color.r * 255f), Mathf.RoundToInt(color.g * 255f), Mathf.RoundToInt(color.b * 255f), Mathf.RoundToInt(color.a * 255f) ); } /// /// Comparer for Unity Color values that uses approximate equality. /// public sealed class ColorComparer : IEqualityComparer { /// public bool Equals(Color x, Color y) { return AreColorsEqual(x, y); } /// public int GetHashCode(Color obj) { return GetColorHashCode(obj); } } } #endif }