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
+109 -33
View File
@@ -7,6 +7,7 @@ final class CompactWindowController: NSObject {
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
private(set) var isCompact = false
private(set) var isTransitioning = false
var presentationWindow: NSWindow? { compactPanel ?? window }
private(set) var queueHeight: CGFloat = 0
static let transitionDuration: TimeInterval = 0.25
@@ -30,9 +31,13 @@ final class CompactWindowController: NSObject {
let level: NSWindow.Level
let hidesOnDeactivate: Bool
let isMovableByWindowBackground: Bool
let isVisible: Bool
let firstResponder: NSResponder?
let contentSuperview: NSView?
let contentFrame: NSRect
@MainActor
init(_ window: NSWindow) {
init(_ window: NSWindow, contentView: NSView) {
frame = window.frame
minSize = window.minSize
contentMinSize = window.contentMinSize
@@ -45,12 +50,19 @@ final class CompactWindowController: NSObject {
level = window.level
hidesOnDeactivate = window.hidesOnDeactivate
isMovableByWindowBackground = window.isMovableByWindowBackground
isVisible = window.isVisible
firstResponder = window.firstResponder
contentSuperview = contentView === window.contentView ? nil : contentView.superview
contentFrame = contentView.frame
}
}
private enum FullScreenTransition { case entering, exiting }
private weak var window: NSWindow?
private weak var contentView: NSView?
private var compactPanel: NSPanel?
private var panelDelegate: FullScreenDelegate?
private var snapshot: Snapshot?
private var fullScreenTransition: FullScreenTransition?
private var delegateProxy: FullScreenDelegate?
@@ -77,7 +89,7 @@ final class CompactWindowController: NSObject {
deinit { NotificationCenter.default.removeObserver(self) }
func attach(_ window: NSWindow) {
func attach(_ window: NSWindow, contentView: NSView? = nil) {
guard window !== self.window else { return }
stopFrameAnimation()
if let oldWindow = self.window, fullScreenTransition == nil,
@@ -87,6 +99,7 @@ final class CompactWindowController: NSObject {
restoreDelegate()
NotificationCenter.default.removeObserver(self)
self.window = window
self.contentView = contentView ?? window.contentView
snapshot = nil
fullScreenTransition = nil
sessionActive = false
@@ -133,13 +146,13 @@ final class CompactWindowController: NSObject {
func updateAspect(_ aspect: Double) {
guard aspect.isFinite, aspect > 0, aspect != videoAspect else { return }
videoAspect = aspect
guard isCompact, !isTransitioning, let window else { return }
guard isCompact, !isTransitioning, let window = presentationWindow else { return }
fitCompactWindow(window, initial: false)
}
// The reveal and native frame share the model's animation clock.
func queueLayout(visible: Bool) -> (height: CGFloat, frame: NSRect)? {
guard isCompact, !isTransitioning, let window else { return nil }
guard isCompact, !isTransitioning, let window = presentationWindow else { return nil }
let usable = Self.usableFrame(visibleFrame(for: window))
let height = visible ? min(240, max(0, usable.height - 90)) : 0
let video = Self.compactSize(aspect: videoAspect, width: window.frame.width,
@@ -152,7 +165,7 @@ final class CompactWindowController: NSObject {
}
func updateQueue(height: CGFloat, frame: NSRect) {
guard isCompact, let window else { return }
guard isCompact, let window = presentationWindow else { return }
window.resizeIncrements = NSSize(width: 1, height: 1)
queueHeight = height
fittedVideoAspect = frame.width / (frame.height - height)
@@ -166,7 +179,7 @@ final class CompactWindowController: NSObject {
}
func snapAfterDrag() {
guard isCompact, !isTransitioning, snappingEnabled, let window else { return }
guard isCompact, !isTransitioning, snappingEnabled, let window = presentationWindow else { return }
let frame = Self.snappedFrame(window.frame, visibleFrame: visibleFrame(for: window))
if frame.origin != window.frame.origin { window.setFrameOrigin(frame.origin) }
}
@@ -174,7 +187,7 @@ final class CompactWindowController: NSObject {
@objc private func windowNotification(_ notification: Notification) {
switch notification.name {
case NSWindow.didEndLiveResizeNotification:
guard isCompact, !isTransitioning, let window,
guard isCompact, !isTransitioning, let window = presentationWindow,
window.frame.width.isFinite, window.frame.width > 0 else { return }
defaults.set(Double(window.frame.width), forKey: Self.widthKey)
case NSWindow.willEnterFullScreenNotification:
@@ -187,7 +200,12 @@ final class CompactWindowController: NSObject {
fullScreenTransition = nil
reconcile()
case NSWindow.willCloseNotification:
if notification.object as? NSWindow === compactPanel {
window?.close()
return
}
stopFrameAnimation()
if let window { removeCompactPanel(returningContentTo: window) }
restoreDelegate()
NotificationCenter.default.removeObserver(self)
window = nil
@@ -318,7 +336,7 @@ final class CompactWindowController: NSObject {
} else if !isCompact {
enterCompact(window, animated: animated)
} else {
if fittedAspect != videoAspect { fitCompactWindow(window, initial: false) }
if fittedAspect != videoAspect, let panel = compactPanel { fitCompactWindow(panel, initial: false) }
setState(compact: true, transitioning: false)
}
return
@@ -333,11 +351,12 @@ final class CompactWindowController: NSObject {
}
if let snapshot {
queueHeight = 0
setState(compact: false, transitioning: animated)
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
animateFrame(snapshot.frame, of: window, animated: animated) { [weak self, weak window] in
setState(compact: false, transitioning: true)
let restoringWindow = presentationWindow ?? window
restoringWindow.aspectRatio = .zero
restoringWindow.minSize = .zero
restoringWindow.contentMinSize = .zero
animateFrame(snapshot.frame, of: restoringWindow, animated: animated) { [weak self, weak window] in
guard let self, let window else { return }
self.restoreSnapshot(to: window)
self.reconcile()
@@ -357,33 +376,84 @@ final class CompactWindowController: NSObject {
window.toggleFullScreen(nil)
}
private func enterCompact(_ window: NSWindow, animated: Bool) {
// After didExitFullScreen, this is the normal frame, not the fullscreen frame.
snapshot = Snapshot(window)
queueHeight = 0
setState(compact: true, transitioning: animated)
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
// A borderless NSWindow cannot become key; retain native keyboard and resize behavior.
window.styleMask.formUnion([.titled, .fullSizeContentView, .resizable])
private func enterCompact(_ normalWindow: NSWindow, animated: Bool) {
// A regular NSWindow cannot join another app's native fullscreen Space.
// Keep the scene and renderer alive, but present their content in a nonactivating panel.
guard let content = contentView else {
finishSession()
return
}
let snapshot = Snapshot(normalWindow, contentView: content)
self.snapshot = snapshot
let window = NSPanel(contentRect: .zero,
styleMask: [.titled, .closable, .resizable, .fullSizeContentView, .nonactivatingPanel],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.isFloatingPanel = true
window.hidesOnDeactivate = false
window.title = normalWindow.title
window.representedURL = normalWindow.representedURL
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.minSize = NSSize(width: 160, height: 90)
window.contentMinSize = NSSize(width: 160, height: 90)
var behavior = window.collectionBehavior
behavior.subtract([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone, .moveToActiveSpace])
behavior.formUnion([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary])
window.collectionBehavior = behavior
window.titlebarSeparatorStyle = .none
window.appearance = normalWindow.appearance
window.isOpaque = normalWindow.isOpaque
window.backgroundColor = normalWindow.backgroundColor
window.hasShadow = normalWindow.hasShadow
window.alphaValue = normalWindow.alphaValue
window.ignoresMouseEvents = normalWindow.ignoresMouseEvents
window.acceptsMouseMovedEvents = normalWindow.acceptsMouseMovedEvents
window.level = .floating
window.hidesOnDeactivate = false
// Explicit drag surfaces preserve controls' mouse tracking in compact mode too.
window.collectionBehavior = [.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]
window.isMovableByWindowBackground = false
window.setFrame(normalWindow.frame, display: false)
let proxy = FullScreenDelegate(owner: self, original: nil)
panelDelegate = proxy
window.delegate = proxy
compactPanel = window
for name in [NSWindow.willCloseNotification, NSWindow.didEndLiveResizeNotification] {
NotificationCenter.default.addObserver(self, selector: #selector(windowNotification(_:)),
name: name, object: window)
}
queueHeight = 0
setState(compact: true, transitioning: animated)
normalWindow.orderOut(nil)
if content === normalWindow.contentView { normalWindow.contentView = nil }
else { content.removeFromSuperview() }
window.contentView = content
window.minSize = NSSize(width: 160, height: 90)
window.contentMinSize = window.minSize
if snapshot.isVisible {
window.orderFrontRegardless()
if NSApp.isActive { window.makeKey() }
window.makeFirstResponder(snapshot.firstResponder)
}
fitCompactWindow(window, initial: true, animated: animated)
}
private func removeCompactPanel(returningContentTo window: NSWindow) {
guard let panel = compactPanel else { return }
NotificationCenter.default.removeObserver(self, name: nil, object: panel)
panel.delegate = nil
panelDelegate = nil
compactPanel = nil
window.title = panel.title
window.representedURL = panel.representedURL
let content = panel.contentView
panel.contentView = nil
if let content, let snapshot, let superview = snapshot.contentSuperview {
superview.addSubview(content)
content.frame = snapshot.contentFrame
} else {
window.contentView = content
}
panel.close()
}
private func restoreSnapshot(to window: NSWindow) {
guard let snapshot else { return }
let wasKey = compactPanel?.isKeyWindow ?? false
removeCompactPanel(returningContentTo: window)
window.aspectRatio = .zero
window.minSize = .zero
window.contentMinSize = .zero
@@ -402,7 +472,13 @@ final class CompactWindowController: NSObject {
} else {
window.aspectRatio = snapshot.aspectRatio
}
if snapshot.isVisible {
if wasKey { window.makeKeyAndOrderFront(nil) }
else { window.orderFront(nil) }
window.makeFirstResponder(snapshot.firstResponder)
}
self.snapshot = nil
onChange?()
}
private func finishSession() {
@@ -429,7 +505,7 @@ final class CompactWindowController: NSObject {
window.animator().setFrame(frame, display: true)
} completionHandler: { [weak self, weak window] in
MainActor.assumeIsolated {
guard let self, let window, self.window === window, self.frameAnimating,
guard let self, let window, self.presentationWindow === window, self.frameAnimating,
self.frameAnimationGeneration == generation else { return }
self.frameAnimating = false
completion()
@@ -441,7 +517,7 @@ final class CompactWindowController: NSObject {
guard frameAnimating else { return }
frameAnimating = false
frameAnimationGeneration &+= 1
guard let window else { return }
guard let window = presentationWindow else { return }
NSAnimationContext.runAnimationGroup { context in
context.duration = 0
window.animator().setFrame(window.frame, display: true)