using UnityEngine; using UnityEditor; using System; using System.Collections.Generic; using System.IO; using System.Reflection; using UnityEngine.UIElements; using UnityEditor.UIElements; using System.Linq.Expressions; namespace Unity.Cinemachine.Editor { /// /// Collection of tools and helpers for drawing inspectors /// [InitializeOnLoad] static partial class InspectorUtility { /// /// Callback that happens whenever something undoable happens, either with /// objects or with selection. This is a good way to track user activity. /// public static EditorApplication.CallbackFunction UserDidSomething; static InspectorUtility() { ObjectChangeEvents.changesPublished -= OnUserDidSomethingStream; ObjectChangeEvents.changesPublished += OnUserDidSomethingStream; Selection.selectionChanged -= OnUserDidSomething; Selection.selectionChanged += OnUserDidSomething; static void OnUserDidSomething() => UserDidSomething?.Invoke(); static void OnUserDidSomethingStream(ref ObjectChangeEventStream stream) => UserDidSomething?.Invoke(); } /// /// Add to a list all assets of a given type found in a given location /// /// The asset type to look for /// The list to add found assets to /// The location in which to look. Path is relative to package root. public static void AddAssetsFromPackageSubDirectory( Type type, List assets, string path) { try { path = CinemachineCore.kPackageRoot + "/" + path; var info = new DirectoryInfo(path); path += "/"; var fileInfo = info.GetFiles(); for (int i = 0; i < fileInfo.Length; ++i) { var file = fileInfo[i]; if (file.Extension != ".asset") continue; var name = path + file.Name; var a = AssetDatabase.LoadAssetAtPath(name, type) as ScriptableObject; if (a != null) assets.Add(a); } } catch { } } /// /// Normalize a curve so that each of X and Y axes ranges from 0 to 1 /// /// Curve to normalize /// The normalized curve public static AnimationCurve NormalizeCurve(AnimationCurve curve) { return RuntimeUtility.NormalizeCurve(curve, true, true); } /// /// Remove the "Cinemachine" prefix, then call the standard Unity Nicify. /// /// The name to nicify /// The nicified name public static string NicifyClassName(string name) { if (name.StartsWith("Cinemachine")) name = name.Substring(11); // Trim the prefix return ObjectNames.NicifyVariableName(name); } /// /// Remove the "Cinemachine" prefix, then call the standard Unity Nicify, /// and add (Deprecated) to types with Obsolete attributes. /// /// The type to nicify as a string /// The nicified name public static string NicifyClassName(Type type) { var name = NicifyClassName(type.Name); if (type.GetCustomAttribute() != null) name += " (Deprecated)"; return name; } private static int m_lastRepaintFrame; /// /// Force a repaint of the Game View /// /// Like it says public static void RepaintGameView(UnityEngine.Object unused = null) { if (m_lastRepaintFrame == Time.frameCount) return; m_lastRepaintFrame = Time.frameCount; EditorApplication.QueuePlayerLoopUpdate(); UnityEditorInternal.InternalEditorUtility.RepaintAllViews(); } static Dictionary s_AssignableTypes = new (); public const string s_NoneString = "(none)"; public static string GetAssignableBehaviourNames(Type inputType) { if (inputType == null) return "(none)"; if (!s_AssignableTypes.ContainsKey(inputType)) { var allSources = ReflectionHelpers.GetTypesDerivedFrom(inputType, (t) => !t.IsAbstract && typeof(MonoBehaviour).IsAssignableFrom(t) && t.GetCustomAttribute() == null); var s = string.Empty; var iter = allSources.GetEnumerator(); while (iter.MoveNext()) { var sep = (s.Length == 0) ? string.Empty : ", "; s += sep + iter.Current.Name; } if (s.Length == 0) s = s_NoneString; s_AssignableTypes[inputType] = s; } return s_AssignableTypes[inputType]; } /// Aligns fields created by UI toolkit the unity inspector standard way. public static string AlignFieldClassName => BaseField.alignedFieldUssClassName; public static float SingleLineHeight => EditorGUIUtility.singleLineHeight; /// /// Convenience extension for UserDidSomething callbacks, making it easier to use lambdas. /// Cleans itself up when the owner is undisplayed. Works in inspectors and PropertyDrawers. /// public static void TrackAnyUserActivity( this VisualElement owner, EditorApplication.CallbackFunction callback) { owner.RegisterCallback(_ => { UserDidSomething += callback; owner.OnInitialGeometry(callback); owner.RegisterCallback(_ => UserDidSomething -= callback); }); } /// /// Convenience extension for EditorApplication.update callbacks, making it easier to use lambdas. /// Cleans itself up when the owner is undisplayed. Works in inspectors and PropertyDrawers. /// public static void ContinuousUpdate( this VisualElement owner, EditorApplication.CallbackFunction callback) { owner.RegisterCallback(_ => { owner.OnInitialGeometry(callback); EditorApplication.update += callback; owner.RegisterCallback(_ => EditorApplication.update -= callback); }); } /// /// Convenience extension to get a callback after initial geometry creation, making it easier to use lambdas. /// Callback will only be called once. Works in inspectors and PropertyDrawers. /// public static void OnInitialGeometry( this VisualElement owner, EditorApplication.CallbackFunction callback) { owner.RegisterCallback(OnGeometryChanged); void OnGeometryChanged(GeometryChangedEvent _) { owner.UnregisterCallback(OnGeometryChanged); // call only once callback(); } } /// /// Convenience extension to track a property value change plus an initial callback at creation time. /// This simplifies logic for the caller, allowing use of lambda callback. /// public static void TrackPropertyWithInitialCallback( this VisualElement owner, SerializedProperty property, Action callback) { owner.OnInitialGeometry(() => callback(property)); owner.TrackPropertyValue(property, callback); } /// Control the visibility of a widget /// The widget /// Whether it should be visible public static void SetVisible(this VisualElement e, bool show) => e.style.display = show ? StyleKeyword.Null : DisplayStyle.None; /// Is the widgte visible? /// The widget /// True if visible public static bool IsVisible(this VisualElement e) => e.style.display != DisplayStyle.None; /// Convenience method: calls e.Add(child) and returns child./// public static T AddChild(this VisualElement e, T child) where T : VisualElement { e.Add(child); return child; } /// /// Tries to set isDelayed of a FloatField, IntField, or TextField child, if it exists. /// /// Parent widget /// name of child (or null) public static void SafeSetIsDelayed(this VisualElement e, string name = null) { var f = e.Q(name); if (f != null) f.isDelayed = true; var i = e.Q(name); if (i != null) i.isDelayed = true; var t = e.Q(name); if (t != null) t.isDelayed = true; } /// /// Draw a bold header in the inspector - hack to get around missing UITK functionality /// /// Container in which to put the header /// The text of the header /// optional tooltip for the header public static void AddHeader(this VisualElement ux, string text, string tooltip = "") { ux.AddChild(new Label() { text = $"{text}", tooltip = tooltip, focusable = false, style = { //height = SingleLineHeight, marginLeft = 3, // GML TODO: remove hardcoded margin marginTop = SingleLineHeight / 2, marginBottom = EditorGUIUtility.standardVerticalSpacing / 2, } }); } /// /// Create a space between inspector sections /// /// Container in which to add the space public static void AddSpace(this VisualElement ux) { ux.Add(new VisualElement { style = { height = SingleLineHeight / 2 }}); } /// /// Add a property dragger to a float or int label, so that dragging it changes the property value. /// public static void AddDelayedFriendlyPropertyDragger( this Label label, SerializedProperty p, VisualElement field, Action OnDraggerCreated = null) { if (p.propertyType == SerializedPropertyType.Float || p.propertyType == SerializedPropertyType.Integer) { label.AddToClassList("unity-base-field__label--with-dragger"); label.OnInitialGeometry(() => { if (p.propertyType == SerializedPropertyType.Float) { var dragger = new DelayedFriendlyFieldDragger(field.Q()); dragger.SetDragZone(label); OnDraggerCreated?.Invoke(dragger); } else if (p.propertyType == SerializedPropertyType.Integer) { var dragger = new DelayedFriendlyFieldDragger(field.Q()); dragger.SetDragZone(label); OnDraggerCreated?.Invoke(dragger); } }); } } public static VisualElement CreateDraggableField(Expression> exp, Label label, out IDelayedFriendlyDragger dragger) { var bindingPath = SerializedPropertyHelper.PropertyName(exp); var tooltip = SerializedPropertyHelper.PropertyTooltip(exp); return CreateDraggableField(SerializedPropertyHelper.PropertyType(exp), bindingPath, tooltip, label, out dragger); } public static VisualElement CreateDraggableField(Type type, string bindingPath, string tooltip, Label label, out IDelayedFriendlyDragger dragger) { VisualElement field; label.AddToClassList("unity-base-field__label--with-dragger"); label.tooltip = tooltip; label.style.alignSelf = Align.Center; if (type == typeof(float)) { field = new FloatField { bindingPath = bindingPath, tooltip = tooltip }; dragger = new DelayedFriendlyFieldDragger((FloatField)field); } else if (type == typeof(int)) { field = new IntegerField { bindingPath = bindingPath, tooltip = tooltip }; dragger = new DelayedFriendlyFieldDragger((IntegerField)field); } else { field = new PropertyField(null, "") { bindingPath = bindingPath, tooltip = tooltip }; dragger = null; } var d = dragger as BaseFieldMouseDragger; d?.SetDragZone(label); return field; } /// A small warning sybmol, suitable for embedding in an inspector row /// The tooltip text /// The little picture: error, warning, or info public static Label MiniHelpIcon(string tooltip, HelpBoxMessageType iconType = HelpBoxMessageType.Warning) { string icon = iconType switch { HelpBoxMessageType.Warning => "console.warnicon.sml", HelpBoxMessageType.Error => "console.erroricon.sml", _ => "console.infoicon.sml", }; return new Label { tooltip = tooltip, style = { flexGrow = 0, flexBasis = SingleLineHeight, backgroundImage = (StyleBackground)EditorGUIUtility.IconContent(icon).image, width = SingleLineHeight, height = SingleLineHeight, alignSelf = Align.Center } }; } /// A small popup context menu, suitable for embedding in an inspector row /// The tooltip text /// The context menu to show when the button is pressed public static Button MiniPopupButton(string tooltip = null, ContextualMenuManipulator contextMenu = null) { var button = new Button { tooltip = tooltip, style = { backgroundImage = (StyleBackground)EditorGUIUtility.IconContent("_Popup").image, width = SingleLineHeight, height = SingleLineHeight, alignSelf = Align.Center, paddingLeft = 1, paddingRight = 1, marginRight = 0 }}; if (contextMenu != null) { contextMenu.activators.Clear(); contextMenu.activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse }); button.AddManipulator(contextMenu); } return button; } /// A small dropdown context menu, suitable for embedding in an inspector row /// The tooltip text /// The context menu to show when the button is pressed public static Button MiniDropdownButton(string tooltip = null, ContextualMenuManipulator contextMenu = null) { var button = new Button { tooltip = tooltip, style = { backgroundImage = (StyleBackground)EditorGUIUtility.IconContent("dropdown").image, width = SingleLineHeight, height = SingleLineHeight, alignSelf = Align.Center, paddingRight = 0, marginRight = 0 }}; if (contextMenu != null) { contextMenu.activators.Clear(); contextMenu.activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse }); button.AddManipulator(contextMenu); } return button; } /// /// This is an inspector container with 2 side-by-side rows. The Left row's width is /// locked to the inspector field label size, for proper alignment. /// public class LeftRightRow : VisualElement { public VisualElement Left; public VisualElement Right; /// /// Set this to offset the Left/Right division from the inspector's Label/Content line /// public float DivisionOffset = 0; /// /// Set this to zero the left margin, useful for foldouts that control the margin themselves. /// public bool KillLeftMargin; VisualElement Row; public LeftRightRow(VisualElement left = null, VisualElement right = null) { // This is to peek at the resolved label width Add(new AlignFieldSizer { OnLabelWidthChanged = (w) => { if (KillLeftMargin) Row.style.marginLeft = 0; Left.style.width = w + DivisionOffset; }}); // Actual contents will live in this row Row = AddChild(this, new VisualElement { style = { marginLeft = 3, flexDirection = FlexDirection.Row }}); left ??= new VisualElement(); Left = Row.AddChild(left); Left.style.flexDirection = FlexDirection.Row; Left.style.flexGrow = 0; right ??= new VisualElement(); Right = Row.AddChild(right); Right.style.flexDirection = FlexDirection.Row; Right.style.flexGrow = 1; } // This is a hacky thing to create custom inspector rows with labels that are the correct size class AlignFieldSizer : BaseField // bool is just a dummy because it has to be something { public Action OnLabelWidthChanged; public AlignFieldSizer() : base (" ", new VisualElement()) { focusable = false; style.flexDirection = FlexDirection.Row; style.flexGrow = 1; style.height = 0; style.marginTop = -EditorGUIUtility.standardVerticalSpacing; AddToClassList(AlignFieldClassName); labelElement.RegisterCallback((_) => OnLabelWidthChanged?.Invoke(labelElement.resolvedStyle.width)); } } } /// /// This creates a row with a properly-sized label in front of it. /// The label's width is locked to the inspector field label size, for proper alignment. /// public class LabeledRow : LeftRightRow { public Label Label { get; private set; } public VisualElement Contents { get; private set; } public LabeledRow(string label, string tooltip = "", VisualElement contents = null) : base(new Label(label) { tooltip = tooltip, style = { alignSelf = Align.Center, flexGrow = 1 }}, contents) { Label = Left as Label; Contents = Right; Contents.tooltip = tooltip; } } /// /// A row containing a property field. Suitable for adding widgets next to the property field. /// public static LabeledRow PropertyRow( SerializedProperty property, out PropertyField propertyField, string label = null) { var row = new LabeledRow(label ?? property.displayName, property.tooltip); row.Contents.style.marginLeft = -2; propertyField = row.Contents.AddChild(new PropertyField(property, "") { style = { flexGrow = 1, flexBasis = SingleLineHeight * 5 }}); AddDelayedFriendlyPropertyDragger(row.Label, property, propertyField, (d) => d.CancelDelayedWhenDragging = true); return row; } /// /// A property field with a minimally-sized label that does not respect inspector sizing. /// Suitable for embedding in a row within the right-hand side of the inspector. /// public class CompactPropertyField : VisualElement { public Label Label; public PropertyField Field; public CompactPropertyField(SerializedProperty property) : this(property, property.displayName) {} public CompactPropertyField(SerializedProperty property, string label, float minLabelWidth = 0) { style.flexDirection = FlexDirection.Row; if (!string.IsNullOrEmpty(label)) Label = AddChild(this, new Label(label) { tooltip = property?.tooltip, style = { alignSelf = Align.Center, minWidth = minLabelWidth }}); Field = AddChild(this, new PropertyField(property, "") { style = { flexGrow = 1, flexBasis = 50 } }); Field.style.marginLeft = Field.style.marginLeft.value.value - 1; if (Label != null && property != null) AddDelayedFriendlyPropertyDragger(Label, property, Field, (d) => d.CancelDelayedWhenDragging = true); } } /// A foldout that displays an overlay in the right-hand column when closed. /// The overlay can optionally have a label of its own (use with caution). public class FoldoutWithOverlay : VisualElement { public readonly Foldout OpenFoldout; public readonly Foldout ClosedFoldout; public readonly VisualElement Overlay; public readonly Label OverlayLabel; public FoldoutWithOverlay(Foldout foldout, VisualElement overlay, Label overlayLabel) { OpenFoldout = foldout; Overlay = overlay; OverlayLabel = overlayLabel; Add(foldout); // There are 2 modes for this element: foldout closed and foldout open. // When closed, we cheat the layout system, and to implement this we do a switcheroo var closedContainer = AddChild(this, new LeftRightRow() { KillLeftMargin = true, style = { flexGrow = 1 }}); var closedFoldout = new Foldout { text = foldout.text, tooltip = foldout.tooltip, value = false }; ClosedFoldout = closedFoldout; ClosedFoldout = closedContainer.Left.AddChild(ClosedFoldout); if (overlayLabel != null) closedContainer.Right.Add(overlayLabel); closedContainer.Right.Add(overlay); // Outdent the label if (overlayLabel != null) closedContainer.Right.OnInitialGeometry(() => closedContainer.Right.style.marginLeft = -overlayLabel.resolvedStyle.width); // Swap the open and closed foldouts when the foldout is opened or closed foldout.SetVisible(foldout.value); closedFoldout.RegisterValueChangedCallback((evt) => { if (evt.target == closedFoldout) { if (evt.newValue && evt.target == closedFoldout) { closedContainer.SetVisible(false); foldout.SetVisible(true); foldout.value = true; closedFoldout.SetValueWithoutNotify(false); foldout.Q().Focus(); } evt.StopPropagation(); } }); closedContainer.SetVisible(!foldout.value); foldout.RegisterValueChangedCallback((evt) => { if (evt.target == foldout) { if (!evt.newValue) { closedContainer.SetVisible(true); foldout.SetVisible(false); closedFoldout.SetValueWithoutNotify(false); foldout.value = false; closedFoldout.Q().Focus(); } evt.StopPropagation(); } }); } } public static VisualElement HelpBoxWithButton( string message, HelpBoxMessageType messageType, string buttonText, Action onClicked, ContextualMenuManipulator contextMenu = null) { var box = new VisualElement { style = { flexDirection = FlexDirection.Row, paddingTop = 8, paddingBottom = 8, paddingLeft = 8, paddingRight = 8 }}; box.AddToClassList("unity-help-box"); var innerBox = box.AddChild(new VisualElement { style = { flexDirection = FlexDirection.Column, flexGrow = 1 }}); var row = innerBox.AddChild(new VisualElement { style = { flexDirection = FlexDirection.Row, flexGrow = 1 }}); var icon = row.AddChild(MiniHelpIcon("", messageType)); icon.style.alignSelf = Align.Auto; icon.style.marginRight = 6; var text = row.AddChild(new Label(message) { style = { flexGrow = 1, flexBasis = 100, alignSelf = Align.Center, whiteSpace = WhiteSpace.Normal }}); var buttons = innerBox.AddChild(new VisualElement { style = { flexDirection = FlexDirection.Row, flexGrow = 1, marginTop = 6 }}); buttons.Add(new VisualElement { style = { flexGrow = 1 }}); var button = buttons.AddChild(new Button(onClicked) { text = buttonText }); if (contextMenu != null) { contextMenu.activators.Clear(); contextMenu.activators.Add(new ManipulatorActivationFilter { button = MouseButton.LeftMouse }); button.AddManipulator(contextMenu); } return box; } public static void AddRemainingProperties(VisualElement ux, SerializedProperty property) { if (property != null) { var p = property.Copy(); do { if (p.name != "m_Script") ux.Add(new PropertyField(p)); } while (p.NextVisible(false)); } } public static bool IsAncestorOf(this Transform p, Transform other) { while (other != null && p != null) { if (other == p) return true; other = other.parent; } return false; } } }