fix(systray): highlight status item and ensure popup appears above other windows on macOS (#4913)

* fix(systray): highlight status item and ensure popup appears above other windows on macOS

Fixes #4910

This commit addresses two issues with the systray-menu example on macOS:

1. System tray icon not in selected state upon clicking:
   - Added systemTraySetHighlight() function to control the NSStatusBarButton highlight state
   - Set highlight:YES before showing menus via popUpStatusItemMenu
   - Added onAttachedWindowShown/Hidden hooks for managing highlight with attached windows
   - Note: For attached windows, the highlight may briefly flash due to macOS
     NSStatusBarButton behavior where mouse-up clears highlight state

2. Popup doesn't display on top of other windows:
   - Set window level to NSPopUpMenuWindowLevel when positioning systray popup windows
   - Call orderFrontRegardless to ensure the window appears in front

The fix adds onAttachedWindowShown() and onAttachedWindowHidden() to the
systemTrayImpl interface to allow platform-specific handling when the
attached window visibility changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add changelog entry for systray fixes (#4910)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: trigger fresh CI run

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Simplify systray fix - remove unused window highlight code

Remove onAttachedWindowShown/Hidden interface methods and systemTraySetHighlight
function since they only worked for menus, not attached windows. The menu
highlight (set before popUpStatusItemMenu) and window level fixes remain.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(v3/macos): use native menu tracking for systray highlight and focus (#4910)

Replace popUpStatusItemMenu: (which never provided native highlighting)
with a local NSEvent monitor that temporarily sets statusItem.menu before
the button processes mouse-down events. This lets macOS handle menu
tracking natively, which:

- Keeps the status bar button highlighted while the menu is open
- Does not activate the app or steal focus from other windows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Lea Anthony 2026-02-28 16:09:43 +11:00 committed by GitHub
commit 5d6e9872d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 126 additions and 24 deletions

View file

@ -24,6 +24,8 @@ After processing, the content will be moved to the main changelog and this file
## Fixed
<!-- Bug fixes -->
- Fix system tray menu highlight state on macOS - icon now shows selected state when menu is open (#4910)
- Fix system tray attached window appearing behind other windows on macOS - now uses proper popup window level (#4910)
- Fix incorrect `@wailsio/runtime` import examples across documentation (#4989)
## Deprecated

View file

@ -93,12 +93,48 @@ func systrayClickCallback(id C.long, buttonID C.int) {
systemTray.processClick(button(buttonID))
}
// systrayPreClickCallback is called from the NSEvent local monitor BEFORE the
// button processes the mouse-down. It returns 1 when the framework should
// show the menu via native tracking (proper highlight, no app activation),
// or 0 to let the action handler fire for custom click/window behaviour.
//
//export systrayPreClickCallback
func systrayPreClickCallback(id C.long, buttonID C.int) C.int {
systemTray := systemTrayMap[uint(id)]
if systemTray == nil || systemTray.nsMenu == nil {
return 0
}
b := button(buttonID)
switch b {
case leftButtonDown:
if systemTray.parent.clickHandler == nil &&
systemTray.parent.attachedWindow.Window == nil {
return 1
}
case rightButtonDown:
if systemTray.parent.rightClickHandler == nil {
// Hide the attached window before the menu appears.
if systemTray.parent.attachedWindow.Window != nil &&
systemTray.parent.attachedWindow.Window.IsVisible() {
systemTray.parent.attachedWindow.Window.Hide()
}
return 1
}
}
return 0
}
func (s *macosSystemTray) setIconPosition(position IconPosition) {
s.iconPosition = position
}
func (s *macosSystemTray) setMenu(menu *Menu) {
s.menu = menu
if s.nsStatusItem != nil && menu != nil {
menu.Update()
s.nsMenu = (menu.impl).(*macosMenu).nsMenu
C.systemTraySetCachedMenu(s.nsStatusItem, s.nsMenu)
}
}
func (s *macosSystemTray) positionWindow(window Window, offset int) error {
@ -167,6 +203,8 @@ func (s *macosSystemTray) run() {
s.menu.Update()
// Convert impl to macosMenu object
s.nsMenu = (s.menu.impl).(*macosMenu).nsMenu
// Cache on the ObjC controller for the event monitor.
C.systemTraySetCachedMenu(s.nsStatusItem, s.nsMenu)
}
})
}

View file

@ -2,8 +2,11 @@
#include <Cocoa/Cocoa.h>
@interface StatusItemController : NSObject
@interface StatusItemController : NSObject <NSMenuDelegate>
@property long id;
@property (assign) NSStatusItem *statusItem;
@property (assign) NSMenu *cachedMenu;
@property (strong) id eventMonitor;
- (void)statusItemClicked:(id)sender;
@end
@ -17,6 +20,7 @@ NSImage* imageFromBytes(const unsigned char *bytes, int length);
void systemTraySetIcon(void* nsStatusItem, void* nsImage, int position, bool isTemplate);
void systemTrayDestroy(void* nsStatusItem);
void showMenu(void* nsStatusItem, void *nsMenu);
void systemTraySetCachedMenu(void* nsStatusItem, void *nsMenu);
void systemTrayGetBounds(void* nsStatusItem, NSRect *rect, void **screen);
NSRect NSScreen_frame(void* screen);
void windowSetScreen(void* window, void* screen, int yOffset);

View file

@ -5,6 +5,7 @@
#include "systemtray_darwin.h"
extern void systrayClickCallback(long, int);
extern int systrayPreClickCallback(long, int);
// StatusItemController.m
@implementation StatusItemController
@ -14,6 +15,13 @@ extern void systrayClickCallback(long, int);
systrayClickCallback(self.id, event.type);
}
- (void)menuDidClose:(NSMenu *)menu {
// Remove the menu from the status item so future clicks invoke the
// action handler instead of re-showing the menu.
self.statusItem.menu = nil;
menu.delegate = nil;
}
@end
// Create a new system tray
@ -21,10 +29,29 @@ void* systemTrayNew(long id) {
StatusItemController *controller = [[StatusItemController alloc] init];
controller.id = id;
NSStatusItem *statusItem = [[[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength] retain];
controller.statusItem = statusItem;
[statusItem setTarget:controller];
[statusItem setAction:@selector(statusItemClicked:)];
NSButton *button = statusItem.button;
[button sendActionOn:(NSEventMaskLeftMouseDown|NSEventMaskRightMouseDown)];
// Install a local event monitor that fires BEFORE the button processes
// the mouse-down. When the pre-click callback says "show menu", we
// temporarily set statusItem.menu so the button enters native menu
// tracking this gives proper highlight and does not activate the app.
controller.eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:
(NSEventMaskLeftMouseDown|NSEventMaskRightMouseDown)
handler:^NSEvent *(NSEvent *event) {
if (event.window != button.window) return event;
int action = systrayPreClickCallback((long)controller.id, (int)event.type);
if (action == 1 && controller.cachedMenu != nil) {
controller.cachedMenu.delegate = controller;
statusItem.menu = controller.cachedMenu;
}
return event;
}];
return (void*)statusItem;
}
@ -135,31 +162,56 @@ void systemTrayDestroy(void* nsStatusItem) {
// Remove the status item from the status bar and its associated menu
dispatch_async(dispatch_get_main_queue(), ^{
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
StatusItemController *controller = (StatusItemController *)[statusItem target];
if (controller.eventMonitor) {
[NSEvent removeMonitor:controller.eventMonitor];
controller.eventMonitor = nil;
}
[[NSStatusBar systemStatusBar] removeStatusItem:statusItem];
[controller release];
[statusItem release];
});
}
// showMenu is used for programmatic OpenMenu() calls. Click-triggered
// menus are handled by the event monitor installed in systemTrayNew.
void showMenu(void* nsStatusItem, void *nsMenu) {
// Show the menu on the main thread
dispatch_async(dispatch_get_main_queue(), ^{
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
[statusItem popUpStatusItemMenu:(NSMenu *)nsMenu];
// Post a mouse up event so the statusitem defocuses
NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseUp
location:[NSEvent mouseLocation]
modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber:0
context:nil
eventNumber:0
clickCount:1
pressure:1];
[NSApp postEvent:event atStart:NO];
[statusItem.button highlight:NO];
NSMenu *menu = (NSMenu *)nsMenu;
StatusItemController *controller = (StatusItemController *)[statusItem target];
// Temporarily assign the menu for native tracking.
menu.delegate = controller;
statusItem.menu = menu;
// Synthesize a mouse-down at the button centre to trigger native
// menu tracking (highlights the button, blocks until dismissed).
NSRect frame = [statusItem.button convertRect:statusItem.button.bounds toView:nil];
NSPoint loc = NSMakePoint(NSMidX(frame), NSMidY(frame));
NSEvent *event = [NSEvent mouseEventWithType:NSEventTypeLeftMouseDown
location:loc
modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber:statusItem.button.window.windowNumber
context:nil
eventNumber:0
clickCount:1
pressure:1.0];
[statusItem.button mouseDown:event];
// Menu dismissed restore custom click handling.
statusItem.menu = nil;
menu.delegate = nil;
});
}
void systemTraySetCachedMenu(void* nsStatusItem, void *nsMenu) {
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
StatusItemController *controller = (StatusItemController *)[statusItem target];
controller.cachedMenu = (NSMenu *)nsMenu;
}
void systemTrayGetBounds(void* nsStatusItem, NSRect *rect, void **outScreen) {
NSStatusItem *statusItem = (NSStatusItem *)nsStatusItem;
NSStatusBarButton *button = statusItem.button;
@ -201,25 +253,25 @@ int statusBarHeight() {
void systemTrayPositionWindow(void* nsStatusItem, void* nsWindow, int offset) {
// Get the status item's button
NSStatusBarButton *button = [(NSStatusItem*)nsStatusItem button];
// Get the frame in screen coordinates
NSRect frame = [button.window convertRectToScreen:button.frame];
// Get the screen that contains the status item
NSScreen *screen = [button.window screen];
if (screen == nil) {
screen = [NSScreen mainScreen];
}
// Get screen's backing scale factor (DPI)
CGFloat scaleFactor = [screen backingScaleFactor];
// Get the window's frame
NSRect windowFrame = [(NSWindow*)nsWindow frame];
// Calculate the horizontal position (centered under the status item)
CGFloat windowX = frame.origin.x + (frame.size.width - windowFrame.size.width) / 2;
// If the window would go off the right edge of the screen, adjust it
if (windowX + windowFrame.size.width > screen.frame.origin.x + screen.frame.size.width) {
windowX = screen.frame.origin.x + screen.frame.size.width - windowFrame.size.width;
@ -228,17 +280,23 @@ void systemTrayPositionWindow(void* nsStatusItem, void* nsWindow, int offset) {
if (windowX < screen.frame.origin.x) {
windowX = screen.frame.origin.x;
}
// Get screen metrics
NSRect screenFrame = [screen frame];
NSRect visibleFrame = [screen visibleFrame];
// Calculate the vertical position
CGFloat scaledOffset = offset * scaleFactor;
CGFloat windowY = visibleFrame.origin.y + visibleFrame.size.height - windowFrame.size.height - scaledOffset;
// Set the window's frame
windowFrame.origin.x = windowX;
windowFrame.origin.y = windowY;
[(NSWindow*)nsWindow setFrame:windowFrame display:YES animate:NO];
// Set window level to popup menu level so it appears above other windows
[(NSWindow*)nsWindow setLevel:NSPopUpMenuWindowLevel];
// Bring window to front
[(NSWindow*)nsWindow orderFrontRegardless];
}