@@ -80,6 +80,16 @@ final class AXElementNode: SelectorMatchable {
8080enum AXIntrospection {
8181 static let maxDepth = 200
8282
83+ /// AX identifier that ``OverlayController`` stamps on its overlay panel
84+ /// window (`panel.setAccessibilityIdentifier(_:)`). The point query and the
85+ /// snapshot both skip any window carrying it, so our own overlay never
86+ /// shadows the host. This is required in addition to the SwiftUI content
87+ /// being `accessibilityHidden`: when the panel expands to the host's full
88+ /// frame it is a live `AXWindow` that `AXUIElementCopyElementAtPosition`
89+ /// hit-tests first, so without excluding the panel WINDOW every hover/click
90+ /// resolves to the overlay's own hosting view instead of the control beneath.
91+ static let overlayWindowIdentifier = " com.annotkit.overlay-window "
92+
8393 private static let actionableRoles : Set < String > = [
8494 " AXButton " , " AXLink " , " AXCheckBox " , " AXRadioButton " ,
8595 " AXPopUpButton " , " AXMenuButton " , " AXMenuItem " , " AXSlider "
@@ -98,12 +108,36 @@ enum AXIntrospection {
98108
99109 // MARK: - Snapshot
100110
101- /// Snapshot every window of the host app as an ``AXElementNode`` tree.
111+ /// Snapshot every window of the host app as an ``AXElementNode`` tree,
112+ /// excluding our own overlay panel window(s) so the overlay never appears as
113+ /// a phantom window shadowing the host.
102114 static func snapshotNodes( ) -> [ AXElementNode ] {
103115 let app = appElement ( )
104- return elementArray ( app, kAXWindowsAttribute) . map { window in
105- buildNode ( window, selfComponent: component ( for: window, indexAmongRole: 0 ) , parentPath: [ ] , depth: 0 )
116+ return elementArray ( app, kAXWindowsAttribute)
117+ . filter { !isOverlayWindow( $0) }
118+ . map { window in
119+ buildNode ( window, selfComponent: component ( for: window, indexAmongRole: 0 ) , parentPath: [ ] , depth: 0 )
120+ }
121+ }
122+
123+ /// True when `window` is one of our own overlay panels, tagged by
124+ /// ``OverlayController`` with ``overlayWindowIdentifier``.
125+ private static func isOverlayWindow( _ window: AXUIElement ) -> Bool {
126+ string ( window, kAXIdentifierAttribute) == overlayWindowIdentifier
127+ }
128+
129+ /// True when `element` lives inside one of our overlay panel windows. Used to
130+ /// reject a point-query hit that resolved into the overlay so we never return
131+ /// the overlay's own hosting view instead of the host control.
132+ private static func belongsToOverlayWindow( _ element: AXUIElement ) -> Bool {
133+ var current : AXUIElement ? = element
134+ var depth = 0
135+ while let node = current, depth < maxDepth {
136+ if string ( node, kAXRoleAttribute) == " AXWindow " { return isOverlayWindow ( node) }
137+ current = copyValue ( node, kAXParentAttribute) . map { unsafeDowncast ( $0, to: AXUIElement . self) }
138+ depth += 1
106139 }
140+ return false
107141 }
108142
109143 /// Public snapshot in terms of ``WindowSnapshot``.
@@ -188,22 +222,70 @@ enum AXIntrospection {
188222 /// Resolve a screen point (AX top-left coordinates) to an annotation target.
189223 /// Uses the native `AXUIElementCopyElementAtPosition` for the deepest
190224 /// element, then walks up to the nearest ancestor carrying a stable identity
191- /// (identifier or label), per docs/spike-ax-pointquery.md. The overlay window
192- /// must be excluded by the caller (marked non-accessibility) so it never
193- /// resolves to itself.
225+ /// (identifier or label), per docs/spike-ax-pointquery.md.
226+ ///
227+ /// The native app-level query is the fast path: when it lands on a real host
228+ /// element (idle corner overlay, or any point the overlay does not cover) it
229+ /// is the most accurate hit-test, so it is kept. But the expanded overlay is
230+ /// a full-window `AXWindow` sitting above the host, so while annotating the
231+ /// native query hits the overlay's own hosting-view group instead of the
232+ /// control beneath — that is the "everything resolves to the whole app" bug.
233+ /// When the native hit belongs to our overlay we discard it and resolve the
234+ /// point by descending the frontmost non-overlay window's AX subtree directly
235+ /// (``hitBeneathOverlay(_:)``), which is what "queries beneath" the overlay.
236+ /// (`AXUIElementCopyElementAtPosition` only hit-tests when given the
237+ /// application element, so we cannot simply re-target it at the host window.)
194238 static func hitTest( _ point: CGPoint ) -> Element ? {
195239 let app = appElement ( )
196240 var hit : AXUIElement ?
197- guard AXUIElementCopyElementAtPosition ( app, Float ( point. x) , Float ( point. y) , & hit) == . success,
198- let deepest = hit
199- else { return nil }
241+ let deepest : AXUIElement
242+ if AXUIElementCopyElementAtPosition ( app, Float ( point. x) , Float ( point. y) , & hit) == . success,
243+ let native = hit, !belongsToOverlayWindow( native) {
244+ deepest = native
245+ } else if let beneath = hitBeneathOverlay ( point) {
246+ deepest = beneath
247+ } else {
248+ return nil
249+ }
200250
201251 let chain = ancestorChain ( from: deepest)
202252 guard !chain. isEmpty else { return nil }
203- let target = nearestIdentified ( in: chain) ?? deepest
253+ // Never fall back to the window or application container: escalating to
254+ // AXWindow is what made a background click resolve to the whole app.
255+ guard let target = nearestIdentified ( in: chain) ?? deepestNonContainer ( in: chain) else {
256+ return nil
257+ }
204258 return element ( for: target, ancestorChain: chain)
205259 }
206260
261+ /// Geometric hit-test beneath the overlay. The native point query cannot see
262+ /// past our own full-window overlay panel, so descend the frontmost
263+ /// non-overlay window whose frame contains `point` to the deepest descendant
264+ /// that still contains it, walking `kAXChildren` by frame. `kAXWindows` is
265+ /// front-to-back, so the first matching window is the frontmost real target.
266+ private static func hitBeneathOverlay( _ point: CGPoint ) -> AXUIElement ? {
267+ let app = appElement ( )
268+ let windows = elementArray ( app, kAXWindowsAttribute) . filter { !isOverlayWindow( $0) }
269+ guard let window = windows. first ( where: { frameScreen ( of: $0) . contains ( point) } ) else {
270+ return nil
271+ }
272+ return deepestChild ( of: window, containing: point, depth: 0 )
273+ }
274+
275+ /// Deepest descendant of `element` whose (non-empty) frame contains `point`.
276+ /// Later children are drawn on top, so a topmost match wins; returns
277+ /// `element` itself when no child contains the point.
278+ private static func deepestChild( of element: AXUIElement , containing point: CGPoint , depth: Int ) -> AXUIElement {
279+ guard depth < maxDepth else { return element }
280+ for child in elementArray ( element, kAXChildrenAttribute) . reversed ( ) {
281+ let frame = frameScreen ( of: child)
282+ if frame. width > 0 , frame. height > 0 , frame. contains ( point) {
283+ return deepestChild ( of: child, containing: point, depth: depth + 1 )
284+ }
285+ }
286+ return element
287+ }
288+
207289 /// Climb `kAXParentAttribute` from `element` up to the window, returning the
208290 /// chain ordered root-first.
209291 private static func ancestorChain( from element: AXUIElement ) -> [ AXUIElement ] {
@@ -240,6 +322,20 @@ enum AXIntrospection {
240322 return nil
241323 }
242324
325+ /// Deepest element in the chain that is still a plausible target — anything
326+ /// that is not the window or application container. Used only when no
327+ /// identified/actionable ancestor exists, so a click on a plain leaf resolves
328+ /// to that leaf rather than escalating to the whole window (which would then
329+ /// show the window title in the composer header).
330+ private static func deepestNonContainer( in rootFirstChain: [ AXUIElement ] ) -> AXUIElement ? {
331+ for element in rootFirstChain. reversed ( ) {
332+ let role = string ( element, kAXRoleAttribute) ?? " "
333+ if role == " AXWindow " || role == " AXApplication " { continue }
334+ return element
335+ }
336+ return nil
337+ }
338+
243339 /// Build a public ``Element`` for `target`, computing its path from the
244340 /// supplied root-first ancestor chain (with same-role sibling indices).
245341 private static func element( for target: AXUIElement , ancestorChain rootFirst: [ AXUIElement ] ) -> Element {
0 commit comments