diff --git a/Editor/FolderEditorUtils.cs b/Editor/FolderEditorUtils.cs index 9f67b89..db06312 100644 --- a/Editor/FolderEditorUtils.cs +++ b/Editor/FolderEditorUtils.cs @@ -9,7 +9,7 @@ namespace UnityHierarchyFolders.Editor { public static class FolderEditorUtils { - private const string _actionName = "Create Heirarchy Folder %#&N"; + private const string _actionName = "Create Hierarchy Folder %#&N"; /// Add new folder "prefab". /// Menu command information. @@ -30,9 +30,11 @@ public class FolderOnBuild : IProcessSceneWithReport public void OnProcessScene(Scene scene, BuildReport report) { + var strippingMode = report == null ? StripSettings.PlayMode : StripSettings.Build; + foreach (var folder in Object.FindObjectsOfType()) { - folder.Flatten(); + folder.Flatten(strippingMode, StripSettings.CapitalizeName); } } } diff --git a/Editor/Icon Handling.meta b/Editor/Icon Handling.meta new file mode 100644 index 0000000..85e73a5 --- /dev/null +++ b/Editor/Icon Handling.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9b9b359874c54b8e9968bce99b84b1cd +timeCreated: 1613920209 \ No newline at end of file diff --git a/Editor/Icon Handling/HierarchyFolderIcon.cs b/Editor/Icon Handling/HierarchyFolderIcon.cs new file mode 100644 index 0000000..6286677 --- /dev/null +++ b/Editor/Icon Handling/HierarchyFolderIcon.cs @@ -0,0 +1,172 @@ +#if UNITY_2019_1_OR_NEWER +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEditor.IMGUI.Controls; +using UnityEngine; +using UnityHierarchyFolders.Runtime; +using Object = UnityEngine.Object; + +namespace UnityHierarchyFolders.Editor +{ + public static class HierarchyFolderIcon + { +#if UNITY_2020_1_OR_NEWER + private const string _openedFolderPrefix = "FolderOpened"; +#else + private const string _openedFolderPrefix = "OpenedFolder"; +#endif + private const string _closedFolderPrefix = "Folder"; + + private static Texture2D _openFolderTexture; + private static Texture2D _closedFolderTexture; + private static Texture2D _openFolderSelectedTexture; + private static Texture2D _closedFolderSelectedTexture; + + private static bool _isInitialized; + private static bool _hasProcessedFrame = true; + + // Reflected members + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static PropertyInfo prop_sceneHierarchy; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static PropertyInfo prop_treeView; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static PropertyInfo prop_data; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static PropertyInfo prop_selectedIcon; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static PropertyInfo prop_objectPPTR; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static MethodInfo meth_getRows; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static MethodInfo meth_isExpanded; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "Special naming scheme")] + private static MethodInfo meth_getAllSceneHierarchyWindows; + + private static (Texture2D open, Texture2D closed)[] _coloredFolderIcons; + public static (Texture2D open, Texture2D closed) ColoredFolderIcons(int i) => _coloredFolderIcons[i]; + + public static int IconColumnCount => IconColors.GetLength(0); + public static int IconRowCount => IconColors.GetLength(1); + + private static readonly Color[,] IconColors = { + {new Color(0.09f, 0.57f, 0.82f), new Color(0.05f, 0.34f, 0.48f),}, + {new Color(0.09f, 0.67f, 0.67f), new Color(0.05f, 0.42f, 0.42f),}, + {new Color(0.23f, 0.73f, 0.36f), new Color(0.15f, 0.41f, 0.22f),}, + {new Color(0.55f, 0.35f, 0.71f), new Color(0.35f, 0.24f, 0.44f),}, + {new Color(0.78f, 0.27f, 0.55f), new Color(0.52f, 0.15f, 0.35f),}, + {new Color(0.80f, 0.66f, 0.10f), new Color(0.56f, 0.46f, 0.02f),}, + {new Color(0.91f, 0.49f, 0.13f), new Color(0.62f, 0.33f, 0.07f),}, + {new Color(0.91f, 0.30f, 0.24f), new Color(0.77f, 0.15f, 0.09f),}, + {new Color(0.35f, 0.49f, 0.63f), new Color(0.24f, 0.33f, 0.42f),}, + }; + + [InitializeOnLoadMethod] + private static void Startup() + { + EditorApplication.update += ResetFolderIcons; + EditorApplication.hierarchyWindowItemOnGUI += RefreshFolderIcons; + } + + private static void InitIfNeeded() + { + if (_isInitialized) { return; } + + _openFolderTexture = (Texture2D)EditorGUIUtility.IconContent($"{_openedFolderPrefix} Icon").image; + _closedFolderTexture = (Texture2D)EditorGUIUtility.IconContent($"{_closedFolderPrefix} Icon").image; + + // We could use the actual white folder icons but I prefer the look of the tinted white folder icon + // To use the actual white version: + // texture = (Texture2D) EditorGUIUtility.IconContent($"{OpenedFolderPrefix | ClosedFolderPrefix} On Icon").image; + _openFolderSelectedTexture = TextureHelper.GetWhiteTexture(_openFolderTexture, $"{_openedFolderPrefix} Icon White"); + _closedFolderSelectedTexture = TextureHelper.GetWhiteTexture(_closedFolderTexture, $"{_closedFolderPrefix} Icon White"); + + _coloredFolderIcons = new (Texture2D, Texture2D)[] { (_openFolderTexture, _closedFolderTexture) }; + + for (int row = 0; row < IconRowCount; row++) + { + for (int column = 0; column < IconColumnCount; column++) + { + int index = 1 + column + row * IconColumnCount; + var color = IconColors[column, row]; + + var openFolderIcon = TextureHelper.GetTintedTexture(_openFolderSelectedTexture, + color, $"{_openFolderSelectedTexture.name} {index}"); + var closedFolderIcon = TextureHelper.GetTintedTexture(_closedFolderSelectedTexture, + color, $"{_closedFolderSelectedTexture.name} {index}"); + + ArrayUtility.Add(ref _coloredFolderIcons, (openFolderIcon, closedFolderIcon)); + } + } + + // reflection + + const BindingFlags BindingAll = BindingFlags.Public + | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance; + + var assembly = typeof(SceneView).Assembly; + + var type_sceneHierarchyWindow = assembly.GetType("UnityEditor.SceneHierarchyWindow"); + meth_getAllSceneHierarchyWindows = type_sceneHierarchyWindow.GetMethod("GetAllSceneHierarchyWindows", BindingAll); + prop_sceneHierarchy = type_sceneHierarchyWindow.GetProperty("sceneHierarchy"); + + var type_sceneHierarchy = assembly.GetType("UnityEditor.SceneHierarchy"); + prop_treeView = type_sceneHierarchy.GetProperty("treeView", BindingAll); + + var type_treeViewController = assembly.GetType("UnityEditor.IMGUI.Controls.TreeViewController"); + prop_data = type_treeViewController.GetProperty("data", BindingAll); + + var type_iTreeViewDataSource = assembly.GetType("UnityEditor.IMGUI.Controls.ITreeViewDataSource"); + meth_getRows = type_iTreeViewDataSource.GetMethod("GetRows"); + meth_isExpanded = type_iTreeViewDataSource.GetMethod("IsExpanded", new Type[] { typeof(TreeViewItem) }); + + var type_gameObjectTreeViewItem = assembly.GetType("UnityEditor.GameObjectTreeViewItem"); + prop_selectedIcon = type_gameObjectTreeViewItem.GetProperty("selectedIcon", BindingAll); + prop_objectPPTR = type_gameObjectTreeViewItem.GetProperty("objectPPTR", BindingAll); + + _isInitialized = true; + } + + private static void ResetFolderIcons() + { + InitIfNeeded(); + _hasProcessedFrame = false; + } + + private static void RefreshFolderIcons(int instanceid, Rect selectionrect) + { + if (_hasProcessedFrame) { return; } + + _hasProcessedFrame = true; + + var windows = ((IEnumerable)meth_getAllSceneHierarchyWindows.Invoke(null, Array.Empty())).Cast().ToList(); + foreach (var window in windows) + { + object sceneHierarchy = prop_sceneHierarchy.GetValue(window); + object treeView = prop_treeView.GetValue(sceneHierarchy); + object data = prop_data.GetValue(treeView); + + var rows = (IList)meth_getRows.Invoke(data, Array.Empty()); + foreach (var item in rows) + { + var itemObject = (Object)prop_objectPPTR.GetValue(item); + if (!Folder.TryGetIconIndex(itemObject, out int colorIndex)) { continue; } + + bool isExpanded = (bool)meth_isExpanded.Invoke(data, new object[] { item }); + + var icons = ColoredFolderIcons(Mathf.Clamp(colorIndex, 0, _coloredFolderIcons.Length - 1)); + + item.icon = isExpanded ? icons.open : icons.closed; + + prop_selectedIcon.SetValue(item, isExpanded ? _openFolderSelectedTexture : _closedFolderSelectedTexture); + } + } + } + } +} +#endif \ No newline at end of file diff --git a/Editor/Icon Handling/HierarchyFolderIcon.cs.meta b/Editor/Icon Handling/HierarchyFolderIcon.cs.meta new file mode 100644 index 0000000..94a703e --- /dev/null +++ b/Editor/Icon Handling/HierarchyFolderIcon.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d6f29c3d38f5ea94dbfbf44facaeaaca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Icon Handling/ReplaceColor Shader.shader b/Editor/Icon Handling/ReplaceColor Shader.shader new file mode 100644 index 0000000..a442a67 --- /dev/null +++ b/Editor/Icon Handling/ReplaceColor Shader.shader @@ -0,0 +1,21 @@ +Shader "UI/Replace color" { + +Properties +{ + _Color ("Replace Color", Color) = (1,1,1) + _MainTex ("Texture", 2D) = "white" +} + +SubShader +{ + Pass + { + SetTexture [_MainTex] + { + ConstantColor [_Color] + combine constant + texture, texture + } + } +} + +} diff --git a/Editor/Icon Handling/ReplaceColor Shader.shader.meta b/Editor/Icon Handling/ReplaceColor Shader.shader.meta new file mode 100644 index 0000000..3852e47 --- /dev/null +++ b/Editor/Icon Handling/ReplaceColor Shader.shader.meta @@ -0,0 +1,10 @@ +fileFormatVersion: 2 +guid: 7b9019de4b525b349a48678a64d7983a +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + preprocessorOverride: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/Icon Handling/TextureHelper.cs b/Editor/Icon Handling/TextureHelper.cs new file mode 100644 index 0000000..5ca3ad7 --- /dev/null +++ b/Editor/Icon Handling/TextureHelper.cs @@ -0,0 +1,191 @@ +// This software includes third-party software subject to the associated copyrights, as follows: +// +// Name: SolidUtilities +// Repo: https://github.com/SolidAlloy/SolidUtilities +// License: MIT (https://github.com/SolidAlloy/SolidUtilities/blob/master/LICENSE) +// Copyright (c) 2020 SolidAlloy + +namespace UnityHierarchyFolders.Editor +{ + using System; + using JetBrains.Annotations; + using UnityEngine; + + /// Helps to create new textures. + internal static class TextureHelper + { + private static readonly Color _fullyTransparent = new Color(1f, 1f, 1f, 0f); + + private static Material _tintMaterial; + private static Material _colorReplaceMaterial; + + public static Texture2D GetTintedTexture(Texture2D original, Color tint, string name) + { + return GetTextureWithMaterial(original, GetTintMaterial(tint), name); + } + + public static Texture2D GetWhiteTexture(Texture2D original, string name) + { + return GetTextureWithMaterial(original, GetColorReplaceMaterial(Color.white), name); + } + + private static Material GetTintMaterial(Color tint) + { + if (_tintMaterial == null) + _tintMaterial = new Material(Shader.Find("UI/Default")); + + _tintMaterial.color = tint; + return _tintMaterial; + } + + private static Material GetColorReplaceMaterial(Color color) + { + if (_colorReplaceMaterial == null) + _colorReplaceMaterial = new Material(Shader.Find("UI/Replace color")); + + _colorReplaceMaterial.color = color; + return _colorReplaceMaterial; + } + + private static Texture2D GetTextureWithMaterial(Texture2D original, Material material, string name) + { + Texture2D newTexture; + + using (new SRGBWriteScope(true)) + { + using (var temporary = new TemporaryActiveTexture(original.width, original.height, 0)) + { + GL.Clear(false, true, _fullyTransparent); + + Graphics.Blit(original, temporary, material); + + newTexture = new Texture2D(original.width, original.width, TextureFormat.ARGB32, false, true) + { + name = name, + filterMode = FilterMode.Bilinear, + hideFlags = HideFlags.DontSave + }; + + newTexture.ReadPixels(new Rect(0f, 0f, original.width, original.width), 0, 0); + newTexture.alphaIsTransparency = true; + newTexture.Apply(); + } + } + + return newTexture; + } + + /// + /// Temporarily sets to the passed value, then returns it back. + /// + [PublicAPI] + public readonly struct SRGBWriteScope : IDisposable + { + private readonly bool _previousValue; + + /// Temporarily sets to true, then executes the action. + /// Temporary value of . + /// + /// using (new SRGBWriteScope(true)) + /// { + /// GL.Clear(false, true, new Color(1f, 1f, 1f, 0f)); + /// Graphics.Blit(Default, temporary, material); + /// }); + /// + public SRGBWriteScope(bool enableWrite) + { + _previousValue = GL.sRGBWrite; + GL.sRGBWrite = enableWrite; + } + + public void Dispose() + { + GL.sRGBWrite = _previousValue; + } + } + + /// + /// Creates a temporary texture, sets it as active in , then removes the changes + /// and sets the previous active texture back automatically. + /// + /// + /// + /// using (var temporaryActiveTexture = new TemporaryActiveTexture(icon.width, icon.height, 0)) + /// { + /// Graphics.Blit(icon, temporary, material); + /// }); + /// + [PublicAPI] + public class TemporaryActiveTexture : IDisposable + { + private readonly RenderTexture _previousActiveTexture; + private readonly TemporaryRenderTexture _value; + + /// + /// Creates a temporary texture, sets it as active in , then removes it + /// and sets the previous active texture back automatically. + /// + /// Width of the temporary texture in pixels. + /// Height of the temporary texture in pixels. + /// Depth buffer of the temporary texture. + /// + /// + /// using (var temporaryActiveTexture = new TemporaryActiveTexture(icon.width, icon.height, 0)) + /// { + /// Graphics.Blit(icon, temporary, material); + /// }); + /// + public TemporaryActiveTexture(int width, int height, int depthBuffer) + { + _previousActiveTexture = RenderTexture.active; + _value = new TemporaryRenderTexture(width, height, depthBuffer); + RenderTexture.active = _value; + } + + public static implicit operator RenderTexture(TemporaryActiveTexture temporaryTexture) => temporaryTexture._value; + + public void Dispose() + { + _value.Dispose(); + RenderTexture.active = _previousActiveTexture; + } + } + + /// Creates a temporary texture that can be used and then removed automatically. + /// + /// + /// using (var temporaryTexture = new TemporaryRenderTexture(icon.width, icon.height, 0)) + /// { + /// Graphics.Blit(icon, temporaryTexture, material); + /// }); + /// + [PublicAPI] + public class TemporaryRenderTexture : IDisposable + { + private readonly RenderTexture _value; + + /// Creates a temporary texture that can be used and then removed automatically. + /// Width of the temporary texture in pixels. + /// Height of the temporary texture in pixels. + /// Depth buffer of the temporary texture. + /// + /// + /// using (var temporaryTexture = new TemporaryRenderTexture(icon.width, icon.height, 0)) + /// { + /// Graphics.Blit(icon, temporaryTexture, material); + /// }); + /// + public TemporaryRenderTexture(int width, int height, int depthBuffer) + { + _value = RenderTexture.GetTemporary(width, height, depthBuffer); + } + + public static implicit operator RenderTexture(TemporaryRenderTexture temporaryRenderTexture) => temporaryRenderTexture._value; + + public void Dispose() + { + RenderTexture.ReleaseTemporary(_value); + } + } + } +} \ No newline at end of file diff --git a/Editor/Icon Handling/TextureHelper.cs.meta b/Editor/Icon Handling/TextureHelper.cs.meta new file mode 100644 index 0000000..631a335 --- /dev/null +++ b/Editor/Icon Handling/TextureHelper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1d329b59d6a342b995af79657ee830da +timeCreated: 1613913214 \ No newline at end of file diff --git a/Editor/Prefab Handling.meta b/Editor/Prefab Handling.meta new file mode 100644 index 0000000..289be7e --- /dev/null +++ b/Editor/Prefab Handling.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 622698139ac84600a4ba37348572c363 +timeCreated: 1613932303 \ No newline at end of file diff --git a/Editor/Prefab Handling/AssetImportGrouper.cs b/Editor/Prefab Handling/AssetImportGrouper.cs new file mode 100644 index 0000000..e62d102 --- /dev/null +++ b/Editor/Prefab Handling/AssetImportGrouper.cs @@ -0,0 +1,27 @@ +namespace UnityHierarchyFolders.Editor +{ + using System; + using UnityEditor; + + internal class AssetImportGrouper : IDisposable + { + private static AssetImportGrouper _instance; + + private AssetImportGrouper() { } + + public static AssetImportGrouper Init() + { + AssetDatabase.StartAssetEditing(); + + if (_instance == null) + _instance = new AssetImportGrouper(); + + return _instance; + } + + public void Dispose() + { + AssetDatabase.StopAssetEditing(); + } + } +} \ No newline at end of file diff --git a/Editor/Prefab Handling/AssetImportGrouper.cs.meta b/Editor/Prefab Handling/AssetImportGrouper.cs.meta new file mode 100644 index 0000000..cee6ad7 --- /dev/null +++ b/Editor/Prefab Handling/AssetImportGrouper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f8101e4361cb4d5db8f6b03b4add2359 +timeCreated: 1614092011 \ No newline at end of file diff --git a/Editor/Prefab Handling/ChangedPrefabs.cs b/Editor/Prefab Handling/ChangedPrefabs.cs new file mode 100644 index 0000000..a39f408 --- /dev/null +++ b/Editor/Prefab Handling/ChangedPrefabs.cs @@ -0,0 +1,116 @@ +namespace UnityHierarchyFolders.Editor +{ + using System; + using System.Collections; + using System.Collections.Generic; + using UnityEditor; + using UnityEngine; + + /// + /// A singleton that contains info about edited prefabs and persists changes between domain reloads. + /// + [Serializable] + internal class ChangedPrefabs : IEnumerable> + { + private const string KeyName = nameof(ChangedPrefabs); + + [SerializeField] private string[] _guids; + [SerializeField] private string[] _contents; + + private static ChangedPrefabs _instance; + + public static ChangedPrefabs Instance + { + get + { + // _instance is null only in PrefabFolderStripper.RevertChanges() when Instance is called for the first time. + // If _instance is null at that point, it means the domain reloaded, so the instance must be retrieved from PlayerPrefs. + // In all other cases, _instance is created with help of Initialize before operating on it, so FromDeserialized won't be called. + if (_instance == null) + { + _instance = FromDeserialized(); + } + + return _instance; + } + } + + public (string guid, string content) this[int index] + { + get => (_guids[index], _contents[index]); + set + { + _guids[index] = value.guid; + _contents[index] = value.content; + } + } + + public static void Initialize(int length) + { + _instance = new ChangedPrefabs + { + _guids = new string[length], + _contents = new string[length] + }; + } + + public static void SerializeIfNeeded() + { + // Serialization is only needed if prefabs are edited before entering play mode and the domain will reload. + // In all other cases, changes to prefabs will be reverted before a domain reload. +#if UNITY_2019_3_OR_NEWER + if (EditorSettings.enterPlayModeOptionsEnabled && EditorSettings.enterPlayModeOptions.HasFlag(EnterPlayModeOptions.DisableDomainReload)) + return; +#endif + + string serializedObject = EditorJsonUtility.ToJson(Instance); + PlayerPrefs.SetString(KeyName, serializedObject); + } + + private static ChangedPrefabs FromDeserialized() + { + string serializedObject = PlayerPrefs.GetString(KeyName); + PlayerPrefs.DeleteKey(KeyName); + var instance = new ChangedPrefabs(); + EditorJsonUtility.FromJsonOverwrite(serializedObject, instance); + return instance; + } + + public Enumerator GetEnumerator() => new Enumerator(this); + + IEnumerator<(string, string)> IEnumerable>.GetEnumerator() + { + return GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public struct Enumerator : IEnumerator> + { + private readonly ChangedPrefabs _instance; + private int _index; + + public Enumerator(ChangedPrefabs instance) + { + _instance = instance; + _index = -1; + } + + public bool MoveNext() + { + return ++_index < Instance._guids.Length; + } + + public void Reset() => _index = 0; + + public (string, string) Current => _instance[_index]; + + object IEnumerator.Current => Current; + + public void Dispose() { } + } + } +} \ No newline at end of file diff --git a/Editor/Prefab Handling/ChangedPrefabs.cs.meta b/Editor/Prefab Handling/ChangedPrefabs.cs.meta new file mode 100644 index 0000000..2da1215 --- /dev/null +++ b/Editor/Prefab Handling/ChangedPrefabs.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 730dba92b19e4b9f81f4fb7583b55b0d +timeCreated: 1614076027 \ No newline at end of file diff --git a/Editor/Prefab Handling/LabelHandler.cs b/Editor/Prefab Handling/LabelHandler.cs new file mode 100644 index 0000000..9ead837 --- /dev/null +++ b/Editor/Prefab Handling/LabelHandler.cs @@ -0,0 +1,63 @@ +namespace UnityHierarchyFolders.Editor +{ + using System.Linq; + using Runtime; + using UnityEditor; + using UnityEngine; + + public class LabelHandler : AssetPostprocessor + { + public const string FolderPrefabLabel = "FolderUser"; + + private static void OnPostprocessAllAssets(string[] importedAssets, string[] _, string[] __, string[] ___) + { + // Group imports into one to improve performance in case there are multiple prefabs that need a label change. + using (AssetImportGrouper.Init()) + { + foreach (string assetPath in importedAssets) + { + if (assetPath.EndsWith(".prefab")) + HandlePrefabLabels(assetPath); + } + } + } + + private static void HandlePrefabLabels(string assetPath) + { + var asset = AssetDatabase.LoadAssetAtPath(assetPath); + + if (asset.GetComponentsInChildren().Length == 0) + { + RemoveFolderLabel(asset, assetPath); + } + else + { + AddFolderLabel(asset, assetPath); + } + } + + private static void RemoveFolderLabel(GameObject assetObject, string assetPath) + { + var labels = AssetDatabase.GetLabels(assetObject); + + if ( ! labels.Contains(FolderPrefabLabel)) + return; + + ArrayUtility.Remove(ref labels, FolderPrefabLabel); + AssetDatabase.SetLabels(assetObject, labels); + AssetDatabase.ImportAsset(assetPath); + } + + private static void AddFolderLabel(GameObject assetObject, string assetPath) + { + var labels = AssetDatabase.GetLabels(assetObject); + + if (labels.Contains(FolderPrefabLabel)) + return; + + ArrayUtility.Add(ref labels, FolderPrefabLabel); + AssetDatabase.SetLabels(assetObject, labels); + AssetDatabase.ImportAsset(assetPath); + } + } +} \ No newline at end of file diff --git a/Editor/Prefab Handling/LabelHandler.cs.meta b/Editor/Prefab Handling/LabelHandler.cs.meta new file mode 100644 index 0000000..aaf748f --- /dev/null +++ b/Editor/Prefab Handling/LabelHandler.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 87092d606cab43079d7ad3ed41b9ab08 +timeCreated: 1613932316 \ No newline at end of file diff --git a/Editor/Prefab Handling/PrefabFolderStripper.cs b/Editor/Prefab Handling/PrefabFolderStripper.cs new file mode 100644 index 0000000..4b5baf3 --- /dev/null +++ b/Editor/Prefab Handling/PrefabFolderStripper.cs @@ -0,0 +1,181 @@ +namespace UnityHierarchyFolders.Editor +{ + using System; + using System.IO; + using System.Linq; + using Runtime; + using UnityEditor; + using UnityEditor.Build; + using UnityEditor.Build.Reporting; + using UnityEditor.Callbacks; + using UnityEngine; + using Object = UnityEngine.Object; + + [InitializeOnLoad] + public class PrefabFolderStripper : IPreprocessBuildWithReport, IPostprocessBuildWithReport + { + static PrefabFolderStripper() + { + EditorApplication.playModeStateChanged += HandlePrefabsOnPlayMode; + } + + public int callbackOrder => 0; + + public void OnPreprocessBuild(BuildReport report) + { + if (StripSettings.StripFoldersFromPrefabsInBuild) + { + using (AssetImportGrouper.Init()) + { + StripFoldersFromDependentPrefabs(); + } + } + } + + public void OnPostprocessBuild(BuildReport report) + { + if (StripSettings.StripFoldersFromPrefabsInBuild) + RevertChanges(); + } + + private static void HandlePrefabsOnPlayMode(PlayModeStateChange state) + { + if ( ! StripSettings.StripFoldersFromPrefabsInPlayMode || StripSettings.PlayMode == StrippingMode.DoNothing) + return; + + // Calling it not in EnteredPlayMode because scripts may instantiate prefabs in Awake or OnEnable + // which happens before EnteredPlayMode. + if (state == PlayModeStateChange.ExitingEditMode) + { + // Stripping folders from all prefabs in the project instead of only the ones referenced in the scenes + // because a prefab may be hot-swapped in Play Mode. + using (AssetImportGrouper.Init()) + { + StripFoldersFromAllPrefabs(); + } + } + else if (state == PlayModeStateChange.ExitingPlayMode) + { + RevertChanges(); + } + } + + private static void StripFoldersFromDependentPrefabs() + { + var scenePaths = EditorBuildSettings.scenes.Select(scene => scene.path).ToArray(); + var dependentAssetsPaths = AssetDatabase.GetDependencies(scenePaths, true); + + var prefabsWithLabel = dependentAssetsPaths.Where(path => + AssetDatabase.GetLabels(GetAssetForLabel(path)).Contains(LabelHandler.FolderPrefabLabel)) + .ToArray(); + + ChangedPrefabs.Initialize(prefabsWithLabel.Length); + + for (int i = 0; i < prefabsWithLabel.Length; i++) + { + string path = prefabsWithLabel[i]; + ChangedPrefabs.Instance[i] = (AssetDatabase.AssetPathToGUID(path), File.ReadAllText(path)); + StripFoldersFromPrefab(path, StripSettings.Build); + } + + // Serialization of ChangedPrefabs is not needed here because domain doesn't reload before changes are reverted. + } + + private static +#if UNITY_2020_1_OR_NEWER + GUID +#else + UnityEngine.Object +#endif + GetAssetForLabel(string path) + { +#if UNITY_2020_1_OR_NEWER + return AssetDatabase.GUIDFromAssetPath(path); +#else + return AssetDatabase.LoadAssetAtPath(path); +#endif + } + + private static void StripFoldersFromAllPrefabs() + { + var prefabGUIDs = AssetDatabase.FindAssets($"l: {LabelHandler.FolderPrefabLabel}"); + ChangedPrefabs.Initialize(prefabGUIDs.Length); + + for (int i = 0; i < prefabGUIDs.Length; i++) + { + string guid = prefabGUIDs[i]; + string path = AssetDatabase.GUIDToAssetPath(guid); + + ChangedPrefabs.Instance[i] = (guid, File.ReadAllText(path)); + StripFoldersFromPrefab(path, StripSettings.PlayMode); + } + + // If domain reload is enabled in Play Mode Options, serialization of the changed prefabs is necessary + // so that changes can be reverted after leaving play mode. + ChangedPrefabs.SerializeIfNeeded(); + } + + private static void StripFoldersFromPrefab(string prefabPath, StrippingMode strippingMode) + { + using (var temp = new EditPrefabContentsScope(prefabPath)) + { + var folders = temp.PrefabContentsRoot.GetComponentsInChildren(); + + foreach (Folder folder in folders) + { + if (folder.gameObject == temp.PrefabContentsRoot) + { + Debug.LogWarning( + $"Hierarchy will not flatten for {prefabPath} because its root is a folder. " + + "It's advised to make the root an empty game object."); + + Object.DestroyImmediate(folder); + } + else + { + folder.Flatten(strippingMode, StripSettings.CapitalizeName); + } + } + } + } + + private static void RevertChanges() + { + foreach ((string guid, string content) in ChangedPrefabs.Instance) + { + string path = AssetDatabase.GUIDToAssetPath(guid); + + // The asset might have been deleted in Play Mode. Additionally, event if the asset is deleted, + // AssetDatabase might still hold a reference to it, so a File.Exists check is needed. + if (string.IsNullOrEmpty(path) || ! File.Exists(path)) + continue; + + File.WriteAllText(path, content); + } + + AssetDatabase.Refresh(); + } + + /// + /// A copy of for backwards compatibility with Unity 2019. + /// + private readonly struct EditPrefabContentsScope : IDisposable + { + public readonly GameObject PrefabContentsRoot; + + private readonly string _assetPath; + + public EditPrefabContentsScope(string assetPath) + { + PrefabContentsRoot = PrefabUtility.LoadPrefabContents(assetPath); + _assetPath = assetPath; + } + + public void Dispose() + { + PrefabUtility.SaveAsPrefabAsset(PrefabContentsRoot, _assetPath); + PrefabUtility.UnloadPrefabContents(PrefabContentsRoot); + } + } + } +} \ No newline at end of file diff --git a/Editor/Prefab Handling/PrefabFolderStripper.cs.meta b/Editor/Prefab Handling/PrefabFolderStripper.cs.meta new file mode 100644 index 0000000..3f7666c --- /dev/null +++ b/Editor/Prefab Handling/PrefabFolderStripper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9ef711838a3d4ac7ae9b012d5a52f29e +timeCreated: 1613934498 \ No newline at end of file diff --git a/Editor/Settings.meta b/Editor/Settings.meta new file mode 100644 index 0000000..4c6e58d --- /dev/null +++ b/Editor/Settings.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5ad62f65af064679bce3c5950b3afaed +timeCreated: 1614010587 \ No newline at end of file diff --git a/Editor/Settings/SettingsDrawer.cs b/Editor/Settings/SettingsDrawer.cs new file mode 100644 index 0000000..d33f3bb --- /dev/null +++ b/Editor/Settings/SettingsDrawer.cs @@ -0,0 +1,114 @@ +namespace UnityHierarchyFolders.Editor +{ + using System; + using System.Collections.Generic; + using Runtime; + using UnityEditor; + using UnityEngine; + + internal static class SettingsDrawer + { + /// + /// Field names of corresponding settings. Each field name can be accessed by the name of the setting variable. + /// + private static readonly Dictionary _fieldNames = new Dictionary + { + { nameof(StripSettings.PlayMode), "Play Mode Stripping Type" }, + { nameof(StripSettings.Build), "Build Stripping Type" }, + { nameof(StripSettings.CapitalizeName), "Capitalize Folder Names" }, + { nameof(StripSettings.StripFoldersFromPrefabsInPlayMode), "Strip folders from prefabs in Play Mode" }, + { nameof(StripSettings.StripFoldersFromPrefabsInBuild), "Strip folders from prefabs in build" }, + }; + + private static readonly GUIContent _buildStrippingName = new GUIContent(_fieldNames[nameof(StripSettings.Build)]); + + [SettingsProvider] + public static SettingsProvider CreateSettingsProvider() + { + return new SettingsProvider("Preferences/Hierarchy Folders", SettingsScope.User) + { + guiHandler = OnGUI, + keywords = GetKeywords() + }; + } + + private static void OnGUI(string searchContext) + { + StripSettings.PlayMode = (StrippingMode) EditorGUILayout.EnumPopup( + _fieldNames[nameof(StripSettings.PlayMode)], StripSettings.PlayMode); + + if (StripSettings.PlayMode == StrippingMode.ReplaceWithSeparator) + { + StripSettings.CapitalizeName = EditorGUILayout.Toggle( + _fieldNames[nameof(StripSettings.CapitalizeName)], StripSettings.CapitalizeName); + } + + StripSettings.Build = (StrippingMode) EditorGUILayout.EnumPopup( + _buildStrippingName, StripSettings.Build, TypeCanBeInBuild, true); + + EditorGUILayout.Space(EditorGUIUtility.singleLineHeight); + + if (StripSettings.StripFoldersFromPrefabsInPlayMode) + { + EditorGUILayout.HelpBox( + "If you notice that entering play mode takes too long, you can try disabling this option. " + + "Folders will not be stripped from prefabs that are instantiated at runtime, but if performance in " + + "Play Mode does not matter, you will be fine.", MessageType.Info); + } + + using (new TemporaryLabelWidth(230f)) + { + StripSettings.StripFoldersFromPrefabsInPlayMode = + EditorGUILayout.Toggle(_fieldNames[nameof(StripSettings.StripFoldersFromPrefabsInPlayMode)], StripSettings.StripFoldersFromPrefabsInPlayMode); + + StripSettings.StripFoldersFromPrefabsInBuild = + EditorGUILayout.Toggle(_fieldNames[nameof(StripSettings.StripFoldersFromPrefabsInBuild)], StripSettings.StripFoldersFromPrefabsInBuild); + } + } + + private static HashSet GetKeywords() + { + var keywords = new HashSet(); + + foreach (string fieldName in _fieldNames.Values) + { + keywords.AddWords(fieldName); + } + + return keywords; + } + + private static void AddWords(this HashSet set, string phrase) + { + foreach (string word in phrase.Split(' ')) + { + set.Add(word); + } + } + + private static bool TypeCanBeInBuild(Enum enumValue) + { + var mode = (StrippingMode) enumValue; + return mode == StrippingMode.PrependWithFolderName || mode == StrippingMode.Delete; + } + + /// + /// Temporarily sets to a certain value, than reverts it. + /// + private readonly struct TemporaryLabelWidth : IDisposable + { + private readonly float _oldWidth; + + public TemporaryLabelWidth(float width) + { + _oldWidth = EditorGUIUtility.labelWidth; + EditorGUIUtility.labelWidth = width; + } + + public void Dispose() + { + EditorGUIUtility.labelWidth = _oldWidth; + } + } + } +} \ No newline at end of file diff --git a/Editor/Settings/SettingsDrawer.cs.meta b/Editor/Settings/SettingsDrawer.cs.meta new file mode 100644 index 0000000..0697ef5 --- /dev/null +++ b/Editor/Settings/SettingsDrawer.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 023a1385294f41a6a7d8ae3844629a7c +timeCreated: 1613836312 \ No newline at end of file diff --git a/Editor/Settings/StripSettings.cs b/Editor/Settings/StripSettings.cs new file mode 100644 index 0000000..970eeb2 --- /dev/null +++ b/Editor/Settings/StripSettings.cs @@ -0,0 +1,95 @@ +namespace UnityHierarchyFolders.Editor +{ + using Runtime; + using UnityEditor; + using UnityEditor.SettingsManagement; + + internal static class StripSettings + { + private const string PackageName = "com.xsduan.hierarchy-folders"; + + private static Settings _instance; + private static UserSetting _playModeSetting; + private static UserSetting _buildSetting; + private static UserSetting _capitalizeName; + private static UserSetting _stripFoldersFromPrefabsInPlayMode; + private static UserSetting _stripFoldersFromPrefabsInBuild; + + public static StrippingMode PlayMode + { + get + { + InitializeIfNeeded(); + return _playModeSetting.value; + } + + set => _playModeSetting.value = value; + } + + public static StrippingMode Build + { + get + { + InitializeIfNeeded(); + return _buildSetting.value; + } + + set => _buildSetting.value = value; + } + + public static bool CapitalizeName + { + get + { + InitializeIfNeeded(); + return _capitalizeName.value; + } + + set => _capitalizeName.value = value; + } + + public static bool StripFoldersFromPrefabsInPlayMode + { + get + { + InitializeIfNeeded(); + return _stripFoldersFromPrefabsInPlayMode.value; + } + + set => _stripFoldersFromPrefabsInPlayMode.value = value; + } + + public static bool StripFoldersFromPrefabsInBuild + { + get + { + InitializeIfNeeded(); + return _stripFoldersFromPrefabsInBuild.value; + } + + set => _stripFoldersFromPrefabsInBuild.value = value; + } + + private static void InitializeIfNeeded() + { + if (_instance != null) + return; + + _instance = new Settings(PackageName); + + _playModeSetting = new UserSetting(_instance, nameof(_playModeSetting), + StrippingMode.PrependWithFolderName, SettingsScope.User); + + _buildSetting = new UserSetting(_instance, nameof(_buildSetting), + StrippingMode.PrependWithFolderName, SettingsScope.User); + + _capitalizeName = new UserSetting(_instance, nameof(_capitalizeName), true, SettingsScope.User); + + _stripFoldersFromPrefabsInPlayMode = new UserSetting(_instance, + nameof(_stripFoldersFromPrefabsInPlayMode), true, SettingsScope.User); + + _stripFoldersFromPrefabsInBuild = new UserSetting(_instance, + nameof(_stripFoldersFromPrefabsInBuild), true, SettingsScope.User); + } + } +} \ No newline at end of file diff --git a/Editor/Settings/StripSettings.cs.meta b/Editor/Settings/StripSettings.cs.meta new file mode 100644 index 0000000..9069fe2 --- /dev/null +++ b/Editor/Settings/StripSettings.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 56180820a6f84c2b8654c24c9dddb507 +timeCreated: 1613843945 \ No newline at end of file diff --git a/Editor/UnityHierarchyFolders.Editor.asmdef b/Editor/UnityHierarchyFolders.Editor.asmdef index 764271f..6fee4f1 100644 --- a/Editor/UnityHierarchyFolders.Editor.asmdef +++ b/Editor/UnityHierarchyFolders.Editor.asmdef @@ -1,9 +1,10 @@ { "name": "UnityHierarchyFolders.Editor", + "rootNamespace": "", "references": [ - "UnityHierarchyFolders.Runtime" + "UnityHierarchyFolders.Runtime", + "Unity.Settings.Editor" ], - "optionalUnityReferences": [], "includePlatforms": [ "Editor" ], @@ -12,5 +13,7 @@ "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, - "defineConstraints": [] + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false } \ No newline at end of file diff --git a/README.md b/README.md index 8348f1e..e45c21b 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,21 @@ To install OpenUPM, please see the [documentation][2]. [2]: https://openupm.com/docs/ +## Stripping Modes + +You can choose how exactly the folder will be removed from the hierarchy in **Preferences -> Hierarchy Folders**. + +The following stripping modes are available: + +- **Prepend With Folder Name** - The folder will be removed, and all child objects will be prepended with the folder name (e.g. childObject => Folder/childObject). This is the default behaviour. +- **Delete** - The folder will be removed, and names of child objects will not change. +- **Do Nothing** *(available only for Play Mode)* - The folder will not be removed, the hierarchy will not change in play mode. Use this mode if you don't need extra performance in Editor. +- **Replace With Separator** *(available only for Play Mode)* - The hierarchy will flatten, and the folder will be replaced with a separator (e.g. "--- FOLDER ---"). Useful if you need extra performance in Editor but still want to see what folder game objects belong to. + +## Stripping folders from prefabs + +With this plugin, it is possible to strip folders from prefabs that are not present in the scene but are instantiated at runtime. Upon entering Play Mode, the plugin goes through all prefabs containing folders and strips them. On exiting Play Mode, the changes are reverted. It shouldn't add significant overhead unless you have thousands of prefabs with folders inside, but if entering Play Mode takes too long, you can try disabling this option in **Preferences -> Hierarchy Folders**. You can also choose whether to strip folders from prefabs before they are packed into a build. + ## Possible FAQs ### Why folders in the first place? diff --git a/Runtime/Folder.cs b/Runtime/Folder.cs index b51a630..70d5fad 100644 --- a/Runtime/Folder.cs +++ b/Runtime/Folder.cs @@ -183,18 +183,52 @@ private void Update() } /// Takes direct children and links them to the parent transform or global. - public void Flatten() + /// Stripping mode to apply. + /// + /// Whether to capitalize the folder name when replacing it with a separator. + /// Applies only if is + /// + public void Flatten(StrippingMode strippingMode, bool capitalizeFolderName) + { + if (strippingMode == StrippingMode.DoNothing) + return; + + MoveChildrenOut(strippingMode); + + HandleSelf(strippingMode, capitalizeFolderName); + } + + private void MoveChildrenOut(StrippingMode strippingMode) { - // gather first-level children int index = this.transform.GetSiblingIndex(); // keep components in logical order - foreach (var child in this.transform.GetComponentsInChildren(includeInactive: true)) + + foreach (var child in GetComponentsInChildren(includeInactive: true)) { - if (child.parent == this.transform) + // gather only first-level children + if (child.parent != this.transform) + continue; + + if (strippingMode == StrippingMode.PrependWithFolderName) { child.name = $"{this.name}/{child.name}"; - child.SetParent(this.transform.parent, true); - child.SetSiblingIndex(++index); } + + child.SetParent(this.transform.parent, true); + child.SetSiblingIndex(++index); + } + } + + private void HandleSelf(StrippingMode strippingMode, bool capitalizeFolderName) + { + if (strippingMode == StrippingMode.ReplaceWithSeparator) + { + // If the folder name is already a separator, don't change it. + if ( ! name.StartsWith("--- ")) + { + name = $"--- {(capitalizeFolderName ? name.ToUpper() : name)} ---"; + } + + return; } if (Application.isPlaying) diff --git a/Runtime/StrippingMode.cs b/Runtime/StrippingMode.cs new file mode 100644 index 0000000..43e4a7a --- /dev/null +++ b/Runtime/StrippingMode.cs @@ -0,0 +1,7 @@ +namespace UnityHierarchyFolders.Runtime +{ + public enum StrippingMode + { + PrependWithFolderName, Delete, DoNothing, ReplaceWithSeparator + } +} \ No newline at end of file diff --git a/Runtime/StrippingMode.cs.meta b/Runtime/StrippingMode.cs.meta new file mode 100644 index 0000000..cfbfbdb --- /dev/null +++ b/Runtime/StrippingMode.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3634a380eb0d4432a5396f7cdc085414 +timeCreated: 1613841896 \ No newline at end of file diff --git a/Runtime/UnityHierarchyFolders.Runtime.asmdef b/Runtime/UnityHierarchyFolders.Runtime.asmdef index 5ed60f9..a8b23e5 100644 --- a/Runtime/UnityHierarchyFolders.Runtime.asmdef +++ b/Runtime/UnityHierarchyFolders.Runtime.asmdef @@ -1,12 +1,14 @@ { "name": "UnityHierarchyFolders.Runtime", + "rootNamespace": "", "references": [], - "optionalUnityReferences": [], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, "overrideReferences": false, "precompiledReferences": [], "autoReferenced": true, - "defineConstraints": [] + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false } \ No newline at end of file diff --git a/package.json b/package.json index e8a3e84..10a9ea5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "com.xsduan.hierarchy-folders", - "displayName": "Heirarchy Folders", - "version": "0.2.1", + "displayName": "Hierarchy Folders", + "version": "0.3.0", "unity": "2018.1", "description": "Self-deleting Folder objects for the heirarchy.", "keywords": [ @@ -13,5 +13,7 @@ "name": "Shane Duan" }, "category": "Unity", - "dependencies": {} + "dependencies": { + "com.unity.settings-manager": "1.0.1" + } }