Skip to content

Commit 1c3b1a4

Browse files
committed
Fix macOS tray toggle and Space switching
- Fix primary action not hiding the tray window in accessory mode (no other windows) - Add Space detection via NSApp.windows iteration (Native.getComponentID returns 0 for Compose DialogWindow) - Move window to active Space via bringFloatingWindowToFront() instead of auto-following - Suppress focus-loss auto-hide on Space switch to prevent race condition - Use AtomicLong for thread-safe cross-thread timestamp communication - Reduce toggle/show/hide delays for snappier responsiveness
1 parent 15f8595 commit 1c3b1a4

2 files changed

Lines changed: 220 additions & 42 deletions

File tree

src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacOsWindowManager.kt

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -203,24 +203,164 @@ class MacOSWindowManager {
203203
if (!isMacOs) return false
204204
val localObjc = objc ?: return false
205205
return try {
206+
// Try direct approach via Native.getComponentID
206207
val viewPtr = Native.getComponentID(awtWindow)
207-
if (viewPtr == 0L) return false
208+
debugln { "[MacOSWindowManager] setMoveToActiveSpace: viewPtr=$viewPtr" }
209+
if (viewPtr != 0L) {
210+
val nsView = Pointer(viewPtr)
211+
val windowSel = localObjc.sel_registerName("window")
212+
val nsWindow = localObjc.objc_msgSend(nsView, windowSel)
213+
if (nsWindow != Pointer.NULL) {
214+
applySpaceBehavior(localObjc, nsWindow)
215+
return true
216+
}
217+
}
218+
219+
// Fallback: iterate NSApp windows and set on floating-level windows
220+
// (tray popup uses alwaysOnTop=true which sets a floating window level)
221+
debugln { "[MacOSWindowManager] Fallback: searching NSApp windows for floating window..." }
222+
val nsApp = getNSApplication() ?: return false
223+
224+
val windowsSel = localObjc.sel_registerName("windows")
225+
val windowsArray = localObjc.objc_msgSend(nsApp, windowsSel)
226+
val countSel = localObjc.sel_registerName("count")
227+
val count = Pointer.nativeValue(localObjc.objc_msgSend(windowsArray, countSel)).toInt()
228+
debugln { "[MacOSWindowManager] Found $count NSWindows" }
229+
230+
val objectAtIndexSel = localObjc.sel_registerName("objectAtIndex:")
231+
val levelSel = localObjc.sel_registerName("level")
232+
233+
var applied = false
234+
for (i in 0 until count) {
235+
val nsWindow = localObjc.objc_msgSend(windowsArray, objectAtIndexSel, i.toLong())
236+
val level = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, levelSel))
237+
debugln { "[MacOSWindowManager] Window[$i]: level=$level" }
238+
// Floating windows have level > 0 (NSFloatingWindowLevel = 3)
239+
if (level > 0) {
240+
applySpaceBehavior(localObjc, nsWindow)
241+
applied = true
242+
}
243+
}
244+
applied
245+
} catch (e: Throwable) {
246+
debugln { "Failed to set moveToActiveSpace: ${e.message}" }
247+
false
248+
}
249+
}
250+
251+
private fun applySpaceBehavior(localObjc: ObjectiveC, nsWindow: Pointer) {
252+
val getCollSel = localObjc.sel_registerName("collectionBehavior")
253+
val current = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, getCollSel))
254+
// Ensure moveToActiveSpace is set (moves window to active Space when ordered front)
255+
val desired = (current and NSWindowCollectionBehaviorCanJoinAllSpaces.inv()) or NSWindowCollectionBehaviorMoveToActiveSpace
256+
if (current != desired) {
257+
debugln { "[MacOSWindowManager] collectionBehavior before=$current, desired=$desired" }
258+
val setCollSel = localObjc.sel_registerName("setCollectionBehavior:")
259+
localObjc.objc_msgSend(nsWindow, setCollSel, desired)
260+
val after = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, getCollSel))
261+
debugln { "[MacOSWindowManager] collectionBehavior after=$after" }
262+
}
263+
debugln { "Window configured with moveToActiveSpace" }
264+
}
265+
266+
/**
267+
* Check if any floating-level NSWindow is on the active Space.
268+
* Uses NSApp.windows iteration (same fallback as setMoveToActiveSpace).
269+
* Returns true if on active Space or if check fails (fail-open).
270+
*/
271+
fun isFloatingWindowOnActiveSpace(): Boolean {
272+
if (!isMacOs) return true
273+
val localObjc = objc ?: return true
274+
return try {
275+
val nsApp = getNSApplication() ?: return true
276+
277+
val windowsSel = localObjc.sel_registerName("windows")
278+
val windowsArray = localObjc.objc_msgSend(nsApp, windowsSel)
279+
val countSel = localObjc.sel_registerName("count")
280+
val count = Pointer.nativeValue(localObjc.objc_msgSend(windowsArray, countSel)).toInt()
281+
282+
val objectAtIndexSel = localObjc.sel_registerName("objectAtIndex:")
283+
val levelSel = localObjc.sel_registerName("level")
284+
val isOnActiveSpaceSel = localObjc.sel_registerName("isOnActiveSpace")
285+
286+
for (i in 0 until count) {
287+
val nsWindow = localObjc.objc_msgSend(windowsArray, objectAtIndexSel, i.toLong())
288+
val level = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, levelSel))
289+
if (level > 0) {
290+
val onActiveSpace = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, isOnActiveSpaceSel)) != 0L
291+
debugln { "[MacOSWindowManager] Floating window level=$level, isOnActiveSpace=$onActiveSpace" }
292+
return onActiveSpace
293+
}
294+
}
295+
true // No floating window found, assume on active Space
296+
} catch (e: Throwable) {
297+
debugln { "Failed to check isOnActiveSpace: ${e.message}" }
298+
true
299+
}
300+
}
301+
302+
/**
303+
* Check if an AWT window is currently on the active macOS Space.
304+
* Returns true if on the active Space, false if on another Space.
305+
* Returns true by default if the check cannot be performed (fail-open).
306+
*/
307+
fun isOnActiveSpace(awtWindow: java.awt.Window): Boolean {
308+
if (!isMacOs) return true
309+
val localObjc = objc ?: return true
310+
return try {
311+
val viewPtr = Native.getComponentID(awtWindow)
312+
if (viewPtr == 0L) return true
208313

209314
val nsView = Pointer(viewPtr)
210315
val windowSel = localObjc.sel_registerName("window")
211316
val nsWindow = localObjc.objc_msgSend(nsView, windowSel)
212-
if (nsWindow == Pointer.NULL) return false
317+
if (nsWindow == Pointer.NULL) return true
213318

214-
// Read current collectionBehavior and add moveToActiveSpace (1 << 1)
215-
val getCollSel = localObjc.sel_registerName("collectionBehavior")
216-
val current = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, getCollSel))
217-
val setCollSel = localObjc.sel_registerName("setCollectionBehavior:")
218-
localObjc.objc_msgSend(nsWindow, setCollSel, current or NSWindowCollectionBehaviorMoveToActiveSpace)
319+
val isOnActiveSpaceSel = localObjc.sel_registerName("isOnActiveSpace")
320+
val result = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, isOnActiveSpaceSel))
321+
result != 0L
322+
} catch (e: Throwable) {
323+
debugln { "Failed to check isOnActiveSpace: ${e.message}" }
324+
true // fail-open: assume on active Space
325+
}
326+
}
219327

