Skip to content

Commit c273dde

Browse files
angusbezzinaclaude
andcommitted
feat(marquee): macOS adapters resolve a drawn frame to an element (VRT-cne0.7)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 parents 7ffbba3 + 53dca4f commit c273dde

4 files changed

Lines changed: 420 additions & 8 deletions

File tree

Sources/AnnotKit/macOS/AXIntrospection.swift

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

Sources/AnnotKit/macOS/MacElementSource.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,10 @@ extension MacElementSource: ComponentLadderSource {
3939
AXIntrospection.componentLadder(for: point)
4040
}
4141
}
42+
43+
extension MacElementSource: MarqueeTargetSource {
44+
public func marqueeLadder(in rect: CGRect) -> [Element] {
45+
AXIntrospection.marqueeLadder(for: rect)
46+
}
47+
}
4248
#endif

Sources/AnnotKit/macOS/MacViewTreeElementSource.swift

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public final class MacViewTreeElementSource: ElementSource, ComponentLadderSourc
7979

8080
public func hitTest(_ point: CGPoint) -> Element? {
8181
guard let chain = Self.resolvedChain(at: point) else { return nil }
82-
let candidates = chain.map(Self.candidate(for:))
82+
let candidates = chain.map { Self.candidate(for: $0) }
8383
guard let index = AnnotationTargetRule.targetIndex(in: candidates) else { return nil }
8484
return Self.element(for: chain[index])
8585
}
@@ -88,7 +88,7 @@ public final class MacViewTreeElementSource: ElementSource, ComponentLadderSourc
8888
/// views), matching the AX and iOS sources so all three widen identically.
8989
public func componentLadder(at point: CGPoint) -> [Element] {
9090
guard let chain = Self.resolvedChain(at: point) else { return [] }
91-
let candidates = chain.map(Self.candidate(for:))
91+
let candidates = chain.map { Self.candidate(for: $0) }
9292
return AnnotationTargetRule.wideningLadder(in: candidates).map { Self.element(for: chain[$0]) }
9393
}
9494

@@ -216,13 +216,21 @@ public final class MacViewTreeElementSource: ElementSource, ComponentLadderSourc
216216

