feat: add expandable compact queue and sidebar shortcut
This commit is contained in:
+10
-5
@@ -101,22 +101,24 @@ def check_queue_drag(root, app, fixtures):
|
||||
def check_window_layout(root, app, fixtures):
|
||||
result_path = root / "window-layout.json"
|
||||
diagnostics = root / "window-layout.log"
|
||||
compact_queue_only = os.environ.get("TEREBI_CHECK_COMPACT_QUEUE_ONLY") == "1"
|
||||
extra = ["--compact-queue-only"] if compact_queue_only else []
|
||||
result = subprocess.run(
|
||||
["open", "-g", "-n", "-W", "--stderr", str(diagnostics), "-a", str(app),
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path)],
|
||||
str(fixtures / "cache.mp4"), "--args", "--no-config", "--check-window-layout", str(result_path), *extra],
|
||||
capture_output=True, text=True, timeout=40,
|
||||
)
|
||||
log = diagnostics.read_text(errors="replace") if diagnostics.exists() else ""
|
||||
report = json.loads(result_path.read_text()) if result_path.exists() else {}
|
||||
expected_animations = 0 if report.get("reduceMotion") else 16
|
||||
expected_queue_animations = 0 if report.get("reduceMotion") else 9
|
||||
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 36
|
||||
expected_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 19)
|
||||
expected_queue_animations = 0 if report.get("reduceMotion") else (4 if compact_queue_only else 13)
|
||||
if (result.returncode != 0 or not report.get("passed") or report.get("steps") != 45
|
||||
or report.get("animatedTransitions") != expected_animations
|
||||
or report.get("animatedQueueTransitions") != expected_queue_animations):
|
||||
raise RuntimeError(f"native window layout failed: {report}; {result.stdout}{result.stderr}{log}")
|
||||
print(f"PASS native SwiftUI window layout: {report['animatedTransitions']} compact and "
|
||||
f"{report['animatedQueueTransitions']} queue animations, stable video size, responsive reversals, "
|
||||
"screen-edge fitting, fullscreen fixed bounds, minimum sizes and exact normal geometry restored")
|
||||
"downward compact queue with native shortcut and painted content, minimum sizes and exact normal geometry restored")
|
||||
|
||||
def check_startup_errors(root, app):
|
||||
config = root / "invalid-startup.toml"
|
||||
@@ -781,6 +783,9 @@ with tempfile.TemporaryDirectory(prefix="terebi-startup-") as directory:
|
||||
descriptor = os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)
|
||||
launch = None
|
||||
try:
|
||||
if os.environ.get("TEREBI_CHECK_COMPACT_QUEUE_ONLY") == "1":
|
||||
check_window_layout(root, app, fixtures)
|
||||
sys.exit(0)
|
||||
if os.environ.get("TEREBI_CHECK_QUEUE_ADD_ONLY") == "1":
|
||||
check_queue_add(root, app, fixtures)
|
||||
sys.exit(0)
|
||||
|
||||
+119
-4
@@ -590,6 +590,7 @@ private enum WindowLayoutCheck {
|
||||
static var step = 0
|
||||
static var windowIdentity: ObjectIdentifier?
|
||||
static var normalFrame = NSRect.zero
|
||||
static var compactFrame = NSRect.zero
|
||||
static var animationStart: NSRect?
|
||||
static var animationTarget = NSRect.zero
|
||||
static var sawIntermediateFrame = false
|
||||
@@ -598,6 +599,7 @@ private enum WindowLayoutCheck {
|
||||
static var queueVideoSize: NSSize?
|
||||
static var observingQueue = false
|
||||
static var animationQueueWidth: CGFloat = 0
|
||||
static var compactQueueOnly: Bool { CommandLine.arguments.contains("--compact-queue-only") }
|
||||
static var expectsAnimation: Bool { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion }
|
||||
// Infrastructure timeout only; assertions depend on native transition completion.
|
||||
static let deadline = ContinuousClock.now.advanced(by: .seconds(30))
|
||||
@@ -677,7 +679,7 @@ private enum WindowLayoutCheck {
|
||||
let window = NSApp.windows.first(where: { $0.canBecomeMain && !($0 is NSPanel) }) else { return }
|
||||
if model.compactWindow.isTransitioning || model.isQueueTransitioning {
|
||||
window.layoutIfNeeded()
|
||||
if model.isQueueTransitioning && !sidebarHeaderIsVisible(in: window) {
|
||||
if model.isQueueTransitioning && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
||||
finish("Top-right controls left the window corner during queue animation at step \(step)"); return
|
||||
}
|
||||
if let size = queueVideoSize, let video = model.videoView {
|
||||
@@ -693,8 +695,8 @@ private enum WindowLayoutCheck {
|
||||
// These run-loop-delivered requests must remain responsive mid-animation.
|
||||
if model.isQueueTransitioning {
|
||||
let frame = window.frame
|
||||
model.showsQueue.toggle()
|
||||
model.showsQueue.toggle()
|
||||
model.perform(model.queueAction)
|
||||
model.perform(model.queueAction)
|
||||
guard sameFrame(window.frame, frame) else { finish("Queue reversal jumped the window"); return }
|
||||
} else {
|
||||
model.toggleCompact()
|
||||
@@ -720,9 +722,20 @@ private enum WindowLayoutCheck {
|
||||
guard ObjectIdentifier(window) == windowIdentity else { finish("Transition replaced the playback window"); return }
|
||||
if model.fullScreenIntent != nil { return }
|
||||
window.layoutIfNeeded()
|
||||
if compactQueueOnly && step == 0 {
|
||||
if !model.showsQueue {
|
||||
guard toggleQueue(model, in: window) else { finish("Normal queue shortcut was not handled"); return }
|
||||
return
|
||||
}
|
||||
step = 36
|
||||
}
|
||||
if model.showsQueue && !model.isCompact && !sidebarHeaderIsVisible(in: window) {
|
||||
finish("Sidebar pin, Cast or settings glyph is not visible at step \(step)"); return
|
||||
}
|
||||
if step >= 36 {
|
||||
advanceCompactQueue(model, window: window)
|
||||
return
|
||||
}
|
||||
if step >= 33 {
|
||||
switch step {
|
||||
case 33, 34:
|
||||
@@ -771,7 +784,9 @@ private enum WindowLayoutCheck {
|
||||
observeAnimation(from: window, to: target)
|
||||
observingQueue = true
|
||||
queueVideoSize = model.videoView?.bounds.size
|
||||
model.showsQueue = showsQueue
|
||||
guard toggleQueue(model, in: window), model.showsQueue == showsQueue else {
|
||||
finish("Native queue shortcut failed in normal mode"); return
|
||||
}
|
||||
}
|
||||
case 1, 5:
|
||||
window.setFrame(NSRect(x: 100, y: 200, width: 763, height: 368), display: true)
|
||||
@@ -800,6 +815,106 @@ private enum WindowLayoutCheck {
|
||||
}
|
||||
step += 1
|
||||
}
|
||||
static func toggleQueue(_ model: PlayerModel, in window: NSWindow, repeatKey: Bool = false) -> Bool {
|
||||
let event = NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: [.command, .shift],
|
||||
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||
characters: "L", charactersIgnoringModifiers: "l", isARepeat: repeatKey, keyCode: 37)!
|
||||
return model.handleKeyEvent(event, in: window)
|
||||
}
|
||||
|
||||
static func observeCompactQueue(_ model: PlayerModel, window: NSWindow) {
|
||||
let target = model.compactWindow.queueLayout(visible: !model.queueVisible)!
|
||||
observeAnimation(from: window, to: target.frame)
|
||||
observingQueue = true
|
||||
queueVideoSize = model.videoView?.bounds.size
|
||||
if !toggleQueue(model, in: window) { finish("Native queue shortcut was not handled") }
|
||||
}
|
||||
|
||||
static func compactQueueHasPaintedContent(_ model: PlayerModel, window: NSWindow) -> Bool {
|
||||
guard let content = window.contentView else { return false }
|
||||
let height = model.compactWindow.queueHeight
|
||||
let region = NSRect(x: 0, y: content.isFlipped ? content.bounds.maxY - height : 0,
|
||||
width: content.bounds.width, height: height)
|
||||
guard let bitmap = content.bitmapImageRepForCachingDisplay(in: region) else { return false }
|
||||
content.cacheDisplay(in: region, to: bitmap)
|
||||
var textPixels = 0
|
||||
for x in 0..<bitmap.pixelsWide {
|
||||
for y in 0..<bitmap.pixelsHigh {
|
||||
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
|
||||
color.alphaComponent > 0.5,
|
||||
min(color.redComponent, color.greenComponent, color.blueComponent) > 0.5 {
|
||||
textPixels += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return textPixels > 30
|
||||
}
|
||||
|
||||
static func advanceCompactQueue(_ model: PlayerModel, window: NSWindow) {
|
||||
switch step {
|
||||
case 36, 42:
|
||||
guard !model.isFullScreen, !model.isCompact else { finish("Expanded compact/fullscreen restoration failed"); return }
|
||||
if step == 42, !sameFrame(window.frame, normalFrame) {
|
||||
finish("Expanded queue corrupted the normal restoration frame"); return
|
||||
}
|
||||
normalFrame = window.frame
|
||||
observeAnimation(from: window, to: CompactWindowController.initialFrame(
|
||||
aspect: model.state.video_aspect_ratio,
|
||||
visibleFrame: (window.screen ?? NSScreen.main!).visibleFrame, width: 320))
|
||||
model.toggleCompact()
|
||||
case 37, 43:
|
||||
guard model.isCompact, !model.queueVisible, model.compactWindow.queueHeight == 0 else {
|
||||
finish("Compact entry did not default to a hidden queue"); return
|
||||
}
|
||||
compactFrame = window.frame
|
||||
observeCompactQueue(model, window: window)
|
||||
case 38, 40, 44:
|
||||
let height = model.compactWindow.queueHeight
|
||||
guard model.queueVisible, height > 0, model.showsQueue,
|
||||
abs(window.frame.maxY - compactFrame.maxY) < 1,
|
||||
abs(window.frame.width / (window.frame.height - height) - model.state.video_aspect_ratio) < 0.01,
|
||||
let video = model.videoView, abs(video.bounds.height - compactFrame.height) < 2 else {
|
||||
finish("Compact queue did not expand below the unchanged video at step \(step)"); return
|
||||
}
|
||||
guard compactQueueHasPaintedContent(model, window: window) else {
|
||||
finish("Expanded compact queue did not paint its native rows and footer"); return
|
||||
}
|
||||
guard toggleQueue(model, in: window, repeatKey: true), model.queueVisible else {
|
||||
finish("Held queue shortcut toggled repeatedly"); return
|
||||
}
|
||||
if step == 38 {
|
||||
observeCompactQueue(model, window: window)
|
||||
} else if step == 40 {
|
||||
if compactQueueOnly {
|
||||
observeAnimation(from: window, to: normalFrame)
|
||||
model.toggleCompact()
|
||||
step = 41
|
||||
} else {
|
||||
// The explicit fullscreen action must discard dropdown geometry before entering the native Space.
|
||||
model.toggleFullScreen()
|
||||
}
|
||||
} else {
|
||||
observeAnimation(from: window, to: normalFrame)
|
||||
model.toggleCompact()
|
||||
}
|
||||
case 39:
|
||||
guard !model.queueVisible, sameFrame(window.frame, compactFrame) else {
|
||||
finish("Compact queue did not collapse to its original video frame"); return
|
||||
}
|
||||
observeCompactQueue(model, window: window)
|
||||
case 41:
|
||||
guard model.isFullScreen, !model.isCompact, model.compactWindow.queueHeight == 0 else {
|
||||
finish("Fullscreen retained compact dropdown geometry"); return
|
||||
}
|
||||
model.toggleFullScreen()
|
||||
default:
|
||||
guard !model.isCompact, model.queueVisible, sameFrame(window.frame, normalFrame) else {
|
||||
finish("Normal queue visibility or restoration frame was lost"); return
|
||||
}
|
||||
finish(); return
|
||||
}
|
||||
step += 1
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -96,11 +96,11 @@ struct CompactWindowChecks {
|
||||
|
||||
// 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 {
|
||||
line: UInt = #line, 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")
|
||||
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()
|
||||
@@ -504,12 +504,73 @@ struct CompactWindowChecks {
|
||||
}
|
||||
}
|
||||
|
||||
static func compactQueue() throws {
|
||||
let fixture = try WindowFixture()
|
||||
let controller = CompactWindowController(reduceMotion: { true })
|
||||
let window = fixture.window
|
||||
defer {
|
||||
controller.restore(resumeFullScreen: false, animated: false)
|
||||
window.close()
|
||||
}
|
||||
controller.attach(window)
|
||||
controller.toggle(aspect: 16.0 / 9.0)
|
||||
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(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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,16 @@ struct ConfigurationChecks {
|
||||
try require(PlayerAction(name: "open_youtube") == nil, "obsolete YouTube action has no alias")
|
||||
try require(bindings.shortcuts(for: .openStream) == [try KeyChord("cmd+shift+o")],
|
||||
"stream action preserves its shortcut")
|
||||
try require(PlayerAction(name: "show_queue") == .showQueue && PlayerAction.hideQueue.name == "hide_queue",
|
||||
"queue visibility actions round-trip through configuration")
|
||||
try require(Set(bindings.actions(for: try KeyChord("cmd+shift+l"))) == [.showQueue, .hideQueue]
|
||||
&& !PlayerAction.showQueue.allowsRepeat && !PlayerAction.hideQueue.allowsRepeat,
|
||||
"queue shortcut toggles without auto-repeat")
|
||||
try requireError(["open_youtube"]) { _ = try KeyBindings(overrides: ["open_youtube": []]) }
|
||||
let inversePairs: [(PlayerAction, PlayerAction)] = [
|
||||
(.play, .pause), (.mute, .unmute), (.enterFullscreen, .exitFullscreen),
|
||||
(.enterCompact, .exitCompact), (.enableAlwaysOnTop, .disableAlwaysOnTop),
|
||||
(.showQueue, .hideQueue),
|
||||
(.showBufferInfo, .hideBufferInfo), (.enableCompactSnapping, .disableCompactSnapping),
|
||||
(.enableAutomaticQuality, .disableAutomaticQuality)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user