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 } +}