Beyond the self-evident path of app termination (which removes all windows for that PID immediately), Zonogy uses several mechanisms to detect individual window destruction.
Not all applications emit AX destroy notifications (e.g., Find My), so we need to cast a wider net. A missing (pid, CGWindowID) in WindowServer (CGWindowListCopyWindowInfo) then can be used to confirm destruction.
On the other hand, some applications leave a closed window registered with WindowServer. For windows that WindowServer still lists, Zonogy combines AX notifications with the application’s current AX window list to decide whether the window is closed. If the available information is inconclusive, Zonogy preserves the window.
Per-PID validation with retry (ValidationRetryManager): After window focus changes within an app, app switches (validates the previous app), app deactivation/hide, and placed native-tab candidates that full sync does not find in WindowServer, runs a PID-scoped check. If no destroyed windows are found but the PID still has managed windows (i.e., AX may be temporarily stale), retries with exponential backoff (≈0.2–3.2 s). This tries to catch window closed as soon as possible so that its zone is emptied and UI updates.
Zone sync pruning: A full syncWindowsToZones() pass (normal layout reconciliation pass, not the live-resize path) checks all managed windows for destruction. Full syncs run frequently — after zone add/remove, window placement, miniaturize/deminiaturize, drag-drop, display-topology changes, WinShot/Launcher operations, and other layout-affecting events.
Native-tab close-rebind: When validation confirms that a placed window no longer exists in WindowServer, Zonogy first attempts the native-tab close-rebind (see SPECIFICATION.md), keeping the window in its zone if a surviving sibling matches and pruning it only otherwise. The all-window syncWindowsToZones() sweep does not pick a sibling from the global CGWindowListCopyWindowInfo snapshot, because that snapshot can be transiently incomplete during wake or display changes. Instead, if full sync does not find a placed native-tab candidate in WindowServer, it leaves the window in place and asks for PID-scoped validation, which then runs the normal close-rebind/deferred-prune path.
Native-tab last-tab merge: When a placed window is moved or resized by its application, and the new frame coincides with another placed same-process window, Zonogy treats this as a possible native-tab merge. The destination managed window keeps its windowId and zone but is updated to track the source window; the source managed record is removed from its old zone without treating that window as closed. The same collapse can also happen from a focus/main-window capture immediately after the source changed frame, because some applications report the moved tab as the focused window before all AX notifications settle.
All window removal paths that confirm the window is gone except app termination use deferred pruning: instead of immediately discarding the window’s identity and recency info, the window is staged in a pending-prune store keyed by (pid, CGWindowID). The zone is vacated immediately (placeholder appears), but the bookkeeping is retained. (Native-tab source collapse is different: the source managed record is removed because the destination record now tracks that window, not because the window disappeared.)
(pid, CGWindowID) reappears during a subsequent capture pass, the window is restored with its original windowId and recency timestamp, and placed back into its original zone (if that zone is still empty) or through the normal placement pipeline otherwise.CGWindowID) is discovered for that PID after the pending entry has aged past a short grace window. Very recent entries are retained because another same-PID window does not prove that a just-pruned (pid, CGWindowID) cannot still reappear.When a window is placed in the floating zone, it receives a 0.5-second protection window during which focus/front-most changes will not trigger occlusion-based floating-zone minimization. If a spurious focus event occurs during this window (e.g., macOS activating a sibling window after the displaced occupant is minimized), the floating-zone occupant is reactivated/raised so it remains visible and interactive. This prevents a newly placed window from being immediately dismissed. Exception: if the floating window is currently minimized (per the AX API), the protection-driven re-raise is skipped, so a user who quickly minimizes a just-placed floating window is not fought by a spurious unminimize.
The same protection mechanism applies when restoring layouts from sleep/wake recovery or WinShot snapshots, so that internal restore operations do not fight normal layout behavior.
For ActiveFit candidate zones during restore, we temporarily suppress ActiveFit during the restore layout pass and then evaluate it once for the active window after the restore settles.
When a managed window assigned to a tiling zone becomes front-most on a display, Zonogy checks whether that display’s floating-zone occupant is occluded by that occupied tiling zone. If it is occluded, minimize the floating window; otherwise leave it unminimized. If a placeholder becomes front-most and its tiling zone’s frame overlaps the floating-zone occupant, promote the floating window into that placeholder’s tiling zone instead of minimizing it.
Implementation notes:
CGWindowListCopyWindowInfo ordering (on-screen windows), using CGWindowID for stable identity.Zone navigation selects structurally within a display; across displays, geometry picks the display and the entry into it is structural.
On one display, moves are read from the zone model — each tiling zone’s column side and stack row, with the floating zone bar as the bottom-most stop below both columns. Crossing columns lands in the matching row: top to top, bottom to bottom, and a full-height column enters a stack at the stack’s top zone. Off the floating zone bar, up climbs to the zone nearest the bar (a stacked bottom zone before a full-height column, then the lower zone index) and a horizontal press lands on the bottom-most zone of the column in that direction.
A press past the edge of the layout crosses to another display. Each display lies in exactly one direction from the current one: with overlapping x-ranges it is above or below, with overlapping y-ranges left or right. The press moves to the nearest display lying in the pressed direction (by center distance along that axis, then across it) and enters through its near side: from below, the floating zone bar; from above, the zone nearest the top edge (a stacked top zone before a full-height column); from the side, the near column at the matching row, with a bar entering at that column’s bottom-most zone.
Each selection carries the trail of moves that produced it. A press exactly opposite to the trail’s last move pops back to that move’s source, so any path retraces step by step to the gesture’s start. (Note that moves are not symmetric — a full-height column reached from a stack’s bottom re-enters the stack at its top — which is why reversal is remembered rather than recomputed.) A jump starts a fresh trail.
A display key finds its display’s last-used window in the same recency order as CmdTab and the Launcher. Because commits happen on modifier release, the gesture runs through a keyboard event tap rather than ordinary hotkeys. Jump keys ignore auto-repeat (a held cell key could otherwise keep adding zones), and a key the gesture takes stays taken until it is released, so a key still held when the gesture ends neither repeats into the focused app nor fires the shortcut behind it. The held modifier combination is configurable (at least two modifiers, like the mouse-gesture modifiers). The in-gesture action keys are borrowed from their shortcuts’ bindings (Move Focused Window to Target Zone, Show Launcher, Add Zone, Remove Zone, Minimize Focused Window). The gesture snapshots the zones when it engages; commits re-check live occupancy. Anything that could invalidate the snapshot mid-gesture (another Zonogy shortcut firing, or a display change) cancels the gesture rather than letting a commit act on stale zones (except, of course, a zone the gesture itself adds, around which the gesture is rebuilt).
Overlapping shortcuts are shown, not prevented (as System Settings does). A shared chord only ever reaches one of its holders: an event tap that engages on it (Zone Navigation, CmdTab) swallows it before any hotkey fires, and duplicate hotkeys cannot both fire. Overlaps count Zone Navigation’s chords (its modifiers plus its navigation keys) and a CmdTab shortcut’s Shift variant, which cycles backward.
When a placement displaces an existing zone occupant, Zonogy picks one of two ways to minimize the displaced window:
DisplacementStrategy.synchronous): minimize before the incoming window is positioned/raised. Setting kAXMinimized = true on a non-frontmost window can produce a brief visual flash of that window before its minimize animation; the exact mechanism isn’t certain, but a “brief flash to key window” is a useful mental model. Running the minimize first means the flash happens while the incoming window is still hidden, so the user never sees it. Used by Zonogy-initiated single-window swaps where the source window already exists and no app launch is in flight (Launcher, drag-drop, moves between zones, full-screen exit deferred placements, etc.).DisplacementStrategy.deferred): queue the minimize through DeferredMinimizationCoordinator (150ms debounce). Used by placeNewWindow, the entry point for any “a window arrived” placement (external unminimize/capture, plus internal callers: manual capture, recapture, startup, drag tear-out reassignment). When a launching app is processing its own queue of windows to unminimize, a synchronous minimize would land at the back of that queue and be re-unminimized — an infinite ping-pong. The debounce keeps resetting as arrivals come in, so displaced windows minimize only after the burst settles, by which point they’re no longer in the app’s queue. Trade-off: the flash artifact can appear over the new occupant, but external-arrival visuals are already imperfect (Zonogy doesn’t control the unminimize timing); internal callers accept the same brief glitch to share one loop-safe entry point.DeferredMinimizationCoordinator is also used by occlusion- and focus-driven floating-zone minimization and the floating-zone explicit minimizeOccupant path. Queued minimizations are cancelled if the window is reassigned to any zone before the timer fires.
MinimizeLoopGuard catches the rare case where a synchronous-path minimize is rapidly re-unminimized by an app outside any launch burst. When two non-suppressed deminiaturize events arrive within 2 seconds for windows Zonogy programmatically minimized in the previous 0.5 seconds, the guard activates for 3 seconds; while active, minimizeWindowProgrammatically routes through the deferred queue regardless of the placement’s requested strategy.
window_ids should be monotonically increasing so logs stay unique; do not recycle identifiers after a window closes.NSWorkspace reports that an application terminated, immediately drop every managed window for that pid and resync so placeholders reappear in vacated zones.Logger.debug(_:)) used by controllers so we can trace zone transitions and window lifecycle without attaching Xcode.swift run 2>&1 | grep --line-buffered "keyword".AXWindowMiniaturized notification for those window IDs (one-shot) with a safety timeout (~3s). When restoring WinShot snapshots, it also suppresses only the next AXWindowDeminiaturized notification for the restored external windows that are being unminimized and pre-positioned as part of the snapshot. Other windows remain unaffected and user-triggered actions still get through.
(grep --line-buffered streams matching lines without delay.)Every synchronous AX API call (e.g., AXUIElementCopyAttributeValue, AXUIElementSetAttributeValue, AXUIElementPerformAction, AXObserverCreate, AXObserverAddNotification) is wrapped in a timing helper. Calls exceeding 0.1s emit a single [SLOW-AX] line with the function name, attribute/action, duration (took Nms), AX status, target pid + bundle, and a thread=main/thread=bg tag; calls under the threshold are silent so normal operation adds no log noise. The thread= tag distinguishes main-thread blocks (which surface as freezes) from background-queue blocks (which show up as stalled UI updates).
To inspect slow calls in /tmp/zonogy-debug.log:
grep '\[SLOW-AX\]' /tmp/zonogy-debug.loggrep -E '\[SLOW-AX\].*took [0-9]{4,}ms' /tmp/zonogy-debug.log (the {4,} matches 4+ digit millisecond counts, i.e. ≥ 1000ms)Each synchronous AX API call is an inter-process request: the target application must be scheduled, read its own state, and reply. When Zonogy issues many such calls per second across many tracked windows, this drains battery both directly (Zonogy’s own CPU work) and indirectly (waking applications that macOS’s App Nap would otherwise leave idle). The mechanisms below keep that volume low without changing user-visible behavior.
The destroyed-window prune pass runs on every full sync. For each tracked window it first checks CGWindowListCopyWindowInfo (cheap, no per-app AX IPC). If WindowServer still lists a placed window and Zonogy has not recently confirmed that its AX element works, Zonogy checks the AX element currently associated with the window by requesting its role and position. If those queries fail, Zonogy applies the AX window-validation logic described under AX destroy notifications for WindowServer-listed windows below.
The safety-net is throttled by a per-window timestamp cache with a 5-second time-to-live. The cache is also refreshed at notification dispatch time: any incoming AX move, resize, miniaturize, deminiaturize, focus-change, or main-window-change notification for a tracked window is itself proof the element is alive, so the corresponding cache entry is refreshed without an additional read.
Even with the cache, the safety-net still fires for windows that haven’t received recent AX notifications. When such a window is minimized, the read is wasted work: Zonogy isn’t acting on its AX state, and the target application is more likely to be in App Nap, so the read forces a wake-up to confirm something the user can’t observe. The safety-net is skipped entirely for minimized windows. CGWindowListCopyWindowInfo still runs unconditionally, so the primary destruction signal is unchanged.
When applying a target frame, the placement code first chooses a position-vs-size order so the in-progress frame stays inside the visible display, applies it, and reads back the resulting frame to verify. If the first pass produced the target frame, the move is done. Otherwise, it follows up with the opposite order as a recovery step, falling through to the existing retry chain if needed.
The move pipeline reads the window’s current frame once at entry. The same value serves both the skip-if-at-target check and the placement step’s apply-order decision.
Zonogy uses five narrowly scoped retry/verification mechanisms to cope with AX timing and consistency issues: three are PID/application-scoped and two are per window. All of them are tied to concrete events (no global polling loops) and are explicitly cancelled when they are no longer needed or when the system goes to sleep.
handleScreensDidSleep calls cancelAllValidationRetries()).AXWindowCreated notifications fail to yield a manageable window (e.g., transient AX errors), WindowCapturePipeline schedules a small number of delayed recapture attempts per PID using cancelAllRetries() to tear them down when captures succeed, the app exits, or the system goes to sleep.WindowController retries with backoff (≈0.25–3.0s, a few attempts) until the origin is at the target and the app accepted the size write — i.e. AXUIElementSetAttributeValue(…, kAXSizeAttribute, …) returned .success. The return status tells a transient failure from a permanent one: a non-.success means the app refused the resize (e.g. a just-created window not yet ready) — keep retrying; a .success that still doesn’t reach the requested size is a real size constraint (e.g. a min-width window) — settle immediately and let ActiveFit handle any overflow. Retry chains are cancelled when zone geometry changes, displays go to sleep, or a new moveWindow call supersedes the old target. ActiveFit interaction: When the retry chain settles, WindowController notifies AppController via frameRetryDidSettle, which evaluates whether ActiveFit reveal mode is needed for zone 2/3 windows.managed, confirmed unmanaged, or unresolved) and only confirmed-unmanaged focus suppresses UI. Focus is confirmed unmanaged if it fails any of the application or window-level management rules (see SPECIFICATION.md) other than the _AXUIElementGetWindow check, whose failure can be transient. Unresolved focus (e.g., transient _AXUIElementGetWindow failures) is treated as managed for suppression purposes and retried with PID-scoped exponential backoff (≈0.2–3.2s); retries are cancelled when focus resolves, the frontmost PID changes, or displays sleep. Zonogy’s own Preferences window also suppresses resize bars on its display (resolved synchronously via AppKit rather than through this retry pipeline).Some applications can emit AXWindowCreated for a window Zonogy already tracks (same PID + CGWindowID) (eg Word). In these cases the notification may carry a fresh AXUIElement for the same underlying window. Zonogy must treat this as an element-rebind event (update the stored AX element, lookup mapping, and window notification registrations atomically), not as a new-window capture and not as a capture failure.
Some applications emit AXUIElementDestroyed while the window stays open; others emit it when closing the window but leave the window registered with WindowServer. When WindowServer still lists the window, Zonogy:
CGWindowID.CGWindowID, Zonogy updates the tracked window to use it, treating this as an AX-element change rather than a window close.CGWindowID, and none matches the tracked window’s CGWindowID.CGWindowID.The full-sync destroyed-window prune pass uses these same checks, but because it was not triggered by AXUIElementDestroyed, it preserves a window that WindowServer still lists even when the AX window list does not contain it.
A prune decision still passes through the native-tab close-rebind described above, which may adopt a visible sibling with a different CGWindowID. Sleep/wake gating follows SPECIFICATION-WAKE.md.
AX move/resize notifications (kAXMovedNotification, kAXResizedNotification) do not indicate whether the change was triggered by:
Zonogy handles this with two complementary mechanisms:
WindowController tags that window as “programmatic” for a short time and ignores the resulting AX moved/resized notifications. This prevents feedback loops where reacting to our own move/resize would cause additional move/resize attempts.snapToZoneOnSelfResize, Zonogy attempts to recognize user edge-drag resizes (cursor near the window border plus left-mouse down or a very recent mouse-up grace window).This attribution work is used by:
While a sheet (such as a save dialog) is open, the Accessibility API reports the sheet, not its window, as the application’s focused window, and the sheet’s role is AXSheet. Zonogy resolves a focused sheet to its containing window by walking up the sheet’s parent chain, so focus-driven behavior (ActiveFit, frontmost-window tracking, unmanaged-focus classification, recency ordering, WinShot active-window resolution) applies to the managed window underneath. For the ActiveFit reveal itself, Zonogy also measures the frames of the window’s attached sheets so the shift accounts for a sheet that extends past the window’s frame (see ActiveFit in SPECIFICATION.md).
Some applications report the subrole for their minimized windows as AXDialogSubrole even if it later becomes kAXStandardWindowSubrole upon un-minimization. So for enumeration of windows to manage, we don’t check subrole for minimized windows.
When unminimizing a window that needs to appear at a specific position (e.g., restoring a WinShot snapshot or selecting a minimized window from Launcher), we first set the window’s position and size while the window is still minimized. However, if we unminimize synchronously right after setting position/size, the window sometimes visually appears at its old location before snapping to the correct position. To address this, we default to async mode for unminimization.
To focus a specific window, we set the window’s kAXMainAttribute, perform kAXRaiseAction, and only then call app.activate(). (The order matters. Requesting activation first seems to restore whichever window the application last had frontmost, overriding the raise.)
When placing a window into the floating zone, the window may fail to receive focus and appear behind tiled windows. Since the floating zone floats above tiled zones, this is the only placement where another window can obscure the placed window. The workaround (in activateFloatingZoneWindow) is to call NSApp.activate(ignoringOtherApps: true) to activate Zonogy first, then yield to the run loop via DispatchQueue.main.async before the make-main / raise / app.activate() sequence described above.
On displays placed above or left of the primary display (negative global coordinates), the system pins a hard-slammed cursor essentially on the display’s boundary coordinate. Drag-session hit-testing resolves that position to the row just past the display, which no window’s drag region can cover (regions are clipped to the visible display area) — so a fast external drag to such an edge never reaches the edge bar’s drop handlers, no matter how the bar’s window is framed.
Two measures compensate:
Zonogy detects native macOS full-screen windows using the (undocumented)AXFullScreen AX attribute for native-full screen mode (ie green-button kind), and with additional detection for non-native-full screen. The big picture intent is to “pause” Zonogy (no UI, no targeting) on a display in full-screen mode, and target another display instead.
Some full-screen exits emit no resize event, which would leave a display stuck in pause after its full-screen window is gone. Focus acts as the repair: when the user focuses a (non-full-screen) window on a paused display, the display is clearly no longer showing full-screen content, so the pause is lifted. The exception is a full-screen window whose Space is merely inactive behind the current one: the user never exited full-screen, and the display goes through “Returning to the full-screen Space” (below), which lifting the pause would derail.
We listen to kAXResizedNotification which fires when windows enter/exit full-screen mode, and query the AXFullScreen attribute via AXUIElementCopyAttributeValue. We use 250ms debounce. (Of course, we also handle window closure and app termination.)
At startup (after window capture) and after display reconfiguration, we also iterate all managed windows and check their AXFullScreen attribute. We also re-scan full-screen state after active Space changes, since some apps (e.g., Safari video) don’t emit resize events for their full-screen windows. This rescan is debounced and uses the same AXFullScreen query pipeline.
Returning to the full-screen Space: When a display has a native full-screen window, MacOS creates a new Space for it on that display. Although Zonogy’s pipelines try to target another display, we can’t completely stop windows opening in the display that’s in full-screen mode. If a window opens on that display, MacOS switches Spaces. This is undesired–instead we want to place any managed window opened in that way into a zone in another (non-full-screen) display and go back to the full-screen Space so we are not interrupted. (For example, we are watching a movie in full-screen, and doing other things at the same time on another display.) To get this behavior, we monitor Spaces through another private API (see “CGS Spaces membership query” below).
For managed apps with exception treatAXUnknownFullWidthAsFullScreen: for windows whose AX subrole is AXUnknown (some presentation-style windows like Keynote full-screen), we treat them as full-screen if their AX frame width matches the display width exactly.
Going back to the full-screen Space (per the previous section) depends on knowing whether that Space still exists while another Space is showing on the same display. AXFullScreen tells us a window claims to be full-screen, but it doesn’t say whether its full-screen Space is the active one. The standard on-screen window list (CGWindowListCopyWindowInfo) doesn’t help either — it only includes windows in the currently active Space, so a window on an inactive full-screen Space looks the same as one that has exited full-screen.
CGS Spaces (CGSCopySpacesForWindows + CGSSpaceGetType == kCGSSpaceFullscreen) answers the question directly: it reports which Spaces a window currently belongs to. We rely on it in places where we would otherwise mistakenly drop Zonogy’s full-screen pause.
For a complete inventory of all timers and delay mechanisms (AX retries, debounce, protection windows, etc.), see SPECIFICATION-TIMERS.md.