fix: use a nonactivating compact panel across fullscreen spaces

This commit is contained in:
2026-10-25 22:29:11 +01:00
parent 599e2f548a
commit d8fb648c90
8 changed files with 393 additions and 125 deletions
+131 -54
View File
@@ -99,12 +99,20 @@ struct CompactWindowChecks {
line: UInt = #line, checking: () throws -> Void = {}) throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while controller.isTransitioning {
if let presentation = controller.presentationWindow {
try require(!presentation.isVisible && !presentation.isKeyWindow,
"native transitions never display or key a hidden presentation")
}
try checking()
try require(ContinuousClock.now < deadline, "native compact transition completes (call at line \(line), compact \(controller.isCompact), target \(compact))")
CFRunLoopRunInMode(.defaultMode, 0.01, false)
}
try checking()
try require(controller.isCompact == compact, "settled mode matches the latest destination")
if let presentation = controller.presentationWindow {
try require(!presentation.isVisible && !presentation.isKeyWindow,
"settled hidden presentation remains undisplayed and non-key")
}
}
@MainActor
@@ -151,6 +159,12 @@ struct CompactWindowChecks {
}
}
@MainActor
final class WindowDelegate: NSObject, NSWindowDelegate {
var closes = 0
func windowWillClose(_ notification: Notification) { closes += 1 }
}
@MainActor
final class WindowFixture {
let window: NSWindow
@@ -158,7 +172,8 @@ struct CompactWindowChecks {
let sentinel: NSView
let sentinelFrame: NSRect
let original: WindowState
let identity: ObjectIdentifier
let delegate = WindowDelegate()
let activationPolicy: NSApplication.ActivationPolicy
init() throws {
// Never orderFront, makeKey, or create an actual native fullscreen Space.
@@ -180,19 +195,24 @@ struct CompactWindowChecks {
window.level = .normal
window.hidesOnDeactivate = true
window.isMovableByWindowBackground = false
window.delegate = delegate
original = WindowState(window)
identity = ObjectIdentifier(window)
activationPolicy = NSApp.activationPolicy()
}
func preservedContent() throws {
try CompactWindowChecks.require(ObjectIdentifier(window) == identity && sentinel.window === window,
"compact mode keeps the actual rendering window")
try CompactWindowChecks.require(window.contentView === content && sentinel.superview === content
func preservedContent(in presentation: NSWindow? = nil) throws {
let presentation = presentation ?? window
try CompactWindowChecks.require(sentinel.window === presentation
&& presentation.contentView === content
&& sentinel.superview === content
&& content.subviews.count == 1
&& CompactWindowChecks.sameFrame(sentinel.frame, sentinelFrame),
"compact mode preserves content and attached sentinel without reparenting")
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow,
"checks never display or key a window")
"presentation retains the same content subtree and renderer sentinel")
try CompactWindowChecks.require(!window.isVisible && !window.isKeyWindow
&& !presentation.isVisible && !presentation.isKeyWindow,
"hidden checks never display or key either window")
try CompactWindowChecks.require(NSApp.activationPolicy() == activationPolicy,
"compact preserves the app's Dock and CmdTab activation policy")
}
func restored() throws {
@@ -200,28 +220,35 @@ struct CompactWindowChecks {
try preservedContent()
}
func compact(aspect: Double) throws {
try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate,
func compact(_ controller: CompactWindowController, aspect: Double) throws {
guard let panel = controller.presentationWindow as? NSPanel else {
throw Failure(description: "FAIL: compact presents a dedicated NSPanel")
}
try CompactWindowChecks.require(panel !== window && panel.styleMask.contains(.nonactivatingPanel),
"compact uses a nonactivating panel, not the normal window")
try CompactWindowChecks.require(window.contentView !== content,
"hidden normal window relinquishes the renderer content")
try original.requireMatches(window)
try CompactWindowChecks.require(panel.level == .floating && !panel.hidesOnDeactivate,
"compact floats and remains visible on deactivate")
try CompactWindowChecks.require(window.collectionBehavior.contains(
try CompactWindowChecks.require(panel.collectionBehavior.contains(
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
"compact can join Spaces, other-app fullscreen, and Stage Manager")
try CompactWindowChecks.require(window.collectionBehavior.intersection(
try CompactWindowChecks.require(panel.collectionBehavior.intersection(
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
"compact removes incompatible primary and auxiliary policies")
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
try CompactWindowChecks.require(panel.contentMinSize == NSSize(width: 160, height: 90),
"compact uses 160x90 content minimum")
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
try CompactWindowChecks.require(window.titleVisibility == .hidden
&& window.titlebarAppearsTransparent,
try CompactWindowChecks.require(panel.canBecomeKey, "compact accepts keyboard playback controls")
try CompactWindowChecks.require(panel.titleVisibility == .hidden && panel.titlebarAppearsTransparent,
"compact hides native title chrome")
let visible = (window.screen ?? NSScreen.main!).visibleFrame
try CompactWindowChecks.require(CompactWindowChecks.close(window.frame.maxX, visible.maxX - 16)
&& CompactWindowChecks.close(window.frame.maxY, visible.maxY - 16)
&& CompactWindowChecks.close(window.frame.width / window.frame.height,
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
try CompactWindowChecks.require(CompactWindowChecks.close(panel.frame.maxX, visible.maxX - 16)
&& CompactWindowChecks.close(panel.frame.maxY, visible.maxY - 16)
&& CompactWindowChecks.close(panel.frame.width / panel.frame.height,
CGFloat(aspect)),
"compact enters at inset top right with playback aspect")
try preservedContent()
try preservedContent(in: panel)
}
}
@@ -238,7 +265,7 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "native completion barrier is an actual animated entry")
try settled(controller, compact: true, checking: checking)
try fixture.compact(aspect: 16.0 / 9.0)
try fixture.compact(controller, aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false)
try settled(controller, compact: false, checking: checking)
try fixture.restored()
@@ -257,47 +284,53 @@ struct CompactWindowChecks {
window.close()
}
controller.attach(window)
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
try require(!controller.isCompact && !controller.isTransitioning
&& controller.presentationWindow === window, "attached window starts ordinary")
try require(controller.snappingEnabled, "isolated controller defaults snapping on")
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isCompact && controller.isTransitioning,
"entry presents the compact destination while native geometry settles")
try fixture.preservedContent()
try fixture.preservedContent(in: controller.presentationWindow)
try settled(controller, compact: true)
try fixture.compact(aspect: 16.0 / 9.0)
try fixture.compact(controller, aspect: 16.0 / 9.0)
let visible = (window.screen ?? NSScreen.main!).visibleFrame
let panel = controller.presentationWindow!
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
width: window.frame.width, height: window.frame.height)
window.setFrame(nearEdge, display: false)
try require(sameFrame(window.frame, nearEdge), "native move installs unsnapped drag position")
width: panel.frame.width, height: panel.frame.height)
panel.setFrame(nearEdge, display: false)
try require(sameFrame(panel.frame, nearEdge), "native move installs unsnapped drag position")
controller.snapAfterDrag()
try require(close(window.frame.minX, visible.minX + 16) && close(window.frame.minY, visible.minY + 16)
&& window.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
try require(close(panel.frame.minX, visible.minX + 16) && close(panel.frame.minY, visible.minY + 16)
&& panel.frame.size == nearEdge.size, "enabled preference snaps the actual moved window")
controller.snappingEnabled = false
let second = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
try require(!second.snappingEnabled, "configuration can disable snapping for a new controller")
window.setFrame(nearEdge, display: false)
panel.setFrame(nearEdge, display: false)
controller.snapAfterDrag()
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
try require(sameFrame(panel.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
second.snappingEnabled = true
let third = CompactWindowController(snappingEnabled: false, reduceMotion: { false })
try require(!third.snappingEnabled, "a session override does not change the next controller's settings")
controller.snappingEnabled = true
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
window.setFrame(resized, display: false)
panel.setFrame(resized, display: false)
controller.updateAspect(16.0 / 9.0)
try require(sameFrame(window.frame, resized),
try require(sameFrame(panel.frame, resized),
"repeated aspect updates preserve a user's compact resize and placement")
controller.updateAspect(4.0 / 3.0)
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0),
try require(close(panel.frame.width / panel.frame.height, 4.0 / 3.0),
"actual compact window follows changed playback aspect")
try fixture.preservedContent()
try fixture.preservedContent(in: panel)
controller.toggle(aspect: 4.0 / 3.0)
try require(controller.presentationWindow === panel,
"restoring continues presenting the panel until content is handed back")
try settled(controller, compact: false)
try fixture.restored()
try require(controller.presentationWindow === window && panel.contentView !== fixture.content
&& !panel.isVisible && !panel.isKeyWindow, "restoration retires the compact panel")
// Enter -> exit before entry completes: the original normal snapshot must survive.
controller.toggle(aspect: 16.0 / 9.0)
@@ -314,7 +347,7 @@ struct CompactWindowChecks {
"exit presents the ordinary destination while native geometry settles")
controller.toggle(aspect: 4.0 / 3.0)
try settled(controller, compact: true)
try fixture.compact(aspect: 4.0 / 3.0)
try fixture.compact(controller, aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false)
try settled(controller, compact: false)
try fixture.restored()
@@ -328,7 +361,7 @@ struct CompactWindowChecks {
}
try settled(controller, compact: compactDestination)
if compactDestination {
try fixture.compact(aspect: 16.0 / 9.0)
try fixture.compact(controller, aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false)
try settled(controller, compact: false)
}
@@ -354,17 +387,39 @@ struct CompactWindowChecks {
"stale completion cannot revive an urgently restored session")
try fixture.restored()
}
controller.toggle(aspect: 16.0 / 9.0)
try settled(controller, compact: true)
let interruptedPanel = controller.presentationWindow!
controller.restore(resumeFullScreen: false)
try require(controller.isTransitioning && controller.presentationWindow === interruptedPanel,
"animated restoration keeps presenting the renderer's panel")
try fixture.preservedContent(in: interruptedPanel)
controller.restore(resumeFullScreen: false, animated: false)
try require(!controller.isTransitioning && controller.presentationWindow === window
&& interruptedPanel.contentView !== fixture.content,
"urgent restore interrupts exit and returns content immediately")
try fixture.restored()
try nativeCompletionOpportunity {
try require(controller.presentationWindow === window && !controller.isTransitioning,
"stale exit completion cannot reclaim restored content")
try fixture.restored()
}
let replacement = try WindowFixture()
defer { replacement.window.close() }
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "attachment interrupts native entry")
let replacedPanel = controller.presentationWindow!
controller.attach(replacement.window)
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
try fixture.restored()
try require(controller.presentationWindow === replacement.window
&& window.delegate === fixture.delegate && replacedPanel.contentView !== fixture.content
&& !replacedPanel.isVisible && !replacedPanel.isKeyWindow,
"replacement restores old delegate and content and retires its panel")
controller.toggle(aspect: 4.0 / 3.0)
try settled(controller, compact: true) { try fixture.restored() }
try replacement.compact(aspect: 4.0 / 3.0)
try replacement.compact(controller, aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false)
try settled(controller, compact: false) { try fixture.restored() }
try replacement.restored()
@@ -386,8 +441,13 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isTransitioning, "close interrupts native entry")
let closingPanel = controller.presentationWindow!
window.close()
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session")
try require(controller.presentationWindow == nil && window.delegate === fixture.delegate
&& fixture.delegate.closes == 1 && closingPanel.contentView !== fixture.content
&& !closingPanel.isVisible && !closingPanel.isKeyWindow,
"closing the normal window restores its delegate and closes its compact panel")
let closedState = WindowState(window)
controller.attach(replacement.window)
controller.toggle(aspect: 16.0 / 9.0)
@@ -396,10 +456,23 @@ struct CompactWindowChecks {
try fixture.preservedContent()
}
try settled(controller, compact: true, checking: closedWindowUnchanged)
try replacement.compact(aspect: 16.0 / 9.0)
try replacement.compact(controller, aspect: 16.0 / 9.0)
controller.restore(resumeFullScreen: false)
try settled(controller, compact: false, checking: closedWindowUnchanged)
try replacement.restored()
controller.toggle(aspect: 16.0 / 9.0)
let userClosedPanel = controller.presentationWindow!
userClosedPanel.close()
try require(!controller.isCompact && !controller.isTransitioning
&& controller.presentationWindow == nil && replacement.delegate.closes == 1
&& replacement.window.delegate === replacement.delegate,
"closing compact presentation also closes and detaches the normal window")
try replacement.restored()
try nativeCompletionOpportunity {
try require(controller.presentationWindow == nil && !controller.isTransitioning,
"stale completion cannot revive a user-closed compact panel")
try replacement.restored()
}
}
static func rememberedSize() throws {
@@ -414,19 +487,21 @@ struct CompactWindowChecks {
}
controller.attach(fixture.window)
controller.toggle(aspect: 16.0 / 9.0)
var resized = fixture.window.frame
let panel = controller.presentationWindow!
var resized = panel.frame
resized.size = NSSize(width: 240, height: 135)
fixture.window.setFrame(resized, display: false)
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
panel.setFrame(resized, display: false)
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: panel)
controller.restore(resumeFullScreen: false)
try fixture.restored()
controller.toggle(aspect: 16.0 / 9.0)
try require(close(fixture.window.frame.width, 240) && close(fixture.window.frame.height, 135),
let reentered = controller.presentationWindow!
try require(close(reentered.frame.width, 240) && close(reentered.frame.height, 135),
"reentering compact restores the manually chosen size")
// Programmatic fitting and ordinary-window resizing must not replace the preference.
resized.size = NSSize(width: 320, height: 180)
fixture.window.setFrame(resized, display: false)
reentered.setFrame(resized, display: false)
controller.updateAspect(4.0 / 3.0)
controller.restore(resumeFullScreen: false)
try fixture.restored()
@@ -440,9 +515,10 @@ struct CompactWindowChecks {
}
reloaded.attach(replacement.window)
reloaded.toggle(aspect: 4.0 / 3.0)
try require(close(replacement.window.frame.width, 240) && close(replacement.window.frame.height, 180),
let reloadedPanel = reloaded.presentationWindow!
try require(close(reloadedPanel.frame.width, 240) && close(reloadedPanel.frame.height, 180),
"a recreated controller restores preferred width with the new video's aspect")
try replacement.compact(aspect: 4.0 / 3.0)
try replacement.compact(reloaded, aspect: 4.0 / 3.0)
reloaded.restore(resumeFullScreen: false)
try replacement.restored()
}
@@ -458,7 +534,7 @@ struct CompactWindowChecks {
controller.toggle(aspect: 16.0 / 9.0)
try require(controller.isCompact && !controller.isTransitioning,
"Reduce Motion enters with geometry and controls already settled")
try fixture.compact(aspect: 16.0 / 9.0)
try fixture.compact(controller, aspect: 16.0 / 9.0)
controller.toggle(aspect: 16.0 / 9.0)
try require(!controller.isCompact && !controller.isTransitioning,
"Reduce Motion restores without a pending frame animation")
@@ -507,13 +583,14 @@ struct CompactWindowChecks {
static func compactQueue() throws {
let fixture = try WindowFixture()
let controller = CompactWindowController(reduceMotion: { true })
let window = fixture.window
let normal = fixture.window
defer {
controller.restore(resumeFullScreen: false, animated: false)
window.close()
normal.close()
}
controller.attach(window)
controller.attach(normal)
controller.toggle(aspect: 16.0 / 9.0)
let window = controller.presentationWindow!
try require(controller.queueHeight == 0, "compact entry hides the queue")
let videoFrame = window.frame
let expanded = controller.queueLayout(visible: true)!
@@ -559,7 +636,7 @@ struct CompactWindowChecks {
try fixture.restored()
controller.toggle(aspect: 4.0 / 3.0)
try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
try fixture.compact(aspect: 4.0 / 3.0)
try fixture.compact(controller, aspect: 4.0 / 3.0)
controller.restore(resumeFullScreen: false)
try fixture.restored()
}