// Animancer // https://kybernetik.com.au/animancer // Copyright 2018-2024 Kybernetik // #if UNITY_EDITOR using System; using System.Collections.Generic; using UnityEditor; using UnityEngine; using Object = UnityEngine.Object; namespace Animancer.Editor { /// [Editor-Only] Various GUI utilities used throughout Animancer. /// https://kybernetik.com.au/animancer/api/Animancer.Editor/AnimancerGUI public static partial class AnimancerGUI { /************************************************************************************************************************/ #region Standard Values /************************************************************************************************************************/ /// The highlight color used for fields showing a warning. public static readonly Color WarningFieldColor = new(1, 0.9f, 0.6f); /// The highlight color used for fields showing an error. public static readonly Color ErrorFieldColor = new(1, 0.6f, 0.6f); /// Returns a color with uniform Red, Green, and Blue values. public static Color Grey(float rgb, float alpha = 1) => new(rgb, rgb, rgb, alpha); /************************************************************************************************************************/ /// set to false. public static readonly GUILayoutOption[] DontExpandWidth = { GUILayout.ExpandWidth(false) }; /************************************************************************************************************************/ /// Returns . public static float LineHeight => EditorGUIUtility.singleLineHeight; /// /// Calculates the number of vertical pixels required to draw the specified `lineCount` using the /// and . /// public static float CalculateHeight(int lineCount) => lineCount <= 0 ? 0 : LineHeight * lineCount + StandardSpacing * (lineCount - 1); /************************************************************************************************************************/ /// Returns . public static float StandardSpacing => EditorGUIUtility.standardVerticalSpacing; /************************************************************************************************************************/ private static float _IndentSize = float.NaN; /// /// The number of pixels of indentation for each increment. /// public static float IndentSize { get { if (float.IsNaN(_IndentSize)) { var indentLevel = EditorGUI.indentLevel; EditorGUI.indentLevel = 1; _IndentSize = EditorGUI.IndentedRect(default).x; EditorGUI.indentLevel = indentLevel; } return _IndentSize; } } /************************************************************************************************************************/ private static float _ToggleWidth = -1; /// The width of a standard with no label. public static float ToggleWidth { get { if (_ToggleWidth == -1) _ToggleWidth = GUI.skin.toggle.CalculateWidth(GUIContent.none); return _ToggleWidth; } } /************************************************************************************************************************/ /// The color of the standard label text. public static Color TextColor => GUI.skin.label.normal.textColor; /************************************************************************************************************************/ private static GUIStyle _MiniButtonStyle; /// A more compact with a fixed size as a tiny box. public static GUIStyle MiniButtonStyle => _MiniButtonStyle ??= new(EditorStyles.miniButton) { margin = new(0, 0, 2, 0), padding = new(2, 3, 2, 2), alignment = TextAnchor.MiddleCenter, fixedHeight = LineHeight, fixedWidth = LineHeight - 1, }; private static GUIStyle _NoPaddingButtonStyle; /// with no . public static GUIStyle NoPaddingButtonStyle => _NoPaddingButtonStyle ??= new(MiniButtonStyle) { padding = new(), fixedWidth = LineHeight, }; /************************************************************************************************************************/ private static GUIStyle _RightLabelStyle; /// using . public static GUIStyle RightLabelStyle => _RightLabelStyle ??= new(EditorStyles.label) { alignment = TextAnchor.MiddleRight, }; /************************************************************************************************************************/ private static GUIStyle _MiniButtonNoPadding; /// A more compact with no padding for its content. public static GUIStyle MiniButtonNoPadding { get { _MiniButtonNoPadding ??= new(EditorStyles.miniButton) { padding = new(), overflow = new(), }; return _MiniButtonNoPadding; } } /************************************************************************************************************************/ /// Constants used by . /// Key combinations are listed for Windows. Other platforms may differ. public static class Commands { /************************************************************************************************************************/ /// public const string SoftDelete = "SoftDelete"; /// + public const string Delete = "Delete"; /// + public const string Copy = "Copy"; /// + public const string Cut = "Cut"; /// + public const string Paste = "Paste"; /// + public const string Duplicate = "Duplicate"; /// + public const string SelectAll = "SelectAll"; /// public const string FrameSelected = "FrameSelected"; /// + public const string FrameSelectedWithLock = "FrameSelectedWithLock"; /// + public const string Find = "Find"; /************************************************************************************************************************/ } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Layout /************************************************************************************************************************/ /// The offset currently applied to the GUI by . public static Vector2 GuiOffset { get; set; } /************************************************************************************************************************/ /// Calls . public static void RepaintEverything() => UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); /************************************************************************************************************************/ /// public static Rect LayoutRect(float height) => GUILayoutUtility.GetRect(0, height); /// public static Rect LayoutRect(float height, GUIStyle style) => GUILayoutUtility.GetRect(0, height, style); /************************************************************************************************************************/ /// Indicates where should add the . public enum SpacingMode { /// No extra space. None, /// Add extra space before the new area. Before, /// Add extra space after the new area. After, /// Add extra space before and after the new area. BeforeAndAfter } /// /// Uses to get a with the specified /// `height` and the added according to the specified `spacing`. /// public static Rect LayoutRect(float height, SpacingMode spacing) { Rect rect; switch (spacing) { case SpacingMode.None: return LayoutRect(height); case SpacingMode.Before: rect = LayoutRect(height + StandardSpacing); rect.yMin += StandardSpacing; return rect; case SpacingMode.After: rect = LayoutRect(height + StandardSpacing); rect.height -= StandardSpacing; return rect; case SpacingMode.BeforeAndAfter: rect = LayoutRect(height + StandardSpacing * 2); rect.yMin += StandardSpacing; rect.height -= StandardSpacing; return rect; default: throw new ArgumentException($"Unsupported {nameof(StandardSpacing)}: " + spacing, nameof(spacing)); } } /// /// Uses to get a occupying a single /// standard line with the added according to the specified `spacing`. /// public static Rect LayoutSingleLineRect(SpacingMode spacing = SpacingMode.None) => LayoutRect(LineHeight, spacing); /************************************************************************************************************************/ /// /// If the is positive, this method moves the by that amount and /// adds the . /// public static void NextVerticalArea(ref Rect area) { if (area.height > 0) area.y += area.height + StandardSpacing; } /************************************************************************************************************************/ /// /// Subtracts the `width` from the left side of the `area` /// and returns a new occupying the removed section. /// 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; } /// /// Subtracts the `width` from the right side of the `area` /// and returns a new occupying the removed section. /// public static Rect StealFromRight(ref Rect area, float width, float padding = 0) { area.width -= width + padding; return new(area.xMax + padding, area.y, width, area.height); } /// /// Subtracts the `height` from the top side of the `area` /// and returns a new occupying the removed section. /// public static Rect StealFromTop(ref Rect area, float height, float padding = 0) { var newRect = new Rect(area.x, area.y, area.width, height); area.yMin += height + padding; return newRect; } /************************************************************************************************************************/ /// /// Subtracts the from the top side of the `area` /// and returns a new occupying the removed section. /// public static Rect StealLineFromTop(ref Rect area) => StealFromTop(ref area, LineHeight, StandardSpacing); /************************************************************************************************************************/ /// /// Returns a copy of the `rect` expanded by the specified `amount` /// (or contracted if negative). /// public static Rect Expand(this Rect rect, float amount) => new( rect.x - amount, rect.y - amount, rect.width + amount * 2, rect.height + amount * 2); /// /// Returns a copy of the `rect` expanded by the specified amounts /// on each axis (or contracted if negative). /// public static Rect Expand(this Rect rect, float x, float y) => new( rect.x - x, rect.y - y, rect.width + x * 2, rect.height + y * 2); /************************************************************************************************************************/ /// Returns a copy of the `rect` expanded to include the `other`. public static Rect Encapsulate(this Rect rect, Rect other) => Rect.MinMaxRect( Math.Min(rect.xMin, other.xMin), Math.Min(rect.yMin, other.yMin), Math.Max(rect.xMax, other.xMax), Math.Max(rect.yMax, other.yMax)); /************************************************************************************************************************/ /// /// Divides the given `area` such that the fields associated with both labels will have equal space /// remaining after the labels themselves. /// 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; } /************************************************************************************************************************/ /// [Animancer Extension] Calls and returns the max width. public static float CalculateWidth(this GUIStyle style, GUIContent content) { style.CalcMinMaxWidth(content, out _, out var width); return Mathf.Ceil(width); } /// [Animancer Extension] Calls and returns the max width. public static float CalculateWidth(this GUIStyle style, string text) { using (var content = PooledGUIContent.Acquire(text)) return style.CalculateWidth(content); } /************************************************************************************************************************/ private static ConversionCache _LabelWidthCache; /// /// Calls using and returns the max /// width. The result is cached for efficient reuse. /// public static float CalculateLabelWidth(string text) { _LabelWidthCache ??= ConversionCache.CreateWidthCache(GUI.skin.label); return _LabelWidthCache.Convert(text); } /************************************************************************************************************************/ private static string[] _IntToStringCache; /// Caches and returns if 0 <= value < 100. public static string ToStringCached(this int value) { const int CacheSize = 100; if (value < 0 || value >= CacheSize) return value.ToString(); if (_IntToStringCache == null) { _IntToStringCache = new string[CacheSize]; for (int i = 0; i < _IntToStringCache.Length; i++) _IntToStringCache[i] = i.ToString(); } return _IntToStringCache[value]; } /************************************************************************************************************************/ /// /// Begins a vertical layout group using the given style and decreases the /// to compensate for the indentation. /// public static void BeginVerticalBox(GUIStyle style) { if (style == null) { GUILayout.BeginVertical(); return; } GUILayout.BeginVertical(style); EditorGUIUtility.labelWidth -= style.padding.left; } /// /// Ends a layout group started by and restores the /// . /// public static void EndVerticalBox(GUIStyle style) { if (style != null) EditorGUIUtility.labelWidth += style.padding.left; GUILayout.EndVertical(); } /************************************************************************************************************************/ private static Func _GetGUIClipRect; /// Returns the of the current . public static Rect GetGUIClipRect() { if (_GetGUIClipRect != null) return _GetGUIClipRect(); var type = typeof(GUI).Assembly.GetType("UnityEngine.GUIClip"); var method = type?.GetMethod("GetTopRect", AnimancerReflection.AnyAccessBindings); if (method != null && method.ReturnType != null && method.GetParameters().Length == 0) { _GetGUIClipRect = (Func)Delegate.CreateDelegate(typeof(Func), method); } else { _GetGUIClipRect = () => new(0, 0, Screen.width, Screen.height); } return _GetGUIClipRect(); } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Labels /************************************************************************************************************************/ private static GUIStyle _WeightLabelStyle; private static float _WeightLabelWidth = -1; /// /// Draws a label showing the `weight` aligned to the right side of the `area` and reduces its /// to remove that label from its area. /// public static void DoWeightLabel(ref Rect area, float weight, float effectiveWeight) { var label = WeightToShortString(weight, out var isExact); _WeightLabelStyle ??= new(GUI.skin.label) { alignment = TextAnchor.MiddleRight, }; if (_WeightLabelWidth < 0) { _WeightLabelStyle.fontStyle = FontStyle.Italic; _WeightLabelWidth = _WeightLabelStyle.CalculateWidth("0.0"); } _WeightLabelStyle.normal.textColor = Color.Lerp(Color.grey, TextColor, 0.2f + effectiveWeight * 0.8f); _WeightLabelStyle.fontStyle = isExact ? FontStyle.Normal : FontStyle.Italic; var weightArea = StealFromRight(ref area, _WeightLabelWidth); GUI.Label(weightArea, label, _WeightLabelStyle); } /************************************************************************************************************************/ private static ConversionCache _ShortWeightCache; /// Returns a string which approximates the `weight` into no more than 3 digits. private static string WeightToShortString(float weight, out bool isExact) { isExact = true; if (weight == 0) return "0.0"; if (weight == 1) return "1.0"; isExact = false; if (weight >= -0.5f && weight < 0.05f) return "~0."; if (weight >= 0.95f && weight < 1.05f) return "~1."; if (weight <= -99.5f) return "-??"; if (weight >= 999.5f) return "???"; _ShortWeightCache ??= new(value => { if (value < -9.5f) return $"{value:F0}"; if (value < -0.5f) return $"{value:F0}."; if (value < 9.5f) return $"{value:F1}"; if (value < 99.5f) return $"{value:F0}."; return $"{value:F0}"; }); var rounded = weight > 0 ? Mathf.Floor(weight * 10) : Mathf.Ceil(weight * 10); isExact = Mathf.Approximately(weight * 10, rounded); return _ShortWeightCache.Convert(weight); } /************************************************************************************************************************/ /// The from before . private static float _TightLabelWidth; /// /// Stores the and changes it to the exact width of the `label`. /// public static string BeginTightLabel(string label) { _TightLabelWidth = EditorGUIUtility.labelWidth; EditorGUIUtility.labelWidth = CalculateLabelWidth(label) + EditorGUI.indentLevel * IndentSize; return label; } /// Reverts to its previous value. public static void EndTightLabel() { EditorGUIUtility.labelWidth = _TightLabelWidth; } /************************************************************************************************************************/ /// Draws a button using and . public static bool CompactMiniButton(GUIContent content) => GUILayout.Button(content, EditorStyles.miniButton, DontExpandWidth); /// Draws a button using . public static bool CompactMiniButton(Rect area, GUIContent content) => GUI.Button(area, content, EditorStyles.miniButton); /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Fields /************************************************************************************************************************/ /// Draws a label field with a foldout. public static bool DoLabelFoldoutFieldGUI(string label, string value, bool isExpanded) { using (var labelContent = PooledGUIContent.Acquire(label)) using (var valueContent = PooledGUIContent.Acquire(value)) return DoLabelFoldoutFieldGUI(labelContent, valueContent, isExpanded); } /// Draws a label field with a foldout. public static bool DoLabelFoldoutFieldGUI(GUIContent label, GUIContent value, bool isExpanded) { var area = LayoutSingleLineRect(); EditorGUI.LabelField(area, label, value); return EditorGUI.Foldout(area, isExpanded, "", true); } /// Draws a foldout which stores its state in a hash set. public static bool DoHashedFoldoutGUI(Rect area, HashSet expandedItems, T item) { var wasExpanded = expandedItems.Contains(item); var isExpanded = EditorGUI.Foldout(area, wasExpanded, "", true); if (isExpanded != wasExpanded) if (isExpanded) expandedItems.Add(item); else expandedItems.Remove(item); return isExpanded; } /************************************************************************************************************************/ /// Draws an object reference field. public static T DoObjectFieldGUI( Rect area, GUIContent label, T value, bool allowSceneObjects) where T : Object => EditorGUI.ObjectField(area, label, value, typeof(T), allowSceneObjects) as T; /// Draws an object reference field. public static T DoObjectFieldGUI( Rect area, string label, T value, bool allowSceneObjects) where T : Object { using var content = PooledGUIContent.Acquire(label); return DoObjectFieldGUI(area, content, value, allowSceneObjects); } /************************************************************************************************************************/ /// Draws an object reference field. public static T DoObjectFieldGUI( GUIContent label, T value, bool allowSceneObjects) where T : Object { var height = EditorGUIUtility.HasObjectThumbnail(typeof(T)) ? 64f : LineHeight; var area = EditorGUILayout.GetControlRect(label != null, height); return DoObjectFieldGUI(area, label, value, allowSceneObjects); } /// Draws an object reference field. public static T DoObjectFieldGUI( string label, T value, bool allowSceneObjects) where T : Object { using var content = PooledGUIContent.Acquire(label); return DoObjectFieldGUI(content, value, allowSceneObjects); } /************************************************************************************************************************/ /// /// Draws an object reference field with a dropdown button as its label /// and returns true if clicked. /// public static bool DoDropdownObjectFieldGUI( Rect area, GUIContent label, bool showDropdown, ref T value) where T : Object { var labelWidth = EditorGUIUtility.labelWidth; labelWidth += 2; area.xMin -= 1; var spacing = StandardSpacing; var labelArea = StealFromLeft(ref area, labelWidth - spacing, spacing); value = DoObjectFieldGUI(area, "", value, true); if (showDropdown) { return EditorGUI.DropdownButton(labelArea, label, FocusType.Passive); } else { GUI.Label(labelArea, label); return false; } } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Events /************************************************************************************************************************/ /// Sets if `guiChanged` is true. public static void SetGuiChanged(bool guiChanged) { if (guiChanged) GUI.changed = true; } /************************************************************************************************************************/ /// /// Calls and sets the /// and . /// public static void Use(this Event guiEvent, int controlId, bool guiChanged = false) { SetGuiChanged(guiChanged); GUIUtility.hotControl = controlId; guiEvent.Use(); } /************************************************************************************************************************/ /// /// Sets the and uses the `currentEvent` /// if the mouse position is inside the `area`. /// /// This method is useful for handling . public static bool TryUseMouseDown(Rect area, Event currentEvent, int controlID) { if (!area.Contains(currentEvent.mousePosition)) return false; GUIUtility.keyboardControl = 0; GUIUtility.hotControl = controlID; currentEvent.Use(); return true; } /************************************************************************************************************************/ /// /// Releases the and uses the `currentEvent` if it was the active control. /// /// This method is useful for handling . public static bool TryUseMouseUp(Event currentEvent, int controlID, bool guiChanged = false) { if (GUIUtility.hotControl != controlID) return false; GUIUtility.hotControl = 0; currentEvent.Use(); SetGuiChanged(guiChanged); return true; } /************************************************************************************************************************/ /// /// Uses the `currentEvent` and sets /// if the `controlID` matches the . /// /// This method is useful for handling . public static bool TryUseHotControl(Event currentEvent, int controlID, bool guiChanged = true) { if (GUIUtility.hotControl != controlID) return false; SetGuiChanged(guiChanged); currentEvent.Use(); return true; } /************************************************************************************************************************/ /// /// Uses the `currentEvent` if the `controlID` has . /// If a `key` is specified, other keys will be ignored. /// /// /// This method is useful for handling /// and . /// public static bool TryUseKey(Event currentEvent, int controlID, KeyCode key = KeyCode.None) { if (GUIUtility.keyboardControl != controlID) return false; if (key != KeyCode.None && currentEvent.keyCode != key) return false; currentEvent.Use(); GUI.changed = true; return true; } /************************************************************************************************************************/ /// /// Returns true and uses the current event if it is /// inside the specified `area`. /// /// Uses and events. public static bool TryUseClickEvent(Rect area, int button = -1, int controlID = 0) { if (controlID == 0) controlID = GUIUtility.GetControlID(FocusType.Passive); var currentEvent = Event.current; if (button >= 0 && currentEvent.button != button) return false; switch (currentEvent.type) { case EventType.MouseDown: TryUseMouseDown(area, currentEvent, controlID); break; case EventType.MouseUp: return TryUseMouseUp(currentEvent, controlID, true) && area.Contains(currentEvent.mousePosition); } return false; } /// /// Returns true and uses the current event if it is inside the last GUI Layout /// that was drawn. /// public static bool TryUseClickEventInLastRect(int button = -1) => TryUseClickEvent(GUILayoutUtility.GetLastRect(), button); /************************************************************************************************************************/ /// Is the `currentEvent` a Middle Click or Alt + Left Click? public static bool IsMiddleClick(this Event currentEvent) => currentEvent.button == 2 || (currentEvent.button == 0 && currentEvent.modifiers == EventModifiers.Alt); /************************************************************************************************************************/ /// Deselects any selected IMGUI control. public static void Deselect() { GUIUtility.hotControl = 0; GUIUtility.keyboardControl = 0; } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ #region Other /************************************************************************************************************************/ /// Draws a line. /// /// Use , , and /// if you want to draw multiple lines more efficiently. /// public static void DrawLine( Vector2 a, Vector2 b, float width, Color color) { BeginTriangles(color); DrawLineBatched(a, b, width); EndTriangles(); } /************************************************************************************************************************/ /// Sets up the rendering details for . /// /// If the color doesn't work correctly, you may need to call /// before this. /// public static void BeginTriangles(Color color) { GL.Begin(GL.TRIANGLES); GL.Color(color); } /// Cleans up the rendering details for . public static void EndTriangles() { GL.End(); } /************************************************************************************************************************/ /// Draws a line. /// Must be called after and before . public static void DrawLineBatched( Vector2 a, Vector2 b, float width) { var perpendicular = 0.5f * width * (a - b).GetPerpendicular().normalized; var a0 = a - perpendicular; var a1 = a + perpendicular; var b0 = b - perpendicular; var b1 = b + perpendicular; GL.Vertex(a0); GL.Vertex(a1); GL.Vertex(b0); GL.Vertex(a1); GL.Vertex(b0); GL.Vertex(b1); } /************************************************************************************************************************/ /// Draws triangular arrow. /// Must be called after and before . public static void DrawArrowTriangleBatched( Vector2 point, Vector2 direction, float width, float length) { direction.Normalize(); var perpendicular = 0.5f * width * direction.GetPerpendicular(); // These commented out bits would use the point as the center of the triangle instead. direction *= length;// * 0.5f; var back = point - direction; GL.Vertex(point);// + direction); GL.Vertex(back + perpendicular); GL.Vertex(back - perpendicular); } /************************************************************************************************************************/ /// Returns a vector perpendicular to the given value with the same magnitude. public static Vector2 GetPerpendicular(this Vector2 vector) => new(vector.y, -vector.x); /************************************************************************************************************************/ /// Draws a `sprite` in the given `area`. public static void DrawSprite(Rect area, Sprite sprite) { var texture = sprite.texture; var textureWidth = texture.width; var textureHeight = texture.height; var spriteRect = sprite.rect; spriteRect.x /= textureWidth; spriteRect.y /= textureHeight; spriteRect.width /= textureWidth; spriteRect.height /= textureHeight; GUI.DrawTextureWithTexCoords( area, texture, spriteRect); } /************************************************************************************************************************/ /// Returns a colour with its hue based on the `hash`. public static Color GetHashColor(int hash, float s = 1, float v = 1, float a = 1) { uint uHash = (uint)hash; double dHash = (double)uHash / uint.MaxValue; float h = (float)dHash; var color = Color.HSVToRGB(h, s, v); color.a = a; return color; } /************************************************************************************************************************/ /// Clears the then returns it to its current state. /// /// This forces the drawer to adjust to height changes which /// it unfortunately doesn't do on its own.. /// public static void ReSelectCurrentObjects() { var selection = Selection.objects; Selection.objects = Array.Empty(); EditorApplication.delayCall += () => EditorApplication.delayCall += () => Selection.objects = selection; } /************************************************************************************************************************/ /// Draws a button which toggles between play and pause icons. public static bool DoPlayPauseToggle( Rect area, bool isPlaying, GUIStyle style = null, string tooltip = null) { var content = isPlaying ? AnimancerIcons.PauseIcon : AnimancerIcons.PlayIcon; var oldTooltip = content.tooltip; content.tooltip = tooltip; style ??= MiniButtonNoPadding; if (GUI.Button(area, content, style)) isPlaying = !isPlaying; content.tooltip = oldTooltip; return isPlaying; } /************************************************************************************************************************/ #endregion /************************************************************************************************************************/ } } #endif