// 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
}