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:
commit
8c2f0127b1
2 changed files with 126 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue