diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 291b6f6f..cb12b170 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 @@ -905,6 +906,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 @@ -965,16 +974,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 @@ -983,6 +999,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, @@ -1102,7 +1159,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, @@ -1158,7 +1219,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 @@ -1215,11 +1280,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 var preferredHostedInspectorWidth: CGFloat? var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)? fileprivate var isApplyingHostedInspectorLayout = false @@ -1240,7 +1307,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) @@ -1256,6 +1322,12 @@ final class WindowBrowserSlotView: NSView { onHostedInspectorLayout?(self) } + override func viewDidMoveToSuperview() { + super.viewDidMoveToSuperview() + attachDropZoneOverlayIfNeeded() + applyResolvedDropZoneOverlay() + } + func setDropZoneOverlay(zone: DropZone?) { forwardedDropZone = zone applyResolvedDropZoneOverlay() @@ -1270,9 +1342,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() } @@ -1280,6 +1405,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) { @@ -1315,6 +1451,7 @@ final class WindowBrowserSlotView: NSView { } return } + attachDropZoneOverlayIfNeeded() let targetFrame = dropZoneOverlayFrame(for: zone, in: bounds.size) let needsFrameUpdate = !Self.rectApproximatelyEqual(previousFrame, targetFrame) @@ -1356,7 +1493,6 @@ final class WindowBrowserSlotView: NSView { private func interactionLayerPriority(of view: NSView) -> Int { if view === paneDropTargetView { return 1 } - if view === dropZoneOverlayView { return 2 } return 0 } @@ -1368,8 +1504,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() @@ -1392,19 +1531,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 { @@ -1435,6 +1568,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 } @@ -1693,10 +1829,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)) " + @@ -1818,6 +1958,14 @@ final class WindowBrowserPortal: NSObject { entriesByWebViewId[webViewId] = entry } + func hideWebView(withId webViewId: ObjectIdentifier, source: String = "externalHide") { + guard var entry = entriesByWebViewId[webViewId] else { return } + entry.visibleInUI = false + entry.zPriority = 0 + entriesByWebViewId[webViewId] = entry + synchronizeWebView(withId: webViewId, source: source) + } + func updateDropZoneOverlay(forWebViewId webViewId: ObjectIdentifier, zone: DropZone?) { guard var entry = entriesByWebViewId[webViewId] else { return } entry.dropZone = zone @@ -1832,6 +1980,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 } @@ -1847,6 +2014,9 @@ final class WindowBrowserPortal: NSObject { zPriority: 0, dropZone: nil, paneDropContext: nil, + searchOverlay: nil, + paneTopChromeHeight: 0, + transientRecoveryReason: nil, transientRecoveryRetriesRemaining: 0 ), webView: webView @@ -1880,6 +2050,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 ) @@ -1997,7 +2170,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 } @@ -2008,9 +2182,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 @@ -2045,15 +2228,24 @@ final class WindowBrowserPortal: NSObject { } return } + func scheduleTransientDetachRecovery(reason: String) -> Bool { + guard entry.visibleInUI else { return false } + return scheduleTransientRecoveryRetryIfNeeded( + forWebViewId: webViewId, + entry: &entry, + webView: webView, + reason: reason + ) + } guard let anchorView = entry.anchorView, let window else { - if entry.visibleInUI { - _ = scheduleTransientRecoveryRetryIfNeeded( - forWebViewId: webViewId, - entry: &entry, - webView: webView, - reason: "missingAnchorOrWindow" - ) - } else { + if scheduleTransientDetachRecovery(reason: "missingAnchorOrWindow") { + containerView.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) + containerView.setDropZoneOverlay(zone: nil) + containerView.isHidden = true + return + } + if !entry.visibleInUI { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } #if DEBUG @@ -2064,11 +2256,20 @@ 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.setPaneTopChromeHeight(0) + containerView.setSearchOverlay(nil) + containerView.setDropZoneOverlay(zone: nil) + containerView.isHidden = true + return + } #if DEBUG if !containerView.isHidden { dlog( @@ -2078,16 +2279,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 @@ -2168,6 +2364,7 @@ final class WindowBrowserPortal: NSObject { } else { resetTransientRecoveryRetryIfNeeded(forWebViewId: webViewId, entry: &entry) } + containerView.setSearchOverlay(nil) containerView.setDropZoneOverlay(zone: nil) containerView.isHidden = true if entry.visibleInUI { @@ -2180,6 +2377,7 @@ final class WindowBrowserPortal: NSObject { } else { scheduleDeferredFullSynchronizeAll() } + containerView.setPaneTopChromeHeight(0) return } let oldFrame = containerView.frame @@ -2339,6 +2537,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") @@ -2549,6 +2749,13 @@ enum BrowserWindowPortalRegistry { portal.updateEntryVisibility(forWebViewId: webViewId, visibleInUI: visibleInUI, zPriority: zPriority) } + static func hide(webView: WKWebView, source: String = "externalHide") { + let webViewId = ObjectIdentifier(webView) + guard let windowId = webViewToWindowId[webViewId], + let portal = portalsByWindowId[windowId] else { return } + portal.hideWebView(withId: webViewId, source: source) + } + static func updateDropZoneOverlay(for webView: WKWebView, zone: DropZone?) { let webViewId = ObjectIdentifier(webView) guard let windowId = webViewToWindowId[webViewId], @@ -2563,6 +2770,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/ContentView.swift b/Sources/ContentView.swift index 126e3b36..588eba67 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2815,13 +2815,14 @@ struct ContentView: View { workspaceHandoffFallbackTask = nil let retiring = retiringWorkspaceId - // Hide terminal portal views for the retiring workspace BEFORE clearing + // Hide portal-hosted views for the retiring workspace BEFORE clearing // retiringWorkspaceId. Once cleared, reconcileMountedWorkspaceIds unmounts // the workspace — but dismantleNSView intentionally doesn't hide portal views - // (to avoid blackouts during transient bonsplit dismantles). Hiding here - // prevents stale portal-hosted terminals from covering browser panes. + // during transient rebuilds. Hiding here prevents stale terminal/browser + // portals from covering the newly selected workspace. if let retiring, let workspace = tabManager.tabs.first(where: { $0.id == retiring }) { workspace.hideAllTerminalPortalViews() + workspace.hideAllBrowserPortalViews() } retiringWorkspaceId = nil diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f7b8ed95..fc803000 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 7c5f0c74..198d42c0 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 @@ -310,21 +311,16 @@ 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 } - .overlay { - RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) - .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) - .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) - .padding(FocusFlashPattern.ringInset) - .allowsHitTesting(false) - } .overlay { // Keep Cmd+F usable when the browser is still in the empty new-tab // state (no WKWebView mounted yet). WebView-backed cases are hosted - // in AppKit by WebViewRepresentable to avoid layering/clipping issues. + // in AppKit by WindowBrowserPortal to avoid layering/clipping issues. if !panel.shouldRenderWebView, let searchState = panel.searchState { BrowserSearchOverlay( panelId: panel.id, @@ -335,6 +331,13 @@ struct BrowserPanelView: View { ) } } + .overlay { + RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius) + .stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3) + .shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10) + .padding(FocusFlashPattern.ringInset) + .allowsHitTesting(false) + } .overlay(alignment: .topLeading) { if addressBarFocused, !omnibarState.suggestions.isEmpty, omnibarPillFrame.width > 0 { OmnibarSuggestionsView( @@ -361,6 +364,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 } @@ -502,6 +508,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) @@ -742,12 +757,21 @@ struct BrowserPanelView: View { if panel.shouldRenderWebView { WebViewRepresentable( panel: panel, - browserSearchState: panel.searchState, shouldAttachWebView: isVisibleInUI, 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. @@ -1945,6 +1969,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 { @@ -3044,12 +3076,13 @@ private struct OmnibarSuggestionsView: View { /// NSViewRepresentable wrapper for WKWebView struct WebViewRepresentable: NSViewRepresentable { let panel: BrowserPanel - let browserSearchState: BrowserSearchState? let shouldAttachWebView: Bool let shouldFocusWebView: Bool let isPanelFocused: Bool let portalZPriority: Int let paneDropZone: DropZone? + let searchOverlay: BrowserPortalSearchOverlayConfiguration? + let paneTopChromeHeight: CGFloat final class Coordinator { weak var panel: BrowserPanel? @@ -3058,7 +3091,6 @@ struct WebViewRepresentable: NSViewRepresentable { var desiredPortalVisibleInUI: Bool = true var desiredPortalZPriority: Int = 0 var lastPortalHostId: ObjectIdentifier? - var searchOverlayHostingView: NSHostingView? } final class HostContainerView: NSView { @@ -3771,65 +3803,16 @@ struct WebViewRepresentable: NSViewRepresentable { host.onGeometryChanged = nil } - private static func removeSearchOverlay(from coordinator: Coordinator) { - coordinator.searchOverlayHostingView?.removeFromSuperview() - coordinator.searchOverlayHostingView = nil - } - - private static func updateSearchOverlay( - panel: BrowserPanel, - coordinator: Coordinator, - containerView: NSView? - ) { - // Layering contract: keep browser Cmd+F UI in the portal-hosted AppKit layer. - // SwiftUI panel overlays can be covered by portal-hosted WKWebView content. - guard let searchState = panel.searchState, - let containerView else { - removeSearchOverlay(from: coordinator) - return + 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 } - - let rootView = BrowserSearchOverlay( - panelId: panel.id, - searchState: searchState, - onNext: { [weak panel] in - panel?.findNext() - }, - onPrevious: { [weak panel] in - panel?.findPrevious() - }, - onClose: { [weak panel] in - panel?.hideFind() - } - ) - - if let overlay = coordinator.searchOverlayHostingView { - overlay.rootView = rootView - if overlay.superview !== containerView { - overlay.removeFromSuperview() - containerView.addSubview(overlay, positioned: .above, relativeTo: nil) - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: containerView.topAnchor), - overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - ]) - } else if containerView.subviews.last !== overlay { - containerView.addSubview(overlay, positioned: .above, relativeTo: nil) - } - return - } - - let overlay = NSHostingView(rootView: rootView) - overlay.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(overlay, positioned: .above, relativeTo: nil) - NSLayoutConstraint.activate([ - overlay.topAnchor.constraint(equalTo: containerView.topAnchor), - overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), - overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), - ]) - coordinator.searchOverlayHostingView = overlay } private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) { @@ -3843,32 +3826,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) - if let panel = coordinator.panel { - Self.updateSearchOverlay( - panel: panel, - coordinator: coordinator, - containerView: webView.superview - ) - } } - 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 { @@ -3885,20 +3871,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) - Self.updateSearchOverlay( - panel: panel, - coordinator: coordinator, - containerView: webView.superview + 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 @@ -3908,17 +3895,21 @@ struct WebViewRepresentable: NSViewRepresentable { visibleInUI: coordinator.desiredPortalVisibleInUI, zPriority: coordinator.desiredPortalZPriority ) - Self.removeSearchOverlay(from: coordinator) } BrowserWindowPortalRegistry.updateDropZoneOverlay( 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() @@ -3937,7 +3928,6 @@ struct WebViewRepresentable: NSViewRepresentable { let webView = panel.webView let coordinator = context.coordinator if let previousWebView = coordinator.webView, previousWebView !== webView { - Self.removeSearchOverlay(from: coordinator) BrowserWindowPortalRegistry.detach(webView: previousWebView) coordinator.lastPortalHostId = nil } @@ -4009,7 +3999,6 @@ struct WebViewRepresentable: NSViewRepresentable { static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { coordinator.attachGeneration += 1 clearPortalCallbacks(for: nsView) - removeSearchOverlay(from: coordinator) guard let webView = coordinator.webView else { return } let panel = coordinator.panel @@ -4039,7 +4028,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/Sources/Workspace.swift b/Sources/Workspace.swift index 15d0a0c4..58d88702 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -3280,6 +3280,19 @@ final class Workspace: Identifiable, ObservableObject { } } + /// Hide all browser portal views for this workspace. + /// Called before the workspace is unmounted so a portal-hosted WKWebView + /// cannot remain visible after this workspace stops being selected. + func hideAllBrowserPortalViews() { + for panel in panels.values { + guard let browser = panel as? BrowserPanel else { continue } + BrowserWindowPortalRegistry.hide( + webView: browser.webView, + source: "workspaceRetire" + ) + } + } + // MARK: - Utility /// Create a new terminal panel (used when replacing the last panel) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 3226755f..efa7c939 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2534,12 +2534,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, - browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, - paneDropZone: nil + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -2572,12 +2573,13 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase { let representable = WebViewRepresentable( panel: panel, - browserSearchState: nil, shouldAttachWebView: true, shouldFocusWebView: false, isPanelFocused: true, portalZPriority: 0, - paneDropZone: nil + paneDropZone: nil, + searchOverlay: nil, + paneTopChromeHeight: 0 ) let coordinator = representable.makeCoordinator() coordinator.webView = panel.webView @@ -8782,6 +8784,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( @@ -8879,22 +8902,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) @@ -8907,6 +8932,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 @@ -10326,8 +10380,11 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { } private func dropZoneOverlay(in slot: WindowBrowserSlotView, excluding webView: WKWebView) -> NSView? { - slot.subviews.first(where: { - $0 !== webView && String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") + let candidates = slot.subviews + (slot.superview?.subviews ?? []) + return candidates.first(where: { + $0 !== slot && + $0 !== webView && + String(describing: type(of: $0)).contains("BrowserDropZoneOverlayView") }) } @@ -10638,9 +10695,9 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { portal.updateDropZoneOverlay(forWebViewId: ObjectIdentifier(webView), zone: .right) slot.layoutSubtreeIfNeeded() XCTAssertFalse(overlay.isHidden) - XCTAssertTrue(slot.subviews.last === overlay, "Overlay should remain above the hosted web view") - XCTAssertEqual(overlay.frame.origin.x, 110, accuracy: 0.5) - XCTAssertEqual(overlay.frame.origin.y, 4, accuracy: 0.5) + XCTAssertTrue(slot.superview?.subviews.last === overlay, "Overlay should remain above the hosted web view") + XCTAssertEqual(overlay.frame.origin.x, slot.frame.origin.x + 110, accuracy: 0.5) + XCTAssertEqual(overlay.frame.origin.y, slot.frame.origin.y + 4, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.width, 106, accuracy: 0.5) XCTAssertEqual(overlay.frame.size.height, 152, accuracy: 0.5) @@ -10777,6 +10834,41 @@ final class BrowserWindowPortalLifecycleTests: XCTestCase { BrowserWindowPortalRegistry.detach(webView: webView) XCTAssertNil(webView.superview) } + + func testRegistryHideKeepsPortalHostedWebViewAttachedButHidden() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 240), + 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 anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120)) + contentView.addSubview(anchor) + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true) + BrowserWindowPortalRegistry.synchronizeForAnchor(anchor) + advanceAnimations() + + guard let slot = webView.superview as? WindowBrowserSlotView else { + XCTFail("Expected browser slot") + return + } + XCTAssertFalse(slot.isHidden) + + BrowserWindowPortalRegistry.hide(webView: webView, source: "unitTest") + advanceAnimations() + + XCTAssertTrue(webView.superview === slot, "Hiding should preserve the hosted WKWebView attachment") + XCTAssertTrue(slot.isHidden, "Hiding should immediately hide the existing portal slot") + } } @MainActor