// Animancer // Copyright 2020 Kybernetik // #if UNITY_EDITOR using System; using System.Collections; using UnityEditor; using UnityEngine; namespace Animancer.Editor { /// <summary>[Editor-Only] Various GUI utilities used throughout Animancer.</summary> public static class AnimancerGUI { /************************************************************************************************************************/ #region Standard Values /************************************************************************************************************************/ /// <summary>The highlight color used for fields showing a warning.</summary> public static readonly Color WarningFieldColor = new Color(1, 0.9f, 0.6f); /// <summary>The highlight color used for fields showing an error.</summary> public static readonly Color ErrorFieldColor = new Color(1, 0.6f, 0.6f); /************************************************************************************************************************/ /// <summary><see cref="GUILayout.ExpandWidth"/> set to false.</summary> public static readonly GUILayoutOption[] DontExpandWidth = { GUILayout.ExpandWidth(false) }; /************************************************************************************************************************/ /// <summary>Wrapper around <see cref="EditorGUIUtility.singleLineHeight"/>.</summary> public static float LineHeight { get { return EditorGUIUtility.singleLineHeight; } } /************************************************************************************************************************/ /// <summary>Wrapper around <see cref="EditorGUIUtility.standardVerticalSpacing"/>.</summary> public static float StandardSpacing { get { return EditorGUIUtility.standardVerticalSpacing; } } /************************************************************************************************************************/ private static float _IndentSize = -1; /// <summary> /// The number of pixels of indentation for each <see cref="EditorGUI.indentLevel"/> increment. /// </summary> public static float IndentSize { get { if (_IndentSize < 0) { var indentLevel = EditorGUI.indentLevel; EditorGUI.indentLevel = 1; _IndentSize = EditorGUI.IndentedRect(new Rect()).x; EditorGUI.indentLevel = indentLevel; } return _IndentSize; } } /************************************************************************************************************************/ private static float _ToggleWidth = -1; /// <summary>The width of a standard <see cref="GUISkin.toggle"/> with no label.</summary> public static float ToggleWidth { get { if (_ToggleWidth == -1) _ToggleWidth = GUI.skin.toggle.CalculateWidth(GUIContent.none); return _ToggleWidth; } } /************************************************************************************************************************/ /// <summary>The color of the standard label text.</summary> public static Color TextColor { get { return GUI.skin.label.normal.textColor; } } /************************************************************************************************************************/ /// <summary> /// A more compact <see cref="EditorStyles.miniButton"/> with a fixed size as a tiny box. /// </summary> public static readonly GUIStyle MiniButton = new GUIStyle(EditorStyles.miniButton) { margin = new RectOffset(0, 0, 2, 0), padding = new RectOffset(2, 3, 2, 2), alignment = TextAnchor.MiddleCenter, fixedHeight = LineHeight, fixedWidth = LineHeight - 1 }; /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Layout /************************************************************************************************************************/ /// <summary>Indicates where <see cref="LayoutSingleLineRect"/> should add the <see cref="StandardSpacing"/>.</summary> public enum SpacingMode { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member. None, Before, After, BeforeAndAfter #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member. } /// <summary> /// Uses <see cref="GUILayoutUtility.GetRect(float, float)"/> to get a <see cref="Rect"/> occupying a single /// standard line with the <see cref="StandardSpacing"/> added according to the specified `spacing`. /// </summary> public static Rect LayoutSingleLineRect(SpacingMode spacing = SpacingMode.None) { Rect rect; switch (spacing) { case SpacingMode.None: return GUILayoutUtility.GetRect(0, LineHeight); case SpacingMode.Before: rect = GUILayoutUtility.GetRect(0, LineHeight + StandardSpacing); rect.yMin += StandardSpacing; return rect; case SpacingMode.After: rect = GUILayoutUtility.GetRect(0, LineHeight + StandardSpacing); rect.yMax -= StandardSpacing; return rect; case SpacingMode.BeforeAndAfter: rect = GUILayoutUtility.GetRect(0, LineHeight + StandardSpacing * 2); rect.yMin += StandardSpacing; rect.yMax -= StandardSpacing; return rect; default: throw new ArgumentException("Unknown StandardSpacing: " + spacing, "spacing"); } } /************************************************************************************************************************/ /// <summary> /// If the <see cref="Rect.height"/> is positive, this method moves the <see cref="Rect.y"/> by that amount and /// adds the <see cref="EditorGUIUtility.standardVerticalSpacing"/>. /// </summary> public static void NextVerticalArea(ref Rect area) { if (area.height > 0) area.y += area.height + StandardSpacing; } /************************************************************************************************************************/ /// <summary> /// Subtracts the `width` from the left side of the `area` and returns a new <see cref="Rect"/> occupying the /// removed section. /// </summary> public static Rect StealFromLeft(ref Rect area, float width, float padding = 0) { var newRect = new Rect(area.x, area.y, width, area.height); area.xMin += width + padding; return newRect; } /// <summary> /// Subtracts the `width` from the right side of the `area` and returns a new <see cref="Rect"/> occupying the /// removed section. /// </summary> public static Rect StealFromRight(ref Rect area, float width, float padding = 0) { area.width -= width + padding; return new Rect(area.xMax + padding, area.y, width, area.height); } /************************************************************************************************************************/ /// <summary> /// Divides the given `area` such that the fields associated with both labels will have equal space /// remaining after the labels themselves. /// </summary> public static void SplitHorizontally(Rect area, string label0, string label1, out float width0, out float width1, out Rect rect0, out Rect rect1) { width0 = CalculateLabelWidth(label0); width1 = CalculateLabelWidth(label1); const float Padding = 1; rect0 = rect1 = area; var remainingWidth = area.width - width0 - width1 - Padding; rect0.width = width0 + remainingWidth * 0.5f; rect1.xMin = rect0.xMax + Padding; } /************************************************************************************************************************/ /// <summary> /// Calls <see cref="GUIStyle.CalcMinMaxWidth"/> and returns the max width. /// </summary> public static float CalculateWidth(this GUIStyle style, GUIContent content) { float _, width; style.CalcMinMaxWidth(content, out _, out width); return width; } /// <summary> /// Calls <see cref="GUIStyle.CalcMinMaxWidth"/> and returns the max width. /// <para></para> /// This method uses the <see cref="TempContent(string, string, bool)"/>. /// </summary> public static float CalculateWidth(this GUIStyle style, string content) { return style.CalculateWidth(TempContent(content)); } /************************************************************************************************************************/ /// <summary> /// Creates a <see cref="ConversionCache{TKey, TValue}"/> for calculating the GUI width occupied by text using the /// specified `style`. /// </summary> public static ConversionCache<string, float> CreateWidthCache(GUIStyle style) { return new ConversionCache<string, float>((text) => style.CalculateWidth(text)); } /************************************************************************************************************************/ private static ConversionCache<string, float> _LabelWidthCache; /// <summary> /// Calls <see cref="GUIStyle.CalcMinMaxWidth"/> using <see cref="GUISkin.label"/> and returns the max /// width. The result is cached for efficient reuse. /// <para></para> /// This method uses the <see cref="TempContent(string, string, bool)"/>. /// </summary> public static float CalculateLabelWidth(string text) { if (_LabelWidthCache == null) _LabelWidthCache = CreateWidthCache(GUI.skin.label); return _LabelWidthCache.Convert(text); } /************************************************************************************************************************/ /// <summary> /// Begins a vertical layout group using the given style and decreases the /// <see cref="EditorGUIUtility.labelWidth"/> to compensate for the indentation. /// </summary> public static void BeginVerticalBox(GUIStyle style) { if (style == null) { GUILayout.BeginVertical(); return; } GUILayout.BeginVertical(style); EditorGUIUtility.labelWidth -= style.padding.left; } /// <summary> /// Ends a layout group started by <see cref="BeginVerticalBox"/> and restores the /// <see cref="EditorGUIUtility.labelWidth"/>. /// </summary> public static void EndVerticalBox(GUIStyle style) { if (style != null) EditorGUIUtility.labelWidth += style.padding.left; GUILayout.EndVertical(); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Labels /************************************************************************************************************************/ /// <summary>Used by <see cref="TempContent(string, string, bool)"/>.</summary> private static GUIContent _TempContent; /// <summary> /// Returns a <see cref="GUIContent"/> with the specified parameters. The same instance is returned by /// every subsequent call. /// </summary> public static GUIContent TempContent(string text = null, string tooltip = null, bool narrowText = true) { if (_TempContent == null) _TempContent = new GUIContent(); if (narrowText) text = GetNarrowText(text); _TempContent.text = text; _TempContent.tooltip = tooltip; return _TempContent; } /// <summary> /// Returns a <see cref="GUIContent"/> with the <see cref="SerializedProperty.displayName"/> and /// <see cref="SerializedProperty.tooltip"/>. The same instance is returned by every subsequent call. /// </summary> public static GUIContent TempContent(SerializedProperty property, bool narrowText = true) { return TempContent(property.displayName, property.tooltip, narrowText); } /************************************************************************************************************************/ private static ConversionCache<float, string> _F1Cache; private static GUIStyle _WeightLabelStyle; private static float _WeightValueWidth; /// <summary> /// Draws a label showing the `weight` aligned to the right side of the `area` and reduces its /// <see cref="Rect.width"/> to remove that label from its area. /// </summary> public static void DoWeightLabel(ref Rect area, float weight) { if (_F1Cache == null) { _F1Cache = new ConversionCache<float, string>((value) => value.ToString("F1")); _WeightLabelStyle = new GUIStyle(GUI.skin.label); _WeightValueWidth = _WeightLabelStyle.CalculateWidth("0.0"); } var weightArea = StealFromRight(ref area, _WeightValueWidth); var label = _F1Cache.Convert(weight); _WeightLabelStyle.normal.textColor = Color.Lerp(Color.grey, TextColor, weight); _WeightLabelStyle.fontStyle = Mathf.Approximately(weight * 10, (int)(weight * 10)) ? FontStyle.Normal : FontStyle.Italic; GUI.Label(weightArea, label, _WeightLabelStyle); } /************************************************************************************************************************/ /// <summary>The <see cref="EditorGUIUtility.labelWidth"/> from before <see cref="BeginTightLabel"/>.</summary> private static float _TightLabelWidth; /// <summary>Stores the <see cref="EditorGUIUtility.labelWidth"/> and changes it to the exact width of the `label`.</summary> public static string BeginTightLabel(string label) { _TightLabelWidth = EditorGUIUtility.labelWidth; EditorGUIUtility.labelWidth = CalculateLabelWidth(label) + EditorGUI.indentLevel * IndentSize; return GetNarrowText(label); } /// <summary>Reverts <see cref="EditorGUIUtility.labelWidth"/> to its previous value.</summary> public static void EndTightLabel() { EditorGUIUtility.labelWidth = _TightLabelWidth; } /************************************************************************************************************************/ private static ConversionCache<string, string> _NarrowTextCache; /// <summary> /// Returns the `text` without any spaces if <see cref="EditorGUIUtility.wideMode"/> is false. /// Otherwise simply returns the `text` without any changes. /// </summary> public static string GetNarrowText(string text) { if (EditorGUIUtility.wideMode || string.IsNullOrEmpty(text)) return text; if (_NarrowTextCache == null) _NarrowTextCache = new ConversionCache<string, string>((str) => str.Replace(" ", "")); return _NarrowTextCache.Convert(text); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Events /************************************************************************************************************************/ /// <summary> /// Returns true and uses the current event if it is <see cref="EventType.MouseUp"/> inside the specified /// `area`. /// </summary> public static bool TryUseClickEvent(Rect area, int button = -1) { var currentEvent = Event.current; if (currentEvent.type == EventType.MouseUp && (button < 0 || currentEvent.button == button) && area.Contains(currentEvent.mousePosition)) { GUI.changed = true; currentEvent.Use(); return true; } else return false; } /// <summary> /// Returns true and uses the current event if it is <see cref="EventType.MouseUp"/> inside the last GUI Layout /// <see cref="Rect"/> that was drawn. /// </summary> public static bool TryUseClickEventInLastRect(int button = -1) { return TryUseClickEvent(GUILayoutUtility.GetLastRect(), button); } /************************************************************************************************************************/ /// <summary> /// Invokes `onDrop` if the <see cref="Event.current"/> is a drag and drop event inside the `dropArea`. /// </summary> public static void HandleDragAndDrop<T>(Rect dropArea, Func<T, bool> validate, Action<T> onDrop) where T : class { if (!dropArea.Contains(Event.current.mousePosition)) return; bool isDrop; switch (Event.current.type) { case EventType.DragUpdated: isDrop = false; break; case EventType.DragPerform: isDrop = true; break; default: return; } var dragging = DragAndDrop.objectReferences; TryDrop(dragging, validate, onDrop, isDrop); } /************************************************************************************************************************/ /// <summary> /// Updates the <see cref="DragAndDrop.visualMode"/> of calls `onDrop` for each of the `objects`. /// </summary> private static void TryDrop<T>(IEnumerable objects, Func<T, bool> validate, Action<T> onDrop, bool isDrop) where T : class { if (objects == null) return; var droppedAny = false; foreach (var obj in objects) { var t = obj as T; if (t != null && (validate == null || validate(t))) { if (!isDrop) { DragAndDrop.visualMode = DragAndDropVisualMode.Copy; break; } else { onDrop(t); droppedAny = true; } } } if (droppedAny) GUIUtility.ExitGUI(); } /************************************************************************************************************************/ /// <summary> /// Uses <see cref="HandleDragAndDrop"/> to deal with drag and drop operations involving /// <see cref="AnimationClip"/>s of <see cref="IAnimationClipSource"/>s. /// </summary> public static void HandleDragAndDropAnimations(Rect dropArea, Action<AnimationClip> onDrop) { HandleDragAndDrop(dropArea, (clip) => !clip.legacy, onDrop); HandleDragAndDrop<IAnimationClipSource>(dropArea, null, (source) => { var clips = ObjectPool.AcquireList<AnimationClip>(); source.GetAnimationClips(clips); TryDrop(clips, (clip) => !clip.legacy, onDrop, true); ObjectPool.Release(clips); }); } /************************************************************************************************************************/ /// <summary>Deselects any selected IMGUI control.</summary> public static void Deselect() { GUIUtility.keyboardControl = 0; } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Fields /************************************************************************************************************************/ /// <summary> /// Draw a <see cref="EditorGUI.FloatField(Rect, GUIContent, float)"/> with an alternate cached string when it /// is not selected (for example, "1" might become "1s" to indicate "seconds"). /// </summary> public static float DoSpecialFloatField(Rect area, GUIContent label, float value, ConversionCache<float, string> toString) { // Treat most events normally, but when repainting show a text field with the cached string. if (label != null) { 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); EditorGUI.TextField(area, label, toString.Convert(value)); } 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)); EditorGUI.indentLevel = indentLevel; } return value; } /// <summary> /// Draw a <see cref="EditorGUI.FloatField(Rect, GUIContent, float)"/> with an alternate cached string when it /// is not selected (for example, "1" might become "1s" to indicate "seconds"). /// </summary> public static void DoFloatFieldWithSuffix(Rect area, GUIContent label, SerializedProperty property, ConversionCache<float, string> toString) { label = EditorGUI.BeginProperty(area, label, property); EditorGUI.BeginChangeCheck(); var value = DoSpecialFloatField(area, label, property.floatValue, toString); if (EditorGUI.EndChangeCheck()) property.floatValue = value; EditorGUI.EndProperty(); } /************************************************************************************************************************/ /// <summary> /// Draw a <see cref="GUI.Toggle(Rect, bool, GUIContent)"/> which sets the value to <see cref="float.NaN"/> /// when disabled followed by two float fields to display the <see cref="SerializedProperty.floatValue"/> as /// both normalized time and seconds. /// </summary> public static void DoOptionalTimeField(ref Rect area, GUIContent label, SerializedProperty property, bool timeIsNormalized, float length, float defaultValue = 0, bool isOptional = true) { label = EditorGUI.BeginProperty(area, label, property); EditorGUI.BeginChangeCheck(); var value = DoOptionalTimeField(ref area, label, property.floatValue, timeIsNormalized, length, defaultValue, isOptional); if (EditorGUI.EndChangeCheck()) property.floatValue = value; EditorGUI.EndProperty(); } /************************************************************************************************************************/ private static ConversionCache<float, string> _XSuffixCache, _SSuffixCache; /// <summary> /// Draw a <see cref="GUI.Toggle(Rect, bool, GUIContent)"/> which sets the value to <see cref="float.NaN"/> /// when disabled followed by two float fields to display the `time` both normalized and in seconds. /// </summary> public static float DoOptionalTimeField(ref Rect area, GUIContent label, float time, bool timeIsNormalized, float length, float defaultValue = 0, bool isOptional = true) { if (_XSuffixCache == null) { _XSuffixCache = new ConversionCache<float, string>((x) => x + "x"); _SSuffixCache = new ConversionCache<float, string>((s) => s + "s"); } area.height = LineHeight; bool showNormalized, showSeconds; if (length > 0) { showNormalized = showSeconds = true; } else { showNormalized = timeIsNormalized; showSeconds = !timeIsNormalized; } var labelWidth = EditorGUIUtility.labelWidth; var enabled = GUI.enabled; var toggleArea = area; if (isOptional) { toggleArea.x += EditorGUIUtility.labelWidth; 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 (enabled && toggleArea.Contains(currentEvent.mousePosition)) { switch (currentEvent.type) { case EventType.Repaint: case EventType.Layout: break; default: GUI.enabled = false; break; } } } else if (float.IsNaN(time)) { time = defaultValue; } var displayTime = float.IsNaN(time) ? defaultValue : time; var normalizedArea = area; var secondsArea = area; if (showNormalized) { if (showSeconds) { var split = (EditorGUIUtility.labelWidth + normalizedArea.xMax - StandardSpacing) * 0.5f; normalizedArea.xMax = split; secondsArea.xMin = split + StandardSpacing; } var normalizedTime = timeIsNormalized ? displayTime : displayTime / length; EditorGUI.BeginChangeCheck(); normalizedTime = DoSpecialFloatField(normalizedArea, label, normalizedTime, _XSuffixCache); if (EditorGUI.EndChangeCheck()) time = timeIsNormalized ? normalizedTime : normalizedTime * length; } EditorGUIUtility.labelWidth = labelWidth; if (showSeconds) { var rawTime = timeIsNormalized ? displayTime * length : displayTime; if (showNormalized) label = null; EditorGUI.BeginChangeCheck(); rawTime = DoSpecialFloatField(secondsArea, label, rawTime, _SSuffixCache); if (EditorGUI.EndChangeCheck()) { if (timeIsNormalized) { if (length != 0) time = rawTime / length; } else { time = rawTime; } } } GUI.enabled = enabled; if (isOptional) DoOptionalTimeToggle(toggleArea, ref time, defaultValue); return time; } /************************************************************************************************************************/ private static void DoOptionalTimeToggle(Rect area, ref float time, float defaultValue) { #if UNITY_2019_3_OR_NEWER area.x += 2; #endif var wasEnabled = !float.IsNaN(time); var isEnabled = GUI.Toggle(area, wasEnabled, GUIContent.none); if (isEnabled != wasEnabled) { time = isEnabled ? defaultValue : float.NaN; Deselect(); } } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ } } #endif