// Animancer // https://kybernetik.com.au/animancer // Copyright 2018-2024 Kybernetik // #if UNITY_EDITOR && UNITY_IMGUI using Animancer.Editor; using System; using UnityEditor; using UnityEngine; using static Animancer.Editor.AnimancerGUI; namespace Animancer.Units.Editor { /// [Editor-Only] A for fields with a . /// https://kybernetik.com.au/animancer/api/Animancer.Units.Editor/UnitsAttributeDrawer [CustomPropertyDrawer(typeof(UnitsAttribute), true)] public class UnitsAttributeDrawer : PropertyDrawer { /************************************************************************************************************************/ /// The attribute on the field being drawn. public UnitsAttribute Attribute { get; private set; } /// The converters used to generate display strings for each of the fields. public CompactUnitConversionCache[] DisplayConverters { get; private set; } /************************************************************************************************************************/ /// Gathers the and sets up the . public void Initialize() => Initialize(attribute); /// Gathers the and sets up the . public void Initialize(Attribute attribute) { if (Attribute != null) return; Attribute = (UnitsAttribute)attribute; var suffixes = Attribute.Suffixes; DisplayConverters = new CompactUnitConversionCache[suffixes.Length]; for (int i = 0; i < suffixes.Length; i++) DisplayConverters[i] = new(suffixes[i]); } /************************************************************************************************************************/ /// public override float GetPropertyHeight(SerializedProperty property, GUIContent label) { var lineCount = GetLineCount(property, label); return LineHeight * lineCount + StandardSpacing * (lineCount - 1); } /// Determines how many lines tall the `property` should be. protected virtual int GetLineCount(SerializedProperty property, GUIContent label) => EditorGUIUtility.wideMode ? 1 : 2; /************************************************************************************************************************/ /// Begins a GUI property block to be ended by . protected static void BeginProperty( Rect area, SerializedProperty property, ref GUIContent label, out float value) { label = EditorGUI.BeginProperty(area, label, property); EditorGUI.BeginChangeCheck(); value = property.floatValue; } /// Ends a GUI property block started by . protected static void EndProperty( Rect area, SerializedProperty property, ref float value) { if (TryUseClickEvent(area, 2)) DefaultValues.SetToDefault(ref value, property); if (EditorGUI.EndChangeCheck()) property.floatValue = value; EditorGUI.EndProperty(); } /************************************************************************************************************************/ /// Draws this attribute's fields for the `property`. public override void OnGUI(Rect area, SerializedProperty property, GUIContent label) { Initialize(); BeginProperty(area, property, ref label, out var value); DoFieldGUI(area, label, ref value); EndProperty(area, property, ref value); } /************************************************************************************************************************/ private static readonly int TextFieldHash = "EditorTextField".GetHashCode(); /// Draws this attribute's fields. public void DoFieldGUI(Rect area, GUIContent label, ref float value) { var isMultiLine = area.height >= LineHeight * 2; area.height = LineHeight; DoOptionalBeforeGUI( Attribute.IsOptional, area, out var toggleArea, out var guiWasEnabled, out var previousLabelWidth); var hasLabel = label != null && !string.IsNullOrEmpty(label.text); Rect allFieldArea; if (isMultiLine) { EditorGUI.LabelField(area, label); label = null; NextVerticalArea(ref area); EditorGUI.indentLevel++; allFieldArea = EditorGUI.IndentedRect(area); EditorGUI.indentLevel--; } else if (hasLabel) { var labelXMax = area.x + EditorGUIUtility.labelWidth; allFieldArea = new(labelXMax, area.y, area.xMax - labelXMax, area.height); } else { allFieldArea = area; } CountActiveFields(out var count, out var last); var currentEvent = Event.current; var beforeControlID = GUIUtility.GetControlID(TextFieldHash, FocusType.Passive, area); if (float.IsNaN(value) && Attribute.DisabledText is not null && currentEvent.type == EventType.Repaint && !area.Contains(currentEvent.mousePosition) && !HasKeyboardControl(beforeControlID, beforeControlID + count)) { var dragArea = area; dragArea.width = EditorGUIUtility.labelWidth; EditorGUIUtility.AddCursorRect(dragArea, MouseCursor.SlideArrow); label ??= GUIContent.none; EditorGUI.TextField(area, label, Attribute.DisabledText); for (int i = 1; i < count; i++) GUIUtility.GetControlID(TextFieldHash, FocusType.Keyboard, area); } else { var width = (allFieldArea.width - StandardSpacing * (count - 1)) / count; var fieldArea = new Rect(allFieldArea.x, allFieldArea.y, width, allFieldArea.height); var displayValue = GetDisplayValue(value, Attribute.DefaultValue); // Draw the active fields. for (int i = 0; i < Attribute.Multipliers.Length; i++) { var multiplier = Attribute.Multipliers[i]; if (float.IsNaN(multiplier)) continue; if (hasLabel) { fieldArea.xMin = area.xMin; } else if (i < last) { fieldArea.width = width; fieldArea.xMax = AnimancerUtilities.Round(fieldArea.xMax); } else { fieldArea.xMax = area.xMax; } EditorGUI.BeginChangeCheck(); var fieldValue = displayValue * multiplier; fieldValue = DoSpecialFloatField(fieldArea, label, fieldValue, DisplayConverters[i]); label = null; hasLabel = false; if (EditorGUI.EndChangeCheck()) value = fieldValue / multiplier; fieldArea.x += fieldArea.width + StandardSpacing; } } DoOptionalAfterGUI( Attribute.IsOptional, toggleArea, ref value, Attribute.DefaultValue, guiWasEnabled, previousLabelWidth); Validate.ValueRule(ref value, Attribute.Rule); } /************************************************************************************************************************/ /// Counts the number of active . private void CountActiveFields(out int count, out int last) { count = 0; last = 0; for (int i = 0; i < Attribute.Multipliers.Length; i++) { if (!float.IsNaN(Attribute.Multipliers[i])) { count++; last = i; } } } /************************************************************************************************************************/ /// Is the in the specified range (inclusive)? private static bool HasKeyboardControl(int minControlID, int maxControlID) { var keyboardControl = GUIUtility.keyboardControl; return keyboardControl >= minControlID && keyboardControl <= maxControlID; } /************************************************************************************************************************/ /// /// Draws a with an alternate string /// when it's not selected (for example, "1" might display as "1s" to indicate "seconds"). /// /// /// This method treats most s normally, /// but for it instead draws a text field with the converted string. /// public static float DoSpecialFloatField( Rect area, GUIContent label, float value, CompactUnitConversionCache toString) { if (label != null && !string.IsNullOrEmpty(label.text)) { if (Event.current.type != EventType.Repaint) return EditorGUI.FloatField(area, label, value); var dragArea = new Rect(area.x, area.y, EditorGUIUtility.labelWidth, area.height); EditorGUIUtility.AddCursorRect(dragArea, MouseCursor.SlideArrow); var text = toString.Convert(value, area.width - EditorGUIUtility.labelWidth); EditorGUI.TextField(area, label, text); } else { var indentLevel = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; if (Event.current.type != EventType.Repaint) value = EditorGUI.FloatField(area, value); else EditorGUI.TextField(area, toString.Convert(value, area.width)); EditorGUI.indentLevel = indentLevel; } return value; } /************************************************************************************************************************/ /// Prepares the details for drawing a toggle to set the field to . /// Call this before drawing the field then call after it. public void DoOptionalBeforeGUI( bool isOptional, Rect area, out Rect toggleArea, out bool guiWasEnabled, out float previousLabelWidth) { toggleArea = area; guiWasEnabled = GUI.enabled; previousLabelWidth = EditorGUIUtility.labelWidth; if (!isOptional) return; toggleArea.x += previousLabelWidth; toggleArea.width = ToggleWidth; EditorGUIUtility.labelWidth += toggleArea.width; EditorGUIUtility.AddCursorRect(toggleArea, MouseCursor.Arrow); // We need to draw the toggle after everything else to it goes on top of the label. But we want it to // get priority for input events, so we disable the other controls during those events in its area. var currentEvent = Event.current; if (guiWasEnabled && toggleArea.Contains(currentEvent.mousePosition)) { switch (currentEvent.type) { case EventType.Repaint: case EventType.Layout: break; default: GUI.enabled = false; break; } } } /************************************************************************************************************************/ /// Draws a toggle to set the `value` to when disabled. public void DoOptionalAfterGUI( bool isOptional, Rect area, ref float value, float defaultValue, bool guiWasEnabled, float previousLabelWidth) { GUI.enabled = guiWasEnabled; EditorGUIUtility.labelWidth = previousLabelWidth; if (!isOptional) return; area.x += StandardSpacing; var wasEnabled = !float.IsNaN(value); // Use the EditorGUI method instead to properly handle EditorGUI.showMixedValue. //var isEnabled = GUI.Toggle(area, wasEnabled, GUIContent.none); var indentLevel = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; var isEnabled = EditorGUI.Toggle(area, wasEnabled); EditorGUI.indentLevel = indentLevel; if (isEnabled != wasEnabled) { value = isEnabled ? defaultValue : float.NaN; Deselect(); } } /************************************************************************************************************************/ /// Returns the value that should be displayed for a given field. public static float GetDisplayValue(float value, float defaultValue) => float.IsNaN(value) ? defaultValue : value; /************************************************************************************************************************/ } } #endif