diff --git a/Editor/IncrementalBuildPipeline/BeeBuildProgramCommon.Data/BeeBuildProgramCommon.Data.gen.csproj b/Editor/IncrementalBuildPipeline/BeeBuildProgramCommon.Data/BeeBuildProgramCommon.Data.gen.csproj index ef251769b5..06c46083d7 100644 --- a/Editor/IncrementalBuildPipeline/BeeBuildProgramCommon.Data/BeeBuildProgramCommon.Data.gen.csproj +++ b/Editor/IncrementalBuildPipeline/BeeBuildProgramCommon.Data/BeeBuildProgramCommon.Data.gen.csproj @@ -19,11 +19,15 @@ false false + + $(HOME) + $(USERPROFILE) + - + diff --git a/Editor/IncrementalBuildPipeline/PlayerBuildProgramLibrary.Data/PlayerBuildProgramLibrary.Data.gen.csproj b/Editor/IncrementalBuildPipeline/PlayerBuildProgramLibrary.Data/PlayerBuildProgramLibrary.Data.gen.csproj index 1d221ff092..ccd864db2e 100644 --- a/Editor/IncrementalBuildPipeline/PlayerBuildProgramLibrary.Data/PlayerBuildProgramLibrary.Data.gen.csproj +++ b/Editor/IncrementalBuildPipeline/PlayerBuildProgramLibrary.Data/PlayerBuildProgramLibrary.Data.gen.csproj @@ -20,12 +20,16 @@ false PlayerBuildProgramLibrary.Data + + $(HOME) + $(USERPROFILE) + - + diff --git a/Editor/IncrementalBuildPipeline/ScriptCompilationBuildProgram.Data/ScriptCompilationBuildProgram.Data.gen.csproj b/Editor/IncrementalBuildPipeline/ScriptCompilationBuildProgram.Data/ScriptCompilationBuildProgram.Data.gen.csproj index 19b9d56861..7c75a60369 100644 --- a/Editor/IncrementalBuildPipeline/ScriptCompilationBuildProgram.Data/ScriptCompilationBuildProgram.Data.gen.csproj +++ b/Editor/IncrementalBuildPipeline/ScriptCompilationBuildProgram.Data/ScriptCompilationBuildProgram.Data.gen.csproj @@ -19,12 +19,16 @@ false false + + $(HOME) + $(USERPROFILE) + - + diff --git a/Editor/Mono/2D/SpriteAtlas/EditorSpriteAtlas.bindings.cs b/Editor/Mono/2D/SpriteAtlas/EditorSpriteAtlas.bindings.cs index febe5c2d84..95d2a717df 100644 --- a/Editor/Mono/2D/SpriteAtlas/EditorSpriteAtlas.bindings.cs +++ b/Editor/Mono/2D/SpriteAtlas/EditorSpriteAtlas.bindings.cs @@ -37,6 +37,9 @@ public static void PackAtlases(SpriteAtlas[] atlases, BuildTarget target, bool c [FreeFunction("SpriteAtlasExtensions::CleanupAtlasPacking")] extern public static void CleanupAtlasPacking(); + + [FreeFunction("SpriteAtlasExtensions::OnSpriteAtlasSettingsChanged")] + extern internal static void OnSpriteAtlasSettingsChanged(); } diff --git a/Editor/Mono/2D/SpriteAtlas/SpriteAtlasImporterInspector.cs b/Editor/Mono/2D/SpriteAtlas/SpriteAtlasImporterInspector.cs index 9fde0c7d30..2d61e1d87a 100644 --- a/Editor/Mono/2D/SpriteAtlas/SpriteAtlasImporterInspector.cs +++ b/Editor/Mono/2D/SpriteAtlas/SpriteAtlasImporterInspector.cs @@ -317,6 +317,8 @@ private SerializedObject GetSerializedAssetObject() public override void OnEnable() { base.OnEnable(); + if (!AreImporterTargetsValid()) // asset gone: base already logged and bailed + return; m_FilterMode = serializedObject.FindProperty("m_TextureSettings.filterMode"); m_AnisoLevel = serializedObject.FindProperty("m_TextureSettings.anisoLevel"); diff --git a/Editor/Mono/Animation/AnimationEventWrapperInspector.cs b/Editor/Mono/Animation/AnimationEventWrapperInspector.cs index ed34a5f4f1..c7195072e8 100644 --- a/Editor/Mono/Animation/AnimationEventWrapperInspector.cs +++ b/Editor/Mono/Animation/AnimationEventWrapperInspector.cs @@ -101,16 +101,21 @@ public static void OnEditAnimationEvents(AnimationEventWrapper[] awEvents, Anima EditorGUIUtility.labelWidth = 130; EditorGUI.showMixedValue = !singleFunctionName; - int wasSelected = singleFunctionName ? selected : -1; + + EditorGUI.BeginChangeCheck(); selected = EditorGUILayout.Popup("Function: ", selected, methodsFormatted); - if (wasSelected != selected && selected != -1 && selected != notSupportedIndex) + if (EditorGUI.EndChangeCheck()) { - foreach (var evt in data.selectedEvents) + if (selected >= 0 && selected < notSupportedIndex) { - evt.functionName = supportedMethods[selected].Name; - evt.stringParameter = string.Empty; + foreach (var evt in data.selectedEvents) + { + evt.functionName = supportedMethods[selected].Name; + evt.stringParameter = string.Empty; + } } } + EditorGUI.showMixedValue = false; EditorGUI.indentLevel++; diff --git a/Editor/Mono/Animation/CurveEditor/CurveEditorSelection.cs b/Editor/Mono/Animation/CurveEditor/CurveEditorSelection.cs index f7fa130907..da072f4806 100644 --- a/Editor/Mono/Animation/CurveEditor/CurveEditorSelection.cs +++ b/Editor/Mono/Animation/CurveEditor/CurveEditorSelection.cs @@ -51,15 +51,15 @@ internal CurveSelection(int curveID, int key, SelectionType type) public int CompareTo(CurveSelection other) { - int cmp = curveID - other.curveID; + int cmp = curveID.CompareTo(other.curveID); if (cmp != 0) return cmp; - cmp = key - other.key; + cmp = key.CompareTo(other.key); if (cmp != 0) return cmp; - return (int)type - (int)other.type; + return ((int)type).CompareTo((int)other.type); } public override bool Equals(object _other) diff --git a/Editor/Mono/Animation/EditorCurveBinding.bindings.cs b/Editor/Mono/Animation/EditorCurveBinding.bindings.cs index cab5880b58..c822207a91 100644 --- a/Editor/Mono/Animation/EditorCurveBinding.bindings.cs +++ b/Editor/Mono/Animation/EditorCurveBinding.bindings.cs @@ -74,7 +74,7 @@ public struct EditorCurveBinding : IEquatable public override int GetHashCode() { - return String.Format("{0}:{1}:{2}", path, type.Name, propertyName).GetHashCode(); + return HashCode.Combine(path, type, propertyName); } public override bool Equals(object other) diff --git a/Editor/Mono/AssemblyInfo/AssemblyInfo.cs b/Editor/Mono/AssemblyInfo/AssemblyInfo.cs index 45fd161ca0..89a060d2ed 100644 --- a/Editor/Mono/AssemblyInfo/AssemblyInfo.cs +++ b/Editor/Mono/AssemblyInfo/AssemblyInfo.cs @@ -221,6 +221,7 @@ [assembly:InternalsVisibleTo("Unity.AI.Navigation.Editor")] [assembly: InternalsVisibleTo("Unity.Scenes")] +[assembly: InternalsVisibleTo("Unity.Scenes.Editor.Tests")] [assembly: InternalsVisibleTo("UnityEditor.Switch.Tests")] @@ -252,6 +253,7 @@ [assembly: InternalsVisibleTo("Unity.Modules.BuildProfileEditor.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.UI.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.Multiplayer.Server.Tests.Editor")] +[assembly: InternalsVisibleTo("Unity.Modules.Multiplayer.PlayMode.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.AssetDatabase.AssetPostProcessor.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.AssetPackage.Tests.Editor")] // This should move with the AnimationWindow to a module at some point @@ -259,6 +261,9 @@ [assembly: InternalsVisibleTo("Unity.Modules.Physics.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.GI.EditorBake.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.GI.LightProbes.Tests.Editor")] +[assembly: InternalsVisibleTo("Unity.Modules.GI.UVUnwrap.Tests.Editor")] +[assembly: InternalsVisibleTo("Unity.Modules.GI.BakedGI.ColorSpace.Tests.Editor")] +[assembly: InternalsVisibleTo("Unity.Modules.GI.LightingInspectorMeshRenderer.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.Physics2D.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.U2D.NineSlice.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.GI.Prefabs.Tests.Editor")] @@ -266,6 +271,7 @@ [assembly: InternalsVisibleTo("Unity.Modules.ShaderCompilationEditor.ShaderCompilation.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.ShaderCompilationEditor.ShaderUtil.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Modules.ShaderCompilationEditor.Rendering.Tests.Editor")] +[assembly: InternalsVisibleTo("Unity.Modules.ShaderCompilationEditor.ShaderCacheConsistency.Tests.Editor")] [assembly: InternalsVisibleTo("Unity.Tests.Shared")] diff --git a/Editor/Mono/AssetDatabase/AssetDatabaseSearching.cs b/Editor/Mono/AssetDatabase/AssetDatabaseSearching.cs index 4b27ab4cab..5a452d519d 100644 --- a/Editor/Mono/AssetDatabase/AssetDatabaseSearching.cs +++ b/Editor/Mono/AssetDatabase/AssetDatabaseSearching.cs @@ -45,6 +45,7 @@ public static GUID[] FindAssetGUIDs(string filter, string[] searchInFolders) var searchFilter = CreateSearchFilter(filter, searchInFolders); return FindAssetGUIDs(searchFilter); } + [VisibleToOtherModules("UnityEditor.UIToolkitAuthoringModule")] internal static string[] FindAssets(SearchFilter searchFilter) { #pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. diff --git a/Editor/Mono/AssetPipeline/SpeedTree/SpeedTree9ImporterEditor.cs b/Editor/Mono/AssetPipeline/SpeedTree/SpeedTree9ImporterEditor.cs index a2a468257b..a2d6c5053d 100644 --- a/Editor/Mono/AssetPipeline/SpeedTree/SpeedTree9ImporterEditor.cs +++ b/Editor/Mono/AssetPipeline/SpeedTree/SpeedTree9ImporterEditor.cs @@ -46,6 +46,12 @@ internal IEnumerable importers public override void OnEnable() { + if (!AreImporterTargetsValid()) + { + base.OnEnable(); // lets the base mark the editor enabled/inert (OnDisable symmetry) + return; + } + m_STImporter = target as SpeedTree9Importer; if (tabs == null) diff --git a/Editor/Mono/AssetPostprocessor.cs b/Editor/Mono/AssetPostprocessor.cs index 7428a15723..70ab8a5933 100644 --- a/Editor/Mono/AssetPostprocessor.cs +++ b/Editor/Mono/AssetPostprocessor.cs @@ -126,12 +126,17 @@ public class MethodInfoCallback : Callback public bool MethodDomainReload { get; } + // Cached: PostprocessAllAssets runs for every import batch and building this + // marker name there would allocate a string per callback per batch. + public string PerformanceMarkerName { get; } + public override string name => classType.FullName; public MethodInfoCallback(MethodInfo method, bool methodDomainReload) { Method = method; MethodDomainReload = methodDomainReload; + PerformanceMarkerName = $"{method.DeclaringType.Name}.OnPostprocessAllAssets"; } public override IEnumerable GetCustomAttributes() => Method.GetCustomAttributes(); @@ -323,7 +328,7 @@ static void PostprocessAllAssets(string[] importedAssets, string[] addedAssets, { if (assetPostProcessor.MethodDomainReload) { - using (new EditorPerformanceMarker($"{assetPostProcessor.classType.Name}.OnPostprocessAllAssets", assetPostProcessor.classType).Auto()) + using (new EditorPerformanceMarker(assetPostProcessor.PerformanceMarkerName, assetPostProcessor.classType).Auto()) InvokeMethod(assetPostProcessor.Method, argsWithDidDomainReload); } else @@ -331,7 +336,7 @@ static void PostprocessAllAssets(string[] importedAssets, string[] addedAssets, if (containsNoAssets) continue; - using (new EditorPerformanceMarker($"{assetPostProcessor.classType.Name}.OnPostprocessAllAssets", assetPostProcessor.classType).Auto()) + using (new EditorPerformanceMarker(assetPostProcessor.PerformanceMarkerName, assetPostProcessor.classType).Auto()) InvokeMethod(assetPostProcessor.Method, args); } } diff --git a/Editor/Mono/BuildPipeline/AssemblyStripper.cs b/Editor/Mono/BuildPipeline/AssemblyStripper.cs index 946b0e4da7..ff2c4a5b48 100644 --- a/Editor/Mono/BuildPipeline/AssemblyStripper.cs +++ b/Editor/Mono/BuildPipeline/AssemblyStripper.cs @@ -266,13 +266,15 @@ public static string WriteEditorData(BuildPostProcessArgs args, NPath linkerInpu { CollectIncludedAndExcludedModules(out var forceIncludeModules, out var forceExcludeModules); - var editorToLinkerData = new EditorToLinkerData - { #pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. - typesInScenes = GetTypesInScenesInformation(args.report, args.usedClassRegistry) + var typesInScenes = GetTypesInScenesInformation(args.report, args.usedClassRegistry) + .OrderBy(data => data.fullManagedTypeName ?? data.nativeClass) + .ToArray(); #pragma warning restore UA2001 - .OrderBy(data => data.fullManagedTypeName ?? data.nativeClass) - .ToArray(), + + var editorToLinkerData = new EditorToLinkerData + { + typesInScenes = typesInScenes, allNativeTypes = CollectNativeTypeData().ToArray(), forceIncludeModules = forceIncludeModules.ToArray(), forceExcludeModules = forceExcludeModules.ToArray() @@ -291,9 +293,10 @@ public static string WriteEditorData(BuildPostProcessArgs args, NPath linkerInpu var unityType = UnityType.FindTypeByName(nativeClass); var managedName = RuntimeClassMetadataUtils.ScriptingWrapperTypeNameForNativeID(unityType.persistentTypeID); -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. - var usedInScenes = rcr.GetScenesForClass(unityType.persistentTypeID)?.OrderBy(p => p); -#pragma warning restore UA2001 + + var usedInScenes = rcr.GetScenesForClass(unityType.persistentTypeID)?.ToArray(); + if (usedInScenes != null) + Array.Sort(usedInScenes); bool noManagedType = unityType.persistentTypeID != 0 && managedName == "UnityEngine.Object"; var information = new EditorToLinkerData.TypeInSceneData( @@ -301,9 +304,7 @@ public static string WriteEditorData(BuildPostProcessArgs args, NPath linkerInpu noManagedType ? null : managedName, nativeClass, unityType.module, -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. - usedInScenes != null ? usedInScenes.ToArray() : null); -#pragma warning restore UA2001 + usedInScenes); items.Add(information); } @@ -407,26 +408,14 @@ private static string GetMethodPreserveBlacklistContents(RuntimeClassRegistry rc #pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. var groupedByAssembly = rcr.GetMethodsToPreserve().GroupBy(m => m.assembly); -#pragma warning restore UA2001 -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. foreach (var assembly in groupedByAssembly.OrderBy(a => a.Key)) -#pragma warning restore UA2001 { -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. var assemblyName = assembly.Key; -#pragma warning restore UA2001 sb.AppendLine(string.Format("\t", assemblyName)); -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. var groupedByType = assembly.GroupBy(m => m.fullTypeName); -#pragma warning restore UA2001 -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. foreach (var type in groupedByType.OrderBy(t => t.Key)) -#pragma warning restore UA2001 { -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. sb.AppendLine(string.Format("\t\t", EscapeXmlString(type.Key))); -#pragma warning restore UA2001 -#pragma warning disable UA2001 // The Banned API Analyzer produces compile errors for any new Linq code. This pre-existing usage has been suppressed, but should be rewritten if possible. foreach (var method in type.OrderBy(m => m.methodName)) #pragma warning restore UA2001 sb.AppendLine(string.Format("\t\t\t", EscapeXmlString(method.methodName))); diff --git a/Editor/Mono/BuildPipeline/BuildPipeline.bindings.cs b/Editor/Mono/BuildPipeline/BuildPipeline.bindings.cs index 80a4f0e31d..135a4706c6 100644 --- a/Editor/Mono/BuildPipeline/BuildPipeline.bindings.cs +++ b/Editor/Mono/BuildPipeline/BuildPipeline.bindings.cs @@ -425,13 +425,19 @@ internal static BuildReport BuildPlayerData(BuildPlayerDataOptions buildPlayerDa /// Builds a content directory (serialized assets and scenes plus a manifest) at a defined output path. /// /// - /// Register the folder at runtime with . - /// Each entry in must be a ScriptableObject; the build includes those roots and - /// everything they reference (including , , and ). - /// Creates an outputPath if missing, normalizes path separators, and defaults to the output folder name. + /// Use buildParameters to configure the build, including the root assets to include, the output + /// path, and optional compression and build options. Each entry in + /// must be a . The build includes those root assets and everything they reference, such as + /// assets referenced through , , and + /// . + /// + /// The method creates if it doesn't exist, normalizes path + /// separators, and defaults to the output folder name. /// /// The build uses and the active subtarget configured for that target in the build settings. /// Select the intended platform in the **Build Profile** window or through [command line arguments](xref:um-command-line-arguments) so that the active target is set to the desired setting prior to calling this method. + /// + /// To load the built content, register the output directory with . /// /// /// @@ -525,6 +531,7 @@ public static BuildReport BuildContentDirectory(BuildContentDirectoryParameters /// /// /// + /// public static AssetBundleManifest BuildAssetBundles(string outputPath, BuildAssetBundleOptions assetBundleOptions, BuildTarget targetPlatform) { BuildAssetBundlesParameters input = new BuildAssetBundlesParameters @@ -730,6 +737,7 @@ public static AssetBundleManifest BuildAssetBundles(string outputPath, AssetBund ///]]> /// ///AssetBundles + /// public static AssetBundleManifest BuildAssetBundles(BuildAssetBundlesParameters buildParameters) { if (buildParameters.targetPlatform == 0 || buildParameters.targetPlatform == BuildTarget.NoTarget) diff --git a/Editor/Mono/BuildPipeline/BuildPipelineInterfaces.cs b/Editor/Mono/BuildPipeline/BuildPipelineInterfaces.cs index ad3d9db3d1..5091f1b7e7 100644 --- a/Editor/Mono/BuildPipeline/BuildPipelineInterfaces.cs +++ b/Editor/Mono/BuildPipeline/BuildPipelineInterfaces.cs @@ -22,7 +22,9 @@ namespace UnityEditor.Build ///Interface that provides control over callback order. ///This is the base class for build callback interfaces, for example , , , , and . /// - ///Every class that implements these interfaces must define the callbackOrder property with a "get" accessor. + ///Every class that implements these interfaces must define the callbackOrder property with a "get" accessor. + /// + ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks) public interface IOrderedCallback { ///Returns a numeric value that determines the order in which the build callback is invoked. @@ -85,7 +87,15 @@ public abstract class BuildPlayerProcessor : IOrderedCallback ///Returns the relative callback order for callbacks. Callbacks with lower values are called before ones with higher values. public virtual int callbackOrder => 0; ///Implement this function to receive a callback before a Player build starts. - ///You can use this function to customize the build before Unity starts building the Player. For example, the following code example demonstrates how to include streaming assets in the Player build without placing them in your project's StreamingAssets folder. + ///You can use this function to customize the build before Unity starts building the Player. For example, the following code example demonstrates how to include streaming assets in the Player build without placing them in your project's StreamingAssets folder. + /// + ///This callback is a good place to confirm that the project is configured correctly before the build starts, and to fail the build early if a required setting is missing. Give validation callbacks a low value so they run before other PrepareForBuild callbacks. + /// + ///To fail the build from this callback, throw a . It reports a clear message without a call stack. + /// + ///Unlike , you can trigger a content-only build from this callback, for example by calling . The Addressables package uses this callback to build content during a Player build. + /// + ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks). ///The context for the scheduled Player build. /// /// /// /// + /// public abstract void PrepareForBuild(BuildPlayerContext buildPlayerContext); } ///Implement this interface to execute code at the start of the Player build process. - ///This interface is replaced by , which works for AssetBundle and ContentDirectory builds as well. - /// - ///At the start of a Player build, Unity uses the property on each implementation to determine the order in which to invoke the callbacks. - /// - ///This callback can be useful for automated tasks and ensuring your build environment is correctly configured. + ///This interface behaves like , but is invoked only for Player builds. Use instead, because it is also invoked for AssetBundle and content directory builds. /// - ///Example usages include: - /// - ///* For validation checks, e.g. confirming required build settings, environmental variables, content or other project-specific conditions. When possible you can automatically fix problems by changing settings. Or you can fail the build, by throwing a BuildFailedException along with a clear error message. - ///* To make sure required Assets are included in the build. See . - ///* To generate version numbers, change logs, link.xml files or other content that should be regenerated prior to each Player build. - ///* For logging, reporting or sending analytics. - /// - ///Note: Build callbacks are a powerful feature, but it is strongly recommended that their implementations maintain deterministic build outputs. - ///The result of a build should be predictable and reproducible, based on the project’s content, the Unity version, and installed packages. - ///Introducing environment-specific behavior, external dependencies, randomness, or other non-deterministic elements can lead to outcomes - ///that are challenging to debug or reproduce. This unpredictability may also compromise the efficiency and accuracy of incremental builds or incremental upgrades. + ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks) + /// /// /// /// @@ -132,34 +130,35 @@ public abstract class BuildPlayerProcessor : IOrderedCallback public interface IPreprocessBuildWithReport : IOrderedCallback { ///Implement this method to receive a callback before the build is started. - ///This method is replaced by , which works for AssetBundle builds as well. - /// This callback is invoked during Player builds, but not during AssetBundle builds. + ///This method is invoked during Player builds only. Use instead, which is also invoked during AssetBundle and content directory builds. ///A report containing information about the build, such as its target platform and output path. /// /// /// void OnPreprocessBuild(BuildReport report); } - ///Implement this interface to execute code at the start of the Player build or AssetBundle build process. - ///At the start of a Player build or AssetBundle build, Unity uses the property on each implementation to determine the order in which to invoke the callbacks. + ///Implement this interface to execute code at the start of the Player, AssetBundle, or content directory build process. + ///At the start of a Player, AssetBundle, or content directory build, Unity uses the property on each implementation to determine the order in which to invoke the callbacks. /// ///This callback can be useful for automated tasks and ensuring your build environment is correctly configured. /// - ///You can't invoke an additional build from inside this callback. To invoke an AssetBundle build at the start of a Player build you should use instead. + ///You can't invoke another build from inside this callback. To invoke a content build at the start of a Player build, use instead. /// ///Example usages include: /// - ///* For validation checks, e.g. confirming required build settings, environmental variables, content or other project-specific conditions. When possible you can automatically fix problems by changing settings. Or you can fail the build, by throwing a BuildFailedException along with a clear error message. - ///* To make sure required Assets are included in the build. See . - ///* To generate version numbers, change logs, link.xml files or other content that should be regenerated prior to each Player build or AssetBundle build. - ///* For logging, reporting or sending analytics. + ///* For validation checks, for example confirming required build settings, environment variables, content, or other project-specific conditions. When possible you can automatically fix problems by changing settings, or you can fail the build. + ///* To make sure required Assets are included in the build. Refer to . + ///* To generate version numbers, change logs, link.xml files, or other content that must be regenerated before each build. + ///* For logging, reporting, or sending analytics. + /// + ///To fail the build from this callback, throw a . It reports a clear message without a call stack and fails the build whether or not the build uses strict mode. A logged error, such as from Debug.LogError, fails the build only when the build uses strict mode (, , or ). /// ///Note: Build callbacks are a powerful feature, but it is strongly recommended that their implementations maintain deterministic build outputs. ///The result of a build should be predictable and reproducible, based on the project’s content, the Unity version, and installed packages. ///Introducing environment-specific behavior, external dependencies, randomness, or other non-deterministic elements can lead to outcomes ///that are challenging to debug or reproduce. This unpredictability might compromise the efficiency and accuracy of incremental builds or incremental upgrades. /// - ///The main difference between this interface and or is that this callback gets called on AssetBundle builds and Player builds. + ///The main difference between this interface and or is that this callback is invoked on AssetBundle and content directory builds as well as Player builds. ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks) /// /// /// /// + /// public interface IPreprocessBuildWithContext : IOrderedCallback { ///Implement this method to receive a callback before the build is started. - ///This callback is invoked during Player builds and AssetBundle builds. + ///This callback is invoked during Player, AssetBundle, and content directory builds. ///A context containing information about the build, such as its build report. /// /// /// /// + /// void OnPreprocessBuild(BuildCallbackContext ctx); } @@ -263,18 +264,17 @@ public interface IPostprocessBuild : IOrderedCallback } ///Implement this interface to execute code immediately after the Player build process is completed. - ///This interface is replaced by , which works for AssetBundle and ContentDirectory builds as well. - ///This is useful for tasks that need to be performed as the last step of building, such as cleaning up assets, generating analytics or reports, or customizing build outputs. + ///This interface behaves like , but is invoked only for Player builds. Use instead, because it is also invoked for AssetBundle and content directory builds. /// - ///As a final step of a Player build, Unity uses the property on each implementation to determine the order in which to invoke the callbacks. + ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks) + /// /// /// public interface IPostprocessBuildWithReport : IOrderedCallback { ///Implement this function to receive a callback after the build is complete. - ///This method is replaced by , which works for AssetBundle builds as well. - /// This callback is invoked during Player builds, but not during AssetBundle builds. - /// If the build stops early, due to a failure or cancellation, then the callback is not invoked. + ///This method is invoked during Player builds only. Use instead, which is also invoked during AssetBundle and content directory builds. + /// If the build stops early, due to a failure or cancellation, then this callback is not invoked. ///A BuildReport containing information about the build, such as the target platform and output path. /// /// void OnPostprocessBuild(BuildReport report); } - ///Implement this interface to execute code immediately after the Player build or AssetBundle build process is completed. - ///This is useful for tasks that need to run after a build completes, even if the build failed or was cancelled. For example, you might want to clean up assets, generate analytics or reports, or customize build outputs. The postprocess callback runs whether the build succeeds, fails, or is cancelled, as long as the corresponding callback ran. It's only skipped if early validation prevents the build from starting. + ///Implement this interface to execute code immediately after the Player, AssetBundle, or content directory build process is completed. + ///This is useful for tasks that need to run after a build completes, even if the build failed or was canceled. For example, you might want to clean up assets, generate analytics or reports, or customize build outputs. The postprocess callback runs whether the build succeeds, fails, or is canceled, as long as the corresponding callback ran. It's only skipped if early validation prevents the build from starting. + /// + ///As a final step of a Player, AssetBundle, or content directory build, Unity uses the property on each implementation to determine the order in which to invoke the callbacks. /// - ///As a final step of a Player build or AssetBundle build, Unity uses the property on each implementation to determine the order in which to invoke the callbacks. + ///To fail the build from this callback, throw a . It reports a clear message without a call stack and fails the build whether or not the build uses strict mode. A logged error, such as from Debug.LogError, fails the build only when the build uses strict mode (, , or ). /// - ///Note: The main difference between this interface and or is that this callback gets called on AssetBundle builds as well as Player builds. + ///Note: The main difference between this interface and or is that this callback is invoked on AssetBundle and content directory builds as well as Player builds. + /// + ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks) /// /// /// /// + /// public interface IPostprocessBuildWithContext : IOrderedCallback { ///Implement this function to receive a callback after the build is complete. - ///This callback is invoked during Player builds or AssetBundle builds. + ///This callback is invoked during Player, AssetBundle, and content directory builds. /// This callback is invoked even when the build stops early due to a failure or cancellation. However it will not be invoked if initial validation checks prevent the build from starting. ///A context containing information about the build, such as its build report. /// @@ -396,6 +401,7 @@ public interface IPostprocessBuildWithContext : IOrderedCallback /// /// /// + /// /// void OnPostprocessBuild(BuildCallbackContext ctx); } @@ -405,7 +411,7 @@ public interface IPostprocessBuildWithContext : IOrderedCallback public interface IPostBuildPlayerScriptDLLs : IOrderedCallback { ///Implement this interface to receive a callback just after the player scripts have been compiled. - ///You can implement this if you need to read or patch managed Assemblies for players being built. You can get assembly locations from the files property of the report parameter. Note that implementing this callback will cause builds to run slower, as assemblies need to be copied to an intermediate location, and is not recommended for best performance. + ///You can implement this if you need to read or patch managed Assemblies for players being built. To retrieve assembly locations, call GetFiles on the BuildReport provided as the report parameter, and read the path property from each returned BuildFile. Note that implementing this callback causes builds to run slower, as assemblies need to be copied to an intermediate location, and is not recommended for best performance. ///A report containing information about the build, such as its target platform and output path. /// /// /// Implement this method to receive a callback for each scene during the build. /// - /// Unity invokes this callback during Player and AssetBundle builds, and also when a scene is reloaded while entering Play mode. Use to determine in which context the callback is called. + /// Unity invokes this callback during Player, AssetBundle, and content directory builds, and also when a scene is loaded while in Play mode. Use to determine in which context the callback is called. /// /// This callback supports editing the provided scene to prepare it for a Player build or entering Play mode, and reading assets. For example, you can add or remove references to project assets in that scene. /// - /// This callback doesn't support modifying the state of other assets. Use it to modify only the provided scene. + /// This callback doesn't support modifying the state of other assets, creating new assets, or deleting assets. Use it to modify only the provided scene. + /// + /// Don't access the build history from this callback. For content directory builds this callback can run in a separate worker process where is not available. + /// + /// To fail the build from this callback, throw a . It reports a clear message without a call stack and fails the build whether or not the build uses strict mode. A logged error, such as from Debug.LogError, fails the build only when the build uses strict mode (, , or ). /// /// Keep implementations deterministic. Avoid random values, timestamps, or external changing data sources. For more information, refer to [Deterministic builds](xref:um-build-deterministic-builds). /// + /// Use and to add explicit dependencies to assets used during your scene modifications. + /// /// Apply to callback types and increment the version whenever the callback logic changes to help Unity invalidate cached scene processing results when needed. /// /// For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks). /// The current scene being processed. /// A report containing information about the current build. When this callback is invoked for scene loading during Play mode, this parameter is null. - /// + /// /// - /// - /// - /// - /// + /// + /// + /// + /// + /// + /// void OnProcessScene(UnityEngine.SceneManagement.Scene scene, BuildReport report); } @@ -526,7 +540,7 @@ public interface IPreprocessShaders : IOrderedCallback ///- Combinations of keywords that are never used. ///- Keywords you only use in your debug builds. /// - ///Unity invokes the `OnProcessShader` callback in both Player and AssetBundle builds. If there are any shaders already in the cache, then this method isn't invoked for those shaders. To ensure the callback runs for all shaders, perform a [clean build](xref:um-build-clean-build). To run it for a specific shader, modify that shader or one of its dependent assets. + ///Unity invokes the `OnProcessShader` callback in Player, AssetBundle, and content directory builds. If there are any shaders already in the cache, then this method isn't invoked for those shaders. To ensure the callback runs for all shaders, perform a [clean build](xref:um-build-clean-build). To run it for a specific shader, modify that shader or one of its dependent assets. /// ///To help you identify keywords and variants to strip, you can [check what shader variants you have in your project](xref:um-shader-how-many-variants). For example if you [declare a keyword](xref:um-sl-multiple-program-variants) called `DEBUG` in your shader code using `#pragma multi_compile _ DEBUG`, the following [Editor script](xref:um-special-folders) finds and strips shader variants that use the keyword. /// @@ -537,15 +551,16 @@ public interface IPreprocessShaders : IOrderedCallback ///3. Implements the `OnProcessShader` callback function and iterates over the `data` list, which contains every variant in the shader. ///4. Uses `data.shaderKeywordSet.IsEnabled()` to check if each variant uses the keyword. ///5. Uses `data.removeAt()` to strip a shader variant if it contains the keyword and you've disabled **Development build** in **[Build Settings](xref:um-build-settings)**. - /// + /// ///You can also find local keywords. You must create the `ShaderKeyword` instance inside the implementation of `OnProcessShader`, so you can use the callback's `shader` variable in the `ShaderKeyword` constructor. /// ///For example if you declare a local keyword called `RED` in your shader code using `#pragma multi_compile_local _ RED`, the following script finds and strips shader variants that use the keyword. - /// + /// ///If you strip a variant that a Material needs at runtime, Unity chooses an available shader variant that matches as closely as possible. /// ///Find out about other ways you can [strip shader variants](xref:um-shader-variant-stripping). /// + ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks) /// ///The shader that Unity is about to compile. ///Details about the specific shader code being compiled. @@ -586,6 +601,7 @@ public interface IPreprocessShaders : IOrderedCallback /// /// /// + /// /// void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList data); } @@ -605,7 +621,9 @@ public interface IPreprocessComputeShaders : IOrderedCallback /// ///Note that this callback only provides details of compute shaders. To see regular shaders that Unity is about to compile into the build, see . /// - ///This callback is invoked for both Player and AssetBundle builds. + ///This callback is invoked for Player, AssetBundle, and content directory builds. + /// + ///For more information about build callbacks, refer to [Use build callbacks](xref:um-build-callbacks) ///The compute shader that Unity is about to compile. ///The name of the kernel that Unity is about to compile. ///The list of shader variants that Unity is about to compile. @@ -655,6 +673,7 @@ public interface IPreprocessComputeShaders : IOrderedCallback ///Declaring and using shader keywords in HLSL /// /// + /// void OnProcessComputeShader(ComputeShader shader, string kernelName, IList data); } diff --git a/Editor/Mono/BuildPipeline/BuildPlayerContext.cs b/Editor/Mono/BuildPipeline/BuildPlayerContext.cs index 5478ad3b6a..8fd1ecedc2 100644 --- a/Editor/Mono/BuildPipeline/BuildPlayerContext.cs +++ b/Editor/Mono/BuildPipeline/BuildPlayerContext.cs @@ -36,14 +36,18 @@ internal BuildPlayerContext(BuildPlayerOptions buildPlayerOptions) /// /// /// This is useful if you want the player build - /// to retrieve type stripping information from content-only builds you do prior to the player build. + /// to retrieve type stripping information from builds you do prior to the Player build. /// /// If this method is called on the same path multiple times, the path is only added once. /// /// If the path passed into this method is not a valid build report directory, at build time an error will be thrown. /// - /// For more information on locating the build report directory for a build, refer to . + /// For more information on locating the build report directory for a build, refer to . + /// + /// For more information, refer to [How code stripping affects content](xref:um-managed-code-stripping-content). ///The path to a build report directory. If the path is invalid, an error will be thrown during the build process. + /// + /// public void AddPreviousBuildReportDirectory(string directory) { if (!AdditionalBuildReportDirectories.Contains(directory)) diff --git a/Editor/Mono/BuildPipeline/BuildTarget.cs b/Editor/Mono/BuildPipeline/BuildTarget.cs index 1209562331..3167055ee0 100644 --- a/Editor/Mono/BuildPipeline/BuildTarget.cs +++ b/Editor/Mono/BuildPipeline/BuildTarget.cs @@ -25,7 +25,7 @@ public enum BuildTarget { ///Build a macOS standalone. /// - ///To specify which architecture to use (Intel, ARM or Universal), please use . + ///To specify which architecture to use (Intel, ARM or Universal), refer to OSXStandalone.UserBuildSettings.architecture. /// StandaloneOSX = 2, @@ -156,7 +156,7 @@ public enum BuildTarget /// tvOS = 37, - ///Build a Nintendo Switch player. + ///Build a Nintendo Switch™ player. /// Switch = 38, @@ -199,7 +199,8 @@ public enum BuildTarget /// VisionOS = 47, - [ExcludeFromDocs] + ///Build a Nintendo Switch™ 2 player. + /// Switch2 = 48, [ExcludeFromDocs] diff --git a/Editor/Mono/BuildPipeline/BuildTargetDiscovery.bindings.cs b/Editor/Mono/BuildPipeline/BuildTargetDiscovery.bindings.cs index eacc7731df..ea8f904991 100644 --- a/Editor/Mono/BuildPipeline/BuildTargetDiscovery.bindings.cs +++ b/Editor/Mono/BuildPipeline/BuildTargetDiscovery.bindings.cs @@ -90,6 +90,8 @@ public struct DiscoveredTargetInfo static readonly string k_SDKProviderNotMultiTargetError = L10n.Tr("The SDK platform provider '{0}' with guid '{1}' references a platform that is not marked as a multi-target platform."); internal static readonly string k_SDKProviderNotDerivedTargetError = L10n.Tr("The SDK platform provider '{0}' with guid '{1}' references a platform that is not marked as a derived platform."); static readonly string k_CreateIPlatformProviderFailedError = L10n.Tr("Failed to create IPlatformProvider instance for type '{0}'."); + static readonly string k_PlatformDeprecatedDefaultWithDisplayName = L10n.Tr("The {0} platform is deprecated."); + static readonly string k_DerivedPlatformUsesDeprecatedBase = L10n.Tr("This platform is based on {0}, which is deprecated."); public static extern bool PlatformHasFlag(BuildTarget platform, TargetAttributes flag); @@ -321,6 +323,11 @@ internal struct PlatformInfo // needs to be removed when https://jira.unity3d.com/browse/PLAT-7721 is implemented public NameAndLink? temporaryLabelAndLinkForIndustrialOnboarding = null; + /// + /// Custom deprecation copy for UI when is set. May be empty if a generic fallback should be used. + /// + public string deprecationMessage = string.Empty; + public PlatformInfo() {} public bool HasFlag(PlatformAttributes flag) { return (flags & flag) == flag; } @@ -345,6 +352,20 @@ internal class PlatformPackageInfo public string publisher; public bool hasThumbnail; + /// + /// Optional catalog flag (e.g. SDK platform JSON, mock platforms). Used as a fallback deprecation + /// signal when Package Manager has no for this package. + /// + public bool deprecated; + + /// + /// Optional deprecation tooltip when is true. Used as a fallback when + /// Package Manager has no for this package. + /// + public string deprecationMessage; + + public PlatformPackageInfo() {} + public PlatformPackageInfo(string displayName, string qualifiedName, string description, string publisher = "", bool hasThumbnail = false) { this.displayName = displayName; @@ -954,10 +975,49 @@ public static GUID GetGUIDFromBuildTarget(BuildTarget buildTarget) return EmptyGuid; } + /// + /// Get the server platform GUID corresponding to the NamedBuildTarget.Server and BuildTarget. + /// + /// The NamedBuildTarget to get the server platform GUID for. + /// The BuildTarget to get the server platform GUID for. + /// The server platform GUID. Derived server platform GUID when the active platform is a derived server platform. Base server platform GUID otherwise. + /// True if a server platform GUID was found; otherwise, false. internal static bool TryGetServerGUIDFromBuildTarget(NamedBuildTarget namedBuildTarget, BuildTarget buildTarget, out GUID result) { result = EmptyGuid; + if (namedBuildTarget != NamedBuildTarget.Server || !IsStandalonePlatform(buildTarget)) + return false; + + if (s_BuildTargetToPlatformGUID.TryGetValue(buildTarget, out var guid)) + { + var module = ModuleManager.FindPlatformSupportModule(guid); + if (module is IDerivedBuildTargetProvider) + { + var derivedPlatformGuid = module.PlatformBuildTarget.Guid; + var (_, subtarget) = GetBuildTargetAndSubtargetFromGUID(derivedPlatformGuid); + if (subtarget == StandaloneBuildSubtarget.Server) + { + result = derivedPlatformGuid; + return true; + } + } + } + + return TryGetBaseServerGUIDFromBuildTarget(namedBuildTarget, buildTarget, out result); + } + + /// + /// Get the base server platform GUID corresponding to the NamedBuildTarget.Server and BuildTarget. + /// + /// The NamedBuildTarget to get the base server platform GUID for. + /// The BuildTarget to get the base server platform GUID for. + /// The base server platform GUID. If the platform is not a derived platform, the same GUID is returned. + /// True if a base server platform GUID was found; otherwise, false. + internal static bool TryGetBaseServerGUIDFromBuildTarget(NamedBuildTarget namedBuildTarget, BuildTarget buildTarget, out GUID result) + { + result = EmptyGuid; + if (namedBuildTarget == NamedBuildTarget.Server) { foreach (var platform in allPlatforms) @@ -978,7 +1038,7 @@ internal static bool TryGetServerGUIDFromBuildTarget(NamedBuildTarget namedBuild internal static GUID GetBasePlatformGUIDFromBuildTarget(NamedBuildTarget namedBuildTarget, BuildTarget buildTarget) { - if (TryGetServerGUIDFromBuildTarget(namedBuildTarget, buildTarget, out var value)) + if (TryGetBaseServerGUIDFromBuildTarget(namedBuildTarget, buildTarget, out var value)) return value; if (s_BuildTargetToPlatformGUID.TryGetValue(buildTarget, out GUID guid)) @@ -1004,8 +1064,8 @@ internal static GUID GetBasePlatformGUID(GUID platformGuid) { if (platformInfo.subtarget != StandaloneBuildSubtarget.Server) return basePlatformGuid; - - if (TryGetServerGUIDFromBuildTarget(NamedBuildTarget.Server, platformInfo.buildTarget, out var serverPlatformGuid)) + + if (TryGetBaseServerGUIDFromBuildTarget(NamedBuildTarget.Server, platformInfo.buildTarget, out var serverPlatformGuid)) return serverPlatformGuid; } @@ -1094,6 +1154,13 @@ static void LoadSDKPlatforms() if (string.IsNullOrEmpty(displayName)) Debug.LogWarning(string.Format(k_SDKPlatformMissingDisplayNameWarning, sdkPlatformGuid)); + if (sdkPlatformInfo.isDeprecated) + flags |= PlatformAttributes.IsDeprecated; + + var deprecationMessage = sdkPlatformInfo.isDeprecated && !string.IsNullOrEmpty(sdkPlatformInfo.deprecationMessage) + ? sdkPlatformInfo.deprecationMessage + : string.Empty; + PlatformInfo platformInfo = new() { supportedPlatformGuids = sdkPlatformInfo.flags.platformType == SDKPlatformType.MultiTarget ? @@ -1109,6 +1176,7 @@ static void LoadSDKPlatforms() buildProfilePlatformBannerBgColorHex = sdkPlatformInfo.bannerBackgroundColorHex ?? "#00000000", internalPackages = sdkPlatformInfo.internalPackages, partnerPackages = sdkPlatformInfo.partnerPackages, + deprecationMessage = deprecationMessage, flags = flags | PlatformAttributes.IsSDKPlatform | PlatformAttributes.IsVisibleInPlatformBrowserOnly | PlatformAttributes.IsWindowsBuildTarget | PlatformAttributes.IsWindowsArm64BuildTarget | PlatformAttributes.IsLinuxBuildTarget | PlatformAttributes.IsMacBuildTarget, @@ -1123,6 +1191,11 @@ static void LoadSDKPlatforms() } var groupIndex = Array.FindIndex(allPlatformGroups, g => g.groupName == targetGroupName); + // SDK platform JSON typically stores the English group identifier (e.g. "Web") while + // allPlatformGroups use localized titles (L10n.Tr("Web")). Without this fallback the + // platform is registered in allPlatforms but never added to a browser group. + if (groupIndex < 0) + groupIndex = Array.FindIndex(allPlatformGroups, g => g.groupName == L10n.Tr(targetGroupName)); if (groupIndex < 0) { Debug.LogWarning(string.Format(k_SDKPlatformUnknownPlatformGroupWarning, sdkPlatformGuid, targetGroupName)); @@ -1689,6 +1762,58 @@ public static string BuildPlatformSubtitle(GUID guid) return string.Empty; } + /// + /// When the platform is marked deprecated, returns true and sets to the + /// configured text. The message may be empty if no custom copy was provided; callers should supply a fallback in that case. + /// Base platforms are checked directly. Derived platforms also check their immediate base platform. + /// Multi-target platforms are deprecated only when marked deprecated on their own platform entry. + /// + public static bool BuildPlatformTryGetDeprecationMessage(GUID guid, out string deprecationMessage) + { + deprecationMessage = string.Empty; + if (!TryGetPlatformInfo(guid, out PlatformInfo platformInfo)) + return false; + + if (platformInfo.HasFlag(PlatformAttributes.IsDeprecated)) + { + deprecationMessage = platformInfo.deprecationMessage; + return true; + } + + if (platformInfo.HasFlag(PlatformAttributes.IsDerivedBuildTarget)) + { + var basePlatformGuid = GetBasePlatformGUID(guid); + if (!basePlatformGuid.Empty() + && basePlatformGuid != guid + && TryGetDirectDeprecationMessage(basePlatformGuid, out var baseDeprecationMessage)) + { + deprecationMessage = FormatDerivedInheritedDeprecationMessage(basePlatformGuid, baseDeprecationMessage); + return true; + } + } + + return false; + } + + static bool TryGetDirectDeprecationMessage(GUID guid, out string deprecationMessage) + { + deprecationMessage = string.Empty; + if (!TryGetPlatformInfo(guid, out PlatformInfo platformInfo) || !platformInfo.HasFlag(PlatformAttributes.IsDeprecated)) + return false; + + deprecationMessage = platformInfo.deprecationMessage; + return true; + } + + static string FormatDerivedInheritedDeprecationMessage(GUID deprecatedBasePlatformGuid, string baseDeprecationMessage) + { + var baseDisplayName = BuildPlatformDisplayName(deprecatedBasePlatformGuid); + if (string.IsNullOrWhiteSpace(baseDeprecationMessage)) + baseDeprecationMessage = string.Format(k_PlatformDeprecatedDefaultWithDisplayName, baseDisplayName); + + return $"{baseDeprecationMessage}\n\n{string.Format(k_DerivedPlatformUsesDeprecatedBase, baseDisplayName)}"; + } + public static PlatformGroup[] GetPlatformGroups() { return allPlatformGroups; diff --git a/Editor/Mono/BuildPipeline/BuildTargetGroup.cs b/Editor/Mono/BuildPipeline/BuildTargetGroup.cs index 21503d74f8..3d1b2e067a 100644 --- a/Editor/Mono/BuildPipeline/BuildTargetGroup.cs +++ b/Editor/Mono/BuildPipeline/BuildTargetGroup.cs @@ -116,7 +116,7 @@ public enum BuildTargetGroup [ExcludeFromDocs] Facebook = 26, - ///Nintendo Switch target. + ///Nintendo Switch™ target. Switch = 27, [Obsolete("Lumin has been removed in 2022.2")] @@ -153,7 +153,7 @@ public enum BuildTargetGroup ///Apple visionOS target. VisionOS = 36, - [ExcludeFromDocs] + ///Nintendo Switch™ 2 target. Switch2 = 37, [ExcludeFromDocs] diff --git a/Editor/Mono/BuildPipeline/DataBuildDirtyTracker.cs b/Editor/Mono/BuildPipeline/DataBuildDirtyTracker.cs index 05c9cf6783..2374469eb1 100644 --- a/Editor/Mono/BuildPipeline/DataBuildDirtyTracker.cs +++ b/Editor/Mono/BuildPipeline/DataBuildDirtyTracker.cs @@ -54,6 +54,10 @@ class BuildData BuildOptions.CompressWithLz4HC; public string[] assemblyNames; + + // Session GUID of the full build that produced this data cache. + // A scripts-only or incremental build that reuses this cache copies it onto its report. + public GUID contentSourceBuildSessionGuid; } private BuildData buildData; @@ -207,7 +211,8 @@ static public void WriteBuildData(string buildDataPath, BuildReport report, stri .Where(m => ModuleMetadata.GetModuleIncludeSettingForModule(m) != ModuleIncludeSetting.ForceExclude) .ToArray(), #pragma warning restore UA2001 - assemblyNames = sortedAssemblyNames + assemblyNames = sortedAssemblyNames, + contentSourceBuildSessionGuid = report.summary.buildSessionGuid }; buildDataPath.ToNPath().WriteAllText(JsonUtility.ToJson(buildData)); } @@ -247,5 +252,30 @@ static public void ForceDirty(string buildDataPath) NPath buildReportPath = buildDataPath; buildReportPath.DeleteIfExists(); } + + // Called (from native BuildPlayer) when a build reuses the data cache instead of rebuilding it — both the + // explicit scripts-only and the automatic (clean CheckDirty) reuse paths converge here. The reused BuildData + // carries the session GUID of the full build that produced the cache; record it on this build's report so it + // is written into BuildReportSummary and Build Analysis can borrow that exact source build's asset table. + [RequiredByNativeCode] + static public void SetContentSourceBuild(BuildReport report, string buildDataPath) + { + try + { + NPath path = buildDataPath; + if (report == null || !path.FileExists()) + return; + + var buildData = JsonUtility.FromJson(path.ReadAllText()); + if (buildData == null || buildData.contentSourceBuildSessionGuid.Empty()) + return; + + report.SetContentSourceBuildSessionGUID(buildData.contentSourceBuildSessionGuid); + } + catch (Exception e) + { + Debug.LogWarning($"Failed to record source content build metadata for this scripts-only build: {e.Message}"); + } + } } } diff --git a/Editor/Mono/BuildPipeline/NamedBuildTarget.cs b/Editor/Mono/BuildPipeline/NamedBuildTarget.cs index 3eadef274c..8edd7f926b 100644 --- a/Editor/Mono/BuildPipeline/NamedBuildTarget.cs +++ b/Editor/Mono/BuildPipeline/NamedBuildTarget.cs @@ -8,7 +8,8 @@ namespace UnityEditor.Build { - ///Build Target by name. This allows to describe and identify build targets that are not fully represented by BuildTargetGroup and BuildTarget. + ///Identifies which platform's build settings an API applies to. + ///Use to choose the platform output to build. Use to choose which platform's settings to read or change. public readonly struct NamedBuildTarget : IEquatable, IComparable { private static readonly string[] k_ValidNames = @@ -40,7 +41,7 @@ namespace UnityEditor.Build "Kepler", }; - ///Unknown. + ///An unknown or unspecified named build target, typically used as a placeholder. public static readonly NamedBuildTarget Unknown = new NamedBuildTarget(""); ///Desktop Standalone. public static readonly NamedBuildTarget Standalone = new NamedBuildTarget("Standalone"); diff --git a/Editor/Mono/BuildPipeline/Settings/BuildAssetBundleOptions.bindings.cs b/Editor/Mono/BuildPipeline/Settings/BuildAssetBundleOptions.bindings.cs index f93cc2aa61..24dddb26dd 100644 --- a/Editor/Mono/BuildPipeline/Settings/BuildAssetBundleOptions.bindings.cs +++ b/Editor/Mono/BuildPipeline/Settings/BuildAssetBundleOptions.bindings.cs @@ -137,7 +137,21 @@ public enum BuildAssetBundleOptions // Force the build to fail when any errors are encountered ///Fails the build if any errors are reported during it. - ///Without this flag, non-fatal errors - such as a failure to compile a shader for a particular platform - will not cause the build to fail, but may result in incorrect behaviour at runtime. + ///Without this flag, non-fatal errors, such as a failure to compile a shader for a particular platform, won't cause the build to fail, but might result in incorrect behavior at runtime. + /// + /// Always set this flag, unless errors are logged from packages or other third-party code that you can't fix, and you need the build to proceed despite them. + /// + /// When this flag is set, errors logged from these build callbacks also fail the build: + /// , + /// , + /// , + /// , and + /// . + /// + /// This flag is the AssetBundle equivalent of . + /// + /// + /// /// /// /// diff --git a/Editor/Mono/BuildPipeline/Settings/BuildContentDirectoryParameters.bindings.cs b/Editor/Mono/BuildPipeline/Settings/BuildContentDirectoryParameters.bindings.cs index aa6927314e..b82e63e32e 100644 --- a/Editor/Mono/BuildPipeline/Settings/BuildContentDirectoryParameters.bindings.cs +++ b/Editor/Mono/BuildPipeline/Settings/BuildContentDirectoryParameters.bindings.cs @@ -29,15 +29,17 @@ public struct BuildContentDirectoryParameters public string outputPath { get; set; } /// - /// Array of paths to the root Assets that should be included in the build. + /// Array of paths to the root assets to include in the build. /// /// - /// This property should contain project-relative paths to existing ScriptableObject-derived Assets. Each specified - /// Asset will be included in the build, and available for direct load. Any dependency referenced from the Asset will also - /// be included in the build. Root assets are automatically loaded when a Content Directory is registered, so only - /// ScriptableObject-derived assets are permitted to prevent accidental misuse (such as attempting to use large assets - /// like Textures or Meshes as root assets). + /// Set this property to project-relative paths of existing -derived assets. The build + /// includes each specified asset along with any dependency it references. After you register the content directory, retrieve + /// the root assets at runtime with . + /// + /// Only ScriptableObject-derived assets are permitted as roots, to prevent accidental misuse such as using large assets + /// like Textures or Meshes as root assets. /// + /// public string[] rootAssetPaths { get; set; } /// @@ -48,6 +50,17 @@ public struct BuildContentDirectoryParameters /// /// The compression settings for the build. Defaults to . /// + /// + /// With the default , the build writes the content as individual loose files + /// without an archive wrapper. Set to wrap the output in archive (.archive) + /// files instead. Any other compression setting always produces archive files. + /// + /// can load a content directory whether + /// its content is stored as loose files or wrapped in archive files. You can also create archive files from loose-file + /// output as a separate step after the build with + /// . + /// + /// public BuildCompression compression { get; set; } // Internal: optional BuildTarget. When unset at default, native code takes both platform and subtarget from current Editor build settings. @@ -72,9 +85,14 @@ public struct BuildContentDirectoryParameters /// Optional name for the build. /// /// - /// This name is stored in the BuildReport and BuildManifest for identification purposes. + /// This name is stored in the BuildReport and BuildManifest for identification purposes. It is reported as + /// in the build's , and as + /// in the lightweight build summary. /// If not specified, the leaf folder name of is used as the default. /// + /// + /// + /// public string name { get; set; } /// diff --git a/Editor/Mono/BuildPipeline/Settings/BuildContentOptions.bindings.cs b/Editor/Mono/BuildPipeline/Settings/BuildContentOptions.bindings.cs index 0695dba7b8..e185bfbad9 100644 --- a/Editor/Mono/BuildPipeline/Settings/BuildContentOptions.bindings.cs +++ b/Editor/Mono/BuildPipeline/Settings/BuildContentOptions.bindings.cs @@ -27,7 +27,7 @@ public enum BuildContentOptions /// /// When this flag is set, the build system will package content into archive files with the ".archive" extension, /// which can improve loading performance and reduce file system overhead. - /// + /// /// This flag is unnecessary if compression is enabled using the field. /// UseArchive = 1 << 0, @@ -45,20 +45,32 @@ public enum BuildContentOptions /// /// Clear all cached build results, resulting in a full rebuild of content. - /// + /// + /// /// /// CleanBuildCache = 1 << 5, /// - /// Do not allow the build to succeed if any errors are reported during it. + /// Fail the build if any errors are logged while it runs. /// /// - /// Without this flag, non-fatal errors - such as a failure to compile a shader for a particular platform - will not - /// cause the build to fail, but may result in incorrect behaviour at runtime. + /// Without this flag, non-fatal errors, such as a failure to compile a shader for a particular platform, don't + /// cause the build to fail, but might result in incorrect behavior at runtime. + /// + /// Always set this flag, unless errors are logged from packages or other third-party code that you can't fix, and you need the build to proceed despite them. + /// + /// When this flag is set, errors logged from these build callbacks also fail the build: + /// , + /// , + /// , + /// , and + /// . /// - /// This flag is equivalent to . + /// This flag is the content directory equivalent of . /// + /// + /// FailBuildWhenErrorsLogged = 1 << 9, /// diff --git a/Editor/Mono/BuildPipeline/Settings/BuildOptions.bindings.cs b/Editor/Mono/BuildPipeline/Settings/BuildOptions.bindings.cs index 7afcb1a4f4..dc5abcfa8a 100644 --- a/Editor/Mono/BuildPipeline/Settings/BuildOptions.bindings.cs +++ b/Editor/Mono/BuildPipeline/Settings/BuildOptions.bindings.cs @@ -44,7 +44,7 @@ public enum BuildOptions // Do not overwrite player directory, but accept user's modifications. ///Appends to an existing Xcode (iOS) project during the build process. - ///This preserves any changes made to the existing Xcode project settings. With the IL2CPP scripting backend, this setting also allows incremental builds of the generated C++ code to work in Xcode. Appending to Xcode projects is supported only on macOS and Windows platforms. + ///This preserves any changes made to the existing Xcode project settings. With the IL2CPP scripting backend, this setting also allows incremental builds of the generated C++ code to work in Xcode. Appending to Xcode projects is supported only on macOS and Windows platforms. This option applies to iOS builds; for similar behavior on Android, use instead. /// AcceptExternalModificationsToPlayer = 1 << 5, @@ -220,7 +220,21 @@ public enum BuildOptions // Force the build to fail when any errors are encountered ///Prevent the build from succeeding if any errors are reported during the build process. - ///Without this flag, non-fatal errors, such as shader compilation issues on a particular platform, won't cause the build to fail, but might lead to incorrect behavior at runtime. + ///Without this flag, non-fatal errors, such as shader compilation issues on a particular platform, won't cause the build to fail, but might lead to incorrect behavior at runtime. + /// + /// Set this flag in most builds. Omit it only when errors are logged from packages or other third-party code that you can't fix, and you need the build to proceed despite them. + /// + /// This flag is set automatically when you use **Build and Run** in the Editor. + /// + /// When this flag is set, errors logged from these build callbacks also fail the build: + /// , + /// , + /// , + /// , and + /// . + /// + /// + /// StrictMode = 1 << 21, ///Build will include Assemblies for testing. diff --git a/Editor/Mono/BuildPipeline/Settings/BuildPlayerOptions.bindings.cs b/Editor/Mono/BuildPipeline/Settings/BuildPlayerOptions.bindings.cs index 86d1b44f04..513bae79b0 100644 --- a/Editor/Mono/BuildPipeline/Settings/BuildPlayerOptions.bindings.cs +++ b/Editor/Mono/BuildPipeline/Settings/BuildPlayerOptions.bindings.cs @@ -64,6 +64,11 @@ public struct BuildPlayerOptions [NativeName("platformGroup")] public BuildTargetGroup targetGroup { get; set; } ///The to build. + ///For best results, leave this property unset so that the build uses the target defined in the active . If you do set it, set it to match the active build target. + /// + ///Building a target that doesn't match the active build target is unreliable. Changing the active build target requires recompiling Editor scripts for the new platform and a domain reload, which can't happen while a build script is running. As a result, build callbacks and platform-dependent code might compile for the wrong platform, and some platforms fail to build at all. + /// + ///To select the target, set the active build profile first: use the Build Profiles window in the Editor, or the -activeBuildProfile or -buildTarget argument on the [command line](xref:um-build-command-line). For more information, refer to [Create a custom build script](xref:um-build-script-build). /// [NativeName("platform")] public BuildTarget target { get; set; } @@ -86,14 +91,21 @@ public struct BuildPlayerOptions /// /// public int subtarget { get; set; } - ///Additional , like whether to run the built player. + ///The flags to apply when building the Player. + ///Set this property to a bitwise combination of values before you pass to . For example, set options = BuildOptions.Development | BuildOptions.AutoRunPlayer to create a development build and run it automatically after the build completes. public BuildOptions options { get; set; } ///The additional preprocessor defines you can specify while compiling assemblies for the Player. These defines are appended to the existing Scripting Define Symbols list configured in the Player settings. public string[] extraScriptingDefines { get; set; } - ///Use this property to reference the output folders or build report directories from one or more content directory builds. + ///Use this property to reference the output folders or build report directories from one or more builds. ///The types used in those builds are included in the information provided to UnityLinker. - ///This ensures that the player can load all the content from those additional builds. + ///This ensures that the Player can load all the content from those additional builds. + /// + ///During a build you can also add directories from a build callback with . + /// + ///For more information, refer to [How code stripping affects content](xref:um-managed-code-stripping-content). + /// + /// public string[] previousBuildReportDirectories { get; set; } [NativeHeader("Editor/Src/BuildPipeline/BuildPlayerOptions.h")] diff --git a/Editor/Mono/BuildProfile/BuildProfileContext.cs b/Editor/Mono/BuildProfile/BuildProfileContext.cs index 9934c6af83..f298a810e0 100644 --- a/Editor/Mono/BuildProfile/BuildProfileContext.cs +++ b/Editor/Mono/BuildProfile/BuildProfileContext.cs @@ -572,19 +572,17 @@ void OnEnable() EditorGraphicsSettings.activeProfileHasGraphicsSettings = ActiveProfileHasGraphicsSettings(); - var buildProfile = activeProfile; + if (activeProfile != null) + return; - if (buildProfile == null) - { - buildProfile = GetForClassicPlatform(EditorUserBuildSettings.activePlatformGuid); + var buildProfile = GetForClassicPlatform(EditorUserBuildSettings.activePlatformGuid); - // profile can be null if we're in the middle of creating classic profiles - if (buildProfile == null) - return; + // profile can be null if we're in the middle of creating classic profiles + if (buildProfile == null) + return; - // We only copy EditorUserBuildSettings into the build profile for classic platforms as we don't want to modify actual user assets - EditorUserBuildSettings.CopyToBuildProfile(buildProfile); - } + // We only copy EditorUserBuildSettings into the build profile for classic platforms as we don't want to modify actual user assets + EditorUserBuildSettings.CopyToBuildProfile(buildProfile); var extension = ModuleManager.GetBuildProfileExtension(buildProfile.platformGuid); if (extension != null) diff --git a/Editor/Mono/BuildProfile/BuildProfileGraphicsSettingsEditor.cs b/Editor/Mono/BuildProfile/BuildProfileGraphicsSettingsEditor.cs index 09cbac3d80..6b56237d0d 100644 --- a/Editor/Mono/BuildProfile/BuildProfileGraphicsSettingsEditor.cs +++ b/Editor/Mono/BuildProfile/BuildProfileGraphicsSettingsEditor.cs @@ -75,11 +75,8 @@ void BindEnumFieldWithFadeGroup(VisualElement content, string id, Action buttonC var enumModeGroup = content.MandatoryQ($"{id}ModesGroup"); var enumModeProperty = serializedObject.FindProperty($"m_{id}Stripping"); - static bool IsModesGroupVisible(StrippingModes mode) => mode == StrippingModes.Custom; - UIElementsEditorUtility.SetVisibility(enumModeGroup, IsModesGroupVisible((StrippingModes)enumModeProperty.intValue)); - var lightmapModesUpdate = UIElementsEditorUtility.BindSerializedProperty(enumMode, enumModeProperty, - mode => UIElementsEditorUtility.SetVisibility(enumModeGroup, IsModesGroupVisible(mode))); - lightmapModesUpdate?.Invoke(); + UIElementsEditorUtility.BindSerializedProperty(enumMode, enumModeProperty, + mode => UIElementsEditorUtility.SetVisibility(enumModeGroup, mode == StrippingModes.Custom)); content.MandatoryQ - sealed class DrawerInstanceIMGUI + sealed partial class DrawerInstanceIMGUI { // Per-(property, container) IMGUI state cache. In-memory, editor-process lifetime. // Key: (propertyPath, targetEntityId, imguiContainerId). @@ -45,8 +46,15 @@ sealed class DrawerInstanceIMGUI // availableWidth (see GetOrCreate with isMultiEdit: true). GetOrCreate promotes a // stub to a full entry on demand while preserving any width already observed // during a prior short-circuit frame. + [AutoStaticsCleanupOnCodeReload] static readonly Dictionary s_Cache = new(); + internal static void InvalidateAllSortOrders() + { + foreach (var instance in s_Cache.Values) + instance.InvalidateSortOrder(); + } + static class Styles { // k_TreeViewHeight caps the rows area so the IMGUI drawer doesn't grow unbounded @@ -64,18 +72,23 @@ static class Styles public const float k_RowVerticalPadding = 5f; public const float k_CellHorizontalPadding = 8f; public const float k_ValueLeftPadding = 16f; + public const float k_OneColumnValueIndent = 14f; + public const float k_StaticValueHeaderTopMargin = 2f; public const float k_CellLabelWidthFraction = 0.35f; public const float k_CellLabelMinWidth = 80f; public const float k_CellControlMinWidth = 40f; - public const float k_DuplicateKeyIconLeftMargin = 4f; - public const float k_DuplicateKeyIconTopOffset = 2f; - public const float k_DuplicateKeyIconSize = 14f; + public const float k_KeyWarningIconLeftMargin = 4f; + public const float k_KeyWarningIconTopOffset = 2f; + public const float k_KeyWarningIconSize = 14f; public const float k_HandleWidth = 6f; public const float k_SortArrowSize = 12f; public const float k_SelectionBorderWidth = 3f; public const float k_VerticalScrollbarWidth = 16f; - public const float k_DuplicatesHelpBoxTopMargin = 4f; - public const float k_DuplicatesHelpBoxBottomMargin = 4f; + public const float k_BoxBottomBorder = 1f; + public const float k_EmptyRowsAreaPadding = 8f; + public const float k_EmptyLabelIndent = 18f; + public const float k_IgnoredHelpBoxTopMargin = 4f; + public const float k_IgnoredHelpBoxBottomMargin = 4f; public static readonly GUIStyle headerBackground = "RL Header"; public static readonly GUIStyle boxBackground = "RL Background"; @@ -84,11 +97,11 @@ static class Styles public static readonly GUIStyle columnLabel = "MultiColumnHeader"; public static readonly GUIStyle columnLabelClipped = new GUIStyle(columnLabel) { clipping = TextClipping.Ellipsis }; - public static GUIContent iconPlus = EditorGUIUtility.TrIconContent("Toolbar Plus"); - public static GUIContent iconMinus = EditorGUIUtility.TrIconContent("Toolbar Minus"); + public static readonly GUIContent iconPlus = EditorGUIUtility.TrIconContent("Toolbar Plus"); + public static readonly GUIContent iconMinus = EditorGUIUtility.TrIconContent("Toolbar Minus"); - public static Texture2D sortAscIcon = EditorGUIUtility.LoadIconRequired("UIPackageResources/Images/scrollup_uielements.png"); - public static Texture2D sortDescIcon = EditorGUIUtility.LoadIconRequired("UIPackageResources/Images/scrolldown_uielements.png"); + public static readonly Texture2D sortAscIcon = EditorGUIUtility.LoadIconRequired("UIPackageResources/Images/scrollup_uielements.png"); + public static readonly Texture2D sortDescIcon = EditorGUIUtility.LoadIconRequired("UIPackageResources/Images/scrolldown_uielements.png"); } readonly struct PropertyCacheKey : IEquatable @@ -126,6 +139,7 @@ public bool Equals(PropertyCacheKey other) public readonly SerializedProperty arrayProperty; public SortedIndexMap sortedIndices = SortedIndexMap.Empty; public readonly HashSet duplicateEntryIndices = new HashSet(); + public readonly HashSet nullKeyEntryIndices = new HashSet(); // Count of items the TreeView is currently rendering. Equal to // sortedIndices.Length by invariant; may differ from arrayProperty.arraySize // between an external array mutation (Undo, script, prefab apply, @@ -144,6 +158,8 @@ public bool Equals(PropertyCacheKey other) // with sortedIndices. public ulong lastKnownKeysHash; public bool needsReload; + public bool needsSortOrderRebuild; + public bool needsLayoutChange; public bool needsDuplicate; public readonly Type keyType; public readonly Type valueType; @@ -154,10 +170,32 @@ public bool Equals(PropertyCacheKey other) public bool hasStaticInlineHeight; // inline compound type with fixed height (no expandable children) public bool needsHeightClassification; // deferred until first element exists to inspect public bool needsHeightRefresh; // a rendered row's measured height drifted from cached value + public bool needsHeightMeasure; // row-height measurement deferred to the next OnGUI Layout pass public bool sortAscending = true; + // Single source of truth for the column layout; oneColumnMode/useValueFoldouts are + // derived views kept so the row-drawing code reads intent-named flags. The two + // OneColumn_* modes both stack key over value, differing only in the per-row foldout. + public DictionaryLayout layout = DictionaryLayout.TwoColumns; + public bool oneColumnMode => layout != DictionaryLayout.TwoColumns; + public bool useValueFoldouts => layout == DictionaryLayout.OneColumnWithValueFoldout; + // Default layout resolved from [DictionaryDisplay] (field- or assembly-level); the + // active layout falls back to this until the user overrides it from the context menu. + public readonly DictionaryLayout attributeLayout; + public readonly GUIContent valueLabelContent; + // Value-cell label when the value type is a collection ("Dictionary"/"Array"/"List"); null + // otherwise, so the cell draws with GUIContent.none. This is what gives a nested collection value + // its foldout title: it is forwarded as the label to the value's own drawer (a nested dictionary + // reads it as its OnGUI label, an array/list as its built-in foldout title). + public readonly GUIContent valueCollectionLabel; public readonly float attributeKeyFraction; public readonly Hash128 stateCacheKey; public float availableWidth; + // Room the host has below the drawer. 0 means something outside can scroll and reveal the + // rows by itself, or that no Repaint has measured yet. + public float hostRoomForRows; + + DictionaryState m_CachedViewState; + int m_CachedStateVersion = -1; // Stubs allocated by GetOrCreate(..., isMultiEdit: true) leave treeView null; // a null treeView is the stable marker that distinguishes a stub from a fully- @@ -187,10 +225,17 @@ public bool Equals(PropertyCacheKey other) // PerformSortToggle doesn't depend on selection that may have moved by // the time it runs. public bool deferredWorkScheduled; - public bool needsDuplicateRefresh; + public bool needsMarkerRefresh; public bool pendingSortToggle; public int[] pendingSortToggleSelectionArrayIndices; public bool needsTreeViewFocus; + // Display index to (re)frame once row heights have settled. A structural mutation + // (AddEntry) runs before the deferred height measurement is consumed on the Layout + // pass, so framing right away scrolls against pre-measurement row rects and can miss + // the target — e.g. a freshly added bottom row not scrolled fully into view. We record + // the target here and frame it from GetExpandedPropertyHeight once heights are settled. + // -1 means nothing is pending. + public int pendingFrameDisplayIndex = -1; // Interaction check. When RunDeferredStructuralWork finds needsReload but // EditorInteractionMonitor.IsReadyToApplyDeferredChanges is false, we install a @@ -232,22 +277,32 @@ public bool Equals(PropertyCacheKey other) dictionaryProperty = property.Copy(); - var genericArgs = GetDictionaryGenericArguments(m_FieldInfo); + // The property's static type is the closed Dictionary for *this* field/value, which + // for a nested dictionary differs from m_FieldInfo.FieldType (the outer field). Prefer it + // for the key/value types and layout lookup; fall back to the field type if unavailable. + ScriptAttributeUtility.GetFieldInfoAndStaticTypeFromProperty(property, out var dictionaryType); + var genericArgs = dictionaryType != null && dictionaryType.IsGenericType + ? dictionaryType.GetGenericArguments() + : GetDictionaryGenericArguments(m_FieldInfo); keyType = genericArgs[0]; valueType = genericArgs[1]; keyHasCustomDrawer = ScriptAttributeUtility.GetDrawerTypeForType(keyType, null) != null; valueHasCustomDrawer = ScriptAttributeUtility.GetDrawerTypeForType(valueType, null) != null; - GetHeaderLabels(m_FieldInfo, out var keyLabel, out var valueLabel, out var keyFraction); + GetHeaderLabels(m_FieldInfo, dictionaryType, out var keyLabel, out var valueLabel, out var keyFraction); attributeKeyFraction = keyFraction; + attributeLayout = ResolveDefaultLayout(m_FieldInfo, dictionaryType); + valueLabelContent = new GUIContent(valueLabel); + var collectionLabel = GetNestedCollectionValueLabel(valueType); + if (collectionLabel != null) + valueCollectionLabel = new GUIContent(collectionLabel); stateCacheKey = ComputeStateCacheKey(property.propertyPath); float effectiveFraction = GetActiveKeyColumnFraction(stateCacheKey, keyFraction); + layout = GetActiveLayout(stateCacheKey, attributeLayout); var cachedState = s_StateCache.GetState(stateCacheKey); if (cachedState != null) - { sortAscending = cachedState.sortAscending; - } header = new DictionaryHeader(keyLabel, valueLabel, effectiveFraction, stateCacheKey); treeViewState = new TreeViewState(); @@ -255,15 +310,11 @@ public bool Equals(PropertyCacheKey other) arrayProperty = GetArrayProperty(dictionaryProperty); sortedIndices = SortedIndexMap.Build(arrayProperty, sortAscending); lastKnownKeysHash = GetKeysContentHash(arrayProperty); - TryRefreshDuplicateIndicesInto(dictionaryProperty, duplicateEntryIndices); + TryRefreshDuplicateAndNullKeyIndicesInto(dictionaryProperty, duplicateEntryIndices, nullKeyEntryIndices); treeView = new DictionaryTreeView(this); - if (displayedItemCount > 0) - ClassifyRowHeights(); - else - needsHeightClassification = IsGenericInlineType(keyType, keyHasCustomDrawer) - || IsGenericInlineType(valueType, valueHasCustomDrawer); + ClassifyRowHeights(); treeView.Reload(); } @@ -300,7 +351,7 @@ public static DrawerInstanceIMGUI GetOrCreate(DictionaryDrawer drawer, Serialize RegisterCacheEvictionOnDetach(imguiContainer, key); // Bind a property-change listener on the IMGUIContainer so any inspector - // showing this dictionary re-sorts and refreshes its duplicate markers when + // showing this dictionary re-sorts and refreshes its key warning markers when // the SerializedObject is mutated elsewhere (e.g. a key edit in a second // inspector pinned to the same target). Stubs (multi-edit) have no // dictionaryProperty to track, so we only register on fully-initialized @@ -322,7 +373,11 @@ public static float GetPropertyHeight(DictionaryDrawer drawer, SerializedPropert if (!property.isExpanded) return EditorGUIUtility.singleLineHeight; - // Invariant: PropertyDrawer's OnGUI / GetPropertyHeight only run while in an IMGUIContainer + // Invariant: size is only ever computed inside an OnGUI pass, so an IMGUIContainer + // is always on the stack here. The deferred reload (delayCall / update) rebuilds row + // *structure* but never measures, so it can't reach this from a container-less + // context — including a nested dictionary's GetPropertyHeight queried via the parent + // drawer's row measurement, which now runs on the parent's OnGUI Layout pass. var imguiContainer = IMGUIContainer.GetCurrentIMGUIContainer(); Debug.Assert(imguiContainer != null, Texts.ExpectedCurrentContainerMessage); @@ -360,43 +415,128 @@ public bool HasFocus() float GetExpandedPropertyHeight() { - if (dynamicRowHeight && displayedItemCount > 0 && needsHeightRefresh) - { - treeView.RefreshCustomRowHeights(); - needsHeightRefresh = false; - } + // Row heights are measured only on the OnGUI Layout pass, where an IMGUIContainer is + // guaranteed (the inspector calls GetPropertyHeight from its own OnGUI). The deferred + // reload rebuilds row *structure* and classifies cells, but never measures: measuring + // a custom-drawer cell (e.g. a nested dictionary) calls into that drawer's + // GetPropertyHeight, which needs the container. We measure here and let + // RefreshCustomRowHeights cache it so the Repaint / event passes of the same frame + // read a consistent total. + // + // For variable-height rows we measure *every* row, not just a sample: with custom row + // heights the TreeView's scroll view clamps scrollPos to (totalHeight - viewport) + // each frame, so a totalHeight that keeps changing as rows are lazily measured would + // repeatedly clamp the scroll and walk it away from a framed position. Measuring all + // rows once makes totalHeight exact and stable, so framing (and the scrollbar) hold. + MeasureRowHeightsIfNeeded(); + + ApplyPendingFrameIfReady(); + + return GetHeightAroundRows() + GetRowsAreaHeight(); + } - float foldoutLine = EditorGUIUtility.singleLineHeight; - float columnHeader = header.height; - float rowsArea; + float GetHeightAroundRows() + { + // The help box is reserved with its margins; HasIgnoredHelpBox reports the box + // alone, since that is what DrawIgnoredHelpBox needs for the rect it draws into. + float heightOfHelpboxAndMargins = 0f; + if (HasIgnoredHelpBox(out float helpboxHeight, out _)) + heightOfHelpboxAndMargins = Styles.k_IgnoredHelpBoxTopMargin + helpboxHeight + Styles.k_IgnoredHelpBoxBottomMargin; + + return EditorGUIUtility.singleLineHeight + + header.height + + Styles.k_BoxBottomBorder + + Styles.k_FooterHeight + Styles.k_FooterSpacing + + heightOfHelpboxAndMargins; + } + float GetRowsAreaHeight() + { if (displayedItemCount == 0) + return EditorGUIUtility.singleLineHeight + Styles.k_EmptyRowsAreaPadding; + + return Mathf.Min(treeView.totalHeight, GetMaxRowsAreaHeight()); + } + + // GUI.BeginScrollView takes its scroll range from the rect it is given, so in a host that + // cannot scroll, rows below the part of that rect it can show would be unreachable. (UUM-149490) + float GetMaxRowsAreaHeight() + { + if (hostRoomForRows <= 0f) + return Styles.k_TreeViewHeight; + + return Mathf.Clamp(hostRoomForRows - GetHeightAroundRows(), + EditorGUIUtility.singleLineHeight, Styles.k_TreeViewHeight); + } + + // An enclosing IMGUI scroll view counts too: a nested dictionary sits inside the outer + // list's, and clamping it there would tie its height to the outer scroll position. + bool HostCanScroll() + => GUI.GetTopScrollView() != null + || imguiContainer?.GetFirstAncestorOfType() != null; + + void MeasureRowHeightsIfNeeded() + { + if (Event.current.type == EventType.Layout && displayedItemCount > 0 && needsHeightMeasure) { - rowsArea = EditorGUIUtility.singleLineHeight + 8f; + needsHeightMeasure = false; + if (variableRowHeight) + treeView.MeasureAllRowHeights(); + else if (hasStaticInlineHeight) + treeView.ComputeFixedInlineRowHeight(); + treeView.RefreshCustomRowHeights(); } - else + + if ((dynamicRowHeight || hasStaticInlineHeight) && displayedItemCount > 0 && needsHeightRefresh) { - rowsArea = Mathf.Min(treeView.totalHeight, Styles.k_TreeViewHeight); + treeView.RefreshCustomRowHeights(); + needsHeightRefresh = false; } + } + + // Apply a deferred frame request (e.g. a newly added row) once row heights are settled — + // after MeasureRowHeightsIfNeeded has run on this Layout pass — so the scroll uses final + // row rects instead of the pre-measurement estimate. Repaint so the scroll position the + // frame sets is rendered. + void ApplyPendingFrameIfReady() + { + if (pendingFrameDisplayIndex < 0 || Event.current.type != EventType.Layout || needsHeightMeasure || needsHeightRefresh) + return; - float footer = Styles.k_FooterHeight + Styles.k_FooterSpacing; - float duplicatesBlock = CalcDuplicatesHelpBoxHeight(availableWidth); - return foldoutLine + columnHeader + rowsArea + 1f + footer + duplicatesBlock; + if (pendingFrameDisplayIndex < displayedItemCount) + { + treeView.FrameItem(pendingFrameDisplayIndex); + HandleUtility.Repaint(); + } + pendingFrameDisplayIndex = -1; } - float CalcDuplicatesHelpBoxHeight(float availWidth) + // True when the ignored-entries help box is rendered: something to report, and the content + // width is known (it is sampled from the first Repaint — see UpdateAvailableWidth). The + // height and the draw both go through here, so a block can't be drawn without being + // reserved, which would land it outside the drawer's rect and over the rest of the inspector. + bool HasIgnoredHelpBox(out float height, out string text) { - if (duplicateEntryIndices.Count == 0 || availWidth <= 0f) - return 0f; + height = 0f; + text = null; + int duplicateCount = duplicateEntryIndices.Count; + int nullKeyCount = nullKeyEntryIndices.Count; + if (duplicateCount + nullKeyCount == 0 || availableWidth <= 0f) + return false; - string text = Texts.GetDuplicatesHelpBoxText(duplicateEntryIndices.Count); - float helpBoxHeight = DrawerEditorGUI.GetHelpBoxWithButtonHeight(MessageType.Warning, text, availWidth); - return Styles.k_DuplicatesHelpBoxTopMargin + helpBoxHeight + Styles.k_DuplicatesHelpBoxBottomMargin; + text = Texts.GetIgnoredHelpBoxText(duplicateCount, nullKeyCount); + height = DrawerEditorGUI.GetHelpBoxWithButtonHeight(MessageType.Warning, text, availableWidth); + return true; } void OnGUI(Rect position, SerializedProperty property, GUIContent label, bool isMultiEdit) { UpdateAvailableWidth(position); + RepaintIfNeeded(); + + // Only Repaint carries the drawer's final rect; a Layout pass hands out a dummy one. + if (Event.current.type == EventType.Repaint) + hostRoomForRows = HostCanScroll() ? 0f : GUIClip.visibleRect.yMax - position.y; var foldoutRect = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); @@ -419,6 +559,8 @@ void OnGUI(Rect position, SerializedProperty property, GUIContent label, bool is return; } + SyncWithDictionaryViewState(); + // Pick up external array-size changes (Undo, script, prefab apply, etc.) // before drawing so the row-count text in the foldout reflects them this // frame. The actual rebuild is deferred to PerformReload between OnGUI @@ -446,6 +588,8 @@ void OnGUI(Rect position, SerializedProperty property, GUIContent label, bool is return; } + MeasureRowHeightsIfNeeded(); + // Expanded body: Two column header, rows (or empty label), footer and help box DrawExpandedBody(position, foldoutRect.yMax, property); } @@ -525,12 +669,21 @@ bool IsAlive() void ClearAllPendingFlags() { needsReload = false; - needsDuplicateRefresh = false; + needsSortOrderRebuild = false; + needsLayoutChange = false; + needsMarkerRefresh = false; pendingSortToggle = false; pendingSortToggleSelectionArrayIndices = null; StopInteractionCheck(); } + public void InvalidateSortOrder() + { + needsReload = true; + needsSortOrderRebuild = true; + ScheduleDeferredStructuralWork(); + } + // Detect external array-size changes and (re-)arm the deferred reload. // needsReload may already be set by a TrackPropertyValue notification; the // OR keeps that pending request scheduled even on a frame where the size @@ -545,6 +698,48 @@ void ScheduleReloadIfArrayChanged() ScheduleDeferredStructuralWork(); } + // Sibling dictionaries (elements sharing a normalized stateCacheKey) share one persisted + // DictionaryState. UITK links live views and pushes; IMGUI is immediate-mode, so each + // instance instead pulls the shared state each frame and applies any divergence. Structural + // changes (sort/layout) are deferred like every other reload; the column fraction is a pure + // draw-time value, so it's applied inline. StateCache hands back the same DictionaryState + // instance to every sibling, so a resize drag mutating that object in-memory (see + // HandleResize) is picked up here without a per-frame disk write. + void SyncWithDictionaryViewState() + { + // Only re-read when shared state changed; a default dictionary would otherwise run GetState -> File.Exists every event. + if (m_CachedStateVersion != StateVersion) + { + m_CachedViewState = GetCachedState(stateCacheKey); + m_CachedStateVersion = StateVersion; + } + var state = m_CachedViewState; + + bool cachedSortAscending = state?.sortAscending ?? true; + if (cachedSortAscending != sortAscending && !pendingSortToggle) + { + sortAscending = cachedSortAscending; + needsReload = true; + needsSortOrderRebuild = true; + ScheduleDeferredStructuralWork(); + } + + var effectiveLayout = GetActiveLayout(state, attributeLayout); + if (effectiveLayout != layout) + { + layout = effectiveLayout; + needsLayoutChange = true; + ScheduleDeferredStructuralWork(); + } + + float effectiveFraction = GetActiveKeyColumnFraction(state, attributeKeyFraction); + if (!Mathf.Approximately(header.column1Fraction, effectiveFraction)) + { + header.column1Fraction = effectiveFraction; + imguiContainer?.MarkDirtyRepaint(); + } + } + // Coalescing entry point for every structural mutation. Caller flips a pending flag // (or fills a snapshot) on the instance and then calls this; we register at most one // EditorApplication.delayCall per instance per "burst", regardless of how many flags @@ -558,6 +753,33 @@ void ScheduleDeferredStructuralWork() EditorApplication.delayCall += RunDeferredStructuralWork; } + // Set by RepaintForHeightChange, consumed on the next Repaint pass. + bool m_NeedsFollowUpRepaint; + + // Use instead of MarkDirtyRepaint when the height from GetPropertyHeight changes with no + // user event left to settle the layout (deferred structural work, context-menu actions, + // first width sample). Two repaints are needed: whoever lays us out from a cached height + // — an enclosing ReorderableList, the IMGUIContainer's measured layout — only notices + // while repainting and drops the stale value after drawing that frame, so the first + // repaint lands against the old geometry and the second comes out right. Same reason + // moving the mouse over the Inspector fixes it. + void RepaintForHeightChange() + { + m_NeedsFollowUpRepaint = true; + imguiContainer?.MarkDirtyRepaint(); + } + + // Chained from a Repaint pass rather than requested up front, so it cannot be coalesced + // into the repaint RepaintForHeightChange already asked for. + void RepaintIfNeeded() + { + if (!m_NeedsFollowUpRepaint || Event.current.type != EventType.Repaint) + return; + + m_NeedsFollowUpRepaint = false; + HandleUtility.Repaint(); + } + // Installs a single EditorApplication.update handler that re-checks the // EditorInteractionMonitor gate every k_InteractionCheckIntervalSeconds. Only one // handler is registered per instance at a time; subsequent calls are no-ops while @@ -611,7 +833,7 @@ void RunInteractionCheck() // Runs strictly between OnGUI passes. Order matters: sort toggle runs first // because it rebuilds sortedIndices wholesale, which makes a subsequent gated // reload either a no-op or correctly idempotent; needsReload then - // needsDuplicateRefresh follow in decreasing structural impact. The + // needsMarkerRefresh follow in decreasing structural impact. The // interaction gate only applies to needsReload — SortToggle originates from // an explicit user click that is itself the interaction, so re-arming would // just spin. @@ -629,6 +851,14 @@ void RunDeferredStructuralWork() bool needsRepaint = false; + if (needsLayoutChange) + { + needsLayoutChange = false; + ClassifyRowHeights(); + treeView.Reload(); + needsRepaint = true; + } + if (pendingSortToggle) { PerformSortToggle(); @@ -641,23 +871,24 @@ void RunDeferredStructuralWork() { int currentSize = arrayProperty.arraySize; bool sizeChanged = currentSize != displayedItemCount; - bool keysChanged = sizeChanged || GetKeysContentHash(arrayProperty) != lastKnownKeysHash; + bool keysChanged = sizeChanged || needsSortOrderRebuild || GetKeysContentHash(arrayProperty) != lastKnownKeysHash; if (!keysChanged) { // Pure value-only edit. Duplicates are determined solely by key // content, so a same-hash refresh would also be a no-op. needsReload = false; - needsDuplicateRefresh = false; + needsMarkerRefresh = false; StopInteractionCheck(); } else if (EditorInteractionMonitor.IsReadyToApplyDeferredChanges(null)) { PerformReload(); needsReload = false; - // A full reload also recomputes duplicateEntryIndices, so a pending - // duplicate-only refresh is subsumed and can be cleared. - needsDuplicateRefresh = false; + // A full reload also recomputes both marker sets, so a pending + // marker-only refresh is subsumed and can be cleared. + needsMarkerRefresh = false; + needsSortOrderRebuild = false; needsRepaint = true; StopInteractionCheck(); } @@ -666,24 +897,26 @@ void RunDeferredStructuralWork() // Interaction is in flight (text edit, hot control, picker open) so // start a EditorApplication.update handler that checks when // the user is done editing at a coarse interval and re-enters - // ScheduleDeferredStructuralWork once the gate opens. The duplicate - // refresh below still runs ungated so the per-row duplicate-key - // icons and the "X duplicates" count keep updating live as the + // ScheduleDeferredStructuralWork once the gate opens. The marker + // refresh below still runs ungated so the per-row key warning + // icons and the "X ignored" count keep updating live as the // user types. - needsDuplicateRefresh = true; + needsMarkerRefresh = true; StartInteractionCheck(); } } - if (needsDuplicateRefresh) + if (needsMarkerRefresh) { - needsDuplicateRefresh = false; - if (TryRefreshDuplicateIndicesInto(dictionaryProperty, duplicateEntryIndices)) + needsMarkerRefresh = false; + if (TryRefreshDuplicateAndNullKeyIndicesInto(dictionaryProperty, duplicateEntryIndices, nullKeyEntryIndices)) needsRepaint = true; } + // Every branch above changes our height: the row set (reload / sort / layout) or the + // ignored-entries help box (marker refresh), so this needs a re-layout, not a repaint. if (needsRepaint) - imguiContainer?.MarkDirtyRepaint(); + RepaintForHeightChange(); } static bool IsGenericInlineType(Type type, bool hasCustomDrawer) @@ -695,6 +928,38 @@ static bool IsGenericInlineType(Type type, bool hasCustomDrawer) return true; } + // True for a key/value type whose cell can render taller than a single line, so the + // row height must be derived from the property instead of using the default + // single-line height. Covers two cases the row classifier must treat alike: + // - generic inline compounds (struct/class drawn by expanding their children), and + // - types with a custom PropertyDrawer, which can be multi-line and/or expandable + // (e.g. a nested Dictionary<,>). The bare IsGenericInlineType check excludes the + // latter, which is why a nested dictionary value otherwise collapses to a single + // row line and the nested drawer overlaps the rows below it. + // Simple single-line types (primitive, string, enum, Object reference) return false. + static bool IsComplexCellType(Type type, bool hasCustomDrawer) + { + if (type == null) + return false; + return hasCustomDrawer || IsGenericInlineType(type, false); + } + + // Whether a cell's rendered height can change after the initial layout, which forces + // per-row lazy height tracking instead of a single fixed row height. A custom-drawer cell + // is always treated as dynamic because the drawer can expand (foldout) or resize at + // runtime (e.g. a nested Dictionary<,>) and its height at classification time — while + // collapsed — is not representative. An array/list cell is likewise dynamic: it has a + // foldout and a resizable element count. A generic inline compound is dynamic only when + // it contains expandable children. + static bool CellHeightCanChange(SerializedProperty prop, bool hasCustomDrawer) + { + if (hasCustomDrawer) + return true; + if (prop != null && prop.isArray) + return true; + return HasExpandableChildren(prop); + } + static bool HasExpandableChildren(SerializedProperty prop) { if (prop == null || !prop.isValid || prop.propertyType != SerializedPropertyType.Generic) @@ -723,23 +988,57 @@ void ClassifyRowHeights() { needsHeightClassification = false; - bool keyIsInline = IsGenericInlineType(keyType, keyHasCustomDrawer); - bool valueIsInline = IsGenericInlineType(valueType, valueHasCustomDrawer); + dynamicRowHeight = false; + variableRowHeight = false; + hasStaticInlineHeight = false; + + // OneColumnWithValueFoldout: the per-row foldout is toggled at runtime, which changes + // the row height each time it expands/collapses, so these rows are always dynamic + // regardless of the key/value types — no element needs to be inspected. + if (useValueFoldouts) + { + dynamicRowHeight = true; + variableRowHeight = true; + needsHeightMeasure = true; + return; + } + + bool keyIsComplex = IsComplexCellType(keyType, keyHasCustomDrawer); + bool valueIsComplex = IsComplexCellType(valueType, valueHasCustomDrawer); - if (!keyIsInline && !valueIsInline) + // Simple key and value: every row is the same fixed height. Flag it static so + // ComputeFixedInlineRowHeight recomputes the shared rowHeight on each switch, + // keeping it correct for the current layout. + if (!keyIsComplex && !valueIsComplex) + { + hasStaticInlineHeight = true; + needsHeightMeasure = true; + return; + } + + // A complex cell's height depends on the actual element, which can only be inspected + // once one exists. Callers classify eagerly — including on an empty dictionary, so the + // layout is ready before the first add — so defer the per-element analysis until the + // first entry exists. + if (displayedItemCount == 0) + { + needsHeightClassification = true; return; + } var element = arrayProperty.GetArrayElementAtIndex(0); GetKeyAndValueProperties(element, out var keyProp, out var valueProp); - bool keyDynamic = keyIsInline && HasExpandableChildren(keyProp); - bool valueDynamic = valueIsInline && HasExpandableChildren(valueProp); + bool keyDynamic = keyIsComplex && CellHeightCanChange(keyProp, keyHasCustomDrawer); + bool valueDynamic = valueIsComplex && CellHeightCanChange(valueProp, valueHasCustomDrawer); dynamicRowHeight = keyDynamic || valueDynamic; variableRowHeight = dynamicRowHeight; hasStaticInlineHeight = !dynamicRowHeight; - if (hasStaticInlineHeight) - treeView.ComputeFixedInlineRowHeight(); + // Classification only decides the row-height *kind*. The actual measurement + // (fixed inline height or variable estimate) runs on the next OnGUI Layout pass — + // see GetExpandedPropertyHeight — so this stays safe to call from the deferred reload. + needsHeightMeasure = true; } // Calculates the rendered height of an EditorGUI.HelpBox(rect, message, type) at a given width @@ -765,10 +1064,10 @@ void UpdateAvailableWidth(Rect position) bool wasUnknown = availableWidth <= 0f; availableWidth = position.width; - // First valid width sample: repaint so GetPropertyHeight can reserve space - // on the next frame using the now-known width. + // First valid width sample: the width-dependent blocks (multi-edit / ignored-entries + // help boxes) reserve and draw nothing until now, so re-layout to pick them up. if (wasUnknown) - HandleUtility.Repaint(); + RepaintForHeightChange(); } // Multi-edit fallback: the dictionary drawer can't merge two TreeViews / sort @@ -790,19 +1089,15 @@ void DrawMultiEditHelpBoxIfExpanded(Rect position, Rect foldoutRect, SerializedP void DrawExpandedBody(Rect position, float startY, SerializedProperty property) { float headerH = header.height; - float fullContentH = displayedItemCount == 0 - ? EditorGUIUtility.singleLineHeight + 8f - : treeView.totalHeight; - float contentH = Mathf.Min(fullContentH, Styles.k_TreeViewHeight); + float contentH = GetRowsAreaHeight(); - const float borderBottom = 1f; float y = startY; // Backgrounds first so the column header / rows draw on top. if (Event.current.type == EventType.Repaint) { var headerRect = new Rect(position.x, y, position.width, headerH); - var contentRect = new Rect(position.x, y + headerH, position.width, contentH + borderBottom); + var contentRect = new Rect(position.x, y + headerH, position.width, contentH + Styles.k_BoxBottomBorder); Styles.headerBackground.Draw(headerRect, false, false, false, false); Styles.boxBackground.Draw(contentRect, false, false, false, false); } @@ -817,7 +1112,7 @@ void DrawExpandedBody(Rect position, float startY, SerializedProperty property) // Rows (or "empty dictionary" placeholder when there are no entries). if (displayedItemCount == 0) { - var emptyRect = new Rect(position.x + 18f, y, position.width - 18f, EditorGUIUtility.singleLineHeight + 8f); + var emptyRect = new Rect(position.x + Styles.k_EmptyLabelIndent, y, position.width - Styles.k_EmptyLabelIndent, contentH); EditorGUI.LabelField(emptyRect, Texts.EmptyDictionaryLabel); y += emptyRect.height; } @@ -830,7 +1125,7 @@ void DrawExpandedBody(Rect position, float startY, SerializedProperty property) treeView.OnGUI(treeRect); y += contentH; } - y += borderBottom; + y += Styles.k_BoxBottomBorder; // Cmd+D / context-menu duplicate is queued during the TreeView OnGUI and // flushed here, after the rows have already drawn for this frame so we @@ -845,29 +1140,25 @@ void DrawExpandedBody(Rect position, float startY, SerializedProperty property) var footerRect = new Rect(position.x, y + Styles.k_FooterSpacing - 1f, position.width, Styles.k_FooterHeight); DrawFooter(footerRect, property); - DrawDuplicatesHelpBox(position, footerRect.yMax); + if (HasIgnoredHelpBox(out float helpBoxHeight, out var helpBoxText)) + DrawIgnoredHelpBox(position, footerRect.yMax, helpBoxHeight, helpBoxText); } - void DrawDuplicatesHelpBox(Rect position, float startY) + void DrawIgnoredHelpBox(Rect position, float startY, float helpBoxHeight, string helpBoxText) { - if (duplicateEntryIndices.Count == 0) - return; - - string text = Texts.GetDuplicatesHelpBoxText(duplicateEntryIndices.Count); - float helpBoxHeight = DrawerEditorGUI.GetHelpBoxWithButtonHeight(MessageType.Warning, text, position.width); - float helpBoxY = startY + Styles.k_DuplicatesHelpBoxTopMargin; + float helpBoxY = startY + Styles.k_IgnoredHelpBoxTopMargin; var helpBoxRect = new Rect(position.x, helpBoxY, position.width, helpBoxHeight); - if (DrawerEditorGUI.HelpBoxWithButton(helpBoxRect, MessageType.Warning, text, Texts.SelectFirstDuplicateButtonLabel)) - SelectFirstDuplicate(); + if (DrawerEditorGUI.HelpBoxWithButton(helpBoxRect, MessageType.Warning, helpBoxText, Texts.SelectFirstIgnoredButtonLabel)) + SelectFirstIgnored(); } - void SelectFirstDuplicate() + void SelectFirstIgnored() { if (treeView == null) return; - int firstDisplayIndex = FindFirstDuplicateDisplayIndex(duplicateEntryIndices, sortedIndices); + int firstDisplayIndex = FindFirstIgnoredDisplayIndex(duplicateEntryIndices, nullKeyEntryIndices, sortedIndices); if (firstDisplayIndex < 0) return; @@ -878,13 +1169,21 @@ void SelectFirstDuplicate() void DrawFoldoutHeader(Rect rect, SerializedProperty property, GUIContent label) { int duplicateCount = duplicateEntryIndices?.Count ?? 0; + int nullKeyCount = nullKeyEntryIndices?.Count ?? 0; + int ignoredCount = duplicateCount + nullKeyCount; int itemCount = displayedItemCount; - string countText = Texts.GetItemCountText(itemCount); - string duplicateText = duplicateCount > 0 - ? Texts.GetDuplicateCountText(duplicateCount) - : ""; - string infoText = $"{countText}{duplicateText}"; + string infoText; + if (DictionaryDrawer.ShowSerializedOrder) + { + infoText = Texts.ShowingSerializedOrderInfoLabel; + } + else + { + infoText = Texts.GetItemCountText(itemCount); + if (ignoredCount > 0) + infoText += Texts.GetIgnoredCountText(ignoredCount); + } var infoSize = EditorStyles.miniLabel.CalcSize(new GUIContent(infoText)); var infoRect = new Rect(rect.xMax - infoSize.x - 4f, rect.y, infoSize.x, rect.height); @@ -911,7 +1210,7 @@ void DrawFooter(Rect rect, SerializedProperty property) } var selection = treeView.GetSelection(); - using (new EditorGUI.DisabledScope(selection.Count == 0)) + using (new EditorGUI.DisabledScope(arrayProperty.arraySize == 0)) { if (GUI.Button(removeRect, Styles.iconMinus, Styles.footerButton)) { @@ -922,25 +1221,23 @@ void DrawFooter(Rect rect, SerializedProperty property) void SetTreeViewFocusOnMouseEvents(Rect treeRect) { - // TreeView focus grab on MouseDown / ScrollWheel. Must be called before the - // TreeView's OnGUI(). - // Two reasons stack: - // - Visual: the active (blue) selection outline only shows when the - // treeview itself owns keyboard focus, mirroring how row clicks - // already grab focus via HandleRowSelectionClick. - // - Correctness: scrolling (wheel, scrollbar drag, repeat-button) culls - // rows that leave the visible area, and culled controls don't allocate - // their controlIDs — that shifts the IDs of the rows that remain and - // reroutes keyboard focus to an unrelated cell. Releasing whatever - // currently owns keyboard focus (typically a text field in a row) - // before the scroll runs avoids that. SetFocus also clears - // EditorGUIUtility.editingTextField, so a text edit ends cleanly. + // TreeView focus grab on ScrollWheel. Must be called before the TreeView's OnGUI(). + // + // This ends an in-progress cell text edit when the user wheel-scrolls the list and + // hands focus (and the blue selection outline) to the treeview itself: SetFocus moves + // keyboardControl to the treeview and clears EditorGUIUtility.editingTextField. + // + // Note it is intentionally NOT done for plain MouseDown. Scrolling culls rows that + // leave the visible area, but the cells' control ids are position-keyed and stay + // stable across culling, so a focused cell is never rerouted to a different row — + // grabbing focus on MouseDown would only steal it from the cell the user just clicked, + // breaking caret placement (first click selects all, second could never place the caret). // // OnOptimizedInspectorGUI(Rect contentRect) clears GUIUtility.keyboardControl // = 0 even when we have treeview focus, so the !HasFocus() check is needed // here too — without it SetFocus would no-op when the user is just panning // over the treeview and we want the blue outline back. - if ((Event.current.type == EventType.MouseDown || Event.current.type == EventType.ScrollWheel) + if (Event.current.type == EventType.ScrollWheel && treeRect.Contains(Event.current.mousePosition) && !treeView.HasFocus()) treeView.SetFocus(); @@ -978,13 +1275,12 @@ void AddEntry() sortedIndices = SortedIndexMap.Build(arrayProperty, sortAscending); lastKnownKeysHash = GetKeysContentHash(arrayProperty); - TryRefreshDuplicateIndicesInto(dictionaryProperty, duplicateEntryIndices); + TryRefreshDuplicateAndNullKeyIndicesInto(dictionaryProperty, duplicateEntryIndices, nullKeyEntryIndices); if (needsHeightClassification) ClassifyRowHeights(); treeView.Reload(); - int newDisplayIndex = sortedIndices.ToDisplayIndex(lastIndex); - treeView.SetSelection(new[] { newDisplayIndex }, TreeViewSelectionOptions.RevealAndFrame); + SelectAndFrameAfterRebuild(new[] { sortedIndices.ToDisplayIndex(lastIndex) }); treeView.SetFocus(); } @@ -993,14 +1289,15 @@ void RemoveSelectedEntries() var selection = treeView.GetSelection(); int newSelectedDisplayIndex = selection.Count == 1 ? selection[0] : -1; - bool removed = RemoveEntriesAtDisplayIndices( - arrayProperty, selection, sortedIndices); + var removed = selection.Count > 0 + ? RemoveEntriesAtDisplayIndices(arrayProperty, selection, sortedIndices) + : RemoveEntryAtDisplayIndex(arrayProperty, arrayProperty.arraySize - 1, sortedIndices); if (!removed) return; sortedIndices = SortedIndexMap.Build(arrayProperty, sortAscending); lastKnownKeysHash = GetKeysContentHash(arrayProperty); - TryRefreshDuplicateIndicesInto(dictionaryProperty, duplicateEntryIndices); + TryRefreshDuplicateAndNullKeyIndicesInto(dictionaryProperty, duplicateEntryIndices, nullKeyEntryIndices); treeView.Reload(); if (displayedItemCount <= 0 || newSelectedDisplayIndex < 0) @@ -1010,7 +1307,7 @@ void RemoveSelectedEntries() else { int clampedSelection = Mathf.Min(newSelectedDisplayIndex, displayedItemCount - 1); - treeView.SetSelection(new[] { clampedSelection }, TreeViewSelectionOptions.RevealAndFrame); + SelectAndFrameAfterRebuild(new[] { clampedSelection }); needsTreeViewFocus = true; } } @@ -1051,7 +1348,12 @@ void PerformReload() int currentSize = arrayProperty.arraySize; sortedIndices = SortedIndexMap.Build(arrayProperty, sortAscending); lastKnownKeysHash = GetKeysContentHash(arrayProperty); - TryRefreshDuplicateIndicesInto(dictionaryProperty, duplicateEntryIndices); + TryRefreshDuplicateAndNullKeyIndicesInto(dictionaryProperty, duplicateEntryIndices, nullKeyEntryIndices); + // Classification only sets flags (which cells are dynamic); it never measures, so it + // is safe here in the deferred (container-less) path. It must run before Reload so + // InitializeLazyHeights allocates per-row tracking for a dictionary that became + // dynamic on this reload (e.g. its first entry was just added). The measurement that + // depends on these flags is deferred to the OnGUI Layout pass (GetExpandedPropertyHeight). if (needsHeightClassification && currentSize > 0) ClassifyRowHeights(); treeView.Reload(); @@ -1079,8 +1381,16 @@ void RevealSelectionAfterSort(int[] selectedArrayIndices) if (sortedIndices.ContainsArrayIndex(arrayIdx)) newSelection.Add(sortedIndices.ToDisplayIndex(arrayIdx)); } - if (newSelection.Count > 0) - treeView.SetSelection(newSelection, TreeViewSelectionOptions.RevealAndFrame); + SelectAndFrameAfterRebuild(newSelection); + } + + void SelectAndFrameAfterRebuild(IList displayIndices) + { + if (displayIndices.Count == 0) + return; + + treeView.SetSelection(displayIndices); + pendingFrameDisplayIndex = displayIndices[displayIndices.Count - 1]; } void ResetToDefaults() @@ -1089,10 +1399,37 @@ void ResetToDefaults() sortAscending = true; header.ResetToDefaultFraction(attributeKeyFraction); + // Cache was just cleared, so the active layout reverts to the attribute default. + layout = attributeLayout; sortedIndices = SortedIndexMap.Build(arrayProperty, sortAscending); lastKnownKeysHash = GetKeysContentHash(arrayProperty); + ClassifyRowHeights(); treeView.Reload(); + RepaintForHeightChange(); + } + + // Single entry point for every layout change from the context menu. Persists the + // user's choice (layoutSetByUser) so it wins over the attribute default, then + // reclassifies row heights and reloads the tree. + void SetLayout(DictionaryLayout newLayout) + { + if (layout == newLayout) + return; + layout = newLayout; + UpdateCachedState(stateCacheKey, state => + { + state.layout = newLayout; + state.layoutSetByUser = true; + }); + ApplyLayoutModeChange(); + } + + void ApplyLayoutModeChange() + { + ClassifyRowHeights(); + treeView.Reload(); + RepaintForHeightChange(); } static int[] MapSelectionToArrayIndices(IList displayIndices, SortedIndexMap sortedIndices) @@ -1114,6 +1451,8 @@ public class DictionaryHeader readonly Hash128 m_StateCacheKey; readonly GUIContent m_KeyLabel; readonly GUIContent m_ValueLabel; + // One-column mode stacks key over value, so the single header spans both. + readonly GUIContent m_OneColumnLabel; public float height => EditorGUIUtility.singleLineHeight + 2f; public bool HasCachedState => DictionaryDrawer.HasCachedState(m_StateCacheKey); @@ -1128,6 +1467,7 @@ public DictionaryHeader(string keyLabel, string valueLabel, float initialFractio { m_KeyLabel = new GUIContent(keyLabel); m_ValueLabel = new GUIContent(valueLabel); + m_OneColumnLabel = new GUIContent(Texts.GetOneColumnHeaderLabel(keyLabel, valueLabel)); m_Column1Fraction = initialFraction; m_ResizeHandleControlID = GUIUtility.GetPermanentControlID(); m_SortToggleControlID = GUIUtility.GetPermanentControlID(); @@ -1142,6 +1482,14 @@ public void OnGUI(Rect rect, DrawerInstanceIMGUI instance) instance.treeView.SetFocus(); } + if (instance.oneColumnMode) + { + DrawOneColumnHeader(rect, instance); + HandleContextMenu(rect, instance); + HandleSortToggle(rect, instance); + return; + } + // Use the effective dictionary width (floored at k_MinDictionaryPixelWidth) // so col1Width can never collapse to zero/negative. The header overflows the // inspector to the right when rect.width is below the floor, matching the @@ -1159,7 +1507,7 @@ public void OnGUI(Rect rect, DrawerInstanceIMGUI instance) GUI.Label(label0Rect, m_KeyLabel, Styles.columnLabelClipped); var arrowIcon = instance.sortAscending ? Styles.sortAscIcon : Styles.sortDescIcon; - if (arrowIcon != null) + if (arrowIcon != null && !DictionaryDrawer.ShowSerializedOrder) { var arrowRect = new Rect(arrowX, label0Rect.y + (label0Rect.height - Styles.k_SortArrowSize) * 0.5f, Styles.k_SortArrowSize, Styles.k_SortArrowSize); GUI.DrawTexture(arrowRect, arrowIcon); @@ -1184,6 +1532,21 @@ public void OnGUI(Rect rect, DrawerInstanceIMGUI instance) HandleSortToggle(col0ButtonRect, instance); } + void DrawOneColumnHeader(Rect rect, DrawerInstanceIMGUI instance) + { + float keyLabelInset = Styles.k_KeyLeftMargin; + float arrowX = rect.xMax - Styles.k_SortArrowSize - 5f; + var label0Rect = new Rect(rect.x + keyLabelInset, rect.y, arrowX - (rect.x + keyLabelInset) - 2f, rect.height); + GUI.Label(label0Rect, m_OneColumnLabel, Styles.columnLabelClipped); + + var arrowIcon = instance.sortAscending ? Styles.sortAscIcon : Styles.sortDescIcon; + if (arrowIcon != null && !DictionaryDrawer.ShowSerializedOrder) + { + var arrowRect = new Rect(arrowX, rect.y + (rect.height - Styles.k_SortArrowSize) * 0.5f, Styles.k_SortArrowSize, Styles.k_SortArrowSize); + GUI.DrawTexture(arrowRect, arrowIcon); + } + } + public void GetColumnRects(Rect rowRect, out Rect col0Rect, out Rect col1Rect) { GetColumnPixelWidths(m_Column1Fraction, rowRect.width, out var col0Width, out var col1Width); @@ -1212,6 +1575,11 @@ void HandleResize(Rect headerRect, float totalWidth, Rect handleRect) // floor when the new totalWidth would force a column under it. float newCol0Fraction = (evt.mousePosition.x - headerRect.x) / totalWidth; m_Column1Fraction = ClampDraggedKeyColumnFraction(newCol0Fraction, totalWidth); + var state = GetCachedState(m_StateCacheKey); + if (state != null) + state.keyColumnFractionSetByUser = m_Column1Fraction; + else + UpdateCachedState(m_StateCacheKey, cached => cached.keyColumnFractionSetByUser = m_Column1Fraction); evt.Use(); } break; @@ -1228,6 +1596,9 @@ void HandleResize(Rect headerRect, float totalWidth, Rect handleRect) void HandleSortToggle(Rect sortRect, DrawerInstanceIMGUI instance) { + if (DictionaryDrawer.ShowSerializedOrder) + return; + var evt = Event.current; switch (evt.GetTypeForControl(m_SortToggleControlID)) { @@ -1269,6 +1640,11 @@ public void PersistSortOrder(bool sortAscending) UpdateCachedState(m_StateCacheKey, cached => cached.sortAscending = sortAscending); } + static void AddLayoutItem(GenericMenu menu, string label, DictionaryLayout layout, DrawerInstanceIMGUI instance) + { + menu.AddItem(new GUIContent(label), instance.layout == layout, () => instance.SetLayout(layout)); + } + static void HandleContextMenu(Rect headerRect, DrawerInstanceIMGUI instance) { var evt = Event.current; @@ -1276,6 +1652,17 @@ static void HandleContextMenu(Rect headerRect, DrawerInstanceIMGUI instance) { var menu = new GenericMenu(); + // The three layouts form a radio group (the active one is checked), + // followed by a separator and the "Reset to Defaults" action. + AddLayoutItem(menu, Texts.TwoColumnsLayoutLabel, DictionaryLayout.TwoColumns, instance); + AddLayoutItem(menu, Texts.OneColumnWithValueFoldoutLayoutLabel, DictionaryLayout.OneColumnWithValueFoldout, instance); + AddLayoutItem(menu, Texts.OneColumnWithValueVisibleLayoutLabel, DictionaryLayout.OneColumnWithValueVisible, instance); + menu.AddSeparator(string.Empty); + + menu.AddItem(new GUIContent(Texts.ShowSerializedOrderLabel), DictionaryDrawer.ShowSerializedOrder, + () => DictionaryDrawer.SetShowSerializedOrder(!DictionaryDrawer.ShowSerializedOrder)); + menu.AddSeparator(string.Empty); + if (instance.header.HasCachedState) { menu.AddItem(new GUIContent(Texts.ResetToDefaultsLabel), false, () => @@ -1298,11 +1685,13 @@ public class DictionaryTreeView : TreeView { readonly DrawerInstanceIMGUI m_Instance; - // Per-row measured heights; -1 = unmeasured. Allocated only when variableRowHeight is true. - // Populated lazily by RowGUI; unmeasured rows use m_EstimatedRowHeight so totalHeight is - // approximately correct before all rows have painted. Stale entries are caught by - // RecordDynamicRowHeight setting needsHeightRefresh on the owning instance when a measured - // row drifts. + // Per-row measured heights; -1 = unmeasured. Allocated only when variableRowHeight is + // true. Filled by MeasureAllRowHeights on the Layout pass after a reload so totalHeight + // is exact (a partially-measured total is unstable and fights the scroll view's clamp — + // see GetExpandedPropertyHeight). RowGUI keeps entries current via RecordRowHeight + // for runtime height changes (e.g. expanding an inline nested dictionary). Any row left + // unmeasured falls back to m_EstimatedRowHeight (the tallest measured row) in + // GetCustomRowHeight. float[] m_LazyHeights; float m_EstimatedRowHeight; @@ -1319,36 +1708,73 @@ public void ComputeFixedInlineRowHeight() var element = m_Instance.arrayProperty.GetArrayElementAtIndex(0); GetKeyAndValueProperties(element, out var keyProp, out var valueProp); - float keyH = GetPropertyFieldHeight(keyProp, m_Instance.keyType, m_Instance.keyHasCustomDrawer); - float valH = GetPropertyFieldHeight(valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer); + // Static-inline rows are uniform, so row 0 represents them all. + // MeasureRowContentHeight accounts for the active layout — stacked key-over-value + // in one-column mode, max(key, value) in two-column — so the fixed height is + // correct for both. + rowHeight = MeasureRowContentHeight(keyProp, valueProp) + Styles.k_RowVerticalPadding * 2; EditorGUIUtility.wideMode = prevWideMode; - - rowHeight = Mathf.Max(keyH, valH) + Styles.k_RowVerticalPadding * 2; } - void InitializeLazyHeights() + // Measures every variable-height row into m_LazyHeights so totalHeight is exact and + // stable. We measure all rows (not just a sample) because the TreeView scroll view + // re-clamps scrollPos to (totalHeight - viewport) every frame: a totalHeight that + // keeps shrinking as rows are measured one-at-a-time would repeatedly clamp the scroll + // and walk it away from a framed row. Measures cell heights via GetPropertyHeight, so + // it must run inside an OnGUI pass — called from GetExpandedPropertyHeight on the + // Layout pass, never from the deferred reload. + public void MeasureAllRowHeights() { int count = m_Instance.displayedItemCount; - if (!m_Instance.variableRowHeight || count == 0) - { - m_LazyHeights = null; + if (count == 0) return; - } + + if (m_LazyHeights == null || m_LazyHeights.Length != count) + m_LazyHeights = new float[count]; bool prevWideMode = EditorGUIUtility.wideMode; EditorGUIUtility.wideMode = true; - var element = m_Instance.arrayProperty.GetArrayElementAtIndex(0); - GetKeyAndValueProperties(element, out var keyProp, out var valueProp); - - float keyH = GetPropertyFieldHeight(keyProp, m_Instance.keyType, m_Instance.keyHasCustomDrawer); - float valH = GetPropertyFieldHeight(valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer); + float maxH = 0f; + for (int displayIndex = 0; displayIndex < count; displayIndex++) + { + float h; + if (TryGetEntryProperties(displayIndex, out var keyProp, out var valueProp, out _)) + { + h = MeasureRowContentHeight(keyProp, valueProp) + Styles.k_RowVerticalPadding * 2; + } + else + { + h = rowHeight; + } + m_LazyHeights[displayIndex] = h; + if (h > maxH) + maxH = h; + } EditorGUIUtility.wideMode = prevWideMode; - m_EstimatedRowHeight = Mathf.Max(keyH, valH) + Styles.k_RowVerticalPadding * 2; + m_EstimatedRowHeight = maxH; + } + + // Structure only — never measures. BuildRoot/Reload run from the deferred reload + // (delayCall / update) with no IMGUIContainer on the stack, so per-row heights (which + // for a custom-drawer cell call into that drawer's GetPropertyHeight) are measured + // later on the OnGUI Layout pass via MeasureAllRowHeights. Until then unmeasured rows + // fall back to the default rowHeight in GetCustomRowHeight. + void InitializeLazyHeights() + { + m_Instance.needsHeightMeasure = true; + int count = m_Instance.displayedItemCount; + if (!m_Instance.variableRowHeight || count == 0) + { + m_LazyHeights = null; + return; + } + + m_EstimatedRowHeight = 0f; m_LazyHeights = new float[count]; for (int i = 0; i < count; i++) m_LazyHeights[i] = -1f; @@ -1404,7 +1830,7 @@ static float GetPropertyFieldHeight(SerializedProperty prop, Type type, bool has if (prop == null) return EditorGUIUtility.singleLineHeight; - if (prop.propertyType == SerializedPropertyType.Generic && !hasCustomDrawer) + if (ShouldInlineChildren(prop, hasCustomDrawer)) return GetInlineChildrenHeight(prop); return EditorGUI.GetPropertyHeight(prop, GUIContent.none, true); @@ -1439,6 +1865,8 @@ static float GetInlineChildrenHeight(SerializedProperty parent) protected override void BeforeRowsGUI() { base.BeforeRowsGUI(); + if (m_Instance.oneColumnMode) + return; GetColumnPixelWidths(m_Instance.header.column1Fraction, treeViewRect.width, out var col0Width, out _); Rect lineRect = new Rect(col0Width, 0, k_VerticalSplitterWidth, totalHeight); EditorGUI.DrawRect(lineRect, SharedStyles.k_RowsSplitColor); @@ -1463,17 +1891,28 @@ protected override void RowGUI(RowGUIArgs args) // use the full treeview rect width. This is needed when the dictionary drawer is // overflowing in narrow Inspectors. Rect fullRowRect = new Rect(args.rowRect.x, args.rowRect.y, treeViewRect.width, args.rowRect.height); - m_Instance.header.GetColumnRects(fullRowRect, out var keyRect, out var valueRect); - if (showingVerticalScrollBar) + if (m_Instance.oneColumnMode) { - float visibleRightEdge = fullRowRect.xMax - Styles.k_VerticalScrollbarWidth; - valueRect.xMax = Mathf.Max(valueRect.xMin, visibleRightEdge); + Rect oneColumnRect = fullRowRect; + if (showingVerticalScrollBar) + oneColumnRect.width = Mathf.Max(0f, oneColumnRect.width - Styles.k_VerticalScrollbarWidth); + DrawOneColumnRow(oneColumnRect, keyProp, valueProp, arrayIndex); } + else + { + m_Instance.header.GetColumnRects(fullRowRect, out var keyRect, out var valueRect); - DrawKeyCell(keyRect, keyProp, arrayIndex); - DrawValueCell(valueRect, valueProp); - RecordDynamicRowHeight(displayIndex, args.rowRect.height, keyProp, valueProp); + if (showingVerticalScrollBar) + { + float visibleRightEdge = fullRowRect.xMax - Styles.k_VerticalScrollbarWidth; + valueRect.xMax = Mathf.Max(valueRect.xMin, visibleRightEdge); + } + + DrawKeyCell(keyRect, keyProp, arrayIndex); + DrawValueCell(valueRect, valueProp); + } + RecordRowHeight(displayIndex, args.rowRect.height, keyProp, valueProp); EditorGUIUtility.labelWidth = prevLabelWidth; EditorGUIUtility.wideMode = prevWideMode; @@ -1535,21 +1974,35 @@ bool TryGetEntryProperties(int displayIndex, out SerializedProperty keyProp, out return true; } + // Selects the entry a drop lands on (before the key ObjectField Uses() the DragPerform), so the deferred re-sort keeps it selected and frames it. Mirrors DictionaryView.OnRowDragPerform. + void SelectEntryOnKeyDrop(Rect keyCellRect, int arrayIndex) + { + if (Event.current.type != EventType.DragPerform || !keyCellRect.Contains(Event.current.mousePosition)) + return; + if (!m_Instance.sortedIndices.ContainsArrayIndex(arrayIndex)) + return; + SetSelection(new[] { m_Instance.sortedIndices.ToDisplayIndex(arrayIndex) }); + + // Grab view focus (a drag from another view left focus there) so the relocated row shows the active blue outline, not grey; keyboardControl is set by the reload's needsTreeViewFocus path. + GUIView.current?.Focus(); + } + void DrawKeyCell(Rect keyRect, SerializedProperty keyProp, int arrayIndex) { keyRect.yMin += Styles.k_RowVerticalPadding; keyRect.yMax -= Styles.k_RowVerticalPadding; - var markerKind = DictionaryKeyUtility.GetMarkerKind(arrayIndex, m_Instance.duplicateEntryIndices); + var markerKind = DictionaryKeyUtility.GetMarkerKind(arrayIndex, m_Instance.duplicateEntryIndices, m_Instance.nullKeyEntryIndices); if (markerKind != DictionaryKeyUtility.KeyMarkerKind.None) - DrawDuplicateKeyIcon(keyRect); + DrawKeyWarningIcon(keyRect, DictionaryKeyUtility.GetMarkerTooltip(markerKind)); float keyLeft = Styles.k_KeyLeftMargin; float minFieldWidth = GetCellMinFieldWidth(keyProp, m_Instance.keyHasCustomDrawer); var keyFieldRect = BuildCellFieldRect(keyRect, keyLeft + Styles.k_CellHorizontalPadding, Styles.k_CellHorizontalPadding, minFieldWidth); EditorGUIUtility.labelWidth = ComputeCellLabelWidth(keyFieldRect.width); - // Key edits no longer flip needsReload / needsDuplicateRefresh from here. + // Key edits no longer flip needsReload / needsMarkerRefresh from here. // The TrackPropertyValue listener registered on the IMGUIContainer in // GetOrCreate handles both same-inspector and cross-inspector // updates uniformly, so this draw site only renders the field. + SelectEntryOnKeyDrop(keyRect, arrayIndex); DrawClippedPropertyField(keyRect, keyFieldRect, keyProp, m_Instance.keyType, m_Instance.keyHasCustomDrawer); } @@ -1560,7 +2013,90 @@ void DrawValueCell(Rect cellRect, SerializedProperty valueProp) float minFieldWidth = GetCellMinFieldWidth(valueProp, m_Instance.valueHasCustomDrawer); var fieldRect = BuildCellFieldRect(cellRect, Styles.k_ValueLeftPadding, Styles.k_CellHorizontalPadding, minFieldWidth); EditorGUIUtility.labelWidth = ComputeCellLabelWidth(fieldRect.width); - DrawPropertyField(fieldRect, valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer); + DrawPropertyField(fieldRect, valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer, m_Instance.valueCollectionLabel); + } + + void DrawOneColumnRow(Rect rowRect, SerializedProperty keyProp, SerializedProperty valueProp, int arrayIndex) + { + float spacing = EditorGUIUtility.standardVerticalSpacing; + float contentLeft = Styles.k_KeyLeftMargin + Styles.k_CellHorizontalPadding; + float y = rowRect.y + Styles.k_RowVerticalPadding; + + float keyH = GetPropertyFieldHeight(keyProp, m_Instance.keyType, m_Instance.keyHasCustomDrawer); + var keyCellRect = new Rect(rowRect.x, y, rowRect.width, keyH); + var markerKind = DictionaryKeyUtility.GetMarkerKind(arrayIndex, m_Instance.duplicateEntryIndices, m_Instance.nullKeyEntryIndices); + if (markerKind != DictionaryKeyUtility.KeyMarkerKind.None) + DrawKeyWarningIcon(keyCellRect, DictionaryKeyUtility.GetMarkerTooltip(markerKind)); + float keyMinFieldWidth = GetCellMinFieldWidth(keyProp, m_Instance.keyHasCustomDrawer); + var keyFieldRect = BuildCellFieldRect(keyCellRect, contentLeft, Styles.k_CellHorizontalPadding, keyMinFieldWidth); + EditorGUIUtility.labelWidth = ComputeCellLabelWidth(keyFieldRect.width); + SelectEntryOnKeyDrop(keyCellRect, arrayIndex); + DrawPropertyField(keyFieldRect, keyProp, m_Instance.keyType, m_Instance.keyHasCustomDrawer); + y += keyH + spacing; + + if (!m_Instance.useValueFoldouts) + y += Styles.k_StaticValueHeaderTopMargin; + + float headerLine = EditorGUIUtility.singleLineHeight; + bool showValue = !m_Instance.useValueFoldouts || (valueProp != null && valueProp.isExpanded); + float labelX = rowRect.x + contentLeft; + float labelWidth = Mathf.Max(0f, rowRect.xMax - Styles.k_CellHorizontalPadding - labelX); + var labelRect = EditorGUI.IndentedRect(new Rect(labelX, y, labelWidth, headerLine)); + if (m_Instance.useValueFoldouts) + { + bool expanded = valueProp != null && valueProp.isExpanded; + const float foldoutArrowAdjustment = 4f; // Move foldout arrow out to align with the key warning icon in the gutter. Still clickable in entire label width + Rect foldoutRect = new Rect(labelRect.x - foldoutArrowAdjustment, labelRect.y, labelRect.width + foldoutArrowAdjustment, labelRect.height); + bool newExpanded = EditorGUI.Foldout(foldoutRect, expanded, GUIContent.none, true); + + var e = Event.current; + if (e.type == EventType.MouseDown && e.button == 0 && foldoutRect.Contains(e.mousePosition)) + { + newExpanded = !expanded; + e.Use(); + } + + if (newExpanded != expanded && valueProp != null) + valueProp.isExpanded = newExpanded; + showValue = newExpanded; + } + GUI.Label(labelRect, m_Instance.valueLabelContent, EditorStyles.boldLabel); + y += headerLine; + + if (showValue) + { + y += spacing; + float valH = GetPropertyFieldHeight(valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer); + var valueCellRect = new Rect(rowRect.x, y, rowRect.width, valH); + float valueMinFieldWidth = GetCellMinFieldWidth(valueProp, m_Instance.valueHasCustomDrawer); + float valueIndent = m_Instance.useValueFoldouts ? Styles.k_OneColumnValueIndent : 0f; + var valueFieldRect = BuildCellFieldRect(valueCellRect, contentLeft + valueIndent, Styles.k_CellHorizontalPadding, valueMinFieldWidth); + EditorGUIUtility.labelWidth = ComputeCellLabelWidth(valueFieldRect.width); + DrawPropertyField(valueFieldRect, valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer, m_Instance.valueCollectionLabel); + } + } + + float MeasureRowContentHeight(SerializedProperty keyProp, SerializedProperty valueProp) + { + float keyH = GetPropertyFieldHeight(keyProp, m_Instance.keyType, m_Instance.keyHasCustomDrawer); + + if (m_Instance.oneColumnMode) + { + // GetPropertyFieldHeight recurses through every visible child field of the value, + // which is wasted work when the value is hidden behind a collapsed foldout — defer + // it until we know the value is shown (mirrors the deferral in DrawOneColumnRow). + float spacing = EditorGUIUtility.standardVerticalSpacing; + float total = keyH + spacing + EditorGUIUtility.singleLineHeight; + if (!m_Instance.useValueFoldouts) + total += Styles.k_StaticValueHeaderTopMargin; + bool showValue = !m_Instance.useValueFoldouts || (valueProp != null && valueProp.isExpanded); + if (showValue) + total += spacing + GetPropertyFieldHeight(valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer); + return total; + } + + float valH = GetPropertyFieldHeight(valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer); + return Mathf.Max(keyH, valH); } // BuildCellFieldRect floors the field width at a non-zero minimum, so on a narrow @@ -1591,20 +2127,20 @@ static Rect BuildCellFieldRect(Rect cellRect, float leftPadding, float rightPadd // Returns the smallest acceptable field-rect width for a cell. The dictionary drawer // dispatches in DrawPropertyField: - // - Generic + no custom drawer → DrawInlineChildren expands child properties and - // each child's PropertyField reserves EditorGUIUtility.labelWidth for its own - // label (e.g. "Color", "Target", "Vector"). The cell therefore needs room for - // both a min label and a min control, plus the kPrefixPaddingRight gap that - // PrefixLabel inserts between them. - // - Anything else → EditorGUI.PropertyField is called with - // GUIContent.none, so PrefixLabel's "no label" branch hands the entire rect to - // the control and labelWidth is irrelevant. Only a min control width is needed. + // - inline children (ShouldInlineChildren) → DrawInlineChildren expands child + // properties and each child's PropertyField reserves EditorGUIUtility.labelWidth + // for its own label (e.g. "Color", "Target", "Vector"). The cell therefore needs + // room for both a min label and a min control, plus the kPrefixPaddingRight gap + // that PrefixLabel inserts between them. + // - anything else (custom drawer, array/list, leaf) → EditorGUI.PropertyField is + // called with GUIContent.none, so PrefixLabel's "no label" branch hands the entire + // rect to the control and labelWidth is irrelevant. Only a min control width is needed. // IMGUI labels default to TextClipping.Overflow, so simply setting labelWidth = 0 // does not hide the label — it keeps overflowing onto the control area. Reserving // the right amount of space up front is the only way to keep both visible. static float GetCellMinFieldWidth(SerializedProperty prop, bool hasCustomDrawer) { - bool willInlineChildren = prop != null && prop.propertyType == SerializedPropertyType.Generic && !hasCustomDrawer; + bool willInlineChildren = ShouldInlineChildren(prop, hasCustomDrawer); return willInlineChildren ? Styles.k_CellLabelMinWidth + EditorGUI.kPrefixPaddingRight + Styles.k_CellControlMinWidth : Styles.k_CellControlMinWidth; @@ -1626,20 +2162,34 @@ static float ComputeCellLabelWidth(float fieldRectWidth) return Mathf.Clamp(desired, Styles.k_CellLabelMinWidth, maxLabel); } - void RecordDynamicRowHeight(int displayIndex, float currentRowHeight, SerializedProperty keyProp, SerializedProperty valueProp) + void RecordRowHeight(int displayIndex, float currentRowHeight, SerializedProperty keyProp, SerializedProperty valueProp) { - if (!m_Instance.dynamicRowHeight) + if (!m_Instance.dynamicRowHeight && !m_Instance.hasStaticInlineHeight) return; - float keyH = GetPropertyFieldHeight(keyProp, m_Instance.keyType, m_Instance.keyHasCustomDrawer); - float valH = GetPropertyFieldHeight(valueProp, m_Instance.valueType, m_Instance.valueHasCustomDrawer); - float measuredH = Mathf.Max(keyH, valH) + Styles.k_RowVerticalPadding * 2; + float measuredH = MeasureRowContentHeight(keyProp, valueProp) + Styles.k_RowVerticalPadding * 2; - if (m_LazyHeights != null && displayIndex >= 0 && displayIndex < m_LazyHeights.Length) - m_LazyHeights[displayIndex] = measuredH; + if (m_Instance.dynamicRowHeight) + { + if (m_LazyHeights != null && displayIndex >= 0 && displayIndex < m_LazyHeights.Length) + m_LazyHeights[displayIndex] = measuredH; + } + else + { + rowHeight = measuredH; + } if (!m_Instance.needsHeightRefresh && Mathf.Abs(currentRowHeight - measuredH) > 0.5f) + { m_Instance.needsHeightRefresh = true; + // The row was drawn at the wrong height (it used the estimate, or a child's + // height just changed — e.g. a nested dictionary expanded, or a row scrolled + // into view for the first time). The fix (RefreshCustomRowHeights) is applied + // on the next Layout pass in GetExpandedPropertyHeight, so we must request a + // repaint to make that pass happen; otherwise the rows only reflow when some + // unrelated event (mouse move) repaints the inspector. + HandleUtility.Repaint(); + } } void DrawRowSelectionOutlineIfSelected(RowGUIArgs args) @@ -1661,18 +2211,34 @@ static void DrawSelectionOutline(Rect rect, bool focused) EditorGUI.DrawRect(new Rect(rect.xMax - w, rect.y + w, w, rect.height - 2 * w), color); } - static void DrawPropertyField(Rect rect, SerializedProperty prop, Type type, bool hasCustomDrawer) + // A cell expands its children inline (DrawInlineChildren) only for a plain serializable + // compound: Generic, no custom drawer, and NOT an array/list. Arrays and lists are + // Generic too, but must go through EditorGUI.PropertyField so they get their real + // drawer (foldout + size + element list / reorderable list). Iterating their raw + // children instead would draw the hidden size field and the elements flat, with no + // foldout and no way to resize — which is how array/list cells were rendering wrong. + static bool ShouldInlineChildren(SerializedProperty prop, bool hasCustomDrawer) + { + return prop != null + && prop.propertyType == SerializedPropertyType.Generic + && !hasCustomDrawer + && !prop.isArray; + } + + static void DrawPropertyField(Rect rect, SerializedProperty prop, Type type, bool hasCustomDrawer, GUIContent label = null) { if (prop == null) return; - if (prop.propertyType == SerializedPropertyType.Generic && !hasCustomDrawer) + if (ShouldInlineChildren(prop, hasCustomDrawer)) { DrawInlineChildren(rect, prop); } else { - EditorGUI.PropertyField(rect, prop, GUIContent.none, true); + // label is non-null only for collection value cells ("Array"/"List"); everything else + // draws label-less so the value fills the cell (a nested dictionary supplies its own title). + EditorGUI.PropertyField(rect, prop, label ?? GUIContent.none, true); } } @@ -1700,23 +2266,23 @@ static void DrawInlineChildren(Rect rect, SerializedProperty parent) } // Draws a fixed-size warning icon at the top of the key column gutter for - // rows whose key is a duplicate. Position and size mirror UITK - // .unity-dictionary-view__duplicate-key-icon so both backends look identical. The + // rows excluded from the runtime dictionary (duplicate or null key). Position and + // size mirror UITK .unity-dictionary-view__duplicate-key-icon so both backends look identical. The // GUI.Label call paints nothing on its own (GUIStyle.none + empty text) but // registers the hit area for the hover tooltip. - static void DrawDuplicateKeyIcon(Rect cellRect) + static void DrawKeyWarningIcon(Rect cellRect, string tooltip) { var icon = EditorGUIUtility.GetHelpIcon(MessageType.Warning); if (icon == null) return; var iconRect = new Rect( - cellRect.x + Styles.k_DuplicateKeyIconLeftMargin, - cellRect.y + Styles.k_DuplicateKeyIconTopOffset, - Styles.k_DuplicateKeyIconSize, - Styles.k_DuplicateKeyIconSize); + cellRect.x + Styles.k_KeyWarningIconLeftMargin, + cellRect.y + Styles.k_KeyWarningIconTopOffset, + Styles.k_KeyWarningIconSize, + Styles.k_KeyWarningIconSize); GUI.DrawTexture(iconRect, icon, ScaleMode.ScaleToFit); - GUI.Label(iconRect, EditorGUIUtility.TempContent(string.Empty, Texts.DuplicateMarkerTooltip), GUIStyle.none); + GUI.Label(iconRect, EditorGUIUtility.TempContent(string.Empty, tooltip), GUIStyle.none); } protected override bool CanMultiSelect(TreeViewItem item) @@ -1770,6 +2336,7 @@ internal static class DrawerEditorGUI // same style with UpperLeft so the content stays anchored to the top, leaving the // bottom-right corner clear for the button overlay. Lazy-init: EditorStyles.helpBox // is not safe to access during static class reload. + [NoAutoStaticsCleanup] // lazy GUIStyle cache guarded by == null; re-created on first access, safe to persist static GUIStyle s_HelpBoxUpperLeft; static GUIStyle helpBoxUpperLeft { diff --git a/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerShared.cs b/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerShared.cs index 5018662f5c..a3b60489a7 100644 --- a/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerShared.cs +++ b/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerShared.cs @@ -7,7 +7,10 @@ using System.Reflection; using System.Text.RegularExpressions; using UnityEngine; +using UnityEngine.Assemblies; using UnityEngine.UIElements; +using Unity.Scripting.LifecycleManagement; +using UnityEngine.Pool; namespace UnityEditor { @@ -65,22 +68,27 @@ internal partial class DictionaryDrawer { internal static class SharedStyles { + [NoAutoStaticsCleanup] // skinned color constant; skin is session-fixed, safe to persist internal static readonly EditorGUIUtility.SkinnedColor k_RowsSplitColor = new EditorGUIUtility.SkinnedColor( new Color(137f / 255f, 137f / 255f, 137f / 255f, 0.3f), new Color(36f / 255f, 36f / 255f, 36f / 255f, 0.5f)); + [NoAutoStaticsCleanup] // skinned color constant; skin is session-fixed, safe to persist internal static readonly EditorGUIUtility.SkinnedColor k_ResizerColor = new EditorGUIUtility.SkinnedColor( new Color(137f / 255f, 137f / 255f, 137f / 255f, 0.3f), new Color(36f / 255f, 36f / 255f, 36f / 255f, 0.8f)); + [NoAutoStaticsCleanup] // skinned color constant; skin is session-fixed, safe to persist internal static readonly EditorGUIUtility.SkinnedColor k_AlternatingRowColor = new EditorGUIUtility.SkinnedColor( new Color(0f, 0f, 0f, 0.07f), new Color(0f, 0f, 0f, 0.04f)); + [NoAutoStaticsCleanup] // skinned color constant; skin is session-fixed, safe to persist internal static readonly EditorGUIUtility.SkinnedColor k_SelectionOutlineColor = new EditorGUIUtility.SkinnedColor( new Color(58f / 255f, 114f / 255f, 176f / 255f), new Color(44f / 255f, 93f / 255f, 135f / 255f)); + [NoAutoStaticsCleanup] // skinned color constant; skin is session-fixed, safe to persist internal static readonly EditorGUIUtility.SkinnedColor k_SelectionOutlineColorInactive = new EditorGUIUtility.SkinnedColor( new Color(174f / 255f, 174f / 255f, 174f / 255f), new Color(77f / 255f, 77f / 255f, 77f / 255f)); @@ -89,20 +97,41 @@ internal static class SharedStyles internal static class Texts { internal static readonly string EmptyDictionaryLabel = L10n.Tr("Dictionary is empty"); + // Foldout title for a nested dictionary, replacing its entry "value" field's displayName ("Value"). + internal static readonly string NestedDictionaryLabel = L10n.Tr("Dictionary"); + // Foldout titles for a dictionary value that is itself a collection, replacing the "Value" displayName. + internal static readonly string NestedArrayLabel = L10n.Tr("Array"); + internal static readonly string NestedListLabel = L10n.Tr("List"); internal static readonly string ResetToDefaultsLabel = L10n.Tr("Reset to Defaults"); internal static readonly string MultiEditUnsupportedMessage = L10n.Tr("Dictionary: Multi-object editing is not supported."); // Entries are sorted by key, so a given row may correspond to different entries across targets, so edits could affect unrelated entries - internal static readonly string DuplicateMarkerTooltip = L10n.Tr("An element with the same key already exists, so this element will not be part of the Dictionary"); + internal static readonly string DuplicateMarkerTooltip = L10n.Tr("An element with the same key already exists, so this element is excluded from the runtime dictionary."); + internal static readonly string NullKeyMarkerTooltip = L10n.Tr("The key is null. A dictionary only stores entries with a valid (non-null) key, so this element is excluded from the runtime dictionary."); internal static readonly string SingleItemCountLabel = L10n.Tr("1 item"); internal static readonly string MultipleItemsCountFormat = L10n.Tr("{0} items"); - internal static readonly string DuplicatesFormat = L10n.Tr("{0} ignored"); + internal static readonly string IgnoredFormat = L10n.Tr("{0} ignored"); internal static readonly string DuplicatesHelpBoxSingle = L10n.Tr("1 duplicate key ignored. Ensure all keys are unique."); internal static readonly string DuplicatesHelpBoxFormat = L10n.Tr("{0} duplicate keys ignored. Ensure all keys are unique."); - internal static readonly string SelectFirstDuplicateButtonLabel = L10n.Tr("Select Duplicate"); - - internal static readonly string DefaultKeyLabel = "Key"; - internal static readonly string DefaultValueLabel = "Value"; + internal static readonly string NullKeysHelpBoxSingle = L10n.Tr("1 null key ignored. A dictionary key can't be null."); + internal static readonly string NullKeysHelpBoxFormat = L10n.Tr("{0} null keys ignored. A dictionary key can't be null."); + internal static readonly string MixedIgnoredHelpBoxFormat = L10n.Tr("{0} entries ignored. Ensure all keys are unique and non-null."); + internal static readonly string SelectFirstIgnoredButtonLabel = L10n.Tr("Select"); + // Header context-menu labels for the three DictionaryLayout values, shown as a + // radio group (the active layout is checked). + internal static readonly string TwoColumnsLayoutLabel = L10n.Tr("Two Columns"); + internal static readonly string OneColumnWithValueFoldoutLayoutLabel = L10n.Tr("One Column With Value Foldout"); + internal static readonly string OneColumnWithValueVisibleLayoutLabel = L10n.Tr("One Column With Value Visible"); + internal static readonly string ShowSerializedOrderLabel = L10n.Tr("Show Serialized Order (Global)"); + internal static readonly string ShowingSerializedOrderInfoLabel = L10n.Tr("Showing serialized order"); + + internal static readonly string DefaultKeyLabel = L10n.Tr("Key"); + internal static readonly string DefaultValueLabel = L10n.Tr("Value"); + // One-column mode stacks the key and value, so the single header column spans both. + internal static readonly string OneColumnHeaderFormat = L10n.Tr("{0} & {1}"); internal static readonly string ExpectedCurrentContainerMessage = "Expected a current IMGUIContainer, please report a bug with repro steps"; + internal static string GetOneColumnHeaderLabel(string keyLabel, string valueLabel) => + string.Format(OneColumnHeaderFormat, keyLabel, valueLabel); + internal static string GetItemCountText(int count) { return count == 1 @@ -113,16 +142,27 @@ internal static string GetItemCountText(int count) // Returned text includes the leading ", " separator so callers can // unconditionally append it after the item-count text without any // separator/comma bookkeeping at the call site. - internal static string GetDuplicateCountText(int count) + internal static string GetIgnoredCountText(int ignoredCount) { - return ", " + string.Format(DuplicatesFormat, count); + return ", " + string.Format(IgnoredFormat, ignoredCount); } - internal static string GetDuplicatesHelpBoxText(int count) + internal static string GetIgnoredHelpBoxText(int duplicateCount, int nullKeyCount) { - return count == 1 + bool hasDuplicates = duplicateCount > 0; + bool hasNullKeys = nullKeyCount > 0; + + if (hasDuplicates && hasNullKeys) + return string.Format(MixedIgnoredHelpBoxFormat, duplicateCount + nullKeyCount); + + if (hasNullKeys) + return nullKeyCount == 1 + ? NullKeysHelpBoxSingle + : string.Format(NullKeysHelpBoxFormat, nullKeyCount); + + return duplicateCount == 1 ? DuplicatesHelpBoxSingle - : string.Format(DuplicatesHelpBoxFormat, count); + : string.Format(DuplicatesHelpBoxFormat, duplicateCount); } } @@ -134,13 +174,28 @@ internal class DictionaryState // GetActiveKeyColumnFraction falls back to the attribute default in that case. public float keyColumnFractionSetByUser = -1f; public bool sortAscending = true; + // Single source of truth for the column layout. TwoColumns: key | value side by + // side. OneColumnWithValueFoldout: key stacked over value, each value collapsible + // behind a "Value" foldout via SerializedProperty.isExpanded. OneColumnWithValueVisible: + // same stacking but every value renders inline with no per-row foldout. + public DictionaryLayout layout = DictionaryLayout.TwoColumns; + // Negative sentinel pattern, mirroring keyColumnFractionSetByUser: false means the + // user has never picked a layout from the context menu, so GetActiveLayout falls + // back to the attribute default. Set to true the moment the user toggles layout. + public bool layoutSetByUser = false; } // Disk-backed, persistent across editor sessions. Key: Hash128 of normalized path - // ([\d+] → []) so list siblings share state (linked resizers). Eviction: only via + // ([\d+] → []) so container siblings share state (linked views). Eviction: only via // explicit RemoveState from the "Reset to Defaults" context menu. + [NoAutoStaticsCleanup] // disk-backed state cache, intentionally persistent across sessions; safe to persist static readonly StateCache s_StateCache = new StateCache("Library/StateCache/DictionaryDrawer/"); + [NoAutoStaticsCleanup] // session-scoped change counter; value is irrelevant across reloads, only that it changes + static int s_StateVersion; + + internal static int StateVersion => s_StateVersion; + internal const float k_MinColumnPixelWidth = 40f; internal const float k_MinDictionaryPixelWidth = 2f * k_MinColumnPixelWidth; internal const float k_VerticalSplitterWidth = 1f; @@ -153,6 +208,7 @@ internal class DictionaryState // Used by tests to assert that certain changes do not trigger a re-sort. Kept on the // shared so a single counter is shared regardless of the per-property DrawerInstance. + [NoAutoStaticsCleanup] // test-only diagnostic counter, reset by tests; safe to persist internal static int s_SortCount; // True when totalWidth has hit the floor, i.e. dragging the resizer can't move the @@ -196,11 +252,29 @@ internal static float ClampDraggedKeyColumnFraction(float keyColumnFraction, flo } internal static float GetActiveKeyColumnFraction(Hash128 stateCacheKey, float attributeFraction) + => GetActiveKeyColumnFraction(s_StateCache.GetState(stateCacheKey), attributeFraction); + + // Overload for callers that already hold the cached state (e.g. a per-frame sync that reads + // several fields), so they resolve all of them from a single GetState lookup. + internal static float GetActiveKeyColumnFraction(DictionaryState state, float attributeFraction) { - var cached = s_StateCache.GetState(stateCacheKey); - if (cached == null || cached.keyColumnFractionSetByUser <= 0f) + if (state == null || state.keyColumnFractionSetByUser <= 0f) return attributeFraction; - return cached.keyColumnFractionSetByUser; + return state.keyColumnFractionSetByUser; + } + + // Layout follows the same default-vs-override rules as the key column fraction: the + // attribute supplies the default, and the cached layout only wins once the user has + // explicitly toggled it from the header context menu (layoutSetByUser). "Reset to + // Defaults" removes the cache entry, so the attribute default returns. + internal static DictionaryLayout GetActiveLayout(Hash128 stateCacheKey, DictionaryLayout attributeLayout) + => GetActiveLayout(s_StateCache.GetState(stateCacheKey), attributeLayout); + + internal static DictionaryLayout GetActiveLayout(DictionaryState state, DictionaryLayout attributeLayout) + { + if (state == null || !state.layoutSetByUser) + return attributeLayout; + return state.layout; } static DictionaryState GetOrCreateCachedState(Hash128 stateCacheKey) @@ -218,6 +292,7 @@ internal static void UpdateCachedState(Hash128 stateCacheKey, Action SessionState.GetBool(k_SerializedOrderSessionKey, false); + + internal static event Action SerializedOrderChanged; + + internal static void SetShowSerializedOrder(bool value) + { + if (ShowSerializedOrder == value) + return; + SessionState.SetBool(k_SerializedOrderSessionKey, value); + SerializedOrderChanged?.Invoke(); + DrawerInstanceIMGUI.InvalidateAllSortOrders(); } // We want a shared ui state for all dictionaries in lists/arrays, so the user do not have @@ -240,6 +331,24 @@ internal static Hash128 ComputeStateCacheKey(string propertyPath) return Hash128.Compute(normalizedPath); } + // Why siblings share state at all: every collection element at a given level intentionally + // shares ONE persisted DictionaryState — their paths all normalize to the same + // ComputeStateCacheKey. Two reasons: + // 1. Avoid an explosion of persisted StateCache objects: without it, resizing a column (or + // changing sort/layout) inside a container with 100,000+ elements could write 100,000+ + // per-element StateCache entries to disk. Collapsing the index means one shared entry per + // level regardless of element count. + // 2. Consistency, matching how [DictionaryDisplayForType] configures appearance for ALL + // dictionaries of a given closed type: every element at the same nested level should look + // and behave the same, so the user adjusts sort/layout/column-width once rather than + // re-doing it across every one of many nested dictionaries. + // Returns true when the property is a collection element (its path carries a numeric [\d+] index + // token), so it has sibling dictionaries whose live views should be linked and kept in sync. + internal static bool ShouldLinkViewStateWithSiblings(string propertyPath) + { + return s_ArrayIndexPattern.IsMatch(propertyPath); + } + static Type[] GetDictionaryGenericArguments(FieldInfo fieldInfo) { return fieldInfo.FieldType.GetGenericArguments(); @@ -247,6 +356,7 @@ static Type[] GetDictionaryGenericArguments(FieldInfo fieldInfo) internal readonly struct SortedIndexMap { + [NoAutoStaticsCleanup] // immutable empty sentinel backed by Array.Empty; safe to persist public static readonly SortedIndexMap Empty = new SortedIndexMap(Array.Empty(), Array.Empty()); @@ -264,11 +374,20 @@ internal readonly struct SortedIndexMap public static SortedIndexMap Build(SerializedProperty arrayProperty, bool ascending) { - s_SortCount++; int n = arrayProperty.arraySize; if (n == 0) return Empty; + if (DictionaryDrawer.ShowSerializedOrder) + { + var identity = new int[n]; + for (int i = 0; i < n; i++) + identity[i] = i; + return new SortedIndexMap(identity, identity); + } + + s_SortCount++; + // The native sort flips its key comparison based on `ascending` but always // breaks ties on the original array index in ascending order. Reversing the // sorted indices in C# would also flip the tiebreaker, pushing a duplicate @@ -314,11 +433,20 @@ internal static bool SortedOrderEquals(int[] a, int[] b) return true; } - // Returns true if the set actually changed, so callers can skip + // Returns true if either set actually changed, so callers can skip // UI refreshes (label text, gutter markers) when nothing differs. - internal static bool TryRefreshDuplicateIndicesInto(SerializedProperty dictionaryProperty, HashSet target) + internal static bool TryRefreshDuplicateAndNullKeyIndicesInto( + SerializedProperty dictionaryProperty, HashSet duplicateTarget, HashSet nullKeyTarget) + { + var ignored = dictionaryProperty.GetDictionaryIgnoredEntries(); + bool duplicatesChanged = TryRefreshIndicesInto(ignored.duplicateEntryIndices, duplicateTarget); + bool nullKeysChanged = TryRefreshIndicesInto(ignored.nullKeyEntryIndices, nullKeyTarget); + return duplicatesChanged || nullKeysChanged; + } + + static bool TryRefreshIndicesInto(int[] newIndices, HashSet target) { - var newIndices = dictionaryProperty.GetDictionaryDuplicateEntryIndices() ?? Array.Empty(); + newIndices ??= Array.Empty(); if (target.Count == newIndices.Length) { bool allMatch = true; @@ -340,27 +468,282 @@ internal static bool TryRefreshDuplicateIndicesInto(SerializedProperty dictionar return true; } - internal static void GetHeaderLabels(FieldInfo fieldInfo, out string keyLabel, out string valueLabel, out float keyColumnFraction) + // Resolves the key/value column header labels and default key-column fraction for a dictionary. + // These come from the same [DictionaryDisplay] attribute that drives layout: a field-level + // attribute on the directly-declared field, or an assembly-level attribute targeting the exact + // closed Dictionary. dictionaryType is the property's static closed type, which for a nested + // inner dictionary differs from fieldInfo.FieldType (see GetFieldDisplayAttribute). + internal static void GetHeaderLabels(FieldInfo fieldInfo, Type dictionaryType, out string keyLabel, out string valueLabel, out float keyColumnFraction) { keyLabel = Texts.DefaultKeyLabel; valueLabel = Texts.DefaultValueLabel; keyColumnFraction = 0.5f; - var attr = fieldInfo?.GetCustomAttribute(); - if (attr != null) + var fieldAttr = GetFieldDisplayAttribute(fieldInfo, dictionaryType); + if (fieldAttr != null) { - if (!string.IsNullOrEmpty(attr.keyColumnLabel)) - keyLabel = attr.keyColumnLabel; - if (!string.IsNullOrEmpty(attr.valueColumnLabel)) - valueLabel = attr.valueColumnLabel; - // Sanity-clamp only — keeps NaN/<0/>1 attribute values out of the cache. - // The actual rendered width is enforced by GetKeyColumnPixelWidth, which - // applies the pixel floor regardless of the stored fraction's exact value. - var fraction = attr.keyColumnFraction; - if (float.IsNaN(fraction)) - fraction = 0.5f; - keyColumnFraction = Mathf.Clamp(fraction, 0.01f, 0.99f); + ApplyHeaderLabels(fieldAttr, ref keyLabel, ref valueLabel, ref keyColumnFraction); + return; } + + if (dictionaryType != null && GetAssemblyLayoutRegistry().TryGetValue(dictionaryType, out var entry)) + ApplyHeaderLabels(entry.attribute, ref keyLabel, ref valueLabel, ref keyColumnFraction); + } + + static void ApplyHeaderLabels(DictionaryDisplayAttribute attr, ref string keyLabel, ref string valueLabel, ref float keyColumnFraction) + { + if (!string.IsNullOrEmpty(attr.keyLabel)) + keyLabel = attr.keyLabel; + if (!string.IsNullOrEmpty(attr.valueLabel)) + valueLabel = attr.valueLabel; + // Sanity-clamp only — keeps NaN/<0/>1 attribute values out of the cache. + // The actual rendered width is enforced by GetKeyColumnPixelWidth, which + // applies the pixel floor regardless of the stored fraction's exact value. + var fraction = attr.keyColumnFraction; + if (float.IsNaN(fraction)) + fraction = 0.5f; + keyColumnFraction = Mathf.Clamp(fraction, 0.01f, 0.99f); + } + + // Returns the field-level [DictionaryDisplay] that applies to THIS dictionary, or null. + // A field attribute only governs the dictionary the field *directly* declares. For a nested + // inner dictionary, fieldInfo resolves to the outer field (dict elements are not fields), so + // its dictionaryType differs from fieldInfo.FieldType; in that case the field attribute belongs + // to the outer dictionary and must not leak onto the inner one (which resolves via the + // assembly-level registry instead). The assembly form (DictionaryDisplayForTypeAttribute) is + // AttributeTargets.Assembly, so it can never appear on a field — no form check is needed here. + static DictionaryDisplayAttribute GetFieldDisplayAttribute(FieldInfo fieldInfo, Type dictionaryType) + { + if (fieldInfo == null || dictionaryType != fieldInfo.FieldType) + return null; + + return fieldInfo.GetCustomAttribute(); + } + + // Single source of truth for the foldout title of a nested collection value, keyed on its type: + // "Dictionary" / "Array" / "List" (add new collection kinds, e.g. HashSet, here). Returns null for + // any non-collection type so the value cell stays label-less and the field keeps its "Value" name. + // + // The enclosing dictionary calls this for its value type and feeds the result to the value cell as a + // plain label (see DictionaryView.m_ValueFieldLabel / DrawerInstanceIMGUI.valueCollectionLabel). The + // value's own drawer then renders it: a nested dictionary reads it through PropertyField (UITK) or its + // OnGUI label (IMGUI), an array/list through its built-in foldout title — so no drawer needs to detect + // the nested-value case itself. + internal static string GetNestedCollectionValueLabel(Type collectionType) + { + if (collectionType == null) + return null; + if (collectionType.IsArray) + return Texts.NestedArrayLabel; + if (collectionType.IsGenericType) + { + var definition = collectionType.GetGenericTypeDefinition(); + if (definition == typeof(Dictionary<,>)) + return Texts.NestedDictionaryLabel; + if (definition == typeof(List<>)) + return Texts.NestedListLabel; + } + return null; + } + + // Resolves the default layout for a dictionary, applying this precedence: + // 1. A field-level [DictionaryDisplay] on the dictionary field (explicit per-field intent). + // 2. An assembly-level [DictionaryDisplayForType(typeof(Dictionary), ...)] matching the exact + // closed dictionary type — the only way to reach a nested dictionary or a type you don't own. + // Matches globally (applies wherever such a dictionary is used), but a rule is only admitted + // if its declaring assembly owns K or V — see GetAssemblyLayoutRegistry / DeclaresTargetType. + // 3. TwoColumns. + // The user's context-menu choice still wins at runtime; GetActiveLayout layers that on top of this. + // dictionaryType is the closed Dictionary for this specific field/value (from the property's + // static type), which for nested dictionaries differs from fieldInfo.FieldType. + internal static DictionaryLayout ResolveDefaultLayout(FieldInfo fieldInfo, Type dictionaryType) + { + var fieldAttr = GetFieldDisplayAttribute(fieldInfo, dictionaryType); + if (fieldAttr != null) + return fieldAttr.layout; + + if (dictionaryType != null && GetAssemblyLayoutRegistry().TryGetValue(dictionaryType, out var exact)) + return exact.attribute.layout; + + return DictionaryLayout.TwoColumns; + } + + // True for a closed Dictionary. Used by the registry builder to reject a + // [DictionaryDisplayForType] whose target is not a closed dictionary: only exact Dictionary + // targets are supported. IsConstructedGenericType (not IsGenericType) is required so the open + // typeof(Dictionary<,>) — which has no concrete K/V to match — is rejected rather than silently + // registered under a key nothing ever resolves to. + static bool IsExactDictionaryType(Type type) + => type != null && type.IsConstructedGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>); + + // One resolved assembly-level entry. Holds the originating attribute so layout, labels, and + // fraction all read from the same source without a second reflection pass, plus the name of the + // assembly that declared it so a duplicate warning can point back to the winning declaration. + readonly struct AssemblyLayoutEntry + { + public readonly DictionaryDisplayAttribute attribute; + public readonly string declaringAssemblyName; + public AssemblyLayoutEntry(DictionaryDisplayAttribute attribute, string declaringAssemblyName) + { + this.attribute = attribute; + this.declaringAssemblyName = declaringAssemblyName; + } + } + + // Lazily-built map of [assembly: DictionaryDisplayForType(targetType, ...)] across all loaded + // assemblies. A target must be a closed Dictionary (other shapes are ignored). Matching is + // global — a target matches wherever such a dictionary is used — but an assembly may only declare + // a rule for a target that involves a type it *defines* somewhere in the dictionary's shape — its + // key, its value, or a type nested within either (see DeclaresTargetType). That ownership gate lets + // an extension style dictionaries over its own types everywhere they appear, while stopping a rule + // for a shape it has no stake in (e.g. Dictionary) from hijacking every such dictionary in + // a project. The key is the closed Dictionary targetType. + // Statics reset on domain reload, so the cache rebuilds automatically when assemblies change. + [AutoStaticsCleanupOnCodeReload] + static Dictionary s_AssemblyLayoutRegistry; + + static Dictionary GetAssemblyLayoutRegistry() + { + if (s_AssemblyLayoutRegistry != null) + return s_AssemblyLayoutRegistry; + + var registry = new Dictionary(); + + // CurrentAssemblies.GetLoadedAssemblies() is the Unity-safe enumeration (AppDomain.GetAssemblies + // can return already-unloaded assemblies — analyzer UAC0005). Sort by name so a target that is + // legitimately owned by more than one assembly (K and V authored in different assemblies) + // resolves deterministically (first by assembly name wins). + var assemblies = new List(CurrentAssemblies.GetLoadedAssemblies()); + assemblies.Sort((a, b) => string.CompareOrdinal(a.FullName, b.FullName)); + + foreach (var assembly in assemblies) + { + object[] attrs; + try + { + attrs = assembly.GetCustomAttributes(typeof(DictionaryDisplayForTypeAttribute), false); + } + catch + { + // A dynamic or otherwise reflection-hostile assembly: skip it. + continue; + } + + foreach (DictionaryDisplayForTypeAttribute attr in attrs) + { + var target = attr.targetType; + + // Only exact closed Dictionary targets are supported. + if (!IsExactDictionaryType(target)) + { + Debug.LogWarning($"The DictionaryDisplayForType attribute targeting {FormatType(target)} in {assembly.GetName().Name} is ignored: it must target a Dictionary, for example typeof(Dictionary)."); + continue; + } + + // Ownership gate: reject a rule whose target involves no type defined in this assembly. + if (!DeclaresTargetType(target, assembly)) + { + Debug.LogWarning(OwnershipRejectionMessage(target, assembly)); + continue; + } + + // Duplicate rules for the same target: keep the first and warn. Note this only sees + // duplicates that survive to metadata — two byte-for-byte identical attributes in one + // assembly (same ctor args and same named args) are folded into a single entry by the + // C# compiler, so they never reach here and cannot be warned about at runtime. What we + // do catch: same-assembly duplicates that differ in any setting, and any cross-assembly + // duplicate (identical or not, since each assembly contributes its own metadata entry). + if (registry.TryGetValue(target, out var existing)) + { + var assemblyName = assembly.GetName().Name; + var where = existing.declaringAssemblyName == assemblyName + ? $"is declared more than once in {assemblyName}" + : $"is declared more than once: kept the rule from {existing.declaringAssemblyName} and ignored the one from {assemblyName}"; + Debug.LogWarning($"The DictionaryDisplayForType attribute targeting {FormatType(target)} {where}; the first registered rule is used."); + continue; + } + registry.Add(target, new AssemblyLayoutEntry(attr, assembly.GetName().Name)); + } + } + + s_AssemblyLayoutRegistry = registry; + return s_AssemblyLayoutRegistry; + } + + // Gates which [DictionaryDisplayForType] rules an assembly may declare: you may only style a + // Dictionary that involves a type you authored *anywhere in its shape*, not merely as the + // direct K or V. A closed generic's own Assembly is its definition's (Dictionary<,> and List<> + // live in the framework), so ownership is carried by the type arguments, and we recurse through + // element types and nested generic arguments to find an authored type at any depth. This is + // intentionally broader than "the direct key or value is owned", because a dictionary that + // contains your type is legitimately yours to style. For an assembly that defines only MyType: + // Dictionary -> owned (MyType is the direct value) + // Dictionary -> owned (an array reports its element's assembly) + // Dictionary> -> owned (MyType nested in the value's generic args) + // Dictionary> -> owned (MyType nested in the inner dictionary) + // Dictionary -> NOT owned (no authored type appears anywhere) + // The gate's purpose is only to stop an assembly from hijacking a shape it has no stake in (the + // last case); it is not meant to require ownership of the outermost key/value specifically. + static bool DeclaresTargetType(Type type, Assembly declaringAssembly) + { + if (type == null) + return false; + if (type.Assembly == declaringAssembly) + return true; + if (type.HasElementType && DeclaresTargetType(type.GetElementType(), declaringAssembly)) + return true; + if (type.IsGenericType) + { + foreach (var arg in type.GetGenericArguments()) + { + if (DeclaresTargetType(arg, declaringAssembly)) + return true; + } + } + return false; + } + + // Builds the warning for a Dictionary rule rejected by the ownership gate. It names K and V + // and states the rule in plain English: the fix is for the developer to declare the attribute in + // whichever assembly defines a type used in the dictionary — they know which one that is, so we + // don't try to guess it. A rejection means no authored type appears anywhere in the shape (not the + // direct key or value, nor any type nested within them), so naming the top-level key and value is + // enough to point at the problem. target is a closed Dictionary<,> (guaranteed by IsExactDictionaryType upstream). + static string OwnershipRejectionMessage(Type target, Assembly declaringAssembly) + { + var args = target.GetGenericArguments(); + var declaringName = declaringAssembly.GetName().Name; + + return $"The DictionaryDisplayForType attribute targeting {FormatType(target)} in {declaringName} is ignored: it must " + + $"target a dictionary whose key, value, or a type nested within either is defined in the declaring assembly, " + + $"but neither key '{FormatType(args[0])}' nor value '{FormatType(args[1])}' involves a type defined in {declaringName}. " + + $"Apply the attribute in the assembly that defines a key or value type used by the dictionary."; + } + + // Renders a Type for a warning: angle-bracket generics (Dictionary`2[Int32,Foo] -> + // Dictionary), recursing through generic arguments. Arrays (and other + // element types) are not generic, so format the element type and re-append the suffix + // (e.g. List`1[] -> List[]) rather than printing the raw runtime name. + static string FormatType(Type type) + { + if (type == null) + return ""; + if (type.HasElementType) + { + var element = type.GetElementType(); + var runtimeName = type.Name; + var suffix = runtimeName.StartsWith(element.Name) ? runtimeName.Substring(element.Name.Length) : string.Empty; + return FormatType(element) + suffix; + } + if (!type.IsGenericType) + return type.Name; + + var name = type.Name; + var tick = name.IndexOf('`'); + if (tick >= 0) + name = name.Substring(0, tick); + var args = Array.ConvertAll(type.GetGenericArguments(), FormatType); + return $"{name}<{string.Join(", ", args)}>"; } internal static bool IsEditingMultipleObjects(SerializedProperty property) @@ -445,24 +828,40 @@ internal static int InsertOrDuplicateSelectedEntry( return lastIndex; } - internal static int FindFirstDuplicateDisplayIndex( + internal static int FindFirstIgnoredDisplayIndex( IEnumerable duplicateArrayIndices, + IEnumerable nullKeyArrayIndices, SortedIndexMap sortedIndices) { - if (duplicateArrayIndices == null) - return -1; - int firstDisplayIndex = int.MaxValue; - foreach (var arrayIndex in duplicateArrayIndices) + firstDisplayIndex = MinDisplayIndex(duplicateArrayIndices, sortedIndices, firstDisplayIndex); + firstDisplayIndex = MinDisplayIndex(nullKeyArrayIndices, sortedIndices, firstDisplayIndex); + return firstDisplayIndex == int.MaxValue ? -1 : firstDisplayIndex; + } + + static int MinDisplayIndex(IEnumerable arrayIndices, SortedIndexMap sortedIndices, int current) + { + if (arrayIndices == null) + return current; + + foreach (var arrayIndex in arrayIndices) { if (!sortedIndices.ContainsArrayIndex(arrayIndex)) continue; int displayIndex = sortedIndices.ToDisplayIndex(arrayIndex); - if (displayIndex < firstDisplayIndex) - firstDisplayIndex = displayIndex; + if (displayIndex < current) + current = displayIndex; } + return current; + } - return firstDisplayIndex == int.MaxValue ? -1 : firstDisplayIndex; + internal static bool RemoveEntryAtDisplayIndex(SerializedProperty arrayProperty, int index, SortedIndexMap sortedIndices) + { + using (ListPool.Get(out var tempList)) + { + tempList.Add(index); + return RemoveEntriesAtDisplayIndices(arrayProperty, tempList, sortedIndices); + } } // Performs the dictionary "Remove" mutation: maps the current selection from @@ -520,11 +919,26 @@ public enum KeyMarkerKind { None, Duplicate, + NullKey, } - public static KeyMarkerKind GetMarkerKind(int arrayIndex, HashSet duplicateEntryIndices) + public static KeyMarkerKind GetMarkerKind(int arrayIndex, HashSet duplicateEntryIndices, HashSet nullKeyEntryIndices) { - return duplicateEntryIndices.Contains(arrayIndex) ? KeyMarkerKind.Duplicate : KeyMarkerKind.None; + if (duplicateEntryIndices != null && duplicateEntryIndices.Contains(arrayIndex)) + return KeyMarkerKind.Duplicate; + if (nullKeyEntryIndices != null && nullKeyEntryIndices.Contains(arrayIndex)) + return KeyMarkerKind.NullKey; + return KeyMarkerKind.None; + } + + public static string GetMarkerTooltip(KeyMarkerKind kind) + { + switch (kind) + { + case KeyMarkerKind.Duplicate: return DictionaryDrawer.Texts.DuplicateMarkerTooltip; + case KeyMarkerKind.NullKey: return DictionaryDrawer.Texts.NullKeyMarkerTooltip; + default: return null; + } } } diff --git a/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerUITK.cs b/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerUITK.cs index 384c0b1df3..a9270caa74 100644 --- a/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerUITK.cs +++ b/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryDrawerUITK.cs @@ -16,6 +16,12 @@ public override VisualElement CreatePropertyGUI(SerializedProperty property) { var dictionaryView = new DictionaryView(); + // preferredLabel is set by PropertyField just before this call (label ?? localizedDisplayName). + // The view uses it as its foldout title, so a nested dictionary value — which the enclosing + // drawer labels "Dictionary" via GetNestedCollectionValueLabel — reads correctly without the + // view having to detect that case itself. + dictionaryView.preferredLabel = preferredLabel; + // At this point the view has just been constructed and is not yet // parented, so dictionaryView.panel is null. Calling BindProperty here // would (a) set bindingPath, and (b) take the "element.panel == null" diff --git a/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryView.cs b/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryView.cs index 3d1f735d31..1f9ff83e40 100644 --- a/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryView.cs +++ b/Editor/Mono/GUI/PropertyDrawers/Dictionary/DictionaryView.cs @@ -4,12 +4,12 @@ using System; using System.Collections.Generic; -using System.Text.RegularExpressions; using Unity.Profiling; using Unity.Profiling.LowLevel; using UnityEditor.UIElements; using UnityEngine.UIElements.Internal; using UnityEditor; +using Unity.Scripting.LifecycleManagement; namespace UnityEngine.UIElements { @@ -19,7 +19,7 @@ namespace UnityEngine.UIElements /// field. Owns the foldout header /// (via ), the +/- footer /// (via ), the two-column -/// "Key | Value" header with its draggable resizer, sort + duplicate +/// "Key | Value" header with its draggable resizer, sort + ignored-key /// detection state, and all per-property data needed to render and mutate /// the dictionary. /// @@ -36,7 +36,7 @@ namespace UnityEngine.UIElements /// BindingRequest that re-walks the tree on the next panel update /// and re-dispatches the bind event, running the rebuild path twice. /// -internal class DictionaryView : ListView +internal partial class DictionaryView : ListView { // All USS classes added by the dictionary view live under the `unity-dictionary-view` // block so they're easy to distinguish from classes inherited from BaseListView, @@ -56,24 +56,59 @@ internal class DictionaryView : ListView static readonly string k_ListViewClass = ussClassName + "__list-view"; static readonly string k_ListViewFocusedClass = ussClassName + "__list-view--focused"; static readonly string k_ListHeaderClass = ussClassName + "__list-header"; + // Leak-safe styling hook for the slim one-column header (see k_OneColumnModeClass for why + // header/row styling can't key on the view root). The header subtree never contains a nested + // dictionary view, so its rules may descend from this marker freely. + static readonly string k_ListHeaderOneColumnClass = k_ListHeaderClass + "--one-column"; static readonly string k_ListHeaderKeyClass = ussClassName + "__list-header__key"; static readonly string k_ListHeaderValueClass = ussClassName + "__list-header__value"; static readonly string k_RowClass = ussClassName + "__row"; + // Leak-safe styling hook for one-column rows/cells (see k_OneColumnModeClass). Row and cell + // rules match via the child combinator from this (e.g. __row--one-column > __field--key) so + // they reach only this view's own rows, never a nested dictionary's rows deeper down. + static readonly string k_RowOneColumnClass = k_RowClass + "--one-column"; static readonly string k_HelpBoxClass = ussClassName + "__helpbox"; - static readonly string k_HelpBoxDuplicatesClass = ussClassName + "__helpbox--duplicates"; - static readonly string k_HelpBoxSelectDuplicateClass = ussClassName + "__helpbox__select-duplicate"; + static readonly string k_HelpBoxIgnoredClass = ussClassName + "__helpbox--duplicates"; + static readonly string k_HelpBoxSelectIgnoredClass = ussClassName + "__helpbox__select-duplicate"; static readonly string k_HeaderSpacerClass = ussClassName + "__header-spacer"; static readonly string k_ToggleLabelClass = ussClassName + "__toggle-label"; static readonly string k_EmptyLabelClass = ussClassName + "__empty-label"; static readonly string k_HeaderInfoClass = ussClassName + "__header-info"; - static readonly string k_DuplicateKeyIconClass = ussClassName + "__duplicate-key-icon"; + static readonly string k_KeyWarningIconClass = ussClassName + "__duplicate-key-icon"; static readonly string k_SelectionIndicatorClass = ussClassName + "__selection-indicator"; static readonly string k_ColumnResizerClass = ussClassName + "__column-resizer"; static readonly string k_ColumnResizerLineClass = ussClassName + "__column-resizer__line"; + // View-level source of truth for one-column mode, toggled on the ListView root. It carries + // NO styling on purpose: a rule descending from it (.unity-dictionary-view--one-column X) + // would also match a NESTED dictionary's elements, since a nested dict lives inside an outer + // one-column row — that descendant leak is exactly what the per-element markers above + // (k_RowOneColumnClass, k_ListHeaderOneColumnClass) avoid. We keep this root marker because + // it's the stable, content-independent signal for "is this view one-column?": present even + // when the view has no rows (the per-element markers only exist alongside their elements), + // so tests/debugging assert against it rather than fishing for an internal modifier. + static readonly string k_OneColumnModeClass = ussClassName + "--one-column"; + // Per-row "Value" disclosure foldout shown in the OneColumnWithValueFoldout layout. Its + // contentContainer holds the value field, so the foldout drives native show/hide + content + // indentation. + static readonly string k_RowFoldoutClass = ussClassName + "__row-foldout"; + // Static bold "Value" header shown in the OneColumnWithValueVisible layout in place of a + // foldout, so the value is always visible and the header is plain, non-interactive text. + static readonly string k_RowValueHeaderClass = ussClassName + "__row-value-header"; + + // Holds the live DictionaryView siblings that share one persisted DictionaryState — the elements of a + // List/array/Dictionary of dictionaries, whose paths all normalize to the same key. Because + // they share a single StateCache entry (avoiding an explosion of per-instance caches), a + // sort-direction, layout, or column-width change made in one sibling must propagate live to + // the others. + // Key is the same Hash128 as s_StateCache (the normalized property path) + [AutoStaticsCleanupOnCodeReload] + static readonly Dictionary> s_LinkedViews = new(); SerializedProperty m_DictionaryFieldProperty; SerializedProperty m_ArrayProperty; Hash128 m_StateCacheKey; + bool m_ShouldLinkWithSiblings; + bool m_IsLinked; readonly Foldout m_Foldout; Label m_HeaderInfoLabel; @@ -81,16 +116,42 @@ internal class DictionaryView : ListView VisualElement m_ListHeader; ColumnResizer m_ColumnResizer; VisualElement m_KeyHeader; + Label m_KeyHeaderLabel; Label m_ValueLabel; VisualElement m_SortIndicator; + // Resolved key column label (default "Key" or DictionaryDisplayAttribute override). + string m_KeyLabelText; + // Resolved value column label (default "Value" or DictionaryDisplayAttribute override), + // reused as the per-row foldout text in one-column mode. + string m_ValueLabelText; + // Label for the value cell's PropertyField: "Array"/"List" when the value type is a collection + // (so the built-in collection drawer's header reads that instead of falling back to "Value"), + // otherwise string.Empty. Uniform across rows since every entry shares the value type. + string m_ValueFieldLabel = string.Empty; + // Foldout title supplied by the hosting PropertyField (PropertyDrawer.preferredLabel) and set in + // DictionaryDrawer.CreatePropertyGUI. A nested dictionary value arrives labelled "Dictionary" from + // its enclosing drawer; an empty/null value falls back to the property's own localizedDisplayName, + // mirroring how ListView resolves its header title. + internal string preferredLabel { get; set; } HelpBox m_MultiEditHelpBox; - HelpBox m_DuplicatesHelpBox; + HelpBox m_IgnoredHelpBox; readonly HashSet m_DuplicateEntryIndices = new(); + readonly HashSet m_NullKeyEntryIndices = new(); bool m_SortScheduled; bool m_SortAscending = true; + // Single source of truth for the column layout. Persisted in DictionaryState and + // shared across list siblings via the normalized path. The two booleans below are + // derived views kept so the render code reads intent-named flags rather than enum + // comparisons. The two OneColumn_* modes both stack Key over Value; they differ + // only in whether each value sits behind a per-row "Value" foldout. + DictionaryLayout m_Layout = DictionaryLayout.TwoColumns; + // Default layout resolved from [DictionaryDisplay] (field- or assembly-level); the + // active layout falls back to this until the user overrides it from the context menu. + DictionaryLayout m_AttributeLayout = DictionaryLayout.TwoColumns; + bool m_OneColumnMode => m_Layout != DictionaryLayout.TwoColumns; DictionaryDrawer.SortedIndexMap m_SortedIndexMap = DictionaryDrawer.SortedIndexMap.Empty; // Hash of the keys at the time we last produced m_SortedIndexMap. // Lets the TrackPropertyValue callback skip sort scheduling when only a @@ -103,6 +164,11 @@ internal class DictionaryView : ListView bool m_IsBound; + // The layout the installed makeItem factory was built for. Each layout uses a distinct row + // template (two-column, one-column foldout, one-column static value), so a layout change + // compares against this to know it must rebuild the row pool with the new template. + DictionaryLayout m_MakeItemLayout = DictionaryLayout.TwoColumns; + int displayedItemCount => m_SortedIndexMap.Length; public Foldout foldout => m_Foldout; @@ -152,13 +218,81 @@ public DictionaryView() autoSelectNewItemOnAdd = false; makeNoneElement = MakeEmptyElement; - makeItem = MakeListItem; + // Default to the two-column template; RebuildFromProperty/ApplyLayoutMode swap in + // the one-column factory when the persisted/selected layout calls for it. + makeItem = MakeTwoColumnRow; bindItem = BindListItem; unbindItem = UnbindListItem; destroyItem = DestroyListItem; onAdd = _ => OnAddClicked(); onRemove = _ => OnRemoveClicked(); - selectionChanged += OnSelectionChanged; + + RegisterCallback(_ => OnViewAttachToPanel()); + RegisterCallback(_ => OnViewDetachFromPanel()); + } + + void OnViewAttachToPanel() + { + DictionaryDrawer.SerializedOrderChanged += OnSerializedOrderChanged; + AddLinkedViewIfNeeded(); + } + + void OnViewDetachFromPanel() + { + DictionaryDrawer.SerializedOrderChanged -= OnSerializedOrderChanged; + RemoveLinkedViewIfNeeded(); + } + + void AddLinkedViewIfNeeded() + { + if (m_IsLinked || m_ArrayProperty == null || panel == null) + return; + if (!m_ShouldLinkWithSiblings) + return; + + if (!s_LinkedViews.TryGetValue(m_StateCacheKey, out var list)) + { + list = new List(); + s_LinkedViews[m_StateCacheKey] = list; + } + list.Add(this); + m_IsLinked = true; + } + + void RemoveLinkedViewIfNeeded() + { + if (!m_IsLinked) + return; + + if (s_LinkedViews.TryGetValue(m_StateCacheKey, out var list)) + { + list.Remove(this); + if (list.Count == 0) + s_LinkedViews.Remove(m_StateCacheKey); + } + m_IsLinked = false; + } + + void PerformActionOnLinkedViews(Action action) + { + if (!m_IsLinked || !s_LinkedViews.TryGetValue(m_StateCacheKey, out var siblings) || siblings.Count <= 1) + return; + + foreach (var view in siblings) + { + if (view == this) + continue; + action(view); + } + } + + void OnSerializedOrderChanged() + { + if (!m_IsBound || m_ArrayProperty == null) + return; + UpdateSortIndicatorClass(); + RebuildSortedIndicesAndRefresh(); + UpdateHeaderInfo(); } [EventInterest(typeof(SerializedPropertyBindEvent))] @@ -174,14 +308,14 @@ protected override void HandleEventBubbleUp(EventBase evt) } // Tears down any prior bound state and rebuilds the view against the new - // property. Resolves key/value types and the optional DictionaryHeaderAttribute + // property. Resolves key/value types and the optional DictionaryDisplayAttribute // from the property's reflected FieldInfo, builds the column header, sort - // scheduler, and duplicate tracking, and installs makeItem/bindItem/onAdd/onRemove + // scheduler, and ignored-key tracking, and installs makeItem/bindItem/onAdd/onRemove // on the list. Only called from HandleEventBubbleUp's SerializedPropertyBindEvent // handler; external callers bind via bindingPath + the inspector's tree walk. void RebuildFromProperty(SerializedProperty property) { - using var _ = s_BuildMarker.Auto(); + using var buildMarker = s_BuildMarker.Auto(); if (property == null) return; @@ -189,19 +323,39 @@ void RebuildFromProperty(SerializedProperty property) ResetBoundState(); m_DictionaryFieldProperty = property.Copy(); - m_StateCacheKey = DictionaryDrawer.ComputeStateCacheKey(m_DictionaryFieldProperty.propertyPath); + var dictionaryPropertyPath = m_DictionaryFieldProperty.propertyPath; + m_StateCacheKey = DictionaryDrawer.ComputeStateCacheKey(dictionaryPropertyPath); + m_ShouldLinkWithSiblings = DictionaryDrawer.ShouldLinkViewStateWithSiblings(dictionaryPropertyPath); + + var fieldInfo = ScriptAttributeUtility.GetFieldInfoAndStaticTypeFromProperty(m_DictionaryFieldProperty, out var dictionaryType); + m_AttributeLayout = DictionaryDrawer.ResolveDefaultLayout(fieldInfo, dictionaryType); + + // Resolve the value-cell label before SyncMakeItemToLayout()/RefreshListView() build any rows, + // so the value PropertyField is created with the right collection label on first paint. + var valueType = dictionaryType != null && dictionaryType.IsGenericType + ? dictionaryType.GetGenericArguments()[1] + : null; + m_ValueFieldLabel = DictionaryDrawer.GetNestedCollectionValueLabel(valueType) ?? string.Empty; var cachedState = DictionaryDrawer.GetCachedState(m_StateCacheKey); if (cachedState != null) - { m_SortAscending = cachedState.sortAscending; - } + m_Layout = DictionaryDrawer.GetActiveLayout(m_StateCacheKey, m_AttributeLayout); + + // Install the matching row template before RefreshListView() builds any rows, so + // the persisted layout is reflected on first paint without a redundant rebuild. + SyncMakeItemToLayout(); // Foldout's bindingPath drives open/closed state via SerializedProperty.isExpanded, - // so the user's collapse/expand survives rebuilds and domain reloads. - m_Foldout.text = m_DictionaryFieldProperty.displayName; - m_Foldout.bindingPath = m_DictionaryFieldProperty.propertyPath; - headerTitle = m_DictionaryFieldProperty.displayName; + // so the user's collapse/expand survives rebuilds and domain reloads. The title follows the + // label the hosting PropertyField supplied (like ListView.headerTitle does), so a nested + // dictionary value reads "Dictionary" while a real field keeps its own display name. + var foldoutLabel = string.IsNullOrEmpty(preferredLabel) + ? m_DictionaryFieldProperty.localizedDisplayName + : preferredLabel; + m_Foldout.text = foldoutLabel; + m_Foldout.bindingPath = dictionaryPropertyPath; + headerTitle = foldoutLabel; if (DictionaryDrawer.IsEditingMultipleObjects(m_DictionaryFieldProperty)) { @@ -218,7 +372,7 @@ void RebuildFromProperty(SerializedProperty property) arrayProp.Next(true); m_ArrayProperty = arrayProp; - BuildDuplicatesHelpBox(m_Foldout.contentContainer); + BuildIgnoredHelpBox(m_Foldout.contentContainer); BuildFoldoutHeaderInfoLabel(); SetTwoColumnHeader(BuildColumnHeader()); @@ -233,9 +387,16 @@ void RebuildFromProperty(SerializedProperty property) RebuildSortedIndices(); RefreshListView(); UpdateHeaderInfo(); - UpdateRemoveButtonState(); + + // Apply the restored persisted layout mode after the header + list are built + // so the root modifier class and per-row config reflect it on first paint. + // RefreshListView() above already bound the rows with the correct layout, so + // skip the redundant rebind here. + ApplyLayoutMode(refresh: false); m_IsBound = true; + + AddLinkedViewIfNeeded(); } void ResetBoundState() @@ -243,6 +404,8 @@ void ResetBoundState() if (!m_IsBound) return; + RemoveLinkedViewIfNeeded(); + // Tear down everything RebuildFromProperty installs so a rebuild for a // different property starts from a clean slate. The foldout itself stays // since its bindingPath gets reassigned below. @@ -257,10 +420,10 @@ void ResetBoundState() var listFooter = m_Foldout.Q(className: BaseListView.footerUssClassName); if (listFooter != null) listFooter.style.display = DisplayStyle.Flex; - if (m_DuplicatesHelpBox != null) + if (m_IgnoredHelpBox != null) { - m_DuplicatesHelpBox.RemoveFromHierarchy(); - m_DuplicatesHelpBox = null; + m_IgnoredHelpBox.RemoveFromHierarchy(); + m_IgnoredHelpBox = null; } if (m_ListHeader != null) { @@ -280,7 +443,6 @@ void ResetBoundState() makeNoneElement = null; onAdd = null; onRemove = null; - selectionChanged -= OnSelectionChanged; UnregisterCallback(OnValidateCommand); UnregisterCallback(OnExecuteCommand); UnregisterCallback(OnDictionaryKeyDown); @@ -292,8 +454,10 @@ void ResetBoundState() m_DictionaryFieldProperty = null; m_ArrayProperty = null; + m_ShouldLinkWithSiblings = false; m_DuplicateEntryIndices.Clear(); + m_NullKeyEntryIndices.Clear(); m_ItemsSource = null; itemsSource = null; m_SortedIndexMap = DictionaryDrawer.SortedIndexMap.Empty; @@ -303,6 +467,12 @@ void ResetBoundState() m_KeyHeader = null; m_ValueLabel = null; m_SortIndicator = null; + // Reset the layout and its root modifier so a rebind to a different (or + // multi-edit) property starts from the default two-column layout rather than + // leaking the previous view's one-column class. + m_Layout = DictionaryLayout.TwoColumns; + m_AttributeLayout = DictionaryLayout.TwoColumns; + EnableInClassList(k_OneColumnModeClass, false); m_IsBound = false; } @@ -329,11 +499,6 @@ void ShowMultiEditHelpBox() listFooter.style.display = DisplayStyle.None; } - void OnSelectionChanged(IEnumerable _) - { - UpdateRemoveButtonState(); - } - void OnTrackedPropertyChanged(object _, SerializedProperty __) { if (m_ArrayProperty == null || !m_ArrayProperty.isValid) @@ -355,13 +520,13 @@ void OnTrackedPropertyChanged(object _, SerializedProperty __) CheckIfKeysChangedAndSortIfNeeded(); // While a key is being edited (or any other editor interaction is in - // flight) the pending sort can't run yet — keep duplicate markers in + // flight) the pending sort can't run yet — keep the key warning markers in // sync so the user sees live feedback as they type. Sorting itself // would yank the focused field out of their hands. - if (!IsReadyToSortByKey() && UpdateDuplicateIndicesOnly()) + if (!IsReadyToSortByKey() && UpdateMarkerIndicesOnly()) { UpdateHeaderInfo(); - UpdateDuplicateKeyIconsOnVisibleItems(); + UpdateKeyWarningIconsOnVisibleItems(); } } @@ -385,28 +550,28 @@ void BuildFoldoutHeaderInfoLabel() toggle.Add(m_HeaderInfoLabel); } - void BuildDuplicatesHelpBox(VisualElement parent) + void BuildIgnoredHelpBox(VisualElement parent) { - // The duplicates helpbox starts hidden via .unity-dictionary-view__helpbox--duplicates + // The ignored-rows helpbox starts hidden via .unity-dictionary-view__helpbox--duplicates // (display: none in USS); UpdateHeaderInfo flips style.display when the - // duplicate count goes non-zero. - m_DuplicatesHelpBox = new HelpBox(string.Empty, HelpBoxMessageType.Warning); - m_DuplicatesHelpBox.AddToClassList(k_HelpBoxClass); - m_DuplicatesHelpBox.AddToClassList(k_HelpBoxDuplicatesClass); + // ignored count (duplicate + null keys) goes non-zero. + m_IgnoredHelpBox = new HelpBox(string.Empty, HelpBoxMessageType.Warning); + m_IgnoredHelpBox.AddToClassList(k_HelpBoxClass); + m_IgnoredHelpBox.AddToClassList(k_HelpBoxIgnoredClass); - var selectButton = new Button(OnSelectFirstDuplicateClicked) + var selectButton = new Button(OnSelectFirstIgnoredClicked) { - text = DictionaryDrawer.Texts.SelectFirstDuplicateButtonLabel + text = DictionaryDrawer.Texts.SelectFirstIgnoredButtonLabel }; - selectButton.AddToClassList(k_HelpBoxSelectDuplicateClass); - m_DuplicatesHelpBox.Add(selectButton); + selectButton.AddToClassList(k_HelpBoxSelectIgnoredClass); + m_IgnoredHelpBox.Add(selectButton); - parent.Add(m_DuplicatesHelpBox); + parent.Add(m_IgnoredHelpBox); } - void OnSelectFirstDuplicateClicked() + void OnSelectFirstIgnoredClicked() { - int firstDisplayIndex = DictionaryDrawer.FindFirstDuplicateDisplayIndex(m_DuplicateEntryIndices, m_SortedIndexMap); + int firstDisplayIndex = DictionaryDrawer.FindFirstIgnoredDisplayIndex(m_DuplicateEntryIndices, m_NullKeyEntryIndices, m_SortedIndexMap); if (firstDisplayIndex < 0) return; @@ -417,8 +582,10 @@ void OnSelectFirstDuplicateClicked() VisualElement BuildColumnHeader() { - var fieldInfo = ScriptAttributeUtility.GetFieldInfoAndStaticTypeFromProperty(m_DictionaryFieldProperty, out _); - DictionaryDrawer.GetHeaderLabels(fieldInfo, out var keyLabelText, out var valueLabelText, out var attributeFraction); + var fieldInfo = ScriptAttributeUtility.GetFieldInfoAndStaticTypeFromProperty(m_DictionaryFieldProperty, out var dictionaryType); + DictionaryDrawer.GetHeaderLabels(fieldInfo, dictionaryType, out var keyLabelText, out var valueLabelText, out var attributeFraction); + m_KeyLabelText = keyLabelText; + m_ValueLabelText = valueLabelText; var header = new VisualElement(); header.AddToClassList(k_ListHeaderClass); @@ -431,8 +598,8 @@ VisualElement BuildColumnHeader() // keyLabel is styled via .unity-dictionary-view__list-header__key > .unity-text-element // (ellipsis overflow + flex grow/shrink), so no inline styles needed here. - var keyLabel = new Label(keyLabelText); - m_KeyHeader.Add(keyLabel); + m_KeyHeaderLabel = new Label(keyLabelText); + m_KeyHeader.Add(m_KeyHeaderLabel); m_SortIndicator = new VisualElement(); m_SortIndicator.AddToClassList(MultiColumnHeaderColumnSortIndicator.ussClassName); @@ -449,7 +616,7 @@ VisualElement BuildColumnHeader() header.Add(m_KeyHeader); m_ColumnResizer = new ColumnResizer( - header, m_DictionaryFieldProperty.propertyPath, attributeFraction, UpdateColumnWidths); + header, m_DictionaryFieldProperty.propertyPath, attributeFraction, onDragged: OnColumnResizerDragged); header.Add(m_ColumnResizer.BuildElement()); header.RegisterCallback(evt => { @@ -459,6 +626,16 @@ VisualElement BuildColumnHeader() header.Add(m_ValueLabel); header.AddManipulator(new ContextualMenuManipulator(evt => { + // The three layouts form a radio group (the active one is checked), followed + // by a separator and the "Reset to Defaults" action. + AppendLayoutAction(evt.menu, DictionaryDrawer.Texts.TwoColumnsLayoutLabel, DictionaryLayout.TwoColumns); + AppendLayoutAction(evt.menu, DictionaryDrawer.Texts.OneColumnWithValueFoldoutLayoutLabel, DictionaryLayout.OneColumnWithValueFoldout); + AppendLayoutAction(evt.menu, DictionaryDrawer.Texts.OneColumnWithValueVisibleLayoutLabel, DictionaryLayout.OneColumnWithValueVisible); + evt.menu.AppendSeparator(); + evt.menu.AppendAction(DictionaryDrawer.Texts.ShowSerializedOrderLabel, + _ => DictionaryDrawer.SetShowSerializedOrder(!DictionaryDrawer.ShowSerializedOrder), + _ => DictionaryDrawer.ShowSerializedOrder ? DropdownMenuAction.Status.Checked : DropdownMenuAction.Status.Normal); + evt.menu.AppendSeparator(); evt.menu.AppendAction(DictionaryDrawer.Texts.ResetToDefaultsLabel, _ => ResetToDefaults(), _ => DictionaryDrawer.HasCachedState(m_StateCacheKey) ? DropdownMenuAction.Status.Normal : DropdownMenuAction.Status.Disabled); @@ -467,15 +644,41 @@ VisualElement BuildColumnHeader() return header; } - VisualElement MakeListItem() + // Strongly-typed list row. Caches its child references so BindListItem reads them + // directly instead of re-running Q<>() tree queries on every bind during scroll, and + // stores the display index in a typed field (no int->object boxing, which element.userData + // would incur). Two-column rows use DictionaryRow; one-column rows use the subclass below. + class DictionaryRow : VisualElement { - var container = new VisualElement(); - container.AddToClassList("unity-list-view__reorderable-item__container"); - container.AddToClassList(k_RowClass); - container.name = "dict-element"; + public int displayIndex = -1; + public VisualElement keyContainer; + public VisualElement valueContainer; + public VisualElement keyWarningIcon; + public PropertyField keyField; + public PropertyField valueField; + } - var duplicateKeyIcon = new VisualElement(); - duplicateKeyIcon.AddToClassList(k_DuplicateKeyIconClass); + // One-column foldout row: the value field lives in valueFoldout.contentContainer (parented + // once at build, never reparented per bind), so the foldout owns native show/hide and content + // indentation. Only the OneColumnWithValueFoldout layout uses this; the value-visible layout + // uses a plain DictionaryRow with a static header (see MakeOneColumnVisibleRow). + sealed class OneColumnDictionaryRow : DictionaryRow + { + public Foldout valueFoldout; + } + + // Builds the parts every row shares regardless of layout: the row container, the + // key warning icon, the key/value cell containers + their PropertyFields, the + // selection indicator, and the row pointer-down handler. The per-mode factory below + // decides how the value field is parented under valueContainer. + DictionaryRow BuildRowScaffold(DictionaryRow row) + { + row.AddToClassList("unity-list-view__reorderable-item__container"); + row.AddToClassList(k_RowClass); + row.name = "dict-element"; + + var keyWarningIcon = new VisualElement(); + keyWarningIcon.AddToClassList(k_KeyWarningIconClass); var keyContainer = new VisualElement { name = "key-container" }; keyContainer.AddToClassList(k_DrawerFieldClass); @@ -498,59 +701,158 @@ VisualElement MakeListItem() ApplyKeyColumnWidth(keyContainer); - // Build the row's two PropertyFields up-front and parent them under the - // key/value containers. They start unbound — BindListItem just calls - // BindProperty on them every time the row is reused, hitting the fast - // rebind path in PropertyField.ResetInternal so the child field tree is - // not torn down and rebuilt per row. + // Build the row's two PropertyFields up-front. They start unbound — BindListItem + // just calls BindProperty on them every time the row is reused, hitting the fast + // rebind path in PropertyField.ResetInternal so the child field tree is not torn + // down and rebuilt per row. var keyField = new PropertyField(property: null, label: string.Empty, showFirstFoldoutHeader: false); - var valueField = new PropertyField(property: null, label: string.Empty, showFirstFoldoutHeader: false); + // m_ValueFieldLabel is "Array"/"List" for collection value types, else string.Empty; an empty + // label lets PropertyField fall back to the value's "Value" displayName (e.g. leaf/struct cells). + var valueField = new PropertyField(property: null, label: m_ValueFieldLabel, showFirstFoldoutHeader: false); + keyContainer.Add(keyField); - valueContainer.Add(valueField); var selectionIndicator = new VisualElement(); selectionIndicator.AddToClassList(k_SelectionIndicatorClass); selectionIndicator.pickingMode = PickingMode.Ignore; - container.Add(duplicateKeyIcon); - container.Add(keyContainer); - container.Add(valueContainer); - container.Add(selectionIndicator); + row.Add(keyWarningIcon); + row.Add(keyContainer); + row.Add(valueContainer); + row.Add(selectionIndicator); + + row.RegisterCallback(OnRowPointerDown, TrickleDown.TrickleDown); + // Select the row when an object is dropped onto its key cell. Registered as + // TrickleDown so it runs before the key ObjectField's own DragPerform handler + // (which sets the value and stops propagation) — the drop then changes the key, + // which re-sorts and moves the row; because the entry is now selected, + // SortIfNeeded's RestoreSelectionByArrayIndices keeps it highlighted and scrolls + // it into view so it's clear where the entry went. + row.RegisterCallback(OnRowDragPerform, TrickleDown.TrickleDown); + + row.keyContainer = keyContainer; + row.valueContainer = valueContainer; + row.keyWarningIcon = keyWarningIcon; + row.keyField = keyField; + row.valueField = valueField; + return row; + } + + // Two-column template: key | value side by side. The value field sits directly in the + // value cell; USS + the resizer drive the column widths. + VisualElement MakeTwoColumnRow() + { + var row = BuildRowScaffold(new DictionaryRow()); + row.valueContainer.Add(row.valueField); + return row; + } + + // One-column foldout template (OneColumnWithValueFoldout): key stacked over value, with the + // value field inside a per-row collapsible "Value" foldout whose content the foldout drives + // native show/hide + indentation for. ConfigureOneColumnRow restores each row's expansion + // state per bind. + VisualElement MakeOneColumnFoldoutRow() + { + var row = (OneColumnDictionaryRow)BuildRowScaffold(new OneColumnDictionaryRow()); + row.AddToClassList(k_RowOneColumnClass); + + var foldout = new Foldout { name = "row-foldout" }; + foldout.AddToClassList(k_RowFoldoutClass); + // Foldout.text adds the .unity-foldout__text class (which the bold USS keys on) to + // the toggle label; set it once here since the value label is stable per view. + foldout.text = m_ValueLabelText; + foldout.RegisterValueChangedCallback(evt => + { + // Ignore ChangeEvent bubbling up from toggles inside the value field. + if (evt.target == foldout) + OnRowFoldoutToggled(row, evt.newValue); + }); + foldout.contentContainer.Add(row.valueField); + + row.valueContainer.Add(foldout); + row.valueFoldout = foldout; + return row; + } + + // One-column static template (OneColumnWithValueVisible): key stacked over value, with the + // value always shown beneath a plain bold "Value" label. The header is a Label, not a foldout + // toggle, so it is inherently non-interactive — clicking it does nothing. Unlike the foldout + // variant the value sits directly under the header with no extra indentation. + VisualElement MakeOneColumnVisibleRow() + { + var row = BuildRowScaffold(new DictionaryRow()); + row.AddToClassList(k_RowOneColumnClass); - container.RegisterCallback(OnRowPointerDown, TrickleDown.TrickleDown); + var valueHeader = new Label(m_ValueLabelText); + valueHeader.AddToClassList(k_RowValueHeaderClass); - return container; + row.valueContainer.Add(valueHeader); + row.valueContainer.Add(row.valueField); + return row; } void BindListItem(VisualElement element, int displayIndex) { using var _ = s_BindListItemMarker.Auto(); - if (!TryGetArrayIndexForBinding(element, displayIndex, out int arrayIndex)) + var row = (DictionaryRow)element; + if (!TryGetArrayIndexForBinding(row, displayIndex, out int arrayIndex)) return; - ApplyAlternatingBackground(element, displayIndex); + ApplyAlternatingBackground(row, displayIndex); var arrayElement = m_ArrayProperty.GetArrayElementAtIndex(arrayIndex); DictionaryDrawer.GetKeyAndValueProperties(arrayElement, out var keyProp, out var valueProp); - var keyContainer = element.Q("key-container"); - var valueContainer = element.Q("value-container"); - ApplyKeyColumnWidth(keyContainer); + ApplyKeyColumnWidth(row.keyContainer); - // The row's PropertyFields were created in MakeListItem; rebinding flips + // The row's PropertyFields were created in the makeItem factory; rebinding flips // their target property without recreating the child field tree. When the - // key/value lookup unexpectedly returns null (e.g. corrupted entry), the - // stale binding is dropped via Unbind() instead of leaving the previous - // row's data visible. - RebindCellField(keyContainer, keyProp); - UpdateDuplicateKeyIconVisibility(element, arrayIndex); - RebindCellField(valueContainer, valueProp); + // key/value lookup unexpectedly returns null (e.g. corrupted entry), the stale + // binding is dropped via Unbind() instead of leaving the previous row's data visible. + RebindCellField(row.keyField, keyProp); + UpdateKeyWarningIconVisibility(row, arrayIndex); + RebindCellField(row.valueField, valueProp); + // Reassert the collection label on reused rows: the label persists across rebinds, but a + // pooled row may have been built for a different value type (the makeItem delegate compares + // equal, so the pool isn't rebuilt on rebind). The setter no-ops when already correct. + row.valueField.label = m_ValueFieldLabel; + + if (row is OneColumnDictionaryRow oneColumnRow) + ConfigureOneColumnRow(oneColumnRow, valueProp); } - static void RebindCellField(VisualElement container, SerializedProperty property) + // Restores a one-column foldout row's expansion state on bind. The value field's visibility + // and indentation are owned by the foldout natively; this just syncs the foldout to the + // persisted per-row expansion state. Only called for OneColumnWithValueFoldout rows — the + // value-visible layout uses a static, always-shown header with no per-bind state. + void ConfigureOneColumnRow(OneColumnDictionaryRow row, SerializedProperty valueProp) + { + bool expanded = valueProp != null && valueProp.isExpanded; + row.valueFoldout.SetValueWithoutNotify(expanded); + } + + // Foldout toggle handler for one-column rows: persists the new expansion state on the + // value property (so it survives rebuilds/domain reloads). The foldout shows/hides its + // content (the value field) natively; DynamicHeight virtualization re-measures the row + // on the resulting layout change. + void OnRowFoldoutToggled(OneColumnDictionaryRow row, bool expanded) + { + if (m_ArrayProperty == null || !m_ArrayProperty.isValid) + return; + int displayIndex = row.displayIndex; + if (displayIndex < 0 || displayIndex >= displayedItemCount) + return; + + int arrayIndex = DisplayToArrayIndex(displayIndex); + var arrayElement = m_ArrayProperty.GetArrayElementAtIndex(arrayIndex); + DictionaryDrawer.GetKeyAndValueProperties(arrayElement, out _, out var valueProp); + if (valueProp != null) + valueProp.isExpanded = expanded; + } + + static void RebindCellField(PropertyField field, SerializedProperty property) { - var field = container.Q(); if (field == null) return; if (property != null) @@ -567,14 +869,9 @@ void UnbindListItem(VisualElement element, int displayIndex) static void UnbindRowPropertyFields(VisualElement element) { - UnbindCellField(element.Q("key-container")); - UnbindCellField(element.Q("value-container")); - } - - static void UnbindCellField(VisualElement container) - { - var field = container?.Q(); - field?.Unbind(); + var row = (DictionaryRow)element; + row.keyField?.Unbind(); + row.valueField?.Unbind(); } static void DestroyListItem(VisualElement element) @@ -582,13 +879,13 @@ static void DestroyListItem(VisualElement element) UnbindRowPropertyFields(element); } - bool TryGetArrayIndexForBinding(VisualElement element, int displayIndex, out int arrayIndex) + bool TryGetArrayIndexForBinding(DictionaryRow row, int displayIndex, out int arrayIndex) { arrayIndex = -1; if (displayIndex < 0 || displayIndex >= displayedItemCount) return false; - element.userData = displayIndex; + row.displayIndex = displayIndex; arrayIndex = DisplayToArrayIndex(displayIndex); return true; } @@ -599,6 +896,16 @@ bool TryGetArrayIndexForBinding(VisualElement element, int displayIndex, out int // alignment — is defined statically in UnityEngine.UIElements.uss. void ApplyKeyColumnWidth(VisualElement keyContainer) { + // In one-column mode the key spans the full row width (driven by USS on + // the --one-column modifier); pushing a pixel width here would fight that, + // and the geometry-change loop must not re-stamp stale widths onto rows. + if (m_OneColumnMode) + { + keyContainer.style.width = StyleKeyword.Null; + keyContainer.style.flexBasis = StyleKeyword.Null; + return; + } + float keyWidth = GetHeaderSplitLineX(); keyContainer.style.flexBasis = keyWidth; keyContainer.style.width = keyWidth; @@ -676,8 +983,9 @@ void FocusContentContainer() void OnRowPointerDown(PointerDownEvent evt) { - if (!(evt.currentTarget is VisualElement row) || !(row.userData is int displayIndex)) + if (!(evt.currentTarget is DictionaryRow row) || row.displayIndex < 0) return; + int displayIndex = row.displayIndex; var selectionSnapshot = new HashSet(SelectedDisplayIndices); schedule.Execute(() => @@ -687,12 +995,42 @@ void OnRowPointerDown(PointerDownEvent evt) }); } + // Selects the entry a drag-and-drop is landing on, but only when the drop targets the + // key cell — a key change is what re-sorts and relocates the row, so selecting it lets + // the deferred SortIfNeeded restore the selection to (and scroll to) the entry's new + // position. Value-cell drops don't re-sort, so we leave them to the field alone. This + // runs during trickle-down, before the key ObjectField's own handler sets the value and + // stops propagation, so the selection is in place before the resulting sort is scheduled. + void OnRowDragPerform(DragPerformEvent evt) + { + if (!(evt.currentTarget is DictionaryRow row) || row.displayIndex < 0) + return; + + if (!(evt.target is VisualElement target) || !IsInSubtree(target, row.keyContainer)) + return; + + SetSelection(row.displayIndex); + } + + static bool IsInSubtree(VisualElement element, VisualElement ancestor) + { + if (element == null || ancestor == null) + return false; + return element == ancestor || element.FindCommonAncestor(ancestor) == ancestor; + } + // Header columns mirror what ApplyKeyColumnWidth does for row cells: only // the resizer-driven width is dynamic, so we just push it onto flex-basis // and width on both header columns. All other layout (flex grow/shrink, // min-width, padding-left/right around the resizer) lives in USS. void ApplyHeaderColumnLayout() { + // One-column mode owns the header layout via ApplyHeaderLayoutMode (key + // header spans full width, value label + resizer hidden); skip the + // two-column width math so geometry-change callbacks don't fight it. + if (m_OneColumnMode) + return; + if (m_ColumnResizer == null || m_KeyHeader == null || m_ValueLabel == null || m_ListHeader == null) return; @@ -723,14 +1061,41 @@ void UpdateColumnWidths() if (scrollView?.contentContainer == null) return; - foreach (var row in scrollView.contentContainer.Children()) + foreach (var wrapper in scrollView.contentContainer.Children()) + { + if (wrapper.Q() is { } row) + ApplyKeyColumnWidth(row.keyContainer); + } + } + + void OnColumnResizerDragged() + { + if (m_ColumnResizer == null) + return; + UpdateColumnWidths(); + PropagateColumnFractionToLinkedViews(m_ColumnResizer.KeyColumnFraction); + } + + void PropagateColumnFractionToLinkedViews(float fraction) + { + if (!m_IsLinked || !s_LinkedViews.TryGetValue(m_StateCacheKey, out var list) || list.Count <= 1) + return; + + foreach (var view in list) { - var keyContainer = row.Q("key-container"); - if (keyContainer != null) - ApplyKeyColumnWidth(keyContainer); + if (view != this) + view.SyncColumnFractionFromSibling(fraction); } } + void SyncColumnFractionFromSibling(float fraction) + { + if (m_ColumnResizer == null) + return; + m_ColumnResizer.SetKeyColumnFraction(fraction); + UpdateColumnWidths(); + } + // Marks that the keys may have changed and queues a single deferred pass. // Calling this again before the pass runs has no extra effect. The actual // decision (hash check + interaction gate) lives in SortIfNeeded, which @@ -755,7 +1120,7 @@ void ScheduleSort(long delayMs) // If the keys did not actually change we don't want to pay the // cost of sorting as this can be expensive for a large dictionary // If the order matches what we already have we skip the (expensive) ListView rebuild and only refresh the - // duplicate markers, which is much cheaper for large dictionaries. + // key warning markers, which is much cheaper for large dictionaries. void SortIfNeeded() { // If the user is interacting with the Editor we wait sorting to prevent disrupting the workflow @@ -776,10 +1141,10 @@ void SortIfNeeded() if (m_SortedIndexMap.DisplayOrderEquals(updatedSortedIndexMap)) { - if (UpdateDuplicateIndicesOnly()) + if (UpdateMarkerIndicesOnly()) { UpdateHeaderInfo(); - UpdateDuplicateKeyIconsOnVisibleItems(); + UpdateKeyWarningIconsOnVisibleItems(); } return; } @@ -791,44 +1156,197 @@ void SortIfNeeded() FocusContentContainer(); } + // Single entry point for every layout change from the context menu. Persists the + // user's choice (layoutSetByUser) so it wins over the attribute default, then runs + // ApplyLayoutMode, which rebuilds the row pool with the new layout's template. The + // rebuild only ever recreates the handful of virtualized visible rows, so it scales + // to large dictionaries. + void AppendLayoutAction(DropdownMenu menu, string label, DictionaryLayout layout) + { + menu.AppendAction(label, + _ => SetLayout(layout), + _ => m_Layout == layout ? DropdownMenuAction.Status.Checked : DropdownMenuAction.Status.Normal); + } + + void SetLayout(DictionaryLayout layout) + { + if (m_Layout == layout) + return; + ApplyLayout(layout); + DictionaryDrawer.UpdateCachedState(m_StateCacheKey, state => + { + state.layout = layout; + state.layoutSetByUser = true; + }); + PerformActionOnLinkedViews(v => v.ApplyLayout(layout)); + } + + void ApplyLayout(DictionaryLayout layout) + { + if (!m_IsBound || m_Layout == layout) + return; + m_Layout = layout; + ApplyLayoutMode(); + } + + // Installs the row template (makeItem) for the current layout without rebuilding. + // Called before the initial row build in RebuildFromProperty; ApplyLayoutMode owns the + // rebuild when the layout (and thus the template) changes at runtime. + void SyncMakeItemToLayout() + { + m_MakeItemLayout = m_Layout; + makeItem = m_Layout switch + { + DictionaryLayout.OneColumnWithValueFoldout => MakeOneColumnFoldoutRow, + DictionaryLayout.OneColumnWithValueVisible => MakeOneColumnVisibleRow, + _ => MakeTwoColumnRow, + }; + } + + // Applies the layout mode. Each layout uses a distinct row template (two-column, one-column + // foldout, one-column static value), so any layout change swaps makeItem and rebuilds the + // pool — heavier than an in-place rebind, but a rare user-initiated switch, and virtualization + // means the pool only ever holds the handful of visible rows. The sorted-index map, property + // tracking, and ignored-key state are layout-independent. + // Pass refresh: false when a list reload already runs alongside this call, so the visible rows + // aren't rebuilt/rebound twice in a single layout change. Returns whether the template changed, + // so a caller that deferred the refresh (refresh: false) knows it must Rebuild() rather than + // RefreshItems() — the stale row pool is the wrong template. + bool ApplyLayoutMode(bool refresh = true) + { + EnableInClassList(k_OneColumnModeClass, m_OneColumnMode); + ApplyHeaderLayoutMode(); + + bool templateChanged = m_MakeItemLayout != m_Layout; + if (templateChanged) + { + SyncMakeItemToLayout(); + if (refresh) + Rebuild(); + } + else if (refresh) + { + RefreshItems(); + } + return templateChanged; + } + + // The slim one-column header (value label + resizer hidden, key header spanning + // full width) is driven by USS on the list header's --one-column modifier. C# only has to + // clear the resizer-driven inline widths that ApplyHeaderColumnLayout stamps on + // the key/value header in two-column mode — otherwise those inline widths would + // outrank the USS flex-grow and pin the slim header to a stale column width. + // Returning to two-column recomputes them. + void ApplyHeaderLayoutMode() + { + if (m_ListHeader == null) + return; + + // Per-view marker that scopes the slim-header USS to this header only (see + // k_ListHeaderOneColumnClass) instead of the leaky view-root --one-column class. + m_ListHeader.EnableInClassList(k_ListHeaderOneColumnClass, m_OneColumnMode); + + // The single header column spans both key and value in one-column mode, so it + // reads "Key & Value" (honoring any DictionaryDisplayAttribute label overrides). + if (m_KeyHeaderLabel != null) + m_KeyHeaderLabel.text = m_OneColumnMode + ? DictionaryDrawer.Texts.GetOneColumnHeaderLabel(m_KeyLabelText, m_ValueLabelText) + : m_KeyLabelText; + + if (m_OneColumnMode) + { + if (m_KeyHeader != null) + { + m_KeyHeader.style.width = StyleKeyword.Null; + m_KeyHeader.style.flexBasis = StyleKeyword.Null; + } + if (m_ValueLabel != null) + { + m_ValueLabel.style.width = StyleKeyword.Null; + m_ValueLabel.style.flexBasis = StyleKeyword.Null; + } + } + else + { + ApplyHeaderColumnLayout(); + } + } + void OnKeyHeaderClicked(ClickEvent evt) { - m_SortAscending = !m_SortAscending; + if (DictionaryDrawer.ShowSerializedOrder) + return; + + bool ascending = !m_SortAscending; + ApplySortAscending(ascending); + DictionaryDrawer.UpdateCachedState(m_StateCacheKey, state => state.sortAscending = ascending); + PerformActionOnLinkedViews(v => v.ApplySortAscending(ascending)); + } + + void ApplySortAscending(bool ascending) + { + if (!m_IsBound || m_SortAscending == ascending) + return; + m_SortAscending = ascending; UpdateSortIndicatorClass(); - DictionaryDrawer.UpdateCachedState(m_StateCacheKey, state => state.sortAscending = m_SortAscending); RebuildSortedIndicesAndRefresh(); } void ResetToDefaults() { DictionaryDrawer.ClearCachedState(m_StateCacheKey); + ApplyResetToDefaults(); + PerformActionOnLinkedViews(v => v.ApplyResetToDefaults()); + } + + void ApplyResetToDefaults() + { + if (!m_IsBound) + return; m_SortAscending = true; UpdateSortIndicatorClass(); + // Cache was just cleared, so the active layout reverts to the attribute default. + m_Layout = m_AttributeLayout; + // RebuildSortedIndicesAndRefresh() below performs the single binding pass. When the + // reset changes the template, that pass must Rebuild() the row pool: a plain + // RefreshItems() would rebind the recycled rows of the previous template (e.g. stacked + // one-column rows) without recreating them, corrupting the layout. + bool templateChanged = ApplyLayoutMode(refresh: false); + m_ColumnResizer?.ResetToDefaultFraction(); - RebuildSortedIndicesAndRefresh(); + UpdateColumnWidths(); + RebuildSortedIndicesAndRefresh(rebuild: templateChanged); } void UpdateSortIndicatorClass() { + if (DictionaryDrawer.ShowSerializedOrder) + { + m_KeyHeader.EnableInClassList(MultiColumnHeaderColumn.sortedAscendingUssClassName, false); + m_KeyHeader.EnableInClassList(MultiColumnHeaderColumn.sortedDescendingUssClassName, false); + return; + } + m_KeyHeader.EnableInClassList(MultiColumnHeaderColumn.sortedAscendingUssClassName, m_SortAscending); m_KeyHeader.EnableInClassList(MultiColumnHeaderColumn.sortedDescendingUssClassName, !m_SortAscending); } - void UpdateDuplicateKeyIconVisibility(VisualElement rowElement, int arrayIndex) + void UpdateKeyWarningIconVisibility(DictionaryRow row, int arrayIndex) { - var icon = rowElement?.Q(className: k_DuplicateKeyIconClass); + var icon = row?.keyWarningIcon; if (icon == null) return; - if (DictionaryKeyUtility.GetMarkerKind(arrayIndex, m_DuplicateEntryIndices) != DictionaryKeyUtility.KeyMarkerKind.None) + var markerKind = DictionaryKeyUtility.GetMarkerKind(arrayIndex, m_DuplicateEntryIndices, m_NullKeyEntryIndices); + if (markerKind != DictionaryKeyUtility.KeyMarkerKind.None) { // Icon, size, and top-offset all live in USS on // .unity-dictionary-view__duplicate-key-icon; we only flip display + tooltip - // from C# based on duplicate-state. + // from C# based on the marker kind. icon.style.display = DisplayStyle.Flex; - icon.tooltip = DictionaryDrawer.Texts.DuplicateMarkerTooltip; + icon.tooltip = DictionaryKeyUtility.GetMarkerTooltip(markerKind); } else { @@ -839,17 +1357,17 @@ void UpdateDuplicateKeyIconVisibility(VisualElement rowElement, int arrayIndex) // Thin wrapper around the shared in-place refresh so call sites stay // self-documenting at the UITK layer. - bool UpdateDuplicateIndicesOnly() - => DictionaryDrawer.TryRefreshDuplicateIndicesInto(m_DictionaryFieldProperty, m_DuplicateEntryIndices); + bool UpdateMarkerIndicesOnly() + => DictionaryDrawer.TryRefreshDuplicateAndNullKeyIndicesInto( + m_DictionaryFieldProperty, m_DuplicateEntryIndices, m_NullKeyEntryIndices); - void UpdateDuplicateKeyIconsOnVisibleItems() + void UpdateKeyWarningIconsOnVisibleItems() { var content = scrollView.contentContainer; foreach (var wrapper in content.Children()) { - var dictElement = wrapper.Q("dict-element"); - if (dictElement != null && dictElement.userData is int displayIndex && displayIndex < displayedItemCount) - UpdateDuplicateKeyIconVisibility(dictElement, DisplayToArrayIndex(displayIndex)); + if (wrapper.Q() is { } row && row.displayIndex >= 0 && row.displayIndex < displayedItemCount) + UpdateKeyWarningIconVisibility(row, DisplayToArrayIndex(row.displayIndex)); } } @@ -898,7 +1416,6 @@ void OnAddClicked() schedule.Execute(() => ScrollToItem(newDisplayIndex)); UpdateHeaderInfo(); - UpdateRemoveButtonState(); } void RebuildSortedIndices() @@ -907,11 +1424,13 @@ void RebuildSortedIndices() m_HashOfKeys = DictionaryDrawer.GetKeysContentHash(m_ArrayProperty); } - void RebuildSortedIndicesAndRefresh() + // Pass rebuild: true when the row template changed (one-column <-> two-column axis flip), + // so the binding pass recreates the row pool instead of rebinding stale rows in place. + void RebuildSortedIndicesAndRefresh(bool rebuild = false) { var selectedArrayIndices = GetSelectedArrayIndices(); RebuildSortedIndices(); - RefreshListView(); + RefreshListView(rebuild); RestoreSelectionByArrayIndices(selectedArrayIndices); FocusContentContainer(); } @@ -951,13 +1470,18 @@ void RestoreSelectionByArrayIndices(List arrayIndices) } } - void RefreshListView() + void RefreshListView(bool rebuild = false) { - UpdateDuplicateIndicesOnly(); + UpdateMarkerIndicesOnly(); UpdateHeaderInfo(); UpdateListViewItemsSource(displayedItemCount); - RefreshItems(); + // Rebuild() recreates the row pool from the current makeItem; RefreshItems() only + // rebinds the existing rows. A template-axis flip needs the former (see ApplyLayoutMode). + if (rebuild) + Rebuild(); + else + RefreshItems(); } // Keeps m_ItemsSource sized to arraySize and wired up as the @@ -982,25 +1506,35 @@ void UpdateHeaderInfo() { int itemCount = m_ArrayProperty.arraySize; int duplicateCount = m_DuplicateEntryIndices.Count; - bool hasDuplicates = duplicateCount > 0; + int nullKeyCount = m_NullKeyEntryIndices.Count; + int ignoredCount = duplicateCount + nullKeyCount; + bool hasIgnored = ignoredCount > 0; if (m_HeaderInfoLabel != null) { - string text = DictionaryDrawer.Texts.GetItemCountText(itemCount); - if (hasDuplicates) - text += DictionaryDrawer.Texts.GetDuplicateCountText(duplicateCount); + string text; + if (DictionaryDrawer.ShowSerializedOrder) + { + text = DictionaryDrawer.Texts.ShowingSerializedOrderInfoLabel; + } + else + { + text = DictionaryDrawer.Texts.GetItemCountText(itemCount); + if (hasIgnored) + text += DictionaryDrawer.Texts.GetIgnoredCountText(ignoredCount); + } m_HeaderInfoLabel.text = text; } - if (m_DuplicatesHelpBox != null) + if (m_IgnoredHelpBox != null) { - if (hasDuplicates) + if (hasIgnored) { - m_DuplicatesHelpBox.text = DictionaryDrawer.Texts.GetDuplicatesHelpBoxText(duplicateCount); - m_DuplicatesHelpBox.style.display = DisplayStyle.Flex; + m_IgnoredHelpBox.text = DictionaryDrawer.Texts.GetIgnoredHelpBoxText(duplicateCount, nullKeyCount); + m_IgnoredHelpBox.style.display = DisplayStyle.Flex; } else { - m_DuplicatesHelpBox.style.display = DisplayStyle.None; + m_IgnoredHelpBox.style.display = DisplayStyle.None; } } } @@ -1010,8 +1544,9 @@ void OnRemoveClicked() var selected = SelectedDisplayIndices; int newSelectedDisplayIndex = selected.Count == 1 ? selected[0] : -1; - bool removed = DictionaryDrawer.RemoveEntriesAtDisplayIndices( - m_ArrayProperty, selected, m_SortedIndexMap); + var removed = selected.Count > 0 ? + DictionaryDrawer.RemoveEntriesAtDisplayIndices(m_ArrayProperty, selected, m_SortedIndexMap) : + DictionaryDrawer.RemoveEntryAtDisplayIndex(m_ArrayProperty, m_ArrayProperty.arraySize - 1, m_SortedIndexMap); if (!removed) return; @@ -1026,15 +1561,6 @@ void OnRemoveClicked() SetSelection(Mathf.Min(newSelectedDisplayIndex, newSize - 1)); FocusContentContainer(); - UpdateRemoveButtonState(); - } - - void UpdateRemoveButtonState() - { - // BaseListView.allowRemove drives the built-in remove button's enabled - // state through UpdateRemoveButton(), which honors both this flag and - // the item count — same UX as the previous hand-built footer. - allowRemove = SelectedDisplayIndices.Count > 0; } /// @@ -1051,16 +1577,9 @@ sealed class ColumnResizer // here, for the drag-start width calculation. const float k_ResizerLineWidth = 1f; - // In-memory, editor-process lifetime. Key: same Hash128 as s_StateCache (normalized path). - // Holds the set of ColumnResizer instances across which fraction changes propagate. - // Eviction: per-resizer in OnDetachFromPanel; entry removed when its list empties. - // Registration is gated on m_IsPartOfList, so top-level dictionaries never enter this table. - static readonly Dictionary> s_LinkedResizers = new(); - readonly VisualElement m_Header; readonly Hash128 m_StateCacheKey; - readonly Action m_OnFractionChanged; - readonly bool m_IsPartOfList; + readonly Action m_OnDragged; readonly float m_AttributeFraction; float m_KeyColumnFraction; @@ -1071,11 +1590,10 @@ sealed class ColumnResizer public float KeyColumnFraction => m_KeyColumnFraction; - public ColumnResizer(VisualElement header, string propertyPath, float attributeFraction, Action onFractionChanged) + public ColumnResizer(VisualElement header, string propertyPath, float attributeFraction, Action onDragged) { m_Header = header; - m_OnFractionChanged = onFractionChanged; - m_IsPartOfList = Regex.IsMatch(propertyPath, @"\[\d+\]"); + m_OnDragged = onDragged; m_AttributeFraction = attributeFraction; m_StateCacheKey = DictionaryDrawer.ComputeStateCacheKey(propertyPath); m_KeyColumnFraction = DictionaryDrawer.GetActiveKeyColumnFraction(m_StateCacheKey, attributeFraction); @@ -1099,55 +1617,17 @@ public VisualElement BuildElement() interaction.RegisterCallback(OnPointerUp); interaction.RegisterCallback(OnPointerCaptureOut); - if (m_IsPartOfList) - { - line.RegisterCallback(OnAttachToPanel); - line.RegisterCallback(OnDetachFromPanel); - } - return line; } - public void ResetToDefaultFraction() + public void SetKeyColumnFraction(float fraction) { - m_KeyColumnFraction = m_AttributeFraction; - m_OnFractionChanged?.Invoke(); - if (m_IsPartOfList) - NotifyLinkedResizers(); + m_KeyColumnFraction = fraction; } - void OnAttachToPanel(AttachToPanelEvent evt) - { - if (!s_LinkedResizers.TryGetValue(m_StateCacheKey, out var list)) - { - list = new List(); - s_LinkedResizers[m_StateCacheKey] = list; - } - list.Add(this); - } - - void OnDetachFromPanel(DetachFromPanelEvent evt) - { - if (s_LinkedResizers.TryGetValue(m_StateCacheKey, out var list)) - { - list.Remove(this); - if (list.Count == 0) - s_LinkedResizers.Remove(m_StateCacheKey); - } - } - - void NotifyLinkedResizers() + public void ResetToDefaultFraction() { - if (!s_LinkedResizers.TryGetValue(m_StateCacheKey, out var list) || list.Count <= 1) - return; - - foreach (var resizer in list) - { - if (resizer == this) - continue; - resizer.m_KeyColumnFraction = m_KeyColumnFraction; - resizer.m_OnFractionChanged?.Invoke(); - } + m_KeyColumnFraction = m_AttributeFraction; } void OnPointerDown(PointerDownEvent evt) @@ -1172,9 +1652,7 @@ void OnPointerMove(PointerMoveEvent evt) float deltaX = evt.position.x - m_DragStartX; float newFraction = m_DragStartFraction + deltaX / m_DragStartHeaderContentWidth; m_KeyColumnFraction = DictionaryDrawer.ClampDraggedKeyColumnFraction(newFraction, m_DragStartHeaderContentWidth); - m_OnFractionChanged?.Invoke(); - if (m_IsPartOfList) - NotifyLinkedResizers(); + m_OnDragged?.Invoke(); evt.StopPropagation(); } diff --git a/Editor/Mono/GUI/TreeView/TreeViewControl/TreeViewOld.cs b/Editor/Mono/GUI/TreeView/TreeViewControl/TreeViewOld.cs index 3a29e3bc0a..1002195d6c 100644 --- a/Editor/Mono/GUI/TreeView/TreeViewControl/TreeViewOld.cs +++ b/Editor/Mono/GUI/TreeView/TreeViewControl/TreeViewOld.cs @@ -9,7 +9,7 @@ namespace UnityEditor.IMGUI.Controls; -[Obsolete("TreeView is now deprecated. You can likely now use TreeView instead and not think more about it. But if you were using that identifier to store InstanceID data, you should instead opt to upgrade your TreeViews to use TreeView to get the proper typing.", true)] +[Obsolete($"TreeView is now deprecated. You can likely now use TreeView instead and not think more about it. But if you were using that identifier to store {nameof(EntityId)} data, you should instead opt to upgrade your TreeViews to use TreeView<{nameof(EntityId)}> to get the proper typing.", true)] public abstract partial class TreeView : TreeViewInternal { protected TreeView(TreeViewState state) @@ -176,7 +176,7 @@ public static implicit operator CanStartDragArgs(TreeView.CanStartDragArgs protected static bool IsChildListForACollapsedParent(IList childList) => TreeView.IsChildListForACollapsedParent(childList.ToGenericIList()); } -[Obsolete("TreeView is now deprecated. You can likely now use TreeView instead and not think more about it. But if you were using that identifier to store InstanceID data, you should instead opt to upgrade your TreeViews to use TreeView to get the proper typing.", true)] +[Obsolete($"TreeView is now deprecated. You can likely now use TreeView instead and not think more about it. But if you were using that identifier to store {nameof(EntityId)} data, you should instead opt to upgrade your TreeViews to use TreeView<{nameof(EntityId)}> to get the proper typing.", true)] public abstract class TreeViewInternal : TreeView { protected TreeViewInternal(TreeViewState state) @@ -231,7 +231,7 @@ public GenericTreeViewItemToNonGenericTreeViewItemIList(IList> } } -[Obsolete("TreeViewItem is now deprecated. You can likely now use TreeViewItem instead and not think more about it. But if you were using that identifier to store InstanceID data, you should instead opt to upgrade your TreeViews to use TreeViewItem to get the proper typing.", true)] +[Obsolete($"TreeViewItem is now deprecated. You can likely now use TreeViewItem instead and not think more about it. But if you were using that identifier to store {nameof(EntityId)} data, you should instead opt to upgrade your TreeViews to use TreeViewItem<{nameof(EntityId)}> to get the proper typing.", true)] public class TreeViewItem : TreeViewItem { public new virtual List children @@ -269,7 +269,7 @@ public TreeViewItem(int id, int depth, string displayName) : base(id, depth, dis internal TreeViewItem(int id, int depth, TreeViewItem parent, string displayName) : base(id, depth, parent, displayName) { } } -[Obsolete("TreeViewState is now deprecated. You can likely now use TreeViewState instead and not think more about it. But if you were using that identifier to store InstanceID data, you should instead opt to upgrade your TreeViews to use TreeViewState to get the proper typing.", true)] +[Obsolete($"TreeViewState is now deprecated. You can likely now use TreeViewState instead and not think more about it. But if you were using that identifier to store {nameof(EntityId)} data, you should instead opt to upgrade your TreeViews to use TreeViewState<{nameof(EntityId)}> to get the proper typing.", true)] public class TreeViewState : TreeViewState {} [Obsolete] diff --git a/Editor/Mono/GUI/WindowLayout.cs b/Editor/Mono/GUI/WindowLayout.cs index fce60fdacb..79f6d02268 100644 --- a/Editor/Mono/GUI/WindowLayout.cs +++ b/Editor/Mono/GUI/WindowLayout.cs @@ -1765,10 +1765,10 @@ static void ShowInspector() [MenuItem("Window/General/Hierarchy %4", false, 4)] static void ShowNewHierarchy() { - if (HierarchyPreferences.UseNewHierarchy) - EditorWindow.GetWindow(HierarchyPreferences.HierarchyV2WindowType); - else + if (EditorSettings.useLegacyHierarchy) EditorWindow.GetWindow(); + else + EditorWindow.GetWindow(HierarchyPreferences.HierarchyV2WindowType); } [MenuItem("Window/General/Project %5", false, 5)] diff --git a/Editor/Mono/GUIView.bindings.cs b/Editor/Mono/GUIView.bindings.cs index e4d752579d..03cb28b263 100644 --- a/Editor/Mono/GUIView.bindings.cs +++ b/Editor/Mono/GUIView.bindings.cs @@ -153,6 +153,16 @@ internal extern bool hdrActive [UnityMarshalThisAs(NativeType.Custom, CustomMarshaller = typeof(NativeHandleMarshaller))] internal extern void GrabPixels(RenderTexture rd, Rect rect); + // Null until the window has rendered once. + [NativeMethod("MonoGUIView::GetAuxBackBufferTexture", HasExplicitThis = true)] + [UnityMarshalThisAs(NativeType.Custom, CustomMarshaller = typeof(NativeHandleMarshaller))] + internal extern RenderTexture GetAuxBackBufferTexture(); + + // True when the aux back buffer's texel row 0 is the top of the window. + [NativeMethod("MonoGUIView::GetAuxBackBufferTextureIsTopOrigin", HasExplicitThis = true)] + [UnityMarshalThisAs(NativeType.Custom, CustomMarshaller = typeof(NativeHandleMarshaller))] + internal extern bool GetAuxBackBufferTextureIsTopOrigin(); + [NativeMethod("MonoGUIView::GetBackingScaleFactor", HasExplicitThis = true)] [UnityMarshalThisAs(NativeType.Custom, CustomMarshaller = typeof(NativeHandleMarshaller))] internal extern float GetBackingScaleFactor(); diff --git a/Editor/Mono/GameView/StatsField.cs b/Editor/Mono/GameView/StatsField.cs index e62f93d41a..6fa7bd43b7 100644 --- a/Editor/Mono/GameView/StatsField.cs +++ b/Editor/Mono/GameView/StatsField.cs @@ -109,8 +109,8 @@ public StatsData() [CreateProperty] public string hybridBatcherDrawInfo => $"{UnityStats.hybridBatcherDrawCalls} draw calls ({UnityStats.hybridBatcherInstances} instances)"; [CreateProperty] public string standardDrawInfo => $"{UnityStats.standardDrawCalls} draw calls ({UnityStats.standardInstances} instances)"; [CreateProperty] public string standardInstancedDrawInfo => $"{UnityStats.standardInstancedDrawCalls} draw calls ({UnityStats.standardInstancedInstances} instances)"; - [CreateProperty] public string triangles => FormatCounts(UnityStats.triangles); - [CreateProperty] public string vertices => FormatCounts(UnityStats.vertices); + [CreateProperty] public string triangles => FormatCounts(UnityStats.trianglesLong); + [CreateProperty] public string vertices => FormatCounts(UnityStats.verticesLong); [CreateProperty] public string desiredTextureMemory => $"{Texture.desiredTextureMemory * k_BytesToMegabytes:F1} MB"; // UnityStats @@ -144,11 +144,15 @@ public StatsData() [CreateProperty] public int animatorComponentsPlaying => UnityStats.animatorComponentsPlaying; - private string FormatCounts(int value) + private string FormatCounts(long value) { - if (value >= 1000) + if (value >= 1_000_000) { - return $"{value / 1000.0f:F1}k"; + return $"{value / 1_000_000.0f:F1}M"; + } + else if (value >= 1_000) + { + return $"{value / 1_000.0f:F1}k"; } return value.ToString(); } diff --git a/Editor/Mono/GenerateIconsWithMipLevels.cs b/Editor/Mono/GenerateIconsWithMipLevels.cs index 08db889e4f..15e62a8931 100644 --- a/Editor/Mono/GenerateIconsWithMipLevels.cs +++ b/Editor/Mono/GenerateIconsWithMipLevels.cs @@ -309,7 +309,15 @@ private static bool BlitMip(Texture2D iconWithMips, List sortedTextur Texture2D tex = sortedTextures[mipLevel]; if (tex) { - Blit(tex, iconWithMips, mipLevel); + try + { + Blit(tex, iconWithMips, mipLevel); + } + catch(Exception ex) + { + Debug.LogException(new Exception("Failed to blit mip level: " + mipLevel + " for texture: " + tex.name, ex)); + return false; + } return true; } else diff --git a/Editor/Mono/Graphics/Analytics/GraphicsToolLifetimeAnalytic.cs b/Editor/Mono/Graphics/Analytics/GraphicsToolLifetimeAnalytic.cs index b591788df7..8a3b0338c3 100644 --- a/Editor/Mono/Graphics/Analytics/GraphicsToolLifetimeAnalytic.cs +++ b/Editor/Mono/Graphics/Analytics/GraphicsToolLifetimeAnalytic.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using Unity.Scripting.LifecycleManagement; using UnityEngine; using UnityEngine.Analytics; @@ -12,7 +13,7 @@ namespace UnityEditor.Rendering.Analytics { // schema = com.unity3d.data.schemas.editor.analytics.uGraphicsToolLifetimeAnalytic_v2 // taxonomy = editor.analytics.uGraphicsToolLifetimeAnalytic.v2 - public class GraphicsToolLifetimeAnalytic + public partial class GraphicsToolLifetimeAnalytic { static bool IsInternalAssembly(Type type) { @@ -91,6 +92,7 @@ internal struct WindowOpenedMetadata public DateTime openedTime; } + [AutoStaticsCleanupOnCodeReload(CleanupStrategy = CleanupStrategy.Clear)] static Dictionary s_WindowOpenedMetadata = new Dictionary(); public static void WindowOpened() diff --git a/Editor/Mono/Graphics/D3D12DeviceFilterListsEditor.cs b/Editor/Mono/Graphics/D3D12DeviceFilterListsEditor.cs index f065baa831..265a7261a1 100644 --- a/Editor/Mono/Graphics/D3D12DeviceFilterListsEditor.cs +++ b/Editor/Mono/Graphics/D3D12DeviceFilterListsEditor.cs @@ -22,6 +22,7 @@ internal static class Styles public const float kHeightBetweenFields = 3.0f; public const float kHeightBetweenRows = 10.0f; public const float kComparatorFieldWidth = 170.0f; + public const string kGfxJobsNoticeText = "The order of the Graphics Jobs Filters is important. Filtering will use the first passing filter to determine Graphics Jobs Mode at runtime."; public static readonly GUIContent preferredGraphicsJobsMode = EditorGUIUtility.TrTextContent("Preferred Graphics Jobs Mode", "Indicates which graphics jobs mode this filter will enforce at runtime."); @@ -460,47 +461,35 @@ struct ErrorInfo } }; - private float DoListInternal(ReorderableFilterList list, float startingHeight) + private void DoListInternal(ReorderableFilterList list) { - var listRect = GUILayoutUtility.GetRect(startingHeight, list.reorderableList.GetHeight(), GUILayout.ExpandWidth(true)); + var listRect = GUILayoutUtility.GetRect(0.0f, list.reorderableList.GetHeight(), GUILayout.ExpandWidth(true)); listRect.x += EditorGUI.kIndentPerLevel; listRect.width -= EditorGUI.kIndentPerLevel; list.DoList(listRect); - return list.reorderableList.GetHeight(); } - private float DoList(ReorderableFilterList list, string name, ref bool showPosition, float startingHeight, Func onBeforeListDraw = null, Func onAfterListDraw = null) + private void DoList(ReorderableFilterList list, string name, ref bool showPosition, Action onBeforeListDraw = null, Action onAfterListDraw = null) { - var height = startingHeight; showPosition = EditorGUILayout.BeginFoldoutHeaderGroup(showPosition, name); if (showPosition) { - using (var scopedHeight = new IndentLevelScope()) + using (new IndentLevelScope()) { - height += onBeforeListDraw?.Invoke(height) ?? 0.0f; - height += DoListInternal(list, height); - height += onAfterListDraw?.Invoke(height) ?? 0.0f; + onBeforeListDraw?.Invoke(); + DoListInternal(list); + onAfterListDraw?.Invoke(); } } EditorGUILayout.EndFoldoutHeaderGroup(); - return height; } - private float DrawGfxJobsExtraNotice(float startingHeight) + private void DrawGfxJobsExtraNotice() { - var content = EditorGUIUtility.TempContent("The order of the Graphics Jobs Filters is important. Filtering will use the first passing filter to determine Graphics Jobs Mode at runtime.", EditorGUIUtility.GetHelpIcon(MessageType.Info)); + var content = EditorGUIUtility.TempContent(D3D12DeviceFilterUI.Styles.kGfxJobsNoticeText, EditorGUIUtility.GetHelpIcon(MessageType.Info)); - var rect = GUILayoutUtility.GetRect(0.0f, 0.0f, GUILayout.ExpandWidth(true)); - rect.x += EditorGUI.kIndentPerLevel; - rect.width -= EditorGUI.kIndentPerLevel; - var height = EditorStyles.helpBox.CalcHeight(content, rect.width); - - rect = GUILayoutUtility.GetRect(0.0f, height, GUILayout.ExpandWidth(true)); - rect.x += EditorGUI.kIndentPerLevel; - rect.width -= EditorGUI.kIndentPerLevel; - - EditorGUI.HelpBox(rect, content); - return height; + // CalcHeight here would measure the Layout-event dummy rect width and clip the box. + EditorGUILayout.HelpBox(content); } public override void OnInspectorGUI() @@ -513,11 +502,9 @@ public override void OnInspectorGUI() using (var changed = new ChangeCheckScope()) { - var height = 0.0f; - - height = DoList(m_AllowReorderableFilterList, "Allow Filters", ref m_ShowAllow, height); - height = DoList(m_DenyReorderableFilterList, "Deny Filters", ref m_ShowDeny, height); - height = DoList(m_GfxJobsReorderableFilterList, "Preferred Graphics Jobs Filters", ref m_ShowGfxJobs, height, DrawGfxJobsExtraNotice); + DoList(m_AllowReorderableFilterList, "Allow Filters", ref m_ShowAllow); + DoList(m_DenyReorderableFilterList, "Deny Filters", ref m_ShowDeny); + DoList(m_GfxJobsReorderableFilterList, "Preferred Graphics Jobs Filters", ref m_ShowGfxJobs, DrawGfxJobsExtraNotice); if (changed.changed) serializedObject.ApplyModifiedProperties(); diff --git a/Editor/Mono/Graphics/GraphicsStateCollectionImporter.cs b/Editor/Mono/Graphics/GraphicsStateCollectionImporter.cs index cfa5138080..221f048a9e 100644 --- a/Editor/Mono/Graphics/GraphicsStateCollectionImporter.cs +++ b/Editor/Mono/Graphics/GraphicsStateCollectionImporter.cs @@ -155,6 +155,8 @@ public static GUIStyle greyFoldoutStyle public override void OnEnable() { base.OnEnable(); + if (!AreImporterTargetsValid()) // asset gone: base already logged and bailed + return; m_RuntimePlatform = serializedObject.FindProperty("runtimePlatform"); m_GraphicsDeviceType = serializedObject.FindProperty("graphicsDeviceType"); diff --git a/Editor/Mono/Handles/HandleUtility.cs b/Editor/Mono/Handles/HandleUtility.cs index b8620b65f2..c86e353288 100644 --- a/Editor/Mono/Handles/HandleUtility.cs +++ b/Editor/Mono/Handles/HandleUtility.cs @@ -170,6 +170,19 @@ internal static float GetParametrization(Vector2 x0, Vector2 x1, Vector2 x2) return -(Vector2.Dot(x1 - x0, x2 - x1) / (x2 - x1).sqrMagnitude); } + internal static Vector3 WorldPointWithScreenOffset(Camera cam, Vector3 worldPoint, Vector2 screenOffset) + { + Transform camTransform = cam.transform; + Vector2 screenPoint = cam.WorldToScreenPoint(worldPoint); + Vector2 rightPixels = (Vector2)cam.WorldToScreenPoint(worldPoint + camTransform.right) - screenPoint; + Vector2 upPixels = (Vector2)cam.WorldToScreenPoint(worldPoint + camTransform.up) - screenPoint; + + float alongRight = rightPixels.sqrMagnitude > Mathf.Epsilon ? screenOffset.x / rightPixels.magnitude : 0f; + float alongUp = upPixels.sqrMagnitude > Mathf.Epsilon ? screenOffset.y / upPixels.magnitude : 0f; + + return worldPoint + camTransform.right * alongRight + camTransform.up * alongUp; + } + // This limits the "shoot off into infinity" factor when the cursor ray and constraint are near parallel. // Increase this value to more conservatively restrict movement, lower to allow more extreme values. // Ex, with a camera roughly 30 degrees to the handle a value of .1 restricts translation to ~1500m, whereas a diff --git a/Editor/Mono/Help.cs b/Editor/Mono/Help.cs index 7ae34d3776..0c9dbbbf69 100644 --- a/Editor/Mono/Help.cs +++ b/Editor/Mono/Help.cs @@ -41,6 +41,7 @@ public partial class Help internal static string k_AlphaReleaseNotesUrlBase = "https://unity3d.com/unity/alpha/"; internal static string k_BetaReleaseNotesUrlBase = "https://unity3d.com/unity/beta/"; internal static string k_ReleaseNotesUrlBase = "https://unity3d.com/unity/whats-new/"; + internal static string k_ThirdPartyBookMark = "#third-party-notices"; internal enum DocRedirectionServer { @@ -168,7 +169,7 @@ internal static string GetNiceHelpNameForObject(Object obj, bool defaultToMonoBe } else { - if (obj is Component || obj is MonoScript) + if (InternalEditorUtility.IsUnityAssembly(obj.GetType()) && (obj is Component || obj is MonoScript)) { return "MonoBehaviour"; } @@ -218,17 +219,20 @@ internal static string GetHelpURLForObject(Object obj, bool defaultToMonoBehavio return FindHelpNamed(url); } - var topicForObject = HelpFileNameForObject(obj); - if (HasNamedHelp(topicForObject)) + if (InternalEditorUtility.IsUnityAssembly(obj.GetType())) { - return FindHelpNamed(topicForObject); - } + var topicForObject = HelpFileNameForObject(obj); + if (HasNamedHelp(topicForObject)) + { + return FindHelpNamed(topicForObject); + } - if (defaultToMonoBehaviour) - { - if (obj is Component || obj is MonoScript) + if (defaultToMonoBehaviour) { - return FindHelpNamed(k_MonoScriptReference); + if (obj is Component || obj is MonoScript) + { + return FindHelpNamed(k_MonoScriptReference); + } } } @@ -384,6 +388,13 @@ internal static void OpenReleaseNotes() Application.OpenURL(releaseNotesUrl); } + [UnityEngine.Scripting.RequiredByNativeCode] + internal static void OpenThirdPartyNotices() + { + var thirdPartyNoticesUrl = GetReleaseNotesUrl(InternalEditorUtility.GetUnityVersionDigits(), InternalEditorUtility.GetUnityDisplayVersion()) + k_ThirdPartyBookMark; + Application.OpenURL(thirdPartyNoticesUrl); + } + internal static string GetReleaseNotesUrl(string digitsOnlyVersion, string displayVersion) { var url = "http://unity3d.com/whatsnew.html"; diff --git a/Editor/Mono/HierarchyPreferences.cs b/Editor/Mono/HierarchyPreferences.cs index d04b56b0cc..280d2e9848 100644 --- a/Editor/Mono/HierarchyPreferences.cs +++ b/Editor/Mono/HierarchyPreferences.cs @@ -10,7 +10,7 @@ namespace UnityEditor { - [VisibleToOtherModules("HierarchyModule")] + [VisibleToOtherModules("HierarchyModule", "UnityEditor.UIToolkitAuthoringModule", "MultiplayerEditorModule")] internal static partial class HierarchyPreferences { public enum IconMode @@ -20,8 +20,6 @@ public enum IconMode GameObjectOnly } - const bool kUseNewHierarchy = false; - public static PrefabStage.Mode DefaultPrefabModeFromHierarchy { get @@ -34,37 +32,41 @@ public static PrefabStage.Mode DefaultPrefabModeFromHierarchy } } - public static IconMode GameObjectIconMode - { - get => (IconMode)s_GameObjectIconMode.value; - set - { - if (s_GameObjectIconMode.value != (int)value) - { - s_GameObjectIconMode.value = (int)value; - GameObjectIconModeChanged?.Invoke(); - } - } - } - - public static event Action GameObjectIconModeChanged; public static readonly SavedBool RenameNewObjects = new SavedBool("SceneHierarchyWindow.RenameNewObjects", true); public static readonly SavedBool UseQueryBuilder = new SavedBool("HierarchyWindow.UseQueryBuilder", true); public static readonly SavedBool AlternatingRowBackground = new SavedBool("HierarchyWindow.AlternatingRowBackground", true); - public static readonly SavedBool UseNewHierarchy = new SavedBool("HierarchyWindow.UseNewHierarchy", kUseNewHierarchy); + public static readonly SavedBool AllowAlphaNumericHierarchy = new SavedBool("AllowAlphaNumericHierarchy", false); + public static readonly SavedInt GameObjectIconMode = new SavedInt("HierarchyWindow.GameObjectIconMode", 0); - static readonly SavedInt s_GameObjectIconMode = new SavedInt("HierarchyWindow.GameObjectIconMode", 0); + /// + /// Fired whenever any tracked hierarchy preference value changes. + /// Subscribers receive no argument; query the specific preference for the new value. + /// + [VisibleToOtherModules("HierarchyModule", "MultiplayerEditorModule")] + [AutoStaticsCleanupOnCodeReload] + internal static event Action AnyPreferenceChanged; + + static void FireAnyPreferenceChanged() => AnyPreferenceChanged?.Invoke(); + + static HierarchyPreferences() + { + RenameNewObjects.valueChanged += FireAnyPreferenceChanged; + UseQueryBuilder.valueChanged += FireAnyPreferenceChanged; + AlternatingRowBackground.valueChanged += FireAnyPreferenceChanged; + EditorSettings.useLegacyHierarchyChanged += FireAnyPreferenceChanged; + GameObjectIconMode.valueChanged += FireAnyPreferenceChanged; + } public static void EnsureCorrectHierarchyIsInUse(EditorWindow window) { var windowIsLegacy = window is SceneHierarchyWindow; - if (UseNewHierarchy != windowIsLegacy) + if (EditorSettings.useLegacyHierarchy == windowIsLegacy) return; - var wndType = UseNewHierarchy ? HierarchyV2WindowType : typeof(SceneHierarchyWindow); + var wndType = EditorSettings.useLegacyHierarchy ? typeof(SceneHierarchyWindow) : HierarchyV2WindowType; var replacementWindow = (EditorWindow)ScriptableObject.CreateInstance(wndType); - if (window.docked && window.m_Parent is DockArea dockParent) + if (window.m_Parent is DockArea dockParent) dockParent.AddTab(dockParent.m_Panes.IndexOf(window), replacementWindow); else { @@ -74,6 +76,23 @@ public static void EnsureCorrectHierarchyIsInUse(EditorWindow window) window.Close(); } + /// + /// Re-reads all hierarchy preferences from the EditorPrefs store and fires + /// for any value that has changed since + /// the last read. Intended for use by virtual-player clones after + /// to pick up changes made in the main editor + /// without bypassing the cached layer. + /// + [VisibleToOtherModules("HierarchyModule", "MultiplayerEditorModule")] + internal static void RefreshPreferences() + { + RenameNewObjects.Refresh(); + UseQueryBuilder.Refresh(); + AlternatingRowBackground.Refresh(); + GameObjectIconMode.Refresh(); + } + + [VisibleToOtherModules("HierarchyModule", "MultiplayerEditorModule")] [AutoStaticsCleanupOnCodeReload] internal static Type HierarchyV2WindowType; } diff --git a/Editor/Mono/HostView.cs b/Editor/Mono/HostView.cs index 044990da7f..49745932fc 100644 --- a/Editor/Mono/HostView.cs +++ b/Editor/Mono/HostView.cs @@ -387,10 +387,10 @@ private static IEnumerable GetBuiltInPaneTypes() yield return typeof(SceneView); yield return typeof(GameView); yield return typeof(InspectorWindow); - if (HierarchyPreferences.UseNewHierarchy) - yield return HierarchyPreferences.HierarchyV2WindowType; - else + if (EditorSettings.useLegacyHierarchy) yield return typeof(SceneHierarchyWindow); + else + yield return HierarchyPreferences.HierarchyV2WindowType; yield return typeof(ProjectBrowser); yield return typeof(ProfilerWindow); if (AnimationWindowCallbacks.AnimationWindowType != null) diff --git a/Editor/Mono/IHierarchyWindow.cs b/Editor/Mono/IHierarchyWindow.cs index 2c4feca423..b5e791871f 100644 --- a/Editor/Mono/IHierarchyWindow.cs +++ b/Editor/Mono/IHierarchyWindow.cs @@ -23,7 +23,7 @@ internal interface IHierarchyWindow static IHierarchyWindow GetLastInteractedHierarchyWindow() { - if (HierarchyPreferences.UseNewHierarchy) + if (!EditorSettings.useLegacyHierarchy) { var windows = Resources.FindObjectsOfTypeAll(HierarchyPreferences.HierarchyV2WindowType); if (windows == null || windows.Length == 0 || windows[0] is not IHierarchyWindow wnd) @@ -38,7 +38,7 @@ static void GetAllHierarchyWindows(List windows) { windows.Clear(); - if (HierarchyPreferences.UseNewHierarchy) + if (!EditorSettings.useLegacyHierarchy) { var objs = Resources.FindObjectsOfTypeAll(HierarchyPreferences.HierarchyV2WindowType); if (objs == null || objs.Length == 0) diff --git a/Editor/Mono/ImportSettings/IHVImageFormatImporterInspector.cs b/Editor/Mono/ImportSettings/IHVImageFormatImporterInspector.cs index 3a9165ebf9..1f1d2fb8ed 100644 --- a/Editor/Mono/ImportSettings/IHVImageFormatImporterInspector.cs +++ b/Editor/Mono/ImportSettings/IHVImageFormatImporterInspector.cs @@ -44,6 +44,8 @@ internal class Styles public override void OnEnable() { base.OnEnable(); + if (!AreImporterTargetsValid()) // asset gone: base already logged and bailed + return; m_IsReadable = serializedObject.FindProperty("m_IsReadable"); m_sRGBTexture = serializedObject.FindProperty("m_sRGBTexture"); diff --git a/Editor/Mono/ImportSettings/SpeedTreeImporterInspector.cs b/Editor/Mono/ImportSettings/SpeedTreeImporterInspector.cs index e5b7f6c617..2d757af28e 100644 --- a/Editor/Mono/ImportSettings/SpeedTreeImporterInspector.cs +++ b/Editor/Mono/ImportSettings/SpeedTreeImporterInspector.cs @@ -29,6 +29,12 @@ private static class Styles public override void OnEnable() { + if (!AreImporterTargetsValid()) + { + base.OnEnable(); // lets the base mark the editor enabled/inert (OnDisable symmetry) + return; + } + m_MaterialLocation = serializedObject.FindProperty("m_MaterialLocation"); m_Materials = serializedObject.FindProperty("m_Materials"); diff --git a/Editor/Mono/ImportSettings/TextureImporterInspector.cs b/Editor/Mono/ImportSettings/TextureImporterInspector.cs index 1181422d96..61fb0a4242 100644 --- a/Editor/Mono/ImportSettings/TextureImporterInspector.cs +++ b/Editor/Mono/ImportSettings/TextureImporterInspector.cs @@ -342,9 +342,9 @@ internal class Styles public readonly GUIContent spritePixelsPerUnit = EditorGUIUtility.TrTextContent("Pixels Per Unit", "How many pixels in the sprite correspond to one unit in the world."); public readonly GUIContent spriteExtrude = EditorGUIUtility.TrTextContent("Extrude Edges", "How much empty area to leave around the sprite in the generated mesh."); - public readonly GUIContent spriteTriangulation = EditorGUIUtility.TrTextContent("Sprite Mesh Triangulation Method", "Use Legacy for old method. And UTess for Delaunary with Subdivision"); - public readonly GUIContent spriteOutline = EditorGUIUtility.TrTextContent("Sprite Outline Detail", "Sprite Outline Detail."); - public readonly GUIContent spriteSubdivision = EditorGUIUtility.TrTextContent("Sprite Mesh Subdivision", "Refine triangulation."); + public readonly GUIContent spriteTriangulation = EditorGUIUtility.TrTextContent("Mesh Triangulation Method", "Use Legacy for old method. And UTess for Delaunary with Subdivision"); + public readonly GUIContent spriteOutline = EditorGUIUtility.TrTextContent("Outline Detail", "Sprite Outline Detail."); + public readonly GUIContent spriteSubdivision = EditorGUIUtility.TrTextContent("Mesh Subdivision", "Refine triangulation."); public readonly GUIContent spriteMeshType = EditorGUIUtility.TrTextContent("Mesh Type", "Type of sprite mesh to generate."); public readonly GUIContent spriteAlignment = EditorGUIUtility.TrTextContent("Pivot", "Sprite pivot point in its localspace. May be used for syncing animation frames of different sizes."); public readonly GUIContent[] spriteAlignmentOptions = @@ -671,6 +671,8 @@ void InitializeGUI() public override void OnEnable() { base.OnEnable(); + if (!AreImporterTargetsValid()) // asset gone: base already logged and bailed + return; Initialize(); } @@ -1269,8 +1271,10 @@ private void SpriteGUI(TextureInspectorGUIElement guiElements) m_SpriteTessellationMethod.intValue = (int)spriteTessellationMethod; if (SpriteTessellationMethod.DelaunaySubdivision == spriteTessellationMethod) { + EditorGUI.indentLevel++; m_SpriteTessellationDetail.floatValue = EditorGUILayout.Slider(s_Styles.spriteOutline, m_SpriteTessellationDetail.floatValue, 0, 1.0f); m_SpriteGeometrySubdivision.floatValue = EditorGUILayout.Slider(s_Styles.spriteSubdivision, m_SpriteGeometrySubdivision.floatValue, 0, 1.0f); + EditorGUI.indentLevel--; } } @@ -1278,29 +1282,26 @@ private void SpriteGUI(TextureInspectorGUIElement guiElements) ToggleFromInt(m_SpriteGenerateFallbackPhysicsShape, s_Styles.spriteGenerateFallbackPhysicsShape); EditorGUI.indentLevel--; - using (new EditorGUI.DisabledScope(targets.Length != 1)) + if (SpriteUtilityWindow.DoOpenSpriteEditorWindowUI(targets.Length == 1)) { - if (SpriteUtilityWindow.DoOpenSpriteEditorWindowUI()) + if (HasModified()) { - if (HasModified()) - { - // To ensure Sprite Editor Window to have the latest texture import setting, - // We must applied those modified values first. - var dialogText = string.Format(s_Styles.applyAndContinueToSpriteEditor.text, ((TextureImporter)target).assetPath); - if (EditorUtility.DisplayDialog(s_Styles.unappliedImportSettings.text, dialogText, s_Styles.yes.text, s_Styles.no.text)) - { - SaveChanges(); - SpriteUtilityWindow.ShowSpriteEditorWindow(this.assetTarget); - - // We reimported the asset which destroyed the editor, so we can't keep running the UI here. - GUIUtility.ExitGUI(); - } - } - else + // To ensure Sprite Editor Window to have the latest texture import setting, + // We must applied those modified values first. + var dialogText = string.Format(s_Styles.applyAndContinueToSpriteEditor.text, ((TextureImporter)target).assetPath); + if (EditorUtility.DisplayDialog(s_Styles.unappliedImportSettings.text, dialogText, s_Styles.yes.text, s_Styles.no.text)) { + SaveChanges(); SpriteUtilityWindow.ShowSpriteEditorWindow(this.assetTarget); + + // We reimported the asset which destroyed the editor, so we can't keep running the UI here. + GUIUtility.ExitGUI(); } } + else + { + SpriteUtilityWindow.ShowSpriteEditorWindow(this.assetTarget); + } } } diff --git a/Editor/Mono/Inspector/AnimationClipEditor.cs b/Editor/Mono/Inspector/AnimationClipEditor.cs index 8d4b9ab449..92508554ef 100644 --- a/Editor/Mono/Inspector/AnimationClipEditor.cs +++ b/Editor/Mono/Inspector/AnimationClipEditor.cs @@ -2095,14 +2095,17 @@ public void Draw(Rect window) { // Draw body of tooltip GUIStyle style = (GUIStyle)"AnimationEventTooltip"; - Vector2 size = style.CalcSize(new GUIContent(m_InstantTooltipText)); - Rect rect = new Rect(window.x + m_InstantTooltipPoint.x, window.y + m_InstantTooltipPoint.y, size.x, size.y); + using (new SDFStyleScope(style)) + { + Vector2 size = style.CalcSize(new GUIContent(m_InstantTooltipText)); + Rect rect = new Rect(window.x + m_InstantTooltipPoint.x, window.y + m_InstantTooltipPoint.y, size.x, size.y); - // Right align tooltip rect if it would otherwise exceed the bounds of the window - if (rect.xMax > window.width) - rect.x = window.width - rect.width; + // Right align tooltip rect if it would otherwise exceed the bounds of the window + if (rect.xMax > window.width) + rect.x = window.width - rect.width; - GUI.Label(rect, m_InstantTooltipText, style); + GUI.Label(rect, m_InstantTooltipText, style); + } } } diff --git a/Editor/Mono/Inspector/AssemblyDefinitionImporterInspector.cs b/Editor/Mono/Inspector/AssemblyDefinitionImporterInspector.cs index 64eb126c2b..e6ecec4e3e 100644 --- a/Editor/Mono/Inspector/AssemblyDefinitionImporterInspector.cs +++ b/Editor/Mono/Inspector/AssemblyDefinitionImporterInspector.cs @@ -176,6 +176,8 @@ public static string UnityVersionTypeName public override void OnEnable() { base.OnEnable(); + if (!AreImporterTargetsValid()) // asset gone: base already logged and bailed + return; //Ensure UIElements handles the IMGUI container with margins alwaysAllowExpansion = true; diff --git a/Editor/Mono/Inspector/AssemblyDefinitionReferenceImporterInspector.cs b/Editor/Mono/Inspector/AssemblyDefinitionReferenceImporterInspector.cs index a7494bca17..bc2d7f7b93 100644 --- a/Editor/Mono/Inspector/AssemblyDefinitionReferenceImporterInspector.cs +++ b/Editor/Mono/Inspector/AssemblyDefinitionReferenceImporterInspector.cs @@ -49,6 +49,8 @@ public string path public override void OnEnable() { base.OnEnable(); + if (!AreImporterTargetsValid()) // asset gone: base already logged and bailed + return; //Ensure UIElements handles the IMGUI container with margins alwaysAllowExpansion = true; diff --git a/Editor/Mono/Inspector/AudioManagerInspector.cs b/Editor/Mono/Inspector/AudioManagerInspector.cs index 787fdd7a24..f8336edced 100644 --- a/Editor/Mono/Inspector/AudioManagerInspector.cs +++ b/Editor/Mono/Inspector/AudioManagerInspector.cs @@ -21,7 +21,7 @@ private class Styles public static GUIContent DefaultSpeakerMode = EditorGUIUtility.TrTextContent("Default Speaker Mode", "Speaker mode at start of the game. This may be changed at runtime using the AudioSettings.Reset function."); public static GUIContent SampleRate = EditorGUIUtility.TrTextContent("System Sample Rate", "Sample rate at which the output device of the audio system runs. Individual sounds may run at different sample rates and will be slowed down/sped up accordingly to match the output rate."); public static GUIContent DSPBufferSize = EditorGUIUtility.TrTextContent("DSP Buffer Size", "Length of mixing buffer. This determines the output latency of the game."); - public static GUIContent AudioFoundation = EditorGUIUtility.TrTextContent("Audio Foundation", "Low-level, platform audio layer. Classic is the same, mature platform layer from previous versions of Unity. Enhanced is the new platform audio layer and is supported on Windows, macOS, iOS, Android (8.1 and later), and Xbox. The benefits include asynchronous starting and stopping of devices and greater control over audio engine behavior. On platforms that don't have enhanced mode yet, the engine will fall back to using classic mode."); + public static GUIContent AudioFoundation = EditorGUIUtility.TrTextContent("Audio Foundation", "Low-level, platform audio layer. Classic is the same, mature platform layer from previous versions of Unity. Enhanced is the new platform audio layer and is supported on Windows, macOS, Linux, iOS, Android (8.1 and later), XBOX Series X|S, XBOX One, and Nintendo Switch™. The benefits include asynchronous starting and stopping of devices and greater control over audio engine behavior. On platforms that don't have enhanced mode yet, the engine will fall back to using classic mode."); public static GUIContent OutputChannelLayout = EditorGUIUtility.TrTextContent("Output Channel Layout", "The audio engine will always run at the selected channel layout and up-mixing or down-mixing will occur to match the device's native channel layout. Alternatively, if DeviceNative is selected, the engine will always run at the device's native channel count, and will be reset if that native channel count changes (i.e. when the default device changes)."); public static GUIContent OutputSamplingRate = EditorGUIUtility.TrTextContent("Output Sampling Rate", "The audio engine will always run at the selected sampling rate and sample-rate conversion will occur to match the device's native sampling rate. Alternatively, if DeviceNative is selected, the engine will always run at the device's native sampling rate, and will be reset if that native sampling rate changes (i.e. when the default device changes)."); public static GUIContent VirtualVoiceCount = EditorGUIUtility.TrTextContent("Max Virtual Voices", "Maximum number of sounds managed by the system. Even though at most RealVoiceCount of the loudest sounds will be physically playing, the remaining sounds will still be updating their play position."); @@ -37,7 +37,8 @@ private class Styles private class StylesNonSearchable { public static GUIContent DSPBufferSizeInfo = EditorGUIUtility.TrTextContent("The requested buffer size ({0}) has been overridden to {1} by the operating system"); - public static GUIContent EnhancedAudioFoundationInfo = EditorGUIUtility.TrTextContent("Enhanced will be used on Windows, macOS, iOS, Android (8.1 and later), and Xbox. Other platforms will use Classic."); + public static GUIContent EnhancedAudioFoundationInfo = EditorGUIUtility.TrTextContent("Enhanced will be used on Windows, macOS, Linux, iOS, Android (8.1 and later), XBOX Series X|S, XBOX One, and Nintendo Switch™. Other platforms will use Classic."); + public static GUIContent PassthroughChannelLayoutInfo = EditorGUIUtility.TrTextContent("On Meta Quest devices, this layout is sent directly to the OS hardware spatialization instead of being down-mixed."); } static readonly string[] k_AdditionalSearchKeywords = @@ -131,6 +132,8 @@ public override void OnInspectorGUI() EditorGUILayout.HelpBox(StylesNonSearchable.EnhancedAudioFoundationInfo.text, MessageType.Info); EditorGUI.indentLevel++; EditorGUILayout.PropertyField(m_OutputChannelLayout, Styles.OutputChannelLayout); + if (m_OutputChannelLayout.intValue == (int)ChannelLayoutBehavior.Surround_7_1_4) + EditorGUILayout.HelpBox(StylesNonSearchable.PassthroughChannelLayoutInfo.text, MessageType.Info); EditorGUILayout.PropertyField(m_OutputSamplingRate, Styles.OutputSamplingRate); EditorGUI.indentLevel--; } diff --git a/Editor/Mono/Inspector/Avatar/AvatarEditor.cs b/Editor/Mono/Inspector/Avatar/AvatarEditor.cs index f0967b30cc..884e810294 100644 --- a/Editor/Mono/Inspector/Avatar/AvatarEditor.cs +++ b/Editor/Mono/Inspector/Avatar/AvatarEditor.cs @@ -135,6 +135,7 @@ protected void ApplyRevertGUI() if (GUILayout.Button("Apply")) { ApplyAndImport(); + GUIUtility.ExitGUI(); } } diff --git a/Editor/Mono/Inspector/Core/AddComponent/AddComponentDataSource.cs b/Editor/Mono/Inspector/Core/AddComponent/AddComponentDataSource.cs index 39e9be758d..c6c3fbbd81 100644 --- a/Editor/Mono/Inspector/Core/AddComponent/AddComponentDataSource.cs +++ b/Editor/Mono/Inspector/Core/AddComponent/AddComponentDataSource.cs @@ -17,6 +17,7 @@ internal class AddComponentDataSource : AdvancedDropdownDataSource UnityEngine.GameObject[] m_Targets; internal static readonly string kScriptHeader = "Component/Scripts/"; + internal static readonly string kNewScriptGroupName = "New script"; public AddComponentDataSource(AdvancedDropdownState state, UnityEngine.GameObject[] targets) { @@ -86,13 +87,23 @@ protected AdvancedDropdownItem RebuildTree() } } root = root.childList.Single(); - var newScript = new ComponentDropdownItem("New script", L10n.Tr("New script")); - newScript.AddChild(new NewScriptDropdownItem()); - root.AddChild(newScript); + AddNewScriptGroup(root); DictionaryPool, int>.Release(pathHashCodeMap); return root; } + void AddNewScriptGroup(AdvancedDropdownItem parent, string className = null) + { + var newScriptGroup = new ComponentDropdownItem(kNewScriptGroupName, L10n.Tr("New script")); + var newScript = new NewScriptDropdownItem(); + if (className != null) + newScript.className = className; + newScriptGroup.AddChild(newScript); + parent.AddChild(newScriptGroup); + // Seed after AddChild: AddChild assigns the group's final id and the state is keyed by id. + m_State.SetSelectedIndex(newScriptGroup, 0); + } + static List GetSortedMenuItems(UnityEngine.GameObject[] targets) { var menus = Unsupported.GetSubmenus("Component"); @@ -184,15 +195,7 @@ protected override AdvancedDropdownItem Search(string searchString) { searchTree.AddChild(element); } - if (searchTree != null) - { - var addNewScriptGroup = new ComponentDropdownItem("New script", L10n.Tr("New script")); - m_State.SetSelectedIndex(addNewScriptGroup, 0); - var addNewScript = new NewScriptDropdownItem(); - addNewScript.className = searchString; - addNewScriptGroup.AddChild(addNewScript); - searchTree.AddChild(addNewScriptGroup); - } + AddNewScriptGroup(searchTree, searchString); return searchTree; } } diff --git a/Editor/Mono/Inspector/Core/AddComponent/AddComponentGUI.cs b/Editor/Mono/Inspector/Core/AddComponent/AddComponentGUI.cs index b82002d7c6..5c0745a1fc 100644 --- a/Editor/Mono/Inspector/Core/AddComponent/AddComponentGUI.cs +++ b/Editor/Mono/Inspector/Core/AddComponent/AddComponentGUI.cs @@ -12,7 +12,7 @@ internal class AddComponentGUI : AdvancedDropdownGUI { private static class Styles { - public static GUIStyle itemStyle = "DD LargeItemStyle"; + public static readonly GUIStyle itemStyle = "DD LargeItemStyle"; const string k_includeNamespaceProSkin = "{0} ({1})"; const string k_includeNamespace = "{0} ({1})"; diff --git a/Editor/Mono/Inspector/Core/AddComponent/AddComponentWindow.cs b/Editor/Mono/Inspector/Core/AddComponent/AddComponentWindow.cs index a6fccbc00c..07fc2f6610 100644 --- a/Editor/Mono/Inspector/Core/AddComponent/AddComponentWindow.cs +++ b/Editor/Mono/Inspector/Core/AddComponent/AddComponentWindow.cs @@ -4,6 +4,7 @@ using System; using System.Linq; +using Unity.Scripting.LifecycleManagement; using UnityEditor.IMGUI.Controls; using UnityEngine; using UnityEngine.Scripting; @@ -26,6 +27,7 @@ internal class AnalyticsEventData private DateTime m_ComponentOpenTime; private const string kComponentSearch = "ComponentSearchString"; private const int kMaxWindowHeight = 395 - 80; + [NoAutoStaticsCleanup] // dropdown UI state intentionally persisted across reloads private static AdvancedDropdownState s_State = new AdvancedDropdownState(); protected override bool setInitialSelectionPosition { get; } = false; diff --git a/Editor/Mono/Inspector/Core/AddComponent/NewScriptDropdownItem.cs b/Editor/Mono/Inspector/Core/AddComponent/NewScriptDropdownItem.cs index 3d2edd1ade..8d7bcd2ab2 100644 --- a/Editor/Mono/Inspector/Core/AddComponent/NewScriptDropdownItem.cs +++ b/Editor/Mono/Inspector/Core/AddComponent/NewScriptDropdownItem.cs @@ -4,6 +4,7 @@ using System; using System.IO; +using Unity.Scripting.LifecycleManagement; using UnityEngine; using UnityEditorInternal; using Microsoft.CSharp; @@ -18,6 +19,7 @@ class NewScriptDropdownItem : ComponentDropdownItem private readonly char[] kInvalidPathChars = new char[] {'<', '>', ':', '"', '|', '?', '*', (char)0}; private readonly char[] kPathSepChars = new char[] {'/', '\\'}; + [NoAutoStaticsCleanup] // lazy provider, safe to persist; re-created on first use if null private static System.CodeDom.Compiler.CodeDomProvider s_CSharpDOMProvider; private string m_Directory = string.Empty; diff --git a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownGUI.cs b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownGUI.cs index 478faec043..f1db6ecb19 100644 --- a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownGUI.cs +++ b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownGUI.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Unity.Scripting.LifecycleManagement; using UnityEditor.StyleSheets; using UnityEngine; using Event = UnityEngine.Event; @@ -15,22 +16,26 @@ internal class AdvancedDropdownGUI { private static class Styles { - public static GUIStyle itemStyle = "DD ItemStyle"; - public static GUIStyle header = "DD HeaderStyle"; + public static readonly GUIStyle itemStyle = "DD ItemStyle"; + public static readonly GUIStyle header = "DD HeaderStyle"; + [NoAutoStaticsCleanup] // mutated in static ctor (padding/clipping), cannot be readonly public static GUIStyle headerEllipsis = "DD HeaderStyle"; - public static GUIStyle checkMark = "DD ItemCheckmark"; - public static GUIStyle lineSeparator = "DefaultLineSeparator"; - public static GUIStyle rightArrow = "ArrowNavigationRight"; - public static GUIStyle leftArrow = "ArrowNavigationLeft"; - public static GUIStyle searchFieldStyle = new GUIStyle(EditorStyles.toolbarSearchField) + public static readonly GUIStyle checkMark = "DD ItemCheckmark"; + public static readonly GUIStyle lineSeparator = "DefaultLineSeparator"; + public static readonly GUIStyle rightArrow = "ArrowNavigationRight"; + public static readonly GUIStyle leftArrow = "ArrowNavigationLeft"; + public static readonly GUIStyle searchFieldStyle = new GUIStyle(EditorStyles.toolbarSearchField) { margin = new RectOffset(5, 4, 4, 5) }; - public static SVC searchBackgroundColor = new SVC("--theme-toolbar-background-color", Color.black); + [NoAutoStaticsCleanup] + public static readonly SVC searchBackgroundColor = new SVC("--theme-toolbar-background-color", Color.black); + [NoAutoStaticsCleanup] // lazy GUI styles built from EditorStyles, safe to persist public static GUIStyle helpBox; + [NoAutoStaticsCleanup] // lazy GUI styles built from EditorStyles, safe to persist public static GUIStyle helpBoxText; - public static GUIContent checkMarkContent = new GUIContent("✔"); + public static readonly GUIContent checkMarkContent = new GUIContent("✔"); static Styles() { @@ -54,12 +59,17 @@ internal static void LoadStyles() Debug.Assert(Event.current.type == EventType.Repaint && Styles.itemStyle != null); } - public static string k_SearchFieldName = "ComponentSearch"; + public static readonly string k_SearchFieldName = "ComponentSearch"; //This should ideally match line height private Vector2 s_IconSize = new Vector2(13, 13); private AdvancedDropdownDataSource m_DataSource; + // Help box layout: icon size and the gap between the icon and the text. + // Shared between DrawHelpBox and CalcHelpBoxHeight so the two stay in sync. + private const float k_HelpBoxIconSize = 24f; + private const float k_HelpBoxIconTextGap = 6f; + internal Rect m_SearchRect; internal Rect m_HeaderRect; @@ -165,18 +175,14 @@ internal virtual void DrawHelpBox(AdvancedDropdownItem.HelpBoxDropdownItem helpB // Compute inner rect (respects helpbox padding) var inner = Styles.helpBox.padding.Remove(rect); - // Icon settings - const float iconSize = 24f; - const float gap = 6f; - var icon = EditorGUIUtility.GetHelpIcon(helpBoxItem.type); // Center icon vertically within the inner rect - float iconY = inner.y + (inner.height - iconSize) * 0.5f; - var iconRect = new Rect(inner.x, iconY, iconSize, iconSize); + float iconY = inner.y + (inner.height - k_HelpBoxIconSize) * 0.5f; + var iconRect = new Rect(inner.x, iconY, k_HelpBoxIconSize, k_HelpBoxIconSize); // Text rect starts after icon - float textX = iconRect.xMax + gap; + float textX = iconRect.xMax + k_HelpBoxIconTextGap; var textRect = new Rect(textX, inner.y, inner.xMax - textX, inner.height); if (Event.current.type != EventType.Repaint) @@ -192,8 +198,9 @@ internal virtual void DrawHelpBox(AdvancedDropdownItem.HelpBoxDropdownItem helpB internal float CalcHelpBoxHeight(AdvancedDropdownItem.HelpBoxDropdownItem helpBoxItem) { - float contentWidth = EditorGUIUtility.currentViewWidth; - return Styles.helpBoxText.CalcHeight(new GUIContent(helpBoxItem.message), contentWidth) + Styles.helpBox.padding.vertical; + float contentWidth = EditorGUIUtility.currentViewWidth - Styles.helpBox.margin.horizontal; + float textWidth = contentWidth - Styles.helpBox.padding.horizontal - k_HelpBoxIconSize - k_HelpBoxIconTextGap; + return Mathf.Max(k_HelpBoxIconSize, Styles.helpBoxText.CalcHeight(new GUIContent(helpBoxItem.message), textWidth)) + Styles.helpBox.padding.vertical; } internal void DrawHeader(AdvancedDropdownItem group, Action backButtonPressed, bool hasParent) diff --git a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownItem.cs b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownItem.cs index 1c86abec7e..1e08bf342d 100644 --- a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownItem.cs +++ b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownItem.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using Unity.Scripting.LifecycleManagement; using UnityEngine; namespace UnityEditor.IMGUI.Controls @@ -94,6 +95,7 @@ internal void AddChildAndKeepId(AdvancedDropdownItem child) m_Children.Add(child); } + [NoAutoStaticsCleanup] // singleton sentinel used for separator identity check, safe to persist static readonly AdvancedDropdownItem k_SeparatorItem = new SeparatorDropdownItem(); public AdvancedDropdownItem(string name) @@ -140,6 +142,12 @@ internal bool IsHelpBox(AdvancedDropdownItem item) return item is HelpBoxDropdownItem; } + // Separators and help boxes are decorative: they can't be hovered, selected, or clicked. + internal bool IsSelectable() + { + return !IsSeparator() && !IsHelpBox(this); + } + public override string ToString() { return m_Name; diff --git a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownState.cs b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownState.cs index 02f7eb1d37..4844578f2c 100644 --- a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownState.cs +++ b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownState.cs @@ -47,36 +47,41 @@ private AdvancedDropdownItemState GetStateForItem(AdvancedDropdownItem item) internal void MoveDownSelection(AdvancedDropdownItem item) { - var state = GetStateForItem(item); - var selectedIndex = state.selectedIndex; - do - { - ++selectedIndex; - } - while (selectedIndex < item.childList.Count && item.childList[selectedIndex].IsSeparator()); - - if (selectedIndex >= item.childList.Count) - selectedIndex = 0; - - if (selectedIndex < item.childList.Count) - SetSelectionOnItem(item, selectedIndex); + MoveSelection(item, 1); } internal void MoveUpSelection(AdvancedDropdownItem item) { - var state = GetStateForItem(item); - var selectedIndex = state.selectedIndex; - do - { - --selectedIndex; - } - while (selectedIndex >= 0 && item.childList[selectedIndex].IsSeparator()); + MoveSelection(item, -1); + } - if (selectedIndex < 0) - selectedIndex = item.childList.Count - 1; + // Moves the selection to the next selectable child in the given direction + // (+1 = down, -1 = up), wrapping around the list and skipping decorative rows + // (separators, help boxes). Does nothing if the level has no selectable child. + private void MoveSelection(AdvancedDropdownItem item, int direction) + { + var count = item.childList.Count; + if (count == 0) + return; + + var index = GetStateForItem(item).selectedIndex; + + // Normalize an empty selection (-1) so the first step lands on the natural + // end of the list: the top when moving down, the bottom when moving up. + if (index < 0) + index = direction > 0 ? -1 : count; - if (selectedIndex >= 0) - SetSelectionOnItem(item, selectedIndex); + // Scan at most one full loop so the wrap path also skips decorative rows + // instead of landing on a leading/trailing separator or help box. + for (var step = 0; step < count; step++) + { + index = (index + direction + count) % count; + if (item.childList[index].IsSelectable()) + { + SetSelectionOnItem(item, index); + return; + } + } } internal void SetSelectionOnItem(AdvancedDropdownItem item, int selectedIndex) diff --git a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownWindow.cs b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownWindow.cs index 937e1a812e..6eb5ae7d48 100644 --- a/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownWindow.cs +++ b/Editor/Mono/Inspector/Core/AdvancedDropdown/AdvancedDropdownWindow.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using Unity.Scripting.LifecycleManagement; using UnityEditor.Callbacks; using UnityEngine; using Event = UnityEngine.Event; @@ -16,8 +17,11 @@ internal class AdvancedDropdownWindow : EditorWindow { private static class Styles { + [NoAutoStaticsCleanup] // mutated in static ctor (padding), cannot be readonly public static GUIStyle background = "DD Background"; + [NoAutoStaticsCleanup] // mutated in static ctor (padding), cannot be readonly public static GUIStyle previewHeader = new GUIStyle(EditorStyles.label); + [NoAutoStaticsCleanup] // mutated in static ctor (padding), cannot be readonly public static GUIStyle previewText = new GUIStyle(EditorStyles.wordWrappedLabel); static Styles() @@ -53,6 +57,9 @@ static Styles() private bool m_DirtyList = true; private bool m_NeedsResize = false; + // The dropdown width is computed once when it opens and then kept constant. + private float m_FixedWidth; + // Caller-supplied size constraints captured in Init() so the dynamic resize logic can // still respect minimumSize/maximumSize even after they're overwritten by ShowAsDropDown. private Vector2 m_OriginalMinSize; @@ -236,6 +243,9 @@ public void Init(Rect buttonRect) ShowAsDropDown(buttonRect, initialSize, GetLocationPriority()); + // Remember the width chosen on open so later navigation only ever changes the height. + m_FixedWidth = position.width; + if (setInitialSelectionPosition) { m_InitialSelectionPosition = m_Gui.GetSelectionHeight(m_DataSource, buttonRect); @@ -422,7 +432,7 @@ private void HandleKeyboard() if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter) { var selected = m_State.GetSelectedChild(m_CurrentlyRenderedTree); - if (selected != null) + if (selected != null && selected.IsSelectable()) { if (selected.hasChildren) { @@ -541,6 +551,10 @@ private void DrawList(AdvancedDropdownItem item) if (item != m_CurrentlyRenderedTree) continue; + // Decorative items (separators, help boxes) are not interactive. + if (!child.IsSelectable()) + continue; + // Select the element the mouse cursor is over. // Only do it on mouse move - keyboard controls are allowed to overwrite this until the next time the mouse moves. if (Event.current.type == EventType.MouseMove || Event.current.type == EventType.MouseDrag) @@ -647,6 +661,9 @@ private void ResizeWindowForCurrentLevel() var newSize = CalculateWindowSize(m_ButtonRectScreenPos); + // Keep the width fixed at the value chosen when the dropdown opened; recalc height only. + newSize.x = m_FixedWidth; + // Skip if computed size matches current window size — common for subclasses that pin the window to a fixed size const float kEpsilon = 0.5f; if (Mathf.Abs(newSize.x - position.width) < kEpsilon && Mathf.Abs(newSize.y - position.height) < kEpsilon) diff --git a/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/MultiLevelDataSource.cs b/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/MultiLevelDataSource.cs index 79f250165f..f28c227b6f 100644 --- a/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/MultiLevelDataSource.cs +++ b/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/MultiLevelDataSource.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; +using Unity.Scripting.LifecycleManagement; namespace UnityEditor.IMGUI.Controls { @@ -21,6 +22,7 @@ internal string label set { m_Label = value; } } + [NoAutoStaticsCleanup] // selection index for currently open dropdown, safe to persist private static int m_SelectedIndex; internal int selectedIndex { diff --git a/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/SimpleDataSource.cs b/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/SimpleDataSource.cs index 9249d990a1..805b8f8934 100644 --- a/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/SimpleDataSource.cs +++ b/Editor/Mono/Inspector/Core/AdvancedDropdown/DataSources/SimpleDataSource.cs @@ -3,6 +3,7 @@ // https://unity3d.com/legal/licenses/Unity_Reference_Only_License using System.Linq; +using Unity.Scripting.LifecycleManagement; using UnityEngine; namespace UnityEditor.IMGUI.Controls @@ -15,6 +16,7 @@ internal GUIContent[] displayedOptions set { m_DisplayedOptions = value; } } + [NoAutoStaticsCleanup] // selection index for currently open dropdown, safe to persist private static int m_SelectedIndex; #pragma warning disable 0649 private AdvancedDropdownState m_State; diff --git a/Editor/Mono/Inspector/Core/AdvancedDropdown/EditorGUI/StatelessAdvancedDropdown.cs b/Editor/Mono/Inspector/Core/AdvancedDropdown/EditorGUI/StatelessAdvancedDropdown.cs index e62c5d9312..b3070b0231 100644 --- a/Editor/Mono/Inspector/Core/AdvancedDropdown/EditorGUI/StatelessAdvancedDropdown.cs +++ b/Editor/Mono/Inspector/Core/AdvancedDropdown/EditorGUI/StatelessAdvancedDropdown.cs @@ -3,11 +3,13 @@ // https://unity3d.com/legal/licenses/Unity_Reference_Only_License using System; +using Unity.Scripting.LifecycleManagement; using UnityEditor.IMGUI.Controls; using UnityEngine; namespace UnityEditor { + [AutoStaticsCleanupOnCodeReload] internal static partial class StatelessAdvancedDropdown { private static AdvancedDropdownWindow s_Instance; diff --git a/Editor/Mono/Inspector/Core/GenericInspector.cs b/Editor/Mono/Inspector/Core/GenericInspector.cs index 99f630d8ec..b09f7cd579 100644 --- a/Editor/Mono/Inspector/Core/GenericInspector.cs +++ b/Editor/Mono/Inspector/Core/GenericInspector.cs @@ -24,9 +24,9 @@ private enum OptimizedBlockState static class Styles { - public static string missingScriptMessage = L10n.Tr("The associated script can not be loaded: {}\n\nThis could be because the script has a compile error or because the script was deleted.\nFix any compile errors if present or assign a valid script."); - public static string missingScriptMessageForPrefabInstance = L10n.Tr("The associated script can not be loaded: {}\n\nYou must resolve missing scripts on prefabs on the prefab asset itself through the prefab view. Open the source prefab asset for this prefab instance to continue."); - public static string missingSerializeReferenceInstanceMessage = L10n.Tr("This object contains SerializeReference types which are missing.\nFor more information see SerializationUtility.HasManagedReferencesWithMissingTypes."); + public static readonly string missingScriptMessage = L10n.Tr("The associated script can not be loaded: {}\n\nThis could be because the script has a compile error or because the script was deleted.\nFix any compile errors if present or assign a valid script."); + public static readonly string missingScriptMessageForPrefabInstance = L10n.Tr("The associated script can not be loaded: {}\n\nYou must resolve missing scripts on prefabs on the prefab asset itself through the prefab view. Open the source prefab asset for this prefab instance to continue."); + public static readonly string missingSerializeReferenceInstanceMessage = L10n.Tr("This object contains SerializeReference types which are missing.\nFor more information see SerializationUtility.HasManagedReferencesWithMissingTypes."); } internal static string GetMissingSerializeRefererenceMessageContainer() diff --git a/Editor/Mono/Inspector/Core/InspectorWindow.cs b/Editor/Mono/Inspector/Core/InspectorWindow.cs index 1de104ce65..9e29b85e1f 100644 --- a/Editor/Mono/Inspector/Core/InspectorWindow.cs +++ b/Editor/Mono/Inspector/Core/InspectorWindow.cs @@ -4,9 +4,9 @@ using System; using System.Collections.Generic; +using Unity.Scripting.LifecycleManagement; using UnityEngine; using UnityEngine.Bindings; -using UnityEngine.Pool; using UnityEngine.Scripting; using UnityEngine.UIElements; @@ -16,13 +16,15 @@ namespace UnityEditor { [VisibleToOtherModules("UnityEditor.PlayModeModule", "UnityEditor.UIToolkitAuthoringModule")] [EditorWindowTitle(title = k_InspectorWindowTitle, useTypeNameAsIconName = true)] - internal class InspectorWindow : PropertyEditor, IPropertyView, IHasCustomMenu + internal partial class InspectorWindow : PropertyEditor, IPropertyView, IHasCustomMenu { const string k_InspectorWindowTitle = "Inspector"; const string k_InspectorWindowTitleDebug = "Inspector (Debug)"; const string k_InspectorWindowTitleDebugInternal = "Inspector (Debug Internal)"; - static readonly List m_AllInspectors = new List(); + [AutoStaticsCleanupOnCodeReload] + static List m_AllInspectors = new List(); + [NoAutoStaticsCleanup] // bool flag, safe to persist; rebuild triggered on next repaint static bool s_AllOptimizedGUIBlocksNeedsRebuild; [SerializeField] EditorGUIUtility.EditorLockTrackerWithActiveEditorTracker m_LockTracker = new EditorGUIUtility.EditorLockTrackerWithActiveEditorTracker(); @@ -251,6 +253,9 @@ protected override void Update() } } + [NoAutoStaticsCleanup] + static List s_WindowsSnapshot = new List(32); + [UsedByNativeCode] internal static void RedrawFromNative() { @@ -260,15 +265,28 @@ internal static void RedrawFromNative() // Acquire a snapshot instead of directly iterating over activeEditorWindows as calling // RebuildContentsContainers can mutate activeEditorWindows. - var activeWindowCount = activeEditorWindows.Count; - using var windowsSnapshot = new RentSpan(activeWindowCount); - for (int i = 0; i < activeWindowCount; ++i) - windowsSnapshot.Span[i] = activeEditorWindows[i]; + var snapshot = s_WindowsSnapshot; + + // Guard against re-entry by setting s_WindowsSnapshot to null for the current entry (the finally statement will restore it). + if (snapshot != null) + s_WindowsSnapshot = null; + else // If snapshot is null, we're re-entering. We shouldn't overwrite s_WindowsSnapshot therefore fallback to allocating new. + snapshot = new List(32); + + try + { + snapshot.AddRange(activeEditorWindows); - foreach (var editorWindow in windowsSnapshot) + for (int i = 0; i < snapshot.Count; ++i) + { + if (snapshot[i] is PropertyEditor propertyEditor && propertyEditor != null) + propertyEditor.RebuildContentsContainers(); + } + } + finally { - if (editorWindow != null && editorWindow is PropertyEditor propertyEditor) - propertyEditor.RebuildContentsContainers(); + snapshot.Clear(); + s_WindowsSnapshot = snapshot; } } diff --git a/Editor/Mono/Inspector/Core/PropertyEditor.cs b/Editor/Mono/Inspector/Core/PropertyEditor.cs index 79ea836c93..2e09e89fd5 100644 --- a/Editor/Mono/Inspector/Core/PropertyEditor.cs +++ b/Editor/Mono/Inspector/Core/PropertyEditor.cs @@ -23,6 +23,7 @@ using JetBrains.Annotations; using Unity.Collections; using Unity.Profiling; +using Unity.Scripting.LifecycleManagement; using UnityEditor.UIElements; using UnityEngine.Pool; using Button = UnityEngine.UIElements.Button; @@ -50,7 +51,7 @@ interface IPropertySourceOpener Object hoveredObject { get; } } - class PropertyEditor : EditorWindow, IPropertyView, IHasCustomMenu + partial class PropertyEditor : EditorWindow, IPropertyView, IHasCustomMenu { internal const string k_AssetPropertiesMenuItemName = "Assets/Properties... _&P"; protected const string s_MultiEditClassName = "unity-inspector-no-multi-edit-warning"; @@ -77,7 +78,6 @@ class PropertyEditor : EditorWindow, IPropertyView, IHasCustomMenu protected const int k_AutoScrollZoneHeight = 24; const float m_PreviewDefaultHeight = 200; const float m_PreviewMinHeight = 20; - static readonly string k_DebugInfoPanelTooltip = L10n.Tr("In Debug mode, the Inspector also displays the item's private properties and doesn't use custom inspector code."); static readonly string k_ExitDebugButtonTooltip = L10n.Tr("Change the Inspector window back to Normal mode."); static readonly string k_DebugModeLabel = L10n.Tr("Inspector mode: Debug"); @@ -105,14 +105,15 @@ class PropertyEditor : EditorWindow, IPropertyView, IHasCustomMenu [SerializeField] protected List m_EntityIdsLockedBeforeSerialization = new List(); [SerializeField] protected PreviewResizer m_PreviewResizer = new PreviewResizer(); protected LabelGUI m_LabelGUI = new LabelGUI(); - [SerializeField] protected EntityId m_LastInspectedObjectEntityId = EntityId.None; [SerializeField] protected float m_LastVerticalScrollValue = 0; [SerializeField] protected string m_GlobalObjectId = ""; [SerializeField] protected InspectorMode m_InspectorMode = InspectorMode.Normal; - private static readonly List m_AllPropertyEditors = new List(); + [AutoStaticsCleanupOnCodeReload] + private static List m_AllPropertyEditors = new List(); private Object m_InspectedObject; private string m_ExpectedTitle; + [AutoStaticsCleanupOnCodeReload] private static PropertyEditor s_LastPropertyEditor; protected EntityId m_LastInitialEditorEntityId; protected Component[] m_ComponentsInPrefabSource; @@ -140,6 +141,7 @@ class PropertyEditor : EditorWindow, IPropertyView, IHasCustomMenu protected VisualElement previewAndLabelElement => m_PreviewAndLabelElement ?? (m_PreviewAndLabelElement = FindVisualElementInTreeByClassName(s_FooterInfoClassName)); protected VisualElement m_VersionControlElement; protected VisualElement versionControlElement => m_VersionControlElement ?? (m_VersionControlElement = FindVisualElementInTreeByClassName(s_HeaderInfoClassName)); + [AutoStaticsCleanupOnCodeReload] protected static Dictionary m_VersionControlBarState = new Dictionary(); protected VisualElement m_MultiEditLabel; protected ScrollView m_ScrollView; @@ -156,13 +158,16 @@ class PropertyEditor : EditorWindow, IPropertyView, IHasCustomMenu Button m_ExitDebugModeButton; List m_SupportedDataModes = new(4); + [NoAutoStaticsCleanup] // fixed singleton list of disabled modes, safe to persist static readonly List k_DisabledDataModes = new() {DataMode.Disabled}; public GUIView parent => m_Parent; public HashSet editorsWithImportedObjectLabel { get; } = new HashSet(); public EditorDragging editorDragging { get; } public Editor lastInteractedEditor { get; set; } + [AutoStaticsCleanupOnCodeReload] internal static PropertyEditor HoveredPropertyEditor { get; private set; } + [AutoStaticsCleanupOnCodeReload] internal static PropertyEditor FocusedPropertyEditor { get; private set; } EditorElementUpdater m_EditorElementUpdater; @@ -208,7 +213,7 @@ protected Rect bottomAreaDropRectangle internal Rect scrollViewportRect => m_ScrollView.contentViewport.rect; - protected static class Styles + protected static partial class Styles { public static readonly GUIStyle preToolbar = "preToolbar"; public static readonly GUIStyle preToolbar2 = "preToolbar2"; @@ -220,15 +225,15 @@ protected static class Styles public static readonly GUIContent preTitle = EditorGUIUtility.TrTextContent("Preview"); public static readonly GUIContent labelTitle = EditorGUIUtility.TrTextContent("Asset Labels"); public static readonly GUIContent addComponentLabel = EditorGUIUtility.TrTextContent("Add Component"); - public static GUIStyle preBackground = "preBackground"; - public static GUIStyle footer = "IN Footer"; - public static GUIStyle preMargins = new GUIStyle() {margin = new RectOffset(0, 0, 0, 4)}; - public static GUIStyle preOptionsButton = new GUIStyle(EditorStyles.toolbarButtonRight) { padding = new RectOffset(), contentOffset = new Vector2(1, 0) }; - public static GUIStyle addComponentArea = EditorStyles.inspectorTitlebar; - public static GUIStyle addComponentButtonStyle = "AC Button"; + public static readonly GUIStyle preBackground = "preBackground"; + public static readonly GUIStyle footer = "IN Footer"; + public static readonly GUIStyle preMargins = new GUIStyle() {margin = new RectOffset(0, 0, 0, 4)}; + public static readonly GUIStyle preOptionsButton = new GUIStyle(EditorStyles.toolbarButtonRight) { padding = new RectOffset(), contentOffset = new Vector2(1, 0) }; + public static readonly GUIStyle addComponentArea = EditorStyles.inspectorTitlebar; + public static readonly GUIStyle addComponentButtonStyle = "AC Button"; public static readonly GUIContent menuIcon = EditorGUIUtility.TrIconContent("_Menu"); - public static GUIStyle previewMiniLabel = EditorStyles.whiteMiniLabel; - public static GUIStyle typeSelection = "IN TypeSelection"; + public static readonly GUIStyle previewMiniLabel = EditorStyles.whiteMiniLabel; + public static readonly GUIStyle typeSelection = "IN TypeSelection"; public static readonly GUIContent vcsCheckoutHint = EditorGUIUtility.TrTextContent("Under Version Control\nCheck out this asset in order to make changes.", EditorGUIUtility.GetHelpIcon(MessageType.Info)); public static readonly GUIContent vcsNotConnected = EditorGUIUtility.TrTextContent("VCS ({0}) is not connected"); @@ -242,11 +247,15 @@ protected static class Styles public static readonly GUIContent vcsSubmit = EditorGUIUtility.TrTextContent("Submit"); public static readonly GUIContent vcsRevert = EditorGUIUtility.TrTextContent("Revert"); public static readonly GUIContent vcsRevertUnchanged = EditorGUIUtility.TrTextContent("Revert Unchanged"); + [NoAutoStaticsCleanup] // array of auto-exempt GUIContent refs, safe to persist public static readonly GUIContent[] vcsRevertMenuNames = {vcsRevertUnchanged}; + [NoAutoStaticsCleanup] // ok the static method assigned here is stateless public static readonly GenericMenu.MenuFunction2[] vcsRevertMenuActions = {DoRevertUnchanged}; public static readonly GUIStyle vcsButtonStyle = EditorStyles.miniButton; + [NoAutoStaticsCleanup] // GUIStyle persists safely; static constructor applies modifiers on first access public static GUIStyle vcsRevertStyle = new GUIStyle(EditorStyles.dropDownList); public static readonly GUIStyle vcsBarStyleOneRow = EditorStyles.toolbar; + [NoAutoStaticsCleanup] // GUIStyle persists safely; static constructor applies modifiers on first access public static GUIStyle vcsBarStyleTwoRows = new GUIStyle(EditorStyles.toolbar); public static readonly string objectDisabledModuleWarningFormat = L10n.Tr( "The built-in package '{0}', which implements this component type, has been disabled in Package Manager. This object will be removed in play mode and from any builds you make." @@ -255,8 +264,10 @@ protected static class Styles "The built-in package '{0}', which is required by the package '{1}', which implements this component type, has been disabled in Package Manager. This object will be removed in play mode and from any builds you make." ); - public static SVC lineSeparatorOffset = new SVC("AC-Button", "--separator-line-top-offset"); - public static SVC lineSeparatorColor = new SVC("--theme-line-separator-color", Color.red); + [NoAutoStaticsCleanup] + public static readonly SVC lineSeparatorOffset = new SVC("AC-Button", "--separator-line-top-offset"); + [NoAutoStaticsCleanup] + public static readonly SVC lineSeparatorColor = new SVC("--theme-line-separator-color", Color.red); static Styles() { @@ -428,9 +439,9 @@ protected virtual void OnDisable() ClearPreviewables(); - // save vertical scroll position - m_LastInspectedObjectEntityId = GetInspectedObject()?.GetEntityId() ?? EntityId.None; - m_LastVerticalScrollValue = m_ScrollView?.verticalScroller.value ?? 0; + // Persist the live scroll offset so it survives a domain reload. + if (m_ScrollView != null) + m_LastVerticalScrollValue = m_ScrollView.verticalScroller.value; EditorApplication.focusChanged -= OnFocusChanged; Undo.undoRedoEvent -= OnUndoRedoPerformed; @@ -526,7 +537,8 @@ protected virtual void OnGUI() m_HasPreviewPeriodicCheckDelayer?.Execute(); } - static readonly List s_Editors = new List(); + [AutoStaticsCleanupOnCodeReload] + static List s_Editors = new List(); [UsedImplicitly] protected virtual void Update() @@ -661,7 +673,6 @@ private void OnGeometryChanged(GeometryChangedEvent e) m_PreviewResizer.SetExpanded(false); } } - RestoreVerticalScrollIfNeeded(); } internal static void ClearAndRebuildAll() @@ -1171,6 +1182,12 @@ protected virtual void BeginRebuildContentContainers() {} protected virtual void EndRebuildContentContainers() {} internal virtual void RebuildContentsContainers() { + // Capture the live scroll offset before teardown so it survives the rebuild. Skip on the + // first rebuild after a domain reload, where the ScrollView is freshly at 0 and the + // serialized value is authoritative. + if (!m_FirstInitialize && m_ScrollView != null) + m_LastVerticalScrollValue = m_ScrollView.verticalScroller.value; + ClearPreviewables(); m_TypeSelectionList = null; m_FirstInitialize = false; @@ -1294,11 +1311,23 @@ internal virtual void RebuildContentsContainers() } k_CreateInspectorElements.End(); + // When restoring scroll, build just enough editors for the saved offset to stay reachable, so it can be + // re-applied below without the scroller clamping it to 0. The remaining editors keep building time-sliced. + if (m_LastVerticalScrollValue > 0f && editorsElement != null && m_ScrollView != null) + { + m_EditorElementUpdater.CreateInspectorElementsToReachScrollOffset(m_ScrollView, editorsElement, m_LastVerticalScrollValue); + } + rootVisualElement.MarkDirtyRepaint(); ScriptAttributeUtility.ClearGlobalCache(); EndRebuildContentContainers(); + + // Re-apply the preserved scroll offset now that the content height is final. + if (m_LastVerticalScrollValue > 0f && m_ScrollView != null) + m_ScrollView.verticalScroller.value = m_LastVerticalScrollValue; + Repaint(); RefreshTitle(); } @@ -2475,16 +2504,6 @@ private void DrawEditors(Editor[] editors) } } - private void RestoreVerticalScrollIfNeeded() - { - if (m_LastInspectedObjectEntityId == EntityId.None) - return; - var inspectedObjectInstanceID = GetInspectedObject()?.GetEntityId() ?? EntityId.None; - if (inspectedObjectInstanceID == m_LastInspectedObjectEntityId && inspectedObjectInstanceID != EntityId.None) - m_ScrollView.verticalScroller.value = m_LastVerticalScrollValue; - m_LastInspectedObjectEntityId = EntityId.None; // reset to make sure the restore occurs once - } - void OnPrefabInstanceUnpacked(GameObject unpackedPrefabInstance, PrefabUnpackMode unpackMode) { if (m_RemovedComponents == null) diff --git a/Editor/Mono/Inspector/Core/RootEditor.cs b/Editor/Mono/Inspector/Core/RootEditor.cs index 72ced64b63..6672873262 100644 --- a/Editor/Mono/Inspector/Core/RootEditor.cs +++ b/Editor/Mono/Inspector/Core/RootEditor.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Reflection; using JetBrains.Annotations; +using Unity.Scripting.LifecycleManagement; using UnityEngine.Internal; using UnityEngine.Pool; using UnityEngine.Scripting; @@ -41,7 +42,8 @@ private static System.Type signature(UnityEngine.Object[] objects, UnityEngine.O } } - internal static class RootEditorUtils + [AutoStaticsCleanupOnCodeReload] + internal static partial class RootEditorUtils { class RootEditorDesc { @@ -54,9 +56,10 @@ class RootEditorDesc } private static bool s_SuppressRootEditor = false; - private static readonly List kSRootEditor = new List(); + private static List kSRootEditor = new List(); - static RootEditorUtils() + [OnCodeLoaded] + static void Initialize() { Rebuild(); } diff --git a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/ExposedReferenceDrawer.cs b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/ExposedReferenceDrawer.cs index ff47cdd0c6..a6823b1fa4 100644 --- a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/ExposedReferenceDrawer.cs +++ b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/ExposedReferenceDrawer.cs @@ -12,11 +12,11 @@ abstract class BaseExposedPropertyDrawer : UnityEditor.PropertyDrawer { - private static float kDriveWidgetWidth = 18.0f; - private static GUIStyle kDropDownStyle = null; - private static Color kMissingOverrideColor = new Color(1.0f, 0.11f, 0.11f, 1.0f); - protected static string kSetExposedPropertyMsg = "Set Exposed Property"; - protected static string kClearExposedPropertyMsg = "Clear Exposed Property"; + private static readonly float kDriveWidgetWidth = 18.0f; + private static readonly GUIStyle kDropDownStyle = "ShurikenDropdown"; + private static readonly Color kMissingOverrideColor = new Color(1.0f, 0.11f, 0.11f, 1.0f); + protected static readonly string kSetExposedPropertyMsg = "Set Exposed Property"; + protected static readonly string kClearExposedPropertyMsg = "Clear Exposed Property"; internal const string kVisualElementName = "ExposedReference"; internal readonly GUIContent ExposePropertyContent = EditorGUIUtility.TrTextContent("Expose Property"); @@ -44,8 +44,6 @@ internal enum OverrideState public BaseExposedPropertyDrawer() { - if (kDropDownStyle == null) - kDropDownStyle = "ShurikenDropdown"; } static internal ExposedPropertyMode GetExposedPropertyMode(string propertyName) diff --git a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/PropertyDrawers.cs b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/PropertyDrawers.cs index 40f7014426..22477829d5 100644 --- a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/PropertyDrawers.cs +++ b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/Implementations/PropertyDrawers.cs @@ -17,7 +17,7 @@ namespace UnityEditor [CustomPropertyDrawer(typeof(RangeAttribute))] internal sealed class RangeDrawer : PropertyDrawer { - private static string s_InvalidTypeMessage = L10n.Tr("Use Range with float or int."); + private static readonly string s_InvalidTypeMessage = L10n.Tr("Use Range with float or int."); public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { @@ -58,7 +58,7 @@ public override VisualElement CreatePropertyGUI(SerializedProperty property) [CustomPropertyDrawer(typeof(MinAttribute))] internal sealed class MinDrawer : PropertyDrawer { - private static string s_InvalidTypeMessage = L10n.Tr("Use Min with float, int or Vector."); + private static readonly string s_InvalidTypeMessage = L10n.Tr("Use Min with float, int or Vector."); private MinAttribute minAttribute { @@ -266,7 +266,7 @@ private EntityId OnValidateValue(EntityId value) [CustomPropertyDrawer(typeof(MultilineAttribute))] internal sealed class MultilineDrawer : PropertyDrawer { - private static string s_InvalidTypeMessage = L10n.Tr("Use Multiline with string."); + private static readonly string s_InvalidTypeMessage = L10n.Tr("Use Multiline with string."); private const int kLineHeight = 13; public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) @@ -319,7 +319,7 @@ public override float GetPropertyHeight(SerializedProperty property, GUIContent internal sealed class TextAreaDrawer : PropertyDrawer { private const int kLineHeight = 15; - private static string s_InvalidTypeMessage = L10n.Tr("Use TextAreaDrawer with string."); + private static readonly string s_InvalidTypeMessage = L10n.Tr("Use TextAreaDrawer with string."); private Vector2 m_ScrollPosition = new Vector2(); public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) @@ -399,7 +399,7 @@ public override float GetPropertyHeight(SerializedProperty property, GUIContent [CustomPropertyDrawer(typeof(ColorUsageAttribute))] internal sealed class ColorUsageDrawer : PropertyDrawer { - private static string s_InvalidTypeMessage = L10n.Tr("Use ColorUsageDrawer with color."); + private static readonly string s_InvalidTypeMessage = L10n.Tr("Use ColorUsageDrawer with color."); public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { @@ -441,7 +441,7 @@ public override VisualElement CreatePropertyGUI(SerializedProperty property) [CustomPropertyDrawer(typeof(GradientUsageAttribute))] internal sealed class GradientUsageDrawer : PropertyDrawer { - private static string s_InvalidTypeMessage = L10n.Tr("Use GradientUsageDrawer with gradient."); + private static readonly string s_InvalidTypeMessage = L10n.Tr("Use GradientUsageDrawer with gradient."); public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { @@ -475,8 +475,8 @@ public override VisualElement CreatePropertyGUI(SerializedProperty property) [CustomPropertyDrawer(typeof(DelayedAttribute))] internal sealed class DelayedDrawer : PropertyDrawer { - static string s_InvalidTypeMessageIMGUI = L10n.Tr("Use Delayed with float, int or string when using IMGUI."); - static string s_InvalidTypeMessageUITK = L10n.Tr("Use Delayed with float, int, string, Vector or Rect when using UI Toolkit."); + static readonly string s_InvalidTypeMessageIMGUI = L10n.Tr("Use Delayed with float, int or string when using IMGUI."); + static readonly string s_InvalidTypeMessageUITK = L10n.Tr("Use Delayed with float, int, string, Vector or Rect when using UI Toolkit."); public override void OnGUI(Rect position, SerializedProperty property, GUIContent label) { diff --git a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/PropertyHandler.cs b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/PropertyHandler.cs index 0f9c00560d..651eb8f1b6 100644 --- a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/PropertyHandler.cs +++ b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/PropertyHandler.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using Unity.Scripting.LifecycleManagement; using UnityEngine; using UnityEditorInternal; using UnityEngine.Bindings; @@ -15,8 +16,9 @@ namespace UnityEditor { [VisibleToOtherModules("UnityEditor.UIBuilderModule")] - internal class PropertyHandler : IDisposable + internal partial class PropertyHandler : IDisposable { + [AutoStaticsCleanupOnCodeReload] readonly static Dictionary> s_DefaultObjectReferenceCache = new(); List m_PropertyDrawers; @@ -43,11 +45,15 @@ internal PropertyDrawer propertyDrawer bool isCurrentlyNested => m_NestingLevel > 0; + [AutoStaticsCleanupOnCodeReload] internal static Dictionary s_reorderableLists = new Dictionary(); + [NoAutoStaticsCleanup] // transient inspector state counters, safe to persist static EntityId s_LastInspectionTarget; + [NoAutoStaticsCleanup] // transient inspector state counters, safe to persist static int s_LastInspectorNumComponents; - static PropertyHandler() + [OnCodeLoaded] + static void Initialize() { Undo.undoRedoEvent += OnUndoRedo; } @@ -459,6 +465,7 @@ public void CallMenuCallback(object[] targets, MethodInfo method) method.Invoke(target, Array.Empty()); } + [NoAutoStaticsCleanup] // used for avoiding repeated allocations, cleared after use systematically static List s_CachedComponents = new List(); internal void TestInvalidateCache() diff --git a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/ScriptAttributeUtility.cs b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/ScriptAttributeUtility.cs index 30c98f956f..aacf912b44 100644 --- a/Editor/Mono/Inspector/Core/ScriptAttributeGUI/ScriptAttributeUtility.cs +++ b/Editor/Mono/Inspector/Core/ScriptAttributeGUI/ScriptAttributeUtility.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Text.RegularExpressions; using Unity.Collections; +using Unity.Scripting.LifecycleManagement; using UnityEngine; using UnityEngine.Bindings; using UnityEngine.Rendering; @@ -14,8 +15,10 @@ namespace UnityEditor { + [AutoStaticsCleanupOnCodeReload] [VisibleToOtherModules("UnityEditor.UIBuilderModule", "UnityEditor.GraphToolkitModule")] - internal class ScriptAttributeUtility + [AutoStaticsCleanupOnCodeReload] + internal partial class ScriptAttributeUtility { readonly struct CustomPropertyDrawerContainer { @@ -33,18 +36,21 @@ public CustomPropertyDrawerContainer(Type drawerType, Type[] supportedRenderPipe // Internal API members internal static Stack s_DrawerStack = new Stack(); + [NoAutoStaticsCleanup] private static Dictionary> s_BuiltinAttributes = null; static Dictionary> s_AutoLoadProperties; + [NoAutoStaticsCleanup] private static PropertyHandler s_SharedNullHandler = new PropertyHandler(); private static PropertyHandler s_NextHandler = new PropertyHandler(); private static PropertyHandlerCache s_GlobalCache = new PropertyHandlerCache(); private static PropertyHandlerCache s_CurrentCache = null; - static readonly Lazy> k_DrawerTypeForType = new(BuildDrawerTypeForTypeDictionary); + static Lazy> k_DrawerTypeForType = new(BuildDrawerTypeForTypeDictionary); static readonly Dictionary k_DrawerStaticTypesCache = new(); static readonly Dictionary k_SupportedRenderPipelinesForSerializedObject = new(); + [NoAutoStaticsCleanup] static readonly Comparer k_RenderPipelineTypeComparer = Comparer.Create((c1, c2) => @@ -572,7 +578,9 @@ class FieldInfoCache // Precompiled regexes used by GetFieldInfoFromPropertyPath on cache misses to avoid pattern parsing // and string concatenation. The trailing-anchor variant is used for the end-of-path check; // the unanchored variant is used to strip all Array.data[x] segments out of the path. + [NoAutoStaticsCleanup] static readonly Regex k_ArrayDataAtEndRegex = new Regex(@"\.Array\.data\[[0-9]+\]$", RegexOptions.Compiled); + [NoAutoStaticsCleanup] static readonly Regex k_ArrayDataRegex = new Regex(@"\.Array\.data\[[0-9]+\]", RegexOptions.Compiled); private static FieldInfo GetFieldInfoFromPropertyPath(Type host, string path, out Type type) diff --git a/Editor/Mono/Inspector/Core/Utils/ObsoleteMessageHelper.cs b/Editor/Mono/Inspector/Core/Utils/ObsoleteMessageHelper.cs index 3f4d37b837..82b2178bd3 100644 --- a/Editor/Mono/Inspector/Core/Utils/ObsoleteMessageHelper.cs +++ b/Editor/Mono/Inspector/Core/Utils/ObsoleteMessageHelper.cs @@ -6,13 +6,15 @@ using System.Collections.Generic; using System.Reflection; using System.Text.RegularExpressions; +using Unity.Scripting.LifecycleManagement; using UnityEngine; using UnityEngine.UIElements; namespace UnityEditor { - static class ObsoleteMessageHelper + static partial class ObsoleteMessageHelper { + [NoAutoStaticsCleanup] // compiled from a fixed literal pattern with no runtime-state dependency; safe to persist across reload static Regex s_VersionTagRegex; // Matches version tags in the format #tagName(MAJOR.MINOR) where tagName is any latin letters. @@ -50,6 +52,7 @@ public ObsoleteMessageContainer(string message, HelpBoxMessageType messageType, } } + [AutoStaticsCleanupOnCodeReload] // lazy cache of obsolete type messages, must reset on reload private static Dictionary s_ObsoleteTypeMessages; private static Type ResolveReplacementType(string typeName) @@ -93,6 +96,8 @@ public static bool TryGetObsoleteMessage(Editor editor, out ObsoleteMessageConta foreach (var type in obsoleteTypes) { var attr = type.GetCustomAttribute(); + if (attr == null) + continue; // TODO: attr should never be null, it indicates a TypeCache issue!!! var message = string.IsNullOrEmpty(attr.Message) ? "This component has been marked as obsolete." : attr.Message; diff --git a/Editor/Mono/Inspector/Editor.cs b/Editor/Mono/Inspector/Editor.cs index c6a17fd216..1776516953 100644 --- a/Editor/Mono/Inspector/Editor.cs +++ b/Editor/Mono/Inspector/Editor.cs @@ -14,6 +14,7 @@ using UnityEngine.Scripting; using UnityEngine.UIElements; using Unity.Collections; +using Unity.Scripting.LifecycleManagement; using Component = UnityEngine.Component; using UnityObject = UnityEngine.Object; @@ -410,7 +411,7 @@ propertyViewer is EditorWindow editorWindow ? editorWindow.dataModeController.dataMode : DataMode.Disabled; - internal static float kLineHeight = EditorGUI.kSingleLineHeight; + internal static readonly float kLineHeight = EditorGUI.kSingleLineHeight; [VisibleToOtherModules("UnityEditor.ShaderFoundryModule")] internal bool hideInspector = false; @@ -420,8 +421,10 @@ propertyViewer is EditorWindow editorWindow internal const float k_HeaderHeight = 21f; internal delegate void OnEditorGUIDelegate(Editor editor, Rect drawRect); + [AutoStaticsCleanupOnCodeReload] // delegate field holding editor icon callbacks internal static OnEditorGUIDelegate OnPostIconGUI = null; + [NoAutoStaticsCleanup] // enabled/disabled within a frame (Dispose pattern), safe to persist internal static bool m_AllowMultiObjectAccess = true; bool m_HasUnsavedChanges = false; @@ -937,6 +940,7 @@ public virtual bool RequiresConstantRepaint() return false; } + [AutoStaticsCleanupOnCodeReload] // event holds user-registered header GUI callbacks public static event Action finishedDefaultHeaderGUI = null; // This is the method that should be called from externally e.g. myEditor.DrawHeader (); diff --git a/Editor/Mono/Inspector/EditorElementUpdater.cs b/Editor/Mono/Inspector/EditorElementUpdater.cs index 59ce1d9dc6..f6684234fc 100644 --- a/Editor/Mono/Inspector/EditorElementUpdater.cs +++ b/Editor/Mono/Inspector/EditorElementUpdater.cs @@ -88,31 +88,52 @@ public void CreateInspectorElementsWithoutLayout(int count) /// Invokes followed by a layout until the given is filled. /// /// The viewport to build elements for. - /// + /// The content container to build elements into. public void CreateInspectorElementsForViewport(ScrollView viewport, VisualElement contentContainer) { if (m_Index >= m_EditorElements.Count) return; + // Filling the viewport is the same operation as keeping the current scroll offset reachable: build until + // the content extends a viewport height past it. Building can disturb the scroller, so restore it after. var scroll = viewport.verticalScroller.value; + CreateInspectorElementsToReachScrollOffset(viewport, contentContainer, scroll); + viewport.verticalScroller.value = scroll; + } + /// + /// Invokes followed by a layout until the built content extends a + /// viewport height past , i.e. until can be applied to + /// the vertical scroller without being clamped. + /// + /// + /// Only the editors needed to make the offset reachable are built up front; the rest are left to the time-sliced + /// update loop. This avoids the main-thread stall of synchronously building every editor while still letting the + /// scroll offset be restored without clamping to 0. Used both to fill the viewport on the first build (offset = + /// current scroll) and to restore a saved scroll offset on a rebuild. + /// + /// The viewport being filled. + /// The content container to build elements into. + /// The vertical scroll offset that must remain reachable. + public void CreateInspectorElementsToReachScrollOffset(ScrollView viewport, VisualElement contentContainer, float targetOffset) + { while (m_Index < m_EditorElements.Count) { var element = m_EditorElements[m_Index++]; element.CreateInspectorElement(); - // If this element contributes to the layout, re-compute it immediately to determine how much of the viewport is occupied. - if (null != element.editor && InternalEditorUtility.GetIsInspectorExpanded(element.editor.target)&& contentContainer.childCount>0) + // If this element contributes to the layout, re-compute it immediately to measure how tall the content is. + if (element.editor != null && InternalEditorUtility.GetIsInspectorExpanded(element.editor.target) && contentContainer.childCount > 0) { Panel?.UpdateWithoutRepaint(); - if (contentContainer.ElementAt(contentContainer.childCount - 1).layout.yMax - scroll > viewport.layout.height) + // Stop once the built content extends past the target offset plus the viewport: the scroller's high + // value then covers the offset, so re-applying it won't clamp. Remaining editors only add height. + if (contentContainer.ElementAt(contentContainer.childCount - 1).layout.yMax - targetOffset > viewport.layout.height) break; } } - - viewport.verticalScroller.value = scroll; } /// @@ -140,7 +161,7 @@ public void CreateInspectorElementsForMilliseconds(long targetMilliseconds) break; // If this element contributes to the layout, re-compute it immediately to determine how much of the viewport is occupied. - if (null != element.editor && InternalEditorUtility.GetIsInspectorExpanded(element.editor.target)) + if (element.editor != null && InternalEditorUtility.GetIsInspectorExpanded(element.editor.target)) Panel?.UpdateWithoutRepaint(); } } diff --git a/Editor/Mono/Inspector/EditorSettingsInspector.cs b/Editor/Mono/Inspector/EditorSettingsInspector.cs index 90062ca820..9796a06520 100644 --- a/Editor/Mono/Inspector/EditorSettingsInspector.cs +++ b/Editor/Mono/Inspector/EditorSettingsInspector.cs @@ -126,6 +126,9 @@ class Content }; public static readonly GUIContent numberingHierarchyScheme = EditorGUIUtility.TrTextContent("Game Object Naming"); public static readonly GUIContent numberingHierarchyDigits = EditorGUIUtility.TrTextContent("Game Object Digits"); + + public static readonly GUIContent hierarchy = EditorGUIUtility.TrTextContent("Hierarchy"); + public static readonly GUIContent useLegacyHierarchy = EditorGUIUtility.TrTextContent("Use Legacy Hierarchy", "Use the legacy Hierarchy window."); public static readonly GUIContent numberingProjectSpace = EditorGUIUtility.TrTextContent("Space Before Number in Asset Names"); public static GUIContent referencedClipsExactNaming = EditorGUIUtility.TrTextContent("Exactly Match Referenced Clip Names", "Controls how referenced clips are matched with models that are animated in Legacy mode. If turned on, the model name and the referenced clip names must exactly match. If turned off, only the start of the model name needs to match the referenced clip name. Also controls the behavior of the \"Update referenced clips\" button for models that are animated in Humanoid mode. See the documentation for EditorSettings.referencedClipsExactNaming for more details."); @@ -563,6 +566,7 @@ public override void OnInspectorGUI() DoShadersSettings(); DoEnterPlayModeSettings(); DoNumberingSchemeSettings(); + DoHierarchySettings(); DoEnterInspectorSettings(); DoBuildProfileSettings(); @@ -957,6 +961,18 @@ private void DoStreamingSettings() } EditorSettings.NamingScheme m_PrevGoNamingScheme; + private void DoHierarchySettings() + { + GUILayout.Space(10); + GUILayout.Label(Content.hierarchy, EditorStyles.boldLabel); + + EditorGUI.BeginChangeCheck(); + bool useLegacyHierarchy = EditorSettings.useLegacyHierarchy; + useLegacyHierarchy = EditorGUILayout.Toggle(Content.useLegacyHierarchy, useLegacyHierarchy); + if (EditorGUI.EndChangeCheck()) + EditorSettings.useLegacyHierarchy = useLegacyHierarchy; + } + int m_PrevGoNamingDigits = -1; string m_GoNamingHelpText; static string GetNewName(string name, List names) @@ -1173,6 +1189,7 @@ private void SetSpritePackerMode(object data) // Legacy Packer has been obsoleted (1 & 2). Disabled (0) is still valid. popupIndex = (popupIndex != 0) ? (popupIndex + spritePackDeprecatedEnums) : 0; + bool refreshSprite = (m_SpritePackerMode.intValue != popupIndex); m_SpritePackerMode.intValue = popupIndex; if (m_IsGlobalSettings) @@ -1183,6 +1200,11 @@ private void SetSpritePackerMode(object data) UnityEditor.U2D.SpriteAtlasImporter.MigrateAllSpriteAtlases(); } } + + if (refreshSprite) + { + UnityEditor.U2D.SpriteAtlasUtility.OnSpriteAtlasSettingsChanged(); + } } private void SetRefreshImportMode(object data) diff --git a/Editor/Mono/Inspector/GraphicsSettingsInspector.cs b/Editor/Mono/Inspector/GraphicsSettingsInspector.cs index bebf999fae..7d06a6c636 100644 --- a/Editor/Mono/Inspector/GraphicsSettingsInspector.cs +++ b/Editor/Mono/Inspector/GraphicsSettingsInspector.cs @@ -130,8 +130,8 @@ void Setup(bool globalSettingsExist) SetupBiRPDeprecationInfoBox(m_CurrentRoot); - BindEnumFieldWithFadeGroup(m_CurrentRoot, "Lightmap", ShaderUtil.CalculateLightmapStrippingFromCurrentScene); - BindEnumFieldWithFadeGroup(m_CurrentRoot, "Fog", ShaderUtil.CalculateFogStrippingFromCurrentScene); + BindEnumFieldWithFadeGroup(m_CurrentRoot, "Lightmap", CalculateLightmapStrippingFromCurrentScene); + BindEnumFieldWithFadeGroup(m_CurrentRoot, "Fog", CalculateFogStrippingFromCurrentScene); BindEnumFieldToLightProbe(m_CurrentRoot); BindEnumFieldToDefaultLightBaker(m_CurrentRoot); @@ -173,10 +173,23 @@ void Setup(bool globalSettingsExist) m_CurrentRoot.Bind(serializedObject); } + void CalculateLightmapStrippingFromCurrentScene() + { + Undo.RegisterCompleteObjectUndo(target, L10n.Tr("Calculate Lightmap Stripping From Current Scene")); + ShaderUtil.CalculateLightmapStrippingFromCurrentScene(); + } + + void CalculateFogStrippingFromCurrentScene() + { + Undo.RegisterCompleteObjectUndo(target, L10n.Tr("Calculate Fog Stripping From Current Scene")); + ShaderUtil.CalculateFogStrippingFromCurrentScene(); + } + void BindShaderPreload(VisualElement root) { var shaderPreloadProperty = serializedObject.FindProperty("m_PreloadedShaders"); shaderPreloadProperty.isExpanded = false; + var recommendGSCInfoBox = root.MandatoryQ("RecommendGSCInfoBox"); var shaderPreloadPropertyField = root.MandatoryQ("PreloadedShaders"); shaderPreloadPropertyField.onGUIHandler = () => @@ -188,7 +201,7 @@ void BindShaderPreload(VisualElement root) if (EditorGUI.EndChangeCheck()) { shaderPreloadProperty.serializedObject.ApplyModifiedProperties(); - UIElementsEditorUtility.SetVisibility(root.MandatoryQ("RecommendGSCInfoBox"), shaderPreloadProperty.arraySize > 0 && shaderPreloadProperty.GetArrayElementAtIndex(0).objectReferenceValue); + UIElementsEditorUtility.SetVisibility(recommendGSCInfoBox, shaderPreloadProperty.arraySize > 0 && shaderPreloadProperty.GetArrayElementAtIndex(0).objectReferenceValue); } }; @@ -196,7 +209,18 @@ void BindShaderPreload(VisualElement root) var shaderPreloadToggle = root.MandatoryQ("ShaderPreloadToggle"); var delayedShaderTimeLimitGroup = root.MandatoryQ("DelayedShaderTimeLimitGroup"); var delayedShaderTimeLimit = root.MandatoryQ("DelayedShaderTimeLimit"); - shaderPreloadToggle.RegisterValueChangedCallback(evt => { + + void RefreshFromProperty() + { + var value = delayedShaderTimeLimitProperty.intValue; + var enabled = value >= 0; + shaderPreloadToggle.SetValueWithoutNotify(enabled); + delayedShaderTimeLimit.SetValueWithoutNotify(Mathf.Max(0, value)); + delayedShaderTimeLimitGroup.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None; + } + + shaderPreloadToggle.RegisterValueChangedCallback(evt => + { delayedShaderTimeLimitGroup.style.display = evt.newValue ? DisplayStyle.Flex : DisplayStyle.None; var newVal = evt.newValue ? delayedShaderTimeLimit.value : -1; if (delayedShaderTimeLimitProperty.intValue != newVal) @@ -213,9 +237,10 @@ void BindShaderPreload(VisualElement root) delayedShaderTimeLimitProperty.serializedObject.ApplyModifiedProperties(); } }); - shaderPreloadToggle.SetValueWithoutNotify(delayedShaderTimeLimitProperty.intValue >= 0); - delayedShaderTimeLimit.SetValueWithoutNotify(Mathf.Max(0, delayedShaderTimeLimitProperty.intValue)); - delayedShaderTimeLimitGroup.style.display = delayedShaderTimeLimitProperty.intValue >= 0 ? DisplayStyle.Flex : DisplayStyle.None; + + RefreshFromProperty(); + // Re-sync when the property changes externally (e.g. Reset / Undo). + shaderPreloadToggle.TrackPropertyValue(delayedShaderTimeLimitProperty, _ => RefreshFromProperty()); var shaderTracking = root.MandatoryQ("ShaderTrackingInfoBox"); shaderTracking.schedule.Execute(() => @@ -377,7 +402,6 @@ void BindEnumFieldWithFadeGroup(VisualElement content, string id, Action buttonC var enumMode = content.MandatoryQ($"{id}Modes"); var enumModeGroup = content.MandatoryQ($"{id}ModesGroup"); var enumModeProperty = serializedObject.FindProperty($"m_{id}Stripping"); - UIElementsEditorUtility.SetVisibility(enumModeGroup, (StrippingModes)enumModeProperty.enumValueFlag == StrippingModes.Custom); UIElementsEditorUtility.BindSerializedProperty(enumMode, enumModeProperty, mode => UIElementsEditorUtility.SetVisibility(enumModeGroup, mode == StrippingModes.Custom)); content.MandatoryQ