CultLib/src/GameCult.Unity/Assets/UI/UI.txt
2026-04-18 06:04:39 +01:00

2917 lines
105 KiB
Text

// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Attributes.cs
using System;
namespace GameCult.Unity.UI
{
[AttributeUsage(AttributeTargets.All, Inherited = false)]
public class InspectableAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
public abstract class PreferredInspectorAttribute : InspectableAttribute { }
public class InspectableReadOnlyLabelAttribute : PreferredInspectorAttribute { }
public class InspectableRangedFloatAttribute : PreferredInspectorAttribute
{
public readonly float Min, Max;
public InspectableRangedFloatAttribute(float min, float max)
{
Min = min;
Max = max;
}
}
public class InspectableRangedIntAttribute : PreferredInspectorAttribute
{
public readonly int Min, Max;
public InspectableRangedIntAttribute(int min, int max)
{
Min = min;
Max = max;
}
}
public class InspectableIncrementIntAttribute : PreferredInspectorAttribute
{
public readonly int Min, Max;
public InspectableIncrementIntAttribute(int min, int max)
{
Min = min;
Max = max;
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\ColorConversion.cs
using Unity.Mathematics;
using UnityEngine;
using static Unity.Mathematics.math;
namespace GameCult.Unity.UI
{
// Color conversion functions from https://www.chilliant.com/rgb2hsv.html
public static class ColorConversion
{
private static float Epsilon = 1e-10f;
public static float3 HUEtoRGB(in float h)
{
float R = abs(h * 6 - 3) - 1;
float G = 2 - abs(h * 6 - 2);
float B = 2 - abs(h * 6 - 4);
return saturate(float3(R,G,B));
}
public static float3 RGBtoHCV(in float3 rgb)
{
// Based on work by Sam Hocevar and Emil Persson
float4 P = (rgb.y < rgb.z) ? float4(rgb.zy, -1.0f, 2.0f/3.0f) : float4(rgb.yz, 0.0f, -1.0f/3.0f);
float4 Q = (rgb.x < P.x) ? float4(P.xyw, rgb.x) : float4(rgb.x, P.yzx);
float C = Q.x - min(Q.w, Q.y);
float H = abs((Q.w - Q.y) / (6 * C + Epsilon) + Q.z);
return float3(H, C, Q.x);
}
public static float3 HSVtoRGB(in float3 hsv)
{
float3 rgb = HUEtoRGB(hsv.x);
return ((rgb - 1) * hsv.y + 1) * hsv.z;
}
public static float3 HSLtoRGB(in float3 hsl)
{
float3 RGB = HUEtoRGB(hsl.x);
float C = (1 - abs(2 * hsl.z - 1)) * hsl.y;
return (RGB - 0.5f) * C + hsl.z;
}
// The weights of RGB contributions to luminance.
// Should sum to unity.
private static float3 HCYwts = float3(0.299f, 0.587f, 0.114f);
public static float3 HCYtoRGB(float3 hcy)
{
float3 RGB = HUEtoRGB(hcy.x);
float Z = dot(RGB, HCYwts);
if (hcy.z < Z)
{
hcy.y *= hcy.z / Z;
}
else if (Z < 1)
{
hcy.y *= (1 - hcy.z) / (1 - Z);
}
return (RGB - Z) * hcy.y + hcy.z;
}
public static float3 RGBtoHSV(in float3 rgb)
{
float3 HCV = RGBtoHCV(rgb);
float S = HCV.y / (HCV.z + Epsilon);
return float3(HCV.x, S, HCV.z);
}
public static float3 RGBtoHSL(in float3 rgb)
{
float3 HCV = RGBtoHCV(rgb);
float L = HCV.z - HCV.y * 0.5f;
float S = HCV.y / (1 - abs(L * 2 - 1) + Epsilon);
return float3(HCV.x, S, L);
}
public static float3 RGBtoHCY(in float3 rgb)
{
// Corrected by David Schaeffer
float3 HCV = RGBtoHCV(rgb);
float Y = dot(rgb, HCYwts);
float Z = dot(HUEtoRGB(HCV.x), HCYwts);
if (Y < Z)
{
HCV.y *= Z / (Epsilon + Y);
}
else
{
HCV.y *= (1 - Z) / (Epsilon + 1 - Y);
}
return float3(HCV.x, HCV.y, Y);
}
public static Color ToColor(this float3 color) => new Color(color.x, color.y, color.z);
public static float3 ToFloat3(this Color color) => new float3(color.r, color.g, color.b);
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\DisplayOptions.cs
using TMPro;
namespace GameCult.Unity.UI
{
public class DisplayOptions
{
public TextAlignmentOptions? TextAlignment { get; }
public VerticalAlignmentOptions? VerticalAlignment { get; }
public float? LayoutPriority { get; }
public float? LayoutHeight { get; }
public float? TextSize { get; }
public bool Interactable { get; }
public bool PlaceInContext { get; }
public DisplayOptions(bool placeInContext = true,
bool interactable = true,
float? layoutHeight = null,
float? textSize = null,
float? layoutPriority = null,
VerticalAlignmentOptions? verticalAlignment = null,
TextAlignmentOptions? textAlignment = null)
{
PlaceInContext = placeInContext;
LayoutHeight = layoutHeight;
Interactable = interactable;
TextSize = textSize;
LayoutPriority = layoutPriority;
VerticalAlignment = verticalAlignment;
TextAlignment = textAlignment;
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Generator.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using GameCult.Unity.UI.Components;
using UnityEngine;
using ZLinq;
namespace GameCult.Unity.UI
{
public class Generator : ResolverComponent, IUIContext
{
[SerializeField] private float inspectorLabelPriority = 3;
[SerializeField] private float inspectorFieldPriority = 2;
[SerializeField] private ReflectiveResolver? resolver;
[SerializeField] private RectTransform? contentRoot;
protected event Action<GameObject>? OnPropertyAdded;
protected event Action? RefreshPropertyValues;
protected List<GameObject> Elements { get; } = new();
public IUIResolver Resolver => resolver ?? throw new NullReferenceException("Generator doesn't have an assigned resolver.");
public Action? Refresh
{
get => RefreshPropertyValues;
set => RefreshPropertyValues = value;
}
public RectTransform ContentRoot => contentRoot ?? (RectTransform)transform;
public Canvas Canvas => GetComponentInParent<Canvas>();
public RectTransform CanvasRoot => (RectTransform)Canvas.transform;
public void Inspect(object obj, bool inspectableOnly = false, bool recursive = true)
{
var members = obj.GetType().GetMembers(BindingFlags.Public | BindingFlags.Instance);
foreach (var member in members)
{
var inspectable = member.GetCustomAttribute<InspectableAttribute>();
if (inspectable == null && inspectableOnly) continue;
var preferred = inspectable as PreferredInspectorAttribute;
switch (member)
{
case FieldInfo field:
{
var type = field.FieldType;
var handler = Resolver.ResolveField(type, preferred);
// If there's a handler for this field and attribute type, use that
if (handler != null)
{
var group = Resolver.Resolve<HorizontalGroup>()?.Configure(this);
if (group == null) continue;
Resolver.Resolve<Label>()?.Configure(group, member.Name);
handler.ConfigureForMember(group, obj, field);
}
else
{
// No field handler found;
// If recursion is enabled and the field is a class marked inspectable or if we don't care,
// Generate a foldout and then inspect recursively inside the foldout
if (recursive && field.FieldType.IsClass &&
(!inspectableOnly || field.FieldType.GetCustomAttribute<InspectableAttribute>() != null))
{
var foldout = Resolver.Resolve<GeneratorFoldout>();
if (foldout != null)
{
foldout.Title = member.Name;
foldout.Inspect(field.GetValue(obj), inspectableOnly, recursive);
}
}
// If all else fails, just display the ToString
else this.AddLabelInspector(member.Name, () => field.GetValue(obj)?.ToString() ?? "Null");
}
continue;
}
case PropertyInfo property:
{
var type = property.PropertyType;
var isReadOnly = !property.CanWrite;
PreferredInspectorAttribute? usedAttr = preferred;
if (isReadOnly && preferred == null && IsDisplayablePrimitive(type))
{
usedAttr = new InspectableReadOnlyLabelAttribute();
}
var handler = Resolver.ResolveField(type, usedAttr);
if (handler != null)
{
var group = Resolver.Resolve<HorizontalGroup>()?.Configure(this);
if (group == null) continue;
Resolver.Resolve<Label>()?.Configure(group, member.Name);
handler.ConfigureForMember(group, obj, property);
}
else
{
if (recursive && property.PropertyType.IsClass &&
(!inspectableOnly || property.PropertyType.GetCustomAttribute<InspectableAttribute>() != null))
{
var foldout = Resolver.Resolve<GeneratorFoldout>();
if (foldout != null)
{
foldout.Title = member.Name;
foldout.Inspect(property.GetValue(obj), inspectableOnly, recursive);
}
}
else this.AddLabelInspector(member.Name, () => property.GetValue(obj)?.ToString() ?? "Null");
}
continue;
}
}
}
}
private static bool IsDisplayablePrimitive(Type type)
{
return type.IsPrimitive || type == typeof(string) || type == typeof(decimal);
}
public virtual void Update()
{
RefreshValues();
}
protected void RefreshValues()
{
RefreshPropertyValues?.Invoke();
}
public void Register(GameObject go)
{
Elements.Add(go);
OnPropertyAdded?.Invoke(go);
}
public void Clear()
{
foreach(var element in Elements)
Destroy(element);
Elements.Clear();
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\GeneratorPanel.cs
using TMPro;
using UnityEngine;
namespace GameCult.Unity.UI
{
public class GeneratorPanel : Generator
{
[SerializeField] private TextMeshProUGUI? title;
public string Title
{
set
{
if (title is not null) title.text = value;
}
}
public void TitleSubtitle(string title, string subtitle) => Title = $"{title}\n<smallcaps><size=50%>{subtitle}";
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Interfaces.cs
using System;
using System.Collections.Generic;
using System.Reflection;
using GameCult.Unity.UI.Components;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace GameCult.Unity.UI
{
public interface IFieldHandler
{
int Priority { get; }
bool CanHandle(Type type, PreferredInspectorAttribute? attr);
void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null);
}
public interface IUIResolver
{
// For general field generation (reflection, attributes, etc.)
IFieldHandler? ResolveField(Type type, PreferredInspectorAttribute? attr);
// For declarative, known-type composition
T? Resolve<T>(string? prefabName = null) where T : ResolverComponent;
}
public interface IUIContext
{
IUIResolver Resolver { get; }
RectTransform ContentRoot { get; }
Canvas Canvas { get; }
RectTransform CanvasRoot { get; }
void Register(GameObject go);
Action? Refresh { get; set; }
// DisplayOptions InspectorLabelDisplayOptions { get; }
// DisplayOptions InspectorFieldDisplayOptions { get; }
}
public interface ILayoutElement
{
public LayoutElement? LayoutElement { get; }
}
public interface ITextElement
{
public TextMeshProUGUI? Text { get; }
}
public interface IMultipleTextElement
{
public IEnumerable<TextMeshProUGUI> Text { get; }
}
public static class DisplayOptionsExtensions
{
public static void ApplyLayoutOptions(this ILayoutElement layoutElement, DisplayOptions? displayOptions)
{
if(displayOptions == null || layoutElement.LayoutElement == null) return;
if(displayOptions.LayoutPriority != null) layoutElement.LayoutElement.flexibleWidth = displayOptions.LayoutPriority.Value;
if(displayOptions.LayoutHeight != null) layoutElement.LayoutElement.minHeight = displayOptions.LayoutHeight.Value;
}
public static void ApplyTextOptions(this ITextElement textElement, DisplayOptions? displayOptions)
{
if(displayOptions == null || textElement.Text is null) return;
if(displayOptions.TextAlignment != null) textElement.Text.alignment = displayOptions.TextAlignment.Value;
if(displayOptions.VerticalAlignment != null) textElement.Text.verticalAlignment = displayOptions.VerticalAlignment.Value;
if(displayOptions.TextSize != null) textElement.Text.fontSize = displayOptions.TextSize.Value;
}
public static void ApplyTextOptions(this IMultipleTextElement textElement, DisplayOptions? displayOptions)
{
if(displayOptions == null) return;
foreach(var text in textElement.Text)
{
if (displayOptions.TextAlignment != null) text.alignment = displayOptions.TextAlignment.Value;
if (displayOptions.VerticalAlignment != null) text.verticalAlignment = displayOptions.VerticalAlignment.Value;
if (displayOptions.TextSize != null) text.fontSize = displayOptions.TextSize.Value;
}
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\MainMenu.cs
using System;
using GameCult.Unity.UI.Components;
using UnityEngine;
using static Unity.Mathematics.math;
namespace GameCult.Unity.UI
{
public class MainMenu : MonoBehaviour
{
[SerializeField] private Prototype? panelPrototype;
[SerializeField] private float fadeTime = .5f;
[SerializeField] private float fadeDistance = 512;
[SerializeField] private float fadeAlphaExponent = 2;
[SerializeField] private float fadePositionExponent = 2;
private (GeneratorPanel panel, CanvasGroup group) _currentMenu, _nextMenu;
private bool _fadeFromRight;
private float _fadeLerp;
private bool _fading;
private Vector3 _panelPosition;
//private Task<DatabaseCache> _databaseLoad;
void Start()
{
// Start loading the database in the background 'cause it takes a few seconds
//_databaseLoad = Task.Run(() => ActionGameManager.Database);
if(panelPrototype == null)
{
gameObject.SetActive(false);
return;
}
_panelPosition = panelPrototype.transform.position;
var panel1 = panelPrototype.Instantiate<GeneratorPanel>();
_currentMenu = (panel1, panel1.GetComponent<CanvasGroup>());
var panel2 = panelPrototype.Instantiate<GeneratorPanel>();
_nextMenu = (panel2, panel2.GetComponent<CanvasGroup>());
_currentMenu.panel.gameObject.SetActive(false);
//_saveDirectory = ActionGameManager.GameDataDirectory.CreateSubdirectory("Saves");
ShowMain();
Fade(true);
}
private void Update()
{
if (_fading)
{
_fadeLerp += Time.deltaTime / fadeTime;
_currentMenu.panel.transform.position =
_panelPosition + (_fadeFromRight ? Vector3.left : Vector3.right) * (fadeDistance * pow(_fadeLerp, fadePositionExponent));
_nextMenu.panel.transform.position =
_panelPosition + (_fadeFromRight ? Vector3.right : Vector3.left) * (fadeDistance * pow(1-_fadeLerp, fadePositionExponent));
_currentMenu.group.alpha = pow(1 - _fadeLerp, fadeAlphaExponent);
_nextMenu.group.alpha = pow(_fadeLerp, fadeAlphaExponent);
if (_fadeLerp > 1)
{
_fading = false;
_currentMenu.panel.gameObject.SetActive(false);
(_currentMenu, _nextMenu) = (_nextMenu, _currentMenu);
}
}
}
private void Fade(bool fromRight)
{
_nextMenu.panel.gameObject.SetActive(true);
_nextMenu.group.alpha = 0;
_fading = true;
_fadeLerp = 0;
_fadeFromRight = fromRight;
}
private void ShowMain()
{
_nextMenu.panel.Clear();
_nextMenu.panel.TitleSubtitle("CultUI", "Runtime UI Composition");
_nextMenu.panel.AddTextButton("Demos",
() =>
{
ShowDemos();
Fade(true);
});
_nextMenu.panel.AddTextButton("Settings",
() =>
{
ShowSettings();
Fade(true);
});
_nextMenu.panel.AddTextButton("Quit", Application.Quit);
}
private void ShowDemos()
{
_nextMenu.panel.Clear();
_nextMenu.panel.TitleSubtitle("CultUI", "Demos");
_nextMenu.panel.AddTextButton("Procedural Inspector",
() =>
{
ShowProceduralInspectorDemo();
Fade(true);
});
_nextMenu.panel.AddTextButton("Back",
() =>
{
ShowMain();
Fade(false);
});
}
private void ShowProceduralInspectorDemo()
{
_nextMenu.panel.Clear();
_nextMenu.panel.TitleSubtitle("CultUI", "Code Inspector");
_nextMenu.panel.AddLabel("For the common task of inspector-style menus with a label and a field, " +
"CultUI offers convenient extensions " +
"which compose a label and a field handler within a horizontal scope. " +
"This can be great for options menus or in-game properties panels.");
_nextMenu.panel.AddLabelInspector("Read-only String", () => $"{Time.time:F1}");
var testString = "Test String";
_nextMenu.panel.AddStringInspector("String", () => testString, s => testString = s);
var testFloat = 10f;
_nextMenu.panel.AddFloatInspector("Float", () => testFloat, f => testFloat = f);
var testRangedFloat = 10f;
_nextMenu.panel.AddRangedFloatInspector("Ranged Float", () => testRangedFloat, f => testRangedFloat = f, 0, 100);
var testBool = true;
_nextMenu.panel.AddBoolInspector("Bool", () => testBool, b => testBool = b);
var testIncrement = 3;
_nextMenu.panel.AddIncrementIntInspector("Increment Int", () => testIncrement, i => testIncrement = i, 0, 5);
_nextMenu.panel.AddButtonInspector("Button", "Click me!", () => Debug.Log("Button Clicked!"));
var testEnum = Animals.Dog;
_nextMenu.panel.AddEnumInspector("Enum", () => testEnum, e => testEnum = e);
var testFlags = AnimalTraits.HasFur;
_nextMenu.panel.AddFlagsEnumInspector("Flags", () => testFlags, f => testFlags = f);
_nextMenu.panel.AddTextButton("Back",
() =>
{
ShowDemos();
Fade(false);
});
}
[Flags]
enum AnimalTraits
{
None = 0,
HasFur = 1,
CanFly = 2,
CanSwim = 4,
IsVenomous = 8,
Nocturnal = 16
}
private enum Animals
{
Dog,
Cat,
Bird
}
private void ShowSettings()
{
_nextMenu.panel.Clear();
_nextMenu.panel.Title = "Settings";
_nextMenu.panel.AddTextButton("Gameplay",
() =>
{
ShowGameplaySettings();
Fade(true);
});
_nextMenu.panel.AddTextButton("Graphics",
() =>
{
ShowGraphicsSettings();
Fade(true);
});
_nextMenu.panel.AddTextButton("Input",
() =>
{
ShowInputSettings();
Fade(true);
});
_nextMenu.panel.AddTextButton("Audio",
() =>
{
ShowAudioSettings();
Fade(true);
});
_nextMenu.panel.AddTextButton("Back",
() =>
{
ShowMain();
Fade(false);
});
}
private void ShowGameplaySettings()
{
_nextMenu.panel.Clear();
_nextMenu.panel.TitleSubtitle("Gameplay", "Settings");
_nextMenu.panel.AddTextButton("Back",
() =>
{
ShowSettings();
Fade(false);
});
}
private void ShowGraphicsSettings()
{
_nextMenu.panel.Clear();
_nextMenu.panel.TitleSubtitle("Graphics", "Settings");
_nextMenu.panel.AddTextButton("Back",
() =>
{
ShowSettings();
Fade(false);
});
}
private void ShowInputSettings()
{
_nextMenu.panel.Clear();
_nextMenu.panel.TitleSubtitle("Input", "Settings");
_nextMenu.panel.AddTextButton("Back",
() =>
{
ShowSettings();
Fade(false);
});
}
private void ShowAudioSettings()
{
_nextMenu.panel.Clear();
_nextMenu.panel.TitleSubtitle("Audio", "Settings");
_nextMenu.panel.AddTextButton("Back",
() =>
{
ShowSettings();
Fade(false);
});
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Prototype.cs
using System;
using System.Collections.Generic;
using UnityEngine;
namespace GameCult.Unity.UI
{
/// <summary>
/// Convenience helper that lets you create "prototype" GameObjects in-scene which act like prefabs:<br/>
/// - The prototype (root) exists in the scene and deactivates itself on start.<br/>
/// - You can Instantiate&lt;T&gt;() to get an active copy in the same parent/transform space.<br/>
/// - When finished, call ReturnToPool() on the instance to return it to the prototype's pool
/// so it can be reused instead of being destroyed and recreated.
/// </summary>
public class Prototype : MonoBehaviour
{
/// <summary>
/// Fired when an instance is returned into the prototype's pool (after it is deactivated).
/// </summary>
public event Action? OnReturnToPool;
// True when this component is the prototype/root (i.e. not a pooled instance).
public bool IsPrototype => _rootPrototype is null;
// Always returns the prototype/root for this object. If this object is the prototype, it returns itself.
public Prototype RootPrototype => _rootPrototype ?? this;
private void Start()
{
// Prototype objects remain inactive in the scene by default.
if (IsPrototype)
gameObject.SetActive(false);
}
private void OnDestroy()
{
// When the prototype is destroyed, clean up pooled instances too.
if (_instancePool != null)
{
foreach (var instance in _instancePool)
Destroy(instance.gameObject);
}
}
/// <summary>
/// Instantiate a copy of this Prototype and return the requested component T from the instance.
/// If there is an available instance in the pool, reuse it.
/// </summary>
public T Instantiate<T>() where T : Component
{
Prototype instance;
// Re-use from pool if possible (pool belongs to the prototype/root).
var poolOwner = RootPrototype;
if (poolOwner._instancePool != null && poolOwner._instancePool.Count > 0)
{
var lastIndex = poolOwner._instancePool.Count - 1;
instance = poolOwner._instancePool[lastIndex];
poolOwner._instancePool.RemoveAt(lastIndex);
instance.transform.SetAsLastSibling();
}
else
{
// Create a fresh instance from this object (clone entire GameObject).
// Place it under the same parent as the prototype.
instance = UnityEngine.Object.Instantiate(this, transform.parent, false);
// For UI RectTransforms, copy anchors/pivot/sizeDelta so layout matches.
if (transform is RectTransform protoRT &&
instance.transform is RectTransform instRT)
{
instRT.anchorMin = protoRT.anchorMin;
instRT.anchorMax = protoRT.anchorMax;
instRT.pivot = protoRT.pivot;
instRT.sizeDelta = protoRT.sizeDelta;
}
// Match local transform values exactly.
instance.transform.localPosition = transform.localPosition;
instance.transform.localRotation = transform.localRotation;
instance.transform.localScale = transform.localScale;
// Mark this new copy as an instance of this prototype.
instance._rootPrototype = this;
}
instance.gameObject.SetActive(true);
return instance.GetComponent<T>();
}
/// <summary>
/// Return this instance to its prototype's pool for reuse.
/// Can only be called on an instance (not on the prototype/root itself).
/// </summary>
public void ReturnToPool()
{
if (IsPrototype)
{
Debug.LogError($"Cannot ReturnToPool: '{name}' appears to be the prototype root (it has no root prototype). " +
"Only instantiated instances can be returned to a pool.");
Destroy(gameObject);
return;
}
// Move instance back under the prototype's parent and add to pool.
transform.SetParent(RootPrototype.transform.parent);
RootPrototype.AddToPool(this);
// Clear any handlers to avoid leaking references while pooled.
OnReturnToPool = null;
}
/// <summary>
/// Get the requested component on the prototype (if this is the prototype) or on the root prototype.
/// </summary>
public T GetOriginal<T>()
{
return RootPrototype.GetComponent<T>();
}
/// <summary>
/// Adds an instance back into this prototype's pool.
/// This method is internal to the prototype; it should only be called on the prototype/root.
/// </summary>
private void AddToPool(Prototype instance)
{
if (!IsPrototype)
{
Debug.LogError($"Attempting to add '{instance.name}' to the pool of '{name}', but '{name}' is not the prototype root.");
// still attempt to add to the real root if available
if (_rootPrototype is not null)
{
_rootPrototype.AddToPool(instance);
return;
}
}
instance.gameObject.SetActive(false);
_instancePool ??= new List<Prototype>();
_instancePool.Add(instance);
// Notify any subscribers that this instance has returned to the pool.
instance.OnReturnToPool?.Invoke();
}
// Reference to the prototype/root. Non-null on instances; null on the prototype itself.
private Prototype? _rootPrototype;
// Pool of instances owned by the prototype/root.
private List<Prototype>? _instancePool;
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\ReflectiveResolver.cs
using System;
using System.Collections.Generic;
using System.Linq;
using GameCult.Unity.UI.Components;
using UnityEngine;
using Object = UnityEngine.Object;
namespace GameCult.Unity.UI
{
[CreateAssetMenu(menuName="Inspector/Reflective Resolver")]
public class ReflectiveResolver : ScriptableObject, IUIResolver
{
[SerializeField] private List<LayoutComponent> fieldPrefabs = new();
[SerializeField] private List<ResolverComponent> componentPrefabs = new();
private List<IFieldHandler> _handlers = new();
private void Awake()
{
foreach (var prefab in fieldPrefabs)
{
if (prefab is not IFieldHandler)
{
Debug.LogWarning($"Prefab {prefab.gameObject.name} in Field Prefabs is not IFieldHandler");
}
_handlers.Add((IFieldHandler)prefab);
}
_handlers = _handlers.OrderByDescending(h => h.Priority).ToList();
}
public IFieldHandler? ResolveField(Type type, PreferredInspectorAttribute? attribute)
{
var match = _handlers.FirstOrDefault(h => h.CanHandle(type, attribute));
if (match != null) return (IFieldHandler)Instantiate((LayoutComponent)match);
Debug.LogWarning($"{name} unable to resolve {type}" +
(attribute != null ? $" with attribute {attribute.GetType().Name}" : ""));
return null;
}
public T? Resolve<T>(string? prefabName = null) where T : ResolverComponent
{
foreach (var component in componentPrefabs)
{
if (component is T t && (prefabName==null || t.gameObject.name.StartsWith(prefabName))) return Instantiate(t);
}
foreach (var field in fieldPrefabs)
{
if (field is T t && (prefabName==null || t.gameObject.name.StartsWith(prefabName))) return Instantiate(t);
}
Debug.LogWarning($"{name} unable to resolve {typeof(T).Name}" + (prefabName != null ? $" with name {prefabName}" : ""));
return null;
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\StringExtensions.cs
using System.Text.RegularExpressions;
namespace GameCult.Unity.UI
{
public static class StringExtensions
{
public static string SplitCamelCase( this string str )
{
return Regex.Replace(
Regex.Replace(
str,
@"(\P{Ll})(\P{Ll}\p{Ll})",
"$1 $2"
),
@"(\p{Ll})(\P{Ll})",
"$1 $2"
);
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\BoolField.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class BoolField : LayoutComponent, IFieldHandler
{
[SerializeField] private Toggle? toggle;
public int Priority => 10;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
type == typeof(bool) && attr == null;
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? options = null)
{
Func<bool> read;
Action<bool> write;
switch (member)
{
case FieldInfo field:
read = () => (bool)field.GetValue(target);
write = b => field.SetValue(target, b);
break;
case PropertyInfo property:
read = () => (bool)property.GetValue(target);
write = b => property.SetValue(target, b);
break;
default:
return;
}
Configure(context, read, write, options);
}
public BoolField Configure(IUIContext context, Func<bool> read, Action<bool> write, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (toggle is null) return this;
toggle.isOn = read();
toggle.onValueChanged.AddListener(b => write(b));
toggle.interactable = displayOptions?.Interactable??true;
this.ApplyLayoutOptions(displayOptions);
context.Refresh += () => toggle.isOn = read();
return this;
}
}
public static class BoolFieldGeneratorExtension
{
public static void AddBoolInspector(this IUIContext context,
string label,
Func<bool> read,
Action<bool> write,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<BoolField>(prefabName)?.Configure(context, read, write));
public static void AddBoolField(this IUIContext context,
Func<bool> read,
Action<bool> write,
string? prefabName = null,
DisplayOptions? displayOptions = null) =>
context.Resolver.Resolve<BoolField>(prefabName)?.Configure(context, read, write, displayOptions);
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ButtonField.cs
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ButtonField.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class ButtonField : LayoutComponent, IFieldHandler, ITextElement
{
[SerializeField] private TextMeshProUGUI? buttonLabel;
[SerializeField] private Button? button;
public int Priority => 10;
public TextMeshProUGUI? Text => buttonLabel;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
type == typeof(Action);
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
Action action;
switch (member)
{
case FieldInfo field:
action = (Action)field.GetValue(target);
break;
case PropertyInfo property:
action = (Action)property.GetValue(target);
break;
default:
return;
}
Configure(context, member.Name, action, displayOptions);
}
public ButtonField Configure(IUIContext context, string labelText, Action onClick, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (buttonLabel is null || button is null) return this;
buttonLabel.text = labelText;
button.onClick.AddListener(() => onClick());
button.interactable = displayOptions?.Interactable??true;
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
public Component Component => this;
}
public static class ButtonFieldGeneratorExtension
{
public static void AddButtonInspector(this IUIContext context,
string label,
string buttonLabel,
Action onClick,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<ButtonField>(prefabName)?.Configure(context, buttonLabel, onClick));
public static void AddButton(this IUIContext context,
string label,
Action onClick,
DisplayOptions? displayOptions = null,
string? prefabName = null) =>
context.Resolver.Resolve<ButtonField>(prefabName)?.Configure(context, label, onClick, displayOptions);
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ClickCatcher.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using R3;
using UnityEngine.EventSystems;
namespace GameCult.Unity.UI.Components
{
public class ClickCatcher : ResolverComponent, IPointerEnterHandler, IPointerExitHandler, IPointerDownHandler, IPointerClickHandler
{
public float dragDistance = 25f;
public bool PointerIsInside { get; private set; }
public Subject<PointerEventData> OnEnter = new Subject<PointerEventData>();
public Subject<PointerEventData> OnExit = new Subject<PointerEventData>();
public Subject<PointerEventData> OnClick = new Subject<PointerEventData>();
public Subject<PointerEventData> OnDown = new Subject<PointerEventData>();
public void OnPointerEnter(PointerEventData eventData)
{
//Debug.Log("Pointer Entered");
OnEnter.OnNext(eventData);
PointerIsInside = true;
}
public void OnPointerExit(PointerEventData eventData)
{
//Debug.Log("Pointer Exited");
OnExit.OnNext(eventData);
PointerIsInside = false;
}
public void OnPointerDown(PointerEventData eventData)
{
OnDown.OnNext(eventData);
}
public void OnPointerClick(PointerEventData eventData)
{
if((eventData.pressPosition - eventData.position).sqrMagnitude < dragDistance)
OnClick.OnNext(eventData);
}
private void OnDestroy()
{
OnEnter.Dispose();
OnExit.Dispose();
OnClick.Dispose();
OnDown.Dispose();
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ColorModal.cs
using System;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using Unity.Mathematics;
using UnityEngine;
using UnityEngine.UI;
using static GameCult.Unity.UI.ColorConversion;
using static Unity.Mathematics.math;
#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.
namespace GameCult.Unity.UI.Components
{
public class ColorModal : Modal
{
private static readonly int Mode = Shader.PropertyToID("_Mode");
private static readonly int H = Shader.PropertyToID("_H");
private static readonly int S = Shader.PropertyToID("_S");
private static readonly int V = Shader.PropertyToID("_V");
private static readonly int Value = Shader.PropertyToID("_Value");
[SerializeField] private ConstrainedSlider2D colorCircle;
[SerializeField] private Slider hueSlider;
[SerializeField] private Slider satSlider;
[SerializeField] private Slider valSlider;
[SerializeField] private EnumField modeEnum;
[SerializeField] private TMP_InputField hexStringField;
[SerializeField] private Image circleImage;
[SerializeField] private Image previewImage;
[SerializeField] private List<Image> sliderBackgrounds = new();
[SerializeField] private List<Image> sliderHandles = new();
[SerializeField] private List<Image> sliderHandleOutlines = new();
[SerializeField] private List<Material> circleMaterials = new();
[SerializeField] private List<Material> hueSliderMaterials = new();
[SerializeField] private List<Material> satSliderMaterials = new();
[SerializeField] private List<Material> valSliderMaterials = new();
private Color _color = Color.red;
private float3 _hsv;
private ColorMode _mode = ColorMode.HSV;
private float3 HSV
{
get => _hsv;
set
{
_hsv = value;
circleImage.material.SetFloat(Value, _hsv.z);
foreach (var image in sliderBackgrounds)
{
image.material.SetFloat(H, _hsv.x);
image.material.SetFloat(S, _hsv.y);
image.material.SetFloat(V, _hsv.z);
}
colorCircle.Value = PolarToRect(_hsv.xy);
hueSlider.value = _hsv.x;
satSlider.value = _hsv.y;
valSlider.value = _hsv.z;
RefreshColor();
}
}
private Color Color
{
get => _color;
set
{
_color = value;
foreach (var handle in sliderHandles)
{
handle.color = value;
}
previewImage.color = value;
RefreshHex();
}
}
private float2 RectToPolar(float2 rect)
{
var offset = rect * 2 - float2(1,1);
return float2(atan2(offset.y, -offset.x) / PI2 + .5f, length(offset));
}
private float2 PolarToRect(float2 polar)
{
float angle = (polar.x - 0.5f) * PI2;
float r = polar.y;
var offset = float2(-r * cos(angle), r * sin(angle));
return (offset + float2(1,1)) / 2;
}
private void RefreshColor()
{
Color = _mode switch
{
ColorMode.HSV => HSVtoRGB(HSV).ToColor().gamma,
ColorMode.HSL => HSLtoRGB(HSV).ToColor().gamma,
ColorMode.HCY => HCYtoRGB(HSV).ToColor().gamma,
_ => throw new ArgumentOutOfRangeException()
};
}
private void RefreshHSV()
{
HSV = _mode switch
{
ColorMode.HSV => RGBtoHSV(_color.linear.ToFloat3()),
ColorMode.HSL => RGBtoHSL(_color.linear.ToFloat3()),
ColorMode.HCY => RGBtoHCY(_color.linear.ToFloat3()),
_ => throw new ArgumentOutOfRangeException()
};
}
private void RefreshHex() => hexStringField.text = '#' + ColorUtility.ToHtmlStringRGB(Color);
private void SetColorMode(ColorMode mode)
{
circleImage.material = circleMaterials[(int)mode];
sliderBackgrounds[0].material = hueSliderMaterials[(int)mode];
sliderBackgrounds[1].material = satSliderMaterials[(int)mode];
sliderBackgrounds[2].material = valSliderMaterials[(int)mode];
}
// BUG: This should work, but neither setting the mode integer or shader keywords actually affects the material
// private void SetColorMode(Material mat, ColorMode mode)
// {
// mat.SetInteger("_Mode", (int)mode);
// var enumType = typeof(ColorMode);
// var names = Enum.GetNames(enumType);
// var values = Enum.GetValues(enumType);
// for (var i = 0; i < names.Length; i++)
// {
// if((ColorMode)values.GetValue(i) == mode)
// {
// mat.EnableKeyword("_Mode_" + names[i]);
// Debug.Log($"Enabling keyword {"_Mode_" + names[i]} on {mat.name}");
// }
// else
// {
// mat.DisableKeyword("_Mode_" + names[i]);
// Debug.Log($"Disabling keyword {"_Mode_" + names[i]} on {mat.name}");
// }
// }
// }
private void OnEnable()
{
colorCircle.ConstrainFunction = v =>
{
var offset = v * 2 - Vector2.one;
if (offset.sqrMagnitude > 1) offset = offset.normalized;
return (offset + Vector2.one) / 2;
};
colorCircle.OnValueChanged.AddListener(v => HSV = float3(RectToPolar(v),_hsv.z));
hueSlider.onValueChanged.AddListener(h => HSV = float3(h, _hsv.yz));
satSlider.onValueChanged.AddListener(s => HSV = float3(_hsv.x, s, _hsv.z));
valSlider.onValueChanged.AddListener(v => HSV = float3(_hsv.xy, v));
var enumType = typeof(ColorMode);
var names = Enum.GetNames(enumType);
var values = Enum.GetValues(enumType);
modeEnum.Configure(this,
() => Array.IndexOf(values, _mode),
i =>
{
_mode = (ColorMode)values.GetValue(i);
SetColorMode(_mode);
RefreshHSV();
}, names, new DisplayOptions(placeInContext:false));
RefreshHSV();
hexStringField.onEndEdit.AddListener(s =>
{
if(ColorUtility.TryParseHtmlString(s, out Color color))
{
Color = color;
RefreshHSV();
}
else
RefreshHex();
});
RefreshHex();
}
enum ColorMode
{
HSV,
HSL,
HCY
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ConstrainedSlider2D.cs
using System;
using UnityEditor;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
/// <summary>
/// A UI element similar to Slider but allowing free 2D movement of the handle within a rect,
/// constrained by an external validation function.
/// </summary>
/// <remarks>
/// The handle RectTransform is driven by the ConstrainedHandle. The position is normalized (0-1) in both axes.
/// An external Func<Vector2, Vector2> can be provided via constrainFunction to validate/constrain positions (e.g., to a circle for HSV picker).
/// When a change to the handle value occurs, a callback is sent to any registered listeners of UI.ConstrainedHandle.onValueChanged.
/// </remarks>
[AddComponentMenu("UI/Constrained Slider 2D")]
[ExecuteAlways]
[RequireComponent(typeof(RectTransform))]
public class ConstrainedSlider2D : Selectable, IDragHandler, IInitializePotentialDragHandler, ICanvasElement
{
[Serializable]
public class ConstrainedSlider2DEvent : UnityEvent<Vector2> { }
[SerializeField]
private RectTransform? handleRect;
public RectTransform? HandleRect
{
get => handleRect;
set
{
if (handleRect == value) return;
handleRect = value;
UpdateCachedReferences();
UpdateVisuals();
}
}
[Space]
[SerializeField]
private ConstrainedSlider2DEvent onValueChanged = new ConstrainedSlider2DEvent();
public ConstrainedSlider2DEvent OnValueChanged
{
get => onValueChanged;
set => onValueChanged = value;
}
/// <summary>
/// External function to constrain the normalized position (Vector2 in [0,1] range).
/// Defaults to identity (no constraint).
/// </summary>
public System.Func<Vector2, Vector2> ConstrainFunction { get; set; } = pos => pos;
// Private fields
private RectTransform? _handleContainerRect;
private Vector2 _offset = Vector2.zero;
// field is never assigned warning
#pragma warning disable 649
private DrivenRectTransformTracker _tracker;
#pragma warning restore 649
// This "delayed" mechanism is required for case 1037681.
private bool _delayedUpdateVisuals;
[SerializeField]
protected Vector2 value = new(0.5f, 0.5f);
/// <summary>
/// Normalized value (0-1 in both axes) representing handle position fraction within the container.
/// </summary>
public virtual Vector2 Value
{
get => value;
set => Set(value);
}
public virtual void SetValueWithoutNotify(Vector2 input)
{
Set(input, false);
}
protected ConstrainedSlider2D() { }
#if UNITY_EDITOR
protected override void OnValidate()
{
base.OnValidate();
// OnValidate is called before OnEnable. We need to make sure not to touch any other objects before OnEnable is run.
if (IsActive())
{
UpdateCachedReferences();
// Update rects in next update since other things might affect them even if value didn't change.
_delayedUpdateVisuals = true;
}
if (!PrefabUtility.IsPartOfPrefabAsset(this) && !Application.isPlaying)
CanvasUpdateRegistry.RegisterCanvasElementForLayoutRebuild(this);
}
#endif
public virtual void Rebuild(CanvasUpdate executing)
{
#if UNITY_EDITOR
if (executing == CanvasUpdate.Prelayout)
OnValueChanged.Invoke(Value);
#endif
}
/// <summary>
/// See ICanvasElement.LayoutComplete
/// </summary>
public virtual void LayoutComplete() { }
/// <summary>
/// See ICanvasElement.GraphicUpdateComplete
/// </summary>
public virtual void GraphicUpdateComplete() { }
protected override void OnEnable()
{
base.OnEnable();
UpdateCachedReferences();
Set(value, false);
// Update rects since they need to be initialized correctly.
UpdateVisuals();
#if UNITY_EDITOR
Undo.undoRedoEvent -= OnUndoRedoEvent;
Undo.undoRedoEvent += OnUndoRedoEvent;
#endif
}
protected override void OnDisable()
{
#if UNITY_EDITOR
Undo.undoRedoEvent -= OnUndoRedoEvent;
#endif
_tracker.Clear();
base.OnDisable();
}
/// <summary>
/// Update the rect based on the delayed update visuals.
/// Got around issue of calling SendMessage from OnValidate.
/// </summary>
protected virtual void Update()
{
if (_delayedUpdateVisuals)
{
_delayedUpdateVisuals = false;
Set(value, false);
UpdateVisuals();
}
}
protected override void OnDidApplyAnimationProperties()
{
Vector2 animatedValue = ComputeValueFromVisuals();
if (animatedValue != value)
{
value = animatedValue;
OnValueChanged.Invoke(value);
}
base.OnDidApplyAnimationProperties();
}
private Vector2 ComputeValueFromVisuals()
{
if (_handleContainerRect is null || handleRect is null) return value;
Vector2 size = _handleContainerRect.rect.size;
if (size == Vector2.zero) return value;
Vector2 delta = handleRect.anchoredPosition;
return new Vector2(
delta.x / size.x + 0.5f,
delta.y / size.y + 0.5f
);
}
void UpdateCachedReferences()
{
if (handleRect && handleRect != (RectTransform)transform)
{
_handleContainerRect = handleRect.parent as RectTransform;
}
else
{
handleRect = null;
_handleContainerRect = null;
}
}
protected virtual void Set(Vector2 input, bool sendCallback = true)
{
// Apply constraint and clamp
Vector2 newValue = ConstrainFunction(input);
newValue.x = Mathf.Clamp01(newValue.x);
newValue.y = Mathf.Clamp01(newValue.y);
if (value == newValue)
return;
value = newValue;
UpdateVisuals();
if (sendCallback)
{
UISystemProfilerApi.AddMarker("ConstrainedHandle.value", this);
onValueChanged.Invoke(newValue);
}
}
protected override void OnRectTransformDimensionsChange()
{
base.OnRectTransformDimensionsChange();
// This can be invoked before OnEnable is called. So we shouldn't be accessing other objects before OnEnable is called.
if (!IsActive())
return;
UpdateVisuals();
}
#if UNITY_EDITOR
void OnUndoRedoEvent(in UndoRedoInfo undo)
{
UpdateVisuals();
}
#endif
// Force-update the handle. Useful if you've changed the properties and want it to update visually.
private void UpdateVisuals()
{
#if UNITY_EDITOR
if (!Application.isPlaying)
UpdateCachedReferences();
#endif
_tracker.Clear();
if (_handleContainerRect != null && handleRect != null)
{
_tracker.Add(this, handleRect, DrivenTransformProperties.AnchoredPosition);
Vector2 size = _handleContainerRect.rect.size;
if (size != Vector2.zero)
{
Vector2 delta = (value - new Vector2(0.5f, 0.5f)) * size;
handleRect.anchoredPosition = delta;
}
}
}
// Update the handle's position based on the mouse.
void UpdateDrag(PointerEventData eventData, Camera cam)
{
RectTransform? containerRect = _handleContainerRect;
if (containerRect == null) return;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(containerRect, eventData.position, cam, out var localCursor))
return;
Vector2 candidateAnchored = localCursor - _offset;
Vector2 size = containerRect.rect.size;
if (size == Vector2.zero) return;
Vector2 candidateNorm = new Vector2(
candidateAnchored.x / size.x + 0.5f,
candidateAnchored.y / size.y + 0.5f
);
// Apply constraint here as well for drag consistency
Vector2 constrainedNorm = ConstrainFunction(candidateNorm);
constrainedNorm.x = Mathf.Clamp01(constrainedNorm.x);
constrainedNorm.y = Mathf.Clamp01(constrainedNorm.y);
Value = constrainedNorm;
}
private bool MayDrag(PointerEventData eventData)
{
return IsActive() && IsInteractable() && eventData.button == PointerEventData.InputButton.Left;
}
public override void OnPointerDown(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;
base.OnPointerDown(eventData);
_offset = Vector2.zero;
Camera cam = eventData.pressEventCamera;
if (handleRect != null && RectTransformUtility.RectangleContainsScreenPoint(handleRect, eventData.pointerPressRaycast.screenPosition, cam))
{
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(handleRect, eventData.pointerPressRaycast.screenPosition, cam, out var localMousePos))
_offset = localMousePos;
}
else
{
// Outside the handle - jump to this point instead
UpdateDrag(eventData, cam);
}
}
public virtual void OnDrag(PointerEventData eventData)
{
if (!MayDrag(eventData))
return;
UpdateDrag(eventData, eventData.pressEventCamera);
}
public override void OnMove(AxisEventData eventData)
{
if (!IsActive() || !IsInteractable())
{
base.OnMove(eventData);
return;
}
Set(value + eventData.moveVector * .05f);
}
public virtual void OnInitializePotentialDrag(PointerEventData eventData)
{
eventData.useDragThreshold = false;
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\EnumField.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.Serialization;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class EnumField : LayoutComponent, IFieldHandler, ITextElement
{
[SerializeField] private Button? dropdown;
[SerializeField] private TextMeshProUGUI? dropdownLabel;
public TextMeshProUGUI? Text => dropdownLabel;
public int Priority => 10;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
type.IsEnum && type.GetCustomAttribute<FlagsAttribute>() == null;
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
var fieldType = member is FieldInfo fi ? fi.FieldType : ((PropertyInfo)member).PropertyType;
var names = Enum.GetNames(fieldType);
var values = Enum.GetValues(fieldType);
Configure(context, () =>
{
if(member is FieldInfo field) return Array.IndexOf(values, field.GetValue(target));
if(member is PropertyInfo property) return Array.IndexOf(values, property.GetValue(target));
return 0;
},i =>
{
if(member is FieldInfo field) field.SetValue(target, values.GetValue(i));
if(member is PropertyInfo property) property.SetValue(target, values.GetValue(i));
}, names, displayOptions);
}
public EnumField Configure(IUIContext context, Func<int> read, Action<int> write, string[] options, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (dropdownLabel is null || dropdown is null) return this;
dropdown.onClick.AddListener(() =>
{
var modal = context.ShowModal();
if (modal == null) return;
for (var i = 0; i < options.Length; i++)
{
var i1 = i;
modal.Resolver.Resolve<ButtonField>("Enum")?.Configure(modal, options[i], () =>
{
write(i1);
Destroy(modal.gameObject);
});
}
modal.AlignVertical((RectTransform)dropdown.transform);
});
dropdown.interactable = displayOptions?.Interactable ?? true;
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
context.Refresh += () => dropdownLabel.text = options[read()];
return this;
}
}
public static class EnumFieldGeneratorExtension
{
public static void AddEnumInspector(this IUIContext context,
string label,
Func<int> read,
Action<int> write,
string[] options,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context, read, write, options));
public static void AddEnumInspector<TEnum>(this IUIContext context,
string label,
Func<TEnum> read,
Action<TEnum> write,
string? prefabName = null) where TEnum : Enum
{
var enumType = typeof(TEnum);
var names = Enum.GetNames(enumType);
var values = Enum.GetValues(enumType);
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context,
() => Array.IndexOf(values, read()),
i => write((TEnum)values.GetValue(i)), names));
}
public static void AddEnumField(this IUIContext context,
Func<int> read,
Action<int> write,
string[] options,
string? prefabName = null) =>
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context, read, write, options);
public static void AddEnumField<TEnum>(this IUIContext context,
Func<TEnum> read,
Action<TEnum> write,
string? prefabName = null) where TEnum : Enum
{
var enumType = typeof(TEnum);
var names = Enum.GetNames(enumType);
var values = Enum.GetValues(enumType);
context.Resolver.Resolve<EnumField>(prefabName)?.Configure(context,
() => Array.IndexOf(values, read()),
i => write((TEnum)values.GetValue(i)), names);
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\GeneratorFoldout.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class GeneratorFoldout : Generator, IFieldHandler, ILayoutElement, ITextElement
{
[SerializeField] private TextMeshProUGUI? label;
[SerializeField] private Image? foldoutIcon;
[SerializeField] private Button? button;
[SerializeField] private VerticalLayoutGroup? layoutGroup;
[SerializeField] private LayoutElement? layoutElement;
[SerializeField] private float foldoutRotationDamping = 10f;
[SerializeField] private int foldedPadding = -8;
[SerializeField] private int expandedPadding = 8;
public event Action<bool>? OnExpand;
private bool _expanded;
private float _targetFoldoutRotation;
private float _foldoutRotation;
public TextMeshProUGUI? Text => label;
public LayoutElement? LayoutElement => layoutElement;
public string Title
{
set
{
if (label != null) label.text = value;
}
}
public bool Expanded => _expanded;
public new void Awake()
{
button?.onClick.AddListener(ToggleExpand);
OnPropertyAdded += go => go.SetActive(_expanded);
}
public override void Update()
{
RefreshValues();
_foldoutRotation =
Mathf.Lerp(_foldoutRotation, _targetFoldoutRotation, foldoutRotationDamping * Time.deltaTime);
if (foldoutIcon is not null) foldoutIcon.transform.localRotation = Quaternion.Euler(0, 0, _foldoutRotation);
}
public void ToggleExpand() => SetExpanded(!_expanded, false);
public void SetExpanded(bool expanded, bool force)
{
_expanded = expanded;
if (layoutGroup != null)
{
var padding = layoutGroup.padding;
padding = new RectOffset(padding.left, padding.right, padding.top, _expanded ? expandedPadding : foldedPadding);
layoutGroup.padding = padding;
}
foreach (var property in Elements) property.SetActive(_expanded);
_targetFoldoutRotation = _expanded ? -90 : 0;
if (force)
{
_foldoutRotation = _targetFoldoutRotation;
if (foldoutIcon != null) foldoutIcon.transform.localRotation = Quaternion.Euler(0, 0, _foldoutRotation);
}
OnExpand?.Invoke(_expanded);
}
public int Priority => 10;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
type.IsEnum && type.GetCustomAttribute<FlagsAttribute>() != null;
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (label is null || button is null) return;
var type = member is FieldInfo fi ? fi.FieldType : ((PropertyInfo)member).PropertyType;
label.text = type.Name.SplitCamelCase();
var names = Enum.GetNames(type);
var values = Enum.GetValues(type);
// Get current bitfield as ulong for flexibility
Func<ulong> read = () => Convert.ToUInt64(member is FieldInfo f ? f.GetValue(target)
: ((PropertyInfo)member).GetValue(target));
// Build one checkbox per flag
foreach (var val in values)
{
ulong bit = Convert.ToUInt64(val);
string flagName = Enum.GetName(type, val) ?? "Default";
if (bit == 0)
{
// Handle "None" specially
this.AddBoolInspector(flagName,
() => Convert.ToUInt64(read()) == 0,
b =>
{
if (b)
{
// Setting "None" clears all bits
var newEnum = Enum.ToObject(type, 0);
if (member is FieldInfo f2) f2.SetValue(target, newEnum);
else if (member is PropertyInfo p2) p2.SetValue(target, newEnum);
}
// Unsetting None is a no-op
});
}
else
{
this.AddBoolInspector(flagName,
() => (read() & bit) != 0,
b =>
{
ulong value = Convert.ToUInt64(read());
if (b) value |= bit;
else value &= ~bit;
var newEnum = Enum.ToObject(type, value);
if (member is FieldInfo f2) f2.SetValue(target, newEnum);
else if (member is PropertyInfo p2) p2.SetValue(target, newEnum);
});
}
}
button.interactable = displayOptions?.Interactable??true;
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
}
public GeneratorFoldout Configure<TEnum>(IUIContext context, Func<TEnum> read, Action<TEnum> write, DisplayOptions? displayOptions = null) where TEnum : Enum
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (label is null || button is null) return this;
label.text = typeof(TEnum).Name.SplitCamelCase();
foreach (TEnum val in Enum.GetValues(typeof(TEnum)))
{
ulong bit = Convert.ToUInt64(val);
string flagName = Enum.GetName(typeof(TEnum), val) ?? "Default";
if (bit == 0)
{
this.AddBoolInspector(flagName,
() => Convert.ToUInt64(read()) == 0,
b =>
{
if (b)
{
write((TEnum)Enum.ToObject(typeof(TEnum), 0UL));
}
});
}
else
{
this.AddBoolInspector(flagName,
() => (Convert.ToUInt64(read()) & bit) != 0,
b =>
{
ulong value = Convert.ToUInt64(read());
if (b) value |= bit;
else value &= ~bit;
write((TEnum)Enum.ToObject(typeof(TEnum), value));
});
}
}
button.interactable = displayOptions?.Interactable??true;
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
public GeneratorFoldout Configure(IUIContext context, string labelText, DisplayOptions? displayOptions = null, params LayoutComponent[] children)
{
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (label is null || button is null) return this;
label.text = labelText;
button.interactable = displayOptions?.Interactable??true;
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
foreach (var component in children)
{
if (component != null) AddComponent(component);
}
return this;
}
public void AddComponent(LayoutComponent component)
{
component.transform.SetParent(ContentRoot, false);
}
}
public static class EnumGeneratorExtensions
{
public static void AddFlagsEnumInspector<TEnum>(
this IUIContext context, string label,
Func<TEnum> read, Action<TEnum> write)
where TEnum : Enum =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<GeneratorFoldout>()?.Configure(context, read, write));
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\HorizontalGroup.cs
using System;
using UnityEngine;
using UnityEngine.UI;
#pragma warning disable CS8602 // Dereference of a possibly null reference.
namespace GameCult.Unity.UI.Components
{
public class HorizontalGroup : ResolverComponent, IUIContext
{
[SerializeField] private HorizontalLayoutGroup? layoutGroup;
private IUIContext? _parentContext;
public IUIResolver Resolver => _parentContext.Resolver;
public RectTransform ContentRoot => (RectTransform)transform;
public Canvas Canvas => _parentContext.Canvas;
public RectTransform CanvasRoot => _parentContext.CanvasRoot;
public void Register(GameObject go) => _parentContext.Register(go);
public Action? Refresh
{
get => _parentContext.Refresh;
set => _parentContext.Refresh = value;
}
// public DisplayOptions InspectorLabelDisplayOptions => _parentContext.InspectorLabelDisplayOptions;
// public DisplayOptions InspectorFieldDisplayOptions => _parentContext.InspectorFieldDisplayOptions;
public HorizontalGroup Configure(IUIContext context, params ILayoutElement?[] children)
{
_parentContext = context;
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
foreach (var component in children)
{
if (component != null) AddComponent(component);
}
return this;
}
public void AddComponent(ILayoutElement component)
{
component.LayoutElement?.transform.SetParent(ContentRoot, false);
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\IncrementField.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class IncrementField : LayoutComponent, IFieldHandler, ITextElement
{
[SerializeField] private TextMeshProUGUI? value;
[SerializeField] private Button? increment;
[SerializeField] private Button? decrement;
public int Priority => 100;
public TextMeshProUGUI? Text => value;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
type == typeof(int) && attr is InspectableIncrementIntAttribute;
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
Func<int> read;
Action<int> write;
switch (member)
{
case FieldInfo field:
read = () => (int)field.GetValue(target);
write = i => field.SetValue(target, i);
break;
case PropertyInfo property:
read = () => (int)property.GetValue(target);
write = i => property.SetValue(target, i);
break;
default:
return;
}
var range = member.GetCustomAttribute<InspectableIncrementIntAttribute>();
Configure(context, read, write, range.Min, range.Max, displayOptions);
}
public IncrementField Configure(IUIContext context,
Func<int> read,
Action<int> write,
int min,
int max,
DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (value is null || increment is null || decrement is null) return this;
value.text = read().ToString();
increment.onClick.AddListener(() => write(Mathf.Min(read() + 1, max)));
decrement.onClick.AddListener(() => write(Mathf.Max(read() - 1, min)));
context.Refresh += () =>
{
var val = read();
value.text = val.ToString();
increment.interactable = val < max;
decrement.interactable = val > min;
};
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
}
public static class IncrementFieldGeneratorExtension
{
public static void AddIncrementIntInspector(this IUIContext context,
string label,
Func<int> read,
Action<int> write,
int min,
int max,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<IncrementField>(prefabName)?.Configure(context, read, write, min, max));
public static void AddIncrementIntField(this IUIContext context,
string label,
Func<int> read,
Action<int> write,
int min,
int max,
string? prefabName = null) =>
context.Resolver.Resolve<IncrementField>(prefabName)?.Configure(context, read, write, min, max);
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\InputField.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using TMPro;
using UnityEngine;
namespace GameCult.Unity.UI.Components
{
public class InputField : LayoutComponent, IFieldHandler, IMultipleTextElement
{
[SerializeField] private TMP_InputField? inputField;
[SerializeField] private TextMeshProUGUI[]? textMeshes;
public int Priority => 10;
public IEnumerable<TextMeshProUGUI> Text => textMeshes ?? Enumerable.Empty<TextMeshProUGUI>();
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
(type == typeof(string) || type == typeof(int) || type == typeof(float)) && attr == null;
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
var type = member switch
{
FieldInfo field => field.FieldType,
PropertyInfo property => property.PropertyType,
_ => null
};
if (type == null) return;
if (type == typeof(string))
{
Func<string> read;
Action<string> write;
switch (member)
{
case FieldInfo field:
read = () => (string)field.GetValue(target);
write = s => field.SetValue(target, s);
break;
case PropertyInfo property:
read = () => (string)property.GetValue(target);
write = s => property.SetValue(target, s);
break;
default:
return;
}
Configure(context, read, write);
}
else if (type == typeof(int))
{
Func<int> read;
Action<int> write;
switch (member)
{
case FieldInfo field:
read = () => (int)field.GetValue(target);
write = i => field.SetValue(target, i);
break;
case PropertyInfo property:
read = () => (int)property.GetValue(target);
write = i => property.SetValue(target, i);
break;
default:
return;
}
Configure(context, read, write);
}
else if (type == typeof(float))
{
Func<float> read;
Action<float> write;
switch (member)
{
case FieldInfo field:
read = () => (float)field.GetValue(target);
write = f => field.SetValue(target, f);
break;
case PropertyInfo property:
read = () => (float)property.GetValue(target);
write = f => property.SetValue(target, f);
break;
default:
return;
}
Configure(context, read, write);
}
}
public InputField Configure(IUIContext context, Func<string> read, Action<string> write, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (inputField is null) return this;
inputField.contentType = TMP_InputField.ContentType.Standard;
inputField.text = read() ?? string.Empty;
inputField.onValueChanged.AddListener(v => write(v));
context.Refresh += () =>
{
var val = read() ?? string.Empty;
if (inputField.text != val) inputField.text = val;
};
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
public InputField Configure(IUIContext context, Func<int> read, Action<int> write, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (inputField is null) return this;
inputField.contentType = TMP_InputField.ContentType.IntegerNumber;
inputField.text = read().ToString();
inputField.onValueChanged.AddListener(v =>
{
if (int.TryParse(v, out var i)) write(i);
});
context.Refresh += () =>
{
var val = read();
if (int.TryParse(inputField.text, out var existing) && existing != val)
inputField.text = val.ToString();
};
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
public InputField Configure(IUIContext context, Func<float> read, Action<float> write, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (inputField is null) return this;
inputField.contentType = TMP_InputField.ContentType.DecimalNumber;
inputField.text = read().ToString("0.###");
inputField.onValueChanged.AddListener(v => write(float.Parse(v)));
context.Refresh += () =>
{
var val = read();
if (float.TryParse(inputField.text, out var existing) && Mathf.Abs(existing-val) < .001)
inputField.text = val.ToString("0.###");
};
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
}
public static class InputFieldGeneratorExtension
{
public static void AddStringInspector(this IUIContext context,
string label,
Func<string> read,
Action<string> write,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write));
public static void AddIntInspector(this IUIContext context,
string label,
Func<int> read,
Action<int> write,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write));
public static void AddFloatInspector(this IUIContext context,
string label,
Func<float> read,
Action<float> write,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write));
public static void AddStringField(this IUIContext context,
Func<string> read,
Action<string> write,
DisplayOptions? displayOptions = null,
string? prefabName = null) =>
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write);
public static void AddIntField(this IUIContext context,
Func<int> read,
Action<int> write,
DisplayOptions? displayOptions = null,
string? prefabName = null) =>
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write);
public static void AddFloatField(this IUIContext context,
Func<float> read,
Action<float> write,
DisplayOptions? displayOptions = null,
string? prefabName = null) =>
context.Resolver.Resolve<InputField>(prefabName)?.Configure(context, read, write);
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\Label.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Globalization;
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.Serialization;
namespace GameCult.Unity.UI.Components
{
public class Label : LayoutComponent, IFieldHandler, ITextElement
{
[SerializeField] protected TextMeshProUGUI? label;
public int Priority => 100;
public TextMeshProUGUI? Text => label;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
attr is InspectableReadOnlyLabelAttribute;
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
Type memberType;
Func<object> getValue;
switch (member)
{
case FieldInfo field:
memberType = field.FieldType;
getValue = () => field.GetValue(target);
break;
case PropertyInfo property:
memberType = property.PropertyType;
getValue = () => property.GetValue(target);
break;
default:
return;
}
string FormattedRead()
{
var val = getValue();
if (val == null) return "Null";
return FormatValue(val, memberType);
}
Configure(context, FormattedRead, displayOptions);
}
private string FormatValue(object val, Type type)
{
if (type == typeof(float))
{
return ((float)val).ToString("F3", CultureInfo.InvariantCulture);
}
if (type == typeof(double))
{
return ((double)val).ToString("F3", CultureInfo.InvariantCulture);
}
if (type == typeof(decimal))
{
return ((decimal)val).ToString("F3", CultureInfo.InvariantCulture);
}
return val.ToString();
}
public Label Configure(IUIContext context, Func<string> read, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (label is null) return this;
label.text = read();
context.Refresh += () => label.text = read();
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
public Label Configure(IUIContext context, string labelText, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (label is null) return this;
label.text = labelText;
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
}
public static class LabelFieldGeneratorExtension
{
public static void AddLabelInspector(this IUIContext context,
string label,
Func<string> read) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<Label>("Value")?.Configure(context, read));
public static void AddLabel(this IUIContext context, Func<string> read, string? prefabName = null) =>
context.Resolver.Resolve<Label>(prefabName)?.Configure(context, read);
public static void AddLabel(this IUIContext context, string label, string? prefabName = null) =>
context.Resolver.Resolve<Label>(prefabName)?.Configure(context, label);
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\LayoutComponent.cs
using UnityEngine;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class LayoutComponent : ResolverComponent, ILayoutElement
{
[SerializeField] private LayoutElement? layoutElement;
public float LayoutPriority
{
set
{
if (layoutElement != null) layoutElement.flexibleWidth = value;
}
}
public LayoutElement? LayoutElement => layoutElement;
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\Modal.cs
using System;
using System.Collections;
using R3;
using UnityEngine;
using UnityEngine.UI;
using Object = UnityEngine.Object;
namespace GameCult.Unity.UI.Components
{
/// <summary>
/// Generic modal UI element that can either center itself
/// or align to another RectTransform while staying within screen bounds.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class Modal : Generator
{
public event Action? BeforeDestroy;
[SerializeField] protected VerticalLayoutGroup? layoutGroup;
/// <summary>
/// Positions the modal below the given element (or above if there's no space), matching its width
/// </summary>
public void AlignVertical(RectTransform alignToElement)
{
var rectTransform = (RectTransform)transform;
LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform);
var modalSize = rectTransform.rect.size;
var pivot = rectTransform.pivot;
pivot.x = 0;
var scaleFactor = Canvas.scaleFactor;
var corners = new Vector3[4];
alignToElement.GetWorldCorners(corners);
var pivotTop = corners[0].y > modalSize.y * scaleFactor;
pivot.y = pivotTop ? 1 : 0;
rectTransform.pivot = pivot;
var delta = alignToElement.sizeDelta;
if (layoutGroup != null)
delta.x = alignToElement.sizeDelta.x + (layoutGroup.padding.left + layoutGroup.padding.right);
rectTransform.sizeDelta = delta;
rectTransform.position = pivotTop ? corners[0] : corners[1];
if (layoutGroup != null)
rectTransform.anchoredPosition += Vector2.left * layoutGroup.padding.left;
}
/// <summary>
/// Positions the modal along the right or left edge,
/// pivoting down or up, respectively, according to available space
/// </summary>
public void AlignEdge(RectTransform alignToElement)
{
var rectTransform = (RectTransform)transform;
LayoutRebuilder.ForceRebuildLayoutImmediate(rectTransform);
var modalSize = rectTransform.rect.size;
var canvasRect = CanvasRoot.rect;
var pivot = rectTransform.pivot;
var scaleFactor = Canvas.scaleFactor;
var corners = new Vector3[4];
alignToElement.GetWorldCorners(corners);
// Is there enough space for the element to drop down from the upper edge?
var pivotTop = corners[2].y > modalSize.y * scaleFactor;
pivot.y = pivotTop ? 1 : 0;
// Is there enough space for the modal to sit on the right side?
var pivotRight = corners[2].x / scaleFactor + modalSize.x < canvasRect.x;
pivot.x = pivotRight ? 1 : 0;
rectTransform.pivot = pivot;
rectTransform.position = pivotTop ? corners[0] : corners[1];
}
public void Center()
{
var rect = (RectTransform)transform;
rect.pivot = new Vector2(.5f,.5f);
rect.anchoredPosition = Vector2.zero;
}
private void OnDestroy()
{
BeforeDestroy?.Invoke();
}
}
public static class ModalGeneratorExtensions
{
/// <summary>
/// Spawns a modal. Automatically creates and wires a click catcher that closes both.
/// </summary>
public static Modal? ShowModal(
this IUIContext context)
{
// Instantiate click catcher behind modal
var catcher = context.Resolver.Resolve<ClickCatcher>();
if (catcher == null) return null;
catcher.transform.SetParent(context.CanvasRoot, false);
catcher.transform.SetAsLastSibling();
// Instantiate modal on top
var modal = context.Resolver.Resolve<Modal>();
if (modal == null) return null;
modal.transform.SetParent(context.CanvasRoot, false);
modal.transform.SetAsLastSibling();
// If modal is ever destroyed, also destroy click catcher
modal.BeforeDestroy += () => Object.Destroy(catcher.gameObject);
// Hook up dismissal
catcher.OnClick.Subscribe(_ => Object.Destroy(modal.gameObject));
return modal;
}
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\ResolverComponent.cs
using UnityEngine;
namespace GameCult.Unity.UI.Components
{
public class ResolverComponent : MonoBehaviour
{
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\SliderField.cs
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Reflection;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace GameCult.Unity.UI.Components
{
public class SliderField : LayoutComponent, IFieldHandler
{
[SerializeField] private Slider? slider;
public int Priority => 100;
public bool CanHandle(Type type, PreferredInspectorAttribute? attr) =>
(type == typeof(float) && attr is InspectableRangedFloatAttribute) ||
(type == typeof(int) && attr is InspectableRangedIntAttribute);
public void ConfigureForMember(IUIContext context, object target, MemberInfo member, DisplayOptions? displayOptions = null)
{
var type = member switch
{
FieldInfo field => field.FieldType,
PropertyInfo property => property.PropertyType,
_ => null
};
if (type == null) return;
if (type == typeof(float))
{
Func<float> read;
Action<float> write;
switch (member)
{
case FieldInfo field:
read = () => (float)field.GetValue(target);
write = f => field.SetValue(target, f);
break;
case PropertyInfo property:
read = () => (float)property.GetValue(target);
write = f => property.SetValue(target, f);
break;
default:
return;
}
var range = member.GetCustomAttribute<InspectableRangedFloatAttribute>();
Configure(context, read, write, range.Min, range.Max, displayOptions);
}
else if (type == typeof(int))
{
Func<int> read;
Action<int> write;
switch (member)
{
case FieldInfo field:
read = () => (int)field.GetValue(target);
write = i => field.SetValue(target, i);
break;
case PropertyInfo property:
read = () => (int)property.GetValue(target);
write = i => property.SetValue(target, i);
break;
default:
return;
}
var range = member.GetCustomAttribute<InspectableRangedIntAttribute>();
Configure(context, read, write, range.Min, range.Max, displayOptions);
}
}
public SliderField Configure(IUIContext context, Func<float> read, Action<float> write, float min, float max, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (slider is null) return this;
slider.wholeNumbers = false;
slider.minValue = min;
slider.maxValue = max;
slider.value = read();
slider.onValueChanged.AddListener(f => write(f));
context.Refresh += () => slider.value = read();
this.ApplyLayoutOptions(displayOptions);
return this;
}
public SliderField Configure(IUIContext context, Func<int> read, Action<int> write, int min, int max, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if (slider is null) return this;
slider.wholeNumbers = true;
slider.minValue = min;
slider.maxValue = max;
slider.value = read();
slider.onValueChanged.AddListener(f => write(Mathf.RoundToInt(f)));
context.Refresh += () => slider.value = read();
this.ApplyLayoutOptions(displayOptions);
return this;
}
}
public static class RangedFloatFieldGeneratorExtension
{
public static void AddRangedFloatInspector(this IUIContext context,
string label,
Func<float> read,
Action<float> write,
float min,
float max,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max));
public static void AddRangedIntInspector(this IUIContext context,
string label,
Func<int> read,
Action<int> write,
int min,
int max,
string? prefabName = null) =>
context.Resolver.Resolve<HorizontalGroup>()?.Configure(context,
context.Resolver.Resolve<Label>()?.Configure(context, label),
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max));
public static void AddRangedFloatField(this IUIContext context,
Func<float> read,
Action<float> write,
float min,
float max,
DisplayOptions? displayOptions = null,
string? prefabName = null) =>
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max, displayOptions);
public static void AddRangedIntField(this IUIContext context,
Func<int> read,
Action<int> write,
int min,
int max,
DisplayOptions? displayOptions = null,
string? prefabName = null) =>
context.Resolver.Resolve<SliderField>(prefabName)?.Configure(context, read, write, min, max, displayOptions);
}
}
// E:\Projects\CultLib\src\GameCult.Unity\Assets\UI\Components\TextButton.cs
using System;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using static Unity.Mathematics.math;
namespace GameCult.Unity.UI.Components
{
public class TextButton : LayoutComponent, IPointerEnterHandler, IPointerExitHandler, ITextElement
{
[SerializeField] private Button? button;
[SerializeField] private TextMeshProUGUI? label;
[SerializeField] private float animationDuration;
[SerializeField] private float hoverSpacing;
private float _lerp;
private bool _hovering;
public TextMeshProUGUI? Text => label;
private void Update()
{
_lerp = saturate(_lerp + Time.deltaTime / animationDuration * (button is { interactable: true } && _hovering ? 1 : -1));
if (label is not null) label.characterSpacing = hoverSpacing * _lerp;
}
public void OnPointerEnter(PointerEventData eventData)
{
_hovering = true;
}
public void OnPointerExit(PointerEventData eventData)
{
_hovering = false;
}
public TextButton Configure(IUIContext context, string labelText, Action onClick, DisplayOptions? displayOptions = null)
{
if(displayOptions is not { PlaceInContext: true })
transform.SetParent(context.ContentRoot, false);
context.Register(gameObject);
if(button is null || label is null) return this;
label.text = labelText;
button.onClick.AddListener(() => onClick());
button.interactable = displayOptions?.Interactable??true;
this.ApplyLayoutOptions(displayOptions);
this.ApplyTextOptions(displayOptions);
return this;
}
}
public static class TextButtonGeneratorExtensions
{
public static void AddTextButton(this IUIContext context, string label, Action onClick, DisplayOptions? displayOptions = null) =>
context.Resolver.Resolve<TextButton>()?.Configure(context, label, onClick, displayOptions);
}
}