diff --git a/docs/src/content/docs/features/windows/basics.mdx b/docs/src/content/docs/features/windows/basics.mdx index 9f66f92247e..8137e821887 100644 --- a/docs/src/content/docs/features/windows/basics.mdx +++ b/docs/src/content/docs/features/windows/basics.mdx @@ -433,6 +433,18 @@ childWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{ - `MacWindowCollectionBehaviorCanJoinAllSpaces` - Visible on all Spaces - `MacWindowCollectionBehaviorFullScreenAuxiliary` - Can overlay fullscreen apps + **Non-activating panel (`Mac.NonActivatingPanel`):** Non-activating `NSPanel`—clicks don't activate your app or make this window main; it can still be key for typing. Often used with `Frameless`, a floating `WindowLevel`, and Space `CollectionBehavior`. + + ```go + window := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Frameless: true, + Mac: application.MacWindow{ + NonActivatingPanel: true, + WindowLevel: application.MacWindowLevelFloating, + }, + }) + ``` + **Native fullscreen:** macOS fullscreen creates a new Space (virtual desktop). diff --git a/docs/src/content/docs/features/windows/options.mdx b/docs/src/content/docs/features/windows/options.mdx index 82526963b73..66bbc6cd49a 100644 --- a/docs/src/content/docs/features/windows/options.mdx +++ b/docs/src/content/docs/features/windows/options.mdx @@ -848,6 +848,15 @@ Mac: application.MacWindow{ }, ``` +**NonActivatingPanel** (`bool`) — Non-activating `NSPanel` (`NSWindowStyleMaskNonactivatingPanel`): does not activate the app or become main; can become key for input. Often with `Frameless` and other `MacWindow` fields. + +```go +Mac: application.MacWindow{ + NonActivatingPanel: true, + WindowLevel: application.MacWindowLevelFloating, +}, +``` + ### Windows Options ```go diff --git a/v3/pkg/application/webview_window_darwin.go b/v3/pkg/application/webview_window_darwin.go index e9a85e55164..0f5ff30b007 100644 --- a/v3/pkg/application/webview_window_darwin.go +++ b/v3/pkg/application/webview_window_darwin.go @@ -24,11 +24,17 @@ struct WebviewPreferences { extern void registerListener(unsigned int event); // Create a new Window -void* windowNew(unsigned int id, int width, int height, bool fraudulentWebsiteWarningEnabled, bool frameless, bool enableDragAndDrop, struct WebviewPreferences preferences) { +void* windowNew(unsigned int id, int width, int height, bool fraudulentWebsiteWarningEnabled, bool frameless, bool enableDragAndDrop, bool nonActivatingPanel, struct WebviewPreferences preferences) { NSWindowStyleMask styleMask = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable; if (frameless) { styleMask = NSWindowStyleMaskBorderless | NSWindowStyleMaskResizable | NSWindowStyleMaskMiniaturizable; } + if (nonActivatingPanel) { + // NSWindowStyleMaskNonactivatingPanel only takes effect because + // WebviewWindow inherits from NSPanel. Setting this on a plain NSWindow + // is silently ignored by AppKit. + styleMask |= NSWindowStyleMaskNonactivatingPanel; + } WebviewWindow* window = [[WebviewWindow alloc] initWithContentRect:NSMakeRect(0, 0, width-1, height-1) styleMask:styleMask backing:NSBackingStoreBuffered @@ -698,7 +704,11 @@ void windowSetPositionOnScreen(void* nsWindow, int x, int y, const char* screenI // Destroy window void windowDestroy(void* nsWindow) { - [(WebviewWindow*)nsWindow close]; + WebviewWindow* w = (WebviewWindow*)nsWindow; + [w close]; + // releasedWhenClosed is NO (see WebviewWindow init), so -close does not + // release the object. Balance the -alloc from windowNew. + [w release]; } // Remove drop shadow from window @@ -715,7 +725,9 @@ void windowSetDisableEscapeExitsFullscreen(void* nsWindow, bool disable) { // windowClose closes the current window static void windowClose(void *window) { - [(WebviewWindow*)window close]; + WebviewWindow* w = (WebviewWindow*)window; + [w close]; + [w release]; } // windowZoom @@ -906,8 +918,12 @@ void windowSetEnabled(void *window, bool enabled) { void windowFocus(void *window) { WebviewWindow* nsWindow = (WebviewWindow*)window; - // If the current application is not active, activate it - if (![[NSApplication sharedApplication] isActive]) { + // Skip the app activation step for non-activating panels - that's the entire + // point of NSWindowStyleMaskNonactivatingPanel. The panel itself can still + // become key (so text fields work) without stealing focus from whichever app + // the user was using. + BOOL isNonActivatingPanel = ([nsWindow styleMask] & NSWindowStyleMaskNonactivatingPanel) != 0; + if (!isNonActivatingPanel && ![[NSApplication sharedApplication] isActive]) { [[NSApplication sharedApplication] activateIgnoringOtherApps:YES]; } [nsWindow makeKeyAndOrderFront:nil]; @@ -1096,7 +1112,11 @@ func (w *macosWebviewWindow) close() { globalApplication.debug("Window close() called - setting unconditionallyClose flag", "windowId", w.parent.id, "title", w.parent.options.Title) // Set the unconditionallyClose flag to allow the window to close atomic.StoreUint32(&w.parent.unconditionallyClose, 1) - C.windowClose(w.nsWindow) + if w.nsWindow != nil { + C.windowClose(w.nsWindow) + // windowClose releases the ObjC object; clear the pointer so we never pass a dangling reference to C. + w.nsWindow = nil + } globalApplication.debug("Window close() completed", "windowId", w.parent.id, "title", w.parent.options.Title) // TODO: Check if we need to unregister the window here or not } @@ -1356,6 +1376,7 @@ func (w *macosWebviewWindow) run() { C.bool(macOptions.EnableFraudulentWebsiteWarnings), C.bool(options.Frameless), C.bool(options.EnableFileDrop), + C.bool(macOptions.NonActivatingPanel), w.getWebviewPreferences(), ) if macOptions.DisableEscapeExitsFullscreen { @@ -1610,7 +1631,10 @@ func (w *macosWebviewWindow) destroy() { w.parent.markAsDestroyed() // Clear caches for this window clearWindowDragCache(w.parent.id) - C.windowDestroy(w.nsWindow) + if w.nsWindow != nil { + C.windowDestroy(w.nsWindow) + w.nsWindow = nil + } } func (w *macosWebviewWindow) setHTML(html string) { diff --git a/v3/pkg/application/webview_window_darwin.h b/v3/pkg/application/webview_window_darwin.h index bfc2a83dbaf..dc29b2d7e40 100644 --- a/v3/pkg/application/webview_window_darwin.h +++ b/v3/pkg/application/webview_window_darwin.h @@ -6,7 +6,13 @@ #import #import -@interface WebviewWindow : NSWindow +// WebviewWindow inherits from NSPanel (not NSWindow) so windows opted in to +// NSWindowStyleMaskNonactivatingPanel actually honor that style. NSPanel's +// default hidesOnDeactivate=YES is cleared in webview_window_darwin.m so +// windows stay visible when another app is active. releasedWhenClosed is NO +// on purpose: the Go bridge owns the object and windowClose/windowDestroy +// must -release it (see webview_window_darwin.go). +@interface WebviewWindow : NSPanel - (BOOL) canBecomeKeyWindow; - (BOOL) canBecomeMainWindow; - (BOOL) acceptsFirstResponder; diff --git a/v3/pkg/application/webview_window_darwin.m b/v3/pkg/application/webview_window_darwin.m index 9676baee2d7..6c3b86028be 100644 --- a/v3/pkg/application/webview_window_darwin.m +++ b/v3/pkg/application/webview_window_darwin.m @@ -22,6 +22,13 @@ @implementation WebviewWindow - (WebviewWindow*) initWithContentRect:(NSRect)contentRect styleMask:(NSUInteger)windowStyle backing:(NSBackingStoreType)bufferingType defer:(BOOL)deferCreation; { self = [super initWithContentRect:contentRect styleMask:windowStyle backing:bufferingType defer:deferCreation]; + // Use releasedWhenClosed=NO so closing never implicitly -releases the object; + // Go keeps an unsafe.Pointer to the NSWindow until native teardown runs + // windowClose/windowDestroy, which must -release to balance windowNew's -alloc. + [self setReleasedWhenClosed:NO]; + // NSPanel defaults hidesOnDeactivate=YES so panels hide when the app resigns active; + // normalize to NSWindow-like NO so ordinary windows stay visible on app switch. + [self setHidesOnDeactivate:NO]; [self setAlphaValue:1.0]; [self setBackgroundColor:[NSColor clearColor]]; [self setOpaque:NO]; @@ -177,9 +184,16 @@ - (NSString *)keyStringFromEvent:(NSEvent *)event { } } - (BOOL)canBecomeKeyWindow { + // Even non-activating panels need to become key so text inputs work. return YES; } - (BOOL) canBecomeMainWindow { + // Non-activating panels (NSWindowStyleMaskNonactivatingPanel) must never + // become the application's main window — that's the canonical accessory + // panel pattern. All other windows behave as before. + if (([self styleMask] & NSWindowStyleMaskNonactivatingPanel) != 0) { + return NO; + } return YES; } - (BOOL) acceptsFirstResponder { diff --git a/v3/pkg/application/webview_window_options.go b/v3/pkg/application/webview_window_options.go index 682042a8543..31715cfc888 100644 --- a/v3/pkg/application/webview_window_options.go +++ b/v3/pkg/application/webview_window_options.go @@ -498,6 +498,22 @@ type MacWindow struct { // web content (e.g. modals with Esc-to-close behaviour) to handle Esc directly. // Default false preserves standard macOS behaviour where Esc exits fullscreen. DisableEscapeExitsFullscreen bool + + // NonActivatingPanel makes the window an NSPanel with the + // NSWindowStyleMaskNonactivatingPanel style: interacting with it does not + // activate the owning app or steal focus from the active app. The window + // can still become key (so text input works) but never main. + // + // Typical use: floating tool palettes, Spotlight-style overlays, menu-bar + // detail panels — usually combined with other window-scoped MacWindow + // fields (WindowLevel, CollectionBehavior, Backdrop) plus Frameless on + // WebviewWindowOptions. + // + // To also hide the dock icon, set ActivationPolicyAccessory at the + // *application* level via Options.Mac.ActivationPolicy. Options.Mac is + // MacOptions and is distinct from WebviewWindowOptions.Mac (this struct, + // MacWindow) — same field name, different scopes. + NonActivatingPanel bool } type MacWindowLevel string diff --git a/v3/test/manual/macos/README.md b/v3/test/manual/macos/README.md new file mode 100644 index 00000000000..5d8f628d28d --- /dev/null +++ b/v3/test/manual/macos/README.md @@ -0,0 +1,40 @@ +# macOS Manual Tests + +Manual test programs for macOS-specific window behavior. These tests can't be +automated in `go test` because they rely on AppKit-level activation state and +visual cues that only a human can verify. + +## Running + +```bash +cd v3/test/manual/macos/ +go run . +``` + +## Tests + +### non-activating-panel + +Verifies `MacWindow.NonActivatingPanel` and the underlying `NSPanel` migration. + +Opens two windows side by side: one with `NonActivatingPanel: true` (the +"Panel"), and one without (the "Normal" window, used as a control). + +| # | Action | Expected behavior | +|---|--------|-------------------| +| 1 | Bring another app to the foreground (e.g. Finder) | Both windows STAY VISIBLE. Regression check for `hidesOnDeactivate` — if the panel vanishes on app switch, the `NSPanel` default leaked through. | +| 2 | Click the input field in the **Panel** window | Cursor blinks in the input; the *other* app's menu bar stays in place. The Wails app does not become active in the dock. | +| 3 | Click the input field in the **Normal** window | Cursor blinks in the input; Wails *does* activate (menu bar switches, dock icon highlights). This is the control case. | +| 4 | Open the Window menu in Wails' menu bar (after clicking the Normal window) | Only the Normal window appears in the list. The Panel never becomes the app's main window. | +| 5 | Type into the Panel's input | Characters appear (proves the panel becomes key even though it doesn't activate the app). | +| 6 | Close the Panel via Cmd+W, then quit and re-run | No crash. Regression check for `releasedWhenClosed` — `NSPanel` defaults that to `YES` and the Go side keeps a raw pointer past `[close]`. | + +### Notes + +- Step 2 is the headline behavior. If it fails, the `windowFocus` / + `activateIgnoringOtherApps:` guard or the `NSWindowStyleMaskNonactivatingPanel` + bit isn't being honored. +- Step 1 is the regression most likely to be re-introduced — easy to forget + that `NSPanel` differs from `NSWindow` on `hidesOnDeactivate`. +- Step 4 verifies the conditional `canBecomeMainWindow` override in + `webview_window_darwin.m`. diff --git a/v3/test/manual/macos/non-activating-panel/main.go b/v3/test/manual/macos/non-activating-panel/main.go new file mode 100644 index 00000000000..c37c3b86bcb --- /dev/null +++ b/v3/test/manual/macos/non-activating-panel/main.go @@ -0,0 +1,84 @@ +// Manual test for MacWindow.NonActivatingPanel. +// +// Opens two windows side by side: +// - "Panel" - NonActivatingPanel: true (the feature under test) +// - "Normal" - default WebviewWindow (the control) +// +// See ../README.md for the verification checklist. +// +//go:build darwin + +package main + +import ( + "log" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +const panelHTML = ` + +

Non-Activating Panel

+

Click the input. Wails should NOT take focus from your other app.

+ +` + +const normalHTML = ` + +

Normal Window (control)

+

Click the input. Wails SHOULD activate (compare with the panel).

+ +` + +func main() { + app := application.New(application.Options{ + Name: "NonActivatingPanel manual test", + }) + + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Panel", + Width: 360, + Height: 180, + X: 400, + Y: 300, + Frameless: true, + HTML: panelHTML, + Mac: application.MacWindow{ + NonActivatingPanel: true, + WindowLevel: application.MacWindowLevelFloating, + Backdrop: application.MacBackdropTranslucent, + CollectionBehavior: application.MacWindowCollectionBehaviorCanJoinAllSpaces | + application.MacWindowCollectionBehaviorFullScreenAuxiliary, + }, + }) + + app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Normal", + Width: 360, + Height: 180, + X: 790, + Y: 300, + HTML: normalHTML, + }) + + log.Println("Two windows opened. See README.md for the verification checklist.") + if err := app.Run(); err != nil { + log.Fatal(err) + } +}