Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions maclib/tray.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 68 additions & 6 deletions maclib/tray.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<CChar>?,
_ darkIconPath: UnsafePointer<CChar>?
) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 = {
Expand All @@ -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)

Expand All @@ -357,6 +406,8 @@ fun ApplicationScope.Tray(
menuContent = menuContent,
maxAttempts = 3,
backoffMs = 200,
lightIconContent = lightIconContent,
darkIconContent = darkIconContent,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading