diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 04901bbc7eba..4b1d2ea228bc 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -7,6 +7,7 @@ final class MainWindowHostingView: NSHostingView { override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } override var safeAreaRect: NSRect { bounds } override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + override var mouseDownCanMoveWindow: Bool { false } required init(rootView: Content) { super.init(rootView: rootView) @@ -27,6 +28,12 @@ final class MainWindowHostingView: NSHostingView { } } +@MainActor +func configureCmuxMainWindowDragBehavior(_ window: NSWindow) { + window.isMovableByWindowBackground = false + window.isMovable = false +} + @MainActor final class CmuxMainWindow: NSWindow { private var isSoftHiddenForVisibilityController = false diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 6c833d7ed402..62969f121c14 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import Foundation func browserOmnibarSelectionDeltaForControlNavigation( @@ -756,3 +757,52 @@ func shouldSuppressWindowMoveForFolderDrag(window: NSWindow, event: NSEvent) -> let hitView = contentView.hitTest(contentPoint) return shouldSuppressWindowMoveForFolderDrag(hitView: hitView) } + +enum WindowMoveSuppressionReason: String { + case folderDrag + case bonsplitPaneTabDrag +} + +func shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: NSWindow, event: NSEvent) -> Bool { + guard event.type == .leftMouseDown else { + return false + } + + return BonsplitTabItemHitRegionRegistry.containsWindowPoint(event.locationInWindow, in: window) +} + +func windowMoveSuppressionReason(window: NSWindow, event: NSEvent) -> WindowMoveSuppressionReason? { + if shouldSuppressWindowMoveForFolderDrag(window: window, event: event) { + return .folderDrag + } + if shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: window, event: event) { + return .bonsplitPaneTabDrag + } + return nil +} + +func beginOrContinueWindowMoveSuppressionSequenceForEvent( + window: NSWindow, + event: NSEvent, + pressedMouseButtons: Int = NSEvent.pressedMouseButtons +) -> WindowMoveSuppressionReason? { + if let activeReason = activeWindowMoveSuppressionSequenceReason(window: window) { + if event.type == .leftMouseDown { + _ = finishWindowMoveSuppressionSequence(window: window) + } else if event.type == .leftMouseUp || event.type == .leftMouseDragged || (pressedMouseButtons & 0x1) != 0 { + ensureWindowMoveSuppressionSequenceIsImmovable(window: window) + return activeReason + } else { + _ = finishWindowMoveSuppressionSequence(window: window) + } + } + + guard let reason = windowMoveSuppressionReason(window: window, event: event) else { + return nil + } + return beginWindowMoveSuppressionSequence(window: window, reason: reason) +} + +func shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: NSWindow, event: NSEvent) -> Bool { + activeWindowMoveSuppressionSequenceReason(window: window) != nil && event.type == .leftMouseUp +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c13c8a5e7010..f0646bd7a2d5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7326,13 +7326,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // cmux persists and restores main windows itself. Disable AppKit window // restoration so the OS cannot resurrect stale duplicate main windows. window.isRestorable = false - window.isMovableByWindowBackground = false - // Keep background dragging disabled so app content gestures and titlebar - // controls still receive clicks, while the OS-level movable flag lets - // macOS tiling and window-management tools such as Swish treat cmux as - // a movable/resizable window. Empty titlebar drags are routed through - // WindowDragHandleView instead of background dragging. - window.isMovable = true + configureCmuxMainWindowDragBehavior(window) let explicitInitialFrame = restoredFrame ?? persistedGeometryFrame if let explicitInitialFrame { window.setFrame(explicitInitialFrame, display: false) @@ -15187,8 +15181,9 @@ private extension NSWindow { cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber } - guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), - let contentView = self.contentView else { + let suppressionReason = beginOrContinueWindowMoveSuppressionSequenceForEvent(window: self, event: event) + let hasActiveSuppressionSequence = activeWindowMoveSuppressionSequenceReason(window: self) != nil + guard suppressionReason != nil || hasActiveSuppressionSequence else { #if DEBUG if event.type == .keyDown { folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 @@ -15207,21 +15202,33 @@ private extension NSWindow { } let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif + let shouldFinishSuppression = shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: self, event: event) - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) - let previousMovableState = temporarilyDisableWindowDragging(window: self) +#if DEBUG + let hitView = Self.cmuxHitViewForEventDispatch(in: self, event: event) +#endif defer { - restoreWindowDragging(window: self, previousMovableState: previousMovableState) + let finishedReason: WindowMoveSuppressionReason? + if shouldFinishSuppression { + finishedReason = finishWindowMoveSuppressionSequence(window: self) + } else { + finishedReason = nil + } #if DEBUG - cmuxDebugLog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + let reasonDescription = finishedReason?.rawValue ?? suppressionReason?.rawValue ?? "activeSequence" + if shouldFinishSuppression { + cmuxDebugLog("window.sendEvent.\(reasonDescription) finish nowMovable=\(isMovable)") + } else { + cmuxDebugLog("window.sendEvent.\(reasonDescription) keepSuppressed nowMovable=\(isMovable)") + } #endif } #if DEBUG let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" - let previousMovableDescription = previousMovableState.map { String($0) } ?? "nil" - cmuxDebugLog("window.sendEvent.folderDown suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableDescription)") + let depth = windowDragSuppressionDepth(window: self) + let reasonDescription = suppressionReason?.rawValue ?? "activeSequence" + cmuxDebugLog("window.sendEvent.\(reasonDescription) suppress=1 hit=\(hitDesc) movable=\(isMovable) depth=\(depth)") #endif cmux_sendEvent(event) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b42ff91c488f..9da7d2611a2e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3195,12 +3195,10 @@ struct ContentView: View { window.isRestorable = false setMinimalModeSidebarTitlebarControlsAvailable(sidebarState.isVisible, in: window) window.titlebarAppearsTransparent = true - // Keep background dragging disabled so app content gestures and - // minimal-mode titlebar controls still receive clicks, while the - // window itself stays movable for macOS tiling and third-party - // window managers. - window.isMovableByWindowBackground = false - window.isMovable = true + // Native AppKit titlebar dragging steals pane-tab drags in minimal + // mode. Keep the main window immovable by default; explicit chrome + // drag zones temporarily enable performDrag for real app moves. + configureCmuxMainWindowDragBehavior(window) window.styleMask.insert(.fullSizeContentView) // Track this window for fullscreen notifications diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index c6cbe71cedfb..3758a2e1652c 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -218,6 +218,9 @@ final class WindowTerminalHostView: NSView { eventType: eventType ) else { return false } guard decision.result else { return false } + if decision.registryHit { + return true + } return hostedTerminalHitView(at: point) == nil } diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 84a60b4a0a37..06f09bb2c09d 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -230,8 +230,22 @@ func handleTitlebarDoubleClick( private enum WindowDragHandleAssociatedObjectKeys { private static let suppressionDepthToken = NSObject() + private static let moveSuppressionSequenceToken = NSObject() static let suppressionDepth = UnsafeRawPointer(Unmanaged.passUnretained(suppressionDepthToken).toOpaque()) + static let moveSuppressionSequence = UnsafeRawPointer(Unmanaged.passUnretained(moveSuppressionSequenceToken).toOpaque()) +} + +// Stored as an NSWindow associated object and touched only from AppKit's +// main-thread mouse-event dispatch path. +private final class WindowMoveSuppressionSequenceState: @unchecked Sendable { + let reason: WindowMoveSuppressionReason + let previousMovableState: Bool + + init(reason: WindowMoveSuppressionReason, previousMovableState: Bool) { + self.reason = reason + self.previousMovableState = previousMovableState + } } func beginWindowDragSuppression(window: NSWindow?) -> Int? { @@ -282,14 +296,74 @@ func isWindowDragSuppressed(window: NSWindow?) -> Bool { windowDragSuppressionDepth(window: window) > 0 } +func activeWindowMoveSuppressionSequenceReason(window: NSWindow?) -> WindowMoveSuppressionReason? { + guard let window, + let state = objc_getAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence + ) as? WindowMoveSuppressionSequenceState else { + return nil + } + return state.reason +} + @discardableResult -func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { +func beginWindowMoveSuppressionSequence( + window: NSWindow?, + reason: WindowMoveSuppressionReason +) -> WindowMoveSuppressionReason? { guard let window else { return nil } + if let activeReason = activeWindowMoveSuppressionSequenceReason(window: window) { + ensureWindowMoveSuppressionSequenceIsImmovable(window: window) + return activeReason + } + let previousMovableState = window.isMovable - if previousMovableState { + _ = beginWindowDragSuppression(window: window) + if window.isMovable { window.isMovable = false } - return previousMovableState + let state = WindowMoveSuppressionSequenceState( + reason: reason, + previousMovableState: previousMovableState + ) + objc_setAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence, + state, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + return reason +} + +func ensureWindowMoveSuppressionSequenceIsImmovable(window: NSWindow?) { + guard let window, + activeWindowMoveSuppressionSequenceReason(window: window) != nil, + window.isMovable else { + return + } + window.isMovable = false +} + +@discardableResult +func finishWindowMoveSuppressionSequence(window: NSWindow?) -> WindowMoveSuppressionReason? { + guard let window, + let state = objc_getAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence + ) as? WindowMoveSuppressionSequenceState else { + return nil + } + + objc_setAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence, + nil, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + _ = endWindowDragSuppression(window: window) + restoreWindowDragging(window: window, previousMovableState: state.previousMovableState) + return state.reason } func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { @@ -303,6 +377,9 @@ func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { @discardableResult func clearWindowDragSuppression(window: NSWindow?) -> Int { guard let window else { return 0 } + if activeWindowMoveSuppressionSequenceReason(window: window) != nil { + _ = finishWindowMoveSuppressionSequence(window: window) + } var depth = windowDragSuppressionDepth(window: window) while depth > 0 { depth = endWindowDragSuppression(window: window) @@ -825,6 +902,19 @@ func windowDragHandleShouldCaptureHit( ) -> Bool { let dragHandleWindow = dragHandleView.window + if let dragHandleWindow, + eventType == .leftMouseDown { + let windowPoint = dragHandleView.convert(point, to: nil) + if BonsplitTabItemHitRegionRegistry.containsWindowPoint(windowPoint, in: dragHandleWindow) { + #if DEBUG + cmuxDebugLog( + "titlebar.dragHandle.hitTest capture=false reason=bonsplitPaneTab point=\(windowDragHandleFormatPoint(point))" + ) + #endif + return false + } + } + // Suppression recovery runs first so stale depth is cleared even for // passive events — the associated-object reads/writes here are pure ObjC // runtime calls and cannot trigger Swift exclusive-access violations. diff --git a/cmuxTests/PortalTabDragRoutingTests.swift b/cmuxTests/PortalTabDragRoutingTests.swift index 68e62d20ee0e..97f49bb54124 100644 --- a/cmuxTests/PortalTabDragRoutingTests.swift +++ b/cmuxTests/PortalTabDragRoutingTests.swift @@ -1,6 +1,6 @@ import XCTest import AppKit -import Bonsplit +@testable import Bonsplit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -22,6 +22,14 @@ final class PortalTabDragRoutingTests: XCTestCase { } } + private func makeHostedTerminalView(frame: NSRect) -> GhosttySurfaceScrollView { + let surfaceView = GhosttyNSView(frame: frame) + let hostedView = GhosttySurfaceScrollView(surfaceView: surfaceView) + hostedView.frame = frame + hostedView.autoresizingMask = [.width, .height] + return hostedView + } + private struct TabStripPassThroughFixture { let host: WindowTerminalHostView let pointInHost: NSPoint @@ -111,6 +119,56 @@ final class PortalTabDragRoutingTests: XCTestCase { ) } + func testHostViewTrustsRegisteredTabStripRegionAboveHostedTerminal() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView, + let container = contentView.superview else { + XCTFail("Expected window content container") + return + } + + let tabStripHeight: CGFloat = 44 + let tabStrip = NSView( + frame: NSRect( + x: 0, + y: contentView.bounds.maxY - tabStripHeight, + width: contentView.bounds.width, + height: tabStripHeight + ) + ) + tabStrip.autoresizingMask = [.width, .minYMargin] + contentView.addSubview(tabStrip) + BonsplitTabBarHitRegionRegistry.register(tabStrip) + defer { BonsplitTabBarHitRegionRegistry.unregister(tabStrip) } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowTerminalHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + let hostedTerminal = makeHostedTerminalView(frame: host.bounds) + host.addSubview(hostedTerminal) + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let titlebarBandHeight = max(28, min(72, window.frame.height - window.contentLayoutRect.height)) + let pointInContent = NSPoint( + x: contentView.bounds.midX, + y: contentView.bounds.maxY - titlebarBandHeight - 8 + ) + let pointInWindow = contentView.convert(pointInContent, to: nil) + let pointInHost = host.convert(pointInWindow, from: nil) + let event = makeMouseEvent(type: .leftMouseDown, at: pointInWindow, window: window) + + XCTAssertNil( + host.performHitTest(at: pointInHost, currentEvent: event), + "Terminal portal should defer to the registered minimal tab strip even when a hosted terminal view overlaps it" + ) + } + func testHostViewPassesThroughUnderlyingTabStripWithoutCurrentEvent() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index adacb591fcf5..39423533bd13 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -7,7 +7,7 @@ import SwiftUI import UniformTypeIdentifiers import WebKit import ObjectiveC.runtime -import Bonsplit +@testable import Bonsplit import UserNotifications #if canImport(cmux_DEV) @@ -16,6 +16,20 @@ import UserNotifications @testable import cmux #endif +private final class FakeBonsplitTabItemRegionView: NSView, BonsplitTabItemHitRegionProviding { + nonisolated(unsafe) var tabFrames: [CGRect] = [] + + deinit {} + + nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { + tabFrames.contains { $0.contains(localPoint) } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + @MainActor final class WindowGlassEffectTests: XCTestCase { func testRemoveRestoresOriginalContentHierarchy() { @@ -902,6 +916,91 @@ final class WindowDragHandleHitTests: XCTestCase { XCTAssertTrue(windowDragHandleShouldCaptureHit(point, in: dragHandle, eventType: .leftMouseDown)) } + func testDragHandleNeverCapturesRegisteredBonsplitPaneTab() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 120), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let container = NSView(frame: contentView.bounds) + contentView.addSubview(container) + + let dragHandle = NSView(frame: container.bounds) + container.addSubview(dragHandle) + + let tabRegion = FakeBonsplitTabItemRegionView(frame: NSRect(x: 20, y: 82, width: 220, height: 30)) + tabRegion.tabFrames = [CGRect(x: 8, y: 0, width: 96, height: 30)] + container.addSubview(tabRegion) + BonsplitTabItemHitRegionRegistry.register(tabRegion) + defer { BonsplitTabItemHitRegionRegistry.unregister(tabRegion) } + + let tabWindowPoint = tabRegion.convert(NSPoint(x: 48, y: 15), to: nil) + let tabDragHandlePoint = dragHandle.convert(tabWindowPoint, from: nil) + XCTAssertFalse( + windowDragHandleShouldCaptureHit( + tabDragHandlePoint, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: window + ), + "A visible pane tab must own its mouse-down; the titlebar drag handle must not turn it into a window drag" + ) + + let emptyWindowPoint = tabRegion.convert(NSPoint(x: 180, y: 15), to: nil) + let emptyDragHandlePoint = dragHandle.convert(emptyWindowPoint, from: nil) + XCTAssertTrue( + windowDragHandleShouldCaptureHit( + emptyDragHandlePoint, + in: dragHandle, + eventType: .leftMouseDown, + eventWindow: window + ), + "Empty tab-strip chrome should remain available for app-window dragging" + ) + } + + func testTabBarEmptyChromeOverlayNeverCapturesRegisteredBonsplitPaneTabWhenFrameCacheIsEmpty() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 120), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let dragZone = TabBarDragZoneView.DragNSView(frame: NSRect(x: 0, y: 72, width: 320, height: 30)) + dragZone.hitRegion = .trailingEmptyChrome(tabFrames: [], reservedTrailingWidth: 48) + dragZone.hitTestEventTypeOverride = .leftMouseDown + contentView.addSubview(dragZone) + + let tabRegion = FakeBonsplitTabItemRegionView(frame: NSRect(x: 10, y: 72, width: 90, height: 30)) + tabRegion.tabFrames = [tabRegion.bounds] + contentView.addSubview(tabRegion) + BonsplitTabItemHitRegionRegistry.register(tabRegion) + defer { BonsplitTabItemHitRegionRegistry.unregister(tabRegion) } + + XCTAssertNil( + dragZone.hitTest(NSPoint(x: 40, y: 15)), + "The empty-chrome overlay must not turn a pane-tab mouse-down into an app-window drag while tab frames are still populating" + ) + XCTAssertIdentical( + dragZone.hitTest(NSPoint(x: 140, y: 15)), + dragZone, + "Empty tab-strip chrome after the registered tab should still be available for app-window dragging" + ) + } + func testDragHandleSkipsForeignLeftMouseDownDuringLaunch() { let point = NSPoint(x: 180, y: 18) let window = NSWindow( @@ -1590,6 +1689,44 @@ final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { let view = TitlebarLeadingInsetPassthroughView(frame: NSRect(x: 0, y: 0, width: 200, height: 40)) XCTAssertFalse(view.mouseDownCanMoveWindow) } + + func testMainWindowHostingViewCannotMoveWindowViaMouseDown() { + let view = MainWindowHostingView(rootView: Color.clear) + XCTAssertFalse( + view.mouseDownCanMoveWindow, + "Main content must never become an implicit AppKit window-drag region; explicit titlebar chrome owns app-window dragging" + ) + } + + func testMainWindowDragBehaviorRequiresExplicitDragZones() { + let window = CmuxMainWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + window.isMovable = true + window.isMovableByWindowBackground = true + + configureCmuxMainWindowDragBehavior(window) + + XCTAssertFalse( + window.isMovable, + "Main windows must not use native AppKit titlebar dragging because pane tabs live in the titlebar band" + ) + XCTAssertFalse(window.isMovableByWindowBackground) + + let previous = withTemporaryWindowMovableEnabled(window: window) { + XCTAssertTrue(window.isMovable) + } + + XCTAssertEqual(previous, false) + XCTAssertFalse( + window.isMovable, + "Explicit chrome drag zones may temporarily enable movement, but the main window must return to pane-tab-safe immovable state" + ) + } } @@ -1653,6 +1790,25 @@ final class FolderWindowMoveSuppressionTests: XCTestCase { XCTAssertFalse(window.isMovable) } + func testClearWindowDragSuppressionFinishesActiveMoveSequence() { + let window = makeWindow() + window.isMovable = true + + XCTAssertEqual( + beginWindowMoveSuppressionSequence(window: window, reason: .bonsplitPaneTabDrag), + .bonsplitPaneTabDrag + ) + XCTAssertFalse(window.isMovable) + XCTAssertEqual(activeWindowMoveSuppressionSequenceReason(window: window), .bonsplitPaneTabDrag) + + XCTAssertEqual(clearWindowDragSuppression(window: window), 0) + + XCTAssertNil(activeWindowMoveSuppressionSequenceReason(window: window)) + XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + XCTAssertTrue(window.isMovable) + } + func testWindowDragSuppressionDepthLifecycle() { let window = makeWindow() XCTAssertEqual(windowDragSuppressionDepth(window: window), 0) @@ -1781,6 +1937,135 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { let dragged = makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 20, y: 20), window: window) XCTAssertFalse(shouldSuppressWindowMoveForFolderDrag(window: window, event: dragged)) } + + func testBonsplitPaneTabMouseDownSuppressesWindowMove() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let tabRegion = FakeBonsplitTabItemRegionView(frame: NSRect(x: 20, y: 132, width: 240, height: 30)) + tabRegion.tabFrames = [CGRect(x: 8, y: 0, width: 96, height: 30)] + contentView.addSubview(tabRegion) + BonsplitTabItemHitRegionRegistry.register(tabRegion) + defer { BonsplitTabItemHitRegionRegistry.unregister(tabRegion) } + + let tabPoint = tabRegion.convert(NSPoint(x: 28, y: 15), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: tabPoint, window: window) + + XCTAssertTrue(shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: window, event: event)) + XCTAssertEqual(windowMoveSuppressionReason(window: window, event: event), .bonsplitPaneTabDrag) + } + + func testBonsplitPaneTabDragSequenceKeepsWindowImmovableUntilMouseUp() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let tabRegion = FakeBonsplitTabItemRegionView(frame: NSRect(x: 20, y: 132, width: 240, height: 30)) + tabRegion.tabFrames = [CGRect(x: 8, y: 0, width: 96, height: 30)] + contentView.addSubview(tabRegion) + BonsplitTabItemHitRegionRegistry.register(tabRegion) + defer { + _ = finishWindowMoveSuppressionSequence(window: window) + BonsplitTabItemHitRegionRegistry.unregister(tabRegion) + } + + let tabPoint = tabRegion.convert(NSPoint(x: 28, y: 15), to: nil) + let down = makeMouseEvent(type: .leftMouseDown, location: tabPoint, window: window) + + XCTAssertEqual(beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: down), .bonsplitPaneTabDrag) + XCTAssertFalse(window.isMovable) + XCTAssertTrue(isWindowDragSuppressed(window: window)) + XCTAssertEqual(activeWindowMoveSuppressionSequenceReason(window: window), .bonsplitPaneTabDrag) + + let draggedOutsideTab = makeMouseEvent( + type: .leftMouseDragged, + location: NSPoint(x: contentView.bounds.midX, y: contentView.bounds.midY), + window: window + ) + XCTAssertEqual( + beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: draggedOutsideTab), + .bonsplitPaneTabDrag + ) + XCTAssertFalse(window.isMovable, "Window must remain immovable for the whole tab-drag mouse sequence") + XCTAssertFalse(shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: window, event: draggedOutsideTab)) + + let up = makeMouseEvent(type: .leftMouseUp, location: tabPoint, window: window) + XCTAssertEqual(beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: up), .bonsplitPaneTabDrag) + XCTAssertTrue(shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: window, event: up)) + XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window), .bonsplitPaneTabDrag) + XCTAssertTrue(window.isMovable) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + XCTAssertNil(activeWindowMoveSuppressionSequenceReason(window: window)) + } + + func testBonsplitPaneTabSuppressionRestoresImmovableMainWindow() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = false + let tabRegion = FakeBonsplitTabItemRegionView(frame: NSRect(x: 20, y: 132, width: 240, height: 30)) + tabRegion.tabFrames = [CGRect(x: 8, y: 0, width: 96, height: 30)] + contentView.addSubview(tabRegion) + BonsplitTabItemHitRegionRegistry.register(tabRegion) + defer { + _ = finishWindowMoveSuppressionSequence(window: window) + BonsplitTabItemHitRegionRegistry.unregister(tabRegion) + } + + let tabPoint = tabRegion.convert(NSPoint(x: 28, y: 15), to: nil) + let down = makeMouseEvent(type: .leftMouseDown, location: tabPoint, window: window) + + XCTAssertEqual(beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: down), .bonsplitPaneTabDrag) + XCTAssertFalse(window.isMovable) + XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window), .bonsplitPaneTabDrag) + XCTAssertFalse( + window.isMovable, + "Tab-drag suppression must not restore native AppKit window dragging when the main window baseline is immovable" + ) + } + + func testNewMouseDownReevaluatesAfterStaleBonsplitPaneTabSuppression() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let tabRegion = FakeBonsplitTabItemRegionView(frame: NSRect(x: 20, y: 132, width: 240, height: 30)) + tabRegion.tabFrames = [CGRect(x: 8, y: 0, width: 96, height: 30)] + contentView.addSubview(tabRegion) + BonsplitTabItemHitRegionRegistry.register(tabRegion) + defer { + _ = finishWindowMoveSuppressionSequence(window: window) + BonsplitTabItemHitRegionRegistry.unregister(tabRegion) + } + + let tabPoint = tabRegion.convert(NSPoint(x: 28, y: 15), to: nil) + let down = makeMouseEvent(type: .leftMouseDown, location: tabPoint, window: window) + XCTAssertEqual(beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: down), .bonsplitPaneTabDrag) + XCTAssertFalse(window.isMovable) + + let emptyChromePoint = tabRegion.convert(NSPoint(x: 180, y: 15), to: nil) + let nextDown = makeMouseEvent(type: .leftMouseDown, location: emptyChromePoint, window: window) + XCTAssertNil( + beginOrContinueWindowMoveSuppressionSequenceForEvent( + window: window, + event: nextDown, + pressedMouseButtons: 1 + ), + "A fresh mouse-down must end stale tab suppression and re-check the actual hit target" + ) + XCTAssertTrue(window.isMovable) + XCTAssertFalse(isWindowDragSuppressed(window: window)) + XCTAssertNil(activeWindowMoveSuppressionSequenceReason(window: window)) + } + + func testBonsplitPaneTabSuppressionLeavesEmptyTabChromeDraggable() { + let (window, contentView) = makeWindowWithContentView() + window.isMovable = true + let tabRegion = FakeBonsplitTabItemRegionView(frame: NSRect(x: 20, y: 132, width: 240, height: 30)) + tabRegion.tabFrames = [CGRect(x: 8, y: 0, width: 96, height: 30)] + contentView.addSubview(tabRegion) + BonsplitTabItemHitRegionRegistry.register(tabRegion) + defer { BonsplitTabItemHitRegionRegistry.unregister(tabRegion) } + + let emptyChromePoint = tabRegion.convert(NSPoint(x: 180, y: 15), to: nil) + let event = makeMouseEvent(type: .leftMouseDown, location: emptyChromePoint, window: window) + + XCTAssertFalse(shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: window, event: event)) + XCTAssertNil(windowMoveSuppressionReason(window: window, event: event)) + } } private final class FilePreviewPDFChromeNotificationFlag: @unchecked Sendable { diff --git a/vendor/bonsplit b/vendor/bonsplit index b5dd1f03993b..f7a39b2b3037 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit b5dd1f03993b70d3aa4b079e347d557323844e67 +Subproject commit f7a39b2b3037822b42c2cd149072279afbbaaadd