Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions Sources/App/CmuxMainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -357,7 +355,6 @@ final class CmuxMainWindow: NSWindow {
// suppressed.
behavior.remove(.fullScreenNone)
behavior.insert(.fullScreenPrimary)
behavior.insert(.fullScreenDisallowsTiling)
return behavior
}

Expand Down
45 changes: 39 additions & 6 deletions Sources/App/MainWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
14 changes: 13 additions & 1 deletion Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot)
let persistedGeometryFrame = (restoredFrame == nil && sourceWindow == nil)
? resolvedPersistedWindowGeometryFrame()
Expand Down Expand Up @@ -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),
Expand Down
92 changes: 90 additions & 2 deletions cmuxTests/AppDelegateShortcutRoutingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
)
)
}

Expand Down
11 changes: 4 additions & 7 deletions cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
@Test func mainWindowDeclaresFullScreenPrimaryCapability() {
let window = CmuxMainWindow(
contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
Expand All @@ -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"
)
}

Expand All @@ -57,22 +57,19 @@ 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))
}

@Test func canonicalBehaviorPreservesUnrelatedBehaviorBits() {
let base: NSWindow.CollectionBehavior = [.moveToActiveSpace]
let result = CmuxMainWindow.canonicalCollectionBehavior(base)
#expect(result.contains(.fullScreenPrimary))
#expect(result.contains(.fullScreenDisallowsTiling))
#expect(result.contains(.moveToActiveSpace))
}

Expand Down
Loading