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:
Lawrence Chen 2026-02-23 17:05:29 -08:00 committed by GitHub
commit 396942c7e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 730 additions and 13 deletions

View file

@ -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"

View file

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

View file

@ -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)
}
}
}
}

View file

@ -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() {