Merge pull request #357 from manaflow-ai/task-titlebar-folder-icon-drags-window
Fix titlebar folder icon drag hit-testing
This commit is contained in:
commit
396942c7e4
4 changed files with 730 additions and 13 deletions
|
|
@ -480,6 +480,29 @@ func browserZoomShortcutTraceActionString(_ action: BrowserZoomShortcutAction?)
|
|||
}
|
||||
#endif
|
||||
|
||||
func shouldSuppressWindowMoveForFolderDrag(hitView: NSView?) -> Bool {
|
||||
var candidate = hitView
|
||||
while let view = candidate {
|
||||
if view is DraggableFolderNSView {
|
||||
return true
|
||||
}
|
||||
candidate = view.superview
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> Bool {
|
||||
guard event.type == .leftMouseDown,
|
||||
window.isMovable,
|
||||
let contentView = window.contentView else {
|
||||
return false
|
||||
}
|
||||
|
||||
let contentPoint = contentView.convert(event.locationInWindow, from: nil)
|
||||
let hitView = contentView.hitTest(contentPoint)
|
||||
return shouldSuppressWindowMoveForFolderDrag(hitView: hitView)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
|
||||
static var shared: AppDelegate?
|
||||
|
|
@ -575,6 +598,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
}
|
||||
method_exchangeImplementations(originalMethod, swizzledMethod)
|
||||
}()
|
||||
private static let didInstallWindowSendEventSwizzle: Void = {
|
||||
let targetClass: AnyClass = NSWindow.self
|
||||
let originalSelector = #selector(NSWindow.sendEvent(_:))
|
||||
let swizzledSelector = #selector(NSWindow.cmux_sendEvent(_:))
|
||||
guard let originalMethod = class_getInstanceMethod(targetClass, originalSelector),
|
||||
let swizzledMethod = class_getInstanceMethod(targetClass, swizzledSelector) else {
|
||||
return
|
||||
}
|
||||
method_exchangeImplementations(originalMethod, swizzledMethod)
|
||||
}()
|
||||
|
||||
#if DEBUG
|
||||
private var didSetupJumpUnreadUITest = false
|
||||
|
|
@ -1410,6 +1443,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = false
|
||||
window.isMovable = false
|
||||
window.center()
|
||||
window.contentView = NSHostingView(rootView: root)
|
||||
|
||||
|
|
@ -2179,6 +2213,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
static func installWindowResponderSwizzlesForTesting() {
|
||||
_ = didInstallWindowKeyEquivalentSwizzle
|
||||
_ = didInstallWindowFirstResponderSwizzle
|
||||
_ = didInstallWindowSendEventSwizzle
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
@ -2196,6 +2231,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
private func installWindowResponderSwizzles() {
|
||||
_ = Self.didInstallWindowKeyEquivalentSwizzle
|
||||
_ = Self.didInstallWindowFirstResponderSwizzle
|
||||
_ = Self.didInstallWindowSendEventSwizzle
|
||||
}
|
||||
|
||||
private func installShortcutMonitor() {
|
||||
|
|
@ -4710,6 +4746,36 @@ private extension NSWindow {
|
|||
return cmux_makeFirstResponder(responder)
|
||||
}
|
||||
|
||||
@objc func cmux_sendEvent(_ event: NSEvent) {
|
||||
guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event),
|
||||
let contentView = self.contentView else {
|
||||
cmux_sendEvent(event)
|
||||
return
|
||||
}
|
||||
|
||||
let contentPoint = contentView.convert(event.locationInWindow, from: nil)
|
||||
let hitView = contentView.hitTest(contentPoint)
|
||||
let previousMovableState = isMovable
|
||||
if previousMovableState {
|
||||
isMovable = false
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
dlog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableState)")
|
||||
#endif
|
||||
|
||||
cmux_sendEvent(event)
|
||||
|
||||
if previousMovableState {
|
||||
isMovable = previousMovableState
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
dlog("window.sendEvent.folderDown restore nowMovable=\(isMovable)")
|
||||
#endif
|
||||
}
|
||||
|
||||
@objc func cmux_performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
#if DEBUG
|
||||
let frType = self.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
|
|
|
|||
|
|
@ -1732,6 +1732,7 @@ struct ContentView: View {
|
|||
WindowDragHandleView()
|
||||
|
||||
TitlebarLeadingInsetReader(inset: $titlebarLeadingInset)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if isFullScreen && !sidebarState.isVisible {
|
||||
|
|
@ -1747,6 +1748,7 @@ struct ContentView: View {
|
|||
.font(.system(size: 13, weight: .bold))
|
||||
.foregroundColor(fakeTitlebarTextColor)
|
||||
.lineLimit(1)
|
||||
.allowsHitTesting(false)
|
||||
|
||||
Spacer()
|
||||
|
||||
|
|
@ -1759,9 +1761,6 @@ struct ContentView: View {
|
|||
.frame(height: titlebarPadding)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
NSApp.keyWindow?.zoom(nil)
|
||||
}
|
||||
.background(fakeTitlebarBackground)
|
||||
.overlay(alignment: .bottom) {
|
||||
Rectangle()
|
||||
|
|
@ -2179,6 +2178,9 @@ struct ContentView: View {
|
|||
// Do not make the entire background draggable; it interferes with drag gestures
|
||||
// like sidebar tab reordering in multi-window mode.
|
||||
window.isMovableByWindowBackground = false
|
||||
// Keep the window immovable by default so titlebar controls (like the folder icon)
|
||||
// cannot accidentally initiate native window drags.
|
||||
window.isMovable = false
|
||||
window.styleMask.insert(.fullSizeContentView)
|
||||
|
||||
// Track this window for fullscreen notifications
|
||||
|
|
@ -7329,9 +7331,21 @@ private struct DraggableFolderIconRepresentable: NSViewRepresentable {
|
|||
}
|
||||
}
|
||||
|
||||
private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
||||
final class DraggableFolderNSView: NSView, NSDraggingSource {
|
||||
private final class FolderIconImageView: NSImageView {
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
}
|
||||
|
||||
var directory: String
|
||||
private var imageView: NSImageView!
|
||||
private var imageView: FolderIconImageView!
|
||||
private var previousWindowMovableState: Bool?
|
||||
private weak var suppressedWindow: NSWindow?
|
||||
private var hasActiveDragSession = false
|
||||
private var didArmWindowDragSuppression = false
|
||||
|
||||
private func formatPoint(_ point: NSPoint) -> String {
|
||||
String(format: "(%.1f,%.1f)", point.x, point.y)
|
||||
}
|
||||
|
||||
init(directory: String) {
|
||||
self.directory = directory
|
||||
|
|
@ -7347,8 +7361,10 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
NSSize(width: 16, height: 16)
|
||||
}
|
||||
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
private func setupImageView() {
|
||||
imageView = NSImageView()
|
||||
imageView = FolderIconImageView()
|
||||
imageView.imageScaling = .scaleProportionallyDown
|
||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(imageView)
|
||||
|
|
@ -7373,9 +7389,40 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
return context == .outsideApplication ? [.copy, .link] : .copy
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
||||
hasActiveDragSession = false
|
||||
restoreWindowMovableStateIfNeeded()
|
||||
#if DEBUG
|
||||
dlog("folder.dragStart dir=\(directory)")
|
||||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||||
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
|
||||
dlog("folder.dragEnd dir=\(directory) operation=\(operation.rawValue) screen=\(formatPoint(screenPoint)) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
guard bounds.contains(point) else { return nil }
|
||||
maybeDisableWindowDraggingEarly(trigger: "hitTest")
|
||||
let hit = super.hitTest(point)
|
||||
#if DEBUG
|
||||
let hitDesc = hit.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let imageHit = (hit === imageView)
|
||||
let wasMovable = previousWindowMovableState.map(String.init) ?? "nil"
|
||||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||||
dlog("folder.hitTest point=\(formatPoint(point)) hit=\(hitDesc) imageViewHit=\(imageHit) returning=DraggableFolderNSView wasMovable=\(wasMovable) nowMovable=\(nowMovable)")
|
||||
#endif
|
||||
return self
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
maybeDisableWindowDraggingEarly(trigger: "mouseDown")
|
||||
hasActiveDragSession = false
|
||||
#if DEBUG
|
||||
let localPoint = convert(event.locationInWindow, from: nil)
|
||||
let responderDesc = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
|
||||
let wasMovable = previousWindowMovableState.map(String.init) ?? "nil"
|
||||
let nowMovable = window.map { String($0.isMovable) } ?? "nil"
|
||||
let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil"
|
||||
dlog("folder.mouseDown dir=\(directory) point=\(formatPoint(localPoint)) firstResponder=\(responderDesc) wasMovable=\(wasMovable) nowMovable=\(nowMovable) windowOrigin=\(windowOrigin)")
|
||||
#endif
|
||||
let fileURL = URL(fileURLWithPath: directory)
|
||||
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
|
||||
|
|
@ -7384,7 +7431,19 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
iconImage.size = NSSize(width: 32, height: 32)
|
||||
draggingItem.setDraggingFrame(bounds, contents: iconImage)
|
||||
|
||||
beginDraggingSession(with: [draggingItem], event: event, source: self)
|
||||
let session = beginDraggingSession(with: [draggingItem], event: event, source: self)
|
||||
hasActiveDragSession = true
|
||||
#if DEBUG
|
||||
let itemCount = session.draggingPasteboard.pasteboardItems?.count ?? 0
|
||||
dlog("folder.dragStart dir=\(directory) pasteboardItems=\(itemCount)")
|
||||
#endif
|
||||
}
|
||||
|
||||
override func mouseUp(with event: NSEvent) {
|
||||
super.mouseUp(with: event)
|
||||
if !hasActiveDragSession {
|
||||
restoreWindowMovableStateIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
override func rightMouseDown(with event: NSEvent) {
|
||||
|
|
@ -7453,6 +7512,59 @@ private final class DraggableFolderNSView: NSView, NSDraggingSource {
|
|||
// Open "Computer" view in Finder (shows all volumes)
|
||||
NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true))
|
||||
}
|
||||
|
||||
private func restoreWindowMovableStateIfNeeded() {
|
||||
guard didArmWindowDragSuppression || previousWindowMovableState != nil else { return }
|
||||
let targetWindow = suppressedWindow ?? window
|
||||
let depthAfter = endWindowDragSuppression(window: targetWindow)
|
||||
restoreWindowDragging(window: targetWindow, previousMovableState: previousWindowMovableState)
|
||||
self.previousWindowMovableState = nil
|
||||
self.suppressedWindow = nil
|
||||
self.didArmWindowDragSuppression = false
|
||||
#if DEBUG
|
||||
let nowMovable = targetWindow.map { String($0.isMovable) } ?? "nil"
|
||||
dlog("folder.dragSuppression restore depth=\(depthAfter) nowMovable=\(nowMovable)")
|
||||
#endif
|
||||
}
|
||||
|
||||
private func maybeDisableWindowDraggingEarly(trigger: String) {
|
||||
guard !didArmWindowDragSuppression else { return }
|
||||
guard let eventType = NSApp.currentEvent?.type,
|
||||
eventType == .leftMouseDown || eventType == .leftMouseDragged else {
|
||||
return
|
||||
}
|
||||
guard let currentWindow = window else { return }
|
||||
|
||||
didArmWindowDragSuppression = true
|
||||
suppressedWindow = currentWindow
|
||||
let suppressionDepth = beginWindowDragSuppression(window: currentWindow) ?? 0
|
||||
if currentWindow.isMovable {
|
||||
previousWindowMovableState = temporarilyDisableWindowDragging(window: currentWindow)
|
||||
} else {
|
||||
previousWindowMovableState = nil
|
||||
}
|
||||
#if DEBUG
|
||||
let wasMovable = previousWindowMovableState.map(String.init) ?? "nil"
|
||||
let nowMovable = String(currentWindow.isMovable)
|
||||
dlog(
|
||||
"folder.dragSuppression trigger=\(trigger) event=\(eventType) depth=\(suppressionDepth) wasMovable=\(wasMovable) nowMovable=\(nowMovable)"
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? {
|
||||
guard let window else { return nil }
|
||||
let wasMovable = window.isMovable
|
||||
if wasMovable {
|
||||
window.isMovable = false
|
||||
}
|
||||
return wasMovable
|
||||
}
|
||||
|
||||
func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) {
|
||||
guard let window, let previousMovableState else { return }
|
||||
window.isMovable = previousMovableState
|
||||
}
|
||||
|
||||
/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested
|
||||
|
|
@ -7534,11 +7646,16 @@ private struct SidebarVisualEffectBackground: NSViewRepresentable {
|
|||
|
||||
|
||||
/// Reads the leading inset required to clear traffic lights + left titlebar accessories.
|
||||
final class TitlebarLeadingInsetPassthroughView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||
}
|
||||
|
||||
private struct TitlebarLeadingInsetReader: NSViewRepresentable {
|
||||
@Binding var inset: CGFloat
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
let view = TitlebarLeadingInsetPassthroughView()
|
||||
view.setFrameSize(.zero)
|
||||
return view
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,187 @@
|
|||
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 {
|
||||
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 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
|
||||
}
|
||||
|
||||
/// A transparent view that enables dragging the window when clicking in empty titlebar space.
|
||||
/// This lets us keep `window.isMovableByWindowBackground = false` so drags in the app content
|
||||
/// (e.g. sidebar tab reordering) don't move the whole window.
|
||||
|
|
@ -14,8 +195,53 @@ struct WindowDragHandleView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
private final class DraggableView: NSView {
|
||||
override var mouseDownCanMoveWindow: Bool { true }
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { self }
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4576,6 +4576,314 @@ final class WindowBrowserHostViewTests: XCTestCase {
|
|||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowDragHandleHitTests: XCTestCase {
|
||||
private final class CapturingView: NSView {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
bounds.contains(point) ? self : nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class HostContainerView: NSView {}
|
||||
private final class PassiveHostContainerView: NSView {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
guard bounds.contains(point) else { return nil }
|
||||
return super.hitTest(point) ?? self
|
||||
}
|
||||
}
|
||||
|
||||
func testDragHandleCapturesHitWhenNoSiblingClaimsPoint() {
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
||||
let dragHandle = NSView(frame: container.bounds)
|
||||
container.addSubview(dragHandle)
|
||||
|
||||
XCTAssertTrue(
|
||||
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle),
|
||||
"Empty titlebar space should drag the window"
|
||||
)
|
||||
}
|
||||
|
||||
func testDragHandleYieldsWhenSiblingClaimsPoint() {
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
||||
let dragHandle = NSView(frame: container.bounds)
|
||||
container.addSubview(dragHandle)
|
||||
|
||||
let folderIconHost = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
|
||||
container.addSubview(folderIconHost)
|
||||
|
||||
XCTAssertFalse(
|
||||
windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle),
|
||||
"Interactive titlebar controls should receive the mouse event"
|
||||
)
|
||||
XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle))
|
||||
}
|
||||
|
||||
func testDragHandleIgnoresHiddenSiblingWhenResolvingHit() {
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
||||
let dragHandle = NSView(frame: container.bounds)
|
||||
container.addSubview(dragHandle)
|
||||
|
||||
let hidden = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
|
||||
hidden.isHidden = true
|
||||
container.addSubview(hidden)
|
||||
|
||||
XCTAssertTrue(windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle))
|
||||
}
|
||||
|
||||
func testDragHandleDoesNotCaptureOutsideBounds() {
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
||||
let dragHandle = NSView(frame: container.bounds)
|
||||
container.addSubview(dragHandle)
|
||||
|
||||
XCTAssertFalse(windowDragHandleShouldCaptureHit(NSPoint(x: 240, y: 18), in: dragHandle))
|
||||
}
|
||||
|
||||
func testPassiveHostingTopHitClassification() {
|
||||
XCTAssertTrue(windowDragHandleShouldTreatTopHitAsPassiveHost(HostContainerView(frame: .zero)))
|
||||
XCTAssertFalse(windowDragHandleShouldTreatTopHitAsPassiveHost(NSButton(frame: .zero)))
|
||||
}
|
||||
|
||||
func testDragHandleIgnoresPassiveHostSiblingHit() {
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
||||
let dragHandle = NSView(frame: container.bounds)
|
||||
container.addSubview(dragHandle)
|
||||
|
||||
let passiveHost = PassiveHostContainerView(frame: container.bounds)
|
||||
container.addSubview(passiveHost)
|
||||
|
||||
XCTAssertTrue(
|
||||
windowDragHandleShouldCaptureHit(NSPoint(x: 180, y: 18), in: dragHandle),
|
||||
"Passive host wrappers should not block titlebar drag capture"
|
||||
)
|
||||
}
|
||||
|
||||
func testDragHandleRespectsInteractiveChildInsidePassiveHost() {
|
||||
let container = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
|
||||
let dragHandle = NSView(frame: container.bounds)
|
||||
container.addSubview(dragHandle)
|
||||
|
||||
let passiveHost = PassiveHostContainerView(frame: container.bounds)
|
||||
let folderControl = CapturingView(frame: NSRect(x: 10, y: 10, width: 16, height: 16))
|
||||
passiveHost.addSubview(folderControl)
|
||||
container.addSubview(passiveHost)
|
||||
|
||||
XCTAssertFalse(
|
||||
windowDragHandleShouldCaptureHit(NSPoint(x: 14, y: 14), in: dragHandle),
|
||||
"Interactive controls inside passive host wrappers should still receive hits"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class DraggableFolderHitTests: XCTestCase {
|
||||
func testFolderHitTestReturnsContainerWhenInsideBounds() {
|
||||
let folderView = DraggableFolderNSView(directory: "/tmp")
|
||||
folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
|
||||
|
||||
guard let hit = folderView.hitTest(NSPoint(x: 8, y: 8)) else {
|
||||
XCTFail("Expected folder icon to capture inside hit")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(hit === folderView)
|
||||
}
|
||||
|
||||
func testFolderHitTestReturnsNilOutsideBounds() {
|
||||
let folderView = DraggableFolderNSView(directory: "/tmp")
|
||||
folderView.frame = NSRect(x: 0, y: 0, width: 16, height: 16)
|
||||
|
||||
XCTAssertNil(folderView.hitTest(NSPoint(x: 20, y: 8)))
|
||||
}
|
||||
|
||||
func testFolderIconDisablesWindowMoveBehavior() {
|
||||
let folderView = DraggableFolderNSView(directory: "/tmp")
|
||||
XCTAssertFalse(folderView.mouseDownCanMoveWindow)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase {
|
||||
func testLeadingInsetViewDoesNotParticipateInHitTesting() {
|
||||
let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
|
||||
XCTAssertNil(view.hitTest(NSPoint(x: 20, y: 10)))
|
||||
}
|
||||
|
||||
func testLeadingInsetViewCannotMoveWindowViaMouseDown() {
|
||||
let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40))
|
||||
XCTAssertFalse(view.mouseDownCanMoveWindow)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FolderWindowMoveSuppressionTests: XCTestCase {
|
||||
private func makeWindow() -> NSWindow {
|
||||
NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
}
|
||||
|
||||
func testSuppressionDisablesMovableWindow() {
|
||||
let window = makeWindow()
|
||||
window.isMovable = true
|
||||
|
||||
let previous = temporarilyDisableWindowDragging(window: window)
|
||||
|
||||
XCTAssertEqual(previous, true)
|
||||
XCTAssertFalse(window.isMovable)
|
||||
}
|
||||
|
||||
func testSuppressionPreservesAlreadyImmovableWindow() {
|
||||
let window = makeWindow()
|
||||
window.isMovable = false
|
||||
|
||||
let previous = temporarilyDisableWindowDragging(window: window)
|
||||
|
||||
XCTAssertEqual(previous, false)
|
||||
XCTAssertFalse(window.isMovable)
|
||||
}
|
||||
|
||||
func testRestoreAppliesPreviousMovableState() {
|
||||
let window = makeWindow()
|
||||
window.isMovable = false
|
||||
|
||||
restoreWindowDragging(window: window, previousMovableState: true)
|
||||
XCTAssertTrue(window.isMovable)
|
||||
|
||||
restoreWindowDragging(window: window, previousMovableState: false)
|
||||
XCTAssertFalse(window.isMovable)
|
||||
}
|
||||
|
||||
func testWindowDragSuppressionDepthLifecycle() {
|
||||
let window = makeWindow()
|
||||
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
|
||||
XCTAssertFalse(isWindowDragSuppressed(window: window))
|
||||
|
||||
XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
|
||||
XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
|
||||
XCTAssertTrue(isWindowDragSuppressed(window: window))
|
||||
|
||||
XCTAssertEqual(endWindowDragSuppression(window: window), 0)
|
||||
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
|
||||
XCTAssertFalse(isWindowDragSuppressed(window: window))
|
||||
}
|
||||
|
||||
func testWindowDragSuppressionIsReferenceCounted() {
|
||||
let window = makeWindow()
|
||||
XCTAssertEqual(beginWindowDragSuppression(window: window), 1)
|
||||
XCTAssertEqual(beginWindowDragSuppression(window: window), 2)
|
||||
XCTAssertEqual(windowDragSuppressionDepth(window: window), 2)
|
||||
XCTAssertTrue(isWindowDragSuppressed(window: window))
|
||||
|
||||
XCTAssertEqual(endWindowDragSuppression(window: window), 1)
|
||||
XCTAssertEqual(windowDragSuppressionDepth(window: window), 1)
|
||||
XCTAssertTrue(isWindowDragSuppressed(window: window))
|
||||
|
||||
XCTAssertEqual(endWindowDragSuppression(window: window), 0)
|
||||
XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)
|
||||
XCTAssertFalse(isWindowDragSuppressed(window: window))
|
||||
}
|
||||
|
||||
func testTemporaryWindowMovableEnableRestoresImmovableWindow() {
|
||||
let window = makeWindow()
|
||||
window.isMovable = false
|
||||
|
||||
let previous = withTemporaryWindowMovableEnabled(window: window) {
|
||||
XCTAssertTrue(window.isMovable)
|
||||
}
|
||||
|
||||
XCTAssertEqual(previous, false)
|
||||
XCTAssertFalse(window.isMovable)
|
||||
}
|
||||
|
||||
func testTemporaryWindowMovableEnablePreservesMovableWindow() {
|
||||
let window = makeWindow()
|
||||
window.isMovable = true
|
||||
|
||||
let previous = withTemporaryWindowMovableEnabled(window: window) {
|
||||
XCTAssertTrue(window.isMovable)
|
||||
}
|
||||
|
||||
XCTAssertEqual(previous, true)
|
||||
XCTAssertTrue(window.isMovable)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowMoveSuppressionHitPathTests: XCTestCase {
|
||||
private func makeWindowWithContentView() -> (NSWindow, NSView) {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 320, height: 180),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
|
||||
window.contentView = contentView
|
||||
return (window, contentView)
|
||||
}
|
||||
|
||||
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
|
||||
guard let event = NSEvent.mouseEvent(
|
||||
with: type,
|
||||
location: location,
|
||||
modifierFlags: [],
|
||||
timestamp: ProcessInfo.processInfo.systemUptime,
|
||||
windowNumber: window.windowNumber,
|
||||
context: nil,
|
||||
eventNumber: 0,
|
||||
clickCount: 1,
|
||||
pressure: 1.0
|
||||
) else {
|
||||
fatalError("Failed to create \(type) mouse event")
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func testSuppressionHitPathRecognizesFolderView() {
|
||||
let folderView = DraggableFolderNSView(directory: "/tmp")
|
||||
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: folderView))
|
||||
}
|
||||
|
||||
func testSuppressionHitPathRecognizesDescendantOfFolderView() {
|
||||
let folderView = DraggableFolderNSView(directory: "/tmp")
|
||||
let child = NSView(frame: .zero)
|
||||
folderView.addSubview(child)
|
||||
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(hitView: child))
|
||||
}
|
||||
|
||||
func testSuppressionHitPathIgnoresUnrelatedViews() {
|
||||
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: NSView(frame: .zero)))
|
||||
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(hitView: nil))
|
||||
}
|
||||
|
||||
func testSuppressionEventPathRecognizesFolderHitInsideWindow() {
|
||||
let (window, contentView) = makeWindowWithContentView()
|
||||
window.isMovable = true
|
||||
let folderView = DraggableFolderNSView(directory: "/tmp")
|
||||
folderView.frame = NSRect(x: 10, y: 10, width: 16, height: 16)
|
||||
contentView.addSubview(folderView)
|
||||
|
||||
let event = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 14, y: 14), window: window)
|
||||
|
||||
XCTAssertTrue(shouldSuppressWindowMoveForFolderDrag(window: window, event: event))
|
||||
}
|
||||
|
||||
func testSuppressionEventPathRejectsNonFolderAndNonMouseDownEvents() {
|
||||
let (window, contentView) = makeWindowWithContentView()
|
||||
window.isMovable = true
|
||||
let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 40, height: 40))
|
||||
contentView.addSubview(plainView)
|
||||
|
||||
let down = makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 20, y: 20), window: window)
|
||||
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: down))
|
||||
|
||||
let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window)
|
||||
XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class GhosttySurfaceOverlayTests: XCTestCase {
|
||||
func testInactiveOverlayVisibilityTracksRequestedState() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue