diff --git a/maclib/tray.h b/maclib/tray.h index 71fa64e..3b2cc90 100644 --- a/maclib/tray.h +++ b/maclib/tray.h @@ -79,6 +79,9 @@ TRAY_API const char *tray_get_status_item_region(void); TRAY_API int tray_get_status_item_position_for(struct tray *tray, int *x, int *y); TRAY_API const char *tray_get_status_item_region_for(struct tray *tray); +/* macOS: pre-rendered appearance icons for instant light/dark switching */ +TRAY_API void tray_set_icons_for_appearance(struct tray *tray, const char *light_icon, const char *dark_icon); + #ifdef __cplusplus } /* extern "C" */ #endif diff --git a/maclib/tray.swift b/maclib/tray.swift index 8aeadcf..1fba0e5 100644 --- a/maclib/tray.swift +++ b/maclib/tray.swift @@ -19,6 +19,8 @@ private class TrayContext { let clickHandler: InstanceButtonClickHandler var contextMenu: NSMenu? let appearanceObserver: MenuBarAppearanceObserver + var lightImage: NSImage? + var darkImage: NSImage? init(statusItem: NSStatusItem, clickHandler: InstanceButtonClickHandler, appearanceObserver: MenuBarAppearanceObserver) { self.statusItem = statusItem self.clickHandler = clickHandler @@ -64,17 +66,23 @@ private class MenuDelegate: NSObject, NSMenuDelegate { } // MARK: - Appearance observer with ultra‑low latency -/// Detects menu‑bar theme changes in <60 ms using KVO + GCD debouncing. +/// Detects menu‑bar theme changes in <10 ms using KVO + GCD debouncing. private class MenuBarAppearanceObserver { private var observation: NSKeyValueObservation? private var workItem: DispatchWorkItem? + private var settleItem: DispatchWorkItem? private var lastAppearance: NSAppearance.Name? + private let trayPtr: UnsafeMutableRawPointer? /// Debounce delay before first evaluation (keep tiny but non‑zero). - private let debounce: TimeInterval = 0.04 // 40 ms + private let debounce: TimeInterval = 0.01 // 10 ms /// Settling delay to avoid reporting intermediate states. private let settle: TimeInterval = 0.005 // 5 ms + init(trayPtr: UnsafeMutableRawPointer? = nil) { + self.trayPtr = trayPtr + } + func startObserving(_ statusItem: NSStatusItem) { observation = statusItem.button?.observe( \.effectiveAppearance, @@ -99,16 +107,30 @@ private class MenuBarAppearanceObserver { matched != lastAppearance else { return } lastAppearance = matched + // Swap cached icon instantly if available + if let ptr = trayPtr, let ctx = contexts[ptr] { + let isDark = matched == .darkAqua + if let img = isDark ? ctx.darkImage : ctx.lightImage { + ctx.statusItem.button?.image = img + } + } + + // Cancel any pending settle callback before scheduling a new one. + settleItem?.cancel() + // Allow the system a single run‑loop to settle, then notify. - DispatchQueue.main.asyncAfter(deadline: .now() + settle) { + let item = DispatchWorkItem { themeCallback?(matched == .darkAqua ? 1 : 0) } + settleItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + settle, execute: item) } func invalidate() { observation?.invalidate() observation = nil workItem?.cancel() + settleItem?.cancel() } } @@ -186,14 +208,15 @@ public func tray_init(_ tray: UnsafeMutableRawPointer) -> Int32 { guard let bar = statusBar else { return -1 } let statusItem = bar.statusItem(withLength: NSStatusItem.variableLength) - let observer = MenuBarAppearanceObserver() - observer.startObserving(statusItem) - + let observer = MenuBarAppearanceObserver(trayPtr: tray) let clickHandler = InstanceButtonClickHandler(trayPtr: tray) let ctx = TrayContext(statusItem: statusItem, clickHandler: clickHandler, appearanceObserver: observer) + // Register context BEFORE starting observation so the .initial KVO fires with context available contexts[tray] = ctx + observer.startObserving(statusItem) + // First-time update sets image/tooltip/menu and target/action tray_update(tray) return 0 @@ -426,6 +449,45 @@ public func tray_get_status_item_region_for( return strdup(region) } +// MARK: - Pre-rendered appearance icons + +@_cdecl("tray_set_icons_for_appearance") +public func tray_set_icons_for_appearance( + _ tray: UnsafeMutableRawPointer?, + _ lightIconPath: UnsafePointer?, + _ darkIconPath: UnsafePointer? +) { + let doWork = { + guard let tray = tray, let ctx = contexts[tray] else { return } + let height = NSStatusBar.system.thickness + + if let path = lightIconPath.flatMap({ String(cString: $0) }), + let img = NSImage(contentsOfFile: path) { + let w = img.size.width * (height / img.size.height) + img.size = NSSize(width: w, height: height) + ctx.lightImage = img + } + if let path = darkIconPath.flatMap({ String(cString: $0) }), + let img = NSImage(contentsOfFile: path) { + let w = img.size.width * (height / img.size.height) + img.size = NSSize(width: w, height: height) + ctx.darkImage = img + } + + // Apply the correct variant for the current appearance + if let button = ctx.statusItem.button { + let isDark = button.effectiveAppearance + .bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + if let img = isDark ? ctx.darkImage : ctx.lightImage { + button.image = img + } + } + } + + if Thread.isMainThread { doWork() } + else { DispatchQueue.main.async { doWork() } } +} + // MARK: - Spaces / Virtual Desktop support /// Sets NSWindowCollectionBehavior.moveToActiveSpace on all app windows diff --git a/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt index f35f8c9..62f9a37 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/lib/mac/MacTrayManager.kt @@ -317,6 +317,13 @@ internal class MacTrayManager( return tray } + fun setAppearanceIcons(lightIconPath: String, darkIconPath: String) { + lock.withLock { + val t = tray ?: return + trayLib.tray_set_icons_for_appearance(t, lightIconPath, darkIconPath) + } + } + // Callback interfaces interface TrayCallback : Callback { fun invoke(tray: Pointer?) @@ -351,6 +358,8 @@ internal class MacTrayManager( @JvmStatic external fun tray_get_status_item_region_for(tray: MacTray): String? @JvmStatic external fun tray_set_windows_move_to_active_space() + + @JvmStatic external fun tray_set_icons_for_appearance(tray: MacTray, light_icon: String, dark_icon: String) } // Structure for a menu item diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt index b206a77..42f40aa 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/api/NativeTray.kt @@ -74,6 +74,9 @@ internal class NativeTray { /** * New update path: render the composable icon to PNG/ICO with retries and then update/init the tray. * If rendering keeps failing, we log and **do not create/update** the tray (never crash the app). + * + * @param lightIconContent Optional composable for the light-appearance icon (macOS only). + * @param darkIconContent Optional composable for the dark-appearance icon (macOS only). */ fun updateComposable( iconContent: @Composable () -> Unit, @@ -83,6 +86,8 @@ internal class NativeTray { menuContent: (TrayMenuBuilder.() -> Unit)? = null, maxAttempts: Int = 3, backoffMs: Long = 200, + lightIconContent: (@Composable () -> Unit)? = null, + darkIconContent: (@Composable () -> Unit)? = null, ) { trayScope.launch { val rendered = renderIconsWithRetry(iconContent, iconRenderProperties, maxAttempts, backoffMs) @@ -112,6 +117,26 @@ internal class NativeTray { errorln { "[NativeTray] Error updating tray after successful render: $th" } } } + + // On macOS, pre-render light/dark variants for instant appearance switching + if (os == MACOS && lightIconContent != null && darkIconContent != null) { + try { + val lightPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, lightIconContent) + val darkPath = ComposableIconUtils.renderComposableToPngFile(iconRenderProperties, darkIconContent) + MacTrayInitializer.setAppearanceIcons(instanceId, lightPath, darkPath) + } catch (th: Throwable) { + errorln { "[NativeTray] Failed to render appearance icons: $th" } + } + } + } + } + + /** + * Set macOS appearance icons directly from file paths. + */ + fun setMacOSAppearanceIcons(lightPath: String, darkPath: String) { + if (os == MACOS && initialized) { + MacTrayInitializer.setAppearanceIcons(instanceId, lightPath, darkPath) } } @@ -323,6 +348,7 @@ fun ApplicationScope.Tray( ) { val isDark = isMenuBarInDarkMode() val isSystemInDarkTheme = isSystemInDarkMode() + val isMacOS = getOperatingSystem() == MACOS // Define the icon content lambda val iconContent: @Composable () -> Unit = { @@ -336,6 +362,29 @@ fun ApplicationScope.Tray( ) } + // On macOS with auto-tint, pre-render both light and dark variants for instant switching + val lightIconContent: (@Composable () -> Unit)? = if (tint == null && isMacOS) { + { + Image( + imageVector = icon, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.Black) + ) + } + } else null + + val darkIconContent: (@Composable () -> Unit)? = if (tint == null && isMacOS) { + { + Image( + imageVector = icon, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.White) + ) + } + } else null + // Calculate menu hash to detect changes val menuHash = MenuContentHash.calculateMenuHash(menuContent) @@ -357,6 +406,8 @@ fun ApplicationScope.Tray( menuContent = menuContent, maxAttempts = 3, backoffMs = 200, + lightIconContent = lightIconContent, + darkIconContent = darkIconContent, ) } diff --git a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt index 27d7f04..cc4a0cb 100644 --- a/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt +++ b/src/commonMain/kotlin/com/kdroid/composetray/tray/impl/MacTrayInitializer.kt @@ -91,6 +91,11 @@ object MacTrayInitializer { manager.update(iconPath, tooltip, onLeftClick, newMenuItems) } + @Synchronized + fun setAppearanceIcons(id: String, lightIconPath: String, darkIconPath: String) { + trayManagers[id]?.setAppearanceIcons(lightIconPath, darkIconPath) + } + @Synchronized fun dispose(id: String) { trayMenuBuilders.remove(id)?.dispose()