fix: use a nonactivating compact panel across fullscreen spaces
This commit is contained in:
+48
-15
@@ -275,6 +275,10 @@ private enum WindowDragCheck {
|
||||
}
|
||||
|
||||
static func interceptDrags(in window: NSWindow) {
|
||||
for (method, original) in implementations.reversed() {
|
||||
imp_removeBlock(method_setImplementation(method, original))
|
||||
}
|
||||
implementations.removeAll()
|
||||
target = window
|
||||
let method = class_getInstanceMethod(type(of: window), #selector(NSWindow.performDrag(with:)))!
|
||||
let original = unsafeBitCast(method_getImplementation(method),
|
||||
@@ -309,8 +313,9 @@ private enum WindowDragCheck {
|
||||
guard ContinuousClock.now < deadline else {
|
||||
finish("Window drag timed out at \(names[probe]), phase \(phase)"); return
|
||||
}
|
||||
guard let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||
let model = PlayerModel.shared
|
||||
guard let window = model.compactWindow.presentationWindow
|
||||
?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||
let fixtures = URL(fileURLWithPath: CommandLine.arguments.last!, isDirectory: true)
|
||||
if target == nil {
|
||||
guard model.canTogglePlayback else { return }
|
||||
@@ -322,10 +327,11 @@ private enum WindowDragCheck {
|
||||
window.makeMain()
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
if target !== window { interceptDrags(in: window) }
|
||||
if phase == 0 {
|
||||
guard NSApp.isActive, window.isKeyWindow, window.isMainWindow else {
|
||||
guard NSApp.isActive, window.isKeyWindow, !window.canBecomeMain || window.isMainWindow else {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.makeMain()
|
||||
if window.canBecomeMain { window.makeMain() }
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return
|
||||
}
|
||||
@@ -588,7 +594,10 @@ private enum WindowLayoutCheck {
|
||||
static var timer: Timer?
|
||||
static var resultURL: URL!
|
||||
static var step = 0
|
||||
static var windowIdentity: ObjectIdentifier?
|
||||
static var normalWindow: NSWindow?
|
||||
static var renderer: NSView?
|
||||
static var content: NSView?
|
||||
static var activationPolicy: NSApplication.ActivationPolicy?
|
||||
static var normalFrame = NSRect.zero
|
||||
static var compactFrame = NSRect.zero
|
||||
static var animationStart: NSRect?
|
||||
@@ -676,7 +685,28 @@ private enum WindowLayoutCheck {
|
||||
let model = PlayerModel.shared
|
||||
guard ContinuousClock.now < deadline else { finish("Window layout did not settle at step \(step)"); return }
|
||||
guard model.canTogglePlayback, model.state.has_video,
|
||||
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||
let normal = normalWindow ?? NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }),
|
||||
let video = model.videoView else { return }
|
||||
let window = model.compactWindow.presentationWindow ?? normal
|
||||
let hostedContent = model.hostedPlayerView ?? window.contentView
|
||||
if normalWindow == nil {
|
||||
normalWindow = normal
|
||||
renderer = video
|
||||
content = hostedContent
|
||||
activationPolicy = NSApp.activationPolicy()
|
||||
}
|
||||
guard video === renderer, hostedContent === content, hostedContent?.window === window,
|
||||
video.window === window, NSApp.activationPolicy() == activationPolicy else {
|
||||
finish("Transition replaced the content/renderer or changed app activation policy"); return
|
||||
}
|
||||
if window !== normal {
|
||||
guard let panel = window as? NSPanel, panel.styleMask.contains(.nonactivatingPanel),
|
||||
!normal.isVisible, !normal.isKeyWindow else {
|
||||
finish("Compact presentation is not an independent nonactivating panel hiding the normal window"); return
|
||||
}
|
||||
} else if model.isCompact {
|
||||
finish("Compact playback retained the normal window"); return
|
||||
}
|
||||
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
|
||||
window.layoutIfNeeded()
|
||||
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
||||
@@ -718,8 +748,9 @@ private enum WindowLayoutCheck {
|
||||
queueVideoSize = nil
|
||||
observingQueue = false
|
||||
}
|
||||
if windowIdentity == nil { windowIdentity = ObjectIdentifier(window) }
|
||||
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
||||
guard model.isCompact ? window !== normal : window === normal else {
|
||||
finish("Settled presentation does not match the requested mode"); return
|
||||
}
|
||||
if model.fullScreenIntent != nil { return }
|
||||
window.layoutIfNeeded()
|
||||
if compactQueueOnly && step == 0 {
|
||||
@@ -1079,20 +1110,21 @@ private enum CLIInstanceCheck {
|
||||
let model = PlayerModel.shared
|
||||
let pid = ProcessInfo.processInfo.processIdentifier
|
||||
// Text input can create non-main auxiliary windows; count actual player windows.
|
||||
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) && $0.contentView != nil }
|
||||
let windows = NSApp.windows.filter { $0.canBecomeMain && !($0 is NSPanel) }
|
||||
let presentation = model.compactWindow.presentationWindow ?? windows.first
|
||||
for window in windows { window.alphaValue = 0 }
|
||||
let request = directory.appendingPathComponent("keys-\(pid).json")
|
||||
var pendingRequestID = ""
|
||||
if let data = try? Data(contentsOf: request),
|
||||
let command = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let id = command["id"] as? String, id != requestID, let window = windows.first {
|
||||
let id = command["id"] as? String, id != requestID, let window = presentation {
|
||||
pendingRequestID = id
|
||||
if !NSApp.isActive || !window.isKeyWindow || !window.isMainWindow {
|
||||
if !NSApp.isActive || !window.isKeyWindow || window.canBecomeMain && !window.isMainWindow {
|
||||
// Synthetic native key requests need foreground focus; cooperative activation
|
||||
// can be refused by the unrelated app currently active on the workstation.
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
window.makeMain()
|
||||
if window.canBecomeMain { window.makeMain() }
|
||||
} else {
|
||||
requestID = id
|
||||
if let media = command["media"] as? String { model.open(URL(fileURLWithPath: media)) }
|
||||
@@ -1140,8 +1172,9 @@ private enum CLIInstanceCheck {
|
||||
}
|
||||
}
|
||||
if let events = command["events"] as? [[String: Any]] {
|
||||
window.makeKey()
|
||||
for input in events {
|
||||
let window = model.compactWindow.presentationWindow ?? window
|
||||
window.makeKey()
|
||||
if let title = input["select"] as? String {
|
||||
guard let menu = NSApp.mainMenu else { fatalError("Native application menu is missing") }
|
||||
prepareMenu(menu)
|
||||
@@ -1206,14 +1239,14 @@ private enum CLIInstanceCheck {
|
||||
"requestID": requestID,
|
||||
"pendingRequestID": pendingRequestID,
|
||||
"active": NSApp.isActive,
|
||||
"keyWindow": windows.first?.isKeyWindow ?? false,
|
||||
"mainWindow": windows.first?.isMainWindow ?? false,
|
||||
"keyWindow": presentation?.isKeyWindow ?? false,
|
||||
"mainWindow": presentation?.isMainWindow ?? false,
|
||||
"editorText": editorText,
|
||||
"compact": model.isCompact,
|
||||
"showsQueue": model.showsQueue,
|
||||
"queueURLs": model.queue.items.map(\.url.absoluteString),
|
||||
"queueIndex": model.queue.items.firstIndex { $0.id == model.queue.currentID } ?? -1,
|
||||
"window": windowSnapshot(windows.first),
|
||||
"window": windowSnapshot(presentation),
|
||||
"onTop": model.isOnTop,
|
||||
]
|
||||
do {
|
||||
|
||||
+131
-54
@@ -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()
|
||||
}
|
||||
|
||||
@@ -169,6 +169,7 @@ struct SystemPlaybackChecks {
|
||||
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
model.attachWindow(window)
|
||||
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
|
||||
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: window.windowNumber, context: nil, characters: text,
|
||||
@@ -252,6 +253,33 @@ struct SystemPlaybackChecks {
|
||||
let modal = NSApp.beginModalSession(for: window)
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
|
||||
NSApp.endModalSession(modal)
|
||||
window.orderOut(nil)
|
||||
model.setVolume(0)
|
||||
model.open(URL(fileURLWithPath: CommandLine.arguments[1]).appendingPathComponent("cache.mp4"))
|
||||
until("keyboard consumer has actual video playback") { model.canTogglePlayback && model.state.has_video }
|
||||
model.setPlayback(playing: false)
|
||||
until("keyboard consumer playback pauses") { model.state.status == PLAYER_PAUSED }
|
||||
require(model.perform(.enterCompact), "video playback accepts compact entry")
|
||||
until("keyboard consumer compact entry settles") { !model.compactWindow.isTransitioning }
|
||||
guard let panel = model.compactWindow.presentationWindow as? NSPanel else {
|
||||
fatalError("FAIL: keyboard consumer requires the compact presentation panel")
|
||||
}
|
||||
model.setShowsBufferInfo(false)
|
||||
require(!model.handleKeyEvent(shortcuts[4], in: window) && !model.showsBufferInfo,
|
||||
"hidden normal window cannot dispatch compact playback shortcuts")
|
||||
let compactShortcut = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: .command,
|
||||
timestamp: 0, windowNumber: panel.windowNumber, context: nil, characters: "b",
|
||||
charactersIgnoringModifiers: "b", isARepeat: false, keyCode: 11)!
|
||||
require(model.handleKeyEvent(compactShortcut, in: panel) && model.showsBufferInfo,
|
||||
"active compact panel dispatches the configured playback shortcut")
|
||||
require(!window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow,
|
||||
"keyboard consumer checks never display or key either presentation")
|
||||
model.compactWindow.restore(resumeFullScreen: false, animated: false)
|
||||
model.setShowsBufferInfo(false)
|
||||
focus(lists[0])
|
||||
require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo
|
||||
&& model.compactWindow.presentationWindow === window,
|
||||
"restored normal window resumes dispatching playback shortcuts")
|
||||
}
|
||||
|
||||
static func main() {
|
||||
@@ -296,7 +324,12 @@ struct SystemPlaybackChecks {
|
||||
backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
let surface = PlaybackSurface.DragView(model: model)
|
||||
surface.frame = window.contentView!.bounds
|
||||
window.contentView!.addSubview(surface)
|
||||
model.attachWindow(window)
|
||||
model.open(directory.appendingPathComponent("cache.mp4"))
|
||||
until("queue geometry fixture has actual video playback") { model.canTogglePlayback && model.state.has_video }
|
||||
let videoFrame = window.frame
|
||||
model.showsQueue = true
|
||||
until("queue opening settles") { !model.isQueueTransitioning }
|
||||
@@ -308,16 +341,30 @@ struct SystemPlaybackChecks {
|
||||
require(abs(window.frame.width - videoFrame.width) < 0.01 &&
|
||||
abs(window.frame.height - videoFrame.height) < 0.01,
|
||||
"closing queue restores video-only window size")
|
||||
guard let renderer = model.videoView else { fatalError("FAIL: native renderer is unavailable") }
|
||||
let content = window.contentView
|
||||
let activationPolicy = NSApp.activationPolicy()
|
||||
model.open(directory.appendingPathComponent("anamorphic.mp4"))
|
||||
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
|
||||
"compact entry is available before native media opening acknowledges")
|
||||
until("decoded anamorphic metadata replaces provisional compact geometry") {
|
||||
model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
|
||||
abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01
|
||||
guard let presentation = model.compactWindow.presentationWindow else { return false }
|
||||
return model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
|
||||
abs(presentation.frame.width / presentation.frame.height - 8.0 / 3.0) < 0.01
|
||||
}
|
||||
let panel = model.compactWindow.presentationWindow!
|
||||
require(panel !== window && panel is NSPanel && panel.styleMask.contains(.nonactivatingPanel)
|
||||
&& panel.contentView === content && model.videoView === renderer && renderer.window === panel
|
||||
&& !window.isVisible && !window.isKeyWindow && !panel.isVisible && !panel.isKeyWindow
|
||||
&& NSApp.activationPolicy() == activationPolicy,
|
||||
"loading metadata resizes the compact panel without replacing renderer or app policy")
|
||||
model.closeFile()
|
||||
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
||||
"closing loading/loaded media restores immediately")
|
||||
require(model.compactWindow.presentationWindow === window && window.contentView === content
|
||||
&& model.videoView === renderer && renderer.window === window && !window.isVisible && !window.isKeyWindow
|
||||
&& panel.contentView !== content && !panel.isVisible && !panel.isKeyWindow,
|
||||
"urgent media close returns retained content to the hidden normal window and retires the panel")
|
||||
until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY }
|
||||
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user