Skip to content

Commit ce4309e

Browse files
authored
Merge pull request #319 from kdroidFilter/windows-outside-click-watcher
Refactor and enhance tray window dismissal behavior in TrayApp
2 parents 379204b + 05a13d6 commit ce4309e

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package com.kdroid.composetray.lib.windows
2+
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
8+
import io.github.kdroidfilter.platformtools.OperatingSystem
9+
import io.github.kdroidfilter.platformtools.getOperatingSystem
10+
import java.awt.Window
11+
import java.util.concurrent.atomic.AtomicBoolean
12+
13+
/**
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.
21+
*/
22+
class WindowsOutsideClickWatcher(
23+
private val windowSupplier: () -> Window?,
24+
private val onOutsideClick: () -> Unit,
25+
private val ignorePointPredicate: ((x: Int, y: Int) -> Boolean)? = null
26+
) : AutoCloseable {
27+
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+
}
40+
41+
/** Start the global low-level mouse hook on a dedicated daemon thread. */
42+
fun start() {
43+
if (getOperatingSystem() != OperatingSystem.WINDOWS) return
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+
}
79+
}
80+
}
81+
} catch (_: Throwable) {
82+
// Never crash the hook; always fall through to CallNextHookEx.
83+
}
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
104+
}
105+
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+
}
117+
}
118+
}
119+
120+
/** Stop the hook (alias to close()). */
121+
fun stop() = close()
122+
123+
/** Uninstalls the hook and signals the hook thread to exit. */
124+
override fun close() {
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
152+
}
153+
}
154+
}

src/commonMain/kotlin/com/kdroid/composetray/tray/api/TrayApp.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import androidx.compose.ui.window.rememberDialogState
2424
import com.kdroid.composetray.lib.linux.LinuxOutsideClickWatcher
2525
import com.kdroid.composetray.lib.mac.MacOSWindowManager
2626
import com.kdroid.composetray.lib.mac.MacOutsideClickWatcher
27+
import com.kdroid.composetray.lib.windows.WindowsOutsideClickWatcher
2728
import com.kdroid.composetray.menu.api.TrayMenuBuilder
2829
import com.kdroid.composetray.utils.*
2930
import io.github.kdroidfilter.platformtools.OperatingSystem
@@ -524,12 +525,20 @@ fun ApplicationScope.TrayApp(
524525
).also { it.start() }
525526
} else null
526527

528+
val windowsWatcher = if (dismissMode == TrayWindowDismissMode.AUTO && getOperatingSystem() == WINDOWS) {
529+
WindowsOutsideClickWatcher(
530+
windowSupplier = { window },
531+
onOutsideClick = { invokeLater { requestHideExplicit() } }
532+
).also { it.start() }
533+
} else null
534+
527535
window.addWindowFocusListener(focusListener)
528536

529537
onDispose {
530538
window.removeWindowFocusListener(focusListener)
531539
macWatcher?.stop()
532540
linuxWatcher?.stop()
541+
windowsWatcher?.stop()
533542
runCatching { WindowVisibilityMonitor.recompute() }
534543
}
535544
}

0 commit comments

Comments
 (0)