220-
debugln { "Window configured to move to active Space" }
221-
true
328+
/**
329+
* Bring the floating-level NSWindow to the front on the active Space.
330+
* With moveToActiveSpace collection behavior, this physically moves the window.
331+
* Also activates the application to ensure focus is gained.
332+
*/
333+
fun bringFloatingWindowToFront(): Boolean {
334+
if (!isMacOs) return false
335+
val localObjc = objc ?: return false
336+
return try {
337+
val nsApp = getNSApplication() ?: return false
338+
339+
// Activate the app so it can receive focus
340+
val activateSel = localObjc.sel_registerName("activateIgnoringOtherApps:")
341+
localObjc.objc_msgSend(nsApp, activateSel, 1L)
342+
343+
val windowsSel = localObjc.sel_registerName("windows")
344+
val windowsArray = localObjc.objc_msgSend(nsApp, windowsSel)
345+
val countSel = localObjc.sel_registerName("count")
346+
val count = Pointer.nativeValue(localObjc.objc_msgSend(windowsArray, countSel)).toInt()
347+
348+
val objectAtIndexSel = localObjc.sel_registerName("objectAtIndex:")
349+
val levelSel = localObjc.sel_registerName("level")
350+
val makeKeyAndOrderFrontSel = localObjc.sel_registerName("makeKeyAndOrderFront:")
351+
352+
for (i in 0 until count) {
353+
val nsWindow = localObjc.objc_msgSend(windowsArray, objectAtIndexSel, i.toLong())
354+
val level = Pointer.nativeValue(localObjc.objc_msgSend(nsWindow, levelSel))
355+
if (level > 0) {
356+
debugln { "[MacOSWindowManager] bringFloatingWindowToFront: level=$level" }
357+
localObjc.objc_msgSend(nsWindow, makeKeyAndOrderFrontSel, Pointer.NULL)
358+
return true
359+
}
360+
}
361+
false
222362
} catch (e: Throwable) {
223-
debugln { "Failed to set moveToActiveSpace: ${e.message}" }
363+
debugln { "Failed to bringFloatingWindowToFront: ${e.message}" }
224364
false
225365
}
226366
}
@@ -237,6 +377,7 @@ class MacOSWindowManager {
237377
const val NSModalPanelWindowLevel = 8L
238378

239379
// NSWindowCollectionBehavior
380+
const val NSWindowCollectionBehaviorCanJoinAllSpaces = 1L // 1 << 0
240381
const val NSWindowCollectionBehaviorMoveToActiveSpace = 2L // 1 << 1
241382
}
242383

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

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import org.jetbrains.compose.resources.painterResource
4848
import java.awt.EventQueue.invokeLater
4949
import java.awt.event.WindowEvent
5050
import java.awt.event.WindowFocusListener
51+
import java.util.concurrent.atomic.AtomicLong
5152

