Skip to content

Commit 05a13d6

Browse files
committed
Refactor WindowsOutsideClickWatcher to use low-level mouse hook for improved efficiency and accuracy
- Replaced polling-based approach with a WH_MOUSE_LL hook. - Enhanced click detection logic to handle global left-button down events. - Improved thread management, cleanup, and error resilience. - Retained public API compatibility.
1 parent 43cea2e commit 05a13d6

1 file changed

Lines changed: 123 additions & 91 deletions

File tree

Lines changed: 123 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,122 +1,154 @@
11
package com.kdroid.composetray.lib.windows
22

3-
import com.sun.jna.Library
4-
import com.sun.jna.Native
5-
import com.sun.jna.Structure
3+
import com.sun.jna.Pointer
4+
import com.sun.jna.platform.win32.Kernel32
5+
import com.sun.jna.platform.win32.WinDef
6+
import com.sun.jna.platform.win32.WinUser
7+
import com.sun.jna.platform.win32.User32
68
import io.github.kdroidfilter.platformtools.OperatingSystem
79
import io.github.kdroidfilter.platformtools.getOperatingSystem
810
import java.awt.Window
9-
import java.util.concurrent.Executors
10-
import java.util.concurrent.ScheduledExecutorService
11-
import java.util.concurrent.TimeUnit
11+
import java.util.concurrent.atomic.AtomicBoolean
1212

