Skip to content

Commit 3b57702

Browse files
angusbezzinaclaude
andcommitted
fix(overlay): mount on a caller-specified window + settle geometry after host layout
Two robustness fixes for hosting the overlay on a real app: - Add Annotation.install(on:) / OverlayController.mount(on:) so a host can pass the EXACT window to annotate. The no-arg auto-picker (NSApp.mainWindow ?? keyWindow ?? first visible non-panel) can resolve to a floating NSPanel when several windows are visible (e.g. VirgilHUD's IconPanel instead of its Settings window), leaving the overlay in the wrong window's corner with a dead AX hit-test. - Re-sync the panel frame + axOrigin across runloop turns until the host frame settles, covering SwiftUI/content-sized hosts that grow to final size after attach without posting didMove/didResize. Adds a headless overlay-probe regression (host grows post-attach; pill + axOrigin track the final frame). 44 tests + both probes green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3a8c53c commit 3b57702

3 files changed

Lines changed: 295 additions & 2 deletions

File tree

Sources/AnnotKit/Annotation.swift

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import Foundation
2+
#if os(macOS)
3+
import AppKit
4+
#endif
25

36
/// The public entry point. A host adds the toolbar in a few lines, dev-gated:
47
///
@@ -60,6 +63,22 @@ public enum Annotation {
6063
#endif
6164
}
6265

66+
#if os(macOS)
67+
/// Mount the overlay on a SPECIFIC host window instead of the auto-picked one.
68+
/// Use when the app already knows the exact window to annotate: the auto-picker
69+
/// (`NSApp.mainWindow ?? keyWindow ?? first visible non-panel`) can otherwise
70+
/// resolve to a floating `NSPanel` when several windows are visible.
71+
public static func install(on host: NSWindow, source: ElementSource? = nil, sink: AnnotationSink? = nil) {
72+
guard isEnabled else { return }
73+
guard controller == nil else { return }
74+
let session = AnnotationSession(source: source ?? MacElementSource(), sink: sink ?? NotesFileSink())
75+
let controller = OverlayController(session: session)
76+
controller.mount(on: host)
77+
Self.controller = controller
78+
isInstalled = true
79+
}
80+
#endif
81+
6382
/// Enter annotate mode (toolbar active, clicks/taps captured).
6483
public static func start() {
6584
#if os(macOS) || os(iOS)

Sources/AnnotKit/macOS/OverlayController.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)