// 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 System.Globalization;
using System.Runtime.CompilerServices;
using UnityEditor;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Settings;
using WallstopStudios.UnityHelpers.Editor.Utils.WButton;
///
/// Provides shared constants, types, and helper methods for enum toggle button drawers.
///
///
/// This utility class consolidates common code used by both the standard PropertyDrawer
/// and the Odin Inspector drawer implementations of WEnumToggleButtons. By centralizing
/// these elements, we ensure consistent behavior and eliminate code duplication.
///
public static class EnumToggleButtonsShared
{
///
/// Default padding around buttons.
///
public const float ButtonPadding = 6f;
///
/// Padding around selected items.
///
public const float SelectionPadding = 8f;
///
/// Default toolbar height.
///
public const float ToolbarHeight = 68f;
///
/// Height of selected item display.
///
public const float SelectedItemHeight = 60f;
///
/// Height of labels.
///
public const float LabelHeight = 22f;
///
/// Minimum width for buttons.
///
public const float MinButtonWidth = 80f;
///
/// Vertical spacing between button rows.
///
public const float ButtonVerticalSpacing = 2f;
///
/// Horizontal spacing between buttons.
///
public const float ButtonHorizontalSpacing = 5f;
///
/// Spacing between toolbar elements.
///
public const float ToolbarSpacing = 6f;
///
/// Gap between toolbar buttons.
///
public const float ToolbarButtonGap = 8f;
///
/// Minimum width for toolbar buttons.
///
public const float ToolbarButtonMinWidth = 60f;
///
/// Width of pagination navigation buttons.
///
public const float PaginationButtonWidth = 22f;
///
/// Minimum width for pagination label.
///
public const float PaginationLabelMinWidth = 80f;
///
/// Spacing above and below summary text.
///
public const float SummarySpacing = 2f;
///
/// Ratio used to split available width evenly between two aligned buttons (e.g., Select All / None).
///
public const float EqualSplitRatio = 0.5f;
///
/// Maximum ratio of the pagination area width allocated to each navigation button.
/// Ensures buttons don't become excessively wide in large layouts.
///
public const float MaxPaginationButtonWidthRatio = 0.2f;
///
/// Ratio used to center elements when distributing overflow correction.
/// Applies half the overflow adjustment to shift elements toward center.
///
public const float OverflowCenteringRatio = 0.5f;
///
/// Content for navigating to previous page.
///
public static readonly GUIContent PrevPageContent = new("◀", "Previous Page");
///
/// Content for navigating to next page.
///
public static readonly GUIContent NextPageContent = new("▶", "Next Page");
///
/// Content for the "None" selection button.
///
public static readonly GUIContent NoneContent = new("None");
///
/// Content for the "All" selection button.
///
public static readonly GUIContent AllContent = new("All");
///
/// Content for expand indicator.
///
public static readonly GUIContent ExpandContent = new("▼");
///
/// Content for collapse indicator.
///
public static readonly GUIContent CollapseContent = new("▲");
///
/// Content for search indicator.
///
public static readonly GUIContent SearchContent = new("🔍", "Search");
// Lazy initialization to avoid calling EditorGUIUtility during static class loading,
// which can hang Unity during "Open Project: Open Scene" if the class is accessed
// before EditorGUIUtility is fully initialized.
private static GUIContent _firstPageContent;
private static GUIContent _lastPageContent;
///
/// Content for navigating to first page.
///
public static GUIContent FirstPageContent =>
_firstPageContent ??= EditorGUIUtility.TrTextContent("<<", "First Page");
///
/// Content for navigating to last page.
///
public static GUIContent LastPageContent =>
_lastPageContent ??= EditorGUIUtility.TrTextContent(">>", "Last Page");
private static readonly Dictionary ButtonStyleCache = new(
new ButtonStyleCacheKeyComparer()
);
private static GUIStyle _summaryStyle;
private const string SummaryStyleKey = "EnumToggleButtons/SummaryStyle";
///
/// Gets the style used for displaying selection summary text.
///
public static GUIStyle SummaryStyle
{
get
{
if (_summaryStyle != null)
{
return _summaryStyle;
}
_summaryStyle = EditorCacheHelper.GetOrCreateStyle(
SummaryStyleKey,
CreateSummaryStyle
);
return _summaryStyle;
}
}
private static GUIStyle CreateSummaryStyle()
{
return new GUIStyle(EditorStyles.wordWrappedMiniLabel) { fontStyle = FontStyle.Italic };
}
///
/// Determines which segment style a button should use based on its position.
///
/// The zero-based index of the button in the visible set.
/// The total number of visible buttons.
/// The number of columns in the layout.
/// The appropriate for the button position.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ButtonSegment ResolveButtonSegment(int index, int total, int columns)
{
if (columns <= 1)
{
return ButtonSegment.Single;
}
int columnIndex = index % columns;
bool isFirst = columnIndex == 0;
bool isLast = columnIndex == columns - 1 || index == total - 1;
if (isFirst && isLast)
{
return ButtonSegment.Single;
}
if (isFirst)
{
return ButtonSegment.Left;
}
if (isLast)
{
return ButtonSegment.Right;
}
return ButtonSegment.Middle;
}
///
/// Calculates the layout metrics for displaying toggle buttons.
///
/// The requested number of buttons per row, or 0 for auto.
/// The total number of options to display.
/// The available width for the button layout.
/// The height of a single button row.
/// The spacing between buttons.
/// The minimum width for a single button.
/// A describing the calculated layout.
public static LayoutMetrics CalculateLayout(
int requestedPerRow,
int optionCount,
float availableWidth,
float lineHeight,
float spacing,
float minWidth
)
{
if (optionCount <= 0)
{
return new LayoutMetrics(1, 0, minWidth, lineHeight, spacing, 0f);
}
if (optionCount == 1)
{
float singleWidth = Mathf.Max(minWidth, availableWidth);
return new LayoutMetrics(1, 1, singleWidth, lineHeight, spacing, lineHeight);
}
int columns =
requestedPerRow > 0
? requestedPerRow
: DetermineAutoColumns(availableWidth, spacing, minWidth);
columns = Mathf.Clamp(columns, 1, optionCount);
int rows = Mathf.CeilToInt(optionCount / (float)columns);
float workingWidth = availableWidth;
if (workingWidth <= 0f)
{
workingWidth = columns * minWidth + (columns - 1) * spacing;
}
float buttonWidth = (workingWidth - (columns - 1) * spacing) / columns;
if (buttonWidth < minWidth)
{
buttonWidth = minWidth;
}
float totalHeight = rows * lineHeight + Mathf.Max(0, rows - 1) * spacing;
return new LayoutMetrics(columns, rows, buttonWidth, lineHeight, spacing, totalHeight);
}
///
/// Determines the optimal number of columns based on available width.
///
/// The available width for the layout.
/// The spacing between buttons.
/// The minimum width for each button.
/// The optimal number of columns, at least 1.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int DetermineAutoColumns(float availableWidth, float spacing, float minWidth)
{
if (availableWidth <= 0f)
{
return 1;
}
float effectiveWidth = minWidth + spacing;
int columns = Mathf.FloorToInt((availableWidth + spacing) / effectiveWidth);
if (columns < 1)
{
columns = 1;
}
return columns;
}
///
/// Checks if a value is a power of two.
///
/// The value to check.
/// True if the value is a power of two and not zero; otherwise, false.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool IsPowerOfTwo(ulong value)
{
return value != 0UL && (value & (value - 1UL)) == 0UL;
}
///
/// Converts an object value to a UInt64 representation.
///
/// The value to convert (typically an enum value).
/// The UInt64 representation, or 0 if conversion fails.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static ulong ConvertToUInt64(object value)
{
if (value == null)
{
return 0UL;
}
try
{
return Convert.ToUInt64(value, CultureInfo.InvariantCulture);
}
catch
{
return 0UL;
}
}
///
/// Gets a cached button style for the specified segment and state.
///
/// The button segment (Single, Left, Middle, Right).
/// Whether the button is in an active (selected) state.
/// The background color when selected.
/// The text color when selected.
/// The background color when not selected.
/// The text color when not selected.
/// A cached configured for the button.
public static GUIStyle GetButtonStyle(
ButtonSegment segment,
bool isActive,
Color selectedBg,
Color selectedText,
Color inactiveBg,
Color inactiveText
)
{
ButtonStyleCacheKey key = new(
segment,
isActive,
selectedBg,
selectedText,
inactiveBg,
inactiveText
);
if (ButtonStyleCache.TryGetValue(key, out GUIStyle cached))
{
return cached;
}
GUIStyle basis = segment switch
{
ButtonSegment.Left => EditorStyles.miniButtonLeft,
ButtonSegment.Middle => EditorStyles.miniButtonMid,
ButtonSegment.Right => EditorStyles.miniButtonRight,
_ => EditorStyles.miniButton,
};
GUIStyle style = new(basis)
{
name =
"EnumToggleButtons/"
+ segment.ToString()
+ "/"
+ (isActive ? "Active" : "Inactive"),
};
Color baseBackground = isActive ? selectedBg : inactiveBg;
Color hoverBackground = WButtonColorUtility.GetHoverColor(baseBackground);
Color activeBackground = WButtonColorUtility.GetActiveColor(baseBackground);
Color textColor = isActive ? selectedText : inactiveText;
ConfigureButtonStyle(
style,
baseBackground,
hoverBackground,
activeBackground,
textColor
);
ButtonStyleCache[key] = style;
return style;
}
///
/// Gets a cached button style using a palette entry.
///
/// The button segment (Single, Left, Middle, Right).
/// Whether the button is in an active (selected) state.
/// The color palette entry to use.
/// A cached configured for the button.
public static GUIStyle GetButtonStyle(
ButtonSegment segment,
bool isActive,
UnityHelpersSettings.WEnumToggleButtonsPaletteEntry palette
)
{
return GetButtonStyle(
segment,
isActive,
palette.SelectedBackgroundColor,
palette.SelectedTextColor,
palette.InactiveBackgroundColor,
palette.InactiveTextColor
);
}
///
/// Configures a GUIStyle with the specified colors for all states.
///
/// The style to configure.
/// The background color for normal state.
/// The background color for hover state.
/// The background color for active/pressed state.
/// The text color for all states.
public static void ConfigureButtonStyle(
GUIStyle style,
Color normalBg,
Color hoverBg,
Color activeBg,
Color textColor
)
{
if (style == null)
{
return;
}
Texture2D normalTexture = EditorCacheHelper.GetOrCreateTexture(normalBg);
Texture2D hoverTexture = EditorCacheHelper.GetOrCreateTexture(hoverBg);
Texture2D activeTexture = EditorCacheHelper.GetOrCreateTexture(activeBg);
style.normal.background = normalTexture;
style.normal.textColor = textColor;
style.focused.background = normalTexture;
style.focused.textColor = textColor;
style.onNormal.background = normalTexture;
style.onNormal.textColor = textColor;
style.onFocused.background = normalTexture;
style.onFocused.textColor = textColor;
style.hover.background = hoverTexture;
style.hover.textColor = textColor;
style.onHover.background = hoverTexture;
style.onHover.textColor = textColor;
style.active.background = activeTexture;
style.active.textColor = textColor;
style.onActive.background = activeTexture;
style.onActive.textColor = textColor;
}
///
/// Clears all cached button styles. Call when theme or colors change.
///
public static void ClearStyleCache()
{
ButtonStyleCache.Clear();
_summaryStyle = null;
}
///
/// Defines the visual segment of a button in a horizontal toolbar layout.
///
public enum ButtonSegment
{
/// A standalone button with no adjacent buttons.
Single = 0,
/// The leftmost button in a group.
Left = 1,
/// A middle button in a group.
Middle = 2,
/// The rightmost button in a group.
Right = 3,
}
///
/// Cache key for button styles, incorporating all visual parameters.
///
public readonly struct ButtonStyleCacheKey : IEquatable
{
///
/// Gets the button segment type.
///
public ButtonSegment Segment { get; }
///
/// Gets whether the button is in an active state.
///
public bool IsActive { get; }
///
/// Gets the selected background color.
///
public Color SelectedBackground { get; }
///
/// Gets the selected text color.
///
public Color SelectedText { get; }
///
/// Gets the inactive background color.
///
public Color InactiveBackground { get; }
///
/// Gets the inactive text color.
///
public Color InactiveText { get; }
///
/// Creates a new button style cache key.
///
/// The button segment type.
/// Whether the button is active.
/// The selected background color.
/// The selected text color.
/// The inactive background color.
/// The inactive text color.
public ButtonStyleCacheKey(
ButtonSegment segment,
bool isActive,
Color selectedBackground,
Color selectedText,
Color inactiveBackground,
Color inactiveText
)
{
Segment = segment;
IsActive = isActive;
SelectedBackground = selectedBackground;
SelectedText = selectedText;
InactiveBackground = inactiveBackground;
InactiveText = inactiveText;
}
///
/// Creates a new button style cache key from a palette entry.
///
/// The button segment type.
/// Whether the button is active.
/// The color palette entry.
public ButtonStyleCacheKey(
ButtonSegment segment,
bool isActive,
UnityHelpersSettings.WEnumToggleButtonsPaletteEntry palette
)
{
Segment = segment;
IsActive = isActive;
SelectedBackground = palette.SelectedBackgroundColor;
SelectedText = palette.SelectedTextColor;
InactiveBackground = palette.InactiveBackgroundColor;
InactiveText = palette.InactiveTextColor;
}
///
public bool Equals(ButtonStyleCacheKey other)
{
return Segment == other.Segment
&& IsActive == other.IsActive
&& EditorCacheHelper.AreColorsEqual(
SelectedBackground,
other.SelectedBackground
)
&& EditorCacheHelper.AreColorsEqual(SelectedText, other.SelectedText)
&& EditorCacheHelper.AreColorsEqual(
InactiveBackground,
other.InactiveBackground
)
&& EditorCacheHelper.AreColorsEqual(InactiveText, other.InactiveText);
}
///
public override bool Equals(object obj)
{
return obj is ButtonStyleCacheKey other && Equals(other);
}
///
public override int GetHashCode()
{
return Objects.HashCode(
Segment,
IsActive,
SelectedBackground.r,
SelectedBackground.g,
SelectedBackground.b,
SelectedBackground.a,
SelectedText.r,
SelectedText.g,
SelectedText.b,
SelectedText.a,
InactiveBackground.r,
InactiveBackground.g,
InactiveBackground.b,
InactiveBackground.a,
InactiveText.r,
InactiveText.g,
InactiveText.b,
InactiveText.a
);
}
}
///
/// Comparer for used in dictionary lookups.
///
public sealed class ButtonStyleCacheKeyComparer : IEqualityComparer
{
///
public bool Equals(ButtonStyleCacheKey x, ButtonStyleCacheKey y)
{
return x.Equals(y);
}
///
public int GetHashCode(ButtonStyleCacheKey obj)
{
return obj.GetHashCode();
}
}
///
/// Represents a single toggle option in an enum toggle button drawer.
///
public readonly struct ToggleOption
{
///
/// Gets the display label for the option.
///
public string Label { get; }
///
/// Gets the original enum value.
///
public object Value { get; }
///
/// Gets the numeric flag value as UInt64.
///
public ulong FlagValue { get; }
///
/// Gets whether this represents a zero-valued flag (typically "None").
///
public bool IsZeroFlag { get; }
///
/// Creates a new toggle option.
///
/// The display label.
/// The enum value.
/// The numeric flag value.
/// Whether this is a zero flag.
public ToggleOption(string label, object value, ulong flagValue, bool isZeroFlag)
{
Label = string.IsNullOrEmpty(label) ? "(Unnamed)" : label;
Value = value;
FlagValue = flagValue;
IsZeroFlag = isZeroFlag;
}
}
///
/// Represents a summary of selected items not visible on the current page.
///
public readonly struct SelectionSummary
{
///
/// Gets a summary indicating no out-of-view selections.
///
public static SelectionSummary None { get; } = new(false, GUIContent.none);
///
/// Gets whether there is summary content to display.
///
public bool HasSummary { get; }
///
/// Gets the GUI content to display.
///
public GUIContent Content { get; }
///
/// Creates a new selection summary.
///
/// Whether there is summary content.
/// The content to display.
public SelectionSummary(bool hasSummary, GUIContent content)
{
HasSummary = hasSummary;
Content = content ?? GUIContent.none;
}
}
///
/// Describes the calculated layout metrics for displaying toggle buttons.
///
public readonly struct LayoutMetrics
{
///
/// Gets the number of columns in the layout.
///
public int Columns { get; }
///
/// Gets the number of rows in the layout.
///
public int Rows { get; }
///
/// Gets the width of each button.
///
public float ButtonWidth { get; }
///
/// Gets the height of each button.
///
public float ButtonHeight { get; }
///
/// Gets the spacing between buttons.
///
public float Spacing { get; }
///
/// Gets the total height of the button layout.
///
public float TotalHeight { get; }
///
/// Creates new layout metrics.
///
/// The number of columns.
/// The number of rows.
/// The button width.
/// The button height.
/// The spacing between buttons.
/// The total layout height.
public LayoutMetrics(
int columns,
int rows,
float buttonWidth,
float buttonHeight,
float spacing,
float totalHeight
)
{
Columns = columns;
Rows = rows;
ButtonWidth = buttonWidth;
ButtonHeight = buttonHeight;
Spacing = spacing;
TotalHeight = totalHeight;
}
///
/// Gets the rectangle for a button at the specified index.
///
/// The bounding rectangle for the entire button area.
/// The zero-based index of the button.
/// The rectangle where the button should be drawn.
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public Rect GetItemRect(Rect bounds, int index)
{
int row = index / Columns;
int column = index % Columns;
float x = bounds.x + column * (ButtonWidth + Spacing);
float y = bounds.y + row * (ButtonHeight + Spacing);
return new Rect(x, y, ButtonWidth, ButtonHeight);
}
}
///
/// Tracks pagination state for a property.
///
public sealed class PaginationState
{
private int _pageIndex;
///
/// Gets or sets the number of items per page.
///
public int PageSize { get; set; }
///
/// Gets or sets the total number of items.
///
public int TotalItems { get; set; }
///
/// Gets or sets the current page index (0-based).
///
public int PageIndex
{
get => _pageIndex;
set => _pageIndex = value;
}
///
/// Gets the total number of pages.
///
public int TotalPages
{
get
{
if (PageSize <= 0)
{
return 1;
}
return Mathf.Max(1, Mathf.CeilToInt(TotalItems / (float)PageSize));
}
}
///
/// Gets the starting index of items on the current page.
///
public int StartIndex
{
get
{
if (TotalItems <= 0 || PageSize <= 0)
{
return 0;
}
int clampedIndex = Mathf.Clamp(PageIndex, 0, TotalPages - 1);
return clampedIndex * PageSize;
}
}
///
/// Gets the number of items visible on the current page.
///
public int VisibleCount
{
get
{
if (TotalItems <= 0 || PageSize <= 0)
{
return 0;
}
int clampedIndex = Mathf.Clamp(PageIndex, 0, TotalPages - 1);
int start = clampedIndex * PageSize;
return Mathf.Clamp(TotalItems - start, 0, PageSize);
}
}
}
}
#endif
}