feat: animate compact window transitions
This commit is contained in:
+288
-77
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import CoreFoundation
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
@@ -93,87 +94,182 @@ struct CompactWindowChecks {
|
||||
"target width shrinks to usable bounds without distorting ordinary aspect")
|
||||
}
|
||||
|
||||
static func windowBehavior() throws {
|
||||
_ = NSApplication.shared
|
||||
guard let screen = NSScreen.main else {
|
||||
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
||||
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
|
||||
static func settled(_ controller: CompactWindowController, compact: Bool,
|
||||
checking: () throws -> Void = {}) throws {
|
||||
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
|
||||
while controller.isTransitioning {
|
||||
try checking()
|
||||
try require(ContinuousClock.now < deadline, "native compact transition completes")
|
||||
CFRunLoopRunInMode(.defaultMode, 0.01, false)
|
||||
}
|
||||
// Creating a window does not display it: never orderFront, makeKey, or enter native fullscreen.
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
try checking()
|
||||
try require(controller.isCompact == compact, "settled mode matches the latest destination")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct WindowState {
|
||||
let frame: NSRect
|
||||
let minSize: NSSize
|
||||
let contentMinSize: NSSize
|
||||
let aspect: NSSize
|
||||
let style: NSWindow.StyleMask
|
||||
let titleVisibility: NSWindow.TitleVisibility
|
||||
let transparentTitlebar: Bool
|
||||
let collection: NSWindow.CollectionBehavior
|
||||
let level: NSWindow.Level
|
||||
let hides: Bool
|
||||
let drag: Bool
|
||||
|
||||
init(_ window: NSWindow) {
|
||||
frame = window.frame
|
||||
minSize = window.minSize
|
||||
contentMinSize = window.contentMinSize
|
||||
aspect = window.aspectRatio
|
||||
style = window.styleMask
|
||||
titleVisibility = window.titleVisibility
|
||||
transparentTitlebar = window.titlebarAppearsTransparent
|
||||
collection = window.collectionBehavior
|
||||
level = window.level
|
||||
hides = window.hidesOnDeactivate
|
||||
drag = window.isMovableByWindowBackground
|
||||
}
|
||||
|
||||
func requireMatches(_ window: NSWindow) throws {
|
||||
try CompactWindowChecks.require(CompactWindowChecks.sameFrame(window.frame, frame),
|
||||
"restore recovers original normal geometry")
|
||||
try CompactWindowChecks.require(window.minSize == minSize && window.contentMinSize == contentMinSize
|
||||
&& window.aspectRatio == aspect, "restore recovers aspect and both minima")
|
||||
try CompactWindowChecks.require(window.styleMask == style && window.collectionBehavior == collection
|
||||
&& window.level == level, "restore recovers style, Spaces policy, and level")
|
||||
try CompactWindowChecks.require(window.titleVisibility == titleVisibility
|
||||
&& window.titlebarAppearsTransparent == transparentTitlebar,
|
||||
"restore recovers original native chrome")
|
||||
try CompactWindowChecks.require(window.hidesOnDeactivate == hides
|
||||
&& window.isMovableByWindowBackground == drag,
|
||||
"restore recovers deactivate and background drag behavior")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WindowFixture {
|
||||
let window: NSWindow
|
||||
let content: NSView
|
||||
let sentinel: NSView
|
||||
let sentinelFrame: NSRect
|
||||
let original: WindowState
|
||||
let identity: ObjectIdentifier
|
||||
|
||||
init() throws {
|
||||
// Never orderFront, makeKey, or create an actual native fullscreen Space.
|
||||
window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
guard let content = window.contentView else { throw Failure(description: "FAIL: missing content view") }
|
||||
let sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
||||
content.addSubview(sentinel)
|
||||
let sentinelFrame = sentinel.frame
|
||||
window.minSize = NSSize(width: 300, height: 180)
|
||||
window.contentMinSize = NSSize(width: 220, height: 120)
|
||||
window.aspectRatio = NSSize(width: 4, height: 3)
|
||||
window.collectionBehavior = [.managed, .fullScreenPrimary]
|
||||
window.level = .normal
|
||||
window.hidesOnDeactivate = true
|
||||
window.isMovableByWindowBackground = false
|
||||
let originalFrame = window.frame
|
||||
let originalMinSize = window.minSize
|
||||
let originalContentMinSize = window.contentMinSize
|
||||
let originalAspect = window.aspectRatio
|
||||
let originalStyle = window.styleMask
|
||||
let originalTitleVisibility = window.titleVisibility
|
||||
let originalTransparentTitlebar = window.titlebarAppearsTransparent
|
||||
let originalCollection = window.collectionBehavior
|
||||
let originalLevel = window.level
|
||||
let originalHides = window.hidesOnDeactivate
|
||||
let originalDrag = window.isMovableByWindowBackground
|
||||
let identity = ObjectIdentifier(window)
|
||||
let controller = CompactWindowController()
|
||||
defer { controller.restore(resumeFullScreen: false) }
|
||||
controller.attach(window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "attached window starts ordinary")
|
||||
try require(controller.snappingEnabled, "fresh isolated preferences default snapping on")
|
||||
window.isReleasedWhenClosed = false
|
||||
guard let content = window.contentView else {
|
||||
throw Failure(description: "FAIL: missing content view")
|
||||
}
|
||||
self.content = content
|
||||
sentinel = NSView(frame: NSRect(x: 5, y: 7, width: 12, height: 14))
|
||||
content.addSubview(sentinel)
|
||||
sentinelFrame = sentinel.frame
|
||||
window.minSize = NSSize(width: 300, height: 180)
|
||||
window.contentMinSize = NSSize(width: 220, height: 120)
|
||||
window.aspectRatio = NSSize(width: 4, height: 3)
|
||||
window.collectionBehavior = [.managed, .fullScreenPrimary]
|
||||
window.level = .normal
|
||||
window.hidesOnDeactivate = true
|
||||
window.isMovableByWindowBackground = false
|
||||
original = WindowState(window)
|
||||
identity = ObjectIdentifier(window)
|
||||
}
|
||||
|
||||
func preservedContent() throws {
|
||||
try require(ObjectIdentifier(window) == identity && sentinel.window === window,
|
||||
"compact mode keeps the actual rendering window")
|
||||
try require(window.contentView === content && sentinel.superview === content
|
||||
&& content.subviews.count == 1 && sameFrame(sentinel.frame, sentinelFrame),
|
||||
"compact mode preserves content and attached sentinel without reparenting")
|
||||
try require(!window.isVisible, "checks never display a window")
|
||||
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
|
||||
&& 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")
|
||||
}
|
||||
|
||||
func restored() throws {
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "restore finishes in ordinary mode")
|
||||
try require(sameFrame(window.frame, originalFrame), "restore recovers original normal geometry")
|
||||
try require(window.minSize == originalMinSize && window.contentMinSize == originalContentMinSize
|
||||
&& window.aspectRatio == originalAspect, "restore recovers aspect and both minima")
|
||||
try require(window.styleMask == originalStyle && window.collectionBehavior == originalCollection
|
||||
&& window.level == originalLevel, "restore recovers style, Spaces policy, and level")
|
||||
try require(window.titleVisibility == originalTitleVisibility
|
||||
&& window.titlebarAppearsTransparent == originalTransparentTitlebar,
|
||||
"restore recovers original native chrome")
|
||||
try require(window.hidesOnDeactivate == originalHides
|
||||
&& window.isMovableByWindowBackground == originalDrag,
|
||||
"restore recovers deactivate and background drag behavior")
|
||||
try original.requireMatches(window)
|
||||
try preservedContent()
|
||||
}
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isCompact && !controller.isTransitioning, "windowed toggle enters compact synchronously")
|
||||
try require(window.level == .floating && !window.hidesOnDeactivate, "compact floats and remains visible on deactivate")
|
||||
try require(window.collectionBehavior.contains([.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||
try require(window.collectionBehavior.intersection([.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||
"compact removes incompatible primary and auxiliary policies")
|
||||
try require(window.contentMinSize == NSSize(width: 160, height: 90), "compact uses 160x90 content minimum")
|
||||
try require(window.isMovableByWindowBackground, "compact allows background dragging")
|
||||
try require(window.canBecomeKey, "compact window still accepts keyboard playback controls")
|
||||
try preservedContent()
|
||||
func compact(aspect: Double) throws {
|
||||
try CompactWindowChecks.require(window.level == .floating && !window.hidesOnDeactivate,
|
||||
"compact floats and remains visible on deactivate")
|
||||
try CompactWindowChecks.require(window.collectionBehavior.contains(
|
||||
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
||||
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
||||
try CompactWindowChecks.require(window.collectionBehavior.intersection(
|
||||
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
||||
"compact removes incompatible primary and auxiliary policies")
|
||||
try CompactWindowChecks.require(window.contentMinSize == NSSize(width: 160, height: 90),
|
||||
"compact uses 160x90 content minimum")
|
||||
try CompactWindowChecks.require(window.isMovableByWindowBackground,
|
||||
"compact allows background dragging")
|
||||
try CompactWindowChecks.require(window.canBecomeKey, "compact accepts keyboard playback controls")
|
||||
try CompactWindowChecks.require(window.titleVisibility == .hidden
|
||||
&& window.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,
|
||||
CGFloat(aspect)),
|
||||
"compact enters at inset top right with playback aspect")
|
||||
try preservedContent()
|
||||
}
|
||||
}
|
||||
|
||||
let visible = (window.screen ?? screen).visibleFrame
|
||||
try require(close(window.frame.maxX, visible.maxX - 16)
|
||||
&& close(window.frame.maxY, visible.maxY - 16)
|
||||
&& close(window.frame.width / window.frame.height, 16.0 / 9.0),
|
||||
"actual compact window enters at inset top right with playback aspect")
|
||||
// A later real native round-trip gives canceled animations a completion opportunity without
|
||||
// guessing elapsed animation time. Watch the interrupted window throughout and after it.
|
||||
static func nativeCompletionOpportunity(checking: () throws -> Void) throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { false })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
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)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false, checking: checking)
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
static func windowBehavior() throws {
|
||||
_ = NSApplication.shared
|
||||
guard NSScreen.main != nil else {
|
||||
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
||||
}
|
||||
let fixture = try WindowFixture()
|
||||
let window = fixture.window
|
||||
let controller = CompactWindowController(reduceMotion: { false })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
window.close()
|
||||
}
|
||||
controller.attach(window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "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 settled(controller, compact: true)
|
||||
try fixture.compact(aspect: 16.0 / 9.0)
|
||||
|
||||
let visible = (window.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)
|
||||
@@ -182,13 +278,13 @@ struct CompactWindowChecks {
|
||||
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")
|
||||
controller.snappingEnabled = false
|
||||
let second = CompactWindowController(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)
|
||||
controller.snapAfterDrag()
|
||||
try require(sameFrame(window.frame, nearEdge), "disabled preference leaves an actual moved window untouched")
|
||||
second.snappingEnabled = true
|
||||
let third = CompactWindowController(snappingEnabled: false)
|
||||
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)
|
||||
@@ -200,26 +296,141 @@ struct CompactWindowChecks {
|
||||
try require(controller.isCompact && !controller.isTransitioning, "aspect update retains compact mode")
|
||||
try require(close(window.frame.width / window.frame.height, 4.0 / 3.0),
|
||||
"actual compact window follows changed playback aspect")
|
||||
try preservedContent()
|
||||
try fixture.preservedContent()
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Enter -> exit before entry completes: the original normal snapshot must survive.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "entry is in flight before its destination changes")
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Exit -> enter keeps the newest aspect and must not save an intermediate restore frame.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try settled(controller, compact: true)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(!controller.isCompact && controller.isTransitioning,
|
||||
"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)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Alternating requests within one native animation settle only at the latest destination.
|
||||
for compactDestination in [false, true] {
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "alternating requests interrupt native entry")
|
||||
for _ in 0..<(compactDestination ? 6 : 5) {
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
}
|
||||
try settled(controller, compact: compactDestination)
|
||||
if compactDestination {
|
||||
try fixture.compact(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false)
|
||||
}
|
||||
try fixture.restored()
|
||||
}
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
// Urgent teardown is immediate; old native completion cannot touch its restored snapshot.
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "urgent restore interrupts native entry")
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "urgent restore settles immediately")
|
||||
try fixture.restored()
|
||||
try nativeCompletionOpportunity {
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"stale completion cannot revive an urgently restored session")
|
||||
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")
|
||||
controller.attach(replacement.window)
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "replacement starts ordinary")
|
||||
try fixture.restored()
|
||||
controller.toggle(aspect: 4.0 / 3.0)
|
||||
try settled(controller, compact: true) { try fixture.restored() }
|
||||
try replacement.compact(aspect: 4.0 / 3.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false) { try fixture.restored() }
|
||||
try replacement.restored()
|
||||
|
||||
controller.attach(window)
|
||||
// Replay AppKit's failure lifecycle without creating a visible fullscreen Space.
|
||||
NotificationCenter.default.post(name: NSWindow.willEnterFullScreenNotification, object: window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning && !controller.isCompact,
|
||||
"compact entry waits for an in-flight native fullscreen transition")
|
||||
window.delegate?.windowDidFailToEnterFullScreen?(window)
|
||||
try restored()
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
NotificationCenter.default.post(name: NSWindow.willExitFullScreenNotification, object: window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
window.delegate?.windowDidFailToExitFullScreen?(window)
|
||||
try settled(controller, compact: false)
|
||||
try fixture.restored()
|
||||
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(controller.isTransitioning, "close interrupts native entry")
|
||||
window.close()
|
||||
try require(!controller.isCompact && !controller.isTransitioning, "closed window ends its session")
|
||||
let closedState = WindowState(window)
|
||||
controller.attach(replacement.window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
func closedWindowUnchanged() throws {
|
||||
try closedState.requireMatches(window)
|
||||
try fixture.preservedContent()
|
||||
}
|
||||
try settled(controller, compact: true, checking: closedWindowUnchanged)
|
||||
try replacement.compact(aspect: 16.0 / 9.0)
|
||||
controller.restore(resumeFullScreen: false)
|
||||
try settled(controller, compact: false, checking: closedWindowUnchanged)
|
||||
try replacement.restored()
|
||||
}
|
||||
|
||||
static func reducedMotion() throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { true })
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
fixture.window.close()
|
||||
}
|
||||
controller.attach(fixture.window)
|
||||
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)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion restores without a pending frame animation")
|
||||
try fixture.restored()
|
||||
try nativeCompletionOpportunity {
|
||||
try require(!controller.isCompact && !controller.isTransitioning,
|
||||
"Reduce Motion leaves no completion that can revive compact state")
|
||||
try fixture.restored()
|
||||
}
|
||||
}
|
||||
|
||||
static func main() throws {
|
||||
try geometry()
|
||||
try windowBehavior()
|
||||
try reducedMotion()
|
||||
print("Compact window checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user