Skip to content

Commit 7e5e9cb

Browse files
Merge pull request #29 from chambm/feat/block-paw-and-ugui-clickthrough
Universal click-through block for IMGUI windows on PAW / uGUI
2 parents f4bea46 + 6bc9489 commit 7e5e9cb

7 files changed

Lines changed: 247 additions & 5 deletions

File tree

ClickThroughBlocker/ClearInputLocks.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public class ClearInputLocks : MonoBehaviour
1919
static internal ToolbarControl toolbarControl = null;
2020
static internal ToolbarControl clickThroughToggleControl = null;
2121
static internal bool focusFollowsclick = false;
22+
static internal bool universalClickBlocking = false;
2223

2324
const string FFC_38 = "000_ClickThroughBlocker/PluginData/FFC-38";
2425
const string FFM_38 = "000_ClickThroughBlocker/PluginData/FFM-38";

ClickThroughBlocker/ClickThroughBlocker.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ internal void OnDestroy()
222222
}
223223
}
224224
#endif
225-
// This is outside the UpdateList method for runtime optimization
225+
// This is outside the UpdateList method for runtime optimization
226226
static CTBWin win = null;
227227
private static Rect UpdateList(int id, Rect rect, string text)
228228
{
@@ -248,6 +248,20 @@ private static Rect UpdateList(int id, Rect rect, string text)
248248
return rect;
249249
}
250250

251+
// Returns true when the cursor is over any IMGUI window drawn this frame.
252+
// The set of rects is populated from Harmony postfixes on every
253+
// GUILayout.Window / GUI.Window overload (see HarmonyPatches.cs), so this
254+
// covers both CTB-wrapped windows and plain mod windows that haven't adopted
255+
// CTB (MechJeb, etc.). Used by the click-blocking Harmony patches.
256+
public static bool MouseOverAnyWindow()
257+
{
258+
#if DUMMY
259+
return false;
260+
#else
261+
return IMGUIWindowTracker.MouseOverAnyWindow();
262+
#endif
263+
}
264+
251265
// This is outside all the GuiLayoutWindow methods for runtime optimization
252266
static Rect r;
253267
public static Rect GUILayoutWindow(int id, Rect screenRect, GUI.WindowFunction func, string text, GUIStyle style, params GUILayoutOption[] options)

