using System; using System.Collections; using System.Reflection; using System.Text; using UnityEditor; using UnityEngine; using UnityTcp.Editor.Helpers; namespace UnityTcp.Editor.Native { internal static partial class NativeWindowBridgeHost { // DockArea.kTabHeight — duplicated here to avoid coupling to Unity internals. private const float kDockTabStripHeight = 19f; // HostView/DockArea Styles.genericMenuTopOffset default (--window-generic-menu-top-offset). private const float kGenericMenuTopOffset = 20f; private const float kPaneOptionsButtonHeight = 18f; private const float kPaneOptionsHitWidth = 52f; private static bool s_PaneOptionsMouseDownPending; private static bool s_PaneOptionsReflectionDone; private static Type s_HostViewType; private static MethodInfo s_HostViewAddDefaultItemsToMenu; private static MethodInfo s_HostViewAddWindowActionMenu; private static FieldInfo s_GenericMenuItemsField; private static Type s_GenericMenuItemType; private static FieldInfo s_MenuItemContentField; private static FieldInfo s_MenuItemSeparatorField; private static FieldInfo s_MenuItemOnField; private static FieldInfo s_MenuItemFuncField; private static FieldInfo s_MenuItemFunc2Field; private static FieldInfo s_MenuItemUserDataField; private struct CachedPaneOptionsEntry { public Delegate callback; public object userData; public bool enabled; } private static CachedPaneOptionsEntry[] s_CachedPaneOptionsEntries; private static void EnsurePaneOptionsReflection() { if (s_PaneOptionsReflectionDone) return; s_PaneOptionsReflectionDone = true; try { var editorAsm = typeof(EditorWindow).Assembly; s_HostViewType = editorAsm.GetType("UnityEditor.HostView"); if (s_HostViewType != null) { s_HostViewAddDefaultItemsToMenu = s_HostViewType.GetMethod( "AddDefaultItemsToMenu", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); s_HostViewAddWindowActionMenu = s_HostViewType.GetMethod( "AddWindowActionMenu", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public); } s_GenericMenuItemsField = typeof(GenericMenu).GetField( "m_MenuItems", BindingFlags.Instance | BindingFlags.NonPublic); if (s_GenericMenuItemsField != null) { var sample = new GenericMenu(); IList list = s_GenericMenuItemsField.GetValue(sample) as IList; if (list != null && list.Count == 0) { sample.AddItem(new GUIContent("x"), false, () => { }); list = s_GenericMenuItemsField.GetValue(sample) as IList; } if (list != null && list.Count > 0) s_GenericMenuItemType = list[0].GetType(); } if (s_GenericMenuItemType != null) { const BindingFlags f = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; s_MenuItemContentField = s_GenericMenuItemType.GetField("content", f); s_MenuItemSeparatorField = s_GenericMenuItemType.GetField("separator", f); s_MenuItemOnField = s_GenericMenuItemType.GetField("on", f); s_MenuItemFuncField = s_GenericMenuItemType.GetField("func", f); s_MenuItemFunc2Field = s_GenericMenuItemType.GetField("func2", f); s_MenuItemUserDataField = s_GenericMenuItemType.GetField("userData", f); } } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-PaneOptions] Reflection init failed: {ex.Message}"); } } /// /// Strict DockArea tab-strip hit-test (tab header only). /// Keep this narrow so toolbar controls (e.g. GameView Gizmos) are not /// misclassified as tab-header clicks. /// private static bool IsInDockTitleBarRegion(float mouseY) { float tabStripH = Mathf.Max(s_TabBarOffsetY, kDockTabStripHeight); return mouseY >= -2f && mouseY <= tabStripH + 2f; } /// /// PaneOptions (⋮) button vertical band in DockArea header. /// Unity draws it around tabStrip + genericMenuTopOffset. /// private static bool IsInPaneOptionsVerticalBand(float mouseY) { float tabStripH = Mathf.Max(s_TabBarOffsetY, kDockTabStripHeight); float top = tabStripH + kGenericMenuTopOffset - 2f; float bottom = top + kPaneOptionsButtonHeight + 4f; return mouseY >= top && mouseY <= bottom; } private static bool IsPaneOptionsClick(float mouseX, float mouseY, float viewWidth) { // In Play mode, the game area starts below the toolbar (tab strip + toolbar). // The PaneOptions vertical band extends into this region, so game UI buttons // at the top-right (e.g. "Menu") can be mistaken for the ⋮ pane-options button // when the GameView is narrow (composite mode). Skip PaneOptions interception // for clicks that fall in the game play area. bool isGameViewPlayMode = s_OffscreenTargetType != null && s_OffscreenTargetType.Name == "GameView" && EditorApplication.isPlaying; if (isGameViewPlayMode && mouseY >= s_TabBarOffsetY + kGameViewToolbarHeight) return false; if (!IsInPaneOptionsVerticalBand(mouseY)) return false; if (viewWidth <= kPaneOptionsHitWidth + 4f) return false; return mouseX >= viewWidth - kPaneOptionsHitWidth; } /// /// Unity menu shortcuts appear as either "Label _%s" (GenericMenu / GUIContent) /// or "Label %F5" (ShortcutManager SequenceToMenuString, space-separated). /// private static void SplitUnityMenuText(string text, out string label, out string shortcut) { label = text ?? ""; shortcut = ""; if (string.IsNullOrEmpty(text)) return; int sep = text.LastIndexOf(" _", StringComparison.Ordinal); if (sep >= 0) { label = text.Substring(0, sep); shortcut = FormatUnityShortcutDisplay(text.Substring(sep + 2)); return; } int space = text.LastIndexOf(' '); if (space < 0) return; string tail = text.Substring(space + 1); if (!LooksLikeUnityMenuShortcutTail(tail)) return; label = text.Substring(0, space); shortcut = FormatUnityShortcutDisplay(tail); } private static bool LooksLikeUnityMenuShortcutTail(string tail) { if (string.IsNullOrEmpty(tail) || tail.Length < 2) return false; int i = 0; while (i < tail.Length) { char c = tail[i]; if (c == '%' || c == '#' || c == '&' || c == '^' || c == '_') { i++; continue; } break; } if (i >= tail.Length || i == 0) return false; for (int j = i; j < tail.Length; j++) { if (!char.IsLetterOrDigit(tail[j])) return false; } return true; } private static string FormatUnityShortcutDisplay(string raw) { if (string.IsNullOrEmpty(raw)) return ""; if (raw.Length > 1 && raw[0] == '_') raw = raw.Substring(1); var sb = new StringBuilder(raw.Length + 8); for (int i = 0; i < raw.Length; i++) { switch (raw[i]) { case '%': sb.Append("Ctrl+"); break; case '#': sb.Append("Shift+"); break; case '&': sb.Append("Alt+"); break; case '^': sb.Append("Ctrl+"); break; default: sb.Append(raw[i]); break; } } string result = sb.ToString(); if (result.EndsWith("+", StringComparison.Ordinal)) result = result.Substring(0, result.Length - 1); if (result.Length >= 2 && result[0] == 'f' && char.IsDigit(result[1])) result = result.ToUpperInvariant(); return result; } private static void AppendPaneOptionsMenuItemJson( StringBuilder sb, int index, string text, string tooltip, bool enabled, bool isOn, bool isSep, ref int validCount) { SplitUnityMenuText(text, out string displayLabel, out string shortcut); if (validCount > 0) sb.Append(','); sb.Append("{\"index\":"); sb.Append(index); sb.Append(",\"path\":\""); sb.Append(EscapeJsonString(text)); sb.Append("\",\"label\":\""); sb.Append(EscapeJsonString(displayLabel)); sb.Append("\",\"tooltip\":\""); sb.Append(EscapeJsonString(tooltip)); sb.Append("\",\"enabled\":"); sb.Append(enabled ? "true" : "false"); sb.Append(",\"checked\":"); sb.Append(isOn ? "true" : "false"); sb.Append(",\"separator\":"); sb.Append(isSep ? "true" : "false"); if (!string.IsNullOrEmpty(shortcut)) { sb.Append(",\"shortcut\":\""); sb.Append(EscapeJsonString(shortcut)); sb.Append('"'); } sb.Append('}'); validCount++; } /// /// mousedown on ⋮: skip SendEvent so SceneView picking / DropdownButton /// hotControl are not activated before mouseup builds the frontend menu. /// private static bool TryBeginPaneOptionsClick(float mouseX, float mouseY, float viewWidth) { if (s_FrontendPopupSent || s_OffscreenTarget == null) return false; if (!IsPaneOptionsClick(mouseX, mouseY, viewWidth)) return false; s_PaneOptionsMouseDownPending = true; if (GUIUtility.hotControl != 0) GUIUtility.hotControl = 0; LogVerbose($"[NWB-PaneOptions] mousedown captured at ({mouseX:F0},{mouseY:F0})"); return true; } /// /// Build the DockArea PaneOptions menu without GenericMenu.DropDown, /// send popup_show to the browser, cache callbacks for popup_select. /// private static bool TryInterceptPaneOptionsClick(float mouseX, float mouseY, float viewWidth, float ppp) { if (s_FrontendPopupSent || s_OffscreenTarget == null) return false; bool pendingUp = s_PaneOptionsMouseDownPending; if (!pendingUp && !IsPaneOptionsClick(mouseX, mouseY, viewWidth)) return false; EnsurePaneOptionsReflection(); if (s_GenericMenuItemsField == null || s_GenericMenuItemType == null) { CodelyLogger.LogWarning("[NWB-PaneOptions] Reflection not ready, cannot intercept"); return false; } try { object dockArea = GetParentView(s_OffscreenTarget); if (dockArea == null) return false; GenericMenu menu = BuildPaneOptionsGenericMenu(dockArea, s_OffscreenTarget); if (menu == null) return false; IList rawItems = s_GenericMenuItemsField.GetValue(menu) as IList; if (rawItems == null || rawItems.Count == 0) return false; float tabStripH = Mathf.Max(s_TabBarOffsetY, kDockTabStripHeight); ComputePopupPositionFromLocalClick( mouseX, mouseY, tabStripH + kGenericMenuTopOffset + 2f, 2f, out float popX, out float popY, explicitSourceView: dockArea, explicitPpp: ppp); s_FrontendPopupIdCounter++; s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter; s_PopupSourceType = "pane_options"; s_CachedPaneOptionsEntries = new CachedPaneOptionsEntry[rawItems.Count]; var sb = new StringBuilder(2048); sb.Append("{\"type\":\"popup_show\",\"id\":\""); sb.Append(s_CurrentFrontendPopupId); sb.Append("\",\"sourceType\":\"pane_options\",\"x\":"); sb.Append(Mathf.RoundToInt(popX)); sb.Append(",\"y\":"); sb.Append(Mathf.RoundToInt(popY)); sb.Append(",\"items\":["); int validCount = 0; for (int i = 0; i < rawItems.Count; i++) { object mi = rawItems[i]; if (mi == null) continue; bool isSep = s_MenuItemSeparatorField != null && (bool)s_MenuItemSeparatorField.GetValue(mi); bool isOn = s_MenuItemOnField != null && (bool)s_MenuItemOnField.GetValue(mi); object content = s_MenuItemContentField?.GetValue(mi); string text = ""; string tooltip = ""; if (content is GUIContent gc) { text = gc.text ?? ""; tooltip = gc.tooltip ?? ""; } Delegate func = s_MenuItemFuncField?.GetValue(mi) as Delegate; Delegate func2 = s_MenuItemFunc2Field?.GetValue(mi) as Delegate; object userData = s_MenuItemUserDataField?.GetValue(mi); bool enabled = func != null || func2 != null; s_CachedPaneOptionsEntries[i] = new CachedPaneOptionsEntry { callback = func2 ?? func, userData = userData, enabled = enabled }; AppendPaneOptionsMenuItemJson(sb, i, text, tooltip, enabled, isOn, isSep, ref validCount); } sb.Append("]}"); if (validCount == 0) return false; if (GUIUtility.hotControl != 0) GUIUtility.hotControl = 0; CancelNativeDelayedPopup(forceRemove: true); TryDismissWin32TrackPopupMenu(); s_PendingNativePopupCloseFrames = Math.Max(s_PendingNativePopupCloseFrames, 60); s_SuppressTargetRepaintWhilePopupOpen = true; bool sent = SendDataChannelMessage(sb.ToString()); s_FrontendPopupSent = sent; if (sent) { s_PaneOptionsMouseDownPending = false; CodelyLogger.Log($"[NWB-PaneOptions] Sent frontend menu: id={s_CurrentFrontendPopupId} items={validCount} target={s_OffscreenTargetType?.Name}"); } else { CodelyLogger.LogWarning("[NWB-PaneOptions] Failed to send popup_show via DataChannel"); } return sent; } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-PaneOptions] Intercept failed: {ex.Message}"); return false; } } /// /// Right-click on tab header: build and send the same DockArea menu /// (Close Tab, Lock, Maximize, etc.) used by the ⋮ pane-options button. /// Called from TrySynthesizeAndForwardContextMenuPopup when the click /// falls in the tab strip region (y <= s_TabBarOffsetY). /// private static bool TrySendTabHeaderContextMenu(float clickX, float clickY) { EnsurePaneOptionsReflection(); if (s_OffscreenTarget == null || s_GenericMenuItemsField == null) return false; try { object dockArea = GetParentView(s_OffscreenTarget); if (dockArea == null) return false; GenericMenu menu = BuildPaneOptionsGenericMenu(dockArea, s_OffscreenTarget); if (menu == null) return false; IList rawItems = s_GenericMenuItemsField.GetValue(menu) as IList; if (rawItems == null || rawItems.Count == 0) return false; s_FrontendPopupIdCounter++; s_CurrentFrontendPopupId = "popup_" + s_FrontendPopupIdCounter; s_PopupSourceType = "pane_options"; s_CachedPaneOptionsEntries = new CachedPaneOptionsEntry[rawItems.Count]; var sb = new StringBuilder(2048); sb.Append("{\"type\":\"popup_show\",\"id\":\""); sb.Append(s_CurrentFrontendPopupId); sb.Append("\",\"sourceType\":\"pane_options\",\"x\":"); float ppp = EditorGUIUtility.pixelsPerPoint; ComputePopupPositionFromLocalClick( clickX, clickY, clickY + 2f, 2f, out float popX, out float popY, explicitSourceView: dockArea, explicitPpp: ppp); sb.Append(Mathf.RoundToInt(popX)); sb.Append(",\"y\":"); sb.Append(Mathf.RoundToInt(popY)); sb.Append(",\"items\":["); int validCount = 0; for (int i = 0; i < rawItems.Count; i++) { object mi = rawItems[i]; if (mi == null) continue; bool isSep = s_MenuItemSeparatorField != null && (bool)s_MenuItemSeparatorField.GetValue(mi); bool isOn = s_MenuItemOnField != null && (bool)s_MenuItemOnField.GetValue(mi); object content = s_MenuItemContentField?.GetValue(mi); string text = ""; string tooltip = ""; if (content is GUIContent gc) { text = gc.text ?? ""; tooltip = gc.tooltip ?? ""; } Delegate func = s_MenuItemFuncField?.GetValue(mi) as Delegate; Delegate func2 = s_MenuItemFunc2Field?.GetValue(mi) as Delegate; object userData = s_MenuItemUserDataField?.GetValue(mi); bool enabled = func != null || func2 != null; s_CachedPaneOptionsEntries[i] = new CachedPaneOptionsEntry { callback = func2 ?? func, userData = userData, enabled = enabled }; AppendPaneOptionsMenuItemJson(sb, i, text, tooltip, enabled, isOn, isSep, ref validCount); } sb.Append("]}"); if (validCount == 0) return false; if (GUIUtility.hotControl != 0) GUIUtility.hotControl = 0; CancelNativeDelayedPopup(forceRemove: true); TryDismissWin32TrackPopupMenu(); s_PendingNativePopupCloseFrames = Math.Max(s_PendingNativePopupCloseFrames, 60); s_SuppressTargetRepaintWhilePopupOpen = true; bool sent = SendDataChannelMessage(sb.ToString()); s_FrontendPopupSent = sent; if (sent) CodelyLogger.Log($"[NWB-PaneOptions] Sent tab-header context menu: id={s_CurrentFrontendPopupId} items={validCount} target={s_OffscreenTargetType?.Name}"); else CodelyLogger.LogWarning("[NWB-PaneOptions] Failed to send tab-header context menu"); return sent; } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-PaneOptions] Tab header context menu failed: {ex.Message}"); return false; } } private static GenericMenu BuildPaneOptionsGenericMenu(object dockArea, EditorWindow view) { var menu = new GenericMenu(); if (view is IHasCustomMenu customMenu) customMenu.AddItemsToMenu(menu); if (s_HostViewAddDefaultItemsToMenu != null && dockArea != null) s_HostViewAddDefaultItemsToMenu.Invoke(dockArea, new object[] { menu, view }); if (s_HostViewAddWindowActionMenu != null) s_HostViewAddWindowActionMenu.Invoke(null, new object[] { menu, view }); return menu; } private static void HandlePaneOptionsPopupSelect(int index) { if (s_CachedPaneOptionsEntries == null || index < 0 || index >= s_CachedPaneOptionsEntries.Length) { CodelyLogger.LogWarning($"[NWB-PaneOptions] Invalid select index={index}"); return; } CachedPaneOptionsEntry entry = s_CachedPaneOptionsEntries[index]; if (!entry.enabled || entry.callback == null) { LogVerbose($"[NWB-PaneOptions] Select ignored: index={index} enabled={entry.enabled}"); return; } try { if (entry.callback is GenericMenu.MenuFunction2 mf2) mf2(entry.userData); else if (entry.callback is GenericMenu.MenuFunction mf) mf(); LogVerbose($"[NWB-PaneOptions] Invoked callback for index={index}"); } catch (Exception ex) { CodelyLogger.LogWarning($"[NWB-PaneOptions] Callback error index={index}: {ex.Message}"); } s_CachedPaneOptionsEntries = null; } } }