@@ -265,12 +265,45 @@ enum AXIntrospection {
265265 let candidates = chain. map { candidate ( for: $0, windowFrame: windowFrame) }
266266 guard let targetIndex = AnnotationTargetRule . targetIndex ( in: candidates) else { return [ ] }
267267 let target = chain [ targetIndex]
268- let targetArea = area ( of: target)
269268
269+ return [ element ( for: target, ancestorChain: chain) ]
270+ + enclosingComponents( of: target, containing: point, in: chain)
271+ . map { element ( for: $0, ancestorChain: ancestorChain ( from: $0) ) }
272+ }
273+
274+ /// The identified components that geometrically ENCLOSE `target` at `point`,
275+ /// smallest-first — the widening rungs above a bound target. Shared verbatim
276+ /// by the point path (``componentLadder(for:)``) and the frame path
277+ /// (``marqueeLadder(for:)``) so a click and a drag onto the same element can
278+ /// never widen through different components; duplicating this scan is exactly
279+ /// how the two paths would silently drift apart.
280+ ///
281+ /// The scan is GEOMETRIC (DECISIONS.md): a `.axCardSurface` card hangs its
282+ /// identifier on a clear background leaf that is a SIBLING of the card's
283+ /// content, so it never appears in an ancestor chain. Scanning each ancestor
284+ /// PLUS its direct children reaches those surfaces without a full-tree walk.
285+ /// Deduped by identifier (the same surface is reachable from several
286+ /// ancestors), and the `>= targetArea` floor keeps a smaller identified
287+ /// sibling that merely happens to cover the point out of the widening ladder.
288+ ///
289+ /// Container ROOTS are excluded, matching ``AnnotationTargetRule/wideningLadder(in:)``
290+ /// stopping at the window. Load-bearing, not tidiness: the chain climbs to
291+ /// `AXApplication`, whose direct children are the app's WINDOWS — including our
292+ /// own overlay panel, which carries ``overlayWindowIdentifier`` and encloses
293+ /// every point in the host. Without this the top rung of every ladder is
294+ /// AnnotKit's own overlay, so widening would bind the user's note to our UI.
295+ private static func enclosingComponents(
296+ of target: AXUIElement ,
297+ containing point: CGPoint ,
298+ in rootFirstChain: [ AXUIElement ]
299+ ) -> [ AXUIElement ] {
300+ let targetArea = area ( of: target)
270301 var containers : [ ( node: AXUIElement , area: CGFloat ) ] = [ ]
271302 var seen = Set < String > ( )
272- for ancestor in chain {
303+ for ancestor in rootFirstChain {
273304 for node in [ ancestor] + elementArray( ancestor, kAXChildrenAttribute) {
305+ let role = string ( node, kAXRoleAttribute) ?? " "
306+ guard role != " AXWindow " , role != " AXApplication " else { continue }
274307 let id = string ( node, kAXIdentifierAttribute) ?? " "
275308 guard !id. isEmpty, !seen. contains ( id) , !CFEqual( node, target) else { continue }
276309 let frame = frameScreen ( of: node)
@@ -282,9 +315,107 @@ enum AXIntrospection {
282315 }
283316 }
284317 containers. sort { $0. area < $1. area }
318+ return containers. map ( \. node)
319+ }
320+
321+ // MARK: - Marquee (drawn frame -> element)
322+
323+ /// The component-widening ladder for a frame the user DREW: the element the
324+ /// frame binds to per ``MarqueeTargetRule`` first, then each enclosing
325+ /// identified component, broadest last. Same shape as
326+ /// ``componentLadder(for:)``, because the session reuses its ladder machinery
327+ /// verbatim — widening and the note's `component` field both assume
328+ /// `ladder[0]` is the bound target. Empty when the frame resolves to nothing
329+ /// (the session then captures a region note instead).
330+ ///
331+ /// Cost: this walks the whole window subtree ONCE, on drag RELEASE only —
332+ /// never during the drag and never on hover. A full walk is affordable at that
333+ /// rate; it would not be on the hover path, which is why the point path still
334+ /// uses the ancestor-chain scan instead.
335+ static func marqueeLadder( for rect: CGRect ) -> [ Element ] {
336+ // Standardize before anything geometric: a right-to-left / bottom-to-top
337+ // drag arrives with negative extents, where `contains` degenerates and the
338+ // window lookup below would silently find nothing.
339+ let marquee = rect. standardized
340+ let center = CGPoint ( x: marquee. midX, y: marquee. midY)
341+ let app = appElement ( )
342+ // Same window pick as ``regionAnchor(for:)`` / ``hitBeneathOverlay(_:)``:
343+ // `kAXWindows` is front-to-back, so the first non-overlay window containing
344+ // the frame's center is the frontmost real target.
345+ let windows = elementArray ( app, kAXWindowsAttribute) . filter { !isOverlayWindow( $0) }
346+ guard let window = windows. first ( where: { frameScreen ( of: $0) . contains ( center) } ) else { return [ ] }
347+ let windowFrame = frameScreen ( of: window)
348+
349+ // ONE recursive walk, so every candidate's depth is measured from the SAME
350+ // root (window = 0). Depth is the rule's tie-break between geometrically
351+ // indistinguishable candidates; assembling the array from several
352+ // differently-rooted traversals would turn that tie-break into noise.
353+ //
354+ // The subtree is collected WHOLE — deliberately not pre-filtered to what
355+ // intersects the drawn frame. The rule's second pass needs the candidates
356+ // whose frames CONTAIN the frame (the user drew inside something), and an
357+ // intersects-the-marquee filter is precisely what discards them.
358+ var nodes : [ AXUIElement ] = [ ]
359+ var candidates : [ MarqueeCandidate ] = [ ]
360+ collectMarqueeCandidates (
361+ window, windowFrame: windowFrame, depth: 0 , nodes: & nodes, candidates: & candidates
362+ )
285363
364+ guard let resolution = MarqueeTargetRule . resolve ( marquee: marquee, in: candidates) else { return [ ] }
365+ let target = nodes [ resolution. index]
366+ let chain = ancestorChain ( from: target)
367+
368+ // The widening rungs are anchored at the TARGET's frame center, not the
369+ // drawn frame's: a sloppy marquee can spill outside the element it bound
370+ // to, and a container that does not contain the target is not a component
371+ // the user could widen to. This is also the value the point path passes.
372+ let targetFrame = frameScreen ( of: target)
373+ let targetCenter = CGPoint ( x: targetFrame. midX, y: targetFrame. midY)
286374 return [ element ( for: target, ancestorChain: chain) ]
287- + containers. map { element ( for: $0. node, ancestorChain: ancestorChain ( from: $0. node) ) }
375+ + enclosingComponents( of: target, containing: targetCenter, in: chain)
376+ . map { element ( for: $0, ancestorChain: ancestorChain ( from: $0) ) }
377+ }
378+
379+ /// Depth-first walk collecting a PARALLEL pair per node: the live
380+ /// `AXUIElement` and its pure ``MarqueeCandidate``, so
381+ /// ``MarqueeTargetRule/Resolution/index`` maps straight back to a live handle.
382+ ///
383+ /// The candidate is built with the same ``candidate(for:windowFrame:)`` the
384+ /// point path uses, so chrome / container-root / window-ghost classification —
385+ /// which is what the rule's eligibility filter reads — is identical for a click
386+ /// and a drag by construction.
387+ private static func collectMarqueeCandidates(
388+ _ element: AXUIElement ,
389+ windowFrame: CGRect ,
390+ depth: Int ,
391+ nodes: inout [ AXUIElement ] ,
392+ candidates: inout [ MarqueeCandidate ]
393+ ) {
394+ nodes. append ( element)
395+ candidates. append (
396+ MarqueeCandidate (
397+ element: candidate ( for: element, windowFrame: windowFrame) ,
398+ frame: frameScreen ( of: element) ,
399+ depth: depth
400+ )
401+ )
402+ guard depth < maxDepth else { return }
403+ for child in elementArray ( element, kAXChildrenAttribute) {
404+ // Starting from a non-overlay window should already put the overlay out
405+ // of reach, but AppKit exposes an attached child PANEL through some
406+ // parents' `kAXChildren`, so verify rather than assume: a marquee that
407+ // swept the overlay's own hosting view would bind the note to our UI.
408+ if isOverlayWindow ( child) { continue }
409+ // Chrome's whole subtree is skipped, not just the button: the traffic
410+ // lights' inner glyph groups carry no chrome subrole of their own, so
411+ // the rule's `isChrome` filter alone would let a marquee over the title
412+ // bar bind to a glyph. The point path rejects chrome geometrically for
413+ // the same reason.
414+ if isChrome ( child) { continue }
415+ collectMarqueeCandidates (
416+ child, windowFrame: windowFrame, depth: depth + 1 , nodes: & nodes, candidates: & candidates
417+ )
418+ }
288419 }
289420
290421 /// Resolve `point` to a root-first ancestor chain of the deepest host element
0 commit comments