@@ -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.
6870private 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,
@@ -99,16 +107,30 @@ private class MenuBarAppearanceObserver {
99107 matched != lastAppearance else { return }
100108 lastAppearance = matched
101109
110+ // Swap cached icon instantly if available
111+ if let ptr = trayPtr, let ctx = contexts [ ptr] {
112+ let isDark = matched == . darkAqua
113+ if let img = isDark ? ctx. darkImage : ctx. lightImage {
114+ ctx. statusItem. button? . image = img
115+ }
116+ }
117+
118+ // Cancel any pending settle callback before scheduling a new one.
119+ settleItem? . cancel ( )
120+
102121 // Allow the system a single run‑loop to settle, then notify.
103- DispatchQueue . main . asyncAfter ( deadline : . now ( ) + settle ) {
122+ let item = DispatchWorkItem {
104123 themeCallback ? ( matched == . darkAqua ? 1 : 0 )
105124 }
125+ settleItem = item
126+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + settle, execute: item)
106127 }
107128
108129 func invalidate( ) {
109130 observation? . invalidate ( )
110131 observation = nil
111132 workItem? . cancel ( )
133+ settleItem? . cancel ( )
112134 }
113135}
114136
@@ -186,14 +208,15 @@ public func tray_init(_ tray: UnsafeMutableRawPointer) -> Int32 {
186208 guard let bar = statusBar else { return - 1 }
187209 let statusItem = bar. statusItem ( withLength: NSStatusItem . variableLength)
188210
189- let observer = MenuBarAppearanceObserver ( )
190- observer. startObserving ( statusItem)
191-
211+ let observer = MenuBarAppearanceObserver ( trayPtr: tray)
192212 let clickHandler = InstanceButtonClickHandler ( trayPtr: tray)
193213
194214 let ctx = TrayContext ( statusItem: statusItem, clickHandler: clickHandler, appearanceObserver: observer)
215+ // Register context BEFORE starting observation so the .initial KVO fires with context available
195216 contexts [ tray] = ctx
196217
218+ observer. startObserving ( statusItem)
219+
197220 // First-time update sets image/tooltip/menu and target/action
198221 tray_update ( tray)
199222 return 0
@@ -426,6 +449,45 @@ public func tray_get_status_item_region_for(
426449 return strdup ( region)
427450}
428451
452+ // MARK: - Pre-rendered appearance icons
453+
454+ @_cdecl ( " tray_set_icons_for_appearance " )
455+ public func tray_set_icons_for_appearance(
456+ _ tray: UnsafeMutableRawPointer ? ,
457+ _ lightIconPath: UnsafePointer < CChar > ? ,
458+ _ darkIconPath: UnsafePointer < CChar > ?
459+ ) {
460+ let doWork = {
461+ guard let tray = tray, let ctx = contexts [ tray] else { return }
462+ let height = NSStatusBar . system. thickness
463+
464+ if let path = lightIconPath. flatMap ( { String ( cString: $0) } ) ,
465+ let img = NSImage ( contentsOfFile: path) {
466+ let w = img. size. width * ( height / img. size. height)
467+ img. size = NSSize ( width: w, height: height)
468+ ctx. lightImage = img
469+ }
470+ if let path = darkIconPath. flatMap ( { String ( cString: $0) } ) ,
471+ let img = NSImage ( contentsOfFile: path) {
472+ let w = img. size. width * ( height / img. size. height)
473+ img. size = NSSize ( width: w, height: height)
474+ ctx. darkImage = img
475+ }
476+
477+ // Apply the correct variant for the current appearance
478+ if let button = ctx. statusItem. button {
479+ let isDark = button. effectiveAppearance
480+ . bestMatch ( from: [ . darkAqua, . aqua] ) == . darkAqua
481+ if let img = isDark ? ctx. darkImage : ctx. lightImage {
482+ button. image = img
483+ }
484+ }
485+ }
486+
487+ if Thread . isMainThread { doWork ( ) }
488+ else { DispatchQueue . main. async { doWork ( ) } }
489+ }
490+
429491// MARK: - Spaces / Virtual Desktop support
430492
431493/// Sets NSWindowCollectionBehavior.moveToActiveSpace on all app windows
0 commit comments