macOS INativeWindow Implementation
July 28, 2026 · View on GitHub
This document describes how INativeWindow is implemented on macOS via CocoaWindow, including window lifecycle, popup management, and behavioral differences from Windows.
For the overall architecture (entry point, INativeController, services), see OSProvider.md. For graphics rendering and elements, see OSProvider_Graphics.md. For hosted mode (single-window rendering, virtual windows), see OSProvider_HostedMode.md.
CocoaWindow
File: Mac/NativeWindow/OSX/CocoaWindow.mm
CocoaWindow implements INativeWindow. Each instance wraps an NSWindow (specifically the CocoaNSWindow subclass), an NSWindowController, and a CocoaWindowDelegate.
Window Creation
CreateWindow() allocates a CocoaNSWindow with:
- Style:
Titled | Closable | Miniaturizable | Resizable - Backing:
NSBackingStoreBuffered, deferred:YES - Initial frame:
(0, 0, 0, 0)— the window starts hidden and zero-sized
The window is not shown at creation time. It only becomes visible when GacUI explicitly calls Show() or ShowDeactivated(). This is important: GacUI creates multiple native windows during initialization (for popups, tooltips, menus) and controls their visibility via its own lifecycle.
Show / ShowDeactivated / Hide
Show() — For the main window (no parent): [nsWindow makeKeyAndOrderFront:nil] + [nsWindow makeMainWindow]. For child windows with non-borderless style (modal dialogs): [nsWindow makeKeyAndOrderFront:nil] + makeFirstResponder. For borderless child windows (popups): [nsWindow orderFront:nil] + makeFirstResponder. On first call, fires InvokeOpened().
ShowDeactivated() — Used for popups. Calls [nsWindow orderFront:nil] + makeFirstResponder without making the window key or main. Wrapped in suppressClosePopups = true/false to prevent re-entrant popup closing (see below). On first call, fires InvokeOpened().
Hide(bool closeWindow) — Matches Windows' PostMessage(WM_CLOSE) semantics. The closeWindow argument is reserved and not used. Calls InvokeClosing() first — if cancelled, returns immediately. Otherwise hides the window via [nsWindow orderOut:nil], fires InvokeClosed(). When the hidden window was key, Hide() records its parent chain before InvokeClosing() because GuiWindow::ShowWithOwner() detaches the native parent during WindowReadyToClose. If the application is still active and AppKit has not selected another key window after orderOut:, the nearest still-existing, visible, enabled parent is activated. This matches hosted and Windows behavior without stealing focus when a non-key popup is hidden or when another application is active. For the main window, Hide() additionally defers DestroyNativeWindow via dispatch_async to trigger app exit (the deferral avoids deleting this while still inside Hide(), matching Windows' async PostMessage behavior). The main window check uses cocoaController (the native controller stored at construction) rather than GetCurrentController(), because in hosted mode GetCurrentController() returns the GuiHostedController whose GetMainWindow() returns a GuiHostedWindow, not the native CocoaWindow.
IsVisible
return [nsWindow isVisible];
This is critical for GacUI's render loop. The render loop (GuiGraphicsHost::Render) only runs when nativeWindow->IsVisible() == true. Inside the render loop, UpdateClientSizeAfterRendering() computes the correct layout size and resizes the window.
A window that starts at 0×0 (like menu popups) relies on the render loop to compute its content size. If IsVisible() returned false for 0×0 windows, the render loop would never run, and the window would stay at 0×0 forever — a deadlock. This is why IsVisible() must not add extra checks like frame.size.width > 0.
SetBounds / SetClientSize
SetBounds() — Notifies listeners via Moving(), flips coordinates for macOS (Cocoa uses bottom-left origin), calls [nsWindow setFrame:display:YES]. Does not call Show().
On Windows, SetBounds maps to MoveWindow / SetWindowPos, which repositions and resizes without affecting visibility or activation. The macOS implementation must match this behavior — SetBounds should never make a window visible or steal focus. GacUI calls Show() / ShowDeactivated() explicitly when it wants a window displayed.
SetClientSize() — Computes the new bounds preserving the top-left position, then calls SetBounds().
SetParent / Child Windows
SetParent(parent) manages the macOS child window relationship:
- Removes from old parent:
[oldParent removeChildWindow:nsWindow], removes fromchildWindowslist - Adds to new parent:
[newParent addChildWindow:nsWindow ordered:NSWindowAbove], adds tochildWindowslist - The
addChildWindow:call is wrapped insuppressClosePopups = true/falsebecause it synchronously triggerswindowDidBecomeKey:→InvokeGotFocus()→ClosePopups(), which would immediately close the popup being opened.
Popup windows (menus, tooltips, combo dropdowns) are child windows of their logical owner.
SetTopMost / GetTopMost
On Windows, "TopMost" means WS_EX_TOPMOST — a z-order flag that keeps a window above all non-topmost windows. The macOS equivalent is the window level:
void SetTopMost(bool topmost) {
[nsWindow setLevel: topmost ? NSPopUpMenuWindowLevel : NSNormalWindowLevel];
}
bool GetTopMost() {
return [nsWindow level] > NSNormalWindowLevel;
}
GacUI calls SetTopMost(controlWindow->GetTopMost()) when opening popups to ensure they appear above their owner.
Custom Frame Mode
When custom frame mode is enabled, the window uses NSWindowStyleMaskBorderless so GacUI can draw its own window chrome. UpdateStyleMask() computes the style mask based on customFrameMode, hasBorder, hasSizeBox, and hasMinimizedBox.
In custom frame mode, CocoaWindow handles hit-testing manually in HandleEventInternal() to detect resize edges and title bar drag areas, matching the behavior of INativeWindowListener::HitTest().
Note: NSWindowStyleMaskBorderless has value 0. This means XOR-based toggling does not work — the style mask must be rebuilt from the boolean flags each time.
Cursor Management and borderOverrideCursor
In custom frame mode, HandleEventInternal() dispatches MouseMoving to all listeners first, then calls HitTestMouseMove() → SetResizingBorder(). This ordering matters: GuiGraphicsHost::MouseMoving() (a listener) sets the composition-level cursor on the window, then SetResizingBorder() overrides with a resize cursor if the mouse is on a border.
CocoaWindow maintains two cursor states:
currentCursor— the application-managed cursor, set bySetWindowCursor()(called byGuiGraphicsHostviaGuiHostedWindowin hosted mode, or directly in non-hosted mode).borderOverrideCursor— a temporary override set bySetResizingBorder()when the mouse is on a window border. Set tonullptrwhen the mouse leaves the border.
SetResizingBorder() does not call SetWindowCursor() for border cursors — it applies them visually without polluting currentCursor. When the mouse leaves the border (hit test returns NoDecision, Title, etc.), borderOverrideCursor is cleared and currentCursor is re-applied.
This separation is critical for hosted mode. In hosted mode, GuiHostedWindow::SetWindowCursor() has an early-return optimization: if the cursor hasn't changed, it skips propagation to the native CocoaWindow. If SetResizingBorder polluted currentCursor with a resize cursor, the early return would prevent the framework from restoring the correct cursor when the mouse left the border area.
resetCursorRects in CocoaBaseView checks borderOverrideCursor first, falling back to currentCursor.
Custom Frame Resize and Minimum Window Size
ResizingDragged() handles border-drag resizing in custom frame mode. Before applying the new frame, it calls Moving(bounds, false, true) on all listeners — matching the Windows WM_SIZING behavior. The draggingBorder=true parameter tells NativeWindowListener_Moving to clamp the dragged edge (keeping the opposite edge fixed) when the window would shrink below minimum size. Without this, the frame would be set too small, triggering the framework to fix it asynchronously by moving the opposite edge, causing a visible bouncing glitch.
Popup Auto-Close
Popups must close when the user clicks outside or the app loses focus. This is managed by three mechanisms:
1. Mouse-down in HandleEventInternal() — When any mouse button is pressed, ClosePopups(this) is called before the event is dispatched.
2. InvokeGotFocus() — When a window gains focus, ClosePopups(this) is called (unless suppressClosePopups is true).
3. applicationDidResignActive: — When the app loses focus entirely, ClosePopupsOnActivation(nullptr, nullptr) closes all popups.
ClosePopups(activatedWindow) builds an exception list (the activated window and all its parents up the chain) and calls ClosePopupsOnActivation(), which iterates all Normal-mode windows and for each calls ClosePopupsOf().
ClosePopupsOf(owner, exceptions) is a static recursive method: for each child of owner, if the child is non-Normal mode, visible, and not in the exceptions list, it calls Hide(false). Then it recurses into that child's children.
suppressClosePopups — A file-static boolean flag that prevents re-entrant popup closing. It is set to true during:
SetParent()when callingaddChildWindow:(which synchronously triggers focus callbacks)ShowDeactivated()when callingorderFront:(which can trigger focus callbacks)
Without this guard, opening a popup would immediately trigger its own closing.
CocoaNSWindow
CocoaNSWindow is an NSWindow subclass that overrides:
canBecomeKeyWindow— ReturnsNOfor disabled windows (prevents modal-owner from stealing key status during modal dialogs). ReturnsYESfor top-level windows (no parent). For child windows, returnsYESonly if the window has a non-zero style mask (i.e., it is NOT borderless). This means modal dialog child windows (which have title bars) can become key and receive keyboard input, while borderless popup child windows cannot.canBecomeMainWindow— ReturnsYESonly if there is no parent window.
CocoaWindowDelegate
An NSWindowDelegate that bridges Cocoa window events to GacUI:
windowShouldClose:— Delegates toHide(true)to perform the full close sequence (BeforeClosing/AfterClosing, hide, Closed). Always returnsNOto prevent Cocoa from releasing the NSWindow — the lifecycle is managed entirely by the C++ side.windowWillClose:— No-op. Only reached when[nsWindow close]is called directly (e.g., from the~CocoaWindowdestructor cleanup).windowDidResize:→InvokeMoved()windowDidBecomeKey:→InvokeGotFocus()windowDidResignKey:→InvokeLostFocus()- Tracks
sizeState(Restored/Minimized/Maximized)
EnableActivate / DisableActivate
These are currently no-ops on macOS. On Windows, DisableActivate sets WS_EX_NOACTIVATE to prevent a window from gaining activation when clicked. GacUI calls SetEnabledActivate(false) on popup windows, but the macOS implementation relies on ShowDeactivated() and canBecomeKeyWindow to achieve similar behavior.
Enable / Disable
On Windows, EnableWindow(hwnd, FALSE) prevents user input (mouse clicks, keyboard) without hiding or moving the window. The macOS implementation simply sets the enabled flag. The enabled flag defaults to true, matching Windows where a newly created window is enabled by default (IsWindowEnabled returns TRUE).
On Windows, EnableWindow(FALSE) prevents the window from becoming the active/focus window at the OS level. On macOS, this is handled by canBecomeKeyWindow returning NO for disabled windows — see the CocoaNSWindow section below.
The framework layer (GuiControlHost::GetEnabled()) delegates to native->IsEnabled(), so when ShowModal calls owner->SetEnabled(false), the owner's GetEnabled() returns false, and the framework won't process input on it.
Previous bugs:
enabledwas initialized tofalse(should betrue), causingShowModal'sCHECK_ERROR(owner && owner->GetEnabled())to fail.Disable()used[nsWindow orderOut:nil]which hid the window entirely.- Later
Disable()used[nsWindow setIgnoresMouseEvents:YES]which made clicks pass through to whatever was behind the window, deactivating the app.
CocoaBaseView
File: Mac/NativeWindow/OSX/CocoaBaseView.mm
CocoaBaseView is an NSView subclass that serves as the content view for every CocoaNSWindow. It:
- Forwards all mouse and keyboard events to
CocoaWindow::HandleEventInternal() - Implements
NSTextInputClientfor IME composition support - Uses
NSTrackingAreafor mouse enter/exit tracking - Handles drag-and-drop via
NSDraggingDestination - Manages cursor rects
Key Differences from Windows
| Aspect | Windows | macOS |
|---|---|---|
| Coordinate system | Top-left origin | Bottom-left origin (Cocoa). All coordinates are flipped via FlipY() / FlipRect() in CocoaHelper. |
| Show without activation | ShowWindow(SW_SHOWNOACTIVATE) | [nsWindow orderFront:nil] — does not make key or main. |
| TopMost | WS_EX_TOPMOST via SetWindowPos | [nsWindow setLevel:NSPopUpMenuWindowLevel] |
| Disable activation | WS_EX_NOACTIVATE | No-op. Handled by canBecomeKeyWindow returning NO for child borderless windows. |
| Enable/Disable | EnableWindow(hwnd, FALSE/TRUE) — toggles input, no visibility change | enabled flag only. canBecomeKeyWindow returns NO for disabled windows. |
| Hide | PostMessage(WM_CLOSE) always | InvokeClosing(), [nsWindow orderOut:nil], InvokeClosed(). Main window defers DestroyNativeWindow via dispatch_async. |
| SetBounds | MoveWindow / SetWindowPos — no visibility side effects | [nsWindow setFrame:display:YES] — no visibility side effects. |
| Custom frame | Style flags via SetWindowLongPtr | NSWindowStyleMaskBorderless + manual hit testing in HandleEventInternal. Note: NSWindowStyleMaskBorderless = 0, so XOR-based toggling does not work. |
| Window creation | Window starts hidden until ShowWindow | Window starts hidden (not ordered). Only becomes visible on Show() / ShowDeactivated(). |
| Popup close trigger | WM_ACTIVATEAPP + mouse messages | windowDidBecomeKey: → InvokeGotFocus(), mouse-down in HandleEventInternal, applicationDidResignActive: |
| Render loop gate | `IsWindowVisible(handle)$ — \text{true} \text{for} 0 \times 0 \text{windows} | $[nsWindow isVisible]` — true for 0×0 windows (they are ordered on screen). The render loop must be able to run for 0×0 popup windows to compute their content size. |
| Event loop | GetMessage / TranslateMessage / DispatchMessage | nextEventMatchingMask: + sendEvent: for both main loop and RunOneCycle. |
| Modal windows | Framework-level. RunOneCycle pumps messages in a loop. | Framework-level. RunOneCycle pumps events via nextEventMatchingMask:. |
RunOneCycle and Modal Windows
File: Mac/NativeWindow/OSX/CocoaNativeController.mm
Overview
GacUI modal windows (ShowModal / ShowModalAsync) are entirely framework-level — they do not use native modal APIs. Instead, the framework:
- Disables the owner window
- Shows the modal dialog
- Spins a mini event loop:
while (!exit && app->RunOneCycle()) - When the dialog closes, the callback fires, re-enables the owner, and the loop exits
This requires RunOneCycle() to process OS events and return, just like a single iteration of the main event loop.
Windows Implementation (Reference)
void Run(INativeWindow* window) override {
mainWindow = ...;
mainWindow->Show();
while (RunOneCycleInternal()); // same function as RunOneCycle()
}
inline bool RunOneCycleInternal() {
MSG message;
if (!GetMessage(&message, NULL, 0, 0)) return false; // blocks until a message arrives
TranslateMessage(&message);
DispatchMessage(&message);
asyncService.ExecuteAsyncTasks();
return true;
}
Key point: Run() and RunOneCycle() use the same function (RunOneCycleInternal). The main event loop is just while (RunOneCycleInternal()). When a modal dialog calls RunOneCycle() in a nested loop, it's the exact same code path — a nested call to the same pump. GetMessage blocks until a message is available, processes exactly one message, runs async tasks, then returns. Returns false on WM_QUIT.
macOS Implementation
void Run(INativeWindow* window) override {
mainWindow = window;
mainWindow->Show();
while (RunOneCycle()); // same function — matches Windows pattern
}
bool RunOneCycle() override {
@autoreleasepool {
NSEvent* event = [NSApp nextEventMatchingMask:NSEventMaskAny
untilDate:[NSDate distantFuture]
inMode:NSDefaultRunLoopMode
dequeue:YES];
if (event != nil) {
[NSApp sendEvent:event];
[NSApp updateWindows];
}
asyncService.ExecuteAsyncTasks();
}
return mainWindow != nullptr;
}
Why Run() Must NOT Use [NSApp run]
The previous implementation used [NSApp run] for the main loop and RunOneCycle() (via nextEventMatchingMask:) for framework-level modal sub-loops. This caused crashes because:
-
Two different event pumps.
[NSApp run]is Cocoa's opaque event loop with its own internal state.RunOneCycle()is a manual pump usingnextEventMatchingMask:. When a modal dialog'sRunOneCycleloop runs inside[NSApp run]'s dispatch chain, the two mechanisms fight over event queue ownership and run loop state. -
[NSApp stop:nil]targets the wrong loop. When the main window is destroyed,[NSApp stop:nil]tells[NSApp run]to exit. But if we're inside a nestedRunOneCycleloop (e.g., a framework modal dialog),[NSApp stop:nil]doesn't affect our manual pump — it affects the outer[NSApp run], causing it to exit prematurely when the nested loop returns. -
mainWindowwas a dangling pointer.DestroyNativeWindowdeleted the window but never setmainWindow = nullptr, soRunOneCycle()returnedmainWindow != nullptr→ alwaystrue, never terminating.
The fix: make Run() use while(RunOneCycle()) — identical to Windows. Both the main loop and modal sub-loops use the same RunOneCycle() function. Nested calls to RunOneCycle are just nested calls to nextEventMatchingMask: + sendEvent:, which is the standard macOS pattern for nested event loops (same as NSModalSession).
Key Design Decisions
[NSDate distantFuture]makesnextEventMatchingMask:block until an event arrives, matching Windows'GetMessagebehavior. No busy-waiting, no CPU cost while idle.[NSDate distantPast]would poll without blocking and cause 100% CPU usage.- GCD timers still fire during
nextEventMatchingMask:becausedispatch_afteron the main queue is processed as a run loop source inNSDefaultRunLoopMode. The 16ms timer fromCocoaInputServicecontinues to work — its callback (GlobalTimerFunc) executes during thenextEventMatchingMask:call, processing rendering and timer events without waiting for an NSEvent. @autoreleasepoolwraps each iteration.[NSApp run]creates autorelease pools automatically per iteration; our custom loop must do this explicitly to prevent memory buildup from temporary Cocoa objects.[NSApp updateWindows]is called after dispatching the event to ensure window display updates happen synchronously, matching the behavior of[NSApp run]'s internal loop.
App Shutdown Sequence
When the main window is destroyed via DestroyNativeWindow:
mainWindowis set tonullptr.- A dummy
NSEventTypeApplicationDefinedevent is posted to wakenextEventMatchingMask:(in case it's blocked waiting for events). RunOneCycle()returnsmainWindow != nullptr→false.- The
while(RunOneCycle())loop inRun()exits.
The dummy event is necessary because DestroyNativeWindow may be called from an async task executed during nextEventMatchingMask: (via a GCD timer → GlobalTimerFunc → ExecuteAsyncTasks). In that case, nextEventMatchingMask: would continue blocking after the async task returns unless we post an event to wake it.
If DestroyNativeWindow is called during sendEvent: dispatch (the normal case — user clicks close), we're already past nextEventMatchingMask:, so the dummy event is harmless — it's simply ignored on the next iteration.
CocoaWindowDelegate::windowShouldClose: delegates to Hide(true) and always returns NO. windowWillClose: is a no-op. The close button does NOT call [NSApp stop:nil] or post events — app termination is handled entirely by DestroyNativeWindow setting mainWindow = nullptr (deferred via dispatch_async from Hide()).