654 lines
36 KiB
Swift
654 lines
36 KiB
Swift
import AppKit
|
|
import CoreFoundation
|
|
import Foundation
|
|
|
|
@main
|
|
@MainActor
|
|
struct CompactWindowChecks {
|
|
struct Failure: Error, CustomStringConvertible {
|
|
let description: String
|
|
}
|
|
|
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) throws {
|
|
if !condition() { throw Failure(description: "FAIL: \(message)") }
|
|
}
|
|
|
|
static func close(_ actual: CGFloat, _ expected: CGFloat) -> Bool {
|
|
abs(actual - expected) < 0.001
|
|
}
|
|
|
|
static func sameFrame(_ actual: NSRect, _ expected: NSRect) -> Bool {
|
|
close(actual.minX, expected.minX) && close(actual.minY, expected.minY)
|
|
&& close(actual.width, expected.width) && close(actual.height, expected.height)
|
|
}
|
|
|
|
static func geometry() throws {
|
|
let screen = NSRect(x: 100, y: 200, width: 1_000, height: 700)
|
|
func snap(_ x: CGFloat, _ y: CGFloat, to expectedX: CGFloat, _ expectedY: CGFloat) throws {
|
|
let frame = NSRect(x: x, y: y, width: 320, height: 180)
|
|
let expected = NSRect(x: expectedX, y: expectedY, width: 320, height: 180)
|
|
try require(sameFrame(CompactWindowController.snappedFrame(frame, visibleFrame: screen), expected),
|
|
"snap (\(x), \(y)) to (\(expectedX), \(expectedY)), preserving size")
|
|
}
|
|
// Inset edges: x = 116 / 764, y = 216 / 704. Each axis acts independently.
|
|
try snap(130, 460, to: 116, 460)
|
|
try snap(750, 460, to: 764, 460)
|
|
try snap(440, 230, to: 440, 216)
|
|
try snap(440, 690, to: 440, 704)
|
|
try snap(130, 230, to: 116, 216)
|
|
try snap(750, 230, to: 764, 216)
|
|
try snap(130, 690, to: 116, 704)
|
|
try snap(750, 690, to: 764, 704)
|
|
try snap(140, 460, to: 116, 460)
|
|
try snap(740, 460, to: 764, 460)
|
|
try snap(440, 240, to: 440, 216)
|
|
try snap(440, 680, to: 440, 704)
|
|
try snap(92, 460, to: 116, 460)
|
|
try snap(788, 460, to: 764, 460)
|
|
try snap(141, 460, to: 141, 460)
|
|
try snap(739, 460, to: 739, 460)
|
|
try snap(440, 241, to: 440, 241)
|
|
try snap(440, 679, to: 440, 679)
|
|
try snap(440, 192, to: 440, 216)
|
|
try snap(440, 728, to: 440, 704)
|
|
try snap(91, 460, to: 91, 460)
|
|
try snap(789, 460, to: 789, 460)
|
|
try snap(440, 191, to: 440, 191)
|
|
try snap(440, 729, to: 440, 729)
|
|
try snap(440, 460, to: 440, 460)
|
|
|
|
let negativeScreen = NSRect(x: -1_440, y: -900, width: 1_440, height: 900)
|
|
let negativeFrame = NSRect(x: -1_410, y: -870, width: 320, height: 180)
|
|
try require(sameFrame(CompactWindowController.snappedFrame(negativeFrame, visibleFrame: negativeScreen),
|
|
NSRect(x: -1_424, y: -884, width: 320, height: 180)),
|
|
"negative-origin screen uses its own inset edges")
|
|
let narrowScreen = NSRect(x: 0, y: 0, width: 100, height: 200)
|
|
try require(sameFrame(CompactWindowController.snappedFrame(
|
|
NSRect(x: 35, y: 80, width: 40, height: 20), visibleFrame: narrowScreen),
|
|
NSRect(x: 44, y: 80, width: 40, height: 20)),
|
|
"overlapping magnetic zones choose the closest edge, not first edge")
|
|
|
|
let usableScreen = NSRect(x: -1_200, y: 50, width: 1_200, height: 800)
|
|
let landscape = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: usableScreen, width: 320)
|
|
try require(sameFrame(landscape, NSRect(x: -336, y: 654, width: 320, height: 180)),
|
|
"16:9 enters at top right with 320pt width and 16pt inset")
|
|
let portrait = CompactWindowController.initialFrame(aspect: 9.0 / 16.0, visibleFrame: usableScreen, width: 320)
|
|
try require(sameFrame(portrait, NSRect(x: -336, y: 834 - 5_120.0 / 9.0, width: 320, height: 5_120.0 / 9.0)),
|
|
"portrait preserves exact aspect when it fits")
|
|
for aspect in [16.0 / 9.0, 9.0 / 16.0, 100.0, 0.01] {
|
|
let frame = CompactWindowController.initialFrame(aspect: aspect, visibleFrame: usableScreen, width: 320)
|
|
try require(frame.origin.x.isFinite && frame.origin.y.isFinite
|
|
&& frame.width.isFinite && frame.height.isFinite,
|
|
"initial geometry is finite for aspect \(aspect)")
|
|
try require(frame.width >= 160 && frame.height >= 90,
|
|
"extreme aspect \(aspect) never defeats minimum size")
|
|
try require(frame.minX >= usableScreen.minX + 16 && frame.minY >= usableScreen.minY + 16
|
|
&& frame.maxX <= usableScreen.maxX - 16 && frame.maxY <= usableScreen.maxY - 16,
|
|
"aspect \(aspect) stays inside inset usable screen")
|
|
try require(close(frame.maxX, usableScreen.maxX - 16) && close(frame.maxY, usableScreen.maxY - 16),
|
|
"aspect \(aspect) stays anchored top right")
|
|
}
|
|
let smallScreen = NSRect(x: 0, y: 0, width: 300, height: 400)
|
|
let bounded = CompactWindowController.initialFrame(aspect: 16.0 / 9.0, visibleFrame: smallScreen, width: 320)
|
|
try require(sameFrame(bounded, NSRect(x: 16, y: 233.25, width: 268, height: 150.75)),
|
|
"target width shrinks to usable bounds without distorting ordinary aspect")
|
|
}
|
|
|
|
// Run the real AppKit completion callbacks. The monotonic deadline is only a hang watchdog.
|
|
static func settled(_ controller: CompactWindowController, compact: Bool,
|
|
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
|
|
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 WindowDelegate: NSObject, NSWindowDelegate {
|
|
var closes = 0
|
|
func windowWillClose(_ notification: Notification) { closes += 1 }
|
|
}
|
|
|
|
@MainActor
|
|
final class WindowFixture {
|
|
let window: NSWindow
|
|
let content: NSView
|
|
let sentinel: NSView
|
|
let sentinelFrame: NSRect
|
|
let original: WindowState
|
|
let delegate = WindowDelegate()
|
|
let activationPolicy: NSApplication.ActivationPolicy
|
|
|
|
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
|
|
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
|
|
window.delegate = delegate
|
|
original = WindowState(window)
|
|
activationPolicy = NSApp.activationPolicy()
|
|
}
|
|
|
|
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),
|
|
"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 {
|
|
try original.requireMatches(window)
|
|
try preservedContent()
|
|
}
|
|
|
|
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(panel.collectionBehavior.contains(
|
|
[.canJoinAllSpaces, .canJoinAllApplications, .fullScreenAuxiliary]),
|
|
"compact can join Spaces, other-app fullscreen, and Stage Manager")
|
|
try CompactWindowChecks.require(panel.collectionBehavior.intersection(
|
|
[.primary, .auxiliary, .fullScreenPrimary, .fullScreenNone]).isEmpty,
|
|
"compact removes incompatible primary and auxiliary policies")
|
|
try CompactWindowChecks.require(panel.contentMinSize == NSSize(width: 160, height: 90),
|
|
"compact uses 160x90 content minimum")
|
|
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 = (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(in: panel)
|
|
}
|
|
}
|
|
|
|
// 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(controller, 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
|
|
&& 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(in: controller.presentationWindow)
|
|
try settled(controller, compact: true)
|
|
try fixture.compact(controller, aspect: 16.0 / 9.0)
|
|
|
|
let panel = controller.presentationWindow!
|
|
let visible = (panel.screen ?? NSScreen.main!).visibleFrame
|
|
let nearEdge = NSRect(x: visible.minX + 26, y: visible.minY + 27,
|
|
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(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")
|
|
panel.setFrame(nearEdge, display: false)
|
|
controller.snapAfterDrag()
|
|
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)
|
|
panel.setFrame(resized, display: false)
|
|
controller.updateAspect(16.0 / 9.0)
|
|
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(panel.frame.width / panel.frame.height, 4.0 / 3.0),
|
|
"actual compact window follows changed playback aspect")
|
|
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)
|
|
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(controller, 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(controller, 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 settled(controller, compact: false)
|
|
try fixture.restored()
|
|
controller.restore(resumeFullScreen: false)
|
|
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()
|
|
}
|
|
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(controller, 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 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")
|
|
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)
|
|
func closedWindowUnchanged() throws {
|
|
try closedState.requireMatches(window)
|
|
try fixture.preservedContent()
|
|
}
|
|
try settled(controller, compact: true, checking: closedWindowUnchanged)
|
|
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 {
|
|
let suite = "terebi.compact-size-check.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
let fixture = try WindowFixture()
|
|
let controller = CompactWindowController(defaults: defaults, reduceMotion: { true })
|
|
defer {
|
|
controller.restore(resumeFullScreen: false, animated: false)
|
|
fixture.window.close()
|
|
}
|
|
controller.attach(fixture.window)
|
|
controller.toggle(aspect: 16.0 / 9.0)
|
|
let panel = controller.presentationWindow!
|
|
var resized = panel.frame
|
|
resized.size = NSSize(width: 240, height: 135)
|
|
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)
|
|
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)
|
|
reentered.setFrame(resized, display: false)
|
|
controller.updateAspect(4.0 / 3.0)
|
|
controller.restore(resumeFullScreen: false)
|
|
try fixture.restored()
|
|
NotificationCenter.default.post(name: NSWindow.didEndLiveResizeNotification, object: fixture.window)
|
|
|
|
let replacement = try WindowFixture()
|
|
let reloaded = CompactWindowController(defaults: UserDefaults(suiteName: suite)!, reduceMotion: { true })
|
|
defer {
|
|
reloaded.restore(resumeFullScreen: false, animated: false)
|
|
replacement.window.close()
|
|
}
|
|
reloaded.attach(replacement.window)
|
|
reloaded.toggle(aspect: 4.0 / 3.0)
|
|
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(reloaded, aspect: 4.0 / 3.0)
|
|
reloaded.restore(resumeFullScreen: false)
|
|
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(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")
|
|
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 continuousQueueResize() throws {
|
|
let aspect = 16.0 / 9.0
|
|
let sidebar: CGFloat = 260
|
|
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 1_160, height: 900 / aspect),
|
|
styleMask: [.titled, .resizable, .fullSizeContentView],
|
|
backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
defer { window.close() }
|
|
let controller = CompactWindowController()
|
|
controller.normalVideoLayout = { (aspect, sidebar) }
|
|
controller.attach(window)
|
|
for horizontal in [true, false] {
|
|
let start = window.frame
|
|
let chrome = start.height - window.contentRect(forFrameRect: start).height
|
|
window.delegate?.windowWillStartLiveResize?(Notification(name: NSWindow.willStartLiveResizeNotification, object: window))
|
|
// AppKit proposes sizes from the pointer's drag origin. The delegate's
|
|
// correction of the other dimension must not feed back into axis selection.
|
|
for offset in [CGFloat(20), 21, 22, 23, 24, 23, 22, 21, 20, 0, -1, -2] {
|
|
let proposal = NSSize(width: start.width + (horizontal ? offset : 0),
|
|
height: start.height + (horizontal ? 0 : offset))
|
|
let size = window.delegate!.windowWillResize!(window, to: proposal)
|
|
try require(close(horizontal ? size.width : size.height,
|
|
horizontal ? proposal.width : proposal.height),
|
|
"continuous queue resize follows the dragged edge without snapping back")
|
|
try require(close((size.width - sidebar) / (size.height - chrome), aspect),
|
|
"continuous queue resize keeps the video aspect")
|
|
var frame = start
|
|
frame.size = size
|
|
window.setFrame(frame, display: false)
|
|
}
|
|
window.delegate?.windowDidEndLiveResize?(Notification(name: NSWindow.didEndLiveResizeNotification, object: window))
|
|
}
|
|
}
|
|
|
|
static func compactQueue() throws {
|
|
let fixture = try WindowFixture()
|
|
let controller = CompactWindowController(reduceMotion: { true })
|
|
let normal = fixture.window
|
|
defer {
|
|
controller.restore(resumeFullScreen: false, animated: false)
|
|
normal.close()
|
|
}
|
|
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)!
|
|
controller.updateQueue(height: expanded.height, frame: expanded.frame)
|
|
try require(close(window.frame.maxY, videoFrame.maxY) && close(window.frame.width, videoFrame.width)
|
|
&& close(window.frame.height - controller.queueHeight, videoFrame.height),
|
|
"queue expands downwards without moving or resizing the compact video")
|
|
try require(window.aspectRatio == .zero && close(window.minSize.height, 90 + controller.queueHeight),
|
|
"expanded queue constrains video, not the combined window aspect")
|
|
for horizontal in [true, false] {
|
|
let start = window.frame
|
|
window.delegate?.windowWillStartLiveResize?(Notification(name: NSWindow.willStartLiveResizeNotification, object: window))
|
|
for offset in [CGFloat(20), 21, 22, 21, 0, -10] {
|
|
let proposed = NSSize(width: start.width + (horizontal ? offset : 0),
|
|
height: start.height + (horizontal ? 0 : offset))
|
|
let size = window.delegate!.windowWillResize!(window, to: proposed)
|
|
try require(close(size.width / (size.height - controller.queueHeight), 16.0 / 9.0),
|
|
"both resize axes preserve the compact video's aspect with queue open")
|
|
try require(close(horizontal ? size.width : size.height, horizontal ? proposed.width : proposed.height),
|
|
"queue resize follows the chosen edge continuously")
|
|
window.setFrame(NSRect(origin: start.origin, size: size), display: false)
|
|
}
|
|
window.delegate?.windowDidEndLiveResize?(Notification(name: NSWindow.didEndLiveResizeNotification, object: window))
|
|
}
|
|
controller.updateAspect(4.0 / 3.0)
|
|
try require(abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
|
|
"metadata changes aspect only in the video above the queue: \(window.frame), queue \(controller.queueHeight)")
|
|
let hidden = controller.queueLayout(visible: false)!
|
|
let top = window.frame.maxY
|
|
controller.updateQueue(height: hidden.height, frame: hidden.frame)
|
|
try require(controller.queueHeight == 0 && close(window.frame.maxY, top)
|
|
&& abs(window.frame.height - window.frame.width / (4.0 / 3.0)) <= 1,
|
|
"hiding the queue contracts upwards and restores native aspect locking")
|
|
var atBottom = window.frame
|
|
atBottom.origin.y = (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
|
|
window.setFrame(atBottom, display: false)
|
|
let bounded = controller.queueLayout(visible: true)!
|
|
controller.updateQueue(height: bounded.height, frame: bounded.frame)
|
|
try require(window.frame.minY >= (window.screen ?? NSScreen.main!).visibleFrame.minY + 16
|
|
&& abs(window.frame.height - controller.queueHeight - window.frame.width / (4.0 / 3.0)) <= 1,
|
|
"queue remains onscreen at the lower edge without distorting the video")
|
|
controller.restore(resumeFullScreen: false)
|
|
try fixture.restored()
|
|
controller.toggle(aspect: 4.0 / 3.0)
|
|
try require(controller.queueHeight == 0, "each compact entry resets queue visibility")
|
|
try fixture.compact(controller, aspect: 4.0 / 3.0)
|
|
controller.restore(resumeFullScreen: false)
|
|
try fixture.restored()
|
|
}
|
|
|
|
static func main() throws {
|
|
try geometry()
|
|
try windowBehavior()
|
|
try reducedMotion()
|
|
try rememberedSize()
|
|
try continuousQueueResize()
|
|
try compactQueue()
|
|
print("Compact window checks passed")
|
|
}
|
|
}
|