diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 9d591ce3..2e1bb0bc 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1530,6 +1530,12 @@ final class BrowserPaneDropTargetView: NSView { final class WindowBrowserSlotView: NSView { override var isOpaque: Bool { false } + override var isHidden: Bool { + didSet { + guard isHidden, !oldValue, let window else { return } + yieldOwnedFirstResponderIfNeeded(in: window, reason: "slotHidden") + } + } private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) private var searchOverlayHostingView: NSHostingView? @@ -1571,6 +1577,13 @@ final class WindowBrowserSlotView: NSView { nil } + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil, let currentWindow = window { + yieldOwnedFirstResponderIfNeeded(in: currentWindow, reason: "slotWillLeaveWindow") + } + super.viewWillMove(toWindow: newWindow) + } + override func layout() { super.layout() paneDropTargetView.frame = bounds @@ -1739,6 +1752,23 @@ final class WindowBrowserSlotView: NSView { return window.makeFirstResponder(nil) } + @discardableResult + private func yieldOwnedFirstResponderIfNeeded(in window: NSWindow, reason: String) -> Bool { + guard let firstResponder = window.firstResponder, + let owningView = firstResponder.browserPortalOwningView, + owningView === self || owningView.isDescendant(of: self) else { + return false + } +#if DEBUG + dlog( + "browser.slot.firstResponder.yield reason=\(reason) " + + "slot=\(browserPortalDebugToken(self)) " + + "responder=\(String(describing: type(of: firstResponder)))" + ) +#endif + return window.makeFirstResponder(nil) + } + func pinHostedWebView(_ webView: WKWebView) { guard webView.superview === self else { return } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1a3d1522..f8ecbf5a 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2434,7 +2434,9 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase { @MainActor final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { - private final class WKInspectorProbeView: NSView {} + private final class WKInspectorProbeView: NSView { + override var acceptsFirstResponder: Bool { true } + } private final class FakeInspector: NSObject { private(set) var attachCount = 0 @@ -11697,6 +11699,51 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { ) } + func testHidingBrowserSlotYieldsOwnedInspectorFirstResponder() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + realizeWindowLayout(window) + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let slot = WindowBrowserSlotView(frame: NSRect(x: 40, y: 24, width: 260, height: 180)) + contentView.addSubview(slot) + + let inspectorContainer = NSView(frame: slot.bounds) + inspectorContainer.autoresizingMask = [.width, .height] + let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds) + inspectorView.autoresizingMask = [.width, .height] + inspectorContainer.addSubview(inspectorView) + slot.addSubview(inspectorContainer) + contentView.layoutSubtreeIfNeeded() + + XCTAssertTrue( + window.makeFirstResponder(inspectorView), + "Precondition failed: inspector probe should become first responder" + ) + XCTAssertTrue(window.firstResponder === inspectorView) + + slot.isHidden = true + + XCTAssertFalse( + window.firstResponder === inspectorView, + "Hiding a browser slot should yield any owned inspector responder before it goes off-screen" + ) + if let firstResponderView = window.firstResponder as? NSView { + XCTAssertFalse( + firstResponderView === slot || firstResponderView.isDescendant(of: slot), + "Hiding a browser slot should not leave first responder inside the hidden slot" + ) + } + } + func testHiddenPortalSyncDoesNotStealLocallyHostedDevToolsWebViewDuringResize() { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 520, height: 320),