217217
/// Read one view's target-relevant facts into a pure ``TargetCandidate``. The
218218
/// NSView tree has no displayed value text, so `value` is always empty.
219-
private static func candidate(for view: NSView) -> TargetCandidate {
219+
///
220+
/// `isContainerRoot` defaults to false because on the POINT path the chain is
221+
/// an ancestor chain rooted at the hit's window content view, and flagging it
222+
/// there would change which element a click resolves to. The marquee walk
223+
/// passes true for the content view: a whole-tree walk offers it as a real
224+
/// candidate, and without the flag a large drag would surround it and bind the
225+
/// note to the app's entire content view instead of the card inside it.
226+
private static func candidate(for view: NSView, isContainerRoot: Bool = false) -> TargetCandidate {
220227
TargetCandidate(
221228
role: String(describing: Swift.type(of: view)),
222229
identifier: view.accessibilityIdentifier(),
223230
label: view.accessibilityLabel() ?? "",
224231
value: "",
225-
isActionable: view is NSControl
232+
isActionable: view is NSControl,
233+
isContainerRoot: isContainerRoot
226234
)
227235
}
228236

@@ -237,4 +245,110 @@ public final class MacViewTreeElementSource: ElementSource, ComponentLadderSourc
237245
NSScreen.screens.first?.frame.height ?? 0
238246
}
239247
}
248+
249+
// MARK: - Marquee (drawn frame -> view)
250+
251+
extension MacViewTreeElementSource: MarqueeTargetSource {
252+
/// The ladder for a frame the user DREW, over the `NSView` tree: the view the
253+
/// frame binds to per ``MarqueeTargetRule`` first, then its enclosing
254+
/// identified views, broadest last — the same contract as
255+
/// ``componentLadder(at:)``, so the session's widening works from a framed
256+
/// selection unchanged.
257+
///
258+
/// Cost: one full walk of the hit window's view tree per drag RELEASE. Never
259+
/// during the drag and never on hover, which is what makes a whole-tree walk
260+
/// affordable here where the hover path must stay on the ancestor chain.
261+
public func marqueeLadder(in rect: CGRect) -> [Element] {
262+
// Standardize first: a right-to-left / bottom-to-top drag arrives with
263+
// negative extents, where the window lookup's `contains` degenerates.
264+
let marquee = rect.standardized
265+
guard let content = Self.marqueeRoot(containing: CGPoint(x: marquee.midX, y: marquee.midY)) else {
266+
return []
267+
}
268+
269+
// ONE recursive walk from the content view, so every candidate's depth is
270+
// measured from the SAME root (content view = 0) — depth is the rule's
271+
// tie-break between geometrically indistinguishable candidates, and mixing
272+
// differently-rooted numbering would make it noise. The subtree is
273+
// collected WHOLE, deliberately not pre-filtered to what intersects the
274+
// frame: the rule's enclosing pass needs the views that CONTAIN the frame,
275+
// which such a filter is exactly what discards.
276+
var views: [NSView] = []
277+
var candidates: [MarqueeCandidate] = []
278+
Self.collectMarqueeCandidates(content, isRoot: true, depth: 0, views: &views, candidates: &candidates)
279+
280+
guard let resolution = MarqueeTargetRule.resolve(marquee: marquee, in: candidates) else { return [] }
281+
let target = views[resolution.index]
282+
return [Self.element(for: target)]
283+
+ Self.enclosingIdentifiedViews(of: target, upTo: content).map { Self.element(for: $0) }
284+
}
285+
286+
/// The content view of the frontmost visible non-overlay window under `point`.
287+
/// `NSApp.orderedWindows` is front-to-back, mirroring how the AX source reads
288+
/// `kAXWindows`, so both sources pick the same window for the same drag. The
289+
/// overlay panel is excluded by the identifier ``OverlayController`` stamps on
290+
/// it — the drawn frame always lies over the expanded overlay, so without this
291+
/// every marquee would walk our own hosting view.
292+
private static func marqueeRoot(containing point: CGPoint) -> NSView? {
293+
for window in NSApp.orderedWindows {
294+
guard window.isVisible,
295+
window.accessibilityIdentifier() != AXIntrospection.overlayWindowIdentifier,
296+
let content = window.contentView,
297+
screenFrame(of: content).contains(point)
298+
else { continue }
299+
return content
300+
}
301+
return nil
302+
}
303+
304+
/// Depth-first walk collecting a PARALLEL pair per view — the live `NSView`
305+
/// and its pure ``MarqueeCandidate`` — so
306+
/// ``MarqueeTargetRule/Resolution/index`` maps straight back to a view.
307+
///
308+
/// Hidden and fully transparent subtrees are skipped: they keep real frames, so
309+
/// a marquee would happily "surround" a hidden view the user cannot even see,
310+
/// and being the largest such frame it would win pass 1 outright. The point
311+
/// path gets this for free from `NSView.hitTest`, which a whole-tree walk does
312+
/// not go through.
313+
private static func collectMarqueeCandidates(
314+
_ view: NSView,
315+
isRoot: Bool,
316+
depth: Int,
317+
views: inout [NSView],
318+
candidates: inout [MarqueeCandidate]
319+
) {
320+
views.append(view)
321+
candidates.append(
322+
MarqueeCandidate(
323+
element: candidate(for: view, isContainerRoot: isRoot),
324+
frame: screenFrame(of: view),
325+
depth: depth
326+
)
327+
)
328+
guard depth < maxDepth else { return }
329+
for subview in view.subviews where !subview.isHidden && subview.alphaValue > 0.01 {
330+
collectMarqueeCandidates(
331+
subview, isRoot: false, depth: depth + 1, views: &views, candidates: &candidates
332+
)
333+
}
334+
}
335+
336+
/// The identified superviews of `target`, nearest first (so broadest last),
337+
/// stopping BEFORE `root` — the content view is the container root and is never
338+
/// a widening rung, for the same reason it is never a target. Pure ancestry,
339+
/// matching this source's point ladder; the AX source's extra geometric scan
340+
/// exists for SwiftUI `.background` surfaces, which are AX-only artifacts with
341+
/// no counterpart in the `NSView` tree.
342+
private static func enclosingIdentifiedViews(of target: NSView, upTo root: NSView) -> [NSView] {
343+
var out: [NSView] = []
344+
var current = target.superview
345+
var depth = 0
346+
while let view = current, view !== root, depth < maxDepth {
347+
if !view.accessibilityIdentifier().isEmpty { out.append(view) }
348+
current = view.superview
349+
depth += 1
350+
}
351+
return out
352+
}
353+
}
240354
#endif

0 commit comments

Comments
 (0)