Skip to content

Commit ad70498

Browse files
committed
Fix macOS tray icon incorrect appearance on multi-monitor setups
Pre-render both light and dark icon variants at the Kotlin level and cache them in Swift so the appearance observer can swap icons instantly on screen/theme changes without a Kotlin round-trip. - Add tray_set_icons_for_appearance() native API (tray.h / tray.swift) - Cache light/dark NSImage in TrayContext, swap in observer evaluate() - Fix observer race condition: cancel settleItem on new appearance change - Reduce debounce from 40ms to 10ms for faster detection - Register context before starting KVO observation in tray_init - Add JNA binding and forwarding through MacTrayManager/MacTrayInitializer - Tray(ImageVector) with auto-tint pre-renders both variants on macOS Closes #306
1 parent 37806e6 commit ad70498

5 files changed

Lines changed: 134 additions & 6 deletions

File tree

maclib/tray.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ TRAY_API const char *tray_get_status_item_region(void);
7979
TRAY_API int tray_get_status_item_position_for(struct tray *tray, int *x, int *y);
8080
TRAY_API const char *tray_get_status_item_region_for(struct tray *tray);
8181

82+
/* macOS: pre-rendered appearance icons for instant light/dark switching */
83+
TRAY_API void tray_set_icons_for_appearance(struct tray *tray, const char *light_icon, const char *dark_icon);
84+
8285
#ifdef __cplusplus
8386
} /* extern "C" */
8487
#endif

