Fix titlebar text dragging while preserving folder icon drag
This commit is contained in:
parent
aeabcdd583
commit
0d98db7277
4 changed files with 656 additions and 15 deletions
|
|
@ -1,23 +1,184 @@
|
|||
import AppKit
|
||||
import Bonsplit
|
||||
import SwiftUI
|
||||
|
||||
private func windowDragHandleFormatPoint(_ point: NSPoint) -> String {
|
||||
String(format: "(%.1f,%.1f)", point.x, point.y)
|
||||
}
|
||||
|
||||
private var windowDragSuppressionDepthKey: UInt8 = 0
|
||||
|
||||
func beginWindowDragSuppression(window: NSWindow?) -> Int? {
|
||||
guard let window else { return nil }
|
||||
let current = windowDragSuppressionDepth(window: window)
|
||||
let next = current + 1
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&windowDragSuppressionDepthKey,
|
||||
NSNumber(value: next),
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
return next
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func endWindowDragSuppression(window: NSWindow?) -> Int {
|
||||
guard let window else { return 0 }
|
||||
let current = windowDragSuppressionDepth(window: window)
|
||||
let next = max(0, current - 1)
|
||||
if next == 0 {
|
||||
objc_setAssociatedObject(window, &windowDragSuppressionDepthKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
} else {
|
||||
objc_setAssociatedObject(
|
||||
window,
|
||||
&windowDragSuppressionDepthKey,
|
||||
NSNumber(value: next),
|
||||
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
|
||||
)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func windowDragSuppressionDepth(window: NSWindow?) -> Int {
|
||||
guard let window,
|
||||
let value = objc_getAssociatedObject(window, &windowDragSuppressionDepthKey) as? NSNumber else {
|
||||
return 0
|
||||
}
|
||||
return value.intValue
|
||||
}
|
||||
|
||||
func isWindowDragSuppressed(window: NSWindow?) -> Bool {
|
||||
windowDragSuppressionDepth(window: window) > 0
|
||||
}
|
||||
|
||||
/// Temporarily enables window movability for explicit drag-handle drags, then
|
||||
/// restores the previous movability state after `body` finishes.
|
||||
@discardableResult
|
||||
func withTemporaryWindowMovableEnabled(window: NSWindow?, _ body: () -> Void) -> Bool? {
|
||||
guard let window else {
|
||||
body()
|
||||
return nil
|
||||
}
|
||||
|
||||
let previousMovableState = window.isMovable
|
||||
if !previousMovableState {
|
||||
window.isMovable = true
|
||||
}
|
||||
defer {
|
||||
if window.isMovable != previousMovableState {
|
||||
window.isMovable = previousMovableState
|
||||
}
|
||||
}
|
||||
|
||||
body()
|
||||
return previousMovableState
|
||||
}
|
||||
|
||||
private enum WindowDragHandleHitTestState {
|
||||
static var isResolvingTopHit = false
|
||||
}
|
||||
|
||||
/// SwiftUI/AppKit hosting wrappers can appear as the top hit even for empty
|
||||
/// titlebar space. Treat those as pass-through so explicit sibling checks decide.
|
||||
func windowDragHandleShouldTreatTopHitAsPassiveHost(_ view: NSView) -> Bool {
|
||||
let className = String(describing: type(of: view))
|
||||
if className.contains("HostContainerView")
|
||||
|| className.contains("AppKitWindowHostingView")
|
||||
|| className.contains("NSHostingView") {
|
||||
return true
|
||||
}
|
||||
if let window = view.window, view === window.contentView {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Returns whether the titlebar drag handle should capture a hit at `point`.
|
||||
/// We only claim the hit when no sibling view already handles it, so interactive
|
||||
/// controls layered in the titlebar (e.g. proxy folder icon) keep their gestures.
|
||||
func windowDragHandleShouldCaptureHit(_ point: NSPoint, in dragHandleView: NSView) -> Bool {
|
||||
guard dragHandleView.bounds.contains(point) else { return false }
|
||||
guard let superview = dragHandleView.superview else { return true }
|
||||
if isWindowDragSuppressed(window: dragHandleView.window) {
|
||||
#if DEBUG
|
||||
let depth = windowDragSuppressionDepth(window: dragHandleView.window)
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false reason=suppressed depth=\(depth) point=\(windowDragHandleFormatPoint(point))"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
guard dragHandleView.bounds.contains(point) else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=false reason=outside point=\(windowDragHandleFormatPoint(point))")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
guard let superview = dragHandleView.superview else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=true reason=noSuperview point=\(windowDragHandleFormatPoint(point))")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
if let window = dragHandleView.window,
|
||||
let contentView = window.contentView,
|
||||
!WindowDragHandleHitTestState.isResolvingTopHit {
|
||||
let pointInWindow = dragHandleView.convert(point, to: nil)
|
||||
let pointInContent = contentView.convert(pointInWindow, from: nil)
|
||||
|
||||
WindowDragHandleHitTestState.isResolvingTopHit = true
|
||||
let topHit = contentView.hitTest(pointInContent)
|
||||
WindowDragHandleHitTestState.isResolvingTopHit = false
|
||||
|
||||
if let topHit {
|
||||
let ownsTopHit = topHit === dragHandleView || topHit.isDescendant(of: dragHandleView)
|
||||
let isPassiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(topHit)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=\(ownsTopHit) strategy=windowTopHit point=\(windowDragHandleFormatPoint(point)) top=\(type(of: topHit)) passiveHost=\(isPassiveHostHit)"
|
||||
)
|
||||
#endif
|
||||
if ownsTopHit {
|
||||
return true
|
||||
}
|
||||
if !isPassiveHostHit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let siblingCount = superview.subviews.count
|
||||
#endif
|
||||
|
||||
for sibling in superview.subviews.reversed() {
|
||||
guard sibling !== dragHandleView else { continue }
|
||||
guard !sibling.isHidden, sibling.alphaValue > 0 else { continue }
|
||||
|
||||
let pointInSibling = dragHandleView.convert(point, to: sibling)
|
||||
if sibling.hitTest(pointInSibling) != nil {
|
||||
if let hitView = sibling.hitTest(pointInSibling) {
|
||||
let passiveHostHit = windowDragHandleShouldTreatTopHitAsPassiveHost(hitView)
|
||||
if passiveHostHit {
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=defer point=\(windowDragHandleFormatPoint(point)) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=true"
|
||||
)
|
||||
#endif
|
||||
continue
|
||||
}
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTest capture=false point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount) sibling=\(type(of: sibling)) hit=\(type(of: hitView)) passiveHost=false"
|
||||
)
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.hitTest capture=true point=\(windowDragHandleFormatPoint(point)) siblingCount=\(siblingCount)")
|
||||
#endif
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
@ -34,9 +195,53 @@ struct WindowDragHandleView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
private final class DraggableView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
windowDragHandleShouldCaptureHit(point, in: self) ? self : nil
|
||||
let shouldCapture = windowDragHandleShouldCaptureHit(point, in: self)
|
||||
#if DEBUG
|
||||
dlog(
|
||||
"titlebar.dragHandle.hitTestResult capture=\(shouldCapture) point=\(windowDragHandleFormatPoint(point)) window=\(window != nil)"
|
||||
)
|
||||
#endif
|
||||
return shouldCapture ? self : nil
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
#if DEBUG
|
||||
let point = convert(event.locationInWindow, from: nil)
|
||||
let depth = windowDragSuppressionDepth(window: window)
|
||||
dlog(
|
||||
"titlebar.dragHandle.mouseDown point=\(windowDragHandleFormatPoint(point)) clickCount=\(event.clickCount) depth=\(depth)"
|
||||
)
|
||||
#endif
|
||||
|
||||
if event.clickCount >= 2 {
|
||||
window?.zoom(nil)
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownDoubleClick zoom=1")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
guard !isWindowDragSuppressed(window: window) else {
|
||||
#if DEBUG
|
||||
dlog("titlebar.dragHandle.mouseDownIgnored reason=suppressed")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if let window {
|
||||
let previousMovableState = withTemporaryWindowMovableEnabled(window: window) {
|
||||
window.performDrag(with: event)
|
||||
}
|
||||
#if DEBUG
|
||||
let restored = previousMovableState.map { String($0) } ?? "nil"
|
||||
dlog("titlebar.dragHandle.mouseDownComplete restoredMovable=\(restored) nowMovable=\(window.isMovable)")
|
||||
#endif
|
||||
} else {
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue