From 5083006761e1ec046e458b31766c18d13c928c01 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 01:30:46 -0700 Subject: [PATCH 01/17] Add minimal tab strip portal routing regression test --- cmuxTests/PortalTabDragRoutingTests.swift | 60 ++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) 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), From 9566f28936d691586bb9aaffcb603a3393df574f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 01:30:56 -0700 Subject: [PATCH 02/17] Let terminal portal defer to registered minimal tab strip --- Sources/TerminalWindowPortal.swift | 3 +++ 1 file changed, 3 insertions(+) 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 } From 2d5517d7c10e98a567b5e1103a75e92f3e088b6b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 03:04:42 -0700 Subject: [PATCH 03/17] Add minimal titlebar implicit drag regression test --- cmuxTests/WindowAndDragTests.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index e65988241e4a..4a715e2d4a93 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1590,6 +1590,14 @@ 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" + ) + } } From e79417400654f71f4a8a26f59d5abc316366307d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 03:09:35 -0700 Subject: [PATCH 04/17] Prevent minimal pane tabs from moving window --- Sources/App/CmuxMainWindow.swift | 1 + Sources/App/ShortcutRoutingSupport.swift | 25 ++++++++++++++ Sources/AppDelegate.swift | 14 ++++---- cmuxTests/WindowAndDragTests.swift | 42 +++++++++++++++++++++++- vendor/bonsplit | 2 +- 5 files changed, 76 insertions(+), 8 deletions(-) diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 04901bbc7eba..8d5841acd87a 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) diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 7224ca395b4a..ad7c87a5a6cd 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -1,4 +1,5 @@ import AppKit +import Bonsplit import Foundation func browserOmnibarSelectionDeltaForControlNavigation( @@ -739,3 +740,27 @@ 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, + window.isMovable 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 +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c8488a416cc2..8865f2e4bf7d 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14830,8 +14830,7 @@ private extension NSWindow { cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber } - guard shouldSuppressWindowMoveForFolderDrag(window: self, event: event), - let contentView = self.contentView else { + guard let suppressionReason = windowMoveSuppressionReason(window: self, event: event) else { #if DEBUG if event.type == .keyDown { folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 @@ -14851,20 +14850,23 @@ private extension NSWindow { let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - let hitView = contentView.hitTest(contentPoint) + let hitView: NSView? = { + guard let contentView = self.contentView else { return nil } + let contentPoint = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(contentPoint) + }() let previousMovableState = temporarilyDisableWindowDragging(window: self) defer { restoreWindowDragging(window: self, previousMovableState: previousMovableState) #if DEBUG - cmuxDebugLog("window.sendEvent.folderDown restore nowMovable=\(isMovable)") + cmuxDebugLog("window.sendEvent.\(suppressionReason.rawValue) restore 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)") + cmuxDebugLog("window.sendEvent.\(suppressionReason.rawValue) suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableDescription)") #endif cmux_sendEvent(event) diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 4a715e2d4a93..25330efdd590 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) @@ -1719,6 +1719,14 @@ final class FolderWindowMoveSuppressionTests: XCTestCase { @MainActor final class WindowMoveSuppressionHitPathTests: XCTestCase { + private final class FakeBonsplitTabItemRegionView: NSView, BonsplitTabItemHitRegionProviding { + nonisolated(unsafe) var tabFrames: [CGRect] = [] + + nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { + tabFrames.contains { $0.contains(localPoint) } + } + } + private func makeWindowWithContentView() -> (NSWindow, NSView) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), @@ -1789,6 +1797,38 @@ 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 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 e1d9954a027a..e1a9e76b1e76 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit e1d9954a027aa35afe003b0ebdbf81875258e171 +Subproject commit e1a9e76b1e7655ea669b6f79f8a0eecb3e377860 From 38e476844bb341f77ed28f397676d490437d1515 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 03:16:24 -0700 Subject: [PATCH 05/17] Mark minimal tab chrome drag affordance --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index e1a9e76b1e76..f8233a412140 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit e1a9e76b1e7655ea669b6f79f8a0eecb3e377860 +Subproject commit f8233a412140f86095ceced2a2412d64f8f5f9c9 From a7c020c687cb9f776717543c4c264dcd8389b9d8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 03:34:59 -0700 Subject: [PATCH 06/17] Keep pane tab drag suppression active until mouse up --- Sources/App/ShortcutRoutingSupport.swift | 22 +++++++ Sources/AppDelegate.swift | 21 ++++-- Sources/WindowDragHandleView.swift | 82 ++++++++++++++++++++++++ cmuxTests/WindowAndDragTests.swift | 38 +++++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index ad7c87a5a6cd..5a87d66b1a17 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -764,3 +764,25 @@ func windowMoveSuppressionReason(window: NSWindow, event: NSEvent) -> WindowMove } return nil } + +func windowMoveSuppressionReasonForEvent(window: NSWindow, event: NSEvent) -> WindowMoveSuppressionReason? { + if let activeReason = activeWindowMoveSuppressionSequenceReason(window: window) { + if event.type == .leftMouseUp || event.type == .leftMouseDragged || (NSEvent.pressedMouseButtons & 0x1) != 0 { + ensureWindowMoveSuppressionSequenceIsImmovable(window: window) + return activeReason + } + + if (NSEvent.pressedMouseButtons & 0x1) == 0 { + _ = 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 8865f2e4bf7d..e7fe0abeb925 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14830,7 +14830,7 @@ private extension NSWindow { cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber } - guard let suppressionReason = windowMoveSuppressionReason(window: self, event: event) else { + guard let suppressionReason = windowMoveSuppressionReasonForEvent(window: self, event: event) else { #if DEBUG if event.type == .keyDown { folderGuardMs = (ProcessInfo.processInfo.systemUptime - folderGuardStart) * 1000.0 @@ -14849,24 +14849,33 @@ private extension NSWindow { } let originalDispatchStart = event.type == .keyDown ? ProcessInfo.processInfo.systemUptime : 0 #endif + let shouldFinishSuppression = shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: self, event: event) let hitView: NSView? = { guard let contentView = self.contentView else { return nil } let contentPoint = contentView.convert(event.locationInWindow, from: nil) return contentView.hitTest(contentPoint) }() - let previousMovableState = temporarilyDisableWindowDragging(window: self) defer { - restoreWindowDragging(window: self, previousMovableState: previousMovableState) + let finishedReason: WindowMoveSuppressionReason? + if shouldFinishSuppression { + finishedReason = finishWindowMoveSuppressionSequence(window: self) + } else { + finishedReason = nil + } #if DEBUG - cmuxDebugLog("window.sendEvent.\(suppressionReason.rawValue) restore nowMovable=\(isMovable)") + if shouldFinishSuppression { + cmuxDebugLog("window.sendEvent.\(finishedReason?.rawValue ?? suppressionReason.rawValue) finish nowMovable=\(isMovable)") + } else { + cmuxDebugLog("window.sendEvent.\(suppressionReason.rawValue) 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.\(suppressionReason.rawValue) suppress=1 hit=\(hitDesc) wasMovable=\(previousMovableDescription)") + let depth = windowDragSuppressionDepth(window: self) + cmuxDebugLog("window.sendEvent.\(suppressionReason.rawValue) suppress=1 hit=\(hitDesc) movable=\(isMovable) depth=\(depth)") #endif cmux_sendEvent(event) diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 84a60b4a0a37..b631d612090d 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -230,8 +230,20 @@ 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()) +} + +private final class WindowMoveSuppressionSequenceState { + let reason: WindowMoveSuppressionReason + let previousMovableState: Bool + + init(reason: WindowMoveSuppressionReason, previousMovableState: Bool) { + self.reason = reason + self.previousMovableState = previousMovableState + } } func beginWindowDragSuppression(window: NSWindow?) -> Int? { @@ -282,6 +294,76 @@ 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 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 + _ = beginWindowDragSuppression(window: window) + if window.isMovable { + window.isMovable = false + } + 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 +} + @discardableResult func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { guard let window else { return nil } diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 25330efdd590..70136cc3ed46 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1814,6 +1814,44 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { 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(windowMoveSuppressionReasonForEvent(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(windowMoveSuppressionReasonForEvent(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(windowMoveSuppressionReasonForEvent(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 testBonsplitPaneTabSuppressionLeavesEmptyTabChromeDraggable() { let (window, contentView) = makeWindowWithContentView() window.isMovable = true From 735dde1dccd7e1902d13f1e46c1f99eff674f5cd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 11:54:35 -0700 Subject: [PATCH 07/17] Keep titlebar drag handle out of pane tabs --- Sources/WindowDragHandleView.swift | 13 +++++++ cmuxTests/WindowAndDragTests.swift | 62 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index b631d612090d..7cdec345a5c5 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -907,6 +907,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/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 70136cc3ed46..9186d3a53687 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -642,6 +642,18 @@ final class WindowDragHandleHitTests: XCTestCase { } } + private final class FakeBonsplitTabItemRegionView: NSView, BonsplitTabItemHitRegionProviding { + nonisolated(unsafe) var tabFrames: [CGRect] = [] + + nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { + tabFrames.contains { $0.contains(localPoint) } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + } + private final class SidebarActionRegionView: NSView, MinimalModeSidebarControlActionHitRegionProviding { nonisolated(unsafe) var config = TitlebarControlsStyle.classic.config @@ -902,6 +914,56 @@ 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 testDragHandleSkipsForeignLeftMouseDownDuringLaunch() { let point = NSPoint(x: 180, y: 18) let window = NSWindow( From 24fa6d407f1f48d0c0b7dfc87d16949e5cc04641 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 11:57:05 -0700 Subject: [PATCH 08/17] Reset window move suppression on fresh mouse down --- Sources/App/ShortcutRoutingSupport.swift | 14 +++++++---- cmuxTests/WindowAndDragTests.swift | 32 ++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 5a87d66b1a17..8d6adca0990a 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -765,14 +765,18 @@ func windowMoveSuppressionReason(window: NSWindow, event: NSEvent) -> WindowMove return nil } -func windowMoveSuppressionReasonForEvent(window: NSWindow, event: NSEvent) -> WindowMoveSuppressionReason? { +func windowMoveSuppressionReasonForEvent( + window: NSWindow, + event: NSEvent, + pressedMouseButtons: Int = NSEvent.pressedMouseButtons +) -> WindowMoveSuppressionReason? { if let activeReason = activeWindowMoveSuppressionSequenceReason(window: window) { - if event.type == .leftMouseUp || event.type == .leftMouseDragged || (NSEvent.pressedMouseButtons & 0x1) != 0 { + if event.type == .leftMouseDown { + _ = finishWindowMoveSuppressionSequence(window: window) + } else if event.type == .leftMouseUp || event.type == .leftMouseDragged || (pressedMouseButtons & 0x1) != 0 { ensureWindowMoveSuppressionSequenceIsImmovable(window: window) return activeReason - } - - if (NSEvent.pressedMouseButtons & 0x1) == 0 { + } else { _ = finishWindowMoveSuppressionSequence(window: window) } } diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 9186d3a53687..2ed7083d4caf 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1914,6 +1914,38 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { XCTAssertNil(activeWindowMoveSuppressionSequenceReason(window: window)) } + 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(windowMoveSuppressionReasonForEvent(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( + windowMoveSuppressionReasonForEvent( + 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 From 73583e5ac726ec9dd2acdfe2c78b42cecf03ec94 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 15:36:31 -0700 Subject: [PATCH 09/17] Keep suppression cleanup on active sequence path --- Sources/AppDelegate.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index e7fe0abeb925..edcf4dd50b89 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14830,7 +14830,9 @@ private extension NSWindow { cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber } - guard let suppressionReason = windowMoveSuppressionReasonForEvent(window: self, event: event) else { + let suppressionReason = windowMoveSuppressionReasonForEvent(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 @@ -14851,11 +14853,13 @@ private extension NSWindow { #endif let shouldFinishSuppression = shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: self, event: event) + #if DEBUG let hitView: NSView? = { guard let contentView = self.contentView else { return nil } let contentPoint = contentView.convert(event.locationInWindow, from: nil) return contentView.hitTest(contentPoint) }() + #endif defer { let finishedReason: WindowMoveSuppressionReason? if shouldFinishSuppression { @@ -14864,10 +14868,11 @@ private extension NSWindow { finishedReason = nil } #if DEBUG + let reasonDescription = finishedReason?.rawValue ?? suppressionReason?.rawValue ?? "activeSequence" if shouldFinishSuppression { - cmuxDebugLog("window.sendEvent.\(finishedReason?.rawValue ?? suppressionReason.rawValue) finish nowMovable=\(isMovable)") + cmuxDebugLog("window.sendEvent.\(reasonDescription) finish nowMovable=\(isMovable)") } else { - cmuxDebugLog("window.sendEvent.\(suppressionReason.rawValue) keepSuppressed nowMovable=\(isMovable)") + cmuxDebugLog("window.sendEvent.\(reasonDescription) keepSuppressed nowMovable=\(isMovable)") } #endif } @@ -14875,7 +14880,8 @@ private extension NSWindow { #if DEBUG let hitDesc = hitView.map { String(describing: type(of: $0)) } ?? "nil" let depth = windowDragSuppressionDepth(window: self) - cmuxDebugLog("window.sendEvent.\(suppressionReason.rawValue) suppress=1 hit=\(hitDesc) movable=\(isMovable) depth=\(depth)") + let reasonDescription = suppressionReason?.rawValue ?? "activeSequence" + cmuxDebugLog("window.sendEvent.\(reasonDescription) suppress=1 hit=\(hitDesc) movable=\(isMovable) depth=\(depth)") #endif cmux_sendEvent(event) From 0180861ffdd9c731677eb30c9f15437220716920 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 15:43:26 -0700 Subject: [PATCH 10/17] Use shared hit testing for suppression debug logs --- Sources/AppDelegate.swift | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index edcf4dd50b89..db68b743b7e5 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -14853,13 +14853,9 @@ private extension NSWindow { #endif let shouldFinishSuppression = shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: self, event: event) - #if DEBUG - let hitView: NSView? = { - guard let contentView = self.contentView else { return nil } - let contentPoint = contentView.convert(event.locationInWindow, from: nil) - return contentView.hitTest(contentPoint) - }() - #endif +#if DEBUG + let hitView = Self.cmuxHitViewForEventDispatch(in: self, event: event) +#endif defer { let finishedReason: WindowMoveSuppressionReason? if shouldFinishSuppression { From df19bcabad8e2afb139696a468b8f4d69c0e1fd6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 15:51:59 -0700 Subject: [PATCH 11/17] Clean up window move suppression state --- Sources/WindowDragHandleView.swift | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 7cdec345a5c5..afc24b04e7d0 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -236,7 +236,9 @@ private enum WindowDragHandleAssociatedObjectKeys { static let moveSuppressionSequence = UnsafeRawPointer(Unmanaged.passUnretained(moveSuppressionSequenceToken).toOpaque()) } -private final class WindowMoveSuppressionSequenceState { +// 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 @@ -364,16 +366,6 @@ func finishWindowMoveSuppressionSequence(window: NSWindow?) -> WindowMoveSuppres return state.reason } -@discardableResult -func temporarilyDisableWindowDragging(window: NSWindow?) -> Bool? { - guard let window else { return nil } - let previousMovableState = window.isMovable - if previousMovableState { - window.isMovable = false - } - return previousMovableState -} - func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { guard let window, let previousMovableState else { return } From 21cbeffed07b81b3f3859bc867dbddf2adba8ff3 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 16:06:48 -0700 Subject: [PATCH 12/17] Keep minimal tab pixels out of window drag overlay --- cmuxTests/WindowAndDragTests.swift | 35 ++++++++++++++++++++++++++++++ vendor/bonsplit | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 2ed7083d4caf..38c1e4b684c9 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -964,6 +964,41 @@ final class WindowDragHandleHitTests: XCTestCase { ) } + 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( diff --git a/vendor/bonsplit b/vendor/bonsplit index f8233a412140..ba8042127a4b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit f8233a412140f86095ceced2a2412d64f8f5f9c9 +Subproject commit ba8042127a4b0cf1976d8bea9652c526ff4366b7 From 4cb077dea2f09c9db1b8e326a31130171fcecb14 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 19:01:33 -0700 Subject: [PATCH 13/17] Make main window movement explicit for pane tabs --- Sources/App/CmuxMainWindow.swift | 6 +++ Sources/App/ShortcutRoutingSupport.swift | 3 +- Sources/AppDelegate.swift | 8 +--- Sources/ContentView.swift | 10 ++--- cmuxTests/WindowAndDragTests.swift | 54 ++++++++++++++++++++++++ 5 files changed, 66 insertions(+), 15 deletions(-) diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 8d5841acd87a..4b1d2ea228bc 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -28,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 ba3883882523..cf4d97d45106 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -764,8 +764,7 @@ enum WindowMoveSuppressionReason: String { } func shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: NSWindow, event: NSEvent) -> Bool { - guard event.type == .leftMouseDown, - window.isMovable else { + guard event.type == .leftMouseDown else { return false } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index da057f546c2d..42cf27b304fe 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -7251,13 +7251,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) 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/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 61c9fdb7f751..f08f16eb2597 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1695,6 +1695,36 @@ final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { "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" + ) + } } @@ -1949,6 +1979,30 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { 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(windowMoveSuppressionReasonForEvent(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 From a08f374c58df288f52aa6b362e7d6ee45984f7b7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 20:12:51 -0700 Subject: [PATCH 14/17] Update Bonsplit minimal tab hit region --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index dc78f5ce7787..02db30f579e6 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit dc78f5ce778745868c54bd2cef510a6177bfcd7a +Subproject commit 02db30f579e6308b12bf7521fc5faa7ac430ed16 From dc1e31c205f44e197f31173670631ab213e193f0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 20:48:16 -0700 Subject: [PATCH 15/17] test: share Bonsplit tab region helper --- cmuxTests/WindowAndDragTests.swift | 34 ++++++++++++------------------ 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index f08f16eb2597..3326eccf3ff2 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -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() { @@ -642,18 +656,6 @@ final class WindowDragHandleHitTests: XCTestCase { } } - private final class FakeBonsplitTabItemRegionView: NSView, BonsplitTabItemHitRegionProviding { - nonisolated(unsafe) var tabFrames: [CGRect] = [] - - nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { - tabFrames.contains { $0.contains(localPoint) } - } - - override func hitTest(_ point: NSPoint) -> NSView? { - nil - } - } - private final class SidebarActionRegionView: NSView, MinimalModeSidebarControlActionHitRegionProviding { nonisolated(unsafe) var config = TitlebarControlsStyle.classic.config @@ -1846,14 +1848,6 @@ final class FolderWindowMoveSuppressionTests: XCTestCase { @MainActor final class WindowMoveSuppressionHitPathTests: XCTestCase { - private final class FakeBonsplitTabItemRegionView: NSView, BonsplitTabItemHitRegionProviding { - nonisolated(unsafe) var tabFrames: [CGRect] = [] - - nonisolated func containsBonsplitTabItemHit(localPoint: NSPoint) -> Bool { - tabFrames.contains { $0.contains(localPoint) } - } - } - private func makeWindowWithContentView() -> (NSWindow, NSView) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), From 2df920f8bbbf3919d531b4c3a21f5414efe41f35 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 18 May 2026 21:42:56 -0700 Subject: [PATCH 16/17] Fix window move suppression cleanup --- Sources/App/ShortcutRoutingSupport.swift | 2 +- Sources/AppDelegate.swift | 2 +- Sources/WindowDragHandleView.swift | 3 +++ cmuxTests/WindowAndDragTests.swift | 34 +++++++++++++++++++----- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index cf4d97d45106..62969f121c14 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -781,7 +781,7 @@ func windowMoveSuppressionReason(window: NSWindow, event: NSEvent) -> WindowMove return nil } -func windowMoveSuppressionReasonForEvent( +func beginOrContinueWindowMoveSuppressionSequenceForEvent( window: NSWindow, event: NSEvent, pressedMouseButtons: Int = NSEvent.pressedMouseButtons diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 42cf27b304fe..529606c55463 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15110,7 +15110,7 @@ private extension NSWindow { cmuxFirstResponderGuardContextWindowNumber = previousContextWindowNumber } - let suppressionReason = windowMoveSuppressionReasonForEvent(window: self, event: event) + let suppressionReason = beginOrContinueWindowMoveSuppressionSequenceForEvent(window: self, event: event) let hasActiveSuppressionSequence = activeWindowMoveSuppressionSequenceReason(window: self) != nil guard suppressionReason != nil || hasActiveSuppressionSequence else { #if DEBUG diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index afc24b04e7d0..06f09bb2c09d 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -377,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) diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 3326eccf3ff2..39423533bd13 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1790,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) @@ -1950,7 +1969,7 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { let tabPoint = tabRegion.convert(NSPoint(x: 28, y: 15), to: nil) let down = makeMouseEvent(type: .leftMouseDown, location: tabPoint, window: window) - XCTAssertEqual(windowMoveSuppressionReasonForEvent(window: window, event: down), .bonsplitPaneTabDrag) + XCTAssertEqual(beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: down), .bonsplitPaneTabDrag) XCTAssertFalse(window.isMovable) XCTAssertTrue(isWindowDragSuppressed(window: window)) XCTAssertEqual(activeWindowMoveSuppressionSequenceReason(window: window), .bonsplitPaneTabDrag) @@ -1960,12 +1979,15 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { location: NSPoint(x: contentView.bounds.midX, y: contentView.bounds.midY), window: window ) - XCTAssertEqual(windowMoveSuppressionReasonForEvent(window: window, event: draggedOutsideTab), .bonsplitPaneTabDrag) + 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(windowMoveSuppressionReasonForEvent(window: window, event: up), .bonsplitPaneTabDrag) + XCTAssertEqual(beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: up), .bonsplitPaneTabDrag) XCTAssertTrue(shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: window, event: up)) XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window), .bonsplitPaneTabDrag) XCTAssertTrue(window.isMovable) @@ -1988,7 +2010,7 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { let tabPoint = tabRegion.convert(NSPoint(x: 28, y: 15), to: nil) let down = makeMouseEvent(type: .leftMouseDown, location: tabPoint, window: window) - XCTAssertEqual(windowMoveSuppressionReasonForEvent(window: window, event: down), .bonsplitPaneTabDrag) + XCTAssertEqual(beginOrContinueWindowMoveSuppressionSequenceForEvent(window: window, event: down), .bonsplitPaneTabDrag) XCTAssertFalse(window.isMovable) XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window), .bonsplitPaneTabDrag) XCTAssertFalse( @@ -2011,13 +2033,13 @@ final class WindowMoveSuppressionHitPathTests: XCTestCase { let tabPoint = tabRegion.convert(NSPoint(x: 28, y: 15), to: nil) let down = makeMouseEvent(type: .leftMouseDown, location: tabPoint, window: window) - XCTAssertEqual(windowMoveSuppressionReasonForEvent(window: window, event: down), .bonsplitPaneTabDrag) + 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( - windowMoveSuppressionReasonForEvent( + beginOrContinueWindowMoveSuppressionSequenceForEvent( window: window, event: nextDown, pressedMouseButtons: 1 From 36fc880fdd8a070a545fbb1febb04405543c00b6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 19 May 2026 17:09:38 -0700 Subject: [PATCH 17/17] Update Bonsplit minimal tab drag hit testing --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 02db30f579e6..f7a39b2b3037 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 02db30f579e6308b12bf7521fc5faa7ac430ed16 +Subproject commit f7a39b2b3037822b42c2cd149072279afbbaaadd