using System; using System.Collections.Generic; using System.Reflection; using UnityEditor; using UnityEngine; using UnityTcp.Editor.Helpers; namespace UnityTcp.Editor.Native { internal static partial class NativeWindowBridgeHost { private const string kCompositeSlotMappingKey = "NWB_CompositeSlotMapping"; private const string kCompositePrePlayTabKey = "NWB_CompositePrePlayTab"; private const string kCompositeRestoreJsonKey = "NWB_CompositeRestoreJson"; private const string kCompositeRestoreFpsKey = "NWB_CompositeRestoreFps"; private const string kDefaultCompositeLayoutPreset = "unity-default-layout"; private const string kSavedCompositeLayoutPreset = "codely-saved-layout"; // Saved when StartCompositeOffscreenCapture succeeds so domain reload // can auto-resume without waiting for the browser to re-send the layout. private static string s_LastCompositeRequestJson; private enum CompositeLayoutApplyMode { Auto = 0, AppendSingle = 1, StrictReplace = 2, PreserveExisting = 3, ReplaceManagedKeepNative = 4 } [Serializable] private sealed class CompositeStreamRequest { public int fps = 30; public int width; public int height; public string layoutPreset; public string applyMode; public string focusedSlotId; public CompositeSlotRequest[] slots; } [Serializable] private sealed class CompositeSlotRequest { public string slotId; public string windowType; public string title; public CompositeRect rect; } [Serializable] private sealed class CompositeRect { public float x; public float y; public float w; public float h; } [Serializable] private sealed class CompositeSlotMappingEntry { public string slotId; public long instanceId; public string windowType; public CompositeRect rect; } [Serializable] private sealed class CompositeSlotMappingStore { public CompositeSlotMappingEntry[] entries; } private sealed class CompositeCaptureSlot { public string SlotId; public string WindowTypeName; public EditorWindow Window; public System.Type WindowType; public RenderTexture SourceRT; public Rect NormalizedRect; public bool OwnsWindow; public bool CloseWindowOnRelease; #if UNITY_EDITOR_WIN // Win32 HWNDs for keyboard injection (SetFocus + PostMessageW). // Detected after Show() using the before/after HWND diff technique. public IntPtr ContainerHwnd; public IntPtr GUIViewHwnd; #endif #if UNITY_EDITOR_OSX // NSWindow pointer for keyboard injection (activateIgnoringOtherApps + makeKeyWindow). public IntPtr NSWindow; #endif } private sealed class CompositeLayoutNode { public CompositeSlotRequest Slot; public CompositeLayoutNode First; public CompositeLayoutNode Second; public string Edge; } private sealed class CompositeLayoutOperation { public string Preset; public string HierarchySlotId; public string GameSlotId; public string SceneSlotId; public string ProjectSlotId; public string ConsoleSlotId; public string InspectorSlotId; public int Step; public int WaitFrames; public int Attempts; } private static bool s_CompositeActive; private static readonly Dictionary s_CompositeSlots = new Dictionary(); private static RenderTexture s_CompositeRT; private static RenderTexture s_CompositePaneDragRT; private static Texture2D s_CompositeReadbackTex; private static int s_CompositeLogicalWidth; private static int s_CompositeLogicalHeight; private static Rect s_CompositeFrameBoundsLogical = Rect.zero; private static Vector2 s_CompositeFrameOffsetPixels = Vector2.zero; private static Vector2 s_CompositeFrameScalePixels = Vector2.one; private static bool s_CompositeWorkspaceReflowPending; #pragma warning disable CS0414 private static int s_CompositeLastViewCount; #pragma warning restore CS0414 private static int s_CompositeSkipFrames; private static CompositeLayoutOperation s_CompositeLayoutOperation; #pragma warning disable CS0414 private static bool s_CompositePlayModeInitDone; private static int s_CompositeForceRepaintFrames; #pragma warning restore CS0414 private static object s_CompositeMouseCaptureView; private static CompositeCaptureSlot s_CompositeMouseCaptureSlot; private static EditorWindow s_CompositeLastTabTarget; /// /// Save composite slot mappings so surviving windows can be rediscovered /// after domain reload without closing them. /// private static void SaveCompositeSlotMapping() { if (s_CompositeSlots == null || s_CompositeSlots.Count == 0) { SessionState.EraseString(kCompositeSlotMappingKey); return; } var entries = new List(); foreach (var kvp in s_CompositeSlots) { var slot = kvp.Value; if (slot?.Window == null) continue; entries.Add(new CompositeSlotMappingEntry { slotId = kvp.Key, instanceId = slot.Window.GetStableInstanceId(), windowType = slot.WindowTypeName ?? string.Empty, rect = new CompositeRect { x = slot.NormalizedRect.x, y = slot.NormalizedRect.y, w = slot.NormalizedRect.width, h = slot.NormalizedRect.height } }); } if (entries.Count == 0) { SessionState.EraseString(kCompositeSlotMappingKey); return; } string json = JsonUtility.ToJson(new CompositeSlotMappingStore { entries = entries.ToArray() }); SessionState.SetString(kCompositeSlotMappingKey, json); LogVerbose($"[NWB-Composite] Saved slot mapping ({entries.Count} slots) before reload"); } /// /// After domain reload, rediscover surviving EditorWindows from the /// saved mapping and pre-populate s_CompositeSlots. /// private static void PrePopulateCompositeSlots() { string json = SessionState.GetString(kCompositeSlotMappingKey, ""); SessionState.EraseString(kCompositeSlotMappingKey); if (string.IsNullOrEmpty(json)) return; CompositeSlotMappingStore store = null; try { store = JsonUtility.FromJson(json); } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-Composite] Failed to parse slot mapping: {ex.Message}"); return; } if (store?.entries == null || store.entries.Length == 0) return; int restored = 0; foreach (var entry in store.entries) { if (entry == null) continue; string slotId = entry.slotId; string typeName = entry.windowType; long instanceId = entry.instanceId; if (string.IsNullOrEmpty(slotId) || instanceId == 0) continue; var obj = InstanceIdExtensions.InstanceIdToObject(instanceId); var window = obj as EditorWindow; if (window == null) { LogVerbose($"[NWB-Composite] Slot '{slotId}' window id={instanceId} did not survive reload"); continue; } var slot = new CompositeCaptureSlot { SlotId = slotId, WindowTypeName = typeName, WindowType = window.GetType(), Window = window, NormalizedRect = ToUnityRect(entry.rect), OwnsWindow = true, CloseWindowOnRelease = false }; s_CompositeSlots[slotId] = slot; restored++; } if (restored > 0) LogVerbose($"[NWB-Composite] Pre-populated {restored} slots from surviving windows"); } private static Rect ToUnityRect(CompositeRect rect) { if (rect == null) return new Rect(0f, 0f, 1f, 1f); return new Rect( rect.x, rect.y, Mathf.Max(0.01f, rect.w), Mathf.Max(0.01f, rect.h)); } /// /// Find an existing DockArea from already-created composite slots. /// Used by CreateCompositeSlot to dock new views immediately. /// private static object FindExistingCompositeDockArea() { foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values) { if (slot?.Window == null) continue; object view = GetParentView(slot.Window); if (view != null && view.GetType().Name == "DockArea") return view; } return null; } private static bool StartCompositeOffscreenCapture(string requestJson, int fps, int width, int height, bool nativeAlreadyStarted = false, CompositeLayoutApplyMode applyMode = CompositeLayoutApplyMode.Auto) { #if UNITY_EDITOR_WIN || UNITY_EDITOR_OSX if (!IsAvailable()) return false; try { CompositeStreamRequest request = JsonUtility.FromJson(requestJson); if (request == null || request.slots == null || request.slots.Length == 0) { CodelyLogger.LogError("[NWB-Composite] Empty composite layout request"); return false; } int logicalW = Mathf.Max((width > 0 ? width : request.width), 320); int logicalH = Mathf.Max((height > 0 ? height : request.height), 240); fps = Mathf.Max(1, fps > 0 ? fps : request.fps); CompositeLayoutApplyMode resolvedApplyMode = ResolveCompositeLayoutApplyMode(request, applyMode); bool samePresetDetachedReconnect = ShouldPreserveCompositeLayoutForSamePreset(request); if (samePresetDetachedReconnect) { bool canKeepPreserve = TryNormalizeSlotIdsForPresetReuse(request) && TryEnsurePresetLayoutConsistencyWithoutRebuild(request); if (canKeepPreserve) { resolvedApplyMode = CompositeLayoutApplyMode.PreserveExisting; LogVerbose($"[NWB-Composite] Same preset detached reconnect, preserve existing layout: preset={request.layoutPreset} slots={s_CompositeSlots.Count}"); } else { // Rect consistency check failed and in-place tree convergence // could not be completed. Fall back to normal request-driven // rebuild path (strict/replace modes) for recovery. samePresetDetachedReconnect = false; resolvedApplyMode = ResolveCompositeLayoutApplyMode(request, applyMode); LogVerbose($"[NWB-Composite] Same preset detached reconnect fallback to rebuild: preset={request.layoutPreset} mode={resolvedApplyMode}"); } } LogVerbose($"[NWB-Composite] Apply mode: {resolvedApplyMode}, requested slots={request.slots.Length}, current slots={s_CompositeSlots.Count}, focusedSlotId={request.focusedSlotId}"); // Switch cleanly from single-window offscreen to composite mode. if (s_OffscreenActive && !s_CompositeActive && s_OffscreenTarget != null) { if (s_OffscreenAutoFloatingWindow) { try { s_OffscreenTarget.Close(); } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-Composite] Close old single target failed: {ex.Message}"); } } s_OffscreenTarget = null; s_OffscreenTargetType = null; s_OffscreenAutoFloatingWindow = false; s_OffscreenFloatingWindowInstanceId = 0; } EnsureCompositeRenderTargets(logicalW, logicalH); // Close existing docked GameView(s) BEFORE ApplyCompositeLayout creates // the floating slot windows. The docked GameView's size info // (PanelSettings.GetGameViewResolution) interferes with the floating // GameView's PanelSettings.m_TargetRect if still present during Show(). // Skip in PreserveExisting mode (domain reload resume) — the floating // GameView already survived the reload. if (!s_ClosedDockedGameViewForStreaming && resolvedApplyMode != CompositeLayoutApplyMode.PreserveExisting) { bool hasGameViewSlot = false; if (request.slots != null) { foreach (var slotReq in request.slots) { if (slotReq != null && !string.IsNullOrEmpty(slotReq.windowType) && slotReq.windowType.Contains("GameView")) { hasGameViewSlot = true; break; } } } if (hasGameViewSlot) CloseExistingGameViewsForStreaming(null); } ApplyCompositeLayout(request, logicalW, logicalH, resolvedApplyMode); if (resolvedApplyMode != CompositeLayoutApplyMode.PreserveExisting) TryActivateCompositeSlot(request.focusedSlotId); int physW = (Mathf.RoundToInt(logicalW * DPIScale) + 1) & ~1; int physH = (Mathf.RoundToInt(logicalH * DPIScale) + 1) & ~1; if (!nativeAlreadyStarted) { int nativeResult = NativeWindowBridgeAPI.NWB_StartOffscreenCapture(fps, physW, physH); if (nativeResult != 1) { CodelyLogger.LogError("[NWB-Composite] Native StartOffscreenCapture failed"); return false; } } bool wasAlreadyComposite = s_CompositeActive; s_CompositeActive = true; s_OffscreenActive = true; s_UseRenderTextureFallback = (PlayerSettings.colorSpace == ColorSpace.Gamma); if (s_UseRenderTextureFallback) CodelyLogger.Log("[NWB-Diag] Gamma color space detected — using PrintWindow fallback (no AuxBackBufferManager)"); s_OffscreenFps = fps; if (!wasAlreadyComposite) { s_OffscreenFrameCount = 0; s_CompositeLastViewCount = 0; } s_CompositeSkipFrames = 0; s_OffscreenTarget = null; s_OffscreenTargetType = null; s_NextCaptureTime = 0; s_HasFrontendHeartbeat = false; s_LastFrontendHeartbeatTime = EditorApplication.timeSinceStartup; RegisterOffscreenLoop(); RegisterSelectionChangeListener(); MakeAllWindowsTransparent(); DeactivateUnityKeepWindows(); #if UNITY_EDITOR_OSX ShowStreamingMaskWindow(); #endif #if UNITY_EDITOR_WIN DeferStreamingMaskWindow(); #endif InitCompositeWindowTracking(); // Cache the request JSON for domain reload auto-resume. s_LastCompositeRequestJson = requestJson; CodelyLogger.Log($"[NWB-Composite] Started slots={s_CompositeSlots.Count} canvas={logicalW}x{logicalH} phys={physW}x{physH} @{fps}fps nativeSkipped={nativeAlreadyStarted}"); return true; } catch (Exception ex) { CodelyLogger.LogError($"[NWB-Composite] Start failed: {ex.Message}\n{ex.StackTrace}"); return false; } #else return false; #endif } private static void EnsureCompositeRenderTargets(int logicalW, int logicalH) { logicalW = Mathf.Max((logicalW + 1) & ~1, 2); logicalH = Mathf.Max((logicalH + 1) & ~1, 2); int physW = (Mathf.RoundToInt(logicalW * DPIScale) + 1) & ~1; int physH = (Mathf.RoundToInt(logicalH * DPIScale) + 1) & ~1; #if UNITY_EDITOR_WIN var rtReadWrite = RenderTextureReadWrite.Linear; bool texLinear = true; #else var rtReadWrite = RenderTextureReadWrite.Default; bool texLinear = false; #endif if (s_CompositeRT == null || s_CompositeRT.width != physW || s_CompositeRT.height != physH) { if (s_CompositeRT != null) { s_CompositeRT.Release(); UnityEngine.Object.DestroyImmediate(s_CompositeRT); } s_CompositeRT = new RenderTexture(physW, physH, 0, RenderTextureFormat.BGRA32, rtReadWrite) { name = "__NWB_CompositeRT__", useMipMap = false, autoGenerateMips = false }; s_CompositeRT.Create(); } if (s_CompositeReadbackTex == null || s_CompositeReadbackTex.width != physW || s_CompositeReadbackTex.height != physH) { if (s_CompositeReadbackTex != null) UnityEngine.Object.DestroyImmediate(s_CompositeReadbackTex); s_CompositeReadbackTex = new Texture2D(physW, physH, TextureFormat.BGRA32, false, texLinear); } s_CompositeLogicalWidth = logicalW; s_CompositeLogicalHeight = logicalH; } private static CompositeLayoutApplyMode ResolveCompositeLayoutApplyMode( CompositeStreamRequest request, CompositeLayoutApplyMode requestedMode) { if (requestedMode != CompositeLayoutApplyMode.Auto) return requestedMode; string requested = request?.applyMode ?? string.Empty; if (string.Equals(requested, "append_single", StringComparison.OrdinalIgnoreCase)) return CompositeLayoutApplyMode.AppendSingle; if (string.Equals(requested, "strict_replace", StringComparison.OrdinalIgnoreCase)) return CompositeLayoutApplyMode.StrictReplace; if (string.Equals(requested, "preserve_existing", StringComparison.OrdinalIgnoreCase)) return CompositeLayoutApplyMode.PreserveExisting; if (string.Equals(requested, "replace_managed_keep_native", StringComparison.OrdinalIgnoreCase)) return CompositeLayoutApplyMode.ReplaceManagedKeepNative; int validCount = 0; if (request?.slots != null) { foreach (CompositeSlotRequest slot in request.slots) { if (slot != null && !string.IsNullOrEmpty(slot.windowType)) validCount++; } } // UX rule: // - Single-slot request means incremental add/update; keep existing slots. // - Multi-slot request means strict layout snapshot; replace by request. return validCount <= 1 ? CompositeLayoutApplyMode.AppendSingle : CompositeLayoutApplyMode.StrictReplace; } private static string GetLastCompositeLayoutPreset() { if (string.IsNullOrEmpty(s_LastCompositeRequestJson)) return string.Empty; try { CompositeStreamRequest last = JsonUtility.FromJson(s_LastCompositeRequestJson); return last?.layoutPreset ?? string.Empty; } catch (Exception) { return string.Empty; } } private static bool ShouldPreserveCompositeLayoutForSamePreset(CompositeStreamRequest request) { if (!s_NativeOffscreenDisconnectedAwaitingRestore) return false; if (s_CompositeSlots == null || s_CompositeSlots.Count == 0) return false; string preset = request?.layoutPreset ?? string.Empty; if (string.IsNullOrEmpty(preset)) return false; if (!string.Equals(preset, kDefaultCompositeLayoutPreset, StringComparison.Ordinal) && !string.Equals(preset, kSavedCompositeLayoutPreset, StringComparison.Ordinal)) return false; string lastPreset = GetLastCompositeLayoutPreset(); if (!string.Equals(lastPreset, preset, StringComparison.Ordinal)) return false; // Preserve only when current live slots can satisfy the full incoming // layout request. If user closed a view (e.g. default 6-slot -> 5-slot), // we must rebuild missing slots instead of preserving the incomplete state. return HasRequiredRequestedWindowTypesForPreserve(request); } private static bool HasRequiredRequestedWindowTypesForPreserve(CompositeStreamRequest request) { if (request?.slots == null || request.slots.Length == 0) return false; var required = new Dictionary(StringComparer.Ordinal); foreach (CompositeSlotRequest slot in request.slots) { string type = slot?.windowType; if (string.IsNullOrEmpty(type)) continue; if (!required.TryGetValue(type, out int count)) count = 0; required[type] = count + 1; } if (required.Count == 0) return false; var available = new Dictionary(StringComparer.Ordinal); foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values) { if (slot?.Window == null) continue; string type = slot.WindowTypeName; if (string.IsNullOrEmpty(type)) continue; if (!available.TryGetValue(type, out int count)) count = 0; available[type] = count + 1; } foreach (var kvp in required) { if (!available.TryGetValue(kvp.Key, out int got) || got < kvp.Value) return false; } return true; } private static bool TryNormalizeSlotIdsForPresetReuse(CompositeStreamRequest request) { if (!s_NativeOffscreenDisconnectedAwaitingRestore || request?.slots == null) return true; var usedExistingIds = new HashSet(StringComparer.Ordinal); foreach (CompositeSlotRequest req in request.slots) { string incomingId = string.IsNullOrEmpty(req?.slotId) ? req?.windowType : req.slotId; if (string.IsNullOrEmpty(incomingId) || req == null) continue; if (s_CompositeSlots.ContainsKey(incomingId)) { usedExistingIds.Add(incomingId); continue; } string remapFromId = null; foreach (var kvp in s_CompositeSlots) { if (usedExistingIds.Contains(kvp.Key)) continue; CompositeCaptureSlot candidate = kvp.Value; if (candidate?.Window == null) continue; if (!string.Equals(candidate.WindowTypeName, req.windowType, StringComparison.Ordinal)) continue; remapFromId = kvp.Key; break; } if (string.IsNullOrEmpty(remapFromId)) continue; CompositeCaptureSlot slot = s_CompositeSlots[remapFromId]; s_CompositeSlots.Remove(remapFromId); slot.SlotId = incomingId; s_CompositeSlots[incomingId] = slot; usedExistingIds.Add(incomingId); LogVerbose($"[NWB-Composite] Normalize preset reuse slotId: '{remapFromId}' -> '{incomingId}' ({req.windowType})"); } return true; } private static bool TryEnsurePresetLayoutConsistencyWithoutRebuild(CompositeStreamRequest request) { if (!TryBuildPresetTargetSlotsForConsistency(request, out List targetSlots)) return false; if (VerifySavedCompositeLayoutRects(targetSlots, 0.03f, out float rectError)) { LogVerbose($"[NWB-Composite] Preset layout rects already aligned (maxError={rectError:F4})"); return true; } LogVerbose($"[NWB-Composite] Preset layout rect mismatch (maxError={rectError:F4}), trying in-place tree convergence"); if (!TryApplySavedCompositeLayoutTree(targetSlots)) return false; CloseOrphanEmptyContainerWindows(); bool alignedAfter = VerifySavedCompositeLayoutRects(targetSlots, 0.03f, out float afterError); if (!alignedAfter) CodelyLogger.LogWarning($"[NWB-Composite] In-place preset tree convergence did not reach tolerance (maxError={afterError:F4})"); else LogVerbose($"[NWB-Composite] In-place preset tree convergence succeeded (maxError={afterError:F4})"); return alignedAfter; } private static bool TryBuildPresetTargetSlotsForConsistency( CompositeStreamRequest request, out List targetSlots) { targetSlots = null; string preset = request?.layoutPreset ?? string.Empty; if (string.IsNullOrEmpty(preset)) return false; if (string.Equals(preset, kSavedCompositeLayoutPreset, StringComparison.Ordinal)) { targetSlots = GetValidCompositeLayoutSlots(request); return targetSlots != null && targetSlots.Count > 0; } if (!string.Equals(preset, kDefaultCompositeLayoutPreset, StringComparison.Ordinal)) return false; if (!TryResolveDefaultLayoutSlotIds(request, out string hierarchySlotId, out string sceneSlotId, out string gameSlotId, out string projectSlotId, out string consoleSlotId, out string inspectorSlotId)) return false; targetSlots = BuildDefaultCompositeLayoutSlots( request, hierarchySlotId, sceneSlotId, gameSlotId, projectSlotId, consoleSlotId, inspectorSlotId); return targetSlots != null && targetSlots.Count > 0; } private static bool IsNativeTrackedCompositeSlot(string slotId) { return !string.IsNullOrEmpty(slotId) && slotId.StartsWith("native-", StringComparison.OrdinalIgnoreCase); } /// /// In detached-hidden state, frontend can re-generate slotId on each restart /// (for example using timestamp suffixes). Rebind the incoming slotId to an /// existing slot with the same window type so we can reuse the old floating /// window instead of creating a new one. /// private static CompositeCaptureSlot TryRemapDetachedCompositeSlot(string incomingSlotId, string windowTypeName, HashSet wanted, int logicalW, int logicalH, CompositeRect requestedRect) { if (!s_NativeOffscreenDisconnectedAwaitingRestore || string.IsNullOrEmpty(windowTypeName)) return null; string reusableSlotId = null; CompositeCaptureSlot reusableSlot = null; foreach (var kvp in s_CompositeSlots) { if (wanted.Contains(kvp.Key)) continue; CompositeCaptureSlot candidate = kvp.Value; if (candidate?.Window == null) continue; if (!string.Equals(candidate.WindowTypeName, windowTypeName, StringComparison.Ordinal)) continue; if (!IsDetachedRemapSizeCompatible(candidate, logicalW, logicalH, requestedRect, out string reason)) { LogVerbose($"[NWB-Composite] Skip detached remap candidate '{kvp.Key}' ({windowTypeName}): {reason}"); continue; } reusableSlotId = kvp.Key; reusableSlot = candidate; break; } if (reusableSlot == null) return null; if (!string.Equals(reusableSlotId, incomingSlotId, StringComparison.Ordinal)) { s_CompositeSlots.Remove(reusableSlotId); reusableSlot.SlotId = incomingSlotId; s_CompositeSlots[incomingSlotId] = reusableSlot; LogVerbose($"[NWB-Composite] Reuse detached slot by windowType: '{reusableSlotId}' -> '{incomingSlotId}' ({windowTypeName})"); } else { LogVerbose($"[NWB-Composite] Reuse detached slot: '{incomingSlotId}' ({windowTypeName})"); } return reusableSlot; } private static bool IsDetachedRemapSizeCompatible( CompositeCaptureSlot candidate, int logicalW, int logicalH, CompositeRect requestedRect, out string reason) { reason = string.Empty; if (candidate?.Window == null) { reason = "candidate window is null"; return false; } Rect nr = ClampCompositeRect(requestedRect); float expectedLogicalW = Mathf.Max(logicalW * nr.width, 1f); float expectedLogicalH = Mathf.Max(logicalH * nr.height, 1f); object parentView = GetParentView(candidate.Window); if (parentView == null) { reason = "parent view is missing"; return false; } Rect viewRect = GetViewScreenPosition(parentView); if (viewRect.width <= 1f || viewRect.height <= 1f) { reason = $"invalid view size {viewRect.width:F1}x{viewRect.height:F1}"; return false; } float expectedW = Mathf.Max(expectedLogicalW * LocalPP, 1f); float expectedH = Mathf.Max(expectedLogicalH * LocalPP, 1f); float actualW = Mathf.Max(viewRect.width * LocalPP, 1f); float actualH = Mathf.Max(viewRect.height * LocalPP, 1f); float widthTolerance = Mathf.Max(96f, expectedW * 0.25f); float heightTolerance = Mathf.Max(96f, expectedH * 0.25f); bool widthOk = Mathf.Abs(actualW - expectedW) <= widthTolerance; bool heightOk = Mathf.Abs(actualH - expectedH) <= heightTolerance; if (widthOk && heightOk) return true; reason = $"size mismatch actual={actualW:F0}x{actualH:F0} expected={expectedW:F0}x{expectedH:F0}"; return false; } private static void ApplyCompositeLayout(CompositeStreamRequest request, int logicalW, int logicalH, CompositeLayoutApplyMode applyMode) { bool preserveExisting = applyMode == CompositeLayoutApplyMode.PreserveExisting; bool strictReplace = applyMode == CompositeLayoutApplyMode.StrictReplace; bool replaceManagedKeepNative = applyMode == CompositeLayoutApplyMode.ReplaceManagedKeepNative; bool forcePresetLayout = string.Equals(request?.layoutPreset, kDefaultCompositeLayoutPreset, StringComparison.Ordinal) || string.Equals(request?.layoutPreset, kSavedCompositeLayoutPreset, StringComparison.Ordinal); // Only reuse detached slot ids for the single-slot reconnect path. // Multi-slot (especially default/saved preset) should rebuild against // the incoming layout snapshot to avoid stale docking topology. bool allowDetachedSlotRemap = s_NativeOffscreenDisconnectedAwaitingRestore && request?.slots != null && request.slots.Length == 1; if (preserveExisting) { // Preserve mode keeps existing docking topology and windows; re-acquire // handles for all alive slots even when incoming slotIds changed. foreach (CompositeCaptureSlot existing in s_CompositeSlots.Values) { if (existing?.Window != null) ReacquireCompositeSlotHandles(existing); } } var wanted = new HashSet(); foreach (CompositeSlotRequest slotRequest in request.slots) { if (slotRequest == null || string.IsNullOrEmpty(slotRequest.windowType)) continue; string slotId = string.IsNullOrEmpty(slotRequest.slotId) ? slotRequest.windowType : slotRequest.slotId; wanted.Add(slotId); if (s_CompositeSlots.TryGetValue(slotId, out CompositeCaptureSlot slot) && slot.Window == null) { ReleaseCompositeSlot(slot); s_CompositeSlots.Remove(slotId); slot = null; } if (preserveExisting) { // During domain-reload resume, keep current windows/rects unchanged. // Only re-acquire native handles for slots that already exist. if (slot != null) ReacquireCompositeSlotHandles(slot); continue; } if (slot == null) { if (allowDetachedSlotRemap) slot = TryRemapDetachedCompositeSlot(slotId, slotRequest.windowType, wanted, logicalW, logicalH, slotRequest.rect); } if (slot == null) { slot = CreateCompositeSlot(slotId, slotRequest.windowType, logicalW, logicalH, slotRequest.rect); if (slot == null) continue; s_CompositeSlots[slotId] = slot; } else { slot.WindowTypeName = slotRequest.windowType; ReacquireCompositeSlotHandles(slot); ResizeCompositeSlot(slot, logicalW, logicalH, slotRequest.rect); } slot.NormalizedRect = ClampCompositeRect(slotRequest.rect); slot.Window?.Repaint(); } if (strictReplace || replaceManagedKeepNative) { var removeIds = new List(); foreach (string id in s_CompositeSlots.Keys) { if (!wanted.Contains(id)) { if (replaceManagedKeepNative && IsNativeTrackedCompositeSlot(id)) { LogVerbose($"[NWB-Composite] Keep native slot '{id}' in ReplaceManagedKeepNative"); continue; } removeIds.Add(id); } } foreach (string id in removeIds) { LogVerbose($"[NWB-Composite] Remove slot '{id}' in mode={applyMode}"); ReleaseCompositeSlot(s_CompositeSlots[id]); s_CompositeSlots.Remove(id); } } if (!preserveExisting) { ResizeCompositeWorkspaceToCanvas(); // Preset layout (default/saved) must always converge to the // preset geometry, even when the request uses a non-strict // applyMode such as ReplaceManagedKeepNative. if (strictReplace || forcePresetLayout) ScheduleCompositeLayoutPreset(request); } } private static CompositeCaptureSlot CreateCompositeSlot(string slotId, string windowTypeName, int logicalW, int logicalH, CompositeRect rect) { System.Type windowType = ResolveEditorWindowType(windowTypeName); if (windowType == null) { CodelyLogger.LogError($"[NWB-Composite] Window type not found: {windowTypeName}"); return null; } EditorWindow reference = null; foreach (var w in Resources.FindObjectsOfTypeAll()) { if (w != null && w.GetType() == windowType) { reference = w; break; } } bool shouldReuseExistingWindow = reference != null && ShouldReuseExistingCompositeWindow(windowTypeName); // Do NOT call GetWindow for types we will create floating copies of (e.g. GameView). // GetWindow creates a docked window whose size info (PanelSettings. // GetGameViewResolution) interferes with the floating copy's texture config. if (reference == null && !shouldReuseExistingWindow) { if (ShouldReuseExistingCompositeWindow(windowTypeName)) reference = EditorWindow.GetWindow(windowType, false, null, false); } EditorWindow floating = shouldReuseExistingWindow ? reference : ScriptableObject.CreateInstance(windowType) as EditorWindow; if (floating == null) { CodelyLogger.LogError($"[NWB-Composite] Failed to create floating window: {windowTypeName}"); return null; } Rect nr = ClampCompositeRect(rect); float slotW = Mathf.Max(logicalW * DpiScaleFactor, 320f * DpiScaleFactor); float slotH = Mathf.Max(logicalH * DpiScaleFactor - s_TabBarOffsetY, 240f * DpiScaleFactor); Rect refPos = reference != null ? reference.position : new Rect(100, 100, slotW, slotH); // When composite is already active, existing reference windows may // be hidden off-screen at (-32000, -32000) physical pixels. // ContainerWindow.position uses DPI-scaled logical coordinates, so // at 200% DPI the position appears as ~(-16000, -16000). // Use -10000 as a safe universal off-screen threshold. bool refOffScreen = (refPos.x <= -10000f || refPos.y <= -10000f); if (refOffScreen) refPos = new Rect(100, 100, slotW, slotH); // Add subsequent slots as tabs in the existing DockArea. If a // layout preset is active, an EditorApplication.update operation // later splits those tabs after Unity has finished layout/repaint. object existingDockArea = FindExistingCompositeDockArea(); bool addedDirectly = false; bool shouldAddAsTab = existingDockArea != null && !shouldReuseExistingWindow; #if UNITY_EDITOR_WIN HashSet beforeShowHwnds = null; #endif #if UNITY_EDITOR_OSX HashSet beforeShowNSWindows = null; #endif if (shouldAddAsTab) { try { System.Type dockAreaType = existingDockArea.GetType(); MethodInfo addTab = dockAreaType.GetMethod("AddTab", BindingFlags.Instance | BindingFlags.Public, null, new System.Type[] { typeof(EditorWindow), typeof(bool) }, null); if (addTab != null) { addTab.Invoke(existingDockArea, new object[] { floating, false }); addedDirectly = true; LogVerbose($"[NWB-Composite] AddTab {windowTypeName} directly to existing DockArea (default tab mode)"); } } catch (Exception ex) { string innerMsg = ex.InnerException?.Message ?? ex.Message; CodelyLogger.LogWarning($"[NWB-Composite] Direct AddTab failed for {windowTypeName}: {innerMsg}, falling back to Show()"); } } if (!addedDirectly) { #if UNITY_EDITOR_WIN beforeShowHwnds = new HashSet(GetAllUnityHwnds()); #endif #if UNITY_EDITOR_OSX beforeShowNSWindows = new HashSet(GetAllNSWindows()); #endif if (!shouldReuseExistingWindow) { floating.position = new Rect(refPos.x + 30f, refPos.y + 30f, slotW, slotH); floating.Show(); floating.position = new Rect(refPos.x + 30f, refPos.y + 30f, slotW, slotH); } // Force initial IMGUI Layout+Repaint for the first standalone window. // Initializes GPU AuxBackBuffer and IMGUI state so the DWM // redirect surface has renderable content for PrintWindow capture. ForceDoubleLayoutRepaint(floating); floating.Repaint(); } // Enable autoRepaintOnSceneChange for all composite windows (tabbed // or independent). This ensures RepaintViews(true) resets // m_CanBlitPaint=false, forcing full DoPaint renders. floating.autoRepaintOnSceneChange = true; var slot = new CompositeCaptureSlot { SlotId = slotId, WindowTypeName = windowTypeName, WindowType = windowType, Window = floating, OwnsWindow = !shouldReuseExistingWindow, CloseWindowOnRelease = shouldReuseExistingWindow, NormalizedRect = nr }; #if UNITY_EDITOR_WIN if (shouldReuseExistingWindow || addedDirectly) { // Window shares ContainerWindow with existing DockArea slot.ContainerHwnd = Cn.Tuanjie.Codely.Editor.NativeWindowHelper.GetHWND(floating); if (slot.ContainerHwnd != IntPtr.Zero) { IntPtr child = GetWindow(slot.ContainerHwnd, GW_CHILD); if (child != IntPtr.Zero) slot.GUIViewHwnd = child; LogVerbose($"[NWB-Composite] {(addedDirectly ? "Tabbed" : "Reused")} slot '{slotId}' HWND=0x{slot.ContainerHwnd.ToInt64():X}, GUIView=0x{(child != IntPtr.Zero ? child.ToInt64() : 0):X}"); } } else { var afterShowHwnds = GetAllUnityHwnds(); foreach (var h in afterShowHwnds) { if (!beforeShowHwnds.Contains(h)) { slot.ContainerHwnd = h; IntPtr child = GetWindow(h, GW_CHILD); if (child != IntPtr.Zero) slot.GUIViewHwnd = child; LogVerbose($"[NWB-Composite] Slot '{slotId}' HWND=0x{h.ToInt64():X}, GUIView=0x{(child != IntPtr.Zero ? child.ToInt64() : 0):X}"); break; } } } // Get the actual GUIView HWND via EditorWindow.m_Parent.nativeHandle. // GetWindow(GW_CHILD) on a ContainerWindow returns RootView, not the // DockArea. The reflected native handle gives the correct DockArea HWND // which has its own D3D swap chain that PrintWindow can capture. IntPtr reflectedGUIView = Cn.Tuanjie.Codely.Editor.EditorWindowNativeHandleHelper.GetGUIViewHandle(floating); if (reflectedGUIView != IntPtr.Zero) slot.GUIViewHwnd = reflectedGUIView; LogVerbose($"[NWB-Composite] Slot '{slotId}' ContainerHwnd=0x{slot.ContainerHwnd.ToInt64():X}, GUIViewHwnd=0x{slot.GUIViewHwnd.ToInt64():X} reflected=0x{reflectedGUIView.ToInt64():X}"); // Remove WS_EX_LAYERED from both ContainerWindow and GUIView. // DoPaint checks WS_EX_LAYERED and sets m_Transparent=true which // causes OnWindowEndRender(presentContent=false), preventing the // DWM redirect surface from being updated. PrintWindow then // captures black. Remove from both to ensure proper rendering. if (slot.ContainerHwnd != IntPtr.Zero) { int style = GetWindowLongW(slot.ContainerHwnd, GWL_EXSTYLE); if ((style & WS_EX_LAYERED) != 0) SetWindowLongW(slot.ContainerHwnd, GWL_EXSTYLE, style & ~WS_EX_LAYERED); } if (slot.GUIViewHwnd != IntPtr.Zero && slot.GUIViewHwnd != slot.ContainerHwnd) { int gvStyle = GetWindowLongW(slot.GUIViewHwnd, GWL_EXSTYLE); if ((gvStyle & WS_EX_LAYERED) != 0) SetWindowLongW(slot.GUIViewHwnd, GWL_EXSTYLE, gvStyle & ~WS_EX_LAYERED); } #endif #if UNITY_EDITOR_OSX if (addedDirectly) { // Share NSWindow from existing slot foreach (CompositeCaptureSlot existSlot in s_CompositeSlots.Values) { if (existSlot?.Window != null && existSlot.NSWindow != IntPtr.Zero) { slot.NSWindow = existSlot.NSWindow; break; } } LogVerbose($"[NWB-Composite] Tabbed slot '{slotId}' NSWindow={slot.NSWindow}"); } else if (!shouldReuseExistingWindow) { var afterShowNSWindows = GetAllNSWindows(); foreach (var nsw in afterShowNSWindows) { if (!beforeShowNSWindows.Contains(nsw)) { slot.NSWindow = nsw; LogVerbose($"[NWB-Composite] Slot '{slotId}' NSWindow={nsw}"); break; } } } #endif ResizeCompositeSlot(slot, logicalW, logicalH, rect); // Initialize the window's content for current selection. // InvokeSelectionChangeOnWindow rebuilds the ActiveEditorTracker // (loads Inspector editors for the selected object). if (floating != null && Selection.activeObject != null) { try { InvokeSelectionChangeOnWindow(floating); } catch (Exception) { } } // For tabbed windows: force initial Layout+Repaint AFTER editors // are loaded by InvokeSelectionChangeOnWindow. This ensures DoPaint // takes the full-render path and writes correct content into the // AuxBackBuffer. Without this, the GUIView's m_CanBlitPaint is true // (set by the first slot's SendEvent render) and subsequent // ForceRepaintImmediately calls blit stale content from AuxBackBuffer. if (addedDirectly) { ForceDoubleLayoutRepaint(floating); floating.Repaint(); } EditorApplication.QueuePlayerLoopUpdate(); return slot; } private static void ScheduleCompositeLayoutPreset(CompositeStreamRequest request) { if (request == null) { CancelCompositeLayoutOperation(); return; } if (request.layoutPreset == kSavedCompositeLayoutPreset) { ScheduleSavedCompositeLayout(request); return; } if (request.layoutPreset != kDefaultCompositeLayoutPreset) { CancelCompositeLayoutOperation(); return; } if (!TryResolveDefaultLayoutSlotIds(request, out string hierarchySlotId, out string sceneSlotId, out string gameSlotId, out string projectSlotId, out string consoleSlotId, out string inspectorSlotId)) return; List defaultSlots = BuildDefaultCompositeLayoutSlots( request, hierarchySlotId, sceneSlotId, gameSlotId, projectSlotId, consoleSlotId, inspectorSlotId); float defaultLayoutRectError; if (IsDefaultCompositeLayoutAchieved(hierarchySlotId, sceneSlotId, gameSlotId, projectSlotId, consoleSlotId, inspectorSlotId) && VerifySavedCompositeLayoutRects(defaultSlots, 0.03f, out defaultLayoutRectError)) return; // NOTE: // The direct SplitView-tree rebuild path is intentionally disabled for // default layout on Windows. In practice this path can produce invalid // DockArea state after domain reload (HostView.GetExtraButtonsWidth NRE), // which then breaks downstream keyboard/input dispatch in Play mode. // // We fall back to the existing step-by-step layout operation, which uses // Unity's normal docking flow and is more stable for multi-view streams. LogVerbose("[NWB-Composite] Skip direct tree rebuild for default layout; using scheduled dock operations"); if (IsDefaultCompositeLayoutAchieved(hierarchySlotId, sceneSlotId, gameSlotId, projectSlotId, consoleSlotId, inspectorSlotId)) return; if (s_CompositeLayoutOperation != null && s_CompositeLayoutOperation.Preset == request.layoutPreset && s_CompositeLayoutOperation.HierarchySlotId == hierarchySlotId && s_CompositeLayoutOperation.GameSlotId == gameSlotId && s_CompositeLayoutOperation.SceneSlotId == sceneSlotId && s_CompositeLayoutOperation.ProjectSlotId == projectSlotId && s_CompositeLayoutOperation.ConsoleSlotId == consoleSlotId && s_CompositeLayoutOperation.InspectorSlotId == inspectorSlotId) { return; } s_CompositeLayoutOperation = new CompositeLayoutOperation { Preset = request.layoutPreset, HierarchySlotId = hierarchySlotId, GameSlotId = gameSlotId, SceneSlotId = sceneSlotId, ProjectSlotId = projectSlotId, ConsoleSlotId = consoleSlotId, InspectorSlotId = inspectorSlotId, Step = 0, WaitFrames = 3, Attempts = 0 }; EditorApplication.update -= CompositeLayoutOperationTick; EditorApplication.update += CompositeLayoutOperationTick; LogVerbose("[NWB-Composite] Scheduled default layout operation"); } private static void ScheduleSavedCompositeLayout(CompositeStreamRequest request) { List savedSlots = GetValidCompositeLayoutSlots(request); TryApplySavedCompositeLayoutTree(savedSlots); // After moving EditorWindows from floating ContainerWindows to the // main editor's DockArea, the original floating ContainerWindows are // left empty (no panes). Close them to prevent zombie black windows. CloseOrphanEmptyContainerWindows(); CancelCompositeLayoutOperation(); } private static List GetValidCompositeLayoutSlots(CompositeStreamRequest request) { var slots = new List(); if (request?.slots == null) return slots; foreach (CompositeSlotRequest slot in request.slots) { if (slot == null || string.IsNullOrEmpty(slot.slotId) || string.IsNullOrEmpty(slot.windowType)) continue; slots.Add(slot); } return slots; } private static List> GroupCompositeSlotsByRect(List slots) { var groups = new List>(); if (slots == null) return groups; foreach (CompositeSlotRequest slot in slots) { bool added = false; foreach (var group in groups) { if (group.Count > 0 && AreCompositeRectsEquivalent(slot.rect, group[0].rect)) { group.Add(slot); added = true; break; } } if (!added) groups.Add(new List { slot }); } return groups; } private static bool AreCompositeRectsEquivalent(CompositeRect a, CompositeRect b) { Rect ar = ClampCompositeRect(a); Rect br = ClampCompositeRect(b); const float eps = 0.01f; return Mathf.Abs(ar.x - br.x) <= eps && Mathf.Abs(ar.y - br.y) <= eps && Mathf.Abs(ar.width - br.width) <= eps && Mathf.Abs(ar.height - br.height) <= eps; } private static CompositeLayoutNode BuildCompositeLayoutNode(List slots) { if (slots == null || slots.Count == 0) return null; if (slots.Count == 1) return new CompositeLayoutNode { Slot = slots[0] }; if (TrySplitCompositeLayoutSlots(slots, true, out List first, out List second) || TrySplitCompositeLayoutSlots(slots, false, out first, out second)) { return new CompositeLayoutNode { First = BuildCompositeLayoutNode(first), Second = BuildCompositeLayoutNode(second), Edge = IsVerticalSplit(first, second) ? "right" : "bottom" }; } slots.Sort((a, b) => { Rect ar = ClampCompositeRect(a.rect); Rect br = ClampCompositeRect(b.rect); int cmp = ar.x.CompareTo(br.x); return cmp != 0 ? cmp : ar.y.CompareTo(br.y); }); return new CompositeLayoutNode { First = new CompositeLayoutNode { Slot = slots[0] }, Second = BuildCompositeLayoutNode(slots.GetRange(1, slots.Count - 1)), Edge = "right" }; } private static bool TrySplitCompositeLayoutSlots( List slots, bool vertical, out List first, out List second) { first = null; second = null; const float eps = 0.01f; var cuts = new List(); foreach (CompositeSlotRequest slot in slots) { Rect r = ClampCompositeRect(slot.rect); cuts.Add(vertical ? r.xMin : r.yMin); cuts.Add(vertical ? r.xMax : r.yMax); } cuts.Sort(); foreach (float cut in cuts) { var a = new List(); var b = new List(); bool crosses = false; foreach (CompositeSlotRequest slot in slots) { Rect r = ClampCompositeRect(slot.rect); float min = vertical ? r.xMin : r.yMin; float max = vertical ? r.xMax : r.yMax; if (max <= cut + eps) a.Add(slot); else if (min >= cut - eps) b.Add(slot); else { crosses = true; break; } } if (!crosses && a.Count > 0 && b.Count > 0) { first = a; second = b; return true; } } return false; } private static bool IsVerticalSplit(List first, List second) { float firstMaxX = float.MinValue; float secondMinX = float.MaxValue; foreach (CompositeSlotRequest slot in first) firstMaxX = Mathf.Max(firstMaxX, ClampCompositeRect(slot.rect).xMax); foreach (CompositeSlotRequest slot in second) secondMinX = Mathf.Min(secondMinX, ClampCompositeRect(slot.rect).xMin); return firstMaxX <= secondMinX + 0.01f; } private static bool TryApplySavedCompositeLayoutTree(List savedSlots) { if (savedSlots == null || savedSlots.Count == 0) return false; bool displayFrozen = false; try { var groups = GroupCompositeSlotsByRect(savedSlots); var layoutSlots = new List(); foreach (var group in groups) { if (group.Count > 0) layoutSlots.Add(group[0]); } if (layoutSlots.Count == 0) return false; CompositeLayoutNode rootNode = BuildCompositeLayoutNode(layoutSlots); if (rootNode == null) return false; object hostWindow = GetCompositeHostWindow(); if (hostWindow == null) return false; Rect rootScreen = GetCompositeRootRectForTree(hostWindow); if (rootScreen.width <= 1f || rootScreen.height <= 1f) return false; SetContainerWindowFreezeDisplay(true); displayFrozen = true; var groupsByAnchor = new Dictionary>(); foreach (var group in groups) { if (group.Count == 0 || string.IsNullOrEmpty(group[0].slotId)) continue; groupsByAnchor[group[0].slotId] = group; } Rect fullBounds = new Rect(0f, 0f, 1f, 1f); object rootView = BuildSavedCompositeViewTree(rootNode, groupsByAnchor, fullBounds, rootScreen.width, rootScreen.height); if (rootView == null) return false; SetViewPosition(rootView, new Rect(0f, 0f, rootScreen.width, rootScreen.height)); SetContainerWindowRootView(hostWindow, rootView); ReflowCompositeSplitRecursive(rootView); RepaintCompositeViewRecursive(rootView); InvokeContainerWindowDisplayAllViews(hostWindow); EditorApplication.QueuePlayerLoopUpdate(); LogVerbose("[NWB-Composite] Applied saved layout by rebuilding SplitView tree"); return true; } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-Composite] Direct saved layout tree rebuild failed: {ex.Message}"); return false; } finally { if (displayFrozen) SetContainerWindowFreezeDisplay(false); } } private static object BuildSavedCompositeViewTree( CompositeLayoutNode node, Dictionary> groupsByAnchor, Rect parentBounds, float rootWidth, float rootHeight) { if (node == null) return null; Rect nodeBounds = GetCompositeLayoutNodeBounds(node); Rect localRect = NormalizedRectToLocalRect(nodeBounds, parentBounds, rootWidth, rootHeight); if (node.Slot != null) { if (!groupsByAnchor.TryGetValue(node.Slot.slotId, out List group)) group = new List { node.Slot }; object dockArea = CreateUnityEditorView("UnityEditor.DockArea"); if (dockArea == null) return null; SetViewPosition(dockArea, localRect); int movedCount = 0; var movedSlots = new List(); foreach (CompositeSlotRequest slotRequest in group) { if (!TryGetCompositeSlot(slotRequest.slotId, out CompositeCaptureSlot slot) || slot?.Window == null) continue; if (!MoveCompositeWindowToDockArea(slot.Window, dockArea)) return null; movedSlots.Add(slot); movedCount++; } if (movedCount == 0) return null; SelectCompositeSlotTabForGroup(group); RepaintCompositeViewRecursive(dockArea); // Reacquire after dockArea repaint to avoid sampling transient window handles. foreach (CompositeCaptureSlot movedSlot in movedSlots) ReacquireCompositeSlotHandles(movedSlot); return dockArea; } object splitView = CreateUnityEditorView("UnityEditor.SplitView"); if (splitView == null) return null; SetSplitViewVertical(splitView, string.Equals(node.Edge, "bottom", StringComparison.OrdinalIgnoreCase)); SetViewPosition(splitView, localRect); object first = BuildSavedCompositeViewTree(node.First, groupsByAnchor, nodeBounds, rootWidth, rootHeight); object second = BuildSavedCompositeViewTree(node.Second, groupsByAnchor, nodeBounds, rootWidth, rootHeight); if (first == null || second == null) return null; if (!AddViewChild(splitView, first) || !AddViewChild(splitView, second)) return null; return splitView; } private static Rect GetCompositeLayoutNodeBounds(CompositeLayoutNode node) { if (node == null) return new Rect(0f, 0f, 1f, 1f); if (node.Slot != null) return ClampCompositeRect(node.Slot.rect); Rect first = GetCompositeLayoutNodeBounds(node.First); Rect second = GetCompositeLayoutNodeBounds(node.Second); return Rect.MinMaxRect( Mathf.Min(first.xMin, second.xMin), Mathf.Min(first.yMin, second.yMin), Mathf.Max(first.xMax, second.xMax), Mathf.Max(first.yMax, second.yMax)); } private static Rect NormalizedRectToLocalRect(Rect bounds, Rect parentBounds, float rootWidth, float rootHeight) { return new Rect( (bounds.xMin - parentBounds.xMin) * rootWidth, (bounds.yMin - parentBounds.yMin) * rootHeight, Mathf.Max(bounds.width * rootWidth, 1f), Mathf.Max(bounds.height * rootHeight, 1f)); } private static object CreateUnityEditorView(string typeName) { System.Type type = ResolveUnityEditorType(typeName); return type != null ? ScriptableObject.CreateInstance(type) : null; } private static System.Type ResolveUnityEditorType(string typeName) { if (string.IsNullOrEmpty(typeName)) return null; System.Type type = System.Type.GetType(typeName) ?? System.Type.GetType(typeName + ",UnityEditor"); if (type != null) return type; foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies()) { type = asm.GetType(typeName); if (type != null) return type; } return null; } private static Rect GetCompositeRootRectForTree(object hostWindow) { object rootSplit = GetRootSplitView(hostWindow); Rect rootRect = GetViewScreenPosition(rootSplit); if (rootRect.width > 1f && rootRect.height > 1f) return rootRect; PropertyInfo positionProp = hostWindow?.GetType().GetProperty("position", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (positionProp != null) { Rect pos = (Rect)positionProp.GetValue(hostWindow); return new Rect(0f, 0f, Mathf.Max(pos.width, 320f), Mathf.Max(pos.height, 240f)); } return new Rect(0f, 0f, Mathf.Max(s_CompositeLogicalWidth, 320), Mathf.Max(s_CompositeLogicalHeight, 240)); } private static void SetSplitViewVertical(object splitView, bool vertical) { FieldInfo verticalField = splitView?.GetType().GetField("vertical", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); verticalField?.SetValue(splitView, vertical); } private static bool AddViewChild(object parent, object child) { if (parent == null || child == null) return false; foreach (MethodInfo method in parent.GetType().GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)) { if (method.Name != "AddChild") continue; ParameterInfo[] parameters = method.GetParameters(); if (parameters.Length == 1) { method.Invoke(parent, new object[] { child }); return true; } } return false; } private static bool MoveCompositeWindowToDockArea(EditorWindow window, object targetDockArea) { if (window == null || targetDockArea == null) return false; try { // Capture the source ContainerWindow BEFORE moving the EditorWindow. // After RemoveTab, the source DockArea may become empty, and we need // to close the orphaned ContainerWindow to prevent zombie black windows. object sourceDockArea = GetParentView(window); object sourceContainerWindow = sourceDockArea != null ? GetViewWindow(sourceDockArea) : null; if (sourceDockArea != null && sourceDockArea.GetType().Name == "DockArea") { MethodInfo removeTab = sourceDockArea.GetType().GetMethod("RemoveTab", BindingFlags.Instance | BindingFlags.Public, null, new System.Type[] { typeof(EditorWindow), typeof(bool), typeof(bool) }, null); removeTab?.Invoke(sourceDockArea, new object[] { window, false, false }); } MethodInfo addTab = targetDockArea.GetType().GetMethod("AddTab", BindingFlags.Instance | BindingFlags.Public, null, new System.Type[] { typeof(EditorWindow), typeof(bool) }, null); if (addTab != null) addTab.Invoke(targetDockArea, new object[] { window, false }); else { addTab = targetDockArea.GetType().GetMethod("AddTab", BindingFlags.Instance | BindingFlags.Public, null, new System.Type[] { typeof(EditorWindow) }, null); if (addTab == null) return false; addTab.Invoke(targetDockArea, new object[] { window }); } // After moving, close the source ContainerWindow if it's now empty. // This unifies behavior with "添加视图" path where EditorWindow.Close() // on the last tab destroys the ContainerWindow naturally. if (sourceContainerWindow != null) { try { // Check if source container is different from target container object targetContainerWindow = GetViewWindow(targetDockArea); if (targetContainerWindow != null && !object.ReferenceEquals(sourceContainerWindow, targetContainerWindow)) { // Source and target are different ContainerWindows. // Check if source ContainerWindow is now empty (no panes). if (!ViewTreeHasDockPanes( sourceContainerWindow.GetType().GetField("m_RootView", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(sourceContainerWindow))) { MethodInfo closeMethod = sourceContainerWindow.GetType().GetMethod("Close", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); closeMethod?.Invoke(sourceContainerWindow, null); LogVerbose("[NWB-Composite] Closed empty source ContainerWindow after MoveCompositeWindowToDockArea"); } } } catch (Exception) { } } return true; } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-Composite] Move window to rebuilt DockArea failed: {ex.Message}"); return false; } } private static void SelectCompositeSlotTabForGroup(List group) { if (group == null || group.Count == 0) return; foreach (CompositeSlotRequest slotRequest in group) { if (TryGetCompositeSlot(slotRequest.slotId, out CompositeCaptureSlot slot)) { SelectCompositeSlotTab(slot); return; } } } private static void SetContainerWindowRootView(object hostWindow, object rootView) { PropertyInfo rootViewProp = hostWindow?.GetType().GetProperty("rootView", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); rootViewProp?.SetValue(hostWindow, rootView); } private static void InvokeContainerWindowDisplayAllViews(object hostWindow) { MethodInfo displayAllViews = hostWindow?.GetType().GetMethod("DisplayAllViews", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); displayAllViews?.Invoke(hostWindow, null); } private static void SetContainerWindowFreezeDisplay(bool freeze) { System.Type containerWindowType = ResolveUnityEditorType("UnityEditor.ContainerWindow"); MethodInfo setFreezeDisplay = containerWindowType?.GetMethod("SetFreezeDisplay", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic); setFreezeDisplay?.Invoke(null, new object[] { freeze }); } private static bool TryResolveDefaultLayoutSlotIds( CompositeStreamRequest request, out string hierarchySlotId, out string sceneSlotId, out string gameSlotId, out string projectSlotId, out string consoleSlotId, out string inspectorSlotId) { hierarchySlotId = null; sceneSlotId = null; gameSlotId = null; projectSlotId = null; consoleSlotId = null; inspectorSlotId = null; if (request?.slots == null) return false; foreach (CompositeSlotRequest slot in request.slots) { if (slot == null || string.IsNullOrEmpty(slot.slotId) || string.IsNullOrEmpty(slot.windowType)) continue; if (slot.windowType.Contains("SceneHierarchyWindow")) hierarchySlotId = slot.slotId; else if (slot.windowType.Contains("SceneView")) sceneSlotId = slot.slotId; else if (slot.windowType.Contains("GameView")) gameSlotId = slot.slotId; else if (slot.windowType.Contains("ProjectBrowser")) projectSlotId = slot.slotId; else if (slot.windowType.Contains("ConsoleWindow")) consoleSlotId = slot.slotId; else if (slot.windowType.Contains("InspectorWindow")) inspectorSlotId = slot.slotId; } return !string.IsNullOrEmpty(hierarchySlotId) && !string.IsNullOrEmpty(sceneSlotId) && !string.IsNullOrEmpty(gameSlotId) && !string.IsNullOrEmpty(projectSlotId) && !string.IsNullOrEmpty(consoleSlotId) && !string.IsNullOrEmpty(inspectorSlotId); } private static List BuildDefaultCompositeLayoutSlots( CompositeStreamRequest request, string hierarchySlotId, string sceneSlotId, string gameSlotId, string projectSlotId, string consoleSlotId, string inspectorSlotId) { var slotsById = new Dictionary(); if (request?.slots != null) { foreach (CompositeSlotRequest slot in request.slots) { if (slot == null || string.IsNullOrEmpty(slot.slotId)) continue; slotsById[slot.slotId] = slot; } } const float rootWidth = 1206f; const float rootHeight = 665f; float leftWidth = 921f / rootWidth; float hierarchyWidth = 228f / rootWidth; float sceneWidth = 693f / rootWidth; float topHeight = 394f / rootHeight; float bottomHeight = 271f / rootHeight; float inspectorX = 921f / rootWidth; float inspectorWidth = 285f / rootWidth; var slots = new List(); AddDefaultCompositeLayoutSlot(slots, slotsById, hierarchySlotId, 0f, 0f, hierarchyWidth, topHeight); AddDefaultCompositeLayoutSlot(slots, slotsById, sceneSlotId, hierarchyWidth, 0f, sceneWidth, topHeight); AddDefaultCompositeLayoutSlot(slots, slotsById, gameSlotId, hierarchyWidth, 0f, sceneWidth, topHeight); AddDefaultCompositeLayoutSlot(slots, slotsById, projectSlotId, 0f, topHeight, leftWidth, bottomHeight); AddDefaultCompositeLayoutSlot(slots, slotsById, consoleSlotId, 0f, topHeight, leftWidth, bottomHeight); AddDefaultCompositeLayoutSlot(slots, slotsById, inspectorSlotId, inspectorX, 0f, inspectorWidth, 1f); return slots; } private static void AddDefaultCompositeLayoutSlot( List slots, Dictionary slotsById, string slotId, float x, float y, float w, float h) { if (string.IsNullOrEmpty(slotId) || !slotsById.TryGetValue(slotId, out CompositeSlotRequest source)) return; slots.Add(new CompositeSlotRequest { slotId = source.slotId, windowType = source.windowType, rect = new CompositeRect { x = x, y = y, w = w, h = h } }); } private static void CompositeLayoutOperationTick() { CompositeLayoutOperation op = s_CompositeLayoutOperation; if (op == null) { EditorApplication.update -= CompositeLayoutOperationTick; return; } if (!s_CompositeActive) { CancelCompositeLayoutOperation(); return; } if (op.WaitFrames > 0) { op.WaitFrames--; RepaintCompositeLayoutOperationSlots(op); return; } if (!TryGetCompositeSlot(op.HierarchySlotId, out CompositeCaptureSlot hierarchySlot) || !TryGetCompositeSlot(op.SceneSlotId, out CompositeCaptureSlot sceneSlot) || !TryGetCompositeSlot(op.GameSlotId, out CompositeCaptureSlot gameSlot) || !TryGetCompositeSlot(op.ProjectSlotId, out CompositeCaptureSlot projectSlot) || !TryGetCompositeSlot(op.ConsoleSlotId, out CompositeCaptureSlot consoleSlot) || !TryGetCompositeSlot(op.InspectorSlotId, out CompositeCaptureSlot inspectorSlot)) { RetryCompositeLayoutOperation("waiting for default layout slots"); return; } switch (op.Step) { case 0: ReacquireCompositeSlotHandles(hierarchySlot); ReacquireCompositeSlotHandles(sceneSlot); ReacquireCompositeSlotHandles(gameSlot); ReacquireCompositeSlotHandles(projectSlot); ReacquireCompositeSlotHandles(consoleSlot); ReacquireCompositeSlotHandles(inspectorSlot); SelectCompositeSlotTab(sceneSlot); op.Step = 1; op.Attempts = 0; op.WaitFrames = 2; return; case 1: if (!AreCompositeSlotsInDifferentDockAreas(sceneSlot, inspectorSlot)) { if (!TryDockCompositeSlotViaSplitView(inspectorSlot, op.SceneSlotId, "right")) { RetryCompositeLayoutOperation("docking Inspector to the right"); return; } ReacquireCompositeSlotHandles(inspectorSlot); } op.Step = 2; op.Attempts = 0; op.WaitFrames = 3; return; case 2: if (!AreCompositeSlotsInDifferentDockAreas(sceneSlot, projectSlot)) { if (!TryDockCompositeSlotViaSplitView(projectSlot, op.SceneSlotId, "bottom")) { RetryCompositeLayoutOperation("docking Project to the bottom"); return; } ReacquireCompositeSlotHandles(projectSlot); } op.Step = 3; op.Attempts = 0; op.WaitFrames = 3; return; case 3: if (!AreCompositeSlotsInSameDockArea(projectSlot, consoleSlot)) { if (!TryMoveCompositeSlotAsTab(consoleSlot, op.ProjectSlotId)) { RetryCompositeLayoutOperation("tabbing Console with Project"); return; } ReacquireCompositeSlotHandles(consoleSlot); } SelectCompositeSlotTab(projectSlot); op.Step = 4; op.Attempts = 0; op.WaitFrames = 2; return; case 4: if (!AreCompositeSlotsInDifferentDockAreas(hierarchySlot, sceneSlot)) { if (!TryDockCompositeSlotViaSplitView(hierarchySlot, op.SceneSlotId, "left")) { RetryCompositeLayoutOperation("docking Hierarchy to the left"); return; } ReacquireCompositeSlotHandles(hierarchySlot); } SelectCompositeSlotTab(hierarchySlot); SelectCompositeSlotTab(sceneSlot); op.Step = 5; op.Attempts = 0; op.WaitFrames = 3; return; default: SelectCompositeSlotTab(hierarchySlot); SelectCompositeSlotTab(sceneSlot); SelectCompositeSlotTab(projectSlot); if (IsDefaultCompositeLayoutAchieved(op.HierarchySlotId, op.SceneSlotId, op.GameSlotId, op.ProjectSlotId, op.ConsoleSlotId, op.InspectorSlotId)) { LogVerbose("[NWB-Composite] Default layout operation completed"); CancelCompositeLayoutOperation(); } else { op.Step = 1; RetryCompositeLayoutOperation("verifying default layout"); } return; } } private static bool TryGetCompositeSlot(string slotId, out CompositeCaptureSlot slot) { slot = null; return !string.IsNullOrEmpty(slotId) && s_CompositeSlots.TryGetValue(slotId, out slot) && slot?.Window != null; } private static void TryActivateCompositeSlot(string slotId) { if (!TryGetCompositeSlot(slotId, out CompositeCaptureSlot slot) || slot.Window == null) return; if (SelectCompositeSlotTab(slot)) { LogVerbose($"[NWB-Composite] Activated reserved slot '{slotId}' ({slot.WindowTypeName})"); } else { CodelyLogger.LogWarning($"[NWB-Composite] Activate slot '{slotId}' failed"); } } private static CompositeCaptureSlot FindCompositeSlotByWindow(EditorWindow window) { if (window == null || s_CompositeSlots == null) return null; foreach (CompositeCaptureSlot slot in s_CompositeSlots.Values) { if (slot?.Window == window) return slot; } return null; } private static void RepaintCompositeLayoutOperationSlots(CompositeLayoutOperation op) { if (op == null) return; if (TryGetCompositeSlot(op.HierarchySlotId, out CompositeCaptureSlot hierarchySlot)) hierarchySlot.Window.Repaint(); if (TryGetCompositeSlot(op.SceneSlotId, out CompositeCaptureSlot sceneSlot)) sceneSlot.Window.Repaint(); if (TryGetCompositeSlot(op.GameSlotId, out CompositeCaptureSlot gameSlot)) gameSlot.Window.Repaint(); if (TryGetCompositeSlot(op.ProjectSlotId, out CompositeCaptureSlot projectSlot)) projectSlot.Window.Repaint(); if (TryGetCompositeSlot(op.ConsoleSlotId, out CompositeCaptureSlot consoleSlot)) consoleSlot.Window.Repaint(); if (TryGetCompositeSlot(op.InspectorSlotId, out CompositeCaptureSlot inspectorSlot)) inspectorSlot.Window.Repaint(); EditorApplication.QueuePlayerLoopUpdate(); } private static void RetryCompositeLayoutOperation(string reason) { CompositeLayoutOperation op = s_CompositeLayoutOperation; if (op == null) return; op.Attempts++; if (op.Attempts > 12) { if (string.Equals(op.Preset, kDefaultCompositeLayoutPreset, StringComparison.Ordinal)) { if (TryApplyDefaultLayoutTreeFallback(op)) { LogVerbose($"[NWB-Composite] Default layout fallback tree rebuild succeeded after '{reason}'"); CancelCompositeLayoutOperation(); return; } } CodelyLogger.LogWarning($"[NWB-Composite] Default layout operation stopped while {reason}"); CancelCompositeLayoutOperation(); return; } op.WaitFrames = 2; RepaintCompositeLayoutOperationSlots(op); } private static bool TryApplyDefaultLayoutTreeFallback(CompositeLayoutOperation op) { if (op == null) return false; if (string.IsNullOrEmpty(op.HierarchySlotId) || string.IsNullOrEmpty(op.SceneSlotId) || string.IsNullOrEmpty(op.GameSlotId) || string.IsNullOrEmpty(op.ProjectSlotId) || string.IsNullOrEmpty(op.ConsoleSlotId) || string.IsNullOrEmpty(op.InspectorSlotId)) return false; var request = new CompositeStreamRequest(); var requestSlots = new List(); TryAppendCurrentSlotForFallback(requestSlots, op.HierarchySlotId); TryAppendCurrentSlotForFallback(requestSlots, op.SceneSlotId); TryAppendCurrentSlotForFallback(requestSlots, op.GameSlotId); TryAppendCurrentSlotForFallback(requestSlots, op.ProjectSlotId); TryAppendCurrentSlotForFallback(requestSlots, op.ConsoleSlotId); TryAppendCurrentSlotForFallback(requestSlots, op.InspectorSlotId); request.slots = requestSlots.ToArray(); if (request.slots.Length < 6) return false; List defaultSlots = BuildDefaultCompositeLayoutSlots( request, op.HierarchySlotId, op.SceneSlotId, op.GameSlotId, op.ProjectSlotId, op.ConsoleSlotId, op.InspectorSlotId); if (defaultSlots == null || defaultSlots.Count == 0) return false; if (!TryApplySavedCompositeLayoutTree(defaultSlots)) return false; CloseOrphanEmptyContainerWindows(); return true; } private static void TryAppendCurrentSlotForFallback(List slots, string slotId) { if (slots == null || string.IsNullOrEmpty(slotId)) return; if (!s_CompositeSlots.TryGetValue(slotId, out CompositeCaptureSlot slot)) return; if (slot?.Window == null) return; slots.Add(new CompositeSlotRequest { slotId = slotId, windowType = slot.WindowTypeName ?? slot.Window.GetType().FullName }); } private static void CancelCompositeLayoutOperation() { s_CompositeLayoutOperation = null; EditorApplication.update -= CompositeLayoutOperationTick; } private static bool IsDefaultCompositeLayoutAchieved( string hierarchySlotId, string sceneSlotId, string gameSlotId, string projectSlotId, string consoleSlotId, string inspectorSlotId) { if (!TryGetCompositeSlot(hierarchySlotId, out CompositeCaptureSlot hierarchySlot) || !TryGetCompositeSlot(sceneSlotId, out CompositeCaptureSlot sceneSlot) || !TryGetCompositeSlot(gameSlotId, out CompositeCaptureSlot gameSlot) || !TryGetCompositeSlot(projectSlotId, out CompositeCaptureSlot projectSlot) || !TryGetCompositeSlot(consoleSlotId, out CompositeCaptureSlot consoleSlot) || !TryGetCompositeSlot(inspectorSlotId, out CompositeCaptureSlot inspectorSlot)) return false; object hierarchyDock = GetParentView(hierarchySlot.Window); object sceneDock = GetParentView(sceneSlot.Window); object gameDock = GetParentView(gameSlot.Window); object projectDock = GetParentView(projectSlot.Window); object consoleDock = GetParentView(consoleSlot.Window); object inspectorDock = GetParentView(inspectorSlot.Window); return hierarchyDock != null && sceneDock != null && gameDock != null && projectDock != null && consoleDock != null && inspectorDock != null && object.ReferenceEquals(sceneDock, gameDock) && object.ReferenceEquals(projectDock, consoleDock) && !object.ReferenceEquals(hierarchyDock, sceneDock) && !object.ReferenceEquals(sceneDock, projectDock) && !object.ReferenceEquals(projectDock, inspectorDock) && !object.ReferenceEquals(sceneDock, inspectorDock) && !object.ReferenceEquals(hierarchyDock, inspectorDock); } private static bool AreCompositeSlotsInDifferentDockAreas(CompositeCaptureSlot first, CompositeCaptureSlot second) { object firstDock = first?.Window != null ? GetParentView(first.Window) : null; object secondDock = second?.Window != null ? GetParentView(second.Window) : null; return firstDock != null && secondDock != null && !object.ReferenceEquals(firstDock, secondDock); } private static bool AreCompositeSlotsInSameDockArea(CompositeCaptureSlot first, CompositeCaptureSlot second) { object firstDock = first?.Window != null ? GetParentView(first.Window) : null; object secondDock = second?.Window != null ? GetParentView(second.Window) : null; return firstDock != null && secondDock != null && object.ReferenceEquals(firstDock, secondDock); } private static bool TryMoveCompositeSlotAsTab(CompositeCaptureSlot slot, string targetSlotId) { if (slot?.Window == null || string.IsNullOrEmpty(targetSlotId)) return false; if (!s_CompositeSlots.TryGetValue(targetSlotId, out CompositeCaptureSlot targetSlot) || targetSlot?.Window == null) return false; try { object sourceDockArea = GetParentView(slot.Window); object targetDockArea = GetParentView(targetSlot.Window); if (sourceDockArea == null || targetDockArea == null) return false; if (object.ReferenceEquals(sourceDockArea, targetDockArea)) return true; MethodInfo removeTab = sourceDockArea.GetType().GetMethod("RemoveTab", BindingFlags.Instance | BindingFlags.Public, null, new System.Type[] { typeof(EditorWindow), typeof(bool), typeof(bool) }, null); MethodInfo addTab = targetDockArea.GetType().GetMethod("AddTab", BindingFlags.Instance | BindingFlags.Public, null, new System.Type[] { typeof(EditorWindow), typeof(bool) }, null); if (removeTab == null || addTab == null) return false; removeTab.Invoke(sourceDockArea, new object[] { slot.Window, true, false }); addTab.Invoke(targetDockArea, new object[] { slot.Window, false }); SelectCompositeSlotTab(targetSlot); return true; } catch (Exception ex) { string innerMsg = ex.InnerException?.Message ?? ex.Message; CodelyLogger.LogWarning($"[NWB-Composite] Move slot '{slot.SlotId}' as tab failed: {innerMsg}"); return false; } } private static bool SelectCompositeSlotTab(CompositeCaptureSlot slot) { if (slot?.Window == null) return false; try { object dockArea = GetParentView(slot.Window); if (dockArea == null) return false; FieldInfo panesField = dockArea.GetType().GetField("m_Panes", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); object panesObj = panesField?.GetValue(dockArea); if (!(panesObj is System.Collections.IList panes)) return false; int index = panes.IndexOf(slot.Window); if (index < 0) return false; PropertyInfo selectedProp = dockArea.GetType().GetProperty("selected", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); selectedProp?.SetValue(dockArea, index); MethodInfo updateTitle = dockArea.GetType().GetMethod("UpdateWindowTitle", BindingFlags.Instance | BindingFlags.NonPublic, null, new System.Type[] { typeof(EditorWindow) }, null); updateTitle?.Invoke(dockArea, new object[] { slot.Window }); MethodInfo repaint = dockArea.GetType().GetMethod("Repaint", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); repaint?.Invoke(dockArea, null); ForceDoubleLayoutRepaint(slot.Window); slot.Window.Repaint(); return true; } catch (Exception ex) { string innerMsg = ex.InnerException?.Message ?? ex.Message; CodelyLogger.LogWarning($"[NWB-Composite] Select slot tab failed for '{slot.SlotId}': {innerMsg}"); return false; } } private static bool TryDockCompositeSlotViaSplitView(CompositeCaptureSlot slot, string targetSlotId, string dockEdge) { if (slot?.Window == null || string.IsNullOrEmpty(targetSlotId) || string.IsNullOrEmpty(dockEdge)) return false; if (!s_CompositeSlots.TryGetValue(targetSlotId, out CompositeCaptureSlot targetSlot) || targetSlot?.Window == null) return false; object dockAreaForDragReset = null; try { object targetDockArea = GetParentView(targetSlot.Window); object sourceDockArea = GetParentView(slot.Window); if (targetDockArea == null || sourceDockArea == null) return false; dockAreaForDragReset = sourceDockArea; Rect targetRect = GetViewScreenPosition(targetDockArea); if (targetRect.width <= 1f || targetRect.height <= 1f) return false; Vector2 screenPos; switch (dockEdge) { case "left": screenPos = new Vector2(targetRect.xMin + 2f, targetRect.center.y); break; case "right": screenPos = new Vector2(targetRect.xMax - 2f, targetRect.center.y); break; case "bottom": screenPos = new Vector2(targetRect.center.x, targetRect.yMax - 2f); break; default: CodelyLogger.LogWarning($"[NWB-Composite] Unsupported dock edge '{dockEdge}' for slot '{slot.SlotId}'"); return false; } FieldInfo originalDragSourceField = sourceDockArea.GetType().GetField("s_OriginalDragSource", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); originalDragSourceField?.SetValue(null, sourceDockArea); FieldInfo dragPaneField = sourceDockArea.GetType().GetField("s_DragPane", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); dragPaneField?.SetValue(null, slot.Window); FieldInfo ignoreDockingField = sourceDockArea.GetType().GetField("s_IgnoreDockingForView", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); FieldInfo panesField = sourceDockArea.GetType().GetField("m_Panes", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); int paneCount = panesField?.GetValue(sourceDockArea) is System.Collections.ICollection panes ? panes.Count : 0; ignoreDockingField?.SetValue(null, paneCount == 1 ? sourceDockArea : null); object hostWindow = GetViewWindow(targetDockArea); object rootSplit = GetRootSplitView(hostWindow); if (rootSplit == null) return false; MethodInfo dragOver = rootSplit.GetType().GetMethod("DragOver", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new System.Type[] { typeof(EditorWindow), typeof(Vector2) }, null); object dropInfo = dragOver?.Invoke(rootSplit, new object[] { slot.Window, screenPos }); if (dropInfo == null) { CodelyLogger.LogWarning($"[NWB-Composite] DragOver returned null for slot '{slot.SlotId}' edge={dockEdge}"); return false; } MethodInfo performDrop = rootSplit.GetType().GetMethod("PerformDrop", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new System.Type[] { typeof(EditorWindow), dropInfo.GetType(), typeof(Vector2) }, null); if (performDrop == null) return false; object result = performDrop.Invoke(rootSplit, new object[] { slot.Window, dropInfo, screenPos }); bool ok = result is bool b && b; if (ok) { SelectCompositeSlotTab(slot); LogVerbose($"[NWB-Composite] Docked slot '{slot.SlotId}' to '{targetSlotId}' edge={dockEdge} via SplitView.PerformDrop"); } return ok; } catch (Exception ex) { string innerMsg = ex.InnerException?.Message ?? ex.Message; CodelyLogger.LogWarning($"[NWB-Composite] SplitView docking failed for slot '{slot.SlotId}': {innerMsg}"); return false; } finally { ResetCompositeDockAreaDragState(dockAreaForDragReset, slot); } } private static void ResetCompositeDockAreaDragState(object dockArea, CompositeCaptureSlot slot) { if (dockArea == null) return; try { System.Type dockAreaType = dockArea.GetType(); dockAreaType.GetField("s_DragPane", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)?.SetValue(null, null); dockAreaType.GetField("s_DropInfo", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)?.SetValue(null, null); dockAreaType.GetField("s_PlaceholderPos", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)?.SetValue(null, -1); dockAreaType.GetField("s_IsDragging", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)?.SetValue(null, false); dockAreaType.GetField("s_OriginalDragSource", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)?.SetValue(null, null); dockAreaType.GetField("s_IgnoreDockingForView", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)?.SetValue(null, null); object newDockArea = slot?.Window != null ? GetParentView(slot.Window) : dockArea; MethodInfo repaint = newDockArea?.GetType().GetMethod("Repaint", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); repaint?.Invoke(newDockArea, null); } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-Composite] DockArea drag state reset failed for '{slot?.SlotId}': {ex.Message}"); } } private static bool VerifySavedCompositeLayoutRects( List savedSlots, float tolerance, out float maxError) { maxError = 0f; if (savedSlots == null || savedSlots.Count == 0) return false; object rootSplit = GetRootSplitView(GetCompositeHostWindow()); if (rootSplit == null) return false; Rect rootScreen = GetViewScreenPosition(rootSplit); if (rootScreen.width <= 1f || rootScreen.height <= 1f) return false; foreach (CompositeSlotRequest savedSlot in savedSlots) { if (!TryGetNormalizedCompositeSlotRect(savedSlot, rootScreen, out Rect current)) return false; Rect expected = ClampCompositeRect(savedSlot.rect); maxError = Mathf.Max(maxError, Mathf.Abs(current.x - expected.x), Mathf.Abs(current.y - expected.y), Mathf.Abs(current.width - expected.width), Mathf.Abs(current.height - expected.height)); } return maxError <= tolerance; } private static bool TryGetNormalizedCompositeSlotRect( CompositeSlotRequest savedSlot, Rect rootScreen, out Rect normalized) { normalized = Rect.zero; if (savedSlot == null || string.IsNullOrEmpty(savedSlot.slotId)) return false; if (!TryGetCompositeSlot(savedSlot.slotId, out CompositeCaptureSlot slot) || slot?.Window == null) return false; object view = GetParentView(slot.Window); if (view == null) return false; Rect viewRect = GetViewScreenPosition(view); if (viewRect.width <= 1f || viewRect.height <= 1f) return false; normalized = NormalizeCompositeViewRect(viewRect, rootScreen); return true; } private static IEnumerable GetViewChildren(object view) { if (view == null) yield break; PropertyInfo childrenProp = view.GetType().GetProperty("children", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); object childrenObj = childrenProp?.GetValue(view); if (!(childrenObj is System.Collections.IEnumerable children)) yield break; foreach (object child in children) { if (child != null) yield return child; } } private static void SetViewPosition(object view, Rect position) { if (view == null) return; PropertyInfo positionProp = view.GetType().GetProperty("position", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); positionProp?.SetValue(view, position); } private static void ReflowCompositeSplitRecursive(object view) { if (view == null) return; if (view.GetType().Name == "SplitView") { MethodInfo reflow = view.GetType().GetMethod("Reflow", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); reflow?.Invoke(view, null); } foreach (object child in GetViewChildren(view)) ReflowCompositeSplitRecursive(child); } private static void RepaintCompositeViewRecursive(object view) { if (view == null) return; MethodInfo repaint = view.GetType().GetMethod("Repaint", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); repaint?.Invoke(view, null); foreach (object child in GetViewChildren(view)) RepaintCompositeViewRecursive(child); } private static void ReleaseCompositeState() { CancelCompositeLayoutOperation(); // Release each slot's SourceRT and close its EditorWindow. // EditorWindow.Close() removes the tab from the DockArea and, if it's // the last tab, closes the ContainerWindow through Unity's normal // destruction path (WM_CLOSE → DestroyWindow), which properly cleans // up D3D11 swap chains and RenderTextureMap entries. // // We do NOT call ContainerWindow.Close() directly via reflection. // That was introduced in commit 762d22a4 to prevent zombie HWNDs, but // it could destroy the MAIN editor's ContainerWindow when the slot's // EditorWindow was parented there — causing the editor to disappear. // Wrap in try-catch so one slot's failure doesn't block the rest. s_IsReleasingComposite = true; foreach (var slot in s_CompositeSlots.Values) { try { ReleaseCompositeSlot(slot); } catch (Exception ex) { CodelyLogger.LogError($"[NWB-Composite] ReleaseCompositeSlot failed for '{slot?.SlotId}': {ex.Message}"); } } s_CompositeSlots.Clear(); s_IsReleasingComposite = false; // Release global RenderTextures. if (RenderTexture.active == s_CompositeRT) RenderTexture.active = null; if (s_CompositeRT != null) { s_CompositeRT.Release(); UnityEngine.Object.DestroyImmediate(s_CompositeRT); s_CompositeRT = null; } if (RenderTexture.active == s_CompositePaneDragRT) RenderTexture.active = null; if (s_CompositePaneDragRT != null) { s_CompositePaneDragRT.Release(); UnityEngine.Object.DestroyImmediate(s_CompositePaneDragRT); s_CompositePaneDragRT = null; } if (s_CompositeReadbackTex != null) { UnityEngine.Object.DestroyImmediate(s_CompositeReadbackTex); s_CompositeReadbackTex = null; } s_CompositeActive = false; s_CompositeLogicalWidth = 0; s_CompositeLogicalHeight = 0; s_CompositeFrameBoundsLogical = Rect.zero; s_CompositeFrameOffsetPixels = Vector2.zero; s_CompositeFrameScalePixels = Vector2.one; s_CompositeWorkspaceReflowPending = false; s_CompositePlayModeInitDone = false; s_CompositeMouseCaptureView = null; s_CompositeMouseCaptureSlot = null; s_CompositeLastTabTarget = null; s_OffscreenTarget = null; s_OffscreenTargetType = null; s_CaptureRT = null; } } }