Merge pull request #485 from manaflow-ai/task-cmuxterm-macos-cv-window-drag-handle-capture-hit

Fix drag-handle top-hit reentrancy state per window
This commit is contained in:
Lawrence Chen 2026-02-25 13:14:11 -08:00 committed by GitHub
commit 8c2f0127b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 126 additions and 4 deletions

View file

@ -41,6 +41,7 @@ func performStandardTitlebarDoubleClick(window: NSWindow?) -> Bool {
}
private var windowDragSuppressionDepthKey: UInt8 = 0
private var windowDragTopHitResolutionDepthKey: UInt8 = 0
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
guard let window else { return nil }
@ -119,7 +120,51 @@ func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) ->
}
private enum WindowDragHandleHitTestState {
static var isResolvingTopHit = false
static func depth(window: NSWindow?) -> Int {
guard let window,
let value = objc_getAssociatedObject(window, &windowDragTopHitResolutionDepthKey) as? NSNumber else {
return 0
}
return value.intValue
}
static func begin(window: NSWindow?) {
guard let window else { return }
let next = depth(window: window) + 1
objc_setAssociatedObject(
window,
&windowDragTopHitResolutionDepthKey,
NSNumber(value: next),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
@discardableResult
static func end(window: NSWindow?) -> Int {
guard let window else { return 0 }
let current = depth(window: window)
let next = max(0, current - 1)
if next == 0 {
objc_setAssociatedObject(
window,
&windowDragTopHitResolutionDepthKey,
nil,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
} else {
objc_setAssociatedObject(
window,
&windowDragTopHitResolutionDepthKey,
NSNumber(value: next),
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
return next
}
static func isResolvingTopHit(window: NSWindow?) -> Bool {
depth(window: window) > 0
}
}
/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty
@ -178,13 +223,15 @@ func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSVie
if let window = dragHandleView.window,
let contentView = window.contentView,
!WindowDragHandleHitTestState.isResolvingTopHit {
!WindowDragHandleHitTestState.isResolvingTopHit(window: window) {
let pointInWindow = dragHandleView.convert(point, to: nil)
let pointInContent = contentView.convert(pointInWindow, from: nil)
WindowDragHandleHitTestState.isResolvingTopHit = true
WindowDragHandleHitTestState.begin(window: window)
defer {
WindowDragHandleHitTestState.end(window: window)
}
let topHit = contentView.hitTest(pointInContent)
WindowDragHandleHitTestState.isResolvingTopHit = false
if let topHit {
let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView)

View file

@ -5541,6 +5541,20 @@ final class WindowDragHandleHitTests: XCTestCase {
}
private final class HostContainerView: NSView {}
private final class BlockingTopHitContainerView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class PassThroughProbeView: NSView {
var onHitTest: (() -> Void)?
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point) else { return nil }
onHitTest?()
return nil
}
}
private final class PassiveHostContainerView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
guard bounds.contains(point) else { return nil }
@ -5628,6 +5642,67 @@ final class WindowDragHandleHitTests: XCTestCase {
"Interactive controls inside passive host wrappers should still receive hits"
)
}
func testTopHitResolutionStateIsScopedPerWindow() {
let point = NSPoint(x: 100, y: 18)
let outerWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { outerWindow.orderOut(nil) }
guard let outerContentView = outerWindow.contentView else {
XCTFail("Expected outer content view")
return
}
let outerContainer = NSView(frame: outerContentView.bounds)
outerContainer.autoresizingMask = [.width, .height]
outerContentView.addSubview(outerContainer)
let outerDragHandle = NSView(frame: outerContainer.bounds)
outerDragHandle.autoresizingMask = [.width, .height]
outerContainer.addSubview(outerDragHandle)
let nestedWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 220, height: 36),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { nestedWindow.orderOut(nil) }
guard let nestedContentView = nestedWindow.contentView else {
XCTFail("Expected nested content view")
return
}
let nestedContainer = BlockingTopHitContainerView(frame: nestedContentView.bounds)
nestedContainer.autoresizingMask = [.width, .height]
nestedContentView.addSubview(nestedContainer)
let nestedDragHandle = NSView(frame: nestedContainer.bounds)
nestedDragHandle.autoresizingMask = [.width, .height]
nestedContainer.addSubview(nestedDragHandle)
XCTAssertFalse(
windowDragHandleShouldCaptureHit(point, in: nestedDragHandle),
"Nested window drag handle should be blocked by top-hit titlebar container"
)
var nestedCaptureResult: Bool?
let probe = PassThroughProbeView(frame: outerContainer.bounds)
probe.autoresizingMask = [.width, .height]
probe.onHitTest = {
nestedCaptureResult = windowDragHandleShouldCaptureHit(point, in: nestedDragHandle)
}
outerContainer.addSubview(probe)
_ = windowDragHandleShouldCaptureHit(point, in: outerDragHandle)
XCTAssertEqual(
nestedCaptureResult,
false,
"Top-hit recursion in one window must not disable top-hit resolution in another window"
)
}
}
@MainActor