ClickThroughBlocker/ClickThroughBlocker.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
<Compile Include="ClickThroughBlocker.cs" />
6060
<Compile Include="FocusLock.cs" />
6161
<Compile Include="GlobalFlagStorage.cs" />
62+
<Compile Include="HarmonyPatches.cs" />
6263
<Compile Include="InstallChecker.cs" />
6364
<Compile Include="Log.cs" />
6465
<Compile Include="OneTimePopup.cs" />
@@ -85,6 +86,10 @@
8586
<Reference Include="ToolbarControl">
8687
<HintPath>$(KSPDIR)\GameData\001_ToolbarControl\Plugins\ToolbarControl.dll</HintPath>
8788
</Reference>
89+
<Reference Include="0Harmony">
90+
<HintPath>$(KSPDIR)\GameData\000_Harmony\0Harmony.dll</HintPath>
91+
<Private>False</Private>
92+
</Reference>
8893
</ItemGroup>
8994
<ItemGroup>
9095
<Service Include="{508349B6-6B84-4DF5-91F0-309BEEBAD82D}" />
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#if !DUMMY
2+
using HarmonyLib;
3+
using System.Collections;
4+
using System.Collections.Generic;
5+
using System.Reflection;
6+
using UnityEngine;
7+
using UnityEngine.EventSystems;
8+
using UnityEngine.UI;
9+
10+
// CTB's existing InputLockManager / EditorLogic-based locks block IMGUI-on-IMGUI and
11+
// IMGUI-on-KSP-input click-through, but they don't reliably block clicks from reaching
12+
// Unity's uGUI EventSystem or the stock Part Action Window. These Harmony patches
13+
// fill that gap by tracking every IMGUI window drawn this frame and short-circuiting
14+
// the click-dispatch paths when the cursor is over any of them. Windows that haven't
15+
// adopted CTB (MechJeb etc., which use plain GUILayout.Window) are protected too.
16+
17+
namespace ClickThroughFix
18+
{
19+
[KSPAddon(KSPAddon.Startup.Instantly, true)]
20+
internal class CTBHarmonyLoader : MonoBehaviour
21+
{
22+
private const string HarmonyId = "ClickThroughFix.CTB";
23+
private const string UniversalLockId = "CTB_universal_IMGUI";
24+
private static bool _patched;
25+
private bool _flightLocked;
26+
private bool _editorLocked;
27+
28+
internal void Awake()
29+
{
30+
DontDestroyOnLoad(this);
31+
if (_patched) return;
32+
_patched = true;
33+
try
34+
{
35+
new Harmony(HarmonyId).PatchAll(typeof(CTBHarmonyLoader).Assembly);
36+
}
37+
catch (System.Exception e)
38+
{
39+
Log.Error("CTB Harmony patching failed: " + e);
40+
}
41+
}
42+
43+
// KSP's editor part-pick (single + double click to move) and various flight
44+
// controls poll Input.GetMouseButton directly and honor InputLockManager /
45+
// EditorLogic locks. uGUI raycaster suppression doesn't reach those paths.
46+
// Set a universal lock whenever the cursor is over any tracked IMGUI window
47+
// so a click meant for the mod window doesn't also grab a part in the editor.
48+
internal void Update()
49+
{
50+
// Sync the static enable flag from the per-save CTB settings each frame so
51+
// toggling the option in the stock settings UI takes effect immediately.
52+
if (HighLogic.CurrentGame != null)
53+
ClearInputLocks.universalClickBlocking =
54+
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().universalClickBlocking;
55+
56+
bool over = ClearInputLocks.universalClickBlocking && IMGUIWindowTracker.MouseOverAnyWindow();
57+
bool wantEditor = over && HighLogic.LoadedSceneIsEditor && EditorLogic.fetch != null;
58+
bool wantFlight = over && (HighLogic.LoadedSceneIsFlight || HighLogic.LoadedSceneHasPlanetarium);
59+
60+
if (wantEditor != _editorLocked)
61+
{
62+
if (wantEditor) EditorLogic.fetch.Lock(true, true, true, UniversalLockId);
63+
else if (EditorLogic.fetch != null) EditorLogic.fetch.Unlock(UniversalLockId);
64+
_editorLocked = wantEditor;
65+
}
66+
if (wantFlight != _flightLocked)
67+
{
68+
if (wantFlight) InputLockManager.SetControlLock(ControlTypes.ALLBUTCAMERAS, UniversalLockId);
69+
else InputLockManager.RemoveControlLock(UniversalLockId);
70+
_flightLocked = wantFlight;
71+
}
72+
}
73+
}
74+
75+
// Records the screen-space rect of every IMGUI window drawn this frame.
76+
// Accumulates within a Unity frame across all OnGUI iterations (Layout, Repaint,
77+
// MouseDown, …) — Unity dispatches MouseDown only to the focused window's body,
78+
// so per-iter clearing would lose the rest of the visible windows. Swap to
79+
// _lastRects at each new Unity frame so EventSystem.RaycastAll (running in
80+
// Update before this frame's OnGUI) sees the previous frame's full set.
81+
internal static class IMGUIWindowTracker
82+
{
83+
private static List<Rect> _curRects = new List<Rect>();
84+
private static List<Rect> _lastRects = new List<Rect>();
85+
private static int _frame = -1;
86+
87+
// Transform the returned window rect through the current GUI.matrix so we store
88+
// actual screen-pixel bounds. KSP applies a UI_SCALE matrix that some mods (KAC)
89+
// live inside; others (MechJeb) override GUI.matrix and draw in screen coords
90+
// directly. By transforming through the matrix in effect at return time, the
91+
// stored rect is always in screen-space and Contains(Input.mousePosition) works
92+
// for both kinds of mods.
93+
public static void RecordScreenSpace(Rect r)
94+
{
95+
if (!ClearInputLocks.universalClickBlocking) return;
96+
var m = GUI.matrix;
97+
Vector3 tl = m.MultiplyPoint3x4(new Vector3(r.xMin, r.yMin, 0f));
98+
Vector3 br = m.MultiplyPoint3x4(new Vector3(r.xMax, r.yMax, 0f));
99+
EnsureFrame();
100+
_curRects.Add(Rect.MinMaxRect(
101+
Mathf.Min(tl.x, br.x),
102+
Mathf.Min(tl.y, br.y),
103+
Mathf.Max(tl.x, br.x),
104+
Mathf.Max(tl.y, br.y)));
105+
}
106+
107+
public static bool MouseOverAnyWindow()
108+
{
109+
if (!ClearInputLocks.universalClickBlocking) return false;
110+
EnsureFrame();
111+
Vector2 mp = Input.mousePosition;
112+
mp.y = Screen.height - mp.y;
113+
for (int i = 0; i < _curRects.Count; i++)
114+
if (_curRects[i].Contains(mp)) return true;
115+
for (int i = 0; i < _lastRects.Count; i++)
116+
if (_lastRects[i].Contains(mp)) return true;
117+
return false;
118+
}
119+
120+
private static void EnsureFrame()
121+
{
122+
int frame = Time.frameCount;
123+
if (frame == _frame) return;
124+
var tmp = _lastRects;
125+
_lastRects = _curRects;
126+
_curRects = tmp;
127+
_curRects.Clear();
128+
_frame = frame;
129+
}
130+
}
131+
132+
// Hook every GUI.Window / GUILayout.Window overload so we catch every IMGUI
133+
// window regardless of which entry point the mod used. Double-records for
134+
// GUILayout-routed windows (which internally call GUI.Window) are harmless —
135+
// MouseOverAnyWindow only checks Contains.
136+
[HarmonyPatch]
137+
internal class CTB_TrackIMGUIWindows
138+
{
139+
[HarmonyTargetMethods]
140+
internal static IEnumerable<MethodBase> Targets()
141+
{
142+
foreach (var t in new[] { typeof(GUI), typeof(GUILayout) })
143+
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
144+
if (m.Name == "Window") yield return m;
145+
}
146+
147+
[HarmonyPostfix]
148+
internal static void Postfix(Rect __result) => IMGUIWindowTracker.RecordScreenSpace(__result);
149+
}
150+
151+
// Suppress a uGUI raycaster's hits when the cursor is over a tracked IMGUI mod
152+
// window. Stock KSP uGUI canvases overlap each other constantly (MainCanvas,
153+
// AppCanvas, Editor are all near-full-screen), so any further "is another canvas
154+
// covering us" heuristic just blocks legitimate stock UI clicks.
155+
[HarmonyPatch(typeof(GraphicRaycaster))]
156+
internal class CTB_GraphicRaycaster_Raycast
157+
{
158+
[HarmonyPrefix]
159+
[HarmonyPatch("Raycast")]
160+
[HarmonyPatch(new[] { typeof(PointerEventData), typeof(List<RaycastResult>) })]
161+
internal static bool Prefix()
162+
{
163+
return !ClickThruBlocker.MouseOverAnyWindow();
164+
}
165+
}
166+
167+
// Right-click on a part runs UIPartActionController.MouseClickCoroutine which opens
168+
// or closes the PAW. Bail when the cursor is over a registered CTB window so a
169+
// right-click meant for the mod window doesn't also pop up the PAW behind it.
170+
[HarmonyPatch(typeof(UIPartActionController))]
171+
internal class CTB_UIPartActionController_MouseClickCoroutine
172+
{
173+
[HarmonyPrefix]
174+
[HarmonyPatch("MouseClickCoroutine")]
175+
internal static bool Prefix(ref IEnumerator __result)
176+
{
177+
if (ClickThruBlocker.MouseOverAnyWindow())
178+
{
179+
__result = NoOp();
180+
return false;
181+
}
182+
return true;
183+
}
184+
185+
private static IEnumerator NoOp() { yield break; }
186+
}
187+
}
188+
#endif

