/* * Copyright (c) Meta Platforms, Inc. and affiliates. * All rights reserved. * * Licensed under the Oculus SDK License Agreement (the "License"); * you may not use the Oculus SDK except in compliance with the License, * which is provided at the time of installation or download, or which * otherwise accompanies this software in either electronic or hard copy form. * * You may obtain a copy of the License at * * https://developer.oculus.com/licenses/oculussdk/ * * Unless required by applicable law or agreed to in writing, the Oculus SDK * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ using UnityEditor; using UnityEngine; using UnityEngine.EventSystems; using System; using System.Linq; using System.Reflection; using System.ComponentModel; using System.Collections.Generic; using Object = UnityEngine.Object; using Component = UnityEngine.Component; namespace Oculus.Interaction.Editor.QuickActions { internal abstract class QuickActionsWizard : EditorWindow { protected class DeviceTypeAttribute : PropertyAttribute { public DeviceTypes SupportedTypes { get; private set; } = DeviceTypes.All; public IReadOnlyList<(string name, DeviceTypes type)> GetDevices() { var names = new List<(string, DeviceTypes)>(); if ((SupportedTypes & DeviceTypes.Hands) != 0 && InteractorUtils.CanAddHandInteractorsToRig()) { names.Add(("Add To Hands", DeviceTypes.Hands)); } if ((SupportedTypes & DeviceTypes.Controllers) != 0 && InteractorUtils.CanAddControllerInteractorsToRig()) { names.Add(("Add To Controllers", DeviceTypes.Controllers)); } if ((SupportedTypes & DeviceTypes.ControllerDrivenHands) != 0 && InteractorUtils.CanAddControllerHandInteractorsToRig()) { names.Add(("Add To Controllers Driven Hands", DeviceTypes.ControllerDrivenHands)); } return names; } public DeviceTypeAttribute() : base() { } public DeviceTypeAttribute(DeviceTypes supportedTypes) : base() { SupportedTypes = supportedTypes; } } [CustomPropertyDrawer(typeof(DeviceTypeAttribute))] public class DeviceTypeDrawer : PropertyDrawer { private DeviceTypeAttribute DeviceTypeAttribute => attribute as DeviceTypeAttribute; public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { var names = DeviceTypeAttribute.GetDevices(); if (names.Count == 0) // No input devices, do not draw dropdown { float iconSize = position.height; GUI.Box(new Rect(position.position, new(iconSize, iconSize)), Styles.WarningIcon, GUI.skin.label); position = new Rect(position.x + iconSize, position.y, position.width - iconSize, position.height); EditorGUI.LabelField(position, "Cannot add interactors: No input devices present in scene."); return; } int mask = 0; for (int i = 0; i < names.Count; ++i) { if ((property.intValue & (int)names[i].type) != 0) { mask |= 1 << i; } } mask = EditorGUI.MaskField(position, mask, names.Select(n => n.name).ToArray()); int value = 0; for (int i = 0; i < names.Count; ++i) { if ((mask & (1 << i)) != 0) { value |= (int)names[i].type; } } property.enumValueFlag = value; } } public class BooleanDropdownAttribute : PropertyAttribute { public string True { get; set; } = "True"; public string False { get; set; } = "False"; } [CustomPropertyDrawer(typeof(BooleanDropdownAttribute))] public class BooleanDropdownDrawer : PropertyDrawer { private BooleanDropdownAttribute BooleanDropdownAttribute => attribute as BooleanDropdownAttribute; private string[] _options; public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { if (_options == null) { _options = new string[2] { BooleanDropdownAttribute.False, BooleanDropdownAttribute.True, }; } property.boolValue = EditorGUI.Popup(position, property.boolValue ? 1 : 0, _options) == 1; } } protected class MessageData { /// /// The message type, which sets the icon. Error messages /// are considered fatal and block creation of the object. /// public readonly MessageType MessageType; /// /// The message to be displayed in the banner /// public readonly string Message; /// /// If non-null, a button will be shown with this name and action /// public readonly ButtonData ButtonData; public MessageData(MessageType messageType, string message, ButtonData buttonData = null) { MessageType = messageType; Message = message; ButtonData = buttonData; } } protected class ButtonData { public readonly string Label; public readonly Action Action; public ButtonData(string label, Action action) { Label = label; Action = action; } } private class WizardSetting : WizardField { public readonly object DefaultValue; public WizardSetting(FieldInfo fieldInfo) : base(fieldInfo) { var defaultValueAttribute = fieldInfo.GetCustomAttribute(); if (defaultValueAttribute != null) { DefaultValue = defaultValueAttribute.Value; } } public void ResetToDefault(Object obj) { if (DefaultValue != null) { _fieldInfo.SetValue(obj, DefaultValue); } } public bool HasDefaultValue(Object obj) { return DefaultValue == null || _fieldInfo.GetValue(obj).Equals(DefaultValue); } } private class WizardDependency : WizardField { public Category Category => _attribute.Category; public override bool ReadOnly => _attribute.ReadOnly; public readonly Action FindAction; public readonly Action FixAction; private readonly WizardDependencyAttribute _attribute; public WizardDependency(FieldInfo fieldInfo) : base(fieldInfo) { _attribute = fieldInfo.GetCustomAttribute(); if (!string.IsNullOrEmpty(_attribute.FindMethod)) { var findAction = fieldInfo.DeclaringType.GetMethod(_attribute.FindMethod, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); FindAction = findAction == null ? null : w => { findAction.Invoke(w, null); w.SyncSerializedObject(); }; } if (!string.IsNullOrEmpty(_attribute.FixMethod)) { var fixAction = fieldInfo.DeclaringType.GetMethod(_attribute.FixMethod, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); FixAction = fixAction == null ? null : w => { fixAction.Invoke(w, null); w.SyncSerializedObject(); }; } } /// /// Check if the serialized property has an object value /// /// The wizard instance to check /// If set, any null array elements /// will cause this to return false. /// public bool HasValue(QuickActionsWizard wizard, bool fullArray = false) { var prop = wizard.SerializedObject.FindProperty(PropertyPath); if (prop.isArray) { int nonNull = 0; for (int i = 0; i < prop.arraySize; ++i) { var element = prop.GetArrayElementAtIndex(i); if (element.objectReferenceValue != null) { ++nonNull; } } return fullArray ? nonNull == prop.arraySize : nonNull > 0; } else { return prop.objectReferenceValue != null; } } } private abstract class WizardField { public virtual bool ReadOnly => false; public readonly string PropertyPath; public readonly string DisplayName; public readonly string Tooltip; public readonly Action ChangeCallback; protected readonly FieldInfo _fieldInfo; private readonly ConditionalHideAttribute _conditionalHide; protected WizardField(FieldInfo fieldInfo) { _fieldInfo = fieldInfo; PropertyPath = fieldInfo.Name; TooltipAttribute tooltip = fieldInfo.GetCustomAttribute(); if (tooltip != null) { Tooltip = tooltip.tooltip; } InspectorNameAttribute inspectorName = fieldInfo.GetCustomAttribute(); DisplayName = inspectorName != null ? inspectorName.displayName : ObjectNames.NicifyVariableName(fieldInfo.Name); _conditionalHide = fieldInfo.GetCustomAttribute(); var changeCheckAttribute = fieldInfo.GetCustomAttribute(); if (changeCheckAttribute != null) { var changeAction = fieldInfo.DeclaringType.GetMethod(changeCheckAttribute.Callback, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); ChangeCallback = changeAction == null ? null : w => { w.SyncSerializedObject(); changeAction.Invoke(w, null); }; } } public bool ShouldConditionalShow(QuickActionsWizard wizard) { if (_conditionalHide == null) { return true; } return ConditionalHideDrawer.ShouldDisplay (wizard.SerializedObject.FindProperty(_conditionalHide.ConditionalFieldPath), _conditionalHide.Value, _conditionalHide.Display); } } protected enum Category { Required, Optional, } protected class ChangeCheckAttribute : System.Attribute { public readonly string Callback; public ChangeCheckAttribute(string callback) { Callback = callback; } } protected class WizardDependencyAttribute : System.Attribute { public Category Category { get; set; } = Category.Required; public string FindMethod { get; set; } = string.Empty; public string FixMethod { get; set; } = string.Empty; public bool ReadOnly { get; set; } = false; public WizardDependencyAttribute() { } } protected class WizardSettingAttribute : System.Attribute { public WizardSettingAttribute() { } } public const string MENU_FOLDER = "GameObject/Interaction SDK/"; /// /// Window should not be drawn in batch mode, ie from Unit Tests /// internal static bool ShouldDraw => !Application.isBatchMode; /// /// The GameObject that the user wishes to augment /// protected GameObject Target { get; private set; } protected bool Targetless { get; private set; } = false; /// /// Shared messages between Wizard types /// protected WizardMessages Messages => _messages ??= new WizardMessages(this); private WizardMessages _messages; private static WizardStyles Styles => _styles ??= new WizardStyles(); private static WizardStyles _styles; private SerializedObject SerializedObject => _serializedObject ??= new SerializedObject(this); private SerializedObject _serializedObject; private WizardDependency[] _dependencies; private WizardSetting[] _settings; private Vector2 _contentScrollPos = Vector2.zero; private bool _foldoutSettings = true; private bool _foldoutRequired = true; private bool _foldoutOptional = true; private bool _shouldClose = false; /// /// Show the provided window type and set the target to /// the provided GameObject /// /// The Wizard type /// The object the user wishes to modify /// The window instance protected static T ShowWindow(GameObject target) where T : QuickActionsWizard { T wizard = ShouldDraw ? GetWindow() : CreateInstance(); wizard.Target = target; wizard.Targetless = target == null; wizard.titleContent = new GUIContent(wizard.GetWindowTitle()); wizard.minSize = new Vector2(480, 320); wizard.InitializeFields(); if (ShouldDraw) { wizard.Show(); } return wizard; } /// /// Executes the wizard action with all default values /// /// The wizard type to run /// The target object /// Whether or not optionals dependencies should be automatically fixed /// Optional delegate to inject modifications to the wizard /// All prefabs instantiated during the creation internal static IEnumerable CreateWithDefaults(GameObject target, bool fixOptionals = true, Action injections = null) where TWizard : QuickActionsWizard { var wizard = CreateInstance(); wizard.Target = target; injections?.Invoke(wizard); List newObjects = new List(); void OnObjCreated(Template t, GameObject o) => newObjects.Add(o); Templates.WhenObjectCreated += OnObjCreated; wizard.InitializeFields(); wizard.FixMissingDependencies(fixOptionals); if (wizard.CanCreate()) { wizard.Create(); } else { Debug.LogError($"Could not execute {typeof(TWizard).Name}"); } Templates.WhenObjectCreated -= OnObjCreated; DestroyImmediate(wizard); return newObjects; } private void Awake() { FieldInfo[] fieldInfos = GetType().GetFields( BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); _dependencies = fieldInfos .Where(fi => fi.GetCustomAttribute() != null && SerializedObject.FindProperty(fi.Name) != null) .Select(fi => new WizardDependency(fi)).ToArray(); _settings = fieldInfos .Where(fi => fi.GetCustomAttribute() != null && SerializedObject.FindProperty(fi.Name) != null) .Select(fi => new WizardSetting(fi)).ToArray(); } internal virtual void OnGUI() { if (Target == null && !Targetless) { Debug.LogError("Target object was destroyed."); Close(); return; } PruneReferences(); UpdateReadOnlyFields(); if (ShouldDraw) { DrawWizard(); } SyncSerializedObject(); if (_shouldClose) { Close(); } } private void DrawWizard() { DrawMessages(); EditorGUILayout.Space(); if (!Targetless) { DrawTargetField(); } EditorGUILayout.Space(); DrawContent(); GUILayout.FlexibleSpace(); DrawFooter(); } protected void SyncSerializedObject() { SerializedObject.ApplyModifiedProperties(); SerializedObject.Update(); } private void UpdateReadOnlyFields() { // Read only fields cannot be assigned by the user, // therefore we attempt to auto-assign each frame foreach (var dependency in _dependencies) { if (dependency.ReadOnly) { dependency.FindAction?.Invoke(this); } } } private void PruneReferences() { // When destroying objects with Undo, references can be left // dangling (shown as Missing) and must be cleaned up. foreach (var dependency in _dependencies) { static void PruneProperty(SerializedProperty property) { if (property != null && property.objectReferenceValue == null) { property.objectReferenceValue = null; } } var prop = SerializedObject .FindProperty(dependency.PropertyPath); if (prop.isArray) { for (int i = 0; i < prop.arraySize; ++i) { PruneProperty(prop.GetArrayElementAtIndex(i)); } } else { PruneProperty(prop); } } } private void DrawContent() { bool ShouldDrawField(WizardField field) { if (!field.ShouldConditionalShow(this)) { return false; } return true; } void DrawHeader(ref bool foldout, Rect rect, string label, GUIContent icon, ButtonData buttonData = null) { var previousLabelWidth = EditorGUIUtility.labelWidth; EditorGUIUtility.labelWidth = rect.width - 8; using (var scope = new EditorGUILayout.HorizontalScope(Styles.FoldoutHorizontal)) { foldout = EditorGUILayout.Foldout(foldout, label, Styles.Foldout); if (buttonData != null) { GUILayout.Box(icon.image, Styles.FixIcon); if (GUILayout.Button(buttonData.Label, Styles.FixAllButton)) { buttonData.Action.Invoke(); } } } EditorGUIUtility.labelWidth = previousLabelWidth; } void DrawField(WizardField field, ButtonData buttonData = null) { using (var scope = new EditorGUILayout.VerticalScope(Styles.ListLabel)) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField(field.DisplayName, Styles.WizardField); if (buttonData != null && GUILayout.Button(buttonData.Label, Styles.FixButton)) { buttonData.Action.Invoke(); } } if (!string.IsNullOrEmpty(field.Tooltip)) { EditorGUILayout.LabelField(field.Tooltip, Styles.WizardFieldTooltip); } var property = SerializedObject.FindProperty(field.PropertyPath); bool guiEnabled = GUI.enabled; GUI.enabled = !field.ReadOnly; EditorGUI.BeginChangeCheck(); EditorGUILayout.PropertyField(property, GUIContent.none); bool changed = EditorGUI.EndChangeCheck(); GUI.enabled = guiEnabled; if (changed) { field.ChangeCallback?.Invoke(this); } } } void DrawDependencies(ref bool foldout, Rect rect, string header, GUIContent icon, IEnumerable dependencies) { void Fix(params WizardDependency[] dependencies) { foreach (var dependency in dependencies) { // Try to find existing or add new if (dependency.FindAction != null) { dependency.FindAction.Invoke(this); } if (!dependency.HasValue(this)) { dependency.FixAction.Invoke(this); } } } var fixable = dependencies .Where(d => !d.HasValue(this) && d.FixAction != null); ButtonData fixAllButton = fixable.Any() ? new ButtonData("Fix All", () => Fix(fixable.ToArray())) : null; DrawHeader(ref foldout, rect, header, icon, fixAllButton); if (!foldout) { return; } foreach (var dependency in dependencies) { ButtonData fixButton = fixable.Contains(dependency) ? new ButtonData("Fix", () => Fix(dependency)) : null; DrawField(dependency, fixButton); } } void DrawSettings(ref bool foldout, Rect rect, string header, GUIContent icon, IEnumerable settings) { void Reset(params WizardSetting[] settings) { foreach (var setting in settings) { setting.ResetToDefault(this); } } var resettable = settings .Where(d => d.DefaultValue != null && !d.HasDefaultValue(this)); ButtonData resetAllButton = resettable.Any() ? new ButtonData("Reset All", () => Reset(resettable.ToArray())) : null; DrawHeader(ref foldout, rect, header, icon, resetAllButton); if (!foldout) { return; } foreach (var setting in settings) { ButtonData resetButton = resettable.Contains(setting) ? new ButtonData("Reset", () => Reset(setting)) : null; DrawField(setting, resetButton); } } // Scrolling Content Area using (new EditorGUILayout.VerticalScope()) { _contentScrollPos = EditorGUILayout.BeginScrollView(_contentScrollPos); // Draw Settings var settings = _settings.Where(fd => ShouldDrawField(fd)); if (settings.Any()) { using (var v = new EditorGUILayout.VerticalScope(Styles.List)) { DrawSettings(ref _foldoutSettings, v.rect, $"Settings ({settings.Count()})", Styles.InfoIcon, settings); } } // Draw Required Dependencies var required = _dependencies.Where(fd => ShouldDrawField(fd) && fd.Category == Category.Required); if (required.Any()) { using (var v = new EditorGUILayout.VerticalScope(Styles.List)) { DrawDependencies(ref _foldoutRequired, v.rect, $"Required Components ({required.Count()})", Styles.ErrorIcon, required); } } // Draw Optional Dependencies var optional = _dependencies.Where(fd => ShouldDrawField(fd) && fd.Category == Category.Optional); if (optional.Any()) { using (var v = new EditorGUILayout.VerticalScope(Styles.List)) { DrawDependencies(ref _foldoutOptional, v.rect, $"Optional Components ({optional.Count()})", Styles.InfoIcon, optional); } } EditorGUILayout.EndScrollView(); } } private bool CanCreate() { bool result = true; result &= !HasErrorMessages(); result &= _dependencies.Count() == 0 || !_dependencies.Any(fd => fd.ShouldConditionalShow(this) && fd.Category == Category.Required && !fd.HasValue(this, true)); return result; } private bool HasErrorMessages() { return GetMessages().Any(msg => msg.MessageType == MessageType.Error); } private void DrawTargetField() { using (new EditorGUILayout.VerticalScope(Styles.TargetLabel)) { GUI.enabled = false; using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField("Target Object", GUILayout.ExpandWidth(false), GUILayout.MaxWidth(90)); EditorGUILayout.ObjectField( Target, typeof(Object), true); } GUI.enabled = true; } } private void DrawMessages() { // Order in decreasing severity var messages = GetMessages() .OrderByDescending(msg => msg.MessageType); using (new EditorGUILayout.VerticalScope()) { foreach (var message in messages) { using (new GUILayout.HorizontalScope()) { EditorGUILayout.HelpBox(message.Message, message.MessageType); if (message.ButtonData != null && GUILayout.Button(message.ButtonData.Label, Styles.MessageButton, GUILayout.ExpandHeight(true))) { message.ButtonData.Action.Invoke(); } } } } } private void DrawFooter() { using (new EditorGUILayout.VerticalScope(Styles.ButtonArea)) { using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Cancel", GUILayout.ExpandHeight(true))) { _shouldClose = true; } GUI.enabled = CanCreate(); if (GUILayout.Button("Create", GUILayout.ExpandHeight(true))) { // Track all template objects created during the Create // action in order to select them in the Hierarchy List newObjects = new List(); void OnObjCreated(Template t, GameObject o) => newObjects.Add(o); // Undo operations will be collapsed to this point var undoBeforeCreate = Undo.GetCurrentGroup(); Templates.WhenObjectCreated += OnObjCreated; Create(); Templates.WhenObjectCreated -= OnObjCreated; // Collapse Undo group into a single operation Undo.SetCurrentGroupName("ISDK QuickActions"); Undo.CollapseUndoOperations(undoBeforeCreate); Selection.objects = newObjects.ToArray(); _shouldClose = true; } GUI.enabled = true; } } } /// /// Get the title of the wizard window /// private string GetWindowTitle() { return ObjectNames.NicifyVariableName(GetType().Name); } /// /// Override with creation logic to be run when user /// wishes to create the components. /// protected abstract void Create(); /// /// Override with any initialization logic needed /// when wizard is first opened. /// protected virtual void InitializeFieldsExtra() { } /// /// Initializes the fields of the Wizard with default values, /// or components found when searching the hierarchy. /// private void InitializeFields() { foreach (var dependency in _dependencies) { dependency.FindAction?.Invoke(this); } foreach (var setting in _settings) { setting.ResetToDefault(this); } InitializeFieldsExtra(); SyncSerializedObject(); } private void FixMissingDependencies(bool fixOptionals = true) { foreach (var dependency in _dependencies) { if (!fixOptionals && dependency.Category == Category.Optional) { continue; } dependency.FindAction?.Invoke(this); if (!dependency.HasValue(this)) { dependency.FixAction?.Invoke(this); } } SyncSerializedObject(); } /// /// Messages are displayed as notification banners in the wizard window. /// /// A collection of messages to be displayed protected virtual IEnumerable GetMessages() { return Enumerable.Empty(); } /// /// Add a component to a GameObject and register it in the Undo stack. /// /// The component type to add /// The GameObject to add the component to /// The newly added component protected T AddComponent(GameObject gameObject) where T : Component { T result = Undo.AddComponent(gameObject); EditorGUIUtility.PingObject(gameObject); return result; } /// /// Add a component to a GameObject and register it in the Undo stack. /// /// The component type to add /// The GameObject to add the component to /// The newly added component protected GameObject AddObject(string name, params Type[] components) { GameObject result = new GameObject(name, components); Undo.RegisterCreatedObjectUndo(result, $"Create {name}"); EditorGUIUtility.PingObject(result); return result; } /// /// Common messages for the window /// protected class WizardMessages { private readonly QuickActionsWizard _wizard; public WizardMessages(QuickActionsWizard wizardInstance) { _wizard = wizardInstance; } public IEnumerable MissingInteractor() where TInteractor : Object, IInteractor where TInteractable : Object, IInteractable { if (FindAnyObjectByType() == null) { string interactorName = typeof(TInteractor).Name; string interactableName = typeof(TInteractable).Name; var message = new MessageData(MessageType.Warning, $"No {interactorName} found in scene. The new {interactableName} " + $"will not work without a {interactorName} present."); return Enumerable.Repeat(message, 1); } return Enumerable.Empty(); } public IEnumerable MissingPointableCanvasModule() where TInteractor : Object, IInteractor { void FixPointableCanvasModule() { GameObject eventSystemGO = FindFirstObjectByType()?.gameObject; Object newObj; if (eventSystemGO != null) { newObj = _wizard.AddComponent(eventSystemGO); } else { newObj = _wizard.AddObject("Pointable Canvas Module", typeof(EventSystem), typeof(PointableCanvasModule)); } Debug.Log($"{nameof(PointableCanvasModule)} Added to Scene.", newObj); } if (FindAnyObjectByType() == null) { string interactorName = typeof(TInteractor).Name; var message = new MessageData(MessageType.Warning, $"No PointableCanvasModule found in scene. The new {interactorName} " + "will not work without a PointableCanvasModule present.", new ButtonData("Fix", FixPointableCanvasModule)); return Enumerable.Repeat(message, 1); } return Enumerable.Empty(); } } } }