@@ -33,6 +33,15 @@ public final class OverlayController: NSObject {
3333 /// Host-window-local size, for clamping the composer on-screen.
3434 private var surfaceSize : CGSize = . zero
3535
36+ /// Host frame captured at the last geometry sync. A SwiftUI/content-sized host
37+ /// can attach at its PRE-LAYOUT frame and grow to its final size a runloop turn
38+ /// or two later without posting `didMove`/`didResize`; comparing against this
39+ /// lets the post-attach settle poll tell when the frame has stabilized.
40+ private var lastSyncedHostFrame : NSRect = . null
41+ /// Bumped on every attach/unmount so an in-flight settle poll for a previous
42+ /// host stops instead of re-syncing against a stale (or detached) window.
43+ private var settleGeneration = 0
44+
3645 public init ( session: AnnotationSession ) {
3746 self . session = session
3847 super. init ( )
@@ -54,8 +63,21 @@ public final class OverlayController: NSObject {
5463 attach ( to: host)
5564 }
5665
66+ /// Mount the overlay on a SPECIFIC host window, bypassing the `hostWindow()`
67+ /// auto-picker. Use when the caller already knows the exact window to annotate
68+ /// (e.g. a settings/document window): the auto-picker
69+ /// (`NSApp.mainWindow ?? keyWindow ?? first visible non-panel`) can otherwise
70+ /// resolve to a floating panel when several windows are visible.
71+ public func mount( on host: NSWindow ) {
72+ guard panel == nil else { return }
73+ attach ( to: host)
74+ }
75+
5776 public func unmount( ) {
5877 NotificationCenter . default. removeObserver ( self )
78+ // Invalidate any in-flight settle poll so it cannot re-sync a detached host.
79+ settleGeneration += 1
80+ lastSyncedHostFrame = . null
5981 if let panel {
6082 host? . removeChildWindow ( panel)
6183 panel. orderOut ( nil )
@@ -142,6 +164,10 @@ public final class OverlayController: NSObject {
142164 NotificationCenter . default. removeObserver ( self , name: NSWindow . didBecomeMainNotification, object: nil )
143165 registerGeometryObservers ( for: host)
144166 syncFrameAndOrigin ( )
167+ // A content-sized host is often still at its pre-layout frame right here and
168+ // grows a runloop turn or two later without posting a move/resize; poll until
169+ // the host frame settles so the panel and axOrigin reflect the FINAL frame.
170+ scheduleSettleResync ( )
145171 }
146172
147173 private func makeRootView( ) -> OverlayView {
@@ -181,6 +207,41 @@ public final class OverlayController: NSObject {
181207 syncFrameAndOrigin ( )
182208 }
183209
210+ /// Re-sync across the next several runloop turns until the host frame stops
211+ /// changing. A SwiftUI/content-sized host attaches at its PRE-LAYOUT frame and
212+ /// grows to its final laid-out size a turn or two later WITHOUT posting
213+ /// `didMove`/`didResize` (content-driven sizing does not fire those), so the
214+ /// notification observers alone leave the panel frame and `axOrigin` stale at the
215+ /// initial tiny frame — which is both the misplacement and the dead AX hit-test.
216+ /// This poll catches that settle; it is bounded (self-terminating) and guarded by
217+ /// a generation token so it stops on unmount / re-attach, and the observers still
218+ /// cover any move/resize after the window has settled. A host created at its final
219+ /// size (the demo, the probes) is already stable, so this does zero extra syncs.
220+ private func scheduleSettleResync( ) {
221+ settleGeneration += 1
222+ pollSettle ( generation: settleGeneration, ticksRemaining: 24 , stableTicks: 0 )
223+ }
224+
225+ private func pollSettle( generation: Int , ticksRemaining: Int , stableTicks: Int ) {
226+ // ~40ms/turn gives layout a real runloop turn to run between polls; a burst of
227+ // plain `async` blocks would drain before SwiftUI lays out and miss the growth.
228+ // A 24-tick budget (~1s) is far more than layout needs.
229+ DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.04 ) { [ weak self] in
230+ guard let self, generation == self . settleGeneration, let host = self . host else { return }
231+ var stable = stableTicks
232+ if host. frame == self . lastSyncedHostFrame {
233+ stable += 1
234+ } else {
235+ stable = 0
236+ self . syncFrameAndOrigin ( )
237+ }
238+ // Stop once the frame has held for two consecutive turns (settled) or the
239+ // budget is spent; otherwise keep polling for the post-layout growth.
240+ guard ticksRemaining > 1 , stable < 2 else { return }
241+ self . pollSettle ( generation: generation, ticksRemaining: ticksRemaining - 1 , stableTicks: stable)
242+ }
243+ }
244+
184245 @objc private func hostWindowAppeared( _ note: Notification ) {
185246 guard panel == nil , hostWindow ( ) != nil else { return }
186247 mount ( )
@@ -196,6 +257,7 @@ public final class OverlayController: NSObject {
196257 let primaryHeight = NSScreen . screens. first? . frame. height ?? 0
197258 axOrigin = ScreenSpace . windowAXOrigin ( cocoaFrame: host. frame, primaryHeight: primaryHeight)
198259 surfaceSize = host. frame. size
260+ lastSyncedHostFrame = host. frame
199261 hostingView? . rootView = makeRootView ( )
200262 }
201263
0 commit comments