diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 30869b7a78c5..b92b35a28859 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -318,9 +318,8 @@ final class CmuxMainWindow: NSWindow { /// /// Declaring `.fullScreenPrimary` here makes native fullscreen reachable /// regardless of the OS's implicit default. It is idempotent where AppKit - /// would have granted it anyway. `.fullScreenDisallowsTiling` is also set - /// permanently so macOS Full Screen Tile does not trap cmux in a managed - /// tile Space that breaks Mission Control and horizontal Space swipes. + /// would have granted it anyway. Fullscreen tiling is controlled by the + /// window creation path when a window is spawned from native fullscreen. override init( contentRect: NSRect, styleMask: NSWindow.StyleMask, @@ -343,9 +342,8 @@ final class CmuxMainWindow: NSWindow { /// Returns `base` guaranteed to carry `.fullScreenPrimary` (and never /// `.fullScreenNone`) so a cmux main window can always enter a native - /// fullscreen Space, plus `.fullScreenDisallowsTiling` so AppKit does not - /// route the window into macOS Full Screen Tile. Pure and `nonisolated` so - /// it can be unit-tested without constructing a window; see + /// fullscreen Space. Pure and `nonisolated` so it can be unit-tested + /// without constructing a window; see /// ``init(contentRect:styleMask:backing:defer:)`` for why declaring the /// capability explicitly is required. nonisolated static func canonicalCollectionBehavior( @@ -357,7 +355,6 @@ final class CmuxMainWindow: NSWindow { // suppressed. behavior.remove(.fullScreenNone) behavior.insert(.fullScreenPrimary) - behavior.insert(.fullScreenDisallowsTiling) return behavior } diff --git a/Sources/App/MainWindowController.swift b/Sources/App/MainWindowController.swift index ed939af13e66..83adde4ae312 100644 --- a/Sources/App/MainWindowController.swift +++ b/Sources/App/MainWindowController.swift @@ -12,6 +12,7 @@ final class MainWindowController: ReleasingWindowController { var shouldRetireZoomIntentForProgrammaticResize: ((CmuxMainWindow) -> Bool) = { _ in true } private var isFullScreenTransitionInProgress = false + private var clearsFullscreenTilingOptOutOnPresentation = false #if DEBUG private func logWindowEvent(_ event: String, notification: Notification) { @@ -24,9 +25,27 @@ final class MainWindowController: ReleasingWindowController { #endif override func managedWindowWillClose(_ window: NSWindow) { + clearFullscreenTilingOptOutIfNeeded(window) onClose?(window) } + /// Temporarily keeps a newly-created window out of the source fullscreen + /// Space until AppKit reports that the window has become active. + func disallowFullscreenTilingUntilPresentation() { + guard let window else { return } + window.collectionBehavior.insert(.fullScreenDisallowsTiling) + clearsFullscreenTilingOptOutOnPresentation = true + } + + /// Returns whether a new window needs a transient fullscreen tiling opt-out + /// while it is being presented from a native fullscreen source. + static func shouldTemporarilyDisallowFullscreenTiling( + sourceWindow: NSWindow?, + restoringSessionWindow: Bool + ) -> Bool { + !restoringSessionWindow && sourceWindow?.styleMask.contains(.fullScreen) == true + } + func windowWillEnterFullScreen(_ notification: Notification) { setFullScreenTransitionInProgress(true, notification: notification) } @@ -86,27 +105,31 @@ final class MainWindowController: ReleasingWindowController { handleGeometryChange(notification) } -#if DEBUG - func windowDidMiniaturize(_ notification: Notification) { - logWindowEvent("didMiniaturize", notification: notification) - } - func windowDidBecomeKey(_ notification: Notification) { +#if DEBUG logWindowEvent("didBecomeKey", notification: notification) +#endif + clearFullscreenTilingOptOutIfNeeded(notification.object as? NSWindow) } func windowDidResignKey(_ notification: Notification) { +#if DEBUG logWindowEvent("didResignKey", notification: notification) +#endif } func windowDidBecomeMain(_ notification: Notification) { +#if DEBUG logWindowEvent("didBecomeMain", notification: notification) +#endif + clearFullscreenTilingOptOutIfNeeded(notification.object as? NSWindow) } func windowDidResignMain(_ notification: Notification) { +#if DEBUG logWindowEvent("didResignMain", notification: notification) - } #endif + } func windowShouldClose(_ sender: NSWindow) -> Bool { let shouldClose = shouldClose?(sender) ?? true @@ -184,4 +207,14 @@ final class MainWindowController: ReleasingWindowController { } placedWindow.recordUserPlacement() } + + private func clearFullscreenTilingOptOutIfNeeded(_ changedWindow: NSWindow?) { + guard clearsFullscreenTilingOptOutOnPresentation, + let window, + changedWindow === window else { + return + } + window.collectionBehavior.remove(.fullScreenDisallowsTiling) + clearsFullscreenTilingOptOutOnPresentation = false + } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 731ef6fe75d0..cc95f70fc15e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5649,7 +5649,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent @discardableResult func moveWorkspaceToNewWindow(workspaceId: UUID, focus: Bool = true) -> UUID? { - let windowId = createMainWindow() + // Resolve the owner before creating the destination. The active/fallback + // window may differ from the workspace's source, especially when moving + // a workspace out of a native fullscreen window. + let sourceWindow = mainWindowContainingWorkspace(workspaceId) + let windowId = createMainWindow(sourceWindow: sourceWindow) guard let destinationManager = tabManagerFor(windowId: windowId) else { return nil } let bootstrapWorkspaceId = destinationManager.tabs.first?.id @@ -10498,6 +10502,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let sourceWindow = resolvedMainWindowSource(preferredSourceWindow) ?? sourceContext.flatMap { resolvedWindow(for: $0) } let existingFrame = sourceWindow?.frame + let shouldTemporarilyDisallowFullScreenTiling = + MainWindowController.shouldTemporarilyDisallowFullscreenTiling( + sourceWindow: sourceWindow, + restoringSessionWindow: sessionWindowSnapshot != nil + ) let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) let persistedGeometryFrame = (restoredFrame == nil && sourceWindow == nil) ? resolvedPersistedWindowGeometryFrame() @@ -10579,6 +10588,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent && !self.isApplyingSessionRestore && !displayReconcilePending } + if shouldTemporarilyDisallowFullScreenTiling { + controller.disallowFullscreenTilingUntilPresentation() + } controller.onClose = { [weak self, weak controller] closingWindow in guard let self, let controller else { return } guard let exactOwner = self.mainWindowOwnerIdentity(forExactWindow: closingWindow), diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index d3a8c720d557..d4743c4d1069 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -1020,7 +1020,7 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(workspace.panels.count, initialPanelCount, "Unmatched chord suffix must not trigger the action") } - func testCreateMainWindowDisallowsFullScreenTilingByDefault() { + func testCreateMainWindowAllowsFullScreenTilingByDefault() { guard let appDelegate = AppDelegate.shared else { XCTFail("Expected AppDelegate.shared") return @@ -1036,9 +1036,97 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { return } + XCTAssertFalse( + window.collectionBehavior.contains(.fullScreenDisallowsTiling), + "Main windows should allow macOS Full Screen Tile unless they are spawned from a native fullscreen source" + ) + } + + func testCreateMainWindowAppliesFullscreenSourceTilingOptOut() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let sourceWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .resizable, .fullScreen], + backing: .buffered, + defer: false + ) + sourceWindow.identifier = NSUserInterfaceItemIdentifier("cmux.main.test-source") + sourceWindow.isReleasedWhenClosed = false + defer { sourceWindow.close() } + + let windowId = appDelegate.createMainWindow(shouldActivate: false, sourceWindow: sourceWindow) + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId) else { + XCTFail("Expected test window") + return + } + + XCTAssertTrue( + window.collectionBehavior.contains(.fullScreenDisallowsTiling), + "A window created from native fullscreen should temporarily opt out of tiling" + ) + } + + func testCreateMainWindowTemporarilyDisallowsFullScreenTilingFromFullscreenSource() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let controller = MainWindowController(window: window) + defer { + window.close() + } + + controller.disallowFullscreenTilingUntilPresentation() XCTAssertTrue( window.collectionBehavior.contains(.fullScreenDisallowsTiling), - "Main windows should opt out of macOS Full Screen Tile so native fullscreen does not trap Space navigation" + "A window spawned from native fullscreen should opt out while it is being presented" + ) + + controller.windowDidBecomeKey(Notification(name: NSWindow.didBecomeKeyNotification, object: window)) + + XCTAssertFalse( + window.collectionBehavior.contains(.fullScreenDisallowsTiling), + "The fullscreen tiling opt-out should be cleared when presentation makes the window key" + ) + } + + func testFullscreenTilingOptOutOnlyAppliesToNativeFullscreenSources() { + let sourceWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .resizable, .fullScreen], + backing: .buffered, + defer: false + ) + defer { + sourceWindow.close() + } + + XCTAssertTrue( + MainWindowController.shouldTemporarilyDisallowFullscreenTiling( + sourceWindow: sourceWindow, + restoringSessionWindow: false + ) + ) + XCTAssertFalse( + MainWindowController.shouldTemporarilyDisallowFullscreenTiling( + sourceWindow: sourceWindow, + restoringSessionWindow: true + ) + ) + XCTAssertFalse( + MainWindowController.shouldTemporarilyDisallowFullscreenTiling( + sourceWindow: nil, + restoringSessionWindow: false + ) ) } diff --git a/cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift b/cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift index 7824ee6a777e..3dd89386bf2e 100644 --- a/cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift +++ b/cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift @@ -22,8 +22,8 @@ struct CmuxMainWindowFullScreenCapabilityTests { // // A CmuxMainWindow must therefore *declare* `.fullScreenPrimary` itself so // native fullscreen is reachable regardless of the OS's implicit default. - // It must also opt out of Full Screen Tile so Mission Control and Space - // navigation remain available while cmux is fullscreen. + // Full Screen Tile remains available so other apps, such as Simulator, + // can be tiled with a fullscreen cmux window. @Test func mainWindowDeclaresFullScreenPrimaryCapability() { let window = CmuxMainWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), @@ -46,8 +46,8 @@ struct CmuxMainWindowFullScreenCapabilityTests { "Main window must never carry .fullScreenNone, which suppresses native fullscreen" ) #expect( - window.collectionBehavior.contains(.fullScreenDisallowsTiling), - "Main window must disallow Full Screen Tile so native fullscreen does not trap Space navigation" + !window.collectionBehavior.contains(.fullScreenDisallowsTiling), + "Main window must allow Full Screen Tile so other apps can join its fullscreen Space" ) } @@ -57,14 +57,12 @@ struct CmuxMainWindowFullScreenCapabilityTests { @Test func canonicalBehaviorAddsFullScreenPrimaryToEmptyBehavior() { let result = CmuxMainWindow.canonicalCollectionBehavior([]) #expect(result.contains(.fullScreenPrimary)) - #expect(result.contains(.fullScreenDisallowsTiling)) #expect(!result.contains(.fullScreenNone)) } @Test func canonicalBehaviorDropsStaleFullScreenNone() { let result = CmuxMainWindow.canonicalCollectionBehavior([.fullScreenNone]) #expect(result.contains(.fullScreenPrimary)) - #expect(result.contains(.fullScreenDisallowsTiling)) #expect(!result.contains(.fullScreenNone)) } @@ -72,7 +70,6 @@ struct CmuxMainWindowFullScreenCapabilityTests { let base: NSWindow.CollectionBehavior = [.moveToActiveSpace] let result = CmuxMainWindow.canonicalCollectionBehavior(base) #expect(result.contains(.fullScreenPrimary)) - #expect(result.contains(.fullScreenDisallowsTiling)) #expect(result.contains(.moveToActiveSpace)) }