diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index da7be546..32d54478 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import ObjectiveC +import SwiftUI import WebKit private var cmuxWindowBrowserPortalKey: UInt8 = 0 @@ -359,6 +360,14 @@ private final class BrowserDropZoneOverlayView: NSView { } } +struct BrowserPortalSearchOverlayConfiguration { + let panelId: UUID + let searchState: BrowserSearchState + let onNext: () -> Void + let onPrevious: () -> Void + let onClose: () -> Void +} + struct BrowserPaneDropContext: Equatable { let workspaceId: UUID let panelId: UUID @@ -419,16 +428,23 @@ enum BrowserPaneDropAction: Equatable { } enum BrowserPaneDropRouting { - static func zone(for location: CGPoint, in size: CGSize) -> DropZone { + private static let padding: CGFloat = 4 + + private static func fullPaneSize(for slotSize: CGSize, topChromeHeight: CGFloat) -> CGSize { + CGSize(width: slotSize.width, height: slotSize.height + max(0, topChromeHeight)) + } + + static func zone(for location: CGPoint, in size: CGSize, topChromeHeight: CGFloat = 0) -> DropZone { + let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight) let edgeRatio: CGFloat = 0.25 - let horizontalEdge = max(80, size.width * edgeRatio) - let verticalEdge = max(80, size.height * edgeRatio) + let horizontalEdge = max(80, fullPaneSize.width * edgeRatio) + let verticalEdge = max(80, fullPaneSize.height * edgeRatio) if location.x < horizontalEdge { return .left - } else if location.x > size.width - horizontalEdge { + } else if location.x > fullPaneSize.width - horizontalEdge { return .right - } else if location.y > size.height - verticalEdge { + } else if location.y > fullPaneSize.height - verticalEdge { return .top } else if location.y < verticalEdge { return .bottom @@ -437,6 +453,47 @@ enum BrowserPaneDropRouting { } } + static func overlayFrame(for zone: DropZone, in size: CGSize, topChromeHeight: CGFloat = 0) -> CGRect { + let fullPaneSize = fullPaneSize(for: size, topChromeHeight: topChromeHeight) + switch zone { + case .center: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height - padding * 2 + ) + case .left: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width / 2 - padding, + height: fullPaneSize.height - padding * 2 + ) + case .right: + return CGRect( + x: fullPaneSize.width / 2, + y: padding, + width: fullPaneSize.width / 2 - padding, + height: fullPaneSize.height - padding * 2 + ) + case .top: + return CGRect( + x: padding, + y: fullPaneSize.height / 2, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height / 2 - padding + ) + case .bottom: + return CGRect( + x: padding, + y: padding, + width: fullPaneSize.width - padding * 2, + height: fullPaneSize.height / 2 - padding + ) + } + } + static func action( for transfer: BrowserPaneDragTransfer, target: BrowserPaneDropContext, @@ -556,7 +613,11 @@ final class BrowserPaneDropTargetView: NSView { } let location = convert(sender.draggingLocation, from: nil) - let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size) + let zone = BrowserPaneDropRouting.zone( + for: location, + in: bounds.size, + topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0 + ) guard let action = BrowserPaneDropRouting.action( for: transfer, target: dropContext, @@ -612,7 +673,11 @@ final class BrowserPaneDropTargetView: NSView { } let location = convert(sender.draggingLocation, from: nil) - let zone = BrowserPaneDropRouting.zone(for: location, in: bounds.size) + let zone = BrowserPaneDropRouting.zone( + for: location, + in: bounds.size, + topChromeHeight: slotView?.effectivePaneTopChromeHeight() ?? 0 + ) activeZone = zone slotView?.setPortalDragDropZone(zone) #if DEBUG @@ -669,11 +734,13 @@ final class WindowBrowserSlotView: NSView { override var isOpaque: Bool { false } private let paneDropTargetView = BrowserPaneDropTargetView(frame: .zero) private let dropZoneOverlayView = BrowserDropZoneOverlayView(frame: .zero) + private var searchOverlayHostingView: NSHostingView? private var forwardedDropZone: DropZone? private var portalDragDropZone: DropZone? private var displayedDropZone: DropZone? private var dropZoneOverlayAnimationGeneration: UInt64 = 0 private var isRefreshingInteractionLayers = false + private var paneTopChromeHeight: CGFloat = 0 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -691,7 +758,6 @@ final class WindowBrowserSlotView: NSView { dropZoneOverlayView.layer?.cornerRadius = 8 dropZoneOverlayView.isHidden = true addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) - addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } @available(*, unavailable) @@ -705,6 +771,12 @@ final class WindowBrowserSlotView: NSView { applyResolvedDropZoneOverlay() } + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + attachDropZoneOverlayIfNeeded() + applyResolvedDropZoneOverlay() + } + func setDropZoneOverlay(zone: DropZone?) { forwardedDropZone = zone applyResolvedDropZoneOverlay() @@ -719,9 +791,62 @@ final class WindowBrowserSlotView: NSView { paneDropTargetView.dropContext = context } + func setPaneTopChromeHeight(_ height: CGFloat) { + let resolvedHeight = max(0, height) + guard abs(paneTopChromeHeight - resolvedHeight) > 0.5 else { return } + paneTopChromeHeight = resolvedHeight + applyResolvedDropZoneOverlay() + } + + func setSearchOverlay(_ configuration: BrowserPortalSearchOverlayConfiguration?) { + guard let configuration else { + searchOverlayHostingView?.removeFromSuperview() + searchOverlayHostingView = nil + return + } + + let rootView = BrowserSearchOverlay( + panelId: configuration.panelId, + searchState: configuration.searchState, + onNext: configuration.onNext, + onPrevious: configuration.onPrevious, + onClose: configuration.onClose + ) + + if let overlay = searchOverlayHostingView { + overlay.rootView = rootView + if overlay.superview !== self { + overlay.removeFromSuperview() + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + return + } + + let overlay = NSHostingView(rootView: rootView) + overlay.translatesAutoresizingMaskIntoConstraints = false + addSubview(overlay) + NSLayoutConstraint.activate([ + overlay.topAnchor.constraint(equalTo: topAnchor), + overlay.bottomAnchor.constraint(equalTo: bottomAnchor), + overlay.leadingAnchor.constraint(equalTo: leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + searchOverlayHostingView = overlay + } + + func effectivePaneTopChromeHeight() -> CGFloat { + paneTopChromeHeight + } + override func didAddSubview(_ subview: NSView) { super.didAddSubview(subview) - guard subview !== paneDropTargetView, subview !== dropZoneOverlayView else { return } + guard subview !== paneDropTargetView else { return } bringInteractionLayersToFrontIfNeeded() } @@ -729,6 +854,17 @@ final class WindowBrowserSlotView: NSView { portalDragDropZone ?? forwardedDropZone } + private func overlayContainerView() -> NSView { + superview ?? self + } + + private func attachDropZoneOverlayIfNeeded() { + let container = overlayContainerView() + guard dropZoneOverlayView.superview !== container else { return } + dropZoneOverlayView.removeFromSuperview() + container.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + } + private func applyResolvedDropZoneOverlay() { let resolvedZone = activeDropZone if resolvedZone != nil, (bounds.width <= 2 || bounds.height <= 2) { @@ -764,6 +900,7 @@ final class WindowBrowserSlotView: NSView { } return } + attachDropZoneOverlayIfNeeded() let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame) @@ -805,7 +942,6 @@ final class WindowBrowserSlotView: NSView { private func interactionLayerPriority(of view: NSView) -> Int { if view === paneDropTargetView { return 1 } - if view === dropZoneOverlayView { return 2 } return 0 } @@ -817,8 +953,11 @@ final class WindowBrowserSlotView: NSView { if paneDropTargetView.superview !== self { addSubview(paneDropTargetView, positioned: .above, relativeTo: nil) } - if dropZoneOverlayView.superview !== self { - addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) + let overlayContainer = overlayContainerView() + if dropZoneOverlayView.superview !== overlayContainer { + attachDropZoneOverlayIfNeeded() + } else if overlayContainer.subviews.last !== dropZoneOverlayView { + overlayContainer.addSubview(dropZoneOverlayView, positioned: .above, relativeTo: nil) } let context = Unmanaged.passUnretained(self).toOpaque() @@ -841,19 +980,13 @@ final class WindowBrowserSlotView: NSView { } private func dropZoneOverlayFrame(for zone: DropZone, in size: CGSize) -> CGRect { - let padding: CGFloat = 4 - switch zone { - case .center: - return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height - padding * 2) - case .left: - return CGRect(x: padding, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) - case .right: - return CGRect(x: size.width / 2, y: padding, width: size.width / 2 - padding, height: size.height - padding * 2) - case .top: - return CGRect(x: padding, y: size.height / 2, width: size.width - padding * 2, height: size.height / 2 - padding) - case .bottom: - return CGRect(x: padding, y: padding, width: size.width - padding * 2, height: size.height / 2 - padding) - } + let localFrame = BrowserPaneDropRouting.overlayFrame( + for: zone, + in: size, + topChromeHeight: paneTopChromeHeight + ) + guard let superview else { return localFrame } + return superview.convert(localFrame, from: self) } private static func rectApproximatelyEqual(_ lhs: CGRect, _ rhs: CGRect, epsilon: CGFloat = 0.5) -> Bool { @@ -884,6 +1017,9 @@ final class WindowBrowserPortal: NSObject { var zPriority: Int var dropZone: DropZone? var paneDropContext: BrowserPaneDropContext? + var searchOverlay: BrowserPortalSearchOverlayConfiguration? + var paneTopChromeHeight: CGFloat + var transientRecoveryReason: String? var transientRecoveryRetriesRemaining: Int } @@ -1142,10 +1278,14 @@ final class WindowBrowserPortal: NSObject { private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView { if let existing = entry.containerView { existing.setPaneDropContext(entry.paneDropContext) + existing.setSearchOverlay(entry.searchOverlay) + existing.setPaneTopChromeHeight(entry.paneTopChromeHeight) return existing } let created = WindowBrowserSlotView(frame: .zero) created.setPaneDropContext(entry.paneDropContext) + created.setSearchOverlay(entry.searchOverlay) + created.setPaneTopChromeHeight(entry.paneTopChromeHeight) #if DEBUG dlog( "browser.portal.container.create web=\(browserPortalDebugToken(webView)) " + @@ -1281,6 +1421,25 @@ final class WindowBrowserPortal: NSObject { entry.containerView?.setPaneDropContext(context) } + func updateSearchOverlay( + forWebViewId webViewId: ObjectIdentifier, + configuration: BrowserPortalSearchOverlayConfiguration? + ) { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.searchOverlay = configuration + entriesByWebViewId[webViewId] = entry + entry.containerView?.setSearchOverlay(configuration) + } + + func updatePaneTopChromeHeight(forWebViewId webViewId: ObjectIdentifier, height: CGFloat) { + guard var entry = entriesByWebViewId[webViewId] else { return } + let resolvedHeight = max(0, height) + guard abs(entry.paneTopChromeHeight - resolvedHeight) > 0.5 else { return } + entry.paneTopChromeHeight = resolvedHeight + entriesByWebViewId[webViewId] = entry + entry.containerView?.setPaneTopChromeHeight(resolvedHeight) + } + func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) { guard ensureInstalled() else { return } @@ -1296,6 +1455,9 @@ final class WindowBrowserPortal: NSObject { zPriority: 0, dropZone: nil, paneDropContext: nil, + searchOverlay: nil, + paneTopChromeHeight: 0, + transientRecoveryReason: nil, transientRecoveryRetriesRemaining: 0 ), webView: webView @@ -1329,6 +1491,9 @@ final class WindowBrowserPortal: NSObject { zPriority: zPriority, dropZone: previousEntry?.dropZone, paneDropContext: previousEntry?.paneDropContext, + searchOverlay: previousEntry?.searchOverlay, + paneTopChromeHeight: previousEntry?.paneTopChromeHeight ?? 0, + transientRecoveryReason: previousEntry?.transientRecoveryReason, transientRecoveryRetriesRemaining: previousEntry?.transientRecoveryRetriesRemaining ?? 0 ) @@ -1446,7 +1611,8 @@ final class WindowBrowserPortal: NSObject { } private func resetTransientRecoveryRetryIfNeeded(forWebViewId webViewId: ObjectIdentifier, entry: inout Entry) { - guard entry.transientRecoveryRetriesRemaining != 0 else { return } + guard entry.transientRecoveryRetriesRemaining != 0 || entry.transientRecoveryReason != nil else { return } + entry.transientRecoveryReason = nil entry.transientRecoveryRetriesRemaining = 0 entriesByWebViewId[webViewId] = entry } @@ -1457,9 +1623,18 @@ final class WindowBrowserPortal: NSObject { webView: WKWebView, reason: String ) -> Bool { - if entry.transientRecoveryRetriesRemaining == 0 { + if entry.transientRecoveryReason != reason { + entry.transientRecoveryReason = reason entry.transientRecoveryRetriesRemaining = Self.transientRecoveryRetryBudget } +#if DEBUG + if entry.transientRecoveryRetriesRemaining <= 0 { + dlog( + "browser.portal.sync.deferRecover.skip web=\(browserPortalDebugToken(webView)) " + + "reason=\(reason) exhausted=1" + ) + } +#endif guard entry.transientRecoveryRetriesRemaining > 0 else { return false } entry.transientRecoveryRetriesRemaining -= 1 @@ -1494,15 +1669,31 @@ final class WindowBrowserPortal: NSObject { } return } - guard let anchorView = entry.anchorView, let window else { - if entry.visibleInUI { - _ = scheduleTransientRecoveryRetryIfNeeded( - forWebViewId: webViewId, - entry: &entry, - webView: webView, - reason: "missingAnchorOrWindow" + func scheduleTransientDetachRecovery(reason: String) -> Bool { + guard entry.visibleInUI else { return false } + let didSchedule = scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: reason + ) + let shouldPreserve = didSchedule && !containerView.isHidden +#if DEBUG + if shouldPreserve { + dlog( + "browser.portal.hidden.deferKeep web=\(browserPortalDebugToken(webView)) " + + "reason=\(reason) frame=\(browserPortalDebugFrame(containerView.frame))" ) - } else { + } +#endif + return shouldPreserve + } + guard let anchorView = entry.anchorView, let window else { + if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") { + containerView.setDropZoneOverlay(zone: nil) + return + } + if !entry.visibleInUI { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } #if DEBUG @@ -1513,11 +1704,17 @@ final class WindowBrowserPortal: NSObject { ) } #endif + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true return } guard anchorView.window === window else { + if scheduleTransientDetachRecovery(reason: "anchorWindowMismatch") { + containerView.setDropZoneOverlay(zone: nil) + return + } #if DEBUG if !containerView.isHidden { dlog( @@ -1527,16 +1724,11 @@ final class WindowBrowserPortal: NSObject { ) } #endif - if entry.visibleInUI { - _ = scheduleTransientRecoveryRetryIfNeeded( - forWebViewId: webViewId, - entry: &entry, - webView: webView, - reason: "anchorWindowMismatch" - ) - } else { + if !entry.visibleInUI { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true return @@ -1617,6 +1809,7 @@ final class WindowBrowserPortal: NSObject { } else { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } + containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true if entry.visibleInUI { @@ -1629,6 +1822,7 @@ final class WindowBrowserPortal: NSObject { } else { scheduleDeferredFullSynchronizeAll() } + containerView.setPaneTopChromeHeight(0) return } let oldFrame = containerView.frame @@ -1788,6 +1982,8 @@ final class WindowBrowserPortal: NSObject { #endif containerView.isHidden = false } + containerView.setPaneTopChromeHeight(shouldHide ? 0 : entry.paneTopChromeHeight) + containerView.setSearchOverlay(shouldHide ? nil : entry.searchOverlay) containerView.setDropZoneOverlay(zone: containerView.isHidden ? nil : entry.dropZone) if revealedForDisplay { refreshReasons.append("reveal") @@ -2011,6 +2207,23 @@ enum BrowserWindowPortalRegistry { portal.updatePaneDropContext(forWebViewId: webViewId, context: context) } + static func updateSearchOverlay( + for webView: WKWebView, + configuration: BrowserPortalSearchOverlayConfiguration? + ) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updateSearchOverlay(forWebViewId: webViewId, configuration: configuration) + } + + static func updatePaneTopChromeHeight(for webView: WKWebView, height: CGFloat) { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.updatePaneTopChromeHeight(forWebViewId: webViewId, height: height) + } + static func detach(webView: WKWebView) { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 885dd16d..76514838 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1244,6 +1244,15 @@ final class BrowserSearchState: ObservableObject { } } +final class BrowserPortalAnchorView: NSView { + override var acceptsFirstResponder: Bool { false } + override var isOpaque: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} + @MainActor final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels @@ -1481,6 +1490,7 @@ final class BrowserPanel: Panel, ObservableObject { } } private var searchNeedleCancellable: AnyCancellable? + let portalAnchorView = BrowserPortalAnchorView(frame: .zero) private var webViewCancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 73f8e3e5..827771bc 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -230,6 +230,7 @@ struct BrowserPanelView: View { @State private var focusFlashOpacity: Double = 0.0 @State private var focusFlashAnimationGeneration: Int = 0 @State private var omnibarPillFrame: CGRect = .zero + @State private var addressBarHeight: CGFloat = 0 @State private var lastHandledAddressBarFocusRequestId: UUID? @State private var isBrowserThemeMenuPresented = false @State private var ghosttyBackgroundGeneration: Int = 0 @@ -306,6 +307,8 @@ struct BrowserPanelView: View { } var body: some View { + // Layering contract: browser Cmd+F UI is mounted in the portal-hosted AppKit + // container. Rendering it here can hide it behind the portal-hosted WKWebView. VStack(spacing: 0) { addressBar webView @@ -317,17 +320,6 @@ struct BrowserPanelView: View { .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } - .overlay { - if let searchState = panel.searchState { - BrowserSearchOverlay( - panelId: panel.id, - searchState: searchState, - onNext: { panel.findNext() }, - onPrevious: { panel.findPrevious() }, - onClose: { panel.hideFind() } - ) - } - } .overlay(alignment: .topLeading) { if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 { OmnibarSuggestionsView( @@ -354,6 +346,9 @@ struct BrowserPanelView: View { .onPreferenceChange(OmnibarPillFramePreferenceKey.self) { frame in omnibarPillFrame = frame } + .onPreferenceChange(BrowserAddressBarHeightPreferenceKey.self) { height in + addressBarHeight = height + } .onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick).filter { [weak panel] note in // Only handle clicks from our own webview. guard let webView = note.object as? CmuxWebView else { return false } @@ -495,6 +490,15 @@ struct BrowserPanelView: View { .padding(.horizontal, 8) .padding(.vertical, addressBarVerticalPadding) .background(browserChromeBackground) + .background { + GeometryReader { geo in + Color.clear + .preference( + key: BrowserAddressBarHeightPreferenceKey.self, + value: geo.size.height + ) + } + } // Keep the omnibar stack above WKWebView so the suggestions popup is visible. .zIndex(1) .environment(\.colorScheme, browserChromeColorScheme) @@ -739,7 +743,17 @@ struct BrowserPanelView: View { shouldFocusWebView: isFocused && !addressBarFocused, isPanelFocused: isFocused, portalZPriority: portalPriority, - paneDropZone: paneDropZone + paneDropZone: paneDropZone, + searchOverlay: panel.searchState.map { searchState in + BrowserPortalSearchOverlayConfiguration( + panelId: panel.id, + searchState: searchState, + onNext: { panel.findNext() }, + onPrevious: { panel.findPrevious() }, + onClose: { panel.hideFind() } + ) + }, + paneTopChromeHeight: addressBarHeight ) // Keep the host stable for normal pane churn, but force a remount when // BrowserPanel replaces its underlying WKWebView after process termination. @@ -1935,6 +1949,14 @@ private struct OmnibarPillFramePreferenceKey: PreferenceKey { } } +private struct BrowserAddressBarHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + // MARK: - Omnibar State Machine struct OmnibarState: Equatable { @@ -3039,6 +3061,8 @@ struct WebViewRepresentable: NSViewRepresentable { let isPanelFocused: Bool let portalZPriority: Int let paneDropZone: DropZone? + let searchOverlay: BrowserPortalSearchOverlayConfiguration? + let paneTopChromeHeight: CGFloat final class Coordinator { weak var panel: BrowserPanel? @@ -3199,6 +3223,18 @@ struct WebViewRepresentable: NSViewRepresentable { host.onGeometryChanged = nil } + private static func installPortalAnchorView(_ anchorView: NSView, in host: NSView) { + if anchorView.superview !== host { + anchorView.removeFromSuperview() + anchorView.frame = host.bounds + anchorView.translatesAutoresizingMaskIntoConstraints = true + anchorView.autoresizingMask = [.width, .height] + host.addSubview(anchorView) + } else if anchorView.frame != host.bounds { + anchorView.frame = host.bounds + } + } + private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { guard let host = nsView as? HostContainerView else { return } @@ -3210,25 +3246,35 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachGeneration += 1 let generation = coordinator.attachGeneration let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil + let activeSearchOverlay = shouldAttachWebView ? searchOverlay : nil + let portalAnchorView = panel.portalAnchorView + Self.installPortalAnchorView(portalAnchorView, in: host) - host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in - guard let host, let webView, let coordinator else { return } + host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator, weak portalAnchorView] in + guard let host, let webView, let coordinator, let portalAnchorView else { return } guard coordinator.attachGeneration == generation else { return } + Self.installPortalAnchorView(portalAnchorView, in: host) guard host.window != nil else { return } BrowserWindowPortalRegistry.bind( webView: webView, - to: host, + to: portalAnchorView, visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: coordinator.desiredPortalVisibleInUI ? paneTopChromeHeight : 0 + ) BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) coordinator.lastPortalHostId = ObjectIdentifier(host) } - host.onGeometryChanged = { [weak host, weak coordinator] in - guard let host, let coordinator else { return } + host.onGeometryChanged = { [weak host, weak coordinator, weak portalAnchorView] in + guard let host, let coordinator, let portalAnchorView else { return } guard coordinator.attachGeneration == generation else { return } guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return } - BrowserWindowPortalRegistry.synchronizeForAnchor(host) + Self.installPortalAnchorView(portalAnchorView, in: host) + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) } if !shouldAttachWebView { @@ -3245,15 +3291,21 @@ struct WebViewRepresentable: NSViewRepresentable { previousVisible != shouldAttachWebView || previousZPriority != portalZPriority if shouldBindNow { + Self.installPortalAnchorView(portalAnchorView, in: host) BrowserWindowPortalRegistry.bind( webView: webView, - to: host, + to: portalAnchorView, visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) coordinator.lastPortalHostId = hostId } - BrowserWindowPortalRegistry.synchronizeForAnchor(host) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: shouldAttachWebView ? paneTopChromeHeight : 0 + ) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) + BrowserWindowPortalRegistry.synchronizeForAnchor(portalAnchorView) } else { // Bind is deferred until host moves into a window. Keep the current // portal entry's desired state in sync so stale callbacks cannot keep @@ -3269,10 +3321,15 @@ struct WebViewRepresentable: NSViewRepresentable { for: webView, zone: shouldAttachWebView ? paneDropZone : nil ) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight( + for: webView, + height: shouldAttachWebView ? paneTopChromeHeight : 0 + ) BrowserWindowPortalRegistry.updatePaneDropContext( for: webView, context: paneDropContext ) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: activeSearchOverlay) panel.restoreDeveloperToolsAfterAttachIfNeeded() @@ -3391,7 +3448,9 @@ struct WebViewRepresentable: NSViewRepresentable { // rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach // still happens on real web view replacement and panel teardown. BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil) + BrowserWindowPortalRegistry.updatePaneTopChromeHeight(for: webView, height: 0) BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil) + BrowserWindowPortalRegistry.updateSearchOverlay(for: webView, configuration: nil) coordinator.lastPortalHostId = nil } diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index e5f4b40f..950589bc 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2450,7 +2450,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, - paneDropZone: nil + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -2487,7 +2489,9 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, - paneDropZone: nil + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -7776,6 +7780,27 @@ final class BrowserPaneDropRoutingTests: XCTestCase { ) } + func testTopChromeHeightPushesTopSplitThresholdIntoWebView() { + let size = CGSize(width: 240, height: 180) + + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 110), + in: size, + topChromeHeight: 36 + ), + .center + ) + XCTAssertEqual( + BrowserPaneDropRouting.zone( + for: CGPoint(x: size.width * 0.5, y: 150), + in: size, + topChromeHeight: 36 + ), + .top + ) + } + func testHitTestingCapturesOnlyForRelevantDragEvents() { XCTAssertTrue( BrowserPaneDropTargetView.shouldCaptureHitTesting( @@ -7873,22 +7898,24 @@ final class WindowBrowserSlotViewTests: XCTestCase { } func testDropZoneOverlayStaysAboveContentWithoutBlockingHits() { - let slot = WindowBrowserSlotView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) let child = CapturingView(frame: slot.bounds) child.autoresizingMask = [.width, .height] slot.addSubview(child) slot.setDropZoneOverlay(zone: .right) - slot.layoutSubtreeIfNeeded() + container.layoutSubtreeIfNeeded() - guard let overlay = slot.subviews.first(where: { - $0 !== child && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + guard let overlay = container.subviews.first(where: { + $0 !== slot && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") }) else { XCTFail("Expected browser slot drop-zone overlay") return } - XCTAssertTrue(slot.subviews.last === overlay, "Overlay should stay above the hosted web view") + XCTAssertTrue(container.subviews.last === overlay, "Overlay should stay above the hosted web view") XCTAssertFalse(overlay.isHidden) XCTAssertEqual(overlay.frame.origin.x, 100, accuracy: 0.5) XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) @@ -7901,6 +7928,35 @@ final class WindowBrowserSlotViewTests: XCTestCase { advanceAnimations() XCTAssertTrue(overlay.isHidden, "Clearing the drop zone should hide the overlay") } + + func testTopDropZoneOverlayUsesFullBrowserContentHeight() { + let container = NSView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + let slot = WindowBrowserSlotView(frame: container.bounds) + container.addSubview(slot) + + slot.setPaneTopChromeHeight(20) + slot.setDropZoneOverlay(zone: .top) + container.layoutSubtreeIfNeeded() + + guard let overlay = container.subviews.first(where: { + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + }) else { + XCTFail("Expected browser slot drop-zone overlay") + return + } + + XCTAssertFalse(overlay.isHidden) + XCTAssertEqual(overlay.frame.origin.x, 4, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, 60, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.width, 192, accuracy: 0.5) + XCTAssertEqual(overlay.frame.size.height, 56, accuracy: 0.5) + XCTAssertGreaterThan(overlay.frame.maxY, slot.frame.maxY) + XCTAssertEqual(slot.layer?.masksToBounds, true) + + slot.setDropZoneOverlay(zone: nil) + advanceAnimations() + XCTAssertEqual(slot.layer?.masksToBounds, true) + } } @MainActor