Windowing — macOS
Overview
WIP — main lifecycle and rendering are stable; the iOS branch in shell2/ios/ shares much of this code but is not yet complete. The macOS backend is MacOSWindow. It uses the objc2 family of crates (objc2, objc2_app_kit, objc2_foundation) for static linking against Cocoa.framework / AppKit.framework. Unlike the Linux and Windows backends, system frameworks are not dlopen'd — they are linked at build time. CoreGraphics, CoreVideo, and the OpenGL framework are loaded via dlopen for narrowly-scoped reasons (older-OS forward compatibility, runtime-only display detection, deprecated API isolation).
The struct embeds the event::CommonWindowState like every other backend. macOS-specific fields include the NSWindow / GLView / WindowDelegate retained pointers, an IOPMAssertionID for keep-screen-awake, the CoreVideoFunctions table for the CVDisplayLink VSYNC callback, the CoreGraphicsFunctions table for display ID enumeration, and the optional MacOSAccessibilityAdapter.
Window lifecycle and the GLView pattern
MacOSWindow::new_with_fc_cache runs on the main thread (enforced by MainThreadMarker). The setup is:
NSApplication::sharedApplication(mtm)— get the singleton.setActivationPolicy(NSApplicationActivationPolicy::Regular)— makes the app a Dock-visible regular app (not a background helper).setup_main_menu(&app, mtm)— installs the standard Application > Quit menu item (Cmd+Q sendsterminate:to NSApp).app.finishLaunching()— required so the Window Server fully registers the app. Without this, accessibility queries returnkAXErrorCannotComplete(-25204) because macOS considers the app „not yet launched“.app.activateIgnoringOtherApps(true)— bring to front (deprecated API, but still the only way to handle this on older macOS).- Create
NSWindowwith the requestedNSWindowStyleMask. - Create the
GLView(defined inline viaobjc2::define_class!) — a customNSOpenGLViewsubclass that owns the GL context, tracking area, IME state, and a back-pointer toMacOSWindow. setup_gl_view_back_pointer()andfinalize_delegate_pointer()— wire the raw*mut c_voidwindow pointer into the view and delegate ivars so callbacks can recover&mut MacOSWindowfrom inside the AppKit callback.
The window is registered in the macOS thread-local registry, keyed by *mut AnyObject.
Run loop — two strategies
The macOS pub fn run chooses one of two event-loop strategies based on AppTerminationBehavior:
RunForever — NSApplication.run()
Standard macOS behaviour: the application runs forever, even when all its windows are closed. app.run() blocks until terminate: is sent (Cmd+Q, Quit menu, or a programmatic call). This is the right choice for menu-bar apps and document-based apps that want to stay in the Dock.
ReturnToMain / EndProcess — manual loop
Suitable for one-shot applications. The pattern:
loop {
autoreleasepool(|_| {
// 1. Drain pending NSEvents (non-blocking)
while let Some(event) = app.nextEventMatchingMask_untilDate_inMode_dequeue(
NSEventMask::Any, None, NSDefaultRunLoopMode, true,
) {
// dispatch to our handlers
for wptr in registry::get_all_window_ptrs() {
let macos_event = MacOSEvent::from_nsevent(&event);
(*wptr).process_event(&event, &macos_event);
}
// forward to system
app.sendEvent(&event);
}
// 2. If all windows closed, return / exit
if registry::is_empty() { /* return or exit */ }
// 3. Process per-window state diff, scroll wheel, gestures, a11y, popup creates
// 4. Block on the run loop with a "distantFuture" date — wakes on
// *any* run loop source (Mach ports, timers, NSEvents)
let run_loop = NSRunLoop::currentRunLoop();
run_loop.runMode_beforeDate(NSDefaultRunLoopMode,
&NSDate::distantFuture());
// 5. After waking, drain any newly-arrived NSEvents and forward them
});
}
The choice between nextEventMatchingMask and runMode:beforeDate: matters: nextEventMatchingMask only dequeues NSEvents and ignores other run loop sources, including the Mach ports macOS accessibility uses. Without runMode:beforeDate: waking on those ports, VoiceOver and System Events queries time out with kAXErrorCannotComplete (-25204). Both APIs are needed — the run loop wakes for any source, and then we drain nextEventMatchingMask to process any new NSEvents.
GLView — receiving events
GLView, defined via objc2::define_class!, overrides the relevant NSResponder methods:
mouseDown:,mouseUp:,mouseDragged:,mouseMoved:,rightMouseDown:,otherMouseDown:, etc.scrollWheel:(continuous trackpad scroll, treated asScrollSource::Touch; mouse wheel isDiscrete).magnifyWithEvent:(pinch),rotateWithEvent:,swipeWithEvent:(mapped toGestureEvent::Pinch/Rotate/Swipe).keyDown:,keyUp:,flagsChanged:for raw key events.insertText:,setMarkedText:replacementRange:,unmarkTextfor IME composition (theNSTextInputClientprotocol).
Each override:
- Recovers
&mut MacOSWindowfromwindow_ptr_ivar. - Updates the relevant fields on
current_window_state. - Calls
process_window_events()fromcommon::event::PlatformWindow.
The IME methods set ime_key_handled.set(true) so that handle_key_down doesn't double-process the same key event during composition.
Tracking areas and mouseExited:
macOS doesn't deliver mouseMoved: to a view by default — you must either enable acceptsMouseMovedEvents on the window (no per-view filtering) or install an NSTrackingArea. GLView's updateTrackingAreas override creates a tracking area that covers its bounds with NSTrackingMouseEnteredAndExited | NSTrackingActiveInActiveApp. The area must be re-created on every viewDidChangeFrame because tracking areas don't follow geometry changes.
Render path — OpenGL via NSOpenGLContext
RenderBackend selects OpenGL or CPU. GPU mode wires:
NSOpenGLPixelFormatAttributearray with depth, stencil, double-buffer, accelerated, GL 3.2 core profile.NSOpenGLPixelFormat::initWithAttributes→NSOpenGLContext.setView:to bind the context to the GLView.gl::GlFunctions::initialize()— opens/System/Library/Frameworks/OpenGL.framework/OpenGLvia dlopen and resolves every entry point withdlsym. The handle is kept on the struct so the framework stays loaded for the window's lifetime.
The NSOpenGLContext lives on the GLView; flushBuffer on present. drawRect: triggers paint; the first drawRect: call is what moves the window from invisible to on-screen.
OpenGL on macOS is deprecated as of 10.14 but still works on every shipping macOS (and through Rosetta on Apple Silicon). Migration to Metal is an open task — see the RenderContext::Metal variant in common/compositor.rs which already includes the necessary MTLDevice / MTLCommandQueue slots.
CPU mode goes through the same cpurender path as the other backends, with the framebuffer drawn into an NSBitmapImageRep and composited via NSImage::drawInRect:.
VSYNC via CVDisplayLink
CoreVideoFunctions is dlopen'd from /System/Library/Frameworks/CoreVideo.framework/CoreVideo. CVDisplayLinkCreateWithCGDisplay(displayID, &mut link) creates a display link tied to the screen's refresh rate; CVDisplayLinkSetOutputCallback(link, vsync_callback, window_ptr as *mut c_void) installs a callback that fires once per VBlank on a dedicated thread. The callback signals new_frame_ready via the shared Arc<(Mutex<bool>, Condvar)> so the next frame can be presented in sync with refresh.
CoreVideo is dlopen'd because the framework moved between OS versions and extern { ... } linkage broke older macOS. Loading it dynamically lets the same binary run on 10.14 through 14.x.
Display IDs — CGDirectDisplayID
CoreGraphicsFunctions loads ApplicationServices.framework (which transitively contains CoreGraphics) and resolves CGMainDisplayID, CGDisplayBounds. This is enough to identify the primary display and read its bounds for monitor enumeration. The full multi-monitor enumeration uses NSScreen::screens(mtm) — the CoreGraphics module is mostly used to build stable MonitorIds that survive screen reconfiguration.
IME — NSTextInputClient
The GLView conforms to NSTextInputClient. AppKit calls insertText:replacementRange: to commit, and setMarkedText:selectedRange:replacementRange: for the live preedit. The marked text is buffered into LayoutWindow.cursor_manager.preedit and rendered inline; firstRectForCharacterRange: returns the caret rect so the IME candidate window appears in the right place.
The constant MIN_IME_CURSOR_HEIGHT = 16.0 caps the candidate-window anchor height — without a minimum, single-line inputs end up with no visible IME panel.
ime_key_handled: Cell<bool> on GLViewIvars is the double-dispatch lock: when setMarkedText: or insertText: is called, it sets the flag so handle_key_down (which AppKit also calls for the same key event) skips sending a key event to the layout layer.
Menus — NSMenu
AzulMenuTarget, defined via define_class!, is an NSObject that receives menu actions. Its menuItemAction: selector fires when any menu item with this target is clicked; the item's tag (which encodes a command_id) is pushed to the global PENDING_MENU_ACTIONS: Mutex<Vec<isize>>.
The main loop drains this queue and looks up the command ID in the window's menu_command_callbacks map to invoke the user CoreMenuCallback.
AzulMenuTarget::shared_instance(mtm) is a thread-local singleton — all NSMenuItems point at the same target, which keeps the per-item ivars at zero size.
The macOS app menu (the „{appname}“ menu with About / Quit) is installed by setup_main_menu independently from any window menu; it survives the closure of all windows.
Tooltips — NSPanel
The macOS tooltip wraps an NSPanel (a borderless utility window) with an NSTextField for the body. Width is computed by character count heuristics (POINTS_PER_CHAR = 7.0, capped at TOOLTIP_MAX_WIDTH = 400). Position uses setFrameTopLeftPoint to align the top-left of the panel with the hover anchor (Cocoa's coordinate system has Y increasing upward, so the geometry needs screen_height - y flipping — handled in the position setter).
This is the legacy direct-AppKit tooltip path. Future tooltips will flow through the same pending_window_creates queue used for popup menus, rendered via the standard layout pipeline so they support arbitrary styled DOM.
Keep-screen-awake — IOPMAssertion
The IOKit FFI for IOPMAssertionCreateWithName + IOPMAssertionRelease lives in the macOS module. When WindowFlags::keep_screen_awake flips on, the window calls IOPMAssertionCreateWithName("PreventUserIdleDisplaySleep", kIOPMAssertionLevelOn, "Azul") and stores the resulting IOPMAssertionID. Release on flip-off or window close.
Clipboard — NSPasteboard
The clipboard module wraps the deprecated objc (not objc2) crate to talk to NSPasteboard via dynamic message dispatch. The flow:
[NSPasteboard generalPasteboard].[pasteboard clearContents].[pasteboard setString:text forType:NSPasteboardTypeString].
Read uses [pasteboard stringForType:NSPasteboardTypeString]. Both operations are synchronous — NSPasteboard is famously slow but there is no async API.
#[link(name = "AppKit", kind = "framework")] on the extern "C" {} block forces NSPasteboard to be in the class resolver's path even though no symbols are imported.
Accessibility — accesskit_macos
The accessibility module uses accesskit_macos::SubclassingAdapter when the a11y feature is on. The adapter swizzles NSAccessibility methods on the GLView so VoiceOver queries get the AzulRoot accessibility tree.
request_initial_tree returns None to force the Placeholder → Active transition, which generates AXFocusedUIElementChanged notifications that VoiceOver needs for correct navigation. Returning Some(tree) here would skip Placeholder and go straight Inactive → Active, suppressing the focus event and breaking screen-reader navigation.
Action requests arrive on a Sender<ActionRequest> channel (ChannelActionHandler); the main loop drains them via process_accessibility_actions() outside of the NSEvent dispatch critical section.
Multi-window registry
Same pattern as Linux and Windows. Thread-local BTreeMap<*mut AnyObject, *mut MacOSWindow>. The pointer is leaked via Box::into_raw from the run loop; recovered by Box::from_raw when the window is unregistered.
pending_window_creates is the dispatch target for popup menus and dialogs — each entry is a fresh WindowCreateOptions that produces a new MacOSWindow registered into the same map.
system_style
SystemStyle::detect_macos queries:
NSApp.effectiveAppearancefor dark/light/auto.NSColor::controlAccentColor(10.14+) for accent color.NSFont::systemFontOfSize:for the UI font name.[NSScreen mainScreen].backingScaleFactorfor the base scale.NSWindow::titlebarAppearsTransparentheuristics for window background material defaults.
Result populates azul_css::system::SystemStyle, identical to the Linux/Windows implementations. Theme changes arrive via NSAppearanceDidChangeNotification on the AppDelegate; the notification triggers a full regenerate_layout.
Known issues / TODOs
- OpenGL is deprecated; a Metal compositor through
RenderContext::Metalis sketched but not implemented. - The CPU rendering path uses
NSImageblit which is not the fastest — Core Graphics direct framebuffer paint viaCGContextRefwould be faster. flagsChanged:modifier diffing is heuristic — sometimes modifier state lags pressed-key state by one event. Real fix needs explicit modifier-state tracking on eachkeyDown:.- Multi-touch (
NSTouchfrom a Magic Trackpad) is not yet routed — onlymouseDown:/scrollWheel:are processed.
Coming Up Next
- Common — Shared shell infrastructure across platforms
- Windows — Windows shell - Win32 messages, DirectComposition, IME
- Menus and CSD — Menus and client-side decorations across platforms
- Windowing Overview — Per-window aggregate, headless variant, and the platform shell layer