From bcb3d204cc826d22eb27c09fa23371f7716602f8 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 12 Nov 2026 14:51:07 -0500 Subject: [PATCH] Inspect: off-screen children don't count toward the per-node child cap (#249) A visible-only walk took a node's first 100 children and only then skipped the off-screen ones, so a long list scrolled to its middle listed none of its visible rows. Skipped children no longer count toward maxChildren, and the walk reads up to ten times the cap, as PR #212 did. Adds the longlist fixture: 400 rows, all Accessibility children, scrolled past the first 190. Why: native2/Sources/DesktopMac/Elements.swift | the per-node child cap counts only children a visible-only walk kept; skipping happens per child after its descriptor read, so the scan is bounded separately (10x the cap) to keep a huge list's read cost finite Co-Authored-By: Claude Opus 5.5 --- native2/Sources/DesktopMac/Elements.swift | 31 +++++++----- native2/fixture/longlist.swift | 60 +++++++++++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 native2/fixture/longlist.swift diff --git a/native2/Sources/DesktopMac/Elements.swift b/native2/Sources/DesktopMac/Elements.swift index 4fb5b1c..8b4f073 100644 --- a/native2/Sources/DesktopMac/Elements.swift +++ b/native2/Sources/DesktopMac/Elements.swift @@ -299,11 +299,14 @@ private struct Walk { return (result, false) } - mutating func process(_ element: AXUIElement, depth: Int) { - guard ContinuousClock.now < deadline else { tree.deadlineReached = true; return } - guard depth < budget.maxDepth else { tree.maxDepthReached = true; return } - guard tree.elements.count < budget.maxElements else { tree.maxElementCountReached = true; return } - guard !visited.contains(where: { CFEqual($0, element) }) else { return } + /// Reads one element and its subtree. Returns false when it lay off screen and was skipped, so it doesn't count + /// toward its parent's child cap. + @discardableResult + mutating func process(_ element: AXUIElement, depth: Int) -> Bool { + guard ContinuousClock.now < deadline else { tree.deadlineReached = true; return true } + guard depth < budget.maxDepth else { tree.maxDepthReached = true; return true } + guard tree.elements.count < budget.maxElements else { tree.maxElementCountReached = true; return true } + guard !visited.contains(where: { CFEqual($0, element) }) else { return true } visited.append(element) AXUIElementSetMessagingTimeout(element, timeout() ?? 0.01) @@ -313,13 +316,14 @@ private struct Walk { descriptor.isFocused != true { tree.offscreenSkipped += 1 - return + return false } if let descriptor = read, !incomplete { emit(descriptor, element, depth: depth) } let outer = clip if let descriptor = read, !incomplete { clip = VisibleClip.narrowed(clip, role: descriptor.role, frame: descriptor.frame) } processChildren(element, depth: depth) clip = outer + return true } mutating func emit(_ descriptor: Descriptor, _ element: AXUIElement, depth: Int) { @@ -385,14 +389,19 @@ private struct Walk { mutating func processChildren(_ element: AXUIElement, depth: Int) { let read = children(element) if read.incomplete { tree.incompleteAccessibilityRead = true } - if read.elements.count > budget.maxChildren { tree.maxChildrenPerNodeReached = true } - let limited = read.elements.prefix(budget.maxChildren) - for (index, child) in limited.enumerated() { - process(child, depth: depth + 1) - guard index < limited.count - 1 else { break } + // A visible-only walk doesn't count children it skipped as off screen toward the cap, so a long list scrolled to + // its middle still lists the rows on screen (#249). It reads at most ten times the cap, since each skipped + // child still costs a read. + let scanned = read.elements.prefix(clip == nil ? budget.maxChildren : budget.maxChildren * 10) + var counted = 0 + for (index, child) in scanned.enumerated() { + if counted == budget.maxChildren { tree.maxChildrenPerNodeReached = true; return } + if process(child, depth: depth + 1) { counted += 1 } + guard index < scanned.count - 1 else { break } if tree.elements.count >= budget.maxElements { tree.maxElementCountReached = true; return } if ContinuousClock.now >= deadline { tree.deadlineReached = true; return } } + if read.elements.count > scanned.count { tree.maxChildrenPerNodeReached = true } } } diff --git a/native2/fixture/longlist.swift b/native2/fixture/longlist.swift new file mode 100644 index 0000000..681a5f4 --- /dev/null +++ b/native2/fixture/longlist.swift @@ -0,0 +1,60 @@ +import AppKit +// The long list fixture (#249): a scroll area whose content is a stack of 400 rows, every one of them an +// Accessibility child of the stack whether or not it is scrolled into view, scrolled at launch past its first 190 +// rows (the stack's spacing puts about row 209 at the top). An inspection must list the rows on screen though +// hundreds of off-screen siblings come before them. Build it with `swiftc -O native2/fixture/longlist.swift -o native2/.build/longlist`; pass a row number to scroll elsewhere. + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +let menu = NSMenu() +let appItem = NSMenuItem() +appItem.submenu = NSMenu(title: "longlist") +appItem.submenu!.addItem(withTitle: "Quit longlist", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") +menu.addItem(appItem) +app.mainMenu = menu + +let window = NSWindow(contentRect: NSRect(x: 240, y: 240, width: 360, height: 420), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false) +window.title = "Long List" +let stack = NSStackView() +stack.orientation = .vertical +stack.alignment = .leading +stack.spacing = 0 +stack.edgeInsets = NSEdgeInsets(top: 0, left: 12, bottom: 0, right: 12) +let rowHeight: CGFloat = 28 +for index in 0..<400 { + let row = NSButton(title: "Row \(index)", target: nil, action: nil) + row.bezelStyle = .inline + row.translatesAutoresizingMaskIntoConstraints = false + row.heightAnchor.constraint(equalToConstant: rowHeight - 4).isActive = true + stack.addArrangedSubview(row) + stack.setCustomSpacing(4, after: row) +} +let flipped = FlippedView() +flipped.addSubview(stack) +stack.translatesAutoresizingMaskIntoConstraints = false +NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: flipped.topAnchor), stack.leadingAnchor.constraint(equalTo: flipped.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: flipped.trailingAnchor), stack.bottomAnchor.constraint(equalTo: flipped.bottomAnchor), +]) +let scroll = NSScrollView(frame: window.contentView!.bounds) +scroll.autoresizingMask = [.width, .height] +scroll.hasVerticalScroller = true +scroll.documentView = flipped +flipped.translatesAutoresizingMaskIntoConstraints = false +flipped.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor).isActive = true +window.contentView!.addSubview(scroll) +window.makeKeyAndOrderFront(nil) +window.layoutIfNeeded() + +let top = CommandLine.arguments.dropFirst().first.flatMap(Int.init) ?? 190 +if let target = stack.arrangedSubviews.dropFirst(top).first { + scroll.contentView.scroll(to: NSPoint(x: 0, y: target.frame.minY)) + scroll.reflectScrolledClipView(scroll.contentView) +} +print("ready, row \(top) at the top") +fflush(stdout) +app.run() + +final class FlippedView: NSView { + override var isFlipped: Bool { true } +}