// MIT License - Copyright (c) 2025 wallstop
// Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE
namespace WallstopStudios.UnityHelpers.Core.Attributes
{
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using WallstopStudios.UnityHelpers.Core.Helper;
///
/// Draws a serialized property as a dropdown populated from fixed values, a static method provider, or an instance method provider.
/// Supports inline lists, strongly typed primitive overloads, and late-bound providers that return custom structs or reference types.
///
///
///
/// Use this attribute when a field should only be assigned values from a curated set (difficulty levels, asset references, data-driven enums, etc.).
/// Inline lists are ideal for short constants, while provider overloads let you mirror external collections without duplicating state.
///
///
///
/// Inline values:
///
/// [WValueDropDown(0, 25, 50, 100)]
/// public int staminaThreshold;
///
/// Typed inline overload:
///
/// [WValueDropDown(true, false)]
/// public bool isEnabled;
///
/// Static provider-based values:
///
/// [WValueDropDown(typeof(PowerUpCatalogue), nameof(PowerUpCatalogue.GetAvailablePowerUps))]
/// public PowerUpDefinition selectedPowerUp;
///
/// private static class PowerUpCatalogue
/// {
/// internal static IEnumerable<PowerUpDefinition> GetAvailablePowerUps()
/// {
/// return Resources.LoadAll<PowerUpDefinition>(\"PowerUps\");
/// }
/// }
///
/// Instance provider:
///
/// [WValueDropDown(nameof(GetAvailableOptions), typeof(int))]
/// public int selectedOption;
///
/// private IEnumerable<int> GetAvailableOptions()
/// {
/// return new[] { 1, 2, 3, 4, 5 };
/// }
///
///
public sealed class WValueDropDownAttribute : PropertyAttribute
{
private const string AttributeName = "WValueDropDownAttribute";
private static readonly object[] Empty = Array.Empty();
private static readonly Func EmptyFactory = _ => Empty;
private sealed class InstanceProviderEntry
{
internal readonly MethodInfo Method;
internal readonly bool IsStatic;
internal readonly Func InstanceInvoker;
internal readonly Func StaticInvoker;
internal InstanceProviderEntry(
MethodInfo method,
Func instanceInvoker,
Func staticInvoker
)
{
Method = method;
IsStatic = method.IsStatic;
InstanceInvoker = instanceInvoker;
StaticInvoker = staticInvoker;
}
internal object Invoke(object context)
{
return IsStatic
? StaticInvoker?.Invoke(Array.Empty())
: InstanceInvoker?.Invoke(context, Array.Empty());
}
}
private readonly struct MethodValidationResult
{
internal readonly bool MethodFound;
internal readonly bool HasValidReturnType;
internal readonly Type ElementType;
internal MethodValidationResult(
bool methodFound,
bool hasValidReturnType,
Type elementType
)
{
MethodFound = methodFound;
HasValidReturnType = hasValidReturnType;
ElementType = elementType;
}
}
private readonly Func _getOptions;
private readonly bool _requiresInstanceContext;
private readonly string _instanceMethodName;
private readonly Dictionary _instanceMethodCache;
private readonly Type _explicitProviderType;
internal Type ProviderType { get; }
internal string ProviderMethodName { get; }
///
public WValueDropDownAttribute(params bool[] options)
: this(typeof(bool), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params char[] options)
: this(typeof(char), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params string[] options)
: this(typeof(string), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params sbyte[] options)
: this(typeof(sbyte), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params byte[] options)
: this(typeof(byte), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params short[] options)
: this(typeof(short), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params ushort[] options)
: this(typeof(ushort), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params int[] options)
: this(typeof(int), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params uint[] options)
: this(typeof(uint), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params long[] options)
: this(typeof(long), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params ulong[] options)
: this(typeof(ulong), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params float[] options)
: this(typeof(float), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
public WValueDropDownAttribute(params double[] options)
: this(typeof(double), WrapStatic(DropDownValueProvider.FromList(options))) { }
///
/// Initializes the attribute using a provider method and infers the option type from its return value.
///
///
/// The provider must be parameterless and return an array or .
/// Static methods are preferred; if no static method is found, the system falls back to instance method resolution.
/// The inspector queries the provider each time it renders the field, keeping the dropdown synchronised with external data.
///
/// Type that defines the provider method (static or instance).
/// Name of the parameterless method that supplies the dropdown values.
public WValueDropDownAttribute(Type providerType, string methodName)
{
ProviderType = providerType;
ProviderMethodName = methodName;
if (providerType == null)
{
Debug.LogError($"{AttributeName}: Provider type cannot be null.");
ValueType = typeof(object);
_getOptions = EmptyFactory;
return;
}
if (string.IsNullOrEmpty(methodName))
{
Debug.LogError($"{AttributeName}: Method name cannot be null or empty.");
ValueType = typeof(object);
_getOptions = EmptyFactory;
return;
}
// First, try to find a static method
Func staticFactory = DropDownValueProvider.FromMethod(
providerType,
methodName,
AttributeName,
out Type resolvedValueType,
logErrorIfNotFound: false
);
if (staticFactory != null)
{
ValueType = resolvedValueType ?? typeof(object);
_getOptions = WrapStaticFactory(staticFactory);
return;
}
// No static method found - set up for instance method resolution
// Try to infer the value type from the instance method and validate it exists
MethodValidationResult validation = ValidateInstanceMethod(providerType, methodName);
if (!validation.MethodFound)
{
Debug.LogError(
$"{AttributeName}: Could not locate a parameterless method named '{methodName}' on {providerType.FullName} that returns enumerable values."
);
ValueType = typeof(object);
_getOptions = EmptyFactory;
return;
}
if (!validation.HasValidReturnType)
{
Debug.LogError(
$"{AttributeName}: Method '{providerType.FullName}.{methodName}' must return an array or IEnumerable."
);
ValueType = typeof(object);
_getOptions = EmptyFactory;
return;
}
ValueType = validation.ElementType ?? typeof(object);
_requiresInstanceContext = true;
_instanceMethodName = methodName;
_instanceMethodCache = new Dictionary();
_explicitProviderType = providerType;
_getOptions = ResolveInstanceMethodValues;
}
///
/// Initializes the attribute with an inline list of values.
///
///
/// Use this overload for custom types or when you already have an object array. Values are coerced to .
///
/// Target value type for the decorated property.
/// One or more selectable values compatible with .
public WValueDropDownAttribute(Type valueType, params object[] options)
: this(
valueType ?? typeof(object),
WrapStaticFactory(DropDownValueProvider.FromList(valueType, options, AttributeName))
) { }
///
/// Initializes the attribute using a method provider with explicit output type conversion.
///
///
/// This overload is useful when the provider returns a type that needs to be converted before appearing in the dropdown (for example, numeric IDs mapped to enums).
/// The method can be either static (resolved at attribute construction) or an instance method on the provider type (resolved at runtime when the context object is available).
/// Static methods are preferred; if no static method is found, the system falls back to instance method resolution.
///
/// Type containing the provider method (static or instance).
/// Parameterless method returning an array or enumerable of values.
/// Target value type for the decorated property.
public WValueDropDownAttribute(Type providerType, string methodName, Type valueType)
{
ValueType = valueType ?? typeof(object);
ProviderType = providerType;
ProviderMethodName = methodName;
if (providerType == null)
{
Debug.LogError($"{AttributeName}: Provider type cannot be null.");
_getOptions = EmptyFactory;
return;
}
if (string.IsNullOrEmpty(methodName))
{
Debug.LogError($"{AttributeName}: Method name cannot be null or empty.");
_getOptions = EmptyFactory;
return;
}
// First, try to find a static method
Func staticFactory = DropDownValueProvider.FromMethod(
providerType,
methodName,
valueType,
AttributeName,
logErrorIfNotFound: false
);
if (staticFactory != null)
{
_getOptions = WrapStaticFactory(staticFactory);
return;
}
// No static method found - set up for instance method resolution and validate it exists
MethodValidationResult validation = ValidateInstanceMethod(providerType, methodName);
if (!validation.MethodFound)
{
Debug.LogError(
$"{AttributeName}: Could not locate a parameterless method named '{methodName}' on {providerType.FullName} that returns enumerable values."
);
_getOptions = EmptyFactory;
return;
}
if (!validation.HasValidReturnType)
{
Debug.LogError(
$"{AttributeName}: Method '{providerType.FullName}.{methodName}' must return an array or IEnumerable."
);
_getOptions = EmptyFactory;
return;
}
_requiresInstanceContext = true;
_instanceMethodName = methodName;
_instanceMethodCache = new Dictionary();
_explicitProviderType = providerType;
_getOptions = ResolveInstanceMethodValues;
}
///
/// Uses a method on the decorated object's type to obtain the allowed values.
/// The method can be instance or static, must be parameterless, and return an array or IEnumerable.
///
/// Method name declared on the target object's type.
/// Target value type for the decorated property.
public WValueDropDownAttribute(string methodName, Type valueType)
{
if (string.IsNullOrWhiteSpace(methodName))
{
Debug.LogError($"{AttributeName}: Method name cannot be null or empty.");
ValueType = valueType ?? typeof(object);
_getOptions = EmptyFactory;
return;
}
_requiresInstanceContext = true;
_instanceMethodName = methodName;
_instanceMethodCache = new Dictionary();
_getOptions = ResolveInstanceMethodValues;
ValueType = valueType ?? typeof(object);
ProviderType = null;
ProviderMethodName = methodName;
}
private WValueDropDownAttribute(Type valueType, Func optionFactory)
{
ValueType = valueType ?? typeof(object);
_getOptions = optionFactory ?? EmptyFactory;
}
///
/// Gets the effective type for the dropdown values.
/// When constructors infer provider output, this is the element type returned by the provider.
///
public Type ValueType { get; }
///
/// Retrieves the dropdown entries as boxed objects without any context.
/// Note: when the attribute targets an instance method, this returns an empty array.
/// The returned array should be treated as read-only.
///
public object[] Options => _getOptions?.Invoke(null) ?? Empty;
///
/// Retrieves the dropdown entries for the supplied context object.
///
/// The object declaring the field/property. Required for instance providers.
/// Resolved option list (never null).
public object[] GetOptions(object context)
{
if (_requiresInstanceContext && context == null)
{
return Empty;
}
return _getOptions?.Invoke(context) ?? Empty;
}
///
/// Indicates whether this attribute uses an instance method provider.
///
internal bool RequiresInstanceContext => _requiresInstanceContext;
private object[] ResolveInstanceMethodValues(object context)
{
if (context == null)
{
return Empty;
}
Type contextType = context.GetType();
// When an explicit provider type is set, use it for method resolution.
// The context must be an instance of the provider type (or derived) for instance methods.
Type lookupType = _explicitProviderType ?? contextType;
// Verify context is compatible with the explicit provider type for instance methods
if (
_explicitProviderType != null
&& !_explicitProviderType.IsAssignableFrom(contextType)
)
{
Debug.LogError(
$"{AttributeName}: Context object of type '{contextType.FullName}' is not assignable to explicit provider type '{_explicitProviderType.FullName}'."
);
return Empty;
}
InstanceProviderEntry provider = GetOrResolveInstanceProvider(lookupType);
if (provider == null)
{
return Empty;
}
object result;
try
{
result = provider.Invoke(context);
}
catch (Exception e)
{
Debug.LogError(
$"{AttributeName}: Invocation of '{lookupType.FullName}.{provider.Method.Name}' threw {e.GetType().Name}."
);
return Empty;
}
return ConvertResult(
result,
provider.Method.DeclaringType ?? lookupType,
provider.Method.Name
);
}
private InstanceProviderEntry GetOrResolveInstanceProvider(Type providerType)
{
if (
_instanceMethodCache != null
&& _instanceMethodCache.TryGetValue(providerType, out InstanceProviderEntry cached)
)
{
return cached;
}
BindingFlags flags =
BindingFlags.Instance
| BindingFlags.Static
| BindingFlags.Public
| BindingFlags.NonPublic;
MethodInfo method = providerType.GetMethod(
_instanceMethodName,
flags,
null,
Type.EmptyTypes,
null
);
if (method == null)
{
Debug.LogError(
$"{AttributeName}: Could not locate '{_instanceMethodName}' on {providerType.FullName}."
);
CacheProvider(providerType, null);
return null;
}
Type returnType = method.ReturnType;
if (returnType == typeof(void))
{
Debug.LogError(
$"{AttributeName}: Method '{providerType.FullName}.{method.Name}' must return an array or IEnumerable."
);
CacheProvider(providerType, null);
return null;
}
bool isEnumerable =
returnType.IsArray
|| (
returnType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(returnType)
);
if (!isEnumerable)
{
Debug.LogError(
$"{AttributeName}: Method '{providerType.FullName}.{method.Name}' must return an array or IEnumerable."
);
CacheProvider(providerType, null);
return null;
}
InstanceProviderEntry entry = method.IsStatic
? new InstanceProviderEntry(
method,
instanceInvoker: null,
staticInvoker: ReflectionHelpers.GetStaticMethodInvoker(method)
)
: new InstanceProviderEntry(
method,
ReflectionHelpers.GetMethodInvoker(method),
staticInvoker: null
);
CacheProvider(providerType, entry);
return entry;
}
private void CacheProvider(Type providerType, InstanceProviderEntry entry)
{
if (_instanceMethodCache == null)
{
return;
}
_instanceMethodCache[providerType] = entry;
}
private static Type InferInstanceMethodValueType(Type providerType, string methodName)
{
if (providerType == null || string.IsNullOrEmpty(methodName))
{
return null;
}
BindingFlags flags =
BindingFlags.Instance
| BindingFlags.Static
| BindingFlags.Public
| BindingFlags.NonPublic;
MethodInfo method = providerType.GetMethod(
methodName,
flags,
null,
Type.EmptyTypes,
null
);
if (method == null)
{
return null;
}
Type returnType = method.ReturnType;
if (returnType == null || returnType == typeof(void))
{
return null;
}
// Array type
if (returnType.IsArray)
{
return returnType.GetElementType();
}
// Generic IEnumerable
if (returnType.IsGenericType)
{
Type[] genericArgs = returnType.GetGenericArguments();
if (genericArgs.Length == 1)
{
return genericArgs[0];
}
}
// Check for IEnumerable interface
Type[] interfaces = returnType.GetInterfaces();
for (int i = 0; i < interfaces.Length; i++)
{
Type iface = interfaces[i];
if (
iface.IsGenericType
&& iface.GetGenericTypeDefinition() == typeof(IEnumerable<>)
)
{
return iface.GetGenericArguments()[0];
}
}
return typeof(object);
}
private static MethodValidationResult ValidateInstanceMethod(
Type providerType,
string methodName
)
{
if (providerType == null || string.IsNullOrEmpty(methodName))
{
return new MethodValidationResult(false, false, null);
}
BindingFlags flags =
BindingFlags.Instance
| BindingFlags.Static
| BindingFlags.Public
| BindingFlags.NonPublic;
MethodInfo method = providerType.GetMethod(
methodName,
flags,
null,
Type.EmptyTypes,
null
);
if (method == null)
{
return new MethodValidationResult(false, false, null);
}
Type returnType = method.ReturnType;
if (returnType == null || returnType == typeof(void))
{
return new MethodValidationResult(true, false, null);
}
// Check if return type is valid (array or IEnumerable)
bool isEnumerable =
returnType.IsArray
|| (
returnType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(returnType)
);
if (!isEnumerable)
{
return new MethodValidationResult(true, false, null);
}
// Infer element type
Type elementType = null;
if (returnType.IsArray)
{
elementType = returnType.GetElementType();
}
else if (returnType.IsGenericType)
{
Type[] genericArgs = returnType.GetGenericArguments();
if (genericArgs.Length == 1)
{
elementType = genericArgs[0];
}
}
if (elementType == null)
{
Type[] interfaces = returnType.GetInterfaces();
for (int i = 0; i < interfaces.Length; i++)
{
Type iface = interfaces[i];
if (
iface.IsGenericType
&& iface.GetGenericTypeDefinition() == typeof(IEnumerable<>)
)
{
elementType = iface.GetGenericArguments()[0];
break;
}
}
}
return new MethodValidationResult(true, true, elementType ?? typeof(object));
}
private object[] ConvertResult(object result, Type providerType, string methodName)
{
if (result == null)
{
return Empty;
}
if (result is object[] objectArray)
{
return objectArray;
}
if (result is Array array)
{
object[] boxed = new object[array.Length];
for (int i = 0; i < array.Length; i++)
{
boxed[i] = array.GetValue(i);
}
return boxed;
}
if (result is IEnumerable enumerable)
{
List values = new();
foreach (object entry in enumerable)
{
values.Add(entry);
}
if (values.Count == 0)
{
return Empty;
}
return values.ToArray();
}
Debug.LogError(
$"{AttributeName}: Method '{providerType.FullName}.{methodName}' returned incompatible type '{result.GetType().FullName}'. Expected an array or IEnumerable."
);
return Empty;
}
private static Func WrapStatic(Func provider)
{
if (provider == null)
{
return EmptyFactory;
}
T[] cachedTypedValues = null;
object[] cachedBoxedValues = null;
return _ =>
{
T[] typedValues = provider();
if (typedValues == null || typedValues.Length == 0)
{
return Empty;
}
if (ReferenceEquals(typedValues, cachedTypedValues) && cachedBoxedValues != null)
{
return cachedBoxedValues;
}
object[] boxedValues = new object[typedValues.Length];
for (int index = 0; index < typedValues.Length; index += 1)
{
boxedValues[index] = typedValues[index];
}
cachedTypedValues = typedValues;
cachedBoxedValues = boxedValues;
return boxedValues;
};
}
private static Func WrapStaticFactory(Func provider)
{
if (provider == null)
{
return EmptyFactory;
}
return _ => provider();
}
}
}