231 lines
14 KiB
Swift
231 lines
14 KiB
Swift
import AppKit
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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")
|
|
}
|
|
|
|
static func windowBehavior(defaults: UserDefaults) throws {
|
|
_ = NSApplication.shared
|
|
guard let screen = NSScreen.main else {
|
|
throw Failure(description: "FAIL: native window checks require a macOS GUI session with a screen")
|
|
}
|
|
// 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),
|
|
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(defaults: defaults)
|
|
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")
|
|
|
|
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")
|
|
}
|
|
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 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()
|
|
|
|
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")
|
|
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")
|
|
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")
|
|
controller.snappingEnabled = false
|
|
let second = CompactWindowController(defaults: defaults)
|
|
try require(!second.snappingEnabled, "disabled preference persists across controller instances")
|
|
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(defaults: defaults)
|
|
try require(third.snappingEnabled, "re-enabled preference persists across controller instances")
|
|
controller.snappingEnabled = true
|
|
let resized = NSRect(x: nearEdge.minX, y: nearEdge.minY, width: 240, height: 135)
|
|
window.setFrame(resized, display: false)
|
|
controller.updateAspect(16.0 / 9.0)
|
|
try require(sameFrame(window.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),
|
|
"actual compact window follows changed playback aspect")
|
|
try preservedContent()
|
|
controller.toggle(aspect: 4.0 / 3.0)
|
|
try restored()
|
|
controller.toggle(aspect: 16.0 / 9.0)
|
|
controller.restore(resumeFullScreen: false)
|
|
try restored()
|
|
controller.restore(resumeFullScreen: false)
|
|
try restored()
|
|
// 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()
|
|
}
|
|
|
|
static func main() throws {
|
|
let suite = "terebi.compact-window-checks.\(UUID().uuidString)"
|
|
guard let defaults = UserDefaults(suiteName: suite) else {
|
|
throw Failure(description: "FAIL: cannot create isolated preference suite")
|
|
}
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
try geometry()
|
|
try windowBehavior(defaults: defaults)
|
|
print("Compact window checks passed")
|
|
}
|
|
}
|