maclib/tray.swift

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ private class TrayContext {
1919
let clickHandler: InstanceButtonClickHandler
2020
var contextMenu: NSMenu?
2121
let appearanceObserver: MenuBarAppearanceObserver
22+
var lightImage: NSImage?
23+
var darkImage: NSImage?
2224
init(statusItem: NSStatusItem, clickHandler: InstanceButtonClickHandler, appearanceObserver: MenuBarAppearanceObserver) {
2325
self.statusItem = statusItem
2426
self.clickHandler = clickHandler
@@ -64,17 +66,23 @@ private class MenuDelegate: NSObject, NSMenuDelegate {
6466
}
6567

6668
// MARK: - Appearance observer with ultra‑low latency
67-
/// Detects menu‑bar theme changes in <60 ms using KVO + GCD debouncing.
69+
/// Detects menu‑bar theme changes in <10 ms using KVO + GCD debouncing.
6870
private class MenuBarAppearanceObserver {
6971
private var observation: NSKeyValueObservation?
7072
private var workItem: DispatchWorkItem?
73+
private var settleItem: DispatchWorkItem?
7174
private var lastAppearance: NSAppearance.Name?
75+
private let trayPtr: UnsafeMutableRawPointer?
7276

7377
/// Debounce delay before first evaluation (keep tiny but non‑zero).
74-
private let debounce: TimeInterval = 0.04 // 40 ms
78+
private let debounce: TimeInterval = 0.01 // 10 ms
7579
/// Settling delay to avoid reporting intermediate states.
7680
private let settle: TimeInterval = 0.005 // 5 ms
7781

82+
init(trayPtr: UnsafeMutableRawPointer? = nil) {
83+
self.trayPtr = trayPtr
84+
}
85+
7886
func startObserving(_ statusItem: NSStatusItem) {
7987
observation = statusItem.button?.observe(
8088
\.effectiveAppearance,
@@ -86,6 +94,7 @@ private class MenuBarAppearanceObserver {
8694

8795
private func scheduleCheck(for appearance: NSAppearance) {
8896
workItem?.cancel()
97+
settleItem?.cancel()
8998

9099
let item = DispatchWorkItem { [weak self] in
91100
self?.evaluate(appearance)
@@ -99,16 +108,27 @@ private class MenuBarAppearanceObserver {
99108
matched != lastAppearance else { return }
100109
lastAppearance = matched
101110

111+
// Swap cached icon instantly if available
112+
if let ptr = trayPtr, let ctx = contexts[ptr] {
113+
let isDark = matched == .darkAqua
114+
if let img = isDark ? ctx.darkImage : ctx.lightImage {
115+
ctx.statusItem.button?.image = img
116+
}
117+
}
118+
102119
// Allow the system a single run‑loop to settle, then notify.
103-
DispatchQueue.main.asyncAfter(deadline: .now() + settle) {
120+
let item = DispatchWorkItem {
104121
themeCallback?(matched == .darkAqua ? 1 : 0)
105122
}
123+
settleItem = item
124+
DispatchQueue.main.asyncAfter(deadline: .now() + settle, execute: item)
106125
}
107126

108127
func invalidate() {
109128
observation?.invalidate()
110129
observation = nil
111130
workItem?.cancel()
131+
settleItem?.cancel()
112132
}
113133
}
114134

@@ -186,14 +206,15 @@ public func tray_init(_ tray: UnsafeMutableRawPointer) -> Int32 {
186206
guard let bar = statusBar else { return -1 }
187207
let statusItem = bar.statusItem(withLength: NSStatusItem.variableLength)
188208

189-
let observer = MenuBarAppearanceObserver()
190-
observer.startObserving(statusItem)
191-
209+
let observer = MenuBarAppearanceObserver(trayPtr: tray)
192210
let clickHandler = InstanceButtonClickHandler(trayPtr: tray)
193211

194212
let ctx = TrayContext(statusItem: statusItem, clickHandler: clickHandler, appearanceObserver: observer)
213+
// Register context BEFORE starting observation so the .initial KVO fires with context available
195214
contexts[tray] = ctx
196215

216+
observer.startObserving(statusItem)
217+
197218
// First-time update sets image/tooltip/menu and target/action
198219
tray_update(tray)
199220
return 0
@@ -426,6 +447,45 @@ public func tray_get_status_item_region_for(
426447
return strdup(region)
427448
}
428449

450+
// MARK: - Pre-rendered appearance icons
451+
452+
@_cdecl("tray_set_icons_for_appearance")
453+
public func tray_set_icons_for_appearance(
454+
_ tray: UnsafeMutableRawPointer?,
455+
_ lightIconPath: UnsafePointer<CChar>?,
456+
_ darkIconPath: UnsafePointer<CChar>?
457+
) {
458+
let doWork = {
459+
guard let tray = tray, let ctx = contexts[tray] else { return }
460+
let height = NSStatusBar.system.thickness
461+
462+
if let path = lightIconPath.flatMap({ String(cString: $0) }),
463+
let img = NSImage(contentsOfFile: path) {
464+
let w = img.size.width * (height / img.size.height)
465+
img.size = NSSize(width: w, height: height)
466+
ctx.lightImage = img
467+
}
468+
if let path = darkIconPath.flatMap({ String(cString: $0) }),
469+
let img = NSImage(contentsOfFile: path) {
470+
let w = img.size.width * (height / img.size.height)
471+
img.size = NSSize(width: w, height: height)
472+
ctx.darkImage = img
473+
}
474+
475+
// Apply the correct variant for the current appearance
476+
if let button = ctx.statusItem.button {
477+
let isDark = button.effectiveAppearance
478+
.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
479+
if let img = isDark ? ctx.darkImage : ctx.lightImage {
480+
button.image = img
481+
}
482+
}
483+
}
484+
485+
if Thread.isMainThread { doWork() }
486+
else { DispatchQueue.main.async { doWork() } }
487+
}
488+
429489
// MARK: - Spaces / Virtual Desktop support
430490

431491
/// Sets NSWindowCollectionBehavior.moveToActiveSpace on all app windows

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,13 @@ internal class MacTrayManager(
317317
return tray
318318
}
319319

320+
fun setAppearanceIcons(lightIconPath: String, darkIconPath: String) {
321+
lock.withLock {
322+
val t = tray ?: return
323+
trayLib.tray_set_icons_for_appearance(t, lightIconPath, darkIconPath)
324+
}
325+
}
326+
320327
// Callback interfaces
321328
interface TrayCallback : Callback {
322329
fun invoke(tray: Pointer?)
@@ -351,6 +358,8 @@ internal class MacTrayManager(
351358
@JvmStatic external fun tray_get_status_item_region_for(tray: MacTray): String?
352359

353360
@JvmStatic external fun tray_set_windows_move_to_active_space()
361+
362+
@JvmStatic external fun tray_set_icons_for_appearance(tray: MacTray, light_icon: String, dark_icon: String)
354363
}
355364

356365
// Structure for a menu item

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ internal class NativeTray {
7474
/**
7575
* New update path: render the composable icon to PNG/ICO with retries and then update/init the tray.
7676
* If rendering keeps failing, we log and **do not create/update** the tray (never crash the app).
77+
*
78+
* @param lightIconContent Optional composable for the light-appearance icon (macOS only).
79+
* @param darkIconContent Optional composable for the dark-appearance icon (macOS only).
7780
*/
7881
fun updateComposable(
7982
iconContent: @Composable () -> Unit,
@@ -83,6 +86,8 @@ internal class NativeTray {
8386
menuContent: (TrayMenuBuilder.() -> Unit)? = null,
8487
maxAttempts: Int = 3,
8588
backoffMs: Long = 200,
89+
lightIconContent: (@Composable () -> Unit)? = null,
90+
darkIconContent: (@Composable () -> Unit)? = null,
8691
) {
8792
trayScope.launch {
8893
val rendered = renderIconsWithRetry(iconContent, iconRenderProperties, maxAttempts, backoffMs)
@@ -112,6 +117,26 @@ internal class NativeTray {
112117
errorln { "[NativeTray] Error updating tray after successful render: $th" }
113118
}
114119
}
120+
121+
// On macOS, pre-render light/dark variants for instant appearance switching
122+
if (os == MACOS && lightIconContent != null && darkIconContent != null) {
123+
try {
124+
val lightPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, lightIconContent)
125+
val darkPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, darkIconContent)
126+
MacTrayInitializer.setAppearanceIcons(instanceId, lightPath, darkPath)
127+
} catch (th: Throwable) {
128+
errorln { "[NativeTray] Failed to render appearance icons: $th" }
129+
}
130+
}
131+
}
132+
}
133+
134+
/**
135+
* Set macOS appearance icons directly from file paths.
136+
*/
137+
fun setMacOSAppearanceIcons(lightPath: String, darkPath: String) {
138+
if (os == MACOS && initialized) {
139+
MacTrayInitializer.setAppearanceIcons(instanceId, lightPath, darkPath)
115140
}
116141
}
117142

@@ -323,6 +348,7 @@ fun ApplicationScope.Tray(
323348
) {
324349
val isDark = isMenuBarInDarkMode()
325350
val isSystemInDarkTheme = isSystemInDarkMode()
351+
val isMacOS = getOperatingSystem() == MACOS
326352

327353
// Define the icon content lambda
328354
val iconContent: @Composable () -> Unit = {
@@ -336,6 +362,29 @@ fun ApplicationScope.Tray(
336362
)
337363
}
338364

365+
// On macOS with auto-tint, pre-render both light and dark variants for instant switching
366+
val lightIconContent: (@Composable () -> Unit)? = if (tint == null && isMacOS) {
367+
{
368+
Image(
369+
imageVector = icon,
370+
contentDescription = null,
371+
modifier = Modifier.fillMaxSize(),
372+
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.Black)
373+
)
374+
}
375+
} else null
376+
377+
val darkIconContent: (@Composable () -> Unit)? = if (tint == null && isMacOS) {
378+
{
379+
Image(
380+
imageVector = icon,
381+
contentDescription = null,
382+
modifier = Modifier.fillMaxSize(),
383+
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.White)
384+
)
385+
}
386+
} else null
387+
339388
// Calculate menu hash to detect changes
340389
val menuHash = MenuContentHash.calculateMenuHash(menuContent)
341390

@@ -357,6 +406,8 @@ fun ApplicationScope.Tray(
357406
menuContent = menuContent,
358407
maxAttempts = 3,
359408
backoffMs = 200,
409+
lightIconContent = lightIconContent,
410+
darkIconContent = darkIconContent,
360411
)
361412
}
362413

src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ object MacTrayInitializer {
9191
manager.update(iconPath, tooltip, onLeftClick, newMenuItems)
9292
}
9393

94+
@Synchronized
95+
fun setAppearanceIcons(id: String, lightIconPath: String, darkIconPath: String) {
96+
trayManagers[id]?.setAppearanceIcons(lightIconPath, darkIconPath)
97+
}
98+
9499
@Synchronized
95100
fun dispose(id: String) {
96101
trayMenuBuilders.remove(id)?.dispose()

0 commit comments

Comments
 (0)