1313
/**
14-
* Minimal JNA mapping for user32.dll functions we need.
15-
*/
16-
interface User32 : Library {
17-
companion object {
18-
val INSTANCE: User32 = Native.load("user32", User32::class.java)
19-
const val VK_LBUTTON = 0x01
20-
}
21-
22-
/**
23-
* Returns the state of a virtual key. High-order bit set => key is down.
24-
*/
25-
fun GetAsyncKeyState(vKey: Int): Short
26-
27-
/**
28-
* Retrieves the cursor's position, in screen coordinates.
29-
* Returns true on success; fills the provided POINT.
30-
*/
31-
fun GetCursorPos(lpPoint: POINT): Boolean
32-
}
33-
34-
/**
35-
* Win32 POINT structure.
36-
*/
37-
open class POINT : Structure() {
38-
@JvmField var x: Int = 0
39-
@JvmField var y: Int = 0
40-
override fun getFieldOrder() = listOf("x", "y")
41-
}
42-
43-
/**
44-
* WindowsOutsideClickWatcher: polls the left mouse button state and cursor position.
45-
* When a left-click press is detected (transition from up -> down) outside the target window,
46-
* it invokes onOutsideClick(). You can provide an optional ignore predicate (e.g., to ignore
47-
* clicks on the system tray icon) that receives (x, y) screen coordinates and returns true
48-
* if the click should be ignored.
14+
* WindowsOutsideClickWatcher using a low-level mouse hook (WH_MOUSE_LL).
15+
*
16+
* Behavior:
17+
* - Listens for global left-button *down* events.
18+
* - If the click occurs outside the supplied window (and not ignored by predicate), invokes onOutsideClick().
19+
*
20+
* Public signatures are preserved.
4921
*/
5022
class WindowsOutsideClickWatcher(
5123
private val windowSupplier: () -> Window?,
5224
private val onOutsideClick: () -> Unit,
5325
private val ignorePointPredicate: ((x: Int, y: Int) -> Boolean)? = null
5426
) : AutoCloseable {
5527

56-
private var scheduler: ScheduledExecutorService? = null
57-
private var prevLeftDown: Boolean = false
28+
@Volatile private var hookThread: Thread? = null
29+
@Volatile private var hookHandle: WinUser.HHOOK? = null
30+
@Volatile private var hookThreadId: Int = 0
31+
@Volatile private var mouseProc: WinUser.LowLevelMouseProc? = null
32+
private val stopping = AtomicBoolean(false)
33+
34+
private companion object {
35+
const val WH_MOUSE_LL = 14
36+
const val WM_LBUTTONDOWN = 0x0201
37+
const val WM_NCLBUTTONDOWN = 0x00A1
38+
const val WM_QUIT = 0x0012
39+
}
5840

41+
/** Start the global low-level mouse hook on a dedicated daemon thread. */
5942
fun start() {
6043
if (getOperatingSystem() != OperatingSystem.WINDOWS) return
61-
if (scheduler != null) return
62-
63-
scheduler = Executors.newSingleThreadScheduledExecutor { r ->
64-
Thread(r, "WindowsOutsideClickWatcher").apply { isDaemon = true }
65-
}.also { exec ->
66-
// ~60Hz polling; tune if you need lower CPU usage
67-
exec.scheduleAtFixedRate({ pollOnce() }, 0, 16, TimeUnit.MILLISECONDS)
68-
}
69-
}
70-
71-
private fun pollOnce() {
72-
try {
73-
val u32 = User32.INSTANCE
74-
// High-order bit set => key is pressed
75-
val leftDownNow = (u32.GetAsyncKeyState(User32.VK_LBUTTON).toInt() and 0x8000) != 0
76-
77-
// Fire only on the transition from "not pressed" to "pressed"
78-
if (leftDownNow && !prevLeftDown) {
79-
val win = windowSupplier.invoke()
80-
if (win != null && win.isShowing) {
81-
val pt = POINT()
82-
if (u32.GetCursorPos(pt)) {
83-
val px = pt.x
84-
val py = pt.y
85-
86-
val winLoc = try { win.locationOnScreen } catch (_: Throwable) { null }
87-
if (winLoc != null) {
88-
val wx = winLoc.x
89-
val wy = winLoc.y
90-
val ww = win.width
91-
val wh = win.height
92-
93-
val insideWindow =
94-
px >= wx && px < wx + ww && py >= wy && py < wy + wh
95-
96-
val ignored = ignorePointPredicate?.invoke(px, py) == true
97-
98-
if (!insideWindow && !ignored) {
99-
onOutsideClick.invoke()
44+
synchronized(this) {
45+
if (hookThread != null) return
46+
stopping.set(false)
47+
48+
hookThread = Thread({
49+
hookThreadId = Kernel32.INSTANCE.GetCurrentThreadId()
50+
51+
// Strong reference kept on the field to avoid GC of the callback.
52+
mouseProc = WinUser.LowLevelMouseProc { nCode, wParam, lParam ->
53+
try {
54+
if (nCode >= 0) {
55+
val msg = wParam.toInt() // For WH_MOUSE_LL this is the WM_* code.
56+
if (msg == WM_LBUTTONDOWN || msg == WM_NCLBUTTONDOWN) {
57+
// lParam is already the populated MSLLHOOKSTRUCT.
58+
val px = lParam.pt.x
59+
val py = lParam.pt.y
60+
61+
val win = windowSupplier()
62+
if (win != null && win.isShowing) {
63+
val winLoc = try { win.locationOnScreen } catch (_: Throwable) { null }
64+
if (winLoc != null) {
65+
val wx = winLoc.x
66+
val wy = winLoc.y
67+
val ww = win.width
68+
val wh = win.height
69+
70+
val insideWindow = px in wx until (wx + ww) && py in wy until (wy + wh)
71+
val ignored = ignorePointPredicate?.invoke(px, py) == true
72+
73+
if (!insideWindow && !ignored) {
74+
// Let caller decide EDT marshaling.
75+
onOutsideClick()
76+
}
77+
}
78+
}
10079
}
10180
}
81+
} catch (_: Throwable) {
82+
// Never crash the hook; always fall through to CallNextHookEx.
10283
}
84+
85+
// Pass original WPARAM and a *pointer* to the struct as LPARAM.
86+
val lParamNative = WinDef.LPARAM(Pointer.nativeValue(lParam.pointer))
87+
User32.INSTANCE.CallNextHookEx(hookHandle, nCode, wParam, lParamNative)
88+
}
89+
90+
// Install the hook (global, threadId = 0).
91+
val hMod = Kernel32.INSTANCE.GetModuleHandle(null)
92+
hookHandle = User32.INSTANCE.SetWindowsHookEx(WH_MOUSE_LL, mouseProc, hMod, 0)
93+
94+
if (hookHandle == null) {
95+
mouseProc = null
96+
return@Thread
97+
}
98+
99+
// Minimal message loop to keep the hook thread alive.
100+
val msg = WinUser.MSG()
101+
while (!stopping.get()) {
102+
val r = User32.INSTANCE.GetMessage(msg, null, 0, 0)
103+
if (r == 0 || r == -1) break // WM_QUIT or error
103104
}
104-
}
105105

106-
prevLeftDown = leftDownNow
107-
} catch (_: Throwable) {
108-
// Swallow errors to keep the scheduler alive
106+
// Cleanup before thread exits.
107+
try {
108+
hookHandle?.let { User32.INSTANCE.UnhookWindowsHookEx(it) }
109+
} finally {
110+
hookHandle = null
111+
mouseProc = null
112+
}
113+
}, "WindowsOutsideClickWatcher-LL").apply {
114+
isDaemon = true
115+
start()
116+
}
109117
}
110118
}
111119

120+
/** Stop the hook (alias to close()). */
112121
fun stop() = close()
113122

123+
/** Uninstalls the hook and signals the hook thread to exit. */
114124
override fun close() {
115-
try {
116-
scheduler?.shutdownNow()
117-
} catch (_: Throwable) {
118-
} finally {
119-
scheduler = null
125+
synchronized(this) {
126+
stopping.set(true)
127+
128+
// Unhook immediately; also helps release if thread is blocked in GetMessage().
129+
try {
130+
hookHandle?.let { User32.INSTANCE.UnhookWindowsHookEx(it) }
131+
} catch (_: Throwable) {
132+
} finally {
133+
hookHandle = null
134+
}
135+
136+
// Break GetMessage() with WM_QUIT.
137+
if (hookThreadId != 0) {
138+
try {
139+
User32.INSTANCE.PostThreadMessage(
140+
hookThreadId,
141+
WM_QUIT,
142+
WinDef.WPARAM(0),
143+
WinDef.LPARAM(0)
144+
)
145+
} catch (_: Throwable) {
146+
}
147+
}
148+
149+
hookThread = null
150+
hookThreadId = 0
151+
mouseProc = null
120152
}
121153
}
122154
}

0 commit comments

Comments
 (0)