Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

macOS Display Topology: Reconfiguration Callbacks and Coordinate Spaces

Track macOS display changes with Quartz callbacks, fresh topology snapshots, explicit point-to-pixel conversion, and safe window recovery.

Display-aware Mac software must handle more than a single screen size. Displays can be attached, removed, mirrored, rotated, assigned different scaling, or rearranged in the desktop coordinate space while an app is running. Quartz Display Services exposes display identifiers, bounds, pixel dimensions, and reconfiguration notifications. AppKit adds its own screen and backing-coordinate abstractions. Treat those as related views of current topology, not as interchangeable integer widths.

This article focuses on observing display topology and adapting UI or rendering state. It does not recommend changing system display modes casually. A display ID identifies a display for current system operations; it is not a durable asset identifier to persist as if it were a monitor serial number. Re-enumerate after changes and rebuild assumptions from the current result.

Enumerate a fresh active-display snapshot

Use CGGetActiveDisplayList to retrieve active display IDs and query the properties required by the feature. A two-call pattern first asks for the count, allocates storage, and then fills the array. Handle the possibility that the topology changes between calls by checking status and the returned count. Keep queries out of a reconfiguration callback; collect a new snapshot after the system reports that reconfiguration is complete.

import CoreGraphics
import Foundation

func activeDisplaySnapshot() -> [(id: CGDirectDisplayID, bounds: CGRect, pixels: CGSize)]? {
    var count: UInt32 = 0
    guard CGGetActiveDisplayList(0, nil, &count) == .success, count > 0 else { return nil }
    var ids = [CGDirectDisplayID](repeating: 0, count: Int(count))
    guard CGGetActiveDisplayList(count, &ids, &count) == .success else { return nil }

    return ids.prefix(Int(count)).map { id in
        let bounds = CGDisplayBounds(id)
        let pixels = CGSize(width: CGDisplayPixelsWide(id), height: CGDisplayPixelsHigh(id))
        return (id, bounds, pixels)
    }
}

The returned CGDisplayBounds values use the global display coordinate space, while the pixel functions report pixel counts. Do not divide or multiply these values without deciding which output you need. For AppKit layout, use points and the relevant screen’s backing conversion; for texture allocation or pixel processing, use the actual pixel dimensions and validate the desired scale.

Handle callbacks as notifications, not work queues

Register a CGDisplayReconfigurationCallBack when the app needs topology updates, and unregister it when the owner ends. Check the returned CGError for both registration and removal, and only record a registration as active after registration succeeds. Quartz can invoke the callback before and after reconfiguration for each affected display. Before the change, the callback indicates a pending change; after it, display state is current. A removed display ID may no longer support further queries. Therefore, use the callback to enqueue a small event and schedule a fresh enumeration after the post-change notification.

Keep the callback short. Do not resize windows, create GPU resources, call back into display configuration APIs, block on another thread, or raise exceptions from it. Serialize topology refreshes so multiple notifications coalesce into one snapshot. If the app receives a begin-change signal, mark the old layout as transitioning rather than immediately tearing down state; publish a replacement layout after fresh enumeration succeeds.

The callback’s userInfo pointer is borrowed context supplied at registration, so any referenced state must remain alive for the entire registration and must be released only after removing the callback. If the callback crosses threads, protect that state or pass a small immutable event into an actor or serial queue. Keep callback registration and removal paired in one owner type; accidental duplicate registrations can make every topology change appear twice and make cleanup hard to audit.

Coalesce change notifications with a generation counter. A burst may represent one user action such as unplugging a dock, but intermediate snapshots can be incomplete. Schedule one refresh after the post-configuration phase and discard an older refresh if a newer generation has started. If the query fails during a transition, keep the last valid snapshot marked stale and retry after a bounded delay; do not publish an empty display list as if it were a stable configuration.

An NSScreen.screens snapshot is often the right source for AppKit window placement and visible-frame work. Quartz display IDs and AppKit screen objects do not provide a permanent positional pairing. Rebuild any mapping after reconfiguration, and do not assume the first item in one list corresponds to the first item in another.

Points, pixels, backing scale, and orientation

AppKit positions windows in points, not raw display pixels. NSScreen.frame and visibleFrame describe desktop placement and the area available around system UI; a window’s backing conversion maps between its logical geometry and device pixels. A Retina screen may have a scale factor greater than one, but code should ask the current screen or view for the conversion instead of hardcoding 2.0.

When a window moves between displays, its effective backing properties can change without a change to its point-size frame. Recreate pixel-sized caches and adjust rendering resolution when backingProperties change. Do not change user window geometry just because pixel dimensions differ. For cross-display drag or placement, keep coordinates in one explicitly named space and convert at boundaries rather than mixing global points, local view coordinates, and texture pixels.

Display rotation adds another boundary for capture or image processing. Quartz reports rotation and geometry, but the app should understand whether its downstream buffer is already oriented as presented before applying another transform. Validate with actual rotated hardware or system display arrangements. Avoid inferring a transform from width and height alone because portrait displays and scaled modes can have similar dimensions.

For pointer interaction, convert event locations from the event’s window coordinates into the target view’s local coordinates with AppKit conversion methods. Do not manually subtract a screen origin or multiply by a scale factor unless the source and destination coordinate systems are explicitly defined. Coordinate origins and axis direction are framework-specific; a mapping that works on one display arrangement can fail when a monitor is placed to the left of or above the primary screen.

When positioning a window after a monitor is removed, compare its frame with the current screens’ visible frames, not merely the union of display bounds. The visible frame accounts for areas reserved by system UI. Choose a recovery rectangle that preserves enough of the title bar and content for the user to move or resize the window, then apply it once the new topology snapshot is ready. Avoid snapping every window to the primary display on every change; preserve intentional multi-screen layouts whenever their target remains available.

Multi-display window and renderer policy

For a windowed editor, preserve a useful window location after a display disappears. If the saved origin is outside all current visible frames, move it into a safe visible region rather than opening an inaccessible window. Keep this recovery separate from display discovery and do not persist a stale display list.

For rendering, rebuild only resources whose dimensions or color configuration actually changed. Publish the new render target dimensions as one immutable generation so draw code does not combine a new width with an old height. If the app captures or processes all displays, treat each display as its own source and define how mirroring affects duplicate content.

Avoid changing display modes or gamma state from a UI response to a topology notification. If the product legitimately manages displays, use the documented configuration transaction APIs, explain the action, check return values, and restore or roll back on failure. Observation and control are different responsibilities.

Acceptance checks

Test one display, different backing scales, monitor attach/detach, mirroring, rotation, primary-display change, resolution change, window moved between screens, display removed while rendering, and repeated callback registration/unregistration. Assert that callbacks are removed exactly once and that a fresh snapshot replaces stale topology before layout decisions are applied.

Record display count, IDs for the current session, bounds, pixel size, backing scale, reconfiguration event phase, and snapshot generation. Measure time from notification to updated UI and count skipped frames during resize. Avoid collecting serial numbers or display names unless the feature requires them. Reliable display-aware code names its coordinate spaces, re-enumerates after changes, and lets AppKit manage window geometry while Quartz informs topology.

Related:

Sources:

Comments