Skip to content

Commit 0b8e842

Browse files
authored
Merge pull request #373 from kdroidFilter/fix/macos-tray-toggle-and-spaces
Fix macOS tray toggle and Space switching
2 parents 15f8595 + c25aced commit 0b8e842

3 files changed

Lines changed: 225 additions & 45 deletions

File tree

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
- [Icon Limitations](#icon-limitations)
6161
- [Theme Behavior](#theme-behavior)
6262
- [ProGuard / R8](#proguard--r8)
63-
- [🧪 TrayApp (Experimental)](#-trayapp-experimental)
63+
- [🧪 TrayApp (Alpha)](#-trayapp-alpha)
6464
- [📱 Apps Using Compose Native Tray](#-apps-using-compose-native-tray)
6565
- [📄 License](#-license)
6666
- [🤝 Contribution](#-contribution)
@@ -565,9 +565,11 @@ Add the following to your ProGuard rules file:
565565
-keep class com.kdroid.composetray.** { *; }
566566
```
567567

568-
# 🧪 TrayApp (Experimental)
568+
# 🧪 TrayApp (Alpha)
569569

570-
`TrayApp` gives your desktop app a **system‑tray/menu‑bar icon** and a **tiny popup window** for quick actions. It’s perfect for quick toggles, mini dashboards, and “control center” UIs.
570+
> **Status: Alpha** — The core API is functional on Windows, macOS, and Linux, but breaking changes may still occur. Feedback and bug reports are welcome!
571+
572+
`TrayApp` gives your desktop app a **system‑tray/menu‑bar icon** and a **tiny popup window** for quick actions. It's perfect for quick toggles, mini dashboards, and "control center" UIs.
571573

572574
**Works on Windows, macOS, and Linux.** Smooth fade animations, smart positioning near the tray, and a simple API so you stay productive.
573575

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

0 commit comments

Comments
 (0)