Files
daluandou/.codely.packages/cn.tuanjie.codely.bridge@1.0.85-47f31100/Editor/Bridge/Native/NativeWindowBridgeHost.Composite.cs
T
huadexuan 13d0c80335 ```
chore(bridge): 删除桥接包的提交记录和Git属性配置

删除了 .codely-bridge-commit 文件中存储的提交哈希值
移除了 .gitattributes 文件中的行结束符配置规则
```
2026-09-24 21:41:14 +08:00

2461 lines
108 KiB
C#

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<string, CompositeCaptureSlot> s_CompositeSlots =
new Dictionary<string, CompositeCaptureSlot>();
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;
/// <summary>
/// Save composite slot mappings so surviving windows can be rediscovered
/// after domain reload without closing them.
/// </summary>
private static void SaveCompositeSlotMapping()
{
if (s_CompositeSlots == null || s_CompositeSlots.Count == 0)
{
SessionState.EraseString(kCompositeSlotMappingKey);
return;
}
var entries = new List<CompositeSlotMappingEntry>();
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");
}
/// <summary>
/// After domain reload, rediscover surviving EditorWindows from the
/// saved mapping and pre-populate s_CompositeSlots.
/// </summary>
private static void PrePopulateCompositeSlots()
{
string json = SessionState.GetString(kCompositeSlotMappingKey, "");
SessionState.EraseString(kCompositeSlotMappingKey);
if (string.IsNullOrEmpty(json))
return;
CompositeSlotMappingStore store = null;
try
{
store = JsonUtility.FromJson<CompositeSlotMappingStore>(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));
}
/// <summary>
/// Find an existing DockArea from already-created composite slots.
/// Used by CreateCompositeSlot to dock new views immediately.
/// </summary>
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<CompositeStreamRequest>(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<CompositeStreamRequest>(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<string, int>(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<string, int>(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<string>(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<CompositeSlotRequest> 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<CompositeSlotRequest> 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);
}
/// <summary>
/// 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.
/// </summary>
private static CompositeCaptureSlot TryRemapDetachedCompositeSlot(string incomingSlotId,
string windowTypeName, HashSet<string> 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<string>();
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<string>();
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<EditorWindow>())
{
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<IntPtr> beforeShowHwnds = null;
#endif
#if UNITY_EDITOR_OSX
HashSet<IntPtr> 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<IntPtr>(GetAllUnityHwnds());
#endif
#if UNITY_EDITOR_OSX
beforeShowNSWindows = new HashSet<IntPtr>(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<CompositeSlotRequest> 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<CompositeSlotRequest> 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<CompositeSlotRequest> GetValidCompositeLayoutSlots(CompositeStreamRequest request)
{
var slots = new List<CompositeSlotRequest>();
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<List<CompositeSlotRequest>> GroupCompositeSlotsByRect(List<CompositeSlotRequest> slots)
{
var groups = new List<List<CompositeSlotRequest>>();
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<CompositeSlotRequest> { 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<CompositeSlotRequest> 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<CompositeSlotRequest> first, out List<CompositeSlotRequest> 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<CompositeSlotRequest> slots,
bool vertical,
out List<CompositeSlotRequest> first,
out List<CompositeSlotRequest> second)
{
first = null;
second = null;
const float eps = 0.01f;
var cuts = new List<float>();
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<CompositeSlotRequest>();
var b = new List<CompositeSlotRequest>();
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<CompositeSlotRequest> first, List<CompositeSlotRequest> 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<CompositeSlotRequest> savedSlots)
{
if (savedSlots == null || savedSlots.Count == 0) return false;
bool displayFrozen = false;
try
{
var groups = GroupCompositeSlotsByRect(savedSlots);
var layoutSlots = new List<CompositeSlotRequest>();
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<string, List<CompositeSlotRequest>>();
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<string, List<CompositeSlotRequest>> 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<CompositeSlotRequest> group))
group = new List<CompositeSlotRequest> { node.Slot };
object dockArea = CreateUnityEditorView("UnityEditor.DockArea");
if (dockArea == null) return null;
SetViewPosition(dockArea, localRect);
int movedCount = 0;
var movedSlots = new List<CompositeCaptureSlot>();
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<CompositeSlotRequest> 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<CompositeSlotRequest> BuildDefaultCompositeLayoutSlots(
CompositeStreamRequest request,
string hierarchySlotId,
string sceneSlotId,
string gameSlotId,
string projectSlotId,
string consoleSlotId,
string inspectorSlotId)
{
var slotsById = new Dictionary<string, CompositeSlotRequest>();
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<CompositeSlotRequest>();
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<CompositeSlotRequest> slots,
Dictionary<string, CompositeSlotRequest> 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<CompositeSlotRequest>();
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<CompositeSlotRequest> 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<CompositeSlotRequest> 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<CompositeSlotRequest> 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<object> 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;
}
}
}