fix: restore window and keyboard interactions with queue

This commit is contained in:
2026-10-18 01:51:51 +02:00
parent afb0c30e09
commit 07243f0ed7
6 changed files with 279 additions and 39 deletions
+19
View File
@@ -285,6 +285,25 @@ def check_cli_instances(root, app, fixtures):
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
"cold default CLI opens one actual player window")
first_pid = next(iter(first))
opened = input_request(first_pid, 1, lambda state: state["showsQueue"]
and state["window"].get("videoOwnsMouse") is True
and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1,
"queue preserves video size and mouse target", action="queue", visible=True)
window = opened[first_pid]["window"]
if (not window["resizable"] or window["aspectWidth"] != 0 or window["aspectHeight"] != 0
or window["minWidth"] > 660 or window["minHeight"] > 300
or window["maxWidth"] <= window["width"] + 80
or window["maxHeight"] <= window["height"] + 80
or not window["controlsOwnMouse"] or not window["sidebarOwnsMouse"]
or not window["edgesAvoidDragSurface"] or window["surfaceMovesOnMouseDown"]):
raise RuntimeError(f"queue must preserve native resize limits and independent mouse targets: {window}")
input_request(first_pid, 1, lambda state: not state["showsQueue"]
and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1,
"queue closes without changing video size", action="queue", visible=False)
print(f"PASS native queue window: independent video/controls/sidebar/edge targets, "
f"resizable min {window['minWidth']}x{window['minHeight']}, unconstrained maxima and cleared aspect")
if os.environ.get("TEREBI_CHECK_WINDOW_ONLY") == "1":
return
if settings(first[first_pid]) != (False, False, True) or config.parent.exists():
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
invoke([])
+45
View File
@@ -98,6 +98,46 @@ private enum CLIInstanceCheck {
return nil
}
static func windowSnapshot(_ window: NSWindow?) -> [String: Any] {
guard let window, let content = window.contentView else { return [:] }
func surface(in view: NSView) -> NSView? {
if view.identifier?.rawValue == "PlaybackSurface" { return view }
return view.subviews.lazy.compactMap { surface(in: $0) }.first
}
var result: [String: Any] = [
"width": window.frame.width, "height": window.frame.height,
"minWidth": window.minSize.width, "minHeight": window.minSize.height,
"maxWidth": window.maxSize.width, "maxHeight": window.maxSize.height,
"contentMinWidth": window.contentMinSize.width, "contentMinHeight": window.contentMinSize.height,
"contentMaxWidth": window.contentMaxSize.width, "contentMaxHeight": window.contentMaxSize.height,
"aspectWidth": window.aspectRatio.width, "aspectHeight": window.aspectRatio.height,
"resizable": window.styleMask.contains(.resizable),
]
if let surface = surface(in: content) {
func hit(_ point: NSPoint) -> NSView? {
content.hitTest(surface.convert(point, to: content.superview))
}
result["videoOwnsMouse"] = hit(NSPoint(x: surface.bounds.midX, y: surface.bounds.midY)) === surface
result["controlsOwnMouse"] = hit(NSPoint(x: surface.bounds.midX, y: 46)) !== surface
result["sidebarOwnsMouse"] = hit(NSPoint(x: surface.bounds.maxX + 130, y: surface.bounds.midY)) !== surface
result["videoWidth"] = surface.bounds.width
result["videoHeight"] = surface.bounds.height
result["surfaceMovesOnMouseDown"] = surface.mouseDownCanMoveWindow
let edges = [NSPoint(x: 1, y: content.bounds.midY),
NSPoint(x: content.bounds.maxX - 1, y: content.bounds.midY),
NSPoint(x: content.bounds.midX, y: 1),
NSPoint(x: content.bounds.midX, y: content.bounds.maxY - 1)]
let targets = edges.map { point in
content.hitTest(content.convert(point, to: content.superview))
}
result["edgeTargets"] = targets.map { $0.map { String(describing: type(of: $0)) } ?? "native window" }
result["edgesAvoidDragSurface"] = targets.allSatisfy { target in
target !== surface && !(target.map { String(describing: type(of: $0)).contains("ChromeView") } ?? false)
}
}
return result
}
static func run(_ configuration: URL) {
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
@@ -147,6 +187,9 @@ private enum CLIInstanceCheck {
switch action {
case "youtube":
model.openYouTube(command["url"] as! String)
case "queue":
model.showsQueue = command["visible"] as! Bool
model.revealControls()
case "caption":
guard let view = window.contentView, let selector = captionSelector(in: view),
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
@@ -252,6 +295,8 @@ private enum CLIInstanceCheck {
"mainWindow": windows.first?.isMainWindow ?? false,
"editorText": editorText,
"compact": model.isCompact,
"showsQueue": model.showsQueue,
"window": windowSnapshot(windows.first),
"onTop": model.isOnTop,
]
do {
+157
View File
@@ -2,6 +2,20 @@ import AppKit
import MediaPlayer
import TerebiConfiguration
// Native focus eligibility normally depends on the user's Full Keyboard Access setting.
// Force eligibility only; keep the controls' real AppKit responder/key behavior.
private final class KeyboardCheckButton: NSButton {
override var acceptsFirstResponder: Bool { true }
}
private final class KeyboardCheckSlider: NSSlider {
override var acceptsFirstResponder: Bool { true }
}
private final class KeyboardCheckLabel: NSTextField {
override var acceptsFirstResponder: Bool { true }
}
@main
@MainActor
struct SystemPlaybackChecks {
@@ -17,6 +31,50 @@ struct SystemPlaybackChecks {
require(condition(), message)
}
private final class DragCheckWindow: NSWindow {
var drags = 0
var fullscreenToggles = 0
override func performDrag(with event: NSEvent) { drags += 1 }
override func toggleFullScreen(_ sender: Any?) { fullscreenToggles += 1 }
}
static func checkWindowSurface(defaults: UserDefaults) {
let model = PlayerModel(defaults: defaults)
defer { model.shutdown() }
let window = DragCheckWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
styleMask: [.titled, .resizable, .fullSizeContentView],
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)
@MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent {
NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil,
eventNumber: 0, clickCount: clicks, pressure: 0)!
}
require(surface.hitTest(NSPoint(x: 100, y: 100)) === surface,
"video renderer and unused background route mouse input to their owning surface")
require(!surface.mouseDownCanMoveWindow, "native background movement cannot turn clicks or resize into drags")
for point in [NSPoint(x: 1, y: 100), NSPoint(x: 639, y: 100),
NSPoint(x: 100, y: 1), NSPoint(x: 100, y: surface.bounds.maxY - 1)] {
require(surface.hitTest(point) == nil, "playback drag surface leaves all native resize edges untouched")
}
surface.mouseDown(with: event(.leftMouseDown))
require(window.drags == 0, "mouse-down alone never begins window dragging")
surface.mouseUp(with: event(.leftMouseUp))
require(window.drags == 0 && window.fullscreenToggles == 0, "single video click never drags or enters fullscreen")
surface.mouseDown(with: event(.leftMouseDown))
surface.mouseDragged(with: event(.leftMouseDragged))
surface.mouseUp(with: event(.leftMouseUp))
require(window.drags == 1 && window.fullscreenToggles == 0, "real mouse-drag callback invokes native window dragging once")
surface.mouseDown(with: event(.leftMouseDown, clicks: 2))
surface.mouseUp(with: event(.leftMouseUp, clicks: 2))
require(window.drags == 1 && window.fullscreenToggles == 1, "double video click retains fullscreen instead of dragging")
}
static func checkCompactProgress() {
let times: [(Double, Double, String)] = [
(12.9, 45.9, "12 / 45 s"),
@@ -99,6 +157,103 @@ struct SystemPlaybackChecks {
"playback failure stops on failed queue entry rather than skipping")
}
static func checkKeyboardRouting(defaults: UserDefaults) {
let bindings = try! KeyBindings(overrides: [
"play": [], "pause": [], "seek_backward_5s": [],
"show_buffer_info": ["space", "return", "left", "shift+left", "tab", "shift+tab",
"cmd+b", "f12", "ctrl+alt+b"]
])
let model = PlayerModel(defaults: defaults, configuration: PlayerConfiguration(keys: bindings))
defer { model.shutdown() }
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer { window.close() }
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,
charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)!
}
let shortcuts = [event(49, " "), event(36, "\r"), event(123, "\u{F702}"),
event(123, "\u{F702}", .shift), event(11, "b", .command),
event(111, "\u{F70F}", .function)]
func focus(_ responder: NSView) {
window.makeFirstResponder(nil)
window.contentView?.subviews.forEach { $0.removeFromSuperview() }
responder.frame = NSRect(x: 0, y: 0, width: 300, height: 200)
window.contentView?.addSubview(responder)
require(window.makeFirstResponder(responder), "keyboard regression fixture accepts actual window focus")
let focusedResponder = (responder as? NSTextField)?.currentEditor() ?? responder
require(window.firstResponder === focusedResponder,
"keyboard regression fixture owns actual control or field-editor focus")
}
let lists = [NSTableView(), NSOutlineView()]
for list in lists {
list.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("title")))
focus(list)
for shortcut in shortcuts {
model.setShowsBufferInfo(false)
require(model.handleKeyEvent(shortcut, in: window) && model.showsBufferInfo,
"focused queue list dispatches configured \(KeyChord.from(shortcut)!) binding")
require(window.firstResponder === list, "playback shortcuts do not steal queue focus")
}
let traversalModifiers: [NSEvent.ModifierFlags] = [[], .shift]
for flags in traversalModifiers {
model.setShowsBufferInfo(false)
require(!model.handleKeyEvent(event(48, "\t", flags), in: window) && !model.showsBufferInfo,
"Tab and Shift-Tab remain native traversal even when configured")
}
}
let label = KeyboardCheckLabel(labelWithString: "Queue item")
focus(label)
model.setShowsBufferInfo(false)
require(model.handleKeyEvent(shortcuts[0], in: window) && model.showsBufferInfo,
"noneditable nonselectable queue label does not block playback")
let controls: [NSControl] = [KeyboardCheckButton(), KeyboardCheckSlider()]
for control in controls {
focus(control)
for shortcut in shortcuts.prefix(4) {
model.setShowsBufferInfo(false)
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
"deliberately focused native button/slider retains activation and navigation")
require(window.firstResponder === control, "native control keyboard focus is preserved")
}
require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo,
"modified configured bindings still work with native control focus")
}
let editor = NSTextView()
focus(editor)
for shortcut in shortcuts {
model.setShowsBufferInfo(false)
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
"text editing retains configured keys instead of dispatching playback")
}
editor.keyDown(with: shortcuts[0])
require(editor.string == " ", "unconsumed Space reaches actual text entry")
label.isSelectable = true
focus(label)
require(!model.handleKeyEvent(shortcuts[0], in: window),
"selectable text retains native text interaction")
label.isSelectable = false
label.isEditable = true
require(!model.handleKeyEvent(shortcuts[0], in: window),
"editable text field retains native text entry")
focus(lists[0])
let voiceOverChord = event(11, "b", [.control, .option])
require(model.handleKeyEvent(voiceOverChord, in: window) != NSWorkspace.shared.isVoiceOverEnabled,
"VoiceOver retains its Control-Option chords when enabled")
let sheet = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100),
styleMask: .titled, backing: .buffered, defer: false)
sheet.isReleasedWhenClosed = false
window.beginSheet(sheet)
require(!model.handleKeyEvent(shortcuts[0], in: window), "attached sheet blocks player shortcuts")
window.endSheet(sheet)
sheet.close()
let modal = NSApp.beginModalSession(for: window)
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
NSApp.endModalSession(modal)
}
static func main() {
checkCompactProgress()
_ = NSApplication.shared
@@ -106,7 +261,9 @@ struct SystemPlaybackChecks {
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
checkKeyboardRouting(defaults: defaults)
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
checkWindowSurface(defaults: defaults)
defaults.removePersistentDomain(forName: suite)
let initialModel = PlayerModel(defaults: defaults)
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")