// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Editor.Utils.WButton
{
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.AnimatedValues;
using UnityEditorInternal;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Attributes;
using WallstopStudios.UnityHelpers.Core.Extension;
using WallstopStudios.UnityHelpers.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Core.Helper;
using WallstopStudios.UnityHelpers.Editor.Settings;
using WallstopStudios.UnityHelpers.Utils;
public enum WButtonPlacement
{
Top = 0,
Bottom = 1,
}
///
/// Compound key for grouping WButtons by group priority, draw order, and group name.
/// This allows multiple groups with different names to render separately with controlled ordering.
///
internal readonly struct WButtonGroupKey
: IEquatable,
IComparable
{
internal readonly int _groupPriority;
internal readonly int _drawOrder;
internal readonly string _groupName;
internal readonly int _declarationOrder;
internal readonly WButtonGroupPlacement _groupPlacement;
internal WButtonGroupKey(
int groupPriority,
int drawOrder,
string groupName,
int declarationOrder,
WButtonGroupPlacement groupPlacement
)
{
_groupPriority = groupPriority;
_drawOrder = drawOrder;
_groupName = groupName ?? string.Empty;
_declarationOrder = declarationOrder;
_groupPlacement = groupPlacement;
}
public bool Equals(WButtonGroupKey other)
{
return _groupPriority == other._groupPriority
&& _drawOrder == other._drawOrder
&& _declarationOrder == other._declarationOrder
&& _groupPlacement == other._groupPlacement
&& string.Equals(_groupName, other._groupName, StringComparison.Ordinal);
}
public override bool Equals(object obj)
{
return obj is WButtonGroupKey other && Equals(other);
}
public override int GetHashCode()
{
return Objects.HashCode(
_groupPriority,
_drawOrder,
_declarationOrder,
_groupPlacement,
_groupName
);
}
public int CompareTo(WButtonGroupKey other)
{
// First compare by group priority (lower values first, NoGroupPriority sorts last)
int priorityComparison = _groupPriority.CompareTo(other._groupPriority);
if (priorityComparison != 0)
{
return priorityComparison;
}
// Then compare by draw order (lower values first)
int drawOrderComparison = _drawOrder.CompareTo(other._drawOrder);
if (drawOrderComparison != 0)
{
return drawOrderComparison;
}
// Finally by declaration order to preserve source code order
return _declarationOrder.CompareTo(other._declarationOrder);
}
}
internal static class WButtonGUI
{
private static readonly Dictionary GroupCounts = new();
private static readonly Dictionary GroupNames = new();
private static readonly Dictionary FoldoutAnimations = new();
private static readonly Dictionary GroupHeaderCache = new();
private static readonly Dictionary<(string, int), string> GroupHeaderTextCache = new();
private static readonly GUIContent ClearHistoryContent = new("Clear History");
private static readonly GUIContent RecentResultsHeaderContent = new("Recent Results");
private static readonly SortedDictionary<
WButtonGroupKey,
List
> ReusableGroups = new();
private static readonly Dictionary<
WButtonGroupKey,
PooledResource>
> ReusableGroupLeases = new();
private static readonly Dictionary ButtonDisplayNameCache = new(
StringComparer.Ordinal
);
private const string RunningLabel = "Running...";
private const float ClearHistoryButtonPadding = 12f;
private const float ClearHistoryMinWidth = 96f;
private const float ClearHistorySpacing = 6f;
///
/// Gets a cached pagination label. Delegates to .
///
private static string GetPaginationLabel(int page, int totalPages)
{
return EditorCacheHelper.GetPaginationLabel(page, totalPages);
}
///
/// Gets a cached string representation of an integer.
/// Delegates to .
///
/// The integer value to convert.
/// A cached string representation of the integer.
private static string GetCachedIntString(int value)
{
return EditorCacheHelper.GetCachedIntString(value);
}
private static readonly Dictionary RunningLabelByCountCache = new();
private static string GetRunningLabel(int count)
{
if (count == 1)
{
return RunningLabel;
}
return RunningLabelByCountCache.GetOrAdd(
count,
c => "Running (" + GetCachedIntString(c) + ")"
);
}
internal static bool DrawButtons(
Editor editor,
WButtonPlacement placement,
IDictionary paginationStates,
IDictionary foldoutStates,
UnityHelpersSettings.WButtonFoldoutBehavior foldoutBehavior,
List triggeredContexts = null,
bool globalPlacementIsTop = true
)
{
if (editor == null)
{
return false;
}
UnityEngine.Object[] targets = editor.targets;
if (targets == null || targets.Length == 0 || targets[0] == null)
{
return false;
}
Type inspectedType = targets[0].GetType();
IReadOnlyList metadataList = WButtonMetadataCache.GetMetadata(
inspectedType
);
if (metadataList.Count == 0)
{
return false;
}
using PooledResource> contextsLease =
Buffers.GetList(
metadataList.Count,
out List contexts
);
BuildContexts(metadataList, targets, contexts);
if (contexts.Count == 0)
{
return false;
}
SortedDictionary> groups = ReusableGroups;
Dictionary>> groupLeases =
ReusableGroupLeases;
GroupByDrawOrderAndGroupName(contexts, groups, groupLeases);
try
{
bool anyDrawn = false;
GroupCounts.Clear();
GroupNames.Clear();
foreach (KeyValuePair> entry in groups)
{
List groupContexts = entry.Value;
GroupCounts[entry.Key] = groupContexts?.Count ?? 0;
string resolvedGroupName = ResolveGroupName(groupContexts);
if (!string.IsNullOrWhiteSpace(resolvedGroupName))
{
GroupNames[entry.Key] = resolvedGroupName;
}
}
foreach (KeyValuePair> entry in groups)
{
WButtonGroupKey groupKey = entry.Key;
WButtonGroupPlacement groupPlacement = groupKey._groupPlacement;
// Resolve effective placement based on group placement setting
bool drawOnTop;
if (groupPlacement == WButtonGroupPlacement.UseGlobalSetting)
{
// Use global setting: render based on the global placement passed by caller
drawOnTop = globalPlacementIsTop;
}
else
{
drawOnTop = groupPlacement == WButtonGroupPlacement.Top;
}
if (
(placement == WButtonPlacement.Top && drawOnTop)
|| (placement == WButtonPlacement.Bottom && !drawOnTop)
)
{
DrawGroup(
groupKey,
entry.Value,
paginationStates,
foldoutStates,
foldoutBehavior,
triggeredContexts
);
anyDrawn = true;
}
}
return anyDrawn;
}
finally
{
foreach (
KeyValuePair<
WButtonGroupKey,
PooledResource>
> entry in groupLeases
)
{
entry.Value.Dispose();
}
}
}
internal static Dictionary GetGroupCountsForTesting()
{
return GroupCounts;
}
internal static Dictionary GetGroupNamesForTesting()
{
return GroupNames;
}
///
/// For testing: sets group counts with simple int keys (legacy compatibility).
/// Creates group keys with the given draw order and empty group name.
///
internal static void SetGroupCountsForTesting(Dictionary counts)
{
GroupCounts.Clear();
foreach (KeyValuePair entry in counts)
{
WButtonGroupKey key = new(
WButtonAttribute.NoGroupPriority,
entry.Key,
null,
0,
WButtonGroupPlacement.UseGlobalSetting
);
GroupCounts[key] = entry.Value;
}
}
///
/// For testing: sets group names with simple int keys (legacy compatibility).
/// Creates group keys with the given draw order and empty group name.
///
internal static void SetGroupNamesForTesting(Dictionary names)
{
GroupNames.Clear();
foreach (KeyValuePair entry in names)
{
WButtonGroupKey key = new(
WButtonAttribute.NoGroupPriority,
entry.Key,
null,
0,
WButtonGroupPlacement.UseGlobalSetting
);
GroupNames[key] = entry.Value;
}
}
///
/// For testing: clears all group counts and names.
///
internal static void ClearGroupDataForTesting()
{
GroupCounts.Clear();
GroupNames.Clear();
}
private static void BuildContexts(
IReadOnlyList metadataList,
UnityEngine.Object[] targets,
List contexts
)
{
contexts.Clear();
int targetCount = targets.Length;
for (int index = 0; index < metadataList.Count; index++)
{
WButtonMethodMetadata metadata = metadataList[index];
bool allValid = true;
for (int targetIndex = 0; targetIndex < targetCount; targetIndex++)
{
if (targets[targetIndex] == null)
{
allValid = false;
break;
}
}
if (!allValid)
{
continue;
}
WButtonMethodContext existingContext = FindCachedContext(metadata, targets);
if (existingContext != null)
{
contexts.Add(existingContext);
continue;
}
WButtonMethodState[] states = new WButtonMethodState[targetCount];
UnityEngine.Object[] contextTargets = new UnityEngine.Object[targetCount];
for (int targetIndex = 0; targetIndex < targetCount; targetIndex++)
{
UnityEngine.Object target = targets[targetIndex];
WButtonTargetState targetState = WButtonStateRepository.GetOrCreate(target);
states[targetIndex] = targetState.GetOrCreateMethodState(metadata);
contextTargets[targetIndex] = target;
}
WButtonMethodContext context = new(metadata, states, contextTargets);
CacheContext(metadata, targets, context);
contexts.Add(context);
}
}
private static readonly Dictionary ContextCache =
new();
private static WButtonMethodContext FindCachedContext(
WButtonMethodMetadata metadata,
UnityEngine.Object[] targets
)
{
ContextCacheKey key = new(metadata, targets);
if (ContextCache.TryGetValue(key, out WButtonMethodContext context))
{
if (ValidateContext(context, targets))
{
return context;
}
ContextCache.Remove(key);
}
return null;
}
private static void CacheContext(
WButtonMethodMetadata metadata,
UnityEngine.Object[] targets,
WButtonMethodContext context
)
{
ContextCacheKey key = new(metadata, targets);
ContextCache[key] = context;
}
private static bool ValidateContext(
WButtonMethodContext context,
UnityEngine.Object[] targets
)
{
UnityEngine.Object[] contextTargets = context.Targets;
if (contextTargets.Length != targets.Length)
{
return false;
}
for (int i = 0; i < targets.Length; i++)
{
if (!ReferenceEquals(contextTargets[i], targets[i]))
{
return false;
}
}
return true;
}
internal static void ClearContextCache()
{
ContextCache.Clear();
}
private static void GroupByDrawOrderAndGroupName(
List contexts,
SortedDictionary> groups,
Dictionary>> leases
)
{
groups.Clear();
leases.Clear();
ConflictingDrawOrderWarnings.Clear();
ConflictingGroupPriorityWarnings.Clear();
ConflictingGroupPlacementWarnings.Clear();
// For buttons with a groupName, we need to merge them into a single group even if they have different drawOrders.
// We use the first (minimum) declaration order's values as the canonical values for the group.
// Buttons without a groupName (empty string) are grouped by their individual drawOrder.
// Track: groupName -> (first declaration order, canonical draw order, canonical group priority, canonical group placement)
Dictionary<
string,
(
int declarationOrder,
int drawOrder,
int groupPriority,
WButtonGroupPlacement groupPlacement
)
> namedGroupInfo = new();
// Track conflicting draw orders for warning purposes
// groupName -> HashSet of all draw orders seen for that group
Dictionary> drawOrdersPerGroup = new();
// Track conflicting group priorities for warning purposes
// groupName -> HashSet of all group priorities seen for that group
Dictionary> groupPrioritiesPerGroup = new();
// Track conflicting group placements for warning purposes
// groupName -> HashSet of all group placements seen for that group
Dictionary> groupPlacementsPerGroup = new();
// First pass: determine canonical values for each named group (based on first declared button)
foreach (WButtonMethodContext context in contexts)
{
string groupName = context.Metadata.GroupName ?? string.Empty;
if (string.IsNullOrEmpty(groupName))
{
// Buttons without a group name are handled separately (grouped by drawOrder alone)
continue;
}
int drawOrder = context.Metadata.DrawOrder;
int declarationOrder = context.Metadata.DeclarationOrder;
int groupPriority = context.Metadata.GroupPriority;
WButtonGroupPlacement groupPlacement = context.Metadata.GroupPlacement;
// Track all draw orders seen for this group (for warning purposes)
drawOrdersPerGroup.GetOrAdd(groupName).Add(drawOrder);
// Track only explicit group priorities for conflict detection (ignore NoGroupPriority sentinel)
if (groupPriority != WButtonAttribute.NoGroupPriority)
{
groupPrioritiesPerGroup.GetOrAdd(groupName).Add(groupPriority);
}
// Track only explicit group placements for conflict detection (ignore UseGlobalSetting sentinel)
if (groupPlacement != WButtonGroupPlacement.UseGlobalSetting)
{
groupPlacementsPerGroup.GetOrAdd(groupName).Add(groupPlacement);
}
if (
!namedGroupInfo.TryGetValue(
groupName,
out (
int declarationOrder,
int drawOrder,
int groupPriority,
WButtonGroupPlacement groupPlacement
) existing
)
)
{
namedGroupInfo[groupName] = (
declarationOrder,
drawOrder,
groupPriority,
groupPlacement
);
}
else if (declarationOrder < existing.declarationOrder)
{
// This button was declared earlier, use its values as canonical
namedGroupInfo[groupName] = (
declarationOrder,
drawOrder,
groupPriority,
groupPlacement
);
}
}
// Generate warnings for groups with conflicting draw orders
foreach (KeyValuePair> entry in drawOrdersPerGroup)
{
if (entry.Value.Count > 1)
{
(
int declarationOrder,
int drawOrder,
int groupPriority,
WButtonGroupPlacement groupPlacement
) info = namedGroupInfo[entry.Key];
ConflictingDrawOrderWarnings[entry.Key] = new DrawOrderConflictInfo(
entry.Key,
info.drawOrder,
entry.Value
);
}
}
// Generate warnings for groups with conflicting group priorities
foreach (KeyValuePair> entry in groupPrioritiesPerGroup)
{
if (entry.Value.Count > 1)
{
(
int declarationOrder,
int drawOrder,
int groupPriority,
WButtonGroupPlacement groupPlacement
) info = namedGroupInfo[entry.Key];
ConflictingGroupPriorityWarnings[entry.Key] = new GroupPriorityConflictInfo(
entry.Key,
info.groupPriority,
entry.Value
);
}
}
// Generate warnings for groups with conflicting group placements
foreach (
KeyValuePair<
string,
HashSet
> entry in groupPlacementsPerGroup
)
{
if (entry.Value.Count > 1)
{
(
int declarationOrder,
int drawOrder,
int groupPriority,
WButtonGroupPlacement groupPlacement
) info = namedGroupInfo[entry.Key];
ConflictingGroupPlacementWarnings[entry.Key] = new GroupPlacementConflictInfo(
entry.Key,
info.groupPlacement,
entry.Value
);
}
}
// Track the first declaration order for each unique group key (for ungrouped buttons)
Dictionary<(int, string), int> firstDeclarationOrderForUngrouped = new();
// First pass for ungrouped buttons: find minimum declaration order per (drawOrder, empty groupName)
foreach (WButtonMethodContext context in contexts)
{
string groupName = context.Metadata.GroupName ?? string.Empty;
if (!string.IsNullOrEmpty(groupName))
{
continue;
}
int drawOrder = context.Metadata.DrawOrder;
int declarationOrder = context.Metadata.DeclarationOrder;
(int, string) lookupKey = (drawOrder, groupName);
if (
!firstDeclarationOrderForUngrouped.TryGetValue(lookupKey, out int existingOrder)
)
{
firstDeclarationOrderForUngrouped[lookupKey] = declarationOrder;
}
else if (declarationOrder < existingOrder)
{
firstDeclarationOrderForUngrouped[lookupKey] = declarationOrder;
}
}
// Second pass: build groups
foreach (WButtonMethodContext context in contexts)
{
string groupName = context.Metadata.GroupName ?? string.Empty;
int drawOrder;
int groupDeclarationOrder;
int groupPriority;
WButtonGroupPlacement groupPlacement;
if (!string.IsNullOrEmpty(groupName))
{
// Named group: use the canonical values from the first declared button
(
int declarationOrder,
int canonicalDrawOrder,
int canonicalGroupPriority,
WButtonGroupPlacement canonicalGroupPlacement
) info = namedGroupInfo[groupName];
drawOrder = info.canonicalDrawOrder;
groupDeclarationOrder = info.declarationOrder;
groupPriority = info.canonicalGroupPriority;
groupPlacement = info.canonicalGroupPlacement;
}
else
{
// Ungrouped button: use its own values, ignore groupPriority and groupPlacement
drawOrder = context.Metadata.DrawOrder;
(int, string) lookupKey = (drawOrder, groupName);
groupDeclarationOrder = firstDeclarationOrderForUngrouped[lookupKey];
groupPriority = WButtonAttribute.NoGroupPriority;
groupPlacement = WButtonGroupPlacement.UseGlobalSetting;
}
WButtonGroupKey groupKey = new(
groupPriority,
drawOrder,
groupName,
groupDeclarationOrder,
groupPlacement
);
if (!groups.TryGetValue(groupKey, out List group))
{
PooledResource> lease =
Buffers.GetList(4, out group);
groups[groupKey] = group;
leases[groupKey] = lease;
}
group.Add(context);
}
}
///
/// Information about conflicting draw orders within a named group.
///
internal readonly struct DrawOrderConflictInfo
{
// ReSharper disable once NotAccessedField.Global
internal readonly string _groupName;
internal readonly int _canonicalDrawOrder;
internal readonly HashSet _allDrawOrders;
internal DrawOrderConflictInfo(
string groupName,
int canonicalDrawOrder,
HashSet allDrawOrders
)
{
_groupName = groupName;
_canonicalDrawOrder = canonicalDrawOrder;
_allDrawOrders = allDrawOrders;
}
}
///
/// Information about conflicting group priorities within a named group.
///
internal readonly struct GroupPriorityConflictInfo
{
// ReSharper disable once NotAccessedField.Global
internal readonly string _groupName;
internal readonly int _canonicalGroupPriority;
internal readonly HashSet _allGroupPriorities;
internal GroupPriorityConflictInfo(
string groupName,
int canonicalGroupPriority,
HashSet allGroupPriorities
)
{
_groupName = groupName;
_canonicalGroupPriority = canonicalGroupPriority;
_allGroupPriorities = allGroupPriorities;
}
}
///
/// Information about conflicting group placements within a named group.
///
internal readonly struct GroupPlacementConflictInfo
{
// ReSharper disable once NotAccessedField.Global
internal readonly string _groupName;
internal readonly WButtonGroupPlacement _canonicalGroupPlacement;
internal readonly HashSet _allGroupPlacements;
internal GroupPlacementConflictInfo(
string groupName,
WButtonGroupPlacement canonicalGroupPlacement,
HashSet allGroupPlacements
)
{
_groupName = groupName;
_canonicalGroupPlacement = canonicalGroupPlacement;
_allGroupPlacements = allGroupPlacements;
}
}
///
/// Warnings about groups with conflicting draw orders. Populated during grouping.
///
private static readonly Dictionary<
string,
DrawOrderConflictInfo
> ConflictingDrawOrderWarnings = new();
///
/// Warnings about groups with conflicting group priorities. Populated during grouping.
///
private static readonly Dictionary<
string,
GroupPriorityConflictInfo
> ConflictingGroupPriorityWarnings = new();
///
/// Warnings about groups with conflicting group placements. Populated during grouping.
///
private static readonly Dictionary<
string,
GroupPlacementConflictInfo
> ConflictingGroupPlacementWarnings = new();
///
/// Gets the current conflicting draw order warnings. Used for testing and UI display.
///
internal static IReadOnlyDictionary<
string,
DrawOrderConflictInfo
> GetConflictingDrawOrderWarnings()
{
return ConflictingDrawOrderWarnings;
}
///
/// Gets the current conflicting group priority warnings. Used for testing and UI display.
///
internal static IReadOnlyDictionary<
string,
GroupPriorityConflictInfo
> GetConflictingGroupPriorityWarnings()
{
return ConflictingGroupPriorityWarnings;
}
///
/// Gets the current conflicting group placement warnings. Used for testing and UI display.
///
internal static IReadOnlyDictionary<
string,
GroupPlacementConflictInfo
> GetConflictingGroupPlacementWarnings()
{
return ConflictingGroupPlacementWarnings;
}
///
/// Clears conflicting draw order warnings. Used for testing.
///
internal static void ClearConflictingDrawOrderWarningsForTesting()
{
ConflictingDrawOrderWarnings.Clear();
}
///
/// Clears conflicting group priority warnings. Used for testing.
///
internal static void ClearConflictingGroupPriorityWarningsForTesting()
{
ConflictingGroupPriorityWarnings.Clear();
}
///
/// Clears conflicting group placement warnings. Used for testing.
///
internal static void ClearConflictingGroupPlacementWarningsForTesting()
{
ConflictingGroupPlacementWarnings.Clear();
}
private static void DrawGroup(
WButtonGroupKey groupKey,
List contexts,
IDictionary paginationStates,
IDictionary foldoutStates,
UnityHelpersSettings.WButtonFoldoutBehavior foldoutBehavior,
List triggeredContexts
)
{
if (contexts == null || contexts.Count == 0)
{
return;
}
// Guard against calling GUI methods outside of a valid GUI context (e.g., in tests)
if (Event.current == null)
{
return;
}
GUIContent header = BuildGroupHeader(groupKey);
bool alwaysOpen =
foldoutBehavior == UnityHelpersSettings.WButtonFoldoutBehavior.AlwaysOpen;
bool expanded = alwaysOpen || GetFoldoutState(foldoutStates, groupKey, foldoutBehavior);
bool effectiveExpanded = expanded;
bool tweenEnabled = UnityHelpersSettings.ShouldTweenWButtonFoldouts();
AnimBool foldoutAnim =
alwaysOpen || !tweenEnabled ? null : GetFoldoutAnim(groupKey, expanded);
if (!tweenEnabled)
{
if (FoldoutAnimations.TryGetValue(groupKey, out AnimBool cached) && cached != null)
{
cached.valueChanged.RemoveListener(RequestRepaint);
}
FoldoutAnimations.Remove(groupKey);
}
Color previousBackground = GUI.backgroundColor;
GUI.backgroundColor = WButtonStyles.GetFoldoutBackgroundColor(expanded || alwaysOpen);
GUILayout.BeginVertical(
alwaysOpen
? WButtonStyles.GroupStyle
: WButtonStyles.GetFoldoutContainerStyle(expanded)
);
GUI.backgroundColor = previousBackground;
if (alwaysOpen)
{
GUILayout.Label(header, WButtonStyles.HeaderStyle);
EditorGUILayout.Space(WButtonStyles.FoldoutContentSpacing);
}
else
{
Rect headerRect = GUILayoutUtility.GetRect(
header,
WButtonStyles.FoldoutHeaderStyle,
GUILayout.ExpandWidth(true)
);
headerRect.xMin += WButtonStyles.FoldoutIconOffset;
EditorGUI.indentLevel++;
bool newExpanded = EditorGUI.Foldout(
headerRect,
expanded,
header,
true,
WButtonStyles.FoldoutHeaderStyle
);
EditorGUI.indentLevel--;
if (foldoutStates != null)
{
foldoutStates[groupKey] = newExpanded;
}
if (foldoutAnim != null)
{
foldoutAnim.target = newExpanded;
}
effectiveExpanded = newExpanded;
EditorGUILayout.Space(WButtonStyles.FoldoutContentSpacing);
}
DrawConflictWarnings(groupKey);
if (alwaysOpen)
{
DrawGroupContent(groupKey, contexts, paginationStates, triggeredContexts);
}
else
{
float fade = foldoutAnim?.faded ?? (effectiveExpanded ? 1f : 0f);
if (foldoutAnim == null)
{
if (effectiveExpanded)
{
DrawGroupContent(groupKey, contexts, paginationStates, triggeredContexts);
}
}
else
{
bool visible = EditorGUILayout.BeginFadeGroup(fade);
if (visible)
{
DrawGroupContent(groupKey, contexts, paginationStates, triggeredContexts);
}
EditorGUILayout.EndFadeGroup();
}
}
GUILayout.EndVertical();
EditorGUILayout.Space();
}
private static void DrawGroupContent(
WButtonGroupKey groupKey,
List contexts,
IDictionary paginationStates,
List triggeredContexts
)
{
int pageSize = UnityHelpersSettings.GetWButtonPageSize();
WButtonPaginationState state = GetPaginationState(
paginationStates,
groupKey,
contexts.Count
);
DrawPaginationControls(state, contexts.Count, pageSize);
int startIndex = state._pageIndex * pageSize;
int endIndex = Mathf.Min(startIndex + pageSize, contexts.Count);
for (int index = startIndex; index < endIndex; index++)
{
WButtonMethodContext context = contexts[index];
DrawMethod(context, triggeredContexts);
if (index < endIndex - 1)
{
EditorGUILayout.Space(6f);
}
}
if (endIndex > startIndex)
{
EditorGUILayout.Space(4f);
}
}
private static void DrawPaginationControls(
WButtonPaginationState state,
int totalItems,
int pageSize
)
{
if (totalItems <= pageSize)
{
state._pageIndex = 0;
return;
}
int totalPages = Mathf.Max(1, Mathf.CeilToInt((float)totalItems / pageSize));
if (state._pageIndex >= totalPages)
{
state._pageIndex = totalPages - 1;
}
using (new EditorGUILayout.HorizontalScope())
{
GUILayout.FlexibleSpace();
EditorGUILayout.LabelField(
GetPaginationLabel(state._pageIndex + 1, totalPages),
GUILayout.Width(90f)
);
using (new EditorGUI.DisabledScope(state._pageIndex == 0))
{
if (GUILayout.Button("Prev", GUILayout.Width(50f)))
{
state._pageIndex--;
if (state._pageIndex < 0)
{
state._pageIndex = 0;
}
}
}
using (new EditorGUI.DisabledScope(state._pageIndex >= totalPages - 1))
{
if (GUILayout.Button("Next", GUILayout.Width(50f)))
{
state._pageIndex++;
if (state._pageIndex >= totalPages)
{
state._pageIndex = totalPages - 1;
}
}
}
}
EditorGUILayout.Space(4f);
}
private static readonly Dictionary ConflictWarningTextCache = new();
private static readonly Dictionary GroupPriorityWarningTextCache = new();
private static readonly Dictionary GroupPlacementWarningTextCache = new();
private static void DrawConflictWarnings(WButtonGroupKey groupKey)
{
DrawConflictingDrawOrderWarning(groupKey);
DrawConflictingGroupPriorityWarning(groupKey);
DrawConflictingGroupPlacementWarning(groupKey);
}
private static void DrawConflictingDrawOrderWarning(WButtonGroupKey groupKey)
{
string groupName = groupKey._groupName;
if (string.IsNullOrEmpty(groupName))
{
return;
}
if (
!ConflictingDrawOrderWarnings.TryGetValue(
groupName,
out DrawOrderConflictInfo conflict
)
)
{
return;
}
if (!ConflictWarningTextCache.TryGetValue(groupName, out string warningText))
{
List sortedOrders = new(conflict._allDrawOrders);
sortedOrders.Sort();
string ordersText = string.Join(", ", sortedOrders);
warningText =
$"Conflicting drawOrder values ({ordersText}) in group \"{groupName}\". Using {conflict._canonicalDrawOrder} from first declared button.";
ConflictWarningTextCache[groupName] = warningText;
}
EditorGUILayout.HelpBox(warningText, MessageType.Warning);
EditorGUILayout.Space(2f);
}
private static void DrawConflictingGroupPriorityWarning(WButtonGroupKey groupKey)
{
string groupName = groupKey._groupName;
if (string.IsNullOrEmpty(groupName))
{
return;
}
if (
!ConflictingGroupPriorityWarnings.TryGetValue(
groupName,
out GroupPriorityConflictInfo conflict
)
)
{
return;
}
string cacheKey = "priority_" + groupName;
if (!GroupPriorityWarningTextCache.TryGetValue(cacheKey, out string warningText))
{
List sortedPriorities = new(conflict._allGroupPriorities);
sortedPriorities.Sort();
List priorityStrings = new(sortedPriorities.Count);
foreach (int priority in sortedPriorities)
{
priorityStrings.Add(
priority == WButtonAttribute.NoGroupPriority
? "NoGroupPriority"
: priority.ToString()
);
}
string prioritiesText = string.Join(", ", priorityStrings);
string canonicalText =
conflict._canonicalGroupPriority == WButtonAttribute.NoGroupPriority
? "NoGroupPriority"
: conflict._canonicalGroupPriority.ToString();
warningText =
$"Conflicting groupPriority values ({prioritiesText}) in group \"{groupName}\". Using {canonicalText} from first declared button.";
GroupPriorityWarningTextCache[cacheKey] = warningText;
}
EditorGUILayout.HelpBox(warningText, MessageType.Warning);
EditorGUILayout.Space(2f);
}
private static void DrawConflictingGroupPlacementWarning(WButtonGroupKey groupKey)
{
string groupName = groupKey._groupName;
if (string.IsNullOrEmpty(groupName))
{
return;
}
if (
!ConflictingGroupPlacementWarnings.TryGetValue(
groupName,
out GroupPlacementConflictInfo conflict
)
)
{
return;
}
string cacheKey = "placement_" + groupName;
if (!GroupPlacementWarningTextCache.TryGetValue(cacheKey, out string warningText))
{
List sortedPlacements = new(conflict._allGroupPlacements);
sortedPlacements.Sort();
string placementsText = string.Join(", ", sortedPlacements);
warningText =
$"Conflicting groupPlacement values ({placementsText}) in group \"{groupName}\". Using {conflict._canonicalGroupPlacement} from first declared button.";
GroupPlacementWarningTextCache[cacheKey] = warningText;
}
EditorGUILayout.HelpBox(warningText, MessageType.Warning);
EditorGUILayout.Space(2f);
}
///
/// Clears the conflict warning content cache. Used for testing.
///
internal static void ClearConflictWarningContentCacheForTesting()
{
ConflictWarningTextCache.Clear();
GroupPriorityWarningTextCache.Clear();
GroupPlacementWarningTextCache.Clear();
}
///
/// Gets cached group placement warning text by group name. Used for testing.
///
internal static bool TryGetGroupPlacementWarningTextForTesting(
string groupName,
out string warningText
)
{
if (string.IsNullOrEmpty(groupName))
{
warningText = null;
return false;
}
return GroupPlacementWarningTextCache.TryGetValue(
"placement_" + groupName,
out warningText
);
}
///
/// Gets cached group priority warning text by group name. Used for testing.
///
internal static bool TryGetGroupPriorityWarningTextForTesting(
string groupName,
out string warningText
)
{
if (string.IsNullOrEmpty(groupName))
{
warningText = null;
return false;
}
return GroupPriorityWarningTextCache.TryGetValue(
"priority_" + groupName,
out warningText
);
}
///
/// Gets cached draw order warning text by group name. Used for testing.
///
internal static bool TryGetDrawOrderWarningTextForTesting(
string groupName,
out string warningText
)
{
if (string.IsNullOrEmpty(groupName))
{
warningText = null;
return false;
}
return ConflictWarningTextCache.TryGetValue(groupName, out warningText);
}
private static bool GetFoldoutState(
IDictionary foldoutStates,
WButtonGroupKey groupKey,
UnityHelpersSettings.WButtonFoldoutBehavior behavior
)
{
bool defaultExpanded =
behavior != UnityHelpersSettings.WButtonFoldoutBehavior.StartCollapsed;
if (foldoutStates == null)
{
return defaultExpanded;
}
if (foldoutStates.TryGetValue(groupKey, out bool current))
{
return current;
}
foldoutStates[groupKey] = defaultExpanded;
return defaultExpanded;
}
private static AnimBool GetFoldoutAnim(WButtonGroupKey groupKey, bool expanded)
{
float speed = UnityHelpersSettings.GetWButtonFoldoutSpeed();
if (!FoldoutAnimations.TryGetValue(groupKey, out AnimBool anim) || anim == null)
{
anim = new AnimBool(expanded) { speed = speed };
anim.valueChanged.AddListener(RequestRepaint);
FoldoutAnimations[groupKey] = anim;
}
anim.speed = speed;
anim.target = expanded;
return anim;
}
private static void RequestRepaint()
{
InternalEditorUtility.RepaintAllViews();
}
internal static GUIContent BuildGroupHeader(WButtonGroupKey groupKey)
{
WButtonGroupPlacement groupPlacement = groupKey._groupPlacement;
// Use placement to determine label style
GUIContent baseLabel =
groupPlacement == WButtonGroupPlacement.Bottom
? WButtonStyles.BottomGroupLabel
: WButtonStyles.TopGroupLabel;
if (
GroupNames.TryGetValue(groupKey, out string customName)
&& !string.IsNullOrWhiteSpace(customName)
)
{
if (!GroupHeaderCache.TryGetValue(groupKey, out GUIContent cached))
{
cached = new GUIContent(customName, baseLabel.tooltip);
GroupHeaderCache[groupKey] = cached;
}
else if (!string.Equals(cached.text, customName, StringComparison.Ordinal))
{
cached.text = customName;
cached.tooltip = baseLabel.tooltip;
}
return cached;
}
if (GroupCounts.Count <= 1)
{
return baseLabel;
}
if (!GroupCounts.TryGetValue(groupKey, out int count) || count <= 0)
{
return baseLabel;
}
int drawOrder = groupKey._drawOrder;
(string, int) textCacheKey = (baseLabel.text, drawOrder);
if (!GroupHeaderTextCache.TryGetValue(textCacheKey, out string textWithOrder))
{
textWithOrder = baseLabel.text + " (" + GetCachedIntString(drawOrder) + ")";
GroupHeaderTextCache[textCacheKey] = textWithOrder;
}
if (!GroupHeaderCache.TryGetValue(groupKey, out GUIContent cachedWithOrder))
{
cachedWithOrder = new GUIContent(textWithOrder, baseLabel.tooltip);
GroupHeaderCache[groupKey] = cachedWithOrder;
}
else if (!string.Equals(cachedWithOrder.text, textWithOrder, StringComparison.Ordinal))
{
cachedWithOrder.text = textWithOrder;
cachedWithOrder.tooltip = baseLabel.tooltip;
}
return cachedWithOrder;
}
///
/// Legacy overload for testing compatibility.
///
internal static GUIContent BuildGroupHeader(int drawOrder)
{
WButtonGroupKey key = new(
WButtonAttribute.NoGroupPriority,
drawOrder,
null,
0,
WButtonGroupPlacement.UseGlobalSetting
);
return BuildGroupHeader(key);
}
private static string ResolveGroupName(List contexts)
{
if (contexts == null)
{
return null;
}
for (int index = 0; index < contexts.Count; index++)
{
WButtonMethodContext context = contexts[index];
string groupName = context?.Metadata?.GroupName;
if (!string.IsNullOrWhiteSpace(groupName))
{
return groupName;
}
}
return null;
}
private static void DrawMethod(
WButtonMethodContext context,
List triggeredContexts
)
{
WButtonMethodMetadata metadata = context.Metadata;
GUILayout.BeginVertical(EditorStyles.helpBox);
WButtonMethodState[] states = context.States;
if (states.Length > 0 && states[0].Parameters.Length > 0)
{
EditorGUI.indentLevel++;
WButtonParameterDrawer.DrawParameters(states);
EditorGUI.indentLevel--;
EditorGUILayout.Space(3f);
}
GetInvocationStatus(states, out int runningCount, out bool cancellable);
bool isRunning = runningCount > 0;
if (isRunning)
{
DrawRunningStatus(context, runningCount, cancellable);
}
DrawHistory(states[0]);
UnityHelpersSettings.WButtonPaletteEntry palette =
UnityHelpersSettings.ResolveWButtonPalette(metadata.ColorKey);
GUIStyle buttonStyle = WButtonStyles.GetColoredButtonStyle(
palette.ButtonColor,
palette.TextColor
);
if (
!ButtonDisplayNameCache.TryGetValue(
metadata.DisplayName,
out GUIContent buttonContent
)
)
{
buttonContent = new GUIContent(metadata.DisplayName);
ButtonDisplayNameCache[metadata.DisplayName] = buttonContent;
}
Rect buttonRect = GUILayoutUtility.GetRect(
buttonContent,
buttonStyle,
GUILayout.Height(WButtonStyles.ButtonHeight),
GUILayout.ExpandWidth(true)
);
using (new EditorGUI.DisabledScope(isRunning))
{
if (GUI.Button(buttonRect, metadata.DisplayName, buttonStyle))
{
context.MarkTriggered();
if (triggeredContexts != null)
{
triggeredContexts.Add(context);
}
}
}
GUILayout.Space(2f);
GUILayout.EndVertical();
}
internal static void GetInvocationStatus(
WButtonMethodState[] states,
out int runningCount,
out bool cancellable
)
{
runningCount = 0;
cancellable = false;
if (states == null || states.Length == 0)
{
return;
}
foreach (WButtonMethodState state in states)
{
WButtonInvocationHandle handle = state.ActiveInvocation;
if (handle == null)
{
continue;
}
if (
handle.Status == WButtonInvocationStatus.Running
|| handle.Status == WButtonInvocationStatus.CancelRequested
)
{
runningCount++;
cancellable |= handle.SupportsCancellation;
}
}
}
private static void DrawRunningStatus(
WButtonMethodContext context,
int runningCount,
bool cancellable
)
{
using (new EditorGUILayout.HorizontalScope())
{
EditorGUILayout.LabelField(GetRunningLabel(runningCount), EditorStyles.miniLabel);
GUILayout.FlexibleSpace();
if (cancellable)
{
UnityHelpersSettings.WButtonPaletteEntry cancelColors =
UnityHelpersSettings.GetWButtonCancelButtonColors();
GUIStyle cancelStyle = WButtonStyles.GetColoredMiniButtonStyle(
cancelColors.ButtonColor,
cancelColors.TextColor
);
if (GUILayout.Button("Cancel", cancelStyle, GUILayout.Width(70f)))
{
WButtonInvocationController.CancelActiveInvocations(context);
}
}
}
EditorGUILayout.Space(2f);
}
private static void DrawHistory(WButtonMethodState state)
{
if (state is not { HasHistory: true })
{
return;
}
GUILayout.BeginVertical(EditorStyles.helpBox);
Rect headerRect = EditorGUILayout.GetControlRect(
false,
EditorGUIUtility.singleLineHeight,
GUILayout.ExpandWidth(true)
);
Vector2 labelSize = EditorStyles.miniBoldLabel.CalcSize(RecentResultsHeaderContent);
float buttonWidth = Mathf.Max(
ClearHistoryMinWidth,
EditorStyles.miniButton.CalcSize(ClearHistoryContent).x + ClearHistoryButtonPadding
);
float availableWidth = headerRect.width;
bool canShowButton =
availableWidth >= (labelSize.x + ClearHistorySpacing + buttonWidth);
float labelWidth = canShowButton
? Mathf.Min(labelSize.x, availableWidth - (buttonWidth + ClearHistorySpacing))
: availableWidth;
Rect labelRect = new(headerRect.x, headerRect.y, labelWidth, headerRect.height);
GUI.Label(labelRect, RecentResultsHeaderContent, EditorStyles.miniBoldLabel);
if (canShowButton)
{
Rect buttonRect = new(
headerRect.xMax - buttonWidth,
headerRect.y,
buttonWidth,
headerRect.height
);
UnityHelpersSettings.WButtonPaletteEntry clearHistoryColors =
UnityHelpersSettings.GetWButtonClearHistoryButtonColors();
GUIStyle clearHistoryStyle = WButtonStyles.GetColoredMiniButtonStyle(
clearHistoryColors.ButtonColor,
clearHistoryColors.TextColor
);
if (GUI.Button(buttonRect, ClearHistoryContent, clearHistoryStyle))
{
state.ClearHistory();
GUI.FocusControl(null);
}
}
if (!state.HasHistory)
{
GUILayout.EndVertical();
EditorGUILayout.Space(3f);
return;
}
for (int index = state.History.Count - 1; index >= 0; index--)
{
WButtonResultEntry entry = state.History[index];
DrawHistoryEntry(entry);
}
GUILayout.EndVertical();
EditorGUILayout.Space(3f);
}
private static void DrawHistoryEntry(WButtonResultEntry entry)
{
string summary = entry.GetDisplayString();
GUIStyle labelStyle =
entry.Kind == WButtonResultKind.Error
? EditorStyles.miniBoldLabel
: EditorStyles.miniLabel;
EditorGUILayout.LabelField(summary, labelStyle);
if (entry.ObjectReference != null)
{
using (new EditorGUI.DisabledScope(true))
{
EditorGUILayout.ObjectField(
"Result",
entry.ObjectReference,
entry.ObjectReference.GetType(),
true
);
}
}
if (entry.Kind == WButtonResultKind.Error && entry.Exception != null)
{
EditorGUILayout.LabelField(
entry.Exception.GetType().Name,
EditorStyles.wordWrappedMiniLabel
);
}
}
private static WButtonPaginationState GetPaginationState(
IDictionary paginationStates,
WButtonGroupKey groupKey,
int itemCount
)
{
if (paginationStates == null)
{
return WButtonPaginationState.Fallback;
}
WButtonPaginationState state = paginationStates.GetOrAdd(groupKey);
int pageSize = UnityHelpersSettings.GetWButtonPageSize();
int pageCount = Mathf.Max(1, Mathf.CeilToInt((float)itemCount / pageSize));
if (state._pageIndex >= pageCount)
{
state._pageIndex = pageCount - 1;
}
if (state._pageIndex < 0)
{
state._pageIndex = 0;
}
return state;
}
}
internal sealed class WButtonPaginationState
{
internal static readonly WButtonPaginationState Fallback = new();
internal int _pageIndex;
}
internal sealed class WButtonMethodContext
{
internal WButtonMethodContext(
WButtonMethodMetadata metadata,
WButtonMethodState[] states,
UnityEngine.Object[] targets
)
{
Metadata = metadata;
States = states;
Targets = targets;
}
internal WButtonMethodMetadata Metadata { get; }
internal WButtonMethodState[] States { get; }
internal UnityEngine.Object[] Targets { get; }
internal bool InvocationRequested { get; private set; }
internal void MarkTriggered()
{
InvocationRequested = true;
}
internal void ResetTrigger()
{
InvocationRequested = false;
}
}
internal readonly struct ContextCacheKey : IEquatable
{
private readonly WButtonMethodMetadata _metadata;
private readonly int _targetHash;
internal ContextCacheKey(WButtonMethodMetadata metadata, UnityEngine.Object[] targets)
{
_metadata = metadata;
_targetHash = ComputeTargetHash(targets);
}
private static int ComputeTargetHash(UnityEngine.Object[] targets)
{
if (targets == null || targets.Length == 0)
{
return 0;
}
Span instanceIds = stackalloc int[targets.Length];
for (int i = 0; i < targets.Length; i++)
{
UnityEngine.Object target = targets[i];
instanceIds[i] = target != null ? target.GetInstanceID() : 0;
}
return Objects.SpanHashCode(instanceIds);
}
public bool Equals(ContextCacheKey other)
{
return ReferenceEquals(_metadata, other._metadata) && _targetHash == other._targetHash;
}
public override bool Equals(object obj)
{
return obj is ContextCacheKey other && Equals(other);
}
public override int GetHashCode()
{
return Objects.HashCode(_metadata, _targetHash);
}
}
#endif
}