// 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 && ODIN_INSPECTOR
using System;
using System.Collections.Generic;
using System.Globalization;
using Sirenix.OdinInspector.Editor;
using UnityEditor;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Attributes;
using WallstopStudios.UnityHelpers.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Settings;
using WallstopStudios.UnityHelpers.Utils;
using EnumShared = WallstopStudios.UnityHelpers.Editor.CustomDrawers.Utils.EnumToggleButtonsShared;
using CacheHelper = WallstopStudios.UnityHelpers.Editor.Core.Helper.EditorCacheHelper;
///
/// Odin Inspector attribute drawer for .
/// Renders enum fields as horizontal toggle buttons instead of a dropdown.
///
///
/// This drawer ensures WEnumToggleButtons works correctly when Odin Inspector is installed
/// and classes derive from SerializedMonoBehaviour or SerializedScriptableObject,
/// where Unity's standard PropertyDrawer system is bypassed.
///
public sealed class WEnumToggleButtonsOdinDrawer
: OdinAttributeDrawer
{
private const float VerticalPadding = 5f;
private static readonly GUIContent SelectAllContent = new("All");
private static readonly GUIContent SelectNoneContent = new("None");
private static readonly GUIContent OutOfViewContent = new();
private static readonly Dictionary EnumOptionsCache =
new();
private static readonly Dictionary PaginationStates =
new(StringComparer.Ordinal);
///
/// Clears all cached state. Called during domain reload to prevent stale references.
///
///
/// This method is called by
/// when the Unity domain reloads (after script compilation, entering/exiting play mode, etc.).
///
internal static void ClearCache()
{
EnumOptionsCache.Clear();
PaginationStates.Clear();
}
protected override void DrawPropertyLayout(GUIContent label)
{
WEnumToggleButtonsAttribute toggleAttribute = Attribute;
if (toggleAttribute == null)
{
CallNextDrawer(label);
return;
}
if (Property == null || Property.ValueEntry == null)
{
CallNextDrawer(label);
return;
}
Type valueType = Property.ValueEntry.TypeOfValue;
if (valueType == null || !valueType.IsEnum)
{
CallNextDrawer(label);
return;
}
EnumShared.ToggleOption[] options = GetCachedEnumOptions(valueType);
if (options == null || options.Length == 0)
{
CallNextDrawer(label);
return;
}
bool isFlags = ReflectionHelpers.HasAttributeSafe(
valueType,
inherit: true
);
UnityHelpersSettings.WEnumToggleButtonsPaletteEntry palette =
UnityHelpersSettings.ResolveWEnumToggleButtonsPalette(toggleAttribute.ColorKey);
bool usePagination = ShouldPaginate(toggleAttribute, options.Length, out int pageSize);
int startIndex = 0;
int visibleCount = options.Length;
EnumShared.PaginationState paginationState = null;
if (usePagination)
{
string stateKey = BuildPaginationKey();
paginationState = GetOrCreatePaginationState(stateKey, options.Length, pageSize);
startIndex = paginationState.StartIndex;
visibleCount = paginationState.VisibleCount;
}
if (visibleCount <= 0)
{
CallNextDrawer(label);
return;
}
object currentValue = Property.ValueEntry.WeakSmartValue;
ulong currentMask = EnumShared.ConvertToUInt64(currentValue);
EnumShared.SelectionSummary summary = BuildSelectionSummary(
options,
currentMask,
isFlags,
startIndex,
visibleCount,
usePagination
);
bool showToolbarControls =
isFlags && (toggleAttribute.ShowSelectAll || toggleAttribute.ShowSelectNone);
Rect totalRect = EditorGUILayout.GetControlRect(
true,
CalculateTotalHeight(
visibleCount,
toggleAttribute.ButtonsPerRow,
showToolbarControls,
usePagination,
summary.HasSummary,
EditorGUIUtility.currentViewWidth - EditorGUIUtility.labelWidth - 20f
)
);
Rect labelRect = new(
totalRect.x,
totalRect.y,
EditorGUIUtility.labelWidth,
EditorGUIUtility.singleLineHeight
);
if (label != null && label != GUIContent.none)
{
EditorGUI.LabelField(labelRect, label);
}
Rect contentRect = new(
totalRect.x + EditorGUIUtility.labelWidth,
totalRect.y,
totalRect.width - EditorGUIUtility.labelWidth,
totalRect.height
);
float currentY = contentRect.y + VerticalPadding;
if (showToolbarControls)
{
Rect toolbarRect = new(
contentRect.x,
currentY,
contentRect.width,
EditorGUIUtility.singleLineHeight
);
DrawToolbar(toolbarRect, options, currentMask, toggleAttribute, palette, isFlags);
currentY += toolbarRect.height + EnumShared.ToolbarSpacing;
}
if (usePagination && paginationState != null)
{
Rect paginationRect = new(
contentRect.x,
currentY,
contentRect.width,
EditorGUIUtility.singleLineHeight
);
DrawPagination(paginationRect, paginationState);
currentY += paginationRect.height + EnumShared.ToolbarSpacing;
}
if (summary.HasSummary)
{
float summaryHeight = EnumShared.SummaryStyle.CalcHeight(
summary.Content,
contentRect.width
);
Rect summaryRect = new(contentRect.x, currentY, contentRect.width, summaryHeight);
EditorGUI.LabelField(summaryRect, summary.Content, EnumShared.SummaryStyle);
currentY += summaryHeight + EnumShared.SummarySpacing;
}
EnumShared.LayoutMetrics metrics = EnumShared.CalculateLayout(
toggleAttribute.ButtonsPerRow,
visibleCount,
contentRect.width,
EditorGUIUtility.singleLineHeight,
EnumShared.ToolbarSpacing,
EnumShared.MinButtonWidth
);
Rect buttonsRect = new(contentRect.x, currentY, contentRect.width, metrics.TotalHeight);
for (int index = 0; index < visibleCount; index += 1)
{
EnumShared.ToggleOption option = options[startIndex + index];
Rect buttonRect = metrics.GetItemRect(buttonsRect, index);
DrawToggle(
buttonRect,
option,
currentMask,
isFlags,
metrics,
index,
visibleCount,
palette
);
}
}
private float CalculateTotalHeight(
int visibleCount,
int buttonsPerRow,
bool showToolbarControls,
bool usePagination,
bool hasSummary,
float availableWidth
)
{
float extraHeight = VerticalPadding * 2f;
if (showToolbarControls)
{
extraHeight += EditorGUIUtility.singleLineHeight + EnumShared.ToolbarSpacing;
}
if (usePagination)
{
extraHeight += EditorGUIUtility.singleLineHeight + EnumShared.ToolbarSpacing;
}
if (hasSummary)
{
float summaryHeight = EnumShared.SummaryStyle.CalcHeight(
OutOfViewContent,
availableWidth
);
extraHeight += summaryHeight + EnumShared.SummarySpacing;
}
EnumShared.LayoutMetrics metrics = EnumShared.CalculateLayout(
buttonsPerRow,
visibleCount,
availableWidth,
EditorGUIUtility.singleLineHeight,
EnumShared.ToolbarSpacing,
EnumShared.MinButtonWidth
);
return extraHeight + metrics.TotalHeight;
}
private void DrawToolbar(
Rect rect,
EnumShared.ToggleOption[] options,
ulong currentMask,
WEnumToggleButtonsAttribute toggleAttribute,
UnityHelpersSettings.WEnumToggleButtonsPaletteEntry palette,
bool isFlags
)
{
if (!isFlags)
{
return;
}
bool drawSelectAll = toggleAttribute.ShowSelectAll;
bool drawSelectNone = toggleAttribute.ShowSelectNone;
if (!drawSelectAll && !drawSelectNone)
{
return;
}
ulong allFlagsMask = CalculateAllFlagsMask(options);
bool allActive = allFlagsMask != 0UL && (currentMask & allFlagsMask) == allFlagsMask;
bool noneActive = currentMask == 0UL;
bool alignedPair = drawSelectAll && drawSelectNone;
if (alignedPair)
{
float availableWidth = rect.width - EnumShared.ToolbarButtonGap;
float buttonWidth = Mathf.Max(
EnumShared.ToolbarButtonMinWidth,
Mathf.Floor(availableWidth * EnumShared.EqualSplitRatio)
);
Rect selectAllRect = new(rect.x, rect.y, buttonWidth, rect.height);
GUIStyle allStyle = EnumShared.GetButtonStyle(
EnumShared.ButtonSegment.Single,
allActive,
palette
);
bool selectAllPressed = GUI.Toggle(
selectAllRect,
allActive,
SelectAllContent,
allStyle
);
if (selectAllPressed && !allActive)
{
ApplyEnumValue(allFlagsMask);
}
Rect selectNoneRect = new(
selectAllRect.xMax + EnumShared.ToolbarButtonGap,
rect.y,
rect.width - buttonWidth - EnumShared.ToolbarButtonGap,
rect.height
);
GUIStyle noneStyle = EnumShared.GetButtonStyle(
EnumShared.ButtonSegment.Single,
noneActive,
palette
);
bool selectNonePressed = GUI.Toggle(
selectNoneRect,
noneActive,
SelectNoneContent,
noneStyle
);
if (selectNonePressed && !noneActive)
{
ApplyEnumValue(0UL);
}
}
else if (drawSelectAll)
{
GUIStyle style = EnumShared.GetButtonStyle(
EnumShared.ButtonSegment.Single,
allActive,
palette
);
bool selectAllPressed = GUI.Toggle(rect, allActive, SelectAllContent, style);
if (selectAllPressed && !allActive)
{
ApplyEnumValue(allFlagsMask);
}
}
else if (drawSelectNone)
{
GUIStyle style = EnumShared.GetButtonStyle(
EnumShared.ButtonSegment.Single,
noneActive,
palette
);
bool selectNonePressed = GUI.Toggle(rect, noneActive, SelectNoneContent, style);
if (selectNonePressed && !noneActive)
{
ApplyEnumValue(0UL);
}
}
}
private static void DrawPagination(Rect rect, EnumShared.PaginationState state)
{
if (state.TotalPages <= 1)
{
return;
}
float spacing = EnumShared.ToolbarSpacing;
float buttonWidth = Mathf.Min(
EnumShared.PaginationButtonWidth,
rect.width * EnumShared.MaxPaginationButtonWidthRatio
);
float labelWidth = Mathf.Max(
EnumShared.PaginationLabelMinWidth,
rect.width - (buttonWidth * 4f) - spacing * 4f
);
Rect firstRect = new(rect.x, rect.y, buttonWidth, rect.height);
Rect prevRect = new(firstRect.xMax + spacing, rect.y, buttonWidth, rect.height);
Rect labelRect = new(prevRect.xMax + spacing, rect.y, labelWidth, rect.height);
Rect nextRect = new(labelRect.xMax + spacing, rect.y, buttonWidth, rect.height);
Rect lastRect = new(nextRect.xMax + spacing, rect.y, buttonWidth, rect.height);
if (lastRect.xMax > rect.xMax)
{
float overflow = lastRect.xMax - rect.xMax;
firstRect.x -= overflow * EnumShared.OverflowCenteringRatio;
prevRect.x -= overflow * EnumShared.OverflowCenteringRatio;
labelRect.x -= overflow * EnumShared.OverflowCenteringRatio;
nextRect.x -= overflow * EnumShared.OverflowCenteringRatio;
lastRect.x -= overflow * EnumShared.OverflowCenteringRatio;
}
bool originalEnabled = GUI.enabled;
bool canNavigateBackward = state.PageIndex > 0;
bool canNavigateForward = state.PageIndex < state.TotalPages - 1;
GUI.enabled = originalEnabled && canNavigateBackward;
if (GUI.Button(firstRect, EnumShared.FirstPageContent, EditorStyles.miniButtonLeft))
{
state.PageIndex = 0;
}
if (GUI.Button(prevRect, EnumShared.PrevPageContent, EditorStyles.miniButtonMid))
{
state.PageIndex = Mathf.Max(0, state.PageIndex - 1);
}
GUI.enabled = originalEnabled;
GUI.Label(
labelRect,
CacheHelper.GetPaginationLabel(state.PageIndex + 1, state.TotalPages),
EditorStyles.miniLabel
);
GUI.enabled = originalEnabled && canNavigateForward;
if (GUI.Button(nextRect, EnumShared.NextPageContent, EditorStyles.miniButtonMid))
{
state.PageIndex = Mathf.Min(state.TotalPages - 1, state.PageIndex + 1);
}
if (GUI.Button(lastRect, EnumShared.LastPageContent, EditorStyles.miniButtonRight))
{
state.PageIndex = state.TotalPages - 1;
}
GUI.enabled = originalEnabled;
}
private void DrawToggle(
Rect rect,
EnumShared.ToggleOption option,
ulong currentMask,
bool isFlags,
EnumShared.LayoutMetrics metrics,
int visibleIndex,
int visibleCount,
UnityHelpersSettings.WEnumToggleButtonsPaletteEntry palette
)
{
bool isActive = IsOptionActive(option, currentMask, isFlags);
EnumShared.ButtonSegment segment = EnumShared.ResolveButtonSegment(
visibleIndex,
visibleCount,
metrics.Columns
);
GUIStyle style = EnumShared.GetButtonStyle(segment, isActive, palette);
bool newState = GUI.Toggle(rect, isActive, option.Label, style);
if (newState == isActive)
{
return;
}
ApplyToggleChange(option, currentMask, isFlags, newState);
}
private static bool IsOptionActive(
EnumShared.ToggleOption option,
ulong currentMask,
bool isFlags
)
{
if (isFlags)
{
if (option.IsZeroFlag)
{
return currentMask == 0UL;
}
return (currentMask & option.FlagValue) == option.FlagValue;
}
return currentMask == option.FlagValue;
}
private void ApplyToggleChange(
EnumShared.ToggleOption option,
ulong currentMask,
bool isFlags,
bool desiredState
)
{
if (isFlags)
{
if (option.IsZeroFlag)
{
if (desiredState && currentMask != 0UL)
{
ApplyEnumValue(0UL);
}
return;
}
ulong mask = option.FlagValue;
ulong newMask;
if (desiredState)
{
newMask = currentMask | mask;
}
else
{
newMask = currentMask & ~mask;
}
ApplyEnumValue(newMask);
}
else
{
ApplyEnumValue(option.FlagValue);
}
}
private void ApplyEnumValue(ulong value)
{
Type enumType = Property.ValueEntry?.TypeOfValue;
if (enumType == null || !enumType.IsEnum)
{
return;
}
object enumValue = Enum.ToObject(enumType, unchecked((long)value));
Property.ValueEntry.WeakSmartValue = enumValue;
}
private string BuildPaginationKey()
{
if (Property == null)
{
return string.Empty;
}
object parent = Property.Parent?.ValueEntry?.WeakSmartValue;
if (parent == null)
{
return Property.Path ?? string.Empty;
}
int instanceId = parent.GetHashCode();
string instancePart = instanceId.ToString("X8", CultureInfo.InvariantCulture);
string pathPart = Property.Path ?? string.Empty;
return instancePart + ":" + pathPart;
}
private static EnumShared.PaginationState GetOrCreatePaginationState(
string key,
int totalItems,
int pageSize
)
{
if (!PaginationStates.TryGetValue(key, out EnumShared.PaginationState state))
{
state = new EnumShared.PaginationState();
PaginationStates[key] = state;
}
state.PageSize = Mathf.Max(1, pageSize);
state.TotalItems = Mathf.Max(0, totalItems);
int totalPages = state.TotalPages;
if (state.PageIndex >= totalPages)
{
state.PageIndex = totalPages - 1;
}
if (state.PageIndex < 0)
{
state.PageIndex = 0;
}
return state;
}
private static EnumShared.SelectionSummary BuildSelectionSummary(
EnumShared.ToggleOption[] options,
ulong currentMask,
bool isFlags,
int startIndex,
int visibleCount,
bool usePagination
)
{
if (!usePagination || options == null || options.Length == 0)
{
return EnumShared.SelectionSummary.None;
}
int endIndex = startIndex + visibleCount;
using PooledResource> lease = Buffers.GetList(
4,
out List outOfView
);
for (int index = 0; index < options.Length; index += 1)
{
EnumShared.ToggleOption option = options[index];
if (!IsOptionActive(option, currentMask, isFlags))
{
continue;
}
if (index >= startIndex && index < endIndex)
{
continue;
}
outOfView.Add(option.Label);
}
if (outOfView.Count == 0)
{
return EnumShared.SelectionSummary.None;
}
string joined = string.Join(", ", outOfView);
string text = "Current (out of view): " + joined;
OutOfViewContent.text = text;
return new EnumShared.SelectionSummary(true, OutOfViewContent);
}
internal static ulong CalculateAllFlagsMask(EnumShared.ToggleOption[] options)
{
ulong mask = 0UL;
for (int index = 0; index < options.Length; index += 1)
{
EnumShared.ToggleOption option = options[index];
if (option.FlagValue != 0UL)
{
mask |= option.FlagValue;
}
}
return mask;
}
internal static EnumShared.ToggleOption[] GetCachedEnumOptions(Type enumType)
{
if (enumType == null || !enumType.IsEnum)
{
return null;
}
if (EnumOptionsCache.TryGetValue(enumType, out EnumShared.ToggleOption[] cached))
{
return cached;
}
bool isFlags = ReflectionHelpers.HasAttributeSafe(
enumType,
inherit: true
);
EnumShared.ToggleOption[] options = BuildEnumOptions(enumType, isFlags);
EnumOptionsCache[enumType] = options;
return options;
}
internal static EnumShared.ToggleOption[] BuildEnumOptions(Type enumType, bool isFlags)
{
Array values = Enum.GetValues(enumType);
using PooledResource> optionsLease =
Buffers.GetList(
values.Length,
out List options
);
for (int index = 0; index < values.Length; index += 1)
{
object value = values.GetValue(index);
if (value == null)
{
continue;
}
string name = Enum.GetName(enumType, value);
if (string.IsNullOrEmpty(name))
{
continue;
}
ulong numericValue = EnumShared.ConvertToUInt64(value);
if (isFlags && numericValue != 0UL && !EnumShared.IsPowerOfTwo(numericValue))
{
Debug.LogWarning(
$"[{nameof(WEnumToggleButtonsOdinDrawer)}] Skipping composite flag value {name} "
+ $"in {enumType.Name} (value: {numericValue})"
);
continue;
}
string label = ObjectNames.NicifyVariableName(name);
EnumShared.ToggleOption option = new(
label,
value,
numericValue,
numericValue == 0UL
);
options.Add(option);
}
if (options.Count == 0)
{
return Array.Empty();
}
return options.ToArray();
}
internal static bool ShouldPaginate(
WEnumToggleButtonsAttribute attribute,
int optionCount,
out int pageSize
)
{
pageSize = ResolvePageSize(attribute);
if (attribute is { EnablePagination: false })
{
return false;
}
return optionCount > pageSize;
}
internal static int ResolvePageSize(WEnumToggleButtonsAttribute attribute)
{
int overrideSize = attribute?.PageSize ?? 0;
if (overrideSize > 0)
{
return Mathf.Clamp(
overrideSize,
UnityHelpersSettings.MinPageSize,
UnityHelpersSettings.MaxPageSize
);
}
return Mathf.Clamp(
UnityHelpersSettings.GetEnumToggleButtonsPageSize(),
UnityHelpersSettings.MinPageSize,
UnityHelpersSettings.MaxPageSize
);
}
}
#endif
}