// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils { #if UNITY_EDITOR using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Helper; using WallstopStudios.UnityHelpers.Editor.Core.Helper; /// /// Provides shared constants, caching, and helper methods for dropdown drawer implementations. /// /// /// This utility class consolidates common code used by IntDropDown, StringInList, and /// WValueDropDown drawers (both standard PropertyDrawer and Odin Inspector implementations). /// By centralizing these elements, we ensure consistent behavior and eliminate code duplication. /// public static class DropDownShared { /// /// Width of pagination navigation buttons in pixels. /// public const float ButtonWidth = 24f; /// /// Width of the pagination label showing "Page X/Y" in pixels. /// public const float PageLabelWidth = 90f; /// /// Height of pagination buttons in pixels. /// public const float PaginationButtonHeight = 20f; /// /// Default width of popup windows in pixels. /// public const float PopupWidth = 360f; /// /// Bottom padding below option lists in pixels. /// public const float OptionBottomPadding = 6f; /// /// Extra height added to option rows in pixels. /// public const float OptionRowExtraHeight = 1.5f; /// /// Horizontal padding for empty search results area in pixels. /// public const float EmptySearchHorizontalPadding = 32f; /// /// Extra padding for empty search state in pixels. /// public const float EmptySearchExtraPadding = 12f; /// /// Message displayed when search yields no results. /// public const string EmptyResultsMessage = "No results match the current search."; /// /// Reusable GUIContent for empty search results message. /// public static readonly GUIContent EmptyResultsContent = new(EmptyResultsMessage); private static readonly Dictionary FormattedOptionCache = new(); private static readonly Dictionary EnumDisplayNameCache = new(); private static readonly Dictionary FallbackOptionLabelCache = new(); private static float s_cachedOptionControlHeight = -1f; private static float s_cachedOptionRowHeight = -1f; private static GUIStyle s_optionButton; private static GUIStyle s_selectedOptionButton; private static GUIStyle s_paginationButtonLeft; private static GUIStyle s_paginationButtonRight; private static GUIStyle s_paginationLabel; /// /// Gets the cached style for option buttons. /// public static GUIStyle OptionButton { get { EnsureStylesInitialized(); return s_optionButton; } } /// /// Gets the cached style for selected option buttons. /// public static GUIStyle SelectedOptionButton { get { EnsureStylesInitialized(); return s_selectedOptionButton; } } /// /// Gets the cached style for left pagination buttons. /// public static GUIStyle PaginationButtonLeft { get { EnsureStylesInitialized(); return s_paginationButtonLeft; } } /// /// Gets the cached style for right pagination buttons. /// public static GUIStyle PaginationButtonRight { get { EnsureStylesInitialized(); return s_paginationButtonRight; } } /// /// Gets the cached style for pagination labels. /// public static GUIStyle PaginationLabel { get { EnsureStylesInitialized(); return s_paginationLabel; } } /// /// Returns a cached string representation of an integer value. /// Delegates to for shared LRU caching. /// /// The integer to convert to string. /// The cached string representation. public static string GetCachedIntString(int value) { return EditorCacheHelper.GetCachedIntString(value); } /// /// Returns a cached pagination label string in the format "Page X / Y". /// Delegates to for shared LRU caching. /// /// The current page number (1-based). /// The total number of pages. /// The cached pagination label string. public static string GetPaginationLabel(int currentPage, int totalPages) { return EditorCacheHelper.GetPaginationLabel(currentPage, totalPages); } /// /// Returns a cached formatted string representation of an option value. /// Handles Unity objects, enums, and general objects appropriately. /// /// The option value to format. /// The cached formatted string. public static string FormatOption(object option) { if (option == null) { return "(null)"; } if (FormattedOptionCache.TryGetValue(option, out string cached)) { return cached; } string formatted; if (option is int intValue) { formatted = GetCachedIntString(intValue); } else if (option is UnityEngine.Object unityObject) { if (unityObject == null) { formatted = "(None)"; } else { string objectName = unityObject.name; formatted = string.IsNullOrEmpty(objectName) ? unityObject.GetType().Name : objectName; } } else if (option is Enum enumValue) { formatted = ObjectNames.NicifyVariableName(enumValue.ToString()); } else if (option is IFormattable formattable) { formatted = formattable.ToString( null, System.Globalization.CultureInfo.InvariantCulture ); } else { formatted = option.ToString(); } FormattedOptionCache[option] = formatted; return formatted; } /// /// Calculates the total number of pages needed for pagination. /// /// The number of items per page. /// The total number of filtered items. /// The number of pages (minimum 1). public static int CalculatePageCount(int pageSize, int filteredCount) { if (filteredCount <= 0) { return 1; } return (filteredCount + pageSize - 1) / pageSize; } /// /// Calculates the number of rows displayed on a specific page. /// /// The total number of filtered items. /// The number of items per page. /// The current page index (0-based). /// The number of rows on the page (minimum 1). public static int CalculateRowsOnPage(int filteredCount, int pageSize, int currentPage) { if (filteredCount <= 0 || pageSize <= 0) { return 1; } int maxPageIndex = CalculatePageCount(pageSize, filteredCount) - 1; int clampedPage = Mathf.Clamp(currentPage, 0, Mathf.Max(0, maxPageIndex)); int startIndex = clampedPage * pageSize; int remaining = filteredCount - startIndex; if (remaining <= 0) { return 1; } return Mathf.Min(pageSize, remaining); } /// /// Calculates the target height for popup windows. /// /// The number of option rows displayed. /// Whether pagination controls are visible. /// The target height in pixels. public static float CalculatePopupTargetHeight(int rowsOnPage, bool includePagination) { int clampedRows = Mathf.Max(1, rowsOnPage); float chromeHeight = CalculatePopupChromeHeight(includePagination); float optionListHeight = clampedRows * GetOptionRowHeight(); float unclampedHeight = chromeHeight + optionListHeight; return unclampedHeight; } /// /// Calculates the chrome (non-content) height of popup windows. /// /// Whether pagination controls are visible. /// The chrome height in pixels. public static float CalculatePopupChromeHeight(bool includePagination) { EnsureStylesInitialized(); float searchHeight = EditorGUIUtility.singleLineHeight; float paginationHeight = includePagination ? s_paginationButtonLeft.fixedHeight : EditorGUIUtility.standardVerticalSpacing; float footerHeight = EditorGUIUtility.standardVerticalSpacing + OptionBottomPadding; return searchHeight + paginationHeight + footerHeight; } /// /// Calculates the height for empty search results state. /// /// Optional measured help box height, or -1f to calculate. /// The empty search height in pixels. public static float CalculateEmptySearchHeight(float measuredHelpBoxHeight = -1f) { GUIStyle helpStyle = EditorStyles.helpBox; int helpMargin = helpStyle.margin?.horizontal ?? 0; float availableWidth = PopupWidth - EmptySearchHorizontalPadding - helpMargin; availableWidth = Mathf.Max(32f, availableWidth); float helpBoxHeight; if (measuredHelpBoxHeight > 0f) { helpBoxHeight = measuredHelpBoxHeight; } else { float calculated = helpStyle.CalcHeight(EmptyResultsContent, availableWidth); float marginVertical = helpStyle.margin?.vertical ?? 0; helpBoxHeight = calculated + marginVertical; } float searchRow = EditorGUIUtility.singleLineHeight + EditorGUIUtility.standardVerticalSpacing; float topSpacer = EditorGUIUtility.standardVerticalSpacing; float bottomSpacer = EditorGUIUtility.standardVerticalSpacing; float footer = EditorGUIUtility.standardVerticalSpacing + OptionBottomPadding + EmptySearchExtraPadding; float result = searchRow + topSpacer + helpBoxHeight + bottomSpacer + footer; return result; } /// /// Gets the height of an option row including margins. /// /// The row height in pixels. public static float GetOptionRowHeight() { if (s_cachedOptionRowHeight > 0f) { return s_cachedOptionRowHeight; } float controlHeight = GetOptionControlHeight(); EnsureStylesInitialized(); RectOffset margin = s_optionButton.margin; float adjustedMargin; if (margin != null) { adjustedMargin = Mathf.Max( 0f, margin.vertical - EditorGUIUtility.standardVerticalSpacing ); } else { adjustedMargin = EditorGUIUtility.standardVerticalSpacing; } s_cachedOptionRowHeight = controlHeight + adjustedMargin; return s_cachedOptionRowHeight; } /// /// Gets the height of an option button control. /// /// The control height in pixels. public static float GetOptionControlHeight() { if (s_cachedOptionControlHeight > 0f) { return s_cachedOptionControlHeight; } EnsureStylesInitialized(); float width = PopupWidth - 32f; float measured = s_optionButton.CalcHeight(GUIContent.none, width); if (measured <= 0f || float.IsNaN(measured)) { measured = EditorGUIUtility.singleLineHeight + OptionRowExtraHeight; } s_cachedOptionControlHeight = measured; return measured; } /// /// Computes a hash code for an array of integer options. /// Used for caching display option arrays. /// /// The array of integer options. /// A hash code for the options array. public static int ComputeOptionsHash(int[] options) { return Objects.SpanHashCode(options); } /// /// Checks if a type is a numeric type (integer or floating-point). /// /// The type to check. /// True if the type is numeric, false otherwise. public static bool IsNumericType(Type type) { return type == typeof(int) || type == typeof(long) || type == typeof(short) || type == typeof(byte) || type == typeof(sbyte) || type == typeof(uint) || type == typeof(ulong) || type == typeof(ushort) || type == typeof(float) || type == typeof(double) || type == typeof(decimal); } /// /// Checks if a type is an integer type. /// /// The type to check. /// True if the type is an integer type, false otherwise. public static bool IsIntegerType(Type type) { return type == typeof(int) || type == typeof(long) || type == typeof(short) || type == typeof(byte) || type == typeof(sbyte) || type == typeof(uint) || type == typeof(ulong) || type == typeof(ushort); } /// /// Compares two values with support for numeric type coercion and enum handling. /// /// The first value. /// The second value. /// True if the values are considered equal, false otherwise. public static bool ValuesMatch(object a, object b) { if (ReferenceEquals(a, b)) { return true; } if (a == null || b == null) { return false; } if (a.Equals(b)) { return true; } if (a is UnityEngine.Object unityA && b is UnityEngine.Object unityB) { return unityA == unityB; } Type typeA = a.GetType(); Type typeB = b.GetType(); if (IsNumericType(typeA) && IsNumericType(typeB)) { try { double numA = Convert.ToDouble(a); double numB = Convert.ToDouble(b); return Math.Abs(numA - numB) < double.Epsilon; } catch { return false; } } if (typeA.IsEnum && IsIntegerType(typeB)) { try { long enumValue = Convert.ToInt64(a); long intValue = Convert.ToInt64(b); return enumValue == intValue; } catch { return false; } } if (typeB.IsEnum && IsIntegerType(typeA)) { try { long enumValue = Convert.ToInt64(b); long intValue = Convert.ToInt64(a); return enumValue == intValue; } catch { return false; } } return false; } /// /// Clears all cached data. Useful for testing or when options change significantly. /// Note: IntToString and PaginationLabel caches are managed centrally by EditorCacheHelper. /// public static void ClearAllCaches() { FormattedOptionCache.Clear(); EnumDisplayNameCache.Clear(); s_cachedOptionControlHeight = -1f; s_cachedOptionRowHeight = -1f; } /// /// Clears only the formatted option cache. Useful when object names may have changed. /// public static void ClearFormattedOptionCache() { FormattedOptionCache.Clear(); } private static void EnsureStylesInitialized() { if (s_optionButton != null) { return; } s_optionButton = new GUIStyle("Button") { alignment = TextAnchor.MiddleLeft, padding = new RectOffset(6, 6, 1, 1), }; s_selectedOptionButton = new GUIStyle(s_optionButton) { fontStyle = FontStyle.Bold }; float paginationHeight = PaginationButtonHeight; s_paginationButtonLeft = new GUIStyle(EditorStyles.miniButtonLeft) { fixedHeight = paginationHeight, padding = new RectOffset(6, 6, 0, 0), }; s_paginationButtonRight = new GUIStyle(EditorStyles.miniButtonRight) { fixedHeight = paginationHeight, padding = new RectOffset(6, 6, 0, 0), }; s_paginationLabel = new GUIStyle(EditorStyles.centeredGreyMiniLabel) { alignment = TextAnchor.MiddleCenter, padding = new RectOffset(0, 0, 0, 0), }; } /// /// Returns a fallback display label for an option at the given index. /// Used when the option's raw display label is null or empty. /// /// The index of the option. /// A fallback label in the format "(Option N)". public static string GetFallbackOptionLabel(int optionIndex) { if (!FallbackOptionLabelCache.TryGetValue(optionIndex, out string cached)) { cached = $"(Option {optionIndex})"; FallbackOptionLabelCache[optionIndex] = cached; } return cached; } /// /// Test hooks for unit testing internal functionality. /// internal static class TestHooks { /// /// Gets the formatted option cache for testing. /// public static Dictionary FormattedOptionCacheAccess => FormattedOptionCache; /// /// Gets the option button margin vertical value for testing. /// public static int OptionButtonMarginVertical { get { EnsureStylesInitialized(); return s_optionButton.margin?.vertical ?? 0; } } /// /// Gets the option footer padding for testing. /// public static float OptionFooterPadding => OptionBottomPadding; /// /// Gets the popup width value for testing. /// public static float PopupWidthValue => PopupWidth; /// /// Gets the empty search horizontal padding value for testing. /// public static float EmptySearchHorizontalPaddingValue => EmptySearchHorizontalPadding; /// /// Gets the empty results message value for testing. /// public static string EmptyResultsMessageValue => EmptyResultsMessage; /// /// Gets the empty search extra padding value for testing. /// public static float EmptySearchExtraPaddingValue => EmptySearchExtraPadding; } } #endif }