// MIT License - Copyright (c) 2025 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Editor.Utils.WGroup { #if UNITY_EDITOR using System; using System.Collections.Generic; using UnityEditor; using UnityEditor.AnimatedValues; using UnityEngine; using WallstopStudios.UnityHelpers.Core.Helper; using WallstopStudios.UnityHelpers.Editor.CustomDrawers; using WallstopStudios.UnityHelpers.Editor.Settings; /// /// Diagnostics helper for debugging WGroup indentation issues. /// /// /// Enable logging by setting to true. Logs are written to the Unity console /// with the prefix "[WGroupIndent]" for easy filtering. /// internal static class WGroupIndentDiagnostics { /// /// When true, enables diagnostic logging for WGroup padding operations. /// internal static bool Enabled { get; set; } /// /// When set, only logs for groups matching this name (substring match). /// Leave null to log all groups. /// internal static string GroupNameFilter { get; set; } private const string LogPrefix = "[WGroupIndent] "; private static bool ShouldLog(string groupName) { if (!Enabled) { return false; } if (!string.IsNullOrEmpty(GroupNameFilter)) { if (string.IsNullOrEmpty(groupName)) { return false; } if ( groupName.IndexOf(GroupNameFilter, System.StringComparison.OrdinalIgnoreCase) < 0 ) { return false; } } return true; } internal static void LogPushPadding( string groupName, float horizontalPadding, float leftPadding, float rightPadding, int indentLevel ) { if (!ShouldLog(groupName)) { return; } Debug.Log( $"{LogPrefix}PushPadding: group={groupName ?? "(null)"}, " + $"horizontal={horizontalPadding:F2}, left={leftPadding:F2}, right={rightPadding:F2}, " + $"indentLevel={indentLevel}, " + $"beforePush: totalLeft={GroupGUIWidthUtility.CurrentLeftPadding:F2}, " + $"totalRight={GroupGUIWidthUtility.CurrentRightPadding:F2}, " + $"depth={GroupGUIWidthUtility.CurrentScopeDepth}" ); } internal static void LogAfterPush(string groupName) { if (!ShouldLog(groupName)) { return; } Debug.Log( $"{LogPrefix}AfterPush: group={groupName ?? "(null)"}, " + $"totalLeft={GroupGUIWidthUtility.CurrentLeftPadding:F2}, " + $"totalRight={GroupGUIWidthUtility.CurrentRightPadding:F2}, " + $"depth={GroupGUIWidthUtility.CurrentScopeDepth}" ); } internal static void LogAfterPop(string groupName) { if (!ShouldLog(groupName)) { return; } Debug.Log( $"{LogPrefix}AfterPop: group={groupName ?? "(null)"}, " + $"totalLeft={GroupGUIWidthUtility.CurrentLeftPadding:F2}, " + $"totalRight={GroupGUIWidthUtility.CurrentRightPadding:F2}, " + $"depth={GroupGUIWidthUtility.CurrentScopeDepth}" ); } internal static void LogDrawProperty(string groupName, string propertyPath, int indentLevel) { if (!ShouldLog(groupName)) { return; } Debug.Log( $"{LogPrefix}DrawProperty: group={groupName ?? "(null)"}, " + $"property={propertyPath}, indentLevel={indentLevel}, " + $"totalLeft={GroupGUIWidthUtility.CurrentLeftPadding:F2}, " + $"totalRight={GroupGUIWidthUtility.CurrentRightPadding:F2}" ); } } internal static class WGroupGUI { internal delegate bool PropertyOverride( SerializedObject owner, SerializedProperty property ); public static void DrawRectUntinted(Rect rect, Color color) { Color previousColor = GUI.color; GUI.color = Color.white; EditorGUI.DrawRect(rect, color); GUI.color = previousColor; } /// /// Draws a group using a pre-built property lookup to avoid FindProperty allocations. /// internal static void DrawGroup( WGroupDefinition definition, SerializedObject serializedObject, Dictionary foldoutStates, IReadOnlyDictionary propertyLookup, PropertyOverride overrideDrawer = null ) { DrawGroupInternal( definition, serializedObject, foldoutStates, propertyLookup, overrideDrawer ); } internal static void DrawGroup( WGroupDefinition definition, SerializedObject serializedObject, Dictionary foldoutStates, PropertyOverride overrideDrawer = null ) { DrawGroupInternal(definition, serializedObject, foldoutStates, null, overrideDrawer); } private static void DrawGroupInternal( WGroupDefinition definition, SerializedObject serializedObject, Dictionary foldoutStates, IReadOnlyDictionary propertyLookup, PropertyOverride overrideDrawer ) { if (definition == null || serializedObject == null) { return; } // Use a plain vertical group for layout, then manually draw the helpBox background. // This prevents helpBox styling from affecting child element rendering // (which was causing tinted headers on Unity's built-in ReorderableList for arrays/lists). EditorGUILayout.BeginVertical(); Rect groupRect = EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true)); { bool expanded = true; bool allowHeader = !definition.HideHeader; bool headerHasFoldout = HeaderHasFoldout(definition); AnimBool foldoutAnim = null; int targetInstanceId = serializedObject?.targetObject?.GetInstanceID() ?? 0; if (headerHasFoldout) { expanded = DrawFoldoutHeader(definition, foldoutStates, targetInstanceId); // Initialize animation state only for collapsible groups when tweening is enabled if (UnityHelpersSettings.ShouldTweenWGroupFoldouts()) { foldoutAnim = WGroupAnimationState.GetOrCreateAnim( definition, expanded, targetInstanceId ); } } else if (allowHeader) { DrawHeader(definition.DisplayName); } // Calculate fade progress for animated content float fade = foldoutAnim?.faded ?? (expanded ? 1f : 0f); if (foldoutAnim == null) { // No animation - draw content immediately when expanded if (expanded) { DrawGroupContent( definition, serializedObject, foldoutStates, propertyLookup, overrideDrawer, allowHeader ); } } else { // Animated - use fade group for smooth transition bool visible = EditorGUILayout.BeginFadeGroup(fade); if (visible) { DrawGroupContent( definition, serializedObject, foldoutStates, propertyLookup, overrideDrawer, allowHeader ); } EditorGUILayout.EndFadeGroup(); } } EditorGUILayout.EndVertical(); // Draw helpBox background manually after getting the final rect. // This preserves the visual appearance without affecting child rendering. if (Event.current.type == EventType.Repaint) { // Get the full group rect including any padding Rect backgroundRect = groupRect; // Add some padding to match helpBox appearance backgroundRect.x -= 3f; backgroundRect.width += 6f; backgroundRect.y -= 2f; backgroundRect.height += 4f; EditorStyles.helpBox.Draw(backgroundRect, false, false, false, false); } EditorGUILayout.EndVertical(); EditorGUILayout.Space(6f); } private static bool DrawFoldoutHeader( WGroupDefinition definition, Dictionary foldoutStates, int targetInstanceId ) { int key = Objects.HashCode( definition.Name, definition.AnchorPropertyPath, targetInstanceId ); if (foldoutStates != null && foldoutStates.TryGetValue(key, out bool expanded)) { // value already loaded } else { expanded = !definition.StartCollapsed; } GUIStyle foldoutStyle = WGroupStyles.GetFoldoutStyle(); float headerHeight = WGroupStyles.GetHeaderHeight(); Rect headerRect = GUILayoutUtility.GetRect( GUIContent.none, foldoutStyle, GUILayout.ExpandWidth(true), GUILayout.Height(headerHeight) ); bool headerHasFoldout = HeaderHasFoldout(definition); Rect foldoutRect = WGroupHeaderVisualUtility.GetContentRect( headerRect, WGroupStyles.HeaderTopPadding, WGroupStyles.HeaderBottomPadding, headerHasFoldout ); int originalIndent = EditorGUI.indentLevel; EditorGUI.indentLevel = 0; expanded = EditorGUI.Foldout( foldoutRect, expanded, definition.DisplayName, true, foldoutStyle ); EditorGUI.indentLevel = originalIndent; if (foldoutStates != null) { foldoutStates[key] = expanded; } GUILayout.Space(2f); return expanded; } private static Rect DrawHeader(string displayName) { if (string.IsNullOrEmpty(displayName)) { return Rect.zero; } GUIContent content = EditorGUIUtility.TrTextContent(displayName); GUIStyle labelStyle = WGroupStyles.GetHeaderLabelStyle(); float headerHeight = WGroupStyles.GetHeaderHeight(); Rect labelRect = GUILayoutUtility.GetRect( content, labelStyle, GUILayout.ExpandWidth(true), GUILayout.Height(headerHeight) ); Rect contentRect = WGroupHeaderVisualUtility.GetContentRect( labelRect, WGroupStyles.HeaderTopPadding, WGroupStyles.HeaderBottomPadding ); GUI.Label(contentRect, content, labelStyle); GUILayout.Space(2f); return labelRect; } private static void DrawGroupContent( WGroupDefinition definition, SerializedObject serializedObject, Dictionary foldoutStates, IReadOnlyDictionary propertyLookup, PropertyOverride overrideDrawer, bool allowHeader ) { if (allowHeader) { EditorGUILayout.Space(2f); } EditorGUI.indentLevel++; float horizontalPadding = GroupGUIWidthUtility.CalculateHorizontalPadding( EditorStyles.helpBox, out float leftPadding, out float rightPadding ); WGroupIndentDiagnostics.LogPushPadding( definition?.Name, horizontalPadding, leftPadding, rightPadding, EditorGUI.indentLevel ); using ( GroupGUIWidthUtility.PushContentPadding( horizontalPadding, leftPadding, rightPadding ) ) { WGroupIndentDiagnostics.LogAfterPush(definition?.Name); IReadOnlyList propertyPaths = definition.PropertyPaths; int propertyCount = propertyPaths.Count; if (propertyCount > 0) { AddContentPadding(); } // Build lookup of child groups by their anchor path Dictionary childByAnchor = null; if (definition.ChildGroups.Count > 0) { childByAnchor = new Dictionary( StringComparer.Ordinal ); foreach (WGroupDefinition child in definition.ChildGroups) { childByAnchor[child.AnchorPropertyPath] = child; } } for (int index = 0; index < propertyCount; index++) { string propertyPath = propertyPaths[index]; // Check if this is a child group anchor if ( childByAnchor != null && childByAnchor.TryGetValue(propertyPath, out WGroupDefinition childGroup) ) { // Render child group recursively DrawGroup( childGroup, serializedObject, foldoutStates, propertyLookup, overrideDrawer ); continue; } SerializedProperty property = ResolveProperty( serializedObject, propertyPath, propertyLookup ); if (property == null) { continue; } // Check WShowIf condition - this handles conditional visibility for all properties // including arrays/lists which need editor-level handling since PropertyDrawers // for attributes on arrays only affect elements, not the array itself if (!WShowIfPropertyDrawer.ShouldShowProperty(property)) { continue; } WGroupIndentDiagnostics.LogDrawProperty( definition?.Name, propertyPath, EditorGUI.indentLevel ); if (overrideDrawer != null && overrideDrawer(serializedObject, property)) { continue; } // All properties are drawn within a WGroup property context. // Custom drawers can check GroupGUIWidthUtility.IsInsideWGroupPropertyDraw // to detect they're inside a WGroup and adjust their layout accordingly. // Simple properties use indent compensation; complex properties with custom // drawers can handle their own layout knowing they're in WGroup context. using (GroupGUIWidthUtility.PushWGroupPropertyContext()) { if (property.hasVisibleChildren) { // Reset GUI.color to prevent helpBox background from tinting // Unity's built-in ReorderableList header (for arrays/lists) Color prevColor = GUI.color; GUI.color = Color.white; EditorGUILayout.PropertyField(property, true); GUI.color = prevColor; } else { GroupGUIIndentUtility.ExecuteWithIndentCompensation(() => EditorGUILayout.PropertyField(property, true) ); } } } if (propertyCount > 0) { AddContentPadding(); } } WGroupIndentDiagnostics.LogAfterPop(definition?.Name); EditorGUI.indentLevel--; } private static bool HeaderHasFoldout(WGroupDefinition definition) { if (definition == null) { return false; } if (definition.HideHeader) { return false; } return definition.Collapsible; } private static void AddContentPadding() { float spacing = Mathf.Max(1f, EditorGUIUtility.standardVerticalSpacing); GUILayout.Space(spacing); } private static SerializedProperty ResolveProperty( SerializedObject serializedObject, string propertyPath, IReadOnlyDictionary propertyLookup ) { if ( propertyLookup != null && propertyLookup.TryGetValue(propertyPath, out SerializedProperty cached) ) { return cached; } return serializedObject.FindProperty(propertyPath); } } internal static class WGroupStyles { internal const float HeaderTopPadding = 1f; internal const float HeaderBottomPadding = 1f; internal const float HeaderVerticalPadding = 4f; private static GUIStyle _foldoutStyle; private static GUIStyle _headerLabelStyle; internal static float GetHeaderHeight() { GUIStyle foldoutStyle = GetFoldoutStyle(); GUIStyle headerStyle = GetHeaderLabelStyle(); float foldoutLineHeight = Mathf.Max( EditorGUIUtility.singleLineHeight, foldoutStyle.lineHeight ); float headerLineHeight = Mathf.Max( EditorGUIUtility.singleLineHeight, headerStyle.lineHeight ); float tallestLineHeight = Mathf.Max(foldoutLineHeight, headerLineHeight); return tallestLineHeight + HeaderVerticalPadding; } internal static GUIStyle GetFoldoutStyle() { if (_foldoutStyle == null) { _foldoutStyle = new GUIStyle(EditorStyles.foldoutHeader) { fontStyle = FontStyle.Bold, padding = new RectOffset(16, 6, 3, 3), }; } return _foldoutStyle; } internal static GUIStyle GetHeaderLabelStyle() { if (_headerLabelStyle == null) { _headerLabelStyle = new GUIStyle(EditorStyles.boldLabel) { alignment = TextAnchor.MiddleLeft, padding = new RectOffset(0, 4, 0, 0), }; } return _headerLabelStyle; } } internal static class WGroupHeaderVisualUtility { private const float HorizontalContentPadding = 3f; private const float VerticalContentPaddingTop = 1f; private const float VerticalContentPaddingBottom = 3f; /// /// Additional left offset applied to foldout header content rects in Inspector context. /// /// /// This offset ensures the foldout arrow is visually contained within the /// header background box. Combined with HorizontalContentPadding (3f) and /// the foldout style's internal left padding (16px), this creates proper /// visual encapsulation of the header content. /// Total left offset in Inspector: 3f (horizontal) + 9f (foldout) + 16f (style) = 28f /// private const float FoldoutContentOffsetInspector = 9f; /// /// Additional left offset applied to foldout header content rects in Settings context. /// /// /// In SettingsProvider contexts, the helpBox and ambient padding already provide /// sufficient visual structure. Using zero offset here prevents excessive indentation. /// Total left offset in Settings: 3f (horizontal) + 0f (foldout) + 16f (style) = 19f /// private const float FoldoutContentOffsetSettings = 0f; private static bool _isSettingsContext; /// /// Gets or sets whether WGroup headers are being drawn in a SettingsProvider context. /// /// /// When true, foldout headers use reduced left offset to prevent excessive indentation. /// This should be set to true before drawing WGroups in SettingsProvider.guiHandler /// and reset to false afterward. /// internal static bool IsSettingsContext { get => _isSettingsContext; set => _isSettingsContext = value; } private static float CurrentFoldoutContentOffset => _isSettingsContext ? FoldoutContentOffsetSettings : FoldoutContentOffsetInspector; /// /// A disposable scope that sets to true for its duration. /// /// /// Use this scope when drawing WGroups in SettingsProvider contexts to ensure /// proper indentation behavior. /// internal sealed class SettingsContextScope : IDisposable { private readonly bool _previousValue; private bool _disposed; public SettingsContextScope() { _previousValue = _isSettingsContext; _isSettingsContext = true; } public void Dispose() { if (_disposed) { return; } _disposed = true; _isSettingsContext = _previousValue; } } internal static Rect GetContentRect( Rect rect, float additionalTopPadding, float additionalBottomPadding, bool includeFoldoutOffset = false ) { if (rect.width <= 0f || rect.height <= 0f) { return rect; } Rect contentRect = rect; float horizontal = HorizontalContentPadding; float topPadding = VerticalContentPaddingTop + Mathf.Max(0f, additionalTopPadding); float bottomPadding = VerticalContentPaddingBottom + Mathf.Max(0f, additionalBottomPadding); contentRect.xMin += horizontal; contentRect.xMax -= horizontal; if (includeFoldoutOffset) { contentRect.xMin += CurrentFoldoutContentOffset; } contentRect.yMin += topPadding; contentRect.yMax -= bottomPadding; if (contentRect.xMax < contentRect.xMin) { float centerX = rect.center.x; contentRect.xMin = centerX; contentRect.xMax = centerX; } if (contentRect.yMax < contentRect.yMin) { float centerY = rect.center.y; contentRect.yMin = centerY; contentRect.yMax = centerY; } return contentRect; } } #endif }