ClickThroughBlocker/OneTimePopup.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public class OneTimePopup : MonoBehaviour
1313
{
1414
internal static OneTimePopup Instance = null;
1515
const int WIDTH = 600;
16-
const int HEIGHT = 350;
16+
const int HEIGHT = 400;
1717
Rect popupRect = new Rect(300, 50, WIDTH, HEIGHT);
1818
bool visible = false;
1919
static string popUpShownCfgPath { get {
@@ -41,6 +41,7 @@ public void Start()
4141
{
4242

4343
visible = true;
44+
universalClickBlocking = HighLogic.CurrentGame.Parameters.CustomParams<CTB>().universalClickBlocking;
4445
if (ClearInputLocks.modeWindow != null)
4546
{
4647
visible = true;
@@ -78,6 +79,7 @@ public void OnGUI()
7879
bool focusFollowsClick = false;
7980
bool oldFocusFollowsMouse = false;
8081
bool oldFocusFollowsClick = false;
82+
bool universalClickBlocking = false;
8183
void PopUpWindow(int id)
8284
{
8385
GUILayout.BeginVertical();
@@ -103,16 +105,21 @@ void PopUpWindow(int id)
103105
oldFocusFollowsClick = true;
104106
focusFollowsMouse = oldFocusFollowsMouse = false;
105107
}
108+
GUILayout.Space(10);
109+
universalClickBlocking = GUILayout.Toggle(universalClickBlocking,
110+
"Universal IMGUI click-through blocking (covers mods that don't use CTB)");
106111
if (!focusFollowsClick && !focusFollowsMouse)
107112
GUI.enabled = false;
108113
GUILayout.BeginHorizontal();
109114
if (GUILayout.Button("Save as global default for all new saves"))
110115
{
111-
SaveGlobalDefault(focusFollowsClick);
116+
SaveGlobalDefault(focusFollowsClick, universalClickBlocking);
112117
}
113118
if (GUILayout.Button("Accept"))
114119
{
115120
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().focusFollowsclick = focusFollowsClick;
121+
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().universalClickBlocking = universalClickBlocking;
122+
ClearInputLocks.universalClickBlocking = universalClickBlocking;
116123
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().showPopup = false;
117124
CreatePopUpFlagFile();
118125
ClearInputLocks.ClearInputLocksToggle();
@@ -140,10 +147,11 @@ static string GlobalDefaultFile
140147
return Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + "/../Global.cfg";
141148
}
142149
}
143-
static internal void SaveGlobalDefault(bool focusFollowsClick)
150+
static internal void SaveGlobalDefault(bool focusFollowsClick, bool universalClickBlocking)
144151
{
145152
ConfigNode node = new ConfigNode();
146153
node.AddValue("focusFollowsClick", focusFollowsClick);
154+
node.AddValue("universalClickBlocking", universalClickBlocking);
147155
node.Save(GlobalDefaultFile);
148156
}
149157

@@ -164,6 +172,20 @@ static internal bool GetGlobalDefault(ref bool b)
164172
}
165173
return false;
166174
}
175+
176+
static internal bool GetGlobalDefaultUniversal(ref bool b)
177+
{
178+
if (System.IO.File.Exists(GlobalDefaultFile))
179+
{
180+
if (HighLogic.CurrentGame == null || HighLogic.CurrentGame.Parameters.CustomParams<CTB>().global)
181+
{
182+
ConfigNode node = ConfigNode.Load(GlobalDefaultFile);
183+
if (node.TryGetValue("universalClickBlocking", ref b))
184+
return true;
185+
}
186+
}
187+
return false;
188+
}
167189
static internal void CreatePopUpFlagFile()
168190
{
169191
RemovePopUpFlagFile(); // remove first to avoid any overwriting

ClickThroughBlocker/RegisterToolbar.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ void Start()
1919
void OnGameSettingsWritten()
2020
{
2121
if (HighLogic.CurrentGame != null && HighLogic.CurrentGame.Parameters.CustomParams<CTB>().global)
22-
OneTimePopup.SaveGlobalDefault (HighLogic.CurrentGame.Parameters.CustomParams<CTB>().focusFollowsclick);
22+
OneTimePopup.SaveGlobalDefault(
23+
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().focusFollowsclick,
24+
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().universalClickBlocking);
2325
}
2426

2527
void OnGameNewStart()
@@ -31,6 +33,9 @@ void OnGameNewStart()
3133
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().showPopup = false;
3234
OneTimePopup.CreatePopUpFlagFile();
3335
}
36+
bool u = false;
37+
if (OneTimePopup.GetGlobalDefaultUniversal(ref u))
38+
HighLogic.CurrentGame.Parameters.CustomParams<CTB>().universalClickBlocking = u;
3439
}
3540
void OnGameStateCreated(Game g)
3641
{
@@ -41,6 +46,9 @@ void OnGameStateCreated(Game g)
4146
g.Parameters.CustomParams<CTB>().showPopup = false;
4247
OneTimePopup.CreatePopUpFlagFile();
4348
}
49+
bool u = false;
50+
if (OneTimePopup.GetGlobalDefaultUniversal(ref u))
51+
g.Parameters.CustomParams<CTB>().universalClickBlocking = u;
4452
}
4553
}
4654
}

ClickThroughBlocker/Settings.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ public class CTB : GameParameters.CustomParameterNode
3434
toolTip = "Click on a window to move the focus to it")]
3535
public bool focusFollowsclick = false;
3636

37+
[GameParameters.CustomParameterUI("Universal IMGUI click-through blocking",
38+
toolTip = "Block clicks / mouse wheel / editor part-pick from passing through\nANY IMGUI mod window onto PAW, the uGUI EventSystem, or KSP's\neditor — even mods that haven't opted in via ClickThruBlocker.GUILayoutWindow.")]
39+
public bool universalClickBlocking = false;
40+
3741

3842
[GameParameters.CustomParameterUI("Focus change is global",
3943
toolTip = "This will make it a global setting for all games")]

0 commit comments

Comments
 (0)