5253
// --------------------- Public API (defaults) ---------------------
5354

@@ -500,12 +501,12 @@ private fun ApplicationScope.TrayAppImplOriginal(
500501
var shouldShowWindow by remember { mutableStateOf(false) }
501502

502503
var lastPrimaryActionAt by remember { mutableStateOf(0L) }
503-
val toggleDebounceMs = 280L
504+
val toggleDebounceMs = 150L
504505

505506
var lastShownAt by remember { mutableStateOf(0L) }
506507
var lastHiddenAt by remember { mutableStateOf(0L) }
507-
val minVisibleDurationMs = 350L
508-
val minHiddenDurationMs = 250L
508+
val minVisibleDurationMs = 200L
509+
val minHiddenDurationMs = 100L
509510

510511
var lastFocusLostAt by remember { mutableStateOf(0L) }
511512
var autoHideEnabledAt by remember { mutableStateOf(0L) }
@@ -522,53 +523,69 @@ private fun ApplicationScope.TrayAppImplOriginal(
522523
// Visibility controller for exit-finish observation; content will NOT be disposed.
523524
val visibleState = remember { MutableTransitionState(false) }
524525

526+
// Thread-safe timestamps for cross-thread communication (IO thread reads, EDT writes)
527+
val lastFocusLostAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
528+
val lastHiddenAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
529+
val lastShownAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
530+
val lastPrimaryActionAtMs = remember { java.util.concurrent.atomic.AtomicLong(0L) }
531+
525532
val requestHideExplicit: () -> Unit = {
526533
val now = System.currentTimeMillis()
527-
val sinceShow = now - lastShownAt
534+
val sinceShow = now - lastShownAtMs.get()
535+
debugln { "[TrayApp] requestHideExplicit called, sinceShow=${sinceShow}ms, thread=${Thread.currentThread().name}" }
528536
if (sinceShow >= minVisibleDurationMs) {
529537
trayAppState.hide()
530-
lastHiddenAt = System.currentTimeMillis()
538+
lastHiddenAtMs.set(System.currentTimeMillis())
539+
lastHiddenAt = lastHiddenAtMs.get()
531540
} else {
532541
val wait = minVisibleDurationMs - sinceShow
533542
CoroutineScope(Dispatchers.IO).launch {
534543
delay(wait)
535544
trayAppState.hide()
536-
lastHiddenAt = System.currentTimeMillis()
545+
lastHiddenAtMs.set(System.currentTimeMillis())
546+
lastHiddenAt = lastHiddenAtMs.get()
537547
}
538548
}
539549
}
540550

541551
val internalPrimaryAction: () -> Unit = {
542552
val now = System.currentTimeMillis()
543-
if (now - lastPrimaryActionAt >= toggleDebounceMs) {
553+
// Read directly from StateFlow for thread-safe cross-thread access
554+
val isVisibleNow = trayAppState.isVisible.value
555+
val timeSinceLastAction = now - lastPrimaryActionAtMs.get()
556+
debugln { "[TrayApp] primaryAction: isVisibleNow=$isVisibleNow, isVisible(compose)=$isVisible, timeSinceLastAction=${timeSinceLastAction}ms, thread=${Thread.currentThread().name}" }
557+
if (timeSinceLastAction >= toggleDebounceMs) {
558+
lastPrimaryActionAtMs.set(now)
544559
lastPrimaryActionAt = now
545-
if (isVisible) {
546-
// On macOS, check if window has focus before hiding
547-
// If it doesn't have focus (e.g., on another Space), bring it to front instead
548-
if (getOperatingSystem() == MACOS && windowRef != null) {
549-
val hasFocus = runCatching { windowRef!!.isFocused() }.getOrElse { false }
550-
if (!hasFocus) {
551-
// Window is not focused (likely on another Space), bring it to current Space
560+
if (isVisibleNow) {
561+
// On macOS, check if the window is on another Space
562+
if (getOperatingSystem() == MACOS) {
563+
val onActiveSpace = runCatching { MacOSWindowManager().isFloatingWindowOnActiveSpace() }.getOrElse { true }
564+
debugln { "[TrayApp] primaryAction: onActiveSpace=$onActiveSpace" }
565+
if (!onActiveSpace) {
566+
// Window is on another Space → move it to current Space via native API
567+
debugln { "[TrayApp] primaryAction -> MOVE TO CURRENT SPACE" }
552568
invokeLater {
553-
runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() }
554-
runCatching { MacOSWindowManager().setMoveToActiveSpace(windowRef!!) }
555-
runCatching {
556-
windowRef!!.toFront()
557-
windowRef!!.requestFocus()
558-
windowRef!!.requestFocusInWindow()
559-
}
569+
runCatching { MacOSWindowManager().bringFloatingWindowToFront() }
560570
}
561571
} else {
572+
debugln { "[TrayApp] primaryAction -> HIDE" }
562573
requestHideExplicit()
563574
}
564575
} else {
576+
debugln { "[TrayApp] primaryAction -> HIDE" }
565577
requestHideExplicit()
566578
}
567579
} else {
568-
if (now - lastHiddenAt >= minHiddenDurationMs) {
569-
if (getOperatingSystem() == WINDOWS && (now - lastFocusLostAt) < 300) {
570-
// ignore immediate re-show after focus loss on Windows
580+
val hiddenAgo = now - lastHiddenAtMs.get()
581+
val focusLostAgo = now - lastFocusLostAtMs.get()
582+
debugln { "[TrayApp] primaryAction SHOW path: hiddenAgo=${hiddenAgo}ms, focusLostAgo=${focusLostAgo}ms" }
583+
if (hiddenAgo >= minHiddenDurationMs) {
584+
if ((getOperatingSystem() == WINDOWS || getOperatingSystem() == MACOS) && focusLostAgo < 150) {
585+
// ignore immediate re-show after focus loss on Windows/macOS
586+
debugln { "[TrayApp] primaryAction -> SHOW BLOCKED (recent focus loss)" }
571587
} else {
588+
debugln { "[TrayApp] primaryAction -> SHOW" }
572589
// Pre-compute position at click time: the native status item
573590
// geometry is guaranteed to be available right now.
574591
runCatching {
@@ -580,8 +597,12 @@ private fun ApplicationScope.TrayAppImplOriginal(
580597
}
581598
trayAppState.show()
582599
}
600+
} else {
601+
debugln { "[TrayApp] primaryAction -> SHOW BLOCKED (too soon after hide: ${hiddenAgo}ms < ${minHiddenDurationMs}ms)" }
583602
}
584603
}
604+
} else {
605+
debugln { "[TrayApp] primaryAction -> DEBOUNCED (${timeSinceLastAction}ms < ${toggleDebounceMs}ms)" }
585606
}
586607
}
587608

@@ -629,22 +650,25 @@ private fun ApplicationScope.TrayAppImplOriginal(
629650
dialogState.position = position
630651

631652
// Wait for Compose to apply the position before showing the window
632-
// This prevents the window from flashing at the wrong position
633-
delay(150) // Give Compose time to recompose with new position
653+
delay(30)
634654

635655
if (getOperatingSystem() == WINDOWS) {
636656
autoHideEnabledAt = System.currentTimeMillis() + 1000
637657
}
638658
debugln { "[TrayApp] Now showing window" }
639659
shouldShowWindow = true
640-
lastShownAt = System.currentTimeMillis()
660+
val showTime = System.currentTimeMillis()
661+
lastShownAt = showTime
662+
lastShownAtMs.set(showTime)
641663
}
642664
} else {
643665
// Wait for exit animation to finish, then actually hide the window
644666
if (shouldShowWindow) {
645667
snapshotFlow { visibleState.isIdle && !visibleState.currentState }.first { it }
646668
shouldShowWindow = false
647-
lastHiddenAt = System.currentTimeMillis()
669+
val hideTime = System.currentTimeMillis()
670+
lastHiddenAt = hideTime
671+
lastHiddenAtMs.set(hideTime)
648672
}
649673
}
650674
}
@@ -694,8 +718,11 @@ private fun ApplicationScope.TrayAppImplOriginal(
694718
invokeLater {
695719
// Move the popup to the current Space before bringing it to front (macOS)
696720
if (getOperatingSystem() == MACOS) {
697-
runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() }
698-
runCatching { MacOSWindowManager().setMoveToActiveSpace(window) }
721+
debugln { "[TrayApp] Setting up macOS Space behavior on window..." }
722+
val nativeResult = runCatching { MacTrayLoader.lib.tray_set_windows_move_to_active_space() }
723+
debugln { "[TrayApp] tray_set_windows_move_to_active_space: ${nativeResult.exceptionOrNull()?.message ?: "OK"}" }
724+
val jnaResult = runCatching { MacOSWindowManager().setMoveToActiveSpace(window) }
725+
debugln { "[TrayApp] setMoveToActiveSpace result=${jnaResult.getOrNull()}, error=${jnaResult.exceptionOrNull()?.message}" }
699726
}
700727
debugln { "[TrayApp] After invokeLater: window at x=${window.x}, y=${window.y}" }
701728
runCatching {
@@ -708,8 +735,18 @@ private fun ApplicationScope.TrayAppImplOriginal(
708735
val focusListener = object : WindowFocusListener {
709736
override fun windowGainedFocus(e: WindowEvent?) = Unit
710737
override fun windowLostFocus(e: WindowEvent?) {
711-
lastFocusLostAt = System.currentTimeMillis()
712-
if (getOperatingSystem() == WINDOWS && lastFocusLostAt < autoHideEnabledAt) return
738+
val now = System.currentTimeMillis()
739+
lastFocusLostAtMs.set(now)
740+
lastFocusLostAt = now
741+
debugln { "[TrayApp] windowLostFocus at $now, dismissMode=$dismissMode, thread=${Thread.currentThread().name}" }
742+
if (getOperatingSystem() == WINDOWS && now < autoHideEnabledAt) return
743+
// On macOS, don't auto-hide if the window is not on the active Space.
744+
// A Space switch caused the focus loss — let the primary action handle it.
745+
if (getOperatingSystem() == MACOS) {
746+
val onActiveSpace = runCatching { MacOSWindowManager().isFloatingWindowOnActiveSpace() }.getOrElse { true }
747+
debugln { "[TrayApp] windowLostFocus: onActiveSpace=$onActiveSpace" }
748+
if (!onActiveSpace) return
749+
}
713750
if (dismissMode == TrayWindowDismissMode.AUTO) requestHideExplicit()
714751
}
715752
}

0 commit comments

Comments
 (0)