diff --git a/Resources/InfoPlist.xcstrings b/Resources/InfoPlist.xcstrings index 9e50f7d4b9f9..b83fc60ac9a5 100644 --- a/Resources/InfoPlist.xcstrings +++ b/Resources/InfoPlist.xcstrings @@ -16,6 +16,102 @@ "state": "translated", "value": "cmux 内で実行中のプログラムが、パスキーとセキュリティキーを検出するために Bluetooth の使用を求めています。" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中运行的程序想要使用蓝牙来发现通行密钥和安全密钥。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中執行的程式想要使用藍牙來探索通行密鑰和安全密鑰。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 내에서 실행 중인 프로그램이 패스키와 보안 키를 찾기 위해 Bluetooth를 사용하려고 합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein in cmux ausgeführtes Programm möchte Bluetooth verwenden, um Passkeys und Sicherheitsschlüssel zu finden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un programa en ejecución dentro de cmux quiere usar Bluetooth para descubrir llaves de acceso y claves de seguridad." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un programme s'exécutant dans cmux souhaite utiliser le Bluetooth pour détecter des clés d’accès et des clés de sécurité." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un programma in esecuzione in cmux desidera usare il Bluetooth per rilevare passkey e chiavi di sicurezza." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Et program, der kører i cmux, vil gerne bruge Bluetooth til at finde adgangsnøgler og sikkerhedsnøgler." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Program działający w cmux chciałby użyć Bluetooth do wykrywania kluczy dostępu i kluczy zabezpieczeń." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Программа, запущенная в cmux, хотела бы использовать Bluetooth для обнаружения ключей доступа и ключей безопасности." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Program koji se izvršava unutar cmux želi koristiti Bluetooth za otkrivanje pristupnih ključeva i sigurnosnih ključeva." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يرغب برنامج يعمل داخل cmux في استخدام Bluetooth لاكتشاف مفاتيح المرور ومفاتيح الأمان." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Et program som kjører i cmux ønsker å bruke Bluetooth til å finne passnøkler og sikkerhetsnøkler." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Um programa em execução no cmux gostaria de usar Bluetooth para descobrir chaves de acesso e chaves de segurança." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โปรแกรมที่ทำงานภายใน cmux ต้องการใช้ Bluetooth เพื่อค้นหาพาสคีย์และคีย์ความปลอดภัย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux içinde çalışan bir program geçiş anahtarlarını ve güvenlik anahtarlarını bulmak için Bluetooth kullanmak istiyor." + } } } }, @@ -33,6 +129,102 @@ "state": "translated", "value": "cmux 内で実行中のプログラムがカメラの使用を求めています。" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中运行的程序想要使用您的摄像头。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在 cmux 中執行的程式想要使用您的相機。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "cmux 내에서 실행 중인 프로그램이 카메라를 사용하려고 합니다." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein in cmux ausgeführtes Programm möchte Ihre Kamera verwenden." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Un programa en ejecución dentro de cmux desea usar tu cámara." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un programme s'exécutant dans cmux souhaite utiliser votre caméra." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Un programma in esecuzione in cmux desidera utilizzare la fotocamera." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Et program, der kører i cmux, vil gerne bruge dit kamera." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Program działający w cmux chciałby użyć Twojej kamery." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Программа, запущенная в cmux, хотела бы использовать вашу камеру." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Program koji se izvršava unutar cmux želi koristiti vašu kameru." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يرغب برنامج يعمل داخل cmux في استخدام الكاميرا." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Et program som kjører i cmux ønsker å bruke kameraet ditt." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Um programa em execução no cmux gostaria de usar sua câmera." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "โปรแกรมที่ทำงานภายใน cmux ต้องการใช้กล้องของคุณ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "cmux içinde çalışan bir program kameranızı kullanmak istiyor." + } } } }, diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 4b1d2ea228bc..f63e074ffc99 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -28,10 +28,35 @@ final class MainWindowHostingView: NSHostingView { } } +/// Applies the main-window AppKit movability baseline for the active presentation mode. +/// +/// Standard mode keeps native OS-level window movability enabled for macOS +/// tiling and third-party window managers. Minimal mode disables native +/// movability so cmux-owned chrome can decide exactly when to call +/// `performDrag`. If a protected drag suppression sequence is active, the +/// window remains immovable and only the post-suppression restore baseline is +/// updated. @MainActor -func configureCmuxMainWindowDragBehavior(_ window: NSWindow) { +func configureCmuxMainWindowDragBehavior( + _ window: NSWindow, + defaults: UserDefaults = .standard +) { + // Keep background dragging disabled so app content gestures and titlebar + // controls receive clicks. In standard mode, leave the OS-level movable bit + // enabled for macOS tiling and third-party window managers. In minimal mode, + // there is no native titlebar: app-owned chrome must explicitly call + // performDrag so Bonsplit pane tabs cannot be stolen by AppKit window moves. window.isMovableByWindowBackground = false - window.isMovable = false + let baselineIsMovable = !WorkspacePresentationModeSettings.isMinimal(defaults: defaults) + if activeWindowMoveSuppressionSequenceReason(window: window) == nil { + window.isMovable = baselineIsMovable + } else { + updateActiveWindowMoveSuppressionSequencePreviousMovableState( + window: window, + previousMovableState: baselineIsMovable + ) + ensureWindowMoveSuppressionSequenceIsImmovable(window: window) + } } @MainActor diff --git a/Sources/App/ShortcutRoutingSupport.swift b/Sources/App/ShortcutRoutingSupport.swift index 5e59b602c759..30c1b5222fa1 100644 --- a/Sources/App/ShortcutRoutingSupport.swift +++ b/Sources/App/ShortcutRoutingSupport.swift @@ -789,6 +789,7 @@ enum WindowMoveSuppressionReason: String { case bonsplitPaneTabDrag } +@MainActor func shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: NSWindow, event: NSEvent) -> Bool { guard event.type == .leftMouseDown else { return false @@ -797,6 +798,7 @@ func shouldSuppressWindowMoveForBonsplitPaneTabDrag(window: NSWindow, event: NSE return BonsplitTabItemHitRegionRegistry.containsWindowPoint(event.locationInWindow, in: window) } +@MainActor func windowMoveSuppressionReason(window: NSWindow, event: NSEvent) -> WindowMoveSuppressionReason? { if shouldSuppressWindowMoveForFolderDrag(window: window, event: event) { return .folderDrag @@ -807,6 +809,7 @@ func windowMoveSuppressionReason(window: NSWindow, event: NSEvent) -> WindowMove return nil } +@MainActor func beginOrContinueWindowMoveSuppressionSequenceForEvent( window: NSWindow, event: NSEvent, @@ -829,6 +832,7 @@ func beginOrContinueWindowMoveSuppressionSequenceForEvent( return beginWindowMoveSuppressionSequence(window: window, reason: reason) } +@MainActor func shouldFinishWindowMoveSuppressionSequenceAfterDispatch(window: NSWindow, event: NSEvent) -> Bool { activeWindowMoveSuppressionSequenceReason(window: window) != nil && event.type == .leftMouseUp } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index cc2902bcd17a..97392eba8327 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -3256,9 +3256,11 @@ struct ContentView: View { window.isRestorable = false setMinimalModeSidebarTitlebarControlsAvailable(sidebarState.isVisible, in: window) window.titlebarAppearsTransparent = 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. + // Keep background dragging disabled so app content gestures and + // titlebar controls receive clicks. Standard mode keeps AppKit + // movability for macOS tiling/window managers; minimal mode keeps + // the native movable bit off so Bonsplit tabs cannot become window + // drags. Explicit cmux drag zones call performDrag when needed. configureCmuxMainWindowDragBehavior(window) window.styleMask.insert(.fullSizeContentView) diff --git a/Sources/DetachedFolderDragIcon.swift b/Sources/DetachedFolderDragIcon.swift index e57a18ce3b93..fc9dc1bf5d85 100644 --- a/Sources/DetachedFolderDragIcon.swift +++ b/Sources/DetachedFolderDragIcon.swift @@ -83,6 +83,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { } func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + finishWindowMoveSuppressionSequence(window: window, matching: .folderDrag) #if DEBUG let nowMovable = window.map { String($0.isMovable) } ?? "nil" let windowOrigin = window.map { formatPoint($0.frame.origin) } ?? "nil" @@ -142,6 +143,7 @@ final class DraggableFolderNSView: NSView, NSDraggingSource { override func mouseUp(with event: NSEvent) { clearPendingDrag() + finishWindowMoveSuppressionSequence(window: window, matching: .folderDrag) super.mouseUp(with: event) } diff --git a/Sources/WindowDecorationsController.swift b/Sources/WindowDecorationsController.swift index 733cccde7076..d5f086a54ea8 100644 --- a/Sources/WindowDecorationsController.swift +++ b/Sources/WindowDecorationsController.swift @@ -1,5 +1,6 @@ import AppKit +@MainActor final class WindowDecorationsController { private var observers: [NSObjectProtocol] = [] private var didStart = false @@ -36,7 +37,11 @@ final class WindowDecorationsController { } func apply(to window: NSWindow) { - if isMainWorkspaceWindow(window), WorkspacePresentationModeSettings.isMinimal() { + let isMainWindow = isMainWorkspaceWindow(window) + if isMainWindow { + configureCmuxMainWindowDragBehavior(window) + } + if isMainWindow, WorkspacePresentationModeSettings.isMinimal() { WindowMouseMovedEventsCoordinator.enable(for: window, owner: self) } else { WindowMouseMovedEventsCoordinator.disable(for: window, owner: self) diff --git a/Sources/WindowDragHandleView.swift b/Sources/WindowDragHandleView.swift index 11f6d8d508e6..9ac62b855664 100644 --- a/Sources/WindowDragHandleView.swift +++ b/Sources/WindowDragHandleView.swift @@ -9,6 +9,10 @@ enum WindowMouseMovedEventsCoordinator { var owners: Set } + // NSLock is used instead of actor isolation because these static reference + // counts are read and restored synchronously from AppKit event-monitor and + // deinit paths that cannot suspend or hop while preserving the prior + // `acceptsMouseMovedEvents` value. private nonisolated(unsafe) static var records: [ObjectIdentifier: Record] = [:] private nonisolated static let lock = NSLock() @@ -73,6 +77,9 @@ private func windowDragHandleEventTypeDescription(_ eventType: NSEvent.EventType } private enum WindowDragHandleBreadcrumbLimiter { + // NSLock keeps this debug breadcrumb limiter synchronous and allocation-light + // from AppKit mouse-event paths; an actor hop would reorder or defer logging + // decisions relative to the event currently being handled. private static let lock = NSLock() private static var lastEmissionByKey: [String: CFAbsoluteTime] = [:] @@ -296,6 +303,8 @@ func isWindowDragSuppressed(window: NSWindow?) -> Bool { windowDragSuppressionDepth(window: window) > 0 } +/// Returns the reason for the active window-move suppression sequence, if one exists. +@MainActor func activeWindowMoveSuppressionSequenceReason(window: NSWindow?) -> WindowMoveSuppressionReason? { guard let window, let state = objc_getAssociatedObject( @@ -307,6 +316,8 @@ func activeWindowMoveSuppressionSequenceReason(window: NSWindow?) -> WindowMoveS return state.reason } +/// Starts a window-move suppression sequence and records the window's restore baseline. +@MainActor @discardableResult func beginWindowMoveSuppressionSequence( window: NSWindow?, @@ -336,6 +347,37 @@ func beginWindowMoveSuppressionSequence( return reason } +/// Updates the restore baseline for an active move-suppression sequence. +/// +/// Presentation mode can change while a protected drag is in progress. The +/// window must stay immovable until the drag finishes, but the post-drag +/// restore value needs to track the current presentation-mode baseline. +@MainActor +func updateActiveWindowMoveSuppressionSequencePreviousMovableState( + window: NSWindow?, + previousMovableState: Bool +) { + guard let window, + let state = objc_getAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence + ) as? WindowMoveSuppressionSequenceState else { + return + } + let updatedState = WindowMoveSuppressionSequenceState( + reason: state.reason, + previousMovableState: previousMovableState + ) + objc_setAssociatedObject( + window, + WindowDragHandleAssociatedObjectKeys.moveSuppressionSequence, + updatedState, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) +} + +/// Keeps a window immovable while its move-suppression sequence is active. +@MainActor func ensureWindowMoveSuppressionSequenceIsImmovable(window: NSWindow?) { guard let window, activeWindowMoveSuppressionSequenceReason(window: window) != nil, @@ -345,6 +387,8 @@ func ensureWindowMoveSuppressionSequenceIsImmovable(window: NSWindow?) { window.isMovable = false } +/// Finishes the active move-suppression sequence and restores the recorded baseline. +@MainActor @discardableResult func finishWindowMoveSuppressionSequence(window: NSWindow?) -> WindowMoveSuppressionReason? { guard let window, @@ -366,6 +410,24 @@ func finishWindowMoveSuppressionSequence(window: NSWindow?) -> WindowMoveSuppres return state.reason } +/// Finishes the active move-suppression sequence only when its reason matches. +/// +/// This lets a specific drag source clean up after itself without accidentally +/// ending another source's still-active suppression sequence. +@MainActor +@discardableResult +func finishWindowMoveSuppressionSequence( + window: NSWindow?, + matching reason: WindowMoveSuppressionReason +) -> WindowMoveSuppressionReason? { + guard activeWindowMoveSuppressionSequenceReason(window: window) == reason else { + return nil + } + return finishWindowMoveSuppressionSequence(window: window) +} + +/// Restores AppKit window dragging to a previously captured value. +@MainActor func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { guard let window, let previousMovableState else { return } @@ -374,6 +436,8 @@ func restoreWindowDragging(window: NSWindow?, previousMovableState: Bool?) { } } +/// Clears all drag-suppression state attached to a window. +@MainActor @discardableResult func clearWindowDragSuppression(window: NSWindow?) -> Int { guard let window else { return 0 } @@ -434,6 +498,10 @@ protocol MinimalModeSidebarControlActionHitRegionProviding: MinimalModeTitlebarC } enum MinimalModeTitlebarControlHitRegionRegistry { + // NSLock protects the weak view table because AppKit can register/unregister + // representable-backed views during layout while hit-testing reads snapshots + // from event dispatch; actor isolation would add a hop to pointer paths and + // cannot guard NSHashTable's synchronous mutation semantics. private static let lock = NSLock() private static let registeredViews = NSHashTable.weakObjects() @@ -1063,6 +1131,7 @@ private func windowDragHandleSiblingHitResolutionScope( /// 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. +@MainActor func windowDragHandleShouldCaptureHit( _ point: NSPoint, in dragHandleView: NSView, diff --git a/cmuxTests/WindowAndDragTests.swift b/cmuxTests/WindowAndDragTests.swift index 50100e52ac1f..aa29cdf029bd 100644 --- a/cmuxTests/WindowAndDragTests.swift +++ b/cmuxTests/WindowAndDragTests.swift @@ -1862,7 +1862,38 @@ final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { ) } - func testMainWindowDragBehaviorRequiresExplicitDragZones() { + func testMainWindowDragBehaviorKeepsOSMovabilityWithoutBackgroundDraggingInStandardMode() { + let suiteName = "cmuxTests.standardDragBehavior.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(WorkspacePresentationModeSettings.Mode.standard.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defer { defaults.removePersistentDomain(forName: suiteName) } + 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 = false + window.isMovableByWindowBackground = true + + configureCmuxMainWindowDragBehavior(window, defaults: defaults) + + XCTAssertTrue( + window.isMovable, + "Standard main windows must keep AppKit movability enabled for macOS tiling and third-party window managers" + ) + XCTAssertFalse( + window.isMovableByWindowBackground, + "App content gestures and titlebar controls must not become implicit AppKit background-drag regions" + ) + } + + func testMainWindowDragBehaviorDisablesNativeMovabilityInMinimalMode() { + let suiteName = "cmuxTests.minimalDragBehavior.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defer { defaults.removePersistentDomain(forName: suiteName) } let window = CmuxMainWindow( contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], @@ -1873,22 +1904,107 @@ final class TitlebarLeadingInsetPassthroughViewTests: XCTestCase { window.isMovable = true window.isMovableByWindowBackground = true - configureCmuxMainWindowDragBehavior(window) + configureCmuxMainWindowDragBehavior(window, defaults: defaults) XCTAssertFalse( window.isMovable, - "Main windows must not use native AppKit titlebar dragging because pane tabs live in the titlebar band" + "Minimal mode has no native titlebar; explicit cmux drag zones must own window movement so Bonsplit tabs cannot move the window" ) XCTAssertFalse(window.isMovableByWindowBackground) + } - let previous = withTemporaryWindowMovableEnabled(window: window) { - XCTAssertTrue(window.isMovable) - } + func testMainWindowDragBehaviorDoesNotReenableActiveSuppressionSequence() { + let suiteName = "cmuxTests.suppressedDragBehavior.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(WorkspacePresentationModeSettings.Mode.standard.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defer { defaults.removePersistentDomain(forName: suiteName) } + 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) } + configureCmuxMainWindowDragBehavior(window, defaults: defaults) + XCTAssertTrue(window.isMovable) + + XCTAssertEqual( + beginWindowMoveSuppressionSequence(window: window, reason: .bonsplitPaneTabDrag), + .bonsplitPaneTabDrag + ) + XCTAssertFalse(window.isMovable) + + configureCmuxMainWindowDragBehavior(window, defaults: defaults) - 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" + "Re-applying window configuration during a tab drag must not re-enable native AppKit movement" + ) + XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window), .bonsplitPaneTabDrag) + XCTAssertTrue(window.isMovable) + } + + func testMainWindowDragBehaviorRestoresMinimalBaselineAfterModeChangeDuringSuppression() { + let suiteName = "cmuxTests.suppressedDragBehavior.standardToMinimal.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(WorkspacePresentationModeSettings.Mode.standard.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defer { defaults.removePersistentDomain(forName: suiteName) } + 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) } + configureCmuxMainWindowDragBehavior(window, defaults: defaults) + XCTAssertTrue(window.isMovable) + + XCTAssertEqual( + beginWindowMoveSuppressionSequence(window: window, reason: .bonsplitPaneTabDrag), + .bonsplitPaneTabDrag + ) + XCTAssertFalse(window.isMovable) + + defaults.set(WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + configureCmuxMainWindowDragBehavior(window, defaults: defaults) + + XCTAssertFalse(window.isMovable) + XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window), .bonsplitPaneTabDrag) + XCTAssertFalse( + window.isMovable, + "A mode change during suppression must update the post-drag baseline instead of restoring stale standard-mode movability" + ) + } + + func testMainWindowDragBehaviorRestoresStandardBaselineAfterModeChangeDuringSuppression() { + let suiteName = "cmuxTests.suppressedDragBehavior.minimalToStandard.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(WorkspacePresentationModeSettings.Mode.minimal.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + defer { defaults.removePersistentDomain(forName: suiteName) } + 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) } + configureCmuxMainWindowDragBehavior(window, defaults: defaults) + XCTAssertFalse(window.isMovable) + + XCTAssertEqual( + beginWindowMoveSuppressionSequence(window: window, reason: .bonsplitPaneTabDrag), + .bonsplitPaneTabDrag + ) + XCTAssertFalse(window.isMovable) + + defaults.set(WorkspacePresentationModeSettings.Mode.standard.rawValue, forKey: WorkspacePresentationModeSettings.modeKey) + configureCmuxMainWindowDragBehavior(window, defaults: defaults) + + XCTAssertFalse(window.isMovable) + XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window), .bonsplitPaneTabDrag) + XCTAssertTrue( + window.isMovable, + "A mode change during suppression must restore the current standard-mode baseline once the protected drag ends" ) } } @@ -1973,6 +2089,23 @@ final class FolderWindowMoveSuppressionTests: XCTestCase { XCTAssertTrue(window.isMovable) } + func testFinishingMatchingSuppressionRestoresMovableWindow() { + let window = makeWindow() + window.isMovable = true + + XCTAssertEqual( + beginWindowMoveSuppressionSequence(window: window, reason: .folderDrag), + .folderDrag + ) + XCTAssertFalse(window.isMovable) + XCTAssertNil(finishWindowMoveSuppressionSequence(window: window, matching: .bonsplitPaneTabDrag)) + XCTAssertFalse(window.isMovable) + + XCTAssertEqual(finishWindowMoveSuppressionSequence(window: window, matching: .folderDrag), .folderDrag) + XCTAssertTrue(window.isMovable) + XCTAssertNil(activeWindowMoveSuppressionSequenceReason(window: window)) + } + func testWindowDragSuppressionDepthLifecycle() { let window = makeWindow() XCTAssertEqual(windowDragSuppressionDepth(window: window), 0)