#if MA_VRCSDK3_AVATARS using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using nadena.dev.modular_avatar.core.editor.menu; using nadena.dev.modular_avatar.core.menu; using UnityEditor; using UnityEngine; using VRC.SDK3.Avatars.Components; using VRC.SDK3.Avatars.ScriptableObjects; using static nadena.dev.modular_avatar.core.editor.Localization; using static nadena.dev.modular_avatar.core.editor.Util; namespace nadena.dev.modular_avatar.core.editor { [CustomEditor(typeof(ModularAvatarMenuInstaller))] [CanEditMultipleObjects] internal class MenuInstallerEditor : MAEditorBase { private ModularAvatarMenuInstaller _installer; private Editor _innerMenuEditor; private VRCExpressionsMenu _menuToAppend; private bool _menuFoldout; private bool _devFoldout; private MenuPreviewGUI _previewGUI; private HashSet _avatarMenus; private VirtualMenu _virtualMenuCache; private Dictionary> _menuInstallersMap; private void OnEnable() { _installer = (ModularAvatarMenuInstaller) target; _previewGUI = new MenuPreviewGUI(Repaint); FindMenus(); FindMenuInstallers(); VRCAvatarDescriptor commonAvatar = FindCommonAvatar(); } private long _cacheSeq = -1; private ImmutableList _cachedTargets = null; private void CacheMenu() { if (VirtualMenu.CacheSequence == _cacheSeq && _cachedTargets != null && _virtualMenuCache != null) return; List> perTarget = new List>(); var commonAvatar = FindCommonAvatar(); if (commonAvatar == null) { _cacheSeq = VirtualMenu.CacheSequence; _cachedTargets = ImmutableList.Empty; _virtualMenuCache = null; return; } _virtualMenuCache = VirtualMenu.ForAvatar(commonAvatar); foreach (var target in targets) { var installer = (ModularAvatarMenuInstaller) target; var installTargets = _virtualMenuCache.GetInstallTargetsForInstaller(installer) .Select(o => (object) o).ToImmutableList(); if (installTargets.Any()) { perTarget.Add(installTargets); } else { perTarget.Add(ImmutableList.Empty.Add(installer.installTargetMenu)); } } for (int i = 1; i < perTarget.Count; i++) { if (perTarget[0].Count != perTarget[i].Count || perTarget[0].Zip(perTarget[i], (a, b) => (Resolve(a) != Resolve(b))).Any(differs => differs)) { perTarget.Clear(); perTarget.Add(ImmutableList.Empty); break; } } _cacheSeq = VirtualMenu.CacheSequence; _cachedTargets = perTarget[0]; object Resolve(object p0) { if (p0 is ModularAvatarMenuInstallTarget target && target != null) return target.transform.parent; return p0; } } // Interpretation: // : Inconsistent install targets // List of [null]: Install to root // List of [VRCExpMenu]: Install to expressions menu // List of [InstallTarget]: Install to single install target // List of [InstallTarget, InstallTarget ...]: Install to multiple install targets private ImmutableList InstallTargets { get { CacheMenu(); return _cachedTargets; } } private VirtualMenu _virtualMenu { get { CacheMenu(); return _virtualMenuCache; } } private void SetupMenuEditor() { if (targets.Length != 1) { _innerMenuEditor = null; _menuToAppend = null; } else if (_installer.menuToAppend != _menuToAppend) { if (_installer.menuToAppend == null) _innerMenuEditor = null; else { _innerMenuEditor = CreateEditor(_installer.menuToAppend); } _menuToAppend = _installer.menuToAppend; } } protected override void OnInnerInspectorGUI() { SetupMenuEditor(); var installTo = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.installTargetMenu)); var isEnabled = targets.Length != 1 || ((ModularAvatarMenuInstaller) target).enabled; VRCAvatarDescriptor commonAvatar = FindCommonAvatar(); if (InstallTargets.Count == 0) { // TODO - show warning for inconsistent targets? } else if (InstallTargets.Count > 0) { if (InstallTargets.Count == 1) { if (InstallTargets[0] == null) { if (isEnabled) { EditorGUILayout.HelpBox(S("menuinstall.help.hint_set_menu"), MessageType.Info); } } else if (InstallTargets[0] is VRCExpressionsMenu menu && !IsMenuReachable(RuntimeUtil.FindAvatarInParents(((Component) target).transform), menu)) { EditorGUILayout.HelpBox(S("menuinstall.help.hint_bad_menu"), MessageType.Error); } } if (InstallTargets.Count == 1 && (InstallTargets[0] is VRCExpressionsMenu || InstallTargets[0] == null)) { var displayValue = installTo.objectReferenceValue; if (displayValue == null) displayValue = commonAvatar.expressionsMenu; EditorGUI.BeginChangeCheck(); var newValue = EditorGUILayout.ObjectField(G("menuinstall.installto"), displayValue, typeof(VRCExpressionsMenu), false); if (EditorGUI.EndChangeCheck()) { installTo.objectReferenceValue = newValue; _cacheSeq = -1; } } else { using (new EditorGUI.DisabledScope(true)) { foreach (var target in InstallTargets) { if (target is VRCExpressionsMenu menu) { EditorGUILayout.ObjectField(G("menuinstall.installto"), menu, typeof(VRCExpressionsMenu), true); } else if (target is ModularAvatarMenuInstallTarget t) { EditorGUILayout.ObjectField(G("menuinstall.installto"), t.transform.parent.gameObject, typeof(GameObject), true); } } } } var avatar = commonAvatar; if (avatar != null && InstallTargets.Count == 1 && GUILayout.Button(G("menuinstall.selectmenu"))) { AvMenuTreeViewWindow.Show(avatar, _installer, menu => { if (InstallTargets.Count != 1 || menu == InstallTargets[0]) return; if (InstallTargets[0] is ModularAvatarMenuInstallTarget oldTarget && oldTarget != null) { DestroyInstallTargets(); } if (menu is ValueTuple vt) // TODO: This should be a named type... { // Menu, ContextCallback menu = vt.Item1; } if (menu is ModularAvatarMenuItem item) { if (item.MenuSource == SubmenuSource.MenuAsset) { menu = item.Control.subMenu; } else { var menuParent = item.menuSource_otherObjectChildren != null ? item.menuSource_otherObjectChildren : item.gameObject; menu = new MenuNodesUnder(menuParent); } } else if (menu is ModularAvatarMenuGroup group) { if (group.targetObject != null) menu = new MenuNodesUnder(group.targetObject); else menu = new MenuNodesUnder(group.gameObject); } if (menu is VRCExpressionsMenu expMenu) { if (expMenu == avatar.expressionsMenu) installTo.objectReferenceValue = null; else installTo.objectReferenceValue = expMenu; } else if (menu is RootMenu) { installTo.objectReferenceValue = null; } else if (menu is MenuNodesUnder nodesUnder) { installTo.objectReferenceValue = null; foreach (var target in targets.Cast().OrderBy(ObjectHierarchyOrder)) { var installer = (ModularAvatarMenuInstaller) target; var child = new GameObject(); Undo.RegisterCreatedObjectUndo(child, "Set install target"); child.transform.SetParent(nodesUnder.root.transform, false); child.name = installer.gameObject.name; var targetComponent = child.AddComponent(); targetComponent.installer = installer; EditorGUIUtility.PingObject(child); } } serializedObject.ApplyModifiedProperties(); VirtualMenu.InvalidateCaches(); Repaint(); }); } } if (targets.Length == 1) { _menuFoldout = EditorGUILayout.Foldout(_menuFoldout, G("menuinstall.showcontents")); if (_menuFoldout) { _previewGUI.DoGUI((ModularAvatarMenuInstaller) target); } } if (targets.Any(t => { var installer = (ModularAvatarMenuInstaller) t; return installer.GetComponent() == null && installer.menuToAppend != null; })) { if (GUILayout.Button("Extract menu to objects")) { ExtractMenu(); } } bool inconsistentSources = false; MenuSource menuSource = null; bool first = true; foreach (var target in targets) { var component = (ModularAvatarMenuInstaller) target; var componentSource = component.GetComponent(); if (componentSource != null) { if (menuSource == null && first) { menuSource = componentSource; } else { inconsistentSources = true; } } } if (menuSource != null) { // TODO localize EditorGUILayout.HelpBox("Menu contents provided by " + menuSource.GetType() + " component", MessageType.Info); } if (!inconsistentSources) { _devFoldout = EditorGUILayout.Foldout(_devFoldout, G("menuinstall.devoptions")); if (_devFoldout) { SerializedProperty menuToAppendProperty = serializedObject.FindProperty(nameof(ModularAvatarMenuInstaller.menuToAppend)); if (!menuToAppendProperty.hasMultipleDifferentValues) { switch (ValidateExpressionMenuIcon( (VRCExpressionsMenu) menuToAppendProperty.objectReferenceValue)) { case ValidateExpressionMenuIconResult.Success: break; case ValidateExpressionMenuIconResult.TooLarge: EditorGUILayout.HelpBox(S("menuinstall.menu_icon_too_large"), MessageType.Error); break; case ValidateExpressionMenuIconResult.Uncompressed: EditorGUILayout.HelpBox(S("menuinstall.menu_icon_uncompressed"), MessageType.Error); break; default: throw new ArgumentOutOfRangeException(); } } EditorGUI.indentLevel++; EditorGUILayout.PropertyField( menuToAppendProperty, new GUIContent(G("menuinstall.srcmenu"))); EditorGUI.indentLevel--; } } serializedObject.ApplyModifiedProperties(); Localization.ShowLanguageUI(); } private string ObjectHierarchyOrder(Component arg) { var list = new List(); var t = arg.transform; while (t != null) { list.Add(t.GetSiblingIndex()); t = t.parent; } list.Reverse(); return string.Join("", list.Select(n => (char) n)); } private void ExtractMenu() { serializedObject.ApplyModifiedProperties(); foreach (var t in targets) { var installer = (ModularAvatarMenuInstaller) t; if (installer.GetComponent() != null || installer.menuToAppend == null) continue; var menu = installer.menuToAppend; if (menu.controls.Count == 0) { continue; } Undo.RecordObject(installer, "Extract menu"); if (menu.controls.Count == 1) { // Attach control directly to the installer var item = installer.gameObject.AddComponent(); Undo.RegisterCreatedObjectUndo(item, "Extract menu"); MenuExtractor.ControlToMenuItem(item, menu.controls[0]); } else { // Use a menu group and attach items on a child var group = installer.gameObject.AddComponent(); var menuRoot = new GameObject(); menuRoot.name = "Menu"; Undo.RegisterCreatedObjectUndo(menuRoot, "Extract menu"); menuRoot.transform.SetParent(group.transform, false); foreach (var control in menu.controls) { var itemObject = new GameObject(); itemObject.gameObject.name = control.name; Undo.RegisterCreatedObjectUndo(itemObject, "Extract menu"); itemObject.transform.SetParent(menuRoot.transform, false); var item = itemObject.AddComponent(); MenuExtractor.ControlToMenuItem(item, control); } } PrefabUtility.RecordPrefabInstancePropertyModifications(installer); EditorUtility.SetDirty(installer); } } private void DestroyInstallTargets() { VirtualMenu menu = VirtualMenu.ForAvatar(FindCommonAvatar()); foreach (var t in targets) { foreach (var oldTarget in menu.GetInstallTargetsForInstaller((ModularAvatarMenuInstaller) t)) { if (PrefabUtility.IsPartOfPrefabInstance(oldTarget)) { Undo.RecordObject(oldTarget, "Change menu install target"); oldTarget.installer = null; PrefabUtility.RecordPrefabInstancePropertyModifications(oldTarget); } else { if (oldTarget.transform.childCount == 0 && oldTarget.GetComponents(typeof(Component)).Length == 2) { Undo.DestroyObjectImmediate(oldTarget.gameObject); } else { Undo.DestroyObjectImmediate(oldTarget); } } } } } private VRCAvatarDescriptor FindCommonAvatar() { VRCAvatarDescriptor commonAvatar = null; foreach (var target in targets) { var component = (ModularAvatarMenuInstaller) target; var avatar = RuntimeUtil.FindAvatarInParents(component.transform); if (avatar == null) return null; if (commonAvatar == null) { commonAvatar = avatar; } else if (commonAvatar != avatar) { return null; } } return commonAvatar; } private void FindMenus() { if (targets.Length > 1) { _avatarMenus = null; return; } _avatarMenus = new HashSet(); var queue = new Queue(); var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform); if (avatar == null || avatar.expressionsMenu == null) return; queue.Enqueue(avatar.expressionsMenu); while (queue.Count > 0) { var menu = queue.Dequeue(); if (_avatarMenus.Contains(menu)) continue; _avatarMenus.Add(menu); foreach (var subMenu in menu.controls) { if (subMenu.type == VRCExpressionsMenu.Control.ControlType.SubMenu && subMenu.subMenu != null) { queue.Enqueue(subMenu.subMenu); } } } } private void FindMenuInstallers() { if (targets.Length > 1) { _menuInstallersMap = null; return; } _menuInstallersMap = new Dictionary>(); var avatar = RuntimeUtil.FindAvatarInParents(((Component) target).transform); if (avatar == null) return; var menuInstallers = avatar.GetComponentsInChildren(true) .Where(menuInstaller => menuInstaller.enabled && menuInstaller.menuToAppend != null); foreach (ModularAvatarMenuInstaller menuInstaller in menuInstallers) { if (menuInstaller == target) continue; var visitedMenus = new HashSet(); var queue = new Queue(); queue.Enqueue(menuInstaller.menuToAppend); while (queue.Count > 0) { VRCExpressionsMenu parent = queue.Dequeue(); var controls = parent.controls.Where(control => control.type == VRCExpressionsMenu.Control.ControlType.SubMenu && control.subMenu != null); foreach (VRCExpressionsMenu.Control control in controls) { // Do not filter in LINQ to avoid closure allocation if (visitedMenus.Contains(control.subMenu)) continue; if (!_menuInstallersMap.TryGetValue(control.subMenu, out List fromInstallers)) { fromInstallers = new List(); _menuInstallersMap[control.subMenu] = fromInstallers; } fromInstallers.Add(menuInstaller); visitedMenus.Add(control.subMenu); queue.Enqueue(control.subMenu); } } } } private bool IsMenuReachable(VRCAvatarDescriptor avatar, VRCExpressionsMenu menu) { var virtualMenu = VirtualMenu.ForAvatar(avatar); return virtualMenu.ContainsMenu(menu); } private static ValidateExpressionMenuIconResult ValidateExpressionMenuIcon(VRCExpressionsMenu menu, HashSet visitedMenus = null) { if (menu == null) return ValidateExpressionMenuIconResult.Success; if (visitedMenus == null) visitedMenus = new HashSet(); if (visitedMenus.Contains(menu)) return ValidateExpressionMenuIconResult.Success; visitedMenus.Add(menu); foreach (VRCExpressionsMenu.Control control in menu.controls) { // Control ValidateExpressionMenuIconResult result = Util.ValidateExpressionMenuIcon(control.icon); if (result != ValidateExpressionMenuIconResult.Success) return result; // Labels if (control.labels != null) { foreach (VRCExpressionsMenu.Control.Label label in control.labels) { ValidateExpressionMenuIconResult labelResult = Util.ValidateExpressionMenuIcon(label.icon); if (labelResult != ValidateExpressionMenuIconResult.Success) return labelResult; } } // SubMenu if (control.type != VRCExpressionsMenu.Control.ControlType.SubMenu) continue; ValidateExpressionMenuIconResult subMenuResult = ValidateExpressionMenuIcon(control.subMenu, visitedMenus); if (subMenuResult != ValidateExpressionMenuIconResult.Success) return subMenuResult; } return ValidateExpressionMenuIconResult.Success; } } } #endif