diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index 97151a08..1f02faf4 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -81,6 +81,113 @@ private extension WKWebView { } } +enum HostedInspectorDockSide { + case leading + case trailing + + static func resolve( + pageFrame: NSRect, + inspectorFrame: NSRect, + epsilon: CGFloat = 1 + ) -> Self? { + if pageFrame.maxX <= inspectorFrame.minX + epsilon { + return .trailing + } + if inspectorFrame.maxX <= pageFrame.minX + epsilon { + return .leading + } + return nil + } + + func dividerX(pageFrame: NSRect, inspectorFrame: NSRect) -> CGFloat { + switch self { + case .leading: + return inspectorFrame.maxX + case .trailing: + return inspectorFrame.minX + } + } + + func dividerHitRect( + in bounds: NSRect, + pageFrame: NSRect, + inspectorFrame: NSRect, + expansion: CGFloat + ) -> NSRect { + let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY)) + let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY)) + return NSRect( + x: dividerX(pageFrame: pageFrame, inspectorFrame: inspectorFrame) - expansion, + y: minY, + width: expansion * 2, + height: max(0, maxY - minY) + ) + } + + func clampedDividerX( + _ proposedDividerX: CGFloat, + containerBounds: NSRect, + pageFrame: NSRect, + minimumInspectorWidth: CGFloat + ) -> CGFloat { + switch self { + case .leading: + let minDividerX = min(containerBounds.maxX, containerBounds.minX + minimumInspectorWidth) + let maxDividerX = max(minDividerX, min(containerBounds.maxX, pageFrame.maxX)) + return max(minDividerX, min(maxDividerX, proposedDividerX)) + case .trailing: + let minDividerX = max(containerBounds.minX, pageFrame.minX) + let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth) + return max(minDividerX, min(maxDividerX, proposedDividerX)) + } + } + + func inspectorWidth(forDividerX dividerX: CGFloat, in containerBounds: NSRect) -> CGFloat { + switch self { + case .leading: + return max(0, dividerX - containerBounds.minX) + case .trailing: + return max(0, containerBounds.maxX - dividerX) + } + } + + func resizedFrames( + preferredWidth: CGFloat, + in containerBounds: NSRect, + pageFrame: NSRect, + inspectorFrame: NSRect + ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { + switch self { + case .leading: + let maximumInspectorWidth = max(0, pageFrame.maxX - containerBounds.minX) + let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let dividerX = min(pageFrame.maxX, containerBounds.minX + clampedInspectorWidth) + + var nextPageFrame = pageFrame + nextPageFrame.origin.x = dividerX + nextPageFrame.size.width = max(0, pageFrame.maxX - dividerX) + + var nextInspectorFrame = inspectorFrame + nextInspectorFrame.origin.x = containerBounds.minX + nextInspectorFrame.size.width = max(0, dividerX - containerBounds.minX) + return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) + + case .trailing: + let maximumInspectorWidth = max(0, containerBounds.maxX - pageFrame.minX) + let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) + let dividerX = max(pageFrame.minX, containerBounds.maxX - clampedInspectorWidth) + + var nextPageFrame = pageFrame + nextPageFrame.size.width = max(0, dividerX - pageFrame.minX) + + var nextInspectorFrame = inspectorFrame + nextInspectorFrame.origin.x = dividerX + nextInspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + return (pageFrame: nextPageFrame, inspectorFrame: nextInspectorFrame) + } + } +} + final class WindowBrowserHostView: NSView { private struct DividerRegion { let rectInWindow: NSRect @@ -97,6 +204,7 @@ final class WindowBrowserHostView: NSView { let containerView: NSView let pageView: NSView let inspectorView: NSView + let dockSide: HostedInspectorDockSide } private struct HostedInspectorDividerDragState { @@ -104,6 +212,7 @@ final class WindowBrowserHostView: NSView { let containerView: NSView let pageView: NSView let inspectorView: NSView + let dockSide: HostedInspectorDockSide let initialWindowX: CGFloat let initialPageFrame: NSRect let initialInspectorFrame: NSRect @@ -383,6 +492,7 @@ final class WindowBrowserHostView: NSView { containerView: hostedInspectorHit.containerView, pageView: hostedInspectorHit.pageView, inspectorView: hostedInspectorHit.inspectorView, + dockSide: hostedInspectorHit.dockSide, initialWindowX: event.locationInWindow.x, initialPageFrame: hostedInspectorHit.pageView.frame, initialInspectorFrame: hostedInspectorHit.inspectorView.frame @@ -414,11 +524,21 @@ final class WindowBrowserHostView: NSView { Self.minimumHostedInspectorWidth, max(60, dragState.initialInspectorFrame.width) ) - let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX) - let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth) - let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX) - let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX)) - let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX) + let initialDividerX = dragState.dockSide.dividerX( + pageFrame: dragState.initialPageFrame, + inspectorFrame: dragState.initialInspectorFrame + ) + let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX) + let clampedDividerX = dragState.dockSide.clampedDividerX( + proposedDividerX, + containerBounds: containerBounds, + pageFrame: dragState.initialPageFrame, + minimumInspectorWidth: minimumInspectorWidth + ) + let inspectorWidth = dragState.dockSide.inspectorWidth( + forDividerX: clampedDividerX, + in: containerBounds + ) dragState.slotView.preferredHostedInspectorWidth = inspectorWidth let appliedFrames = applyHostedInspectorDividerWidth( @@ -427,7 +547,8 @@ final class WindowBrowserHostView: NSView { slotView: dragState.slotView, containerView: dragState.containerView, pageView: dragState.pageView, - inspectorView: dragState.inspectorView + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide ), reason: "drag" ) @@ -438,7 +559,8 @@ final class WindowBrowserHostView: NSView { slotView: dragState.slotView, containerView: dragState.containerView, pageView: dragState.pageView, - inspectorView: dragState.inspectorView + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide ) ) #if DEBUG @@ -710,22 +832,31 @@ final class WindowBrowserHostView: NSView { while let inspectorView = current, inspectorView !== slot { guard let containerView = inspectorView.superview else { break } - let pageCandidates = containerView.subviews.filter { candidate in - guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false } - guard candidate !== inspectorView else { return false } - guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false } - return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 + let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in + guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil } + guard candidate !== inspectorView else { return nil } + guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { + return nil + } + guard let dockSide = HostedInspectorDockSide.resolve( + pageFrame: candidate.frame, + inspectorFrame: inspectorView.frame + ) else { + return nil + } + return (view: candidate, dockSide: dockSide) } - if let pageView = pageCandidates.max(by: { - hostedInspectorPageCandidateScore($0, inspectorView: inspectorView) - < hostedInspectorPageCandidateScore($1, inspectorView: inspectorView) + if let pageCandidate = pageCandidates.max(by: { + hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView) + < hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView) }) { bestHit = HostedInspectorDividerHit( slotView: slot, containerView: containerView, - pageView: pageView, - inspectorView: inspectorView + pageView: pageCandidate.view, + inspectorView: inspectorView, + dockSide: pageCandidate.dockSide ) } @@ -739,13 +870,11 @@ final class WindowBrowserHostView: NSView { let slotBounds = hit.slotView.bounds let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView) let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView) - let minY = max(slotBounds.minY, min(pageFrame.minY, inspectorFrame.minY)) - let maxY = min(slotBounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY)) - return NSRect( - x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion, - y: minY, - width: Self.hostedInspectorDividerHitExpansion * 2, - height: max(0, maxY - minY) + return hit.dockSide.dividerHitRect( + in: slotBounds, + pageFrame: pageFrame, + inspectorFrame: inspectorFrame, + expansion: Self.hostedInspectorDividerHitExpansion ) } @@ -792,16 +921,14 @@ final class WindowBrowserHostView: NSView { reason: String ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { let containerBounds = hit.containerView.bounds - let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX) - let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) - let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth) - - var pageFrame = hit.pageView.frame - pageFrame.size.width = max(0, dividerX - pageFrame.minX) - - var inspectorFrame = hit.inspectorView.frame - inspectorFrame.origin.x = dividerX - inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + let nextFrames = hit.dockSide.resizedFrames( + preferredWidth: preferredWidth, + in: containerBounds, + pageFrame: hit.pageView.frame, + inspectorFrame: hit.inspectorView.frame + ) + let pageFrame = nextFrames.pageFrame + let inspectorFrame = nextFrames.inspectorFrame let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5) let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5) @@ -1465,27 +1592,26 @@ final class WindowBrowserSlotView: NSView { func pinHostedWebView(_ webView: WKWebView) { guard webView.superview === self else { return } - let needsNewConstraints = + let needsFrameHosting = hostedWebView !== webView || - hostedWebViewConstraints.isEmpty || - webView.translatesAutoresizingMaskIntoConstraints - guard needsNewConstraints else { + !hostedWebViewConstraints.isEmpty || + !webView.translatesAutoresizingMaskIntoConstraints || + webView.autoresizingMask != [.width, .height] || + !Self.rectApproximatelyEqual(webView.frame, bounds) + guard needsFrameHosting else { needsLayout = true layoutSubtreeIfNeeded() return } NSLayoutConstraint.deactivate(hostedWebViewConstraints) + hostedWebViewConstraints = [] hostedWebView = webView - webView.translatesAutoresizingMaskIntoConstraints = false - webView.autoresizingMask = [] - hostedWebViewConstraints = [ - webView.topAnchor.constraint(equalTo: topAnchor), - webView.bottomAnchor.constraint(equalTo: bottomAnchor), - webView.leadingAnchor.constraint(equalTo: leadingAnchor), - webView.trailingAnchor.constraint(equalTo: trailingAnchor), - ] - NSLayoutConstraint.activate(hostedWebViewConstraints) + // Attached Web Inspector mutates the moved WKWebView's frame directly. + // Edge constraints fight side-docked resizing and cause visible churn. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = bounds needsLayout = true layoutSubtreeIfNeeded() } @@ -1687,6 +1813,18 @@ final class WindowBrowserPortal: NSObject { _ = ensureInstalled() } + static func shouldTreatSplitResizeAsExternalGeometry( + _ splitView: NSSplitView, + window: NSWindow, + hostView: WindowBrowserHostView + ) -> Bool { + guard splitView.window === window else { return false } + // WebKit's attached DevTools uses internal NSSplitView instances for the + // side/bottom inspector layout. Those resizes are local to hosted content + // and should not trigger a full portal re-sync/refresh pass. + return !splitView.isDescendant(of: hostView) + } + private func installGeometryObservers(for window: NSWindow) { guard geometryObservers.isEmpty else { return } @@ -1718,7 +1856,11 @@ final class WindowBrowserPortal: NSObject { guard let self, let splitView = notification.object as? NSSplitView, let window = self.window, - splitView.window === window else { return } + Self.shouldTreatSplitResizeAsExternalGeometry( + splitView, + window: window, + hostView: self.hostView + ) else { return } self.scheduleExternalGeometrySynchronize() } }) diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index bdbf7513..fa10ff45 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3571,6 +3571,7 @@ struct WebViewRepresentable: NSViewRepresentable { let containerView: NSView let pageView: NSView let inspectorView: NSView + let dockSide: HostedInspectorDockSide } private struct GeometryState: Equatable { @@ -3584,6 +3585,7 @@ struct WebViewRepresentable: NSViewRepresentable { let containerView: NSView let pageView: NSView let inspectorView: NSView + let dockSide: HostedInspectorDockSide let initialWindowX: CGFloat let initialPageFrame: NSRect let initialInspectorFrame: NSRect @@ -3917,6 +3919,7 @@ struct WebViewRepresentable: NSViewRepresentable { containerView: hostedInspectorHit.containerView, pageView: hostedInspectorHit.pageView, inspectorView: hostedInspectorHit.inspectorView, + dockSide: hostedInspectorHit.dockSide, initialWindowX: event.locationInWindow.x, initialPageFrame: hostedInspectorHit.pageView.frame, initialInspectorFrame: hostedInspectorHit.inspectorView.frame @@ -3937,18 +3940,29 @@ struct WebViewRepresentable: NSViewRepresentable { Self.minimumHostedInspectorWidth, max(60, dragState.initialInspectorFrame.width) ) - let minDividerX = max(containerBounds.minX, dragState.initialPageFrame.minX) - let maxDividerX = max(minDividerX, containerBounds.maxX - minimumInspectorWidth) - let proposedDividerX = dragState.initialInspectorFrame.minX + (event.locationInWindow.x - dragState.initialWindowX) - let clampedDividerX = max(minDividerX, min(maxDividerX, proposedDividerX)) - let inspectorWidth = max(0, containerBounds.maxX - clampedDividerX) + let initialDividerX = dragState.dockSide.dividerX( + pageFrame: dragState.initialPageFrame, + inspectorFrame: dragState.initialInspectorFrame + ) + let proposedDividerX = initialDividerX + (event.locationInWindow.x - dragState.initialWindowX) + let clampedDividerX = dragState.dockSide.clampedDividerX( + proposedDividerX, + containerBounds: containerBounds, + pageFrame: dragState.initialPageFrame, + minimumInspectorWidth: minimumInspectorWidth + ) + let inspectorWidth = dragState.dockSide.inspectorWidth( + forDividerX: clampedDividerX, + in: containerBounds + ) preferredHostedInspectorWidth = inspectorWidth _ = applyHostedInspectorDividerWidth( inspectorWidth, to: HostedInspectorDividerHit( containerView: dragState.containerView, pageView: dragState.pageView, - inspectorView: dragState.inspectorView + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide ), reason: "drag" ) @@ -3959,7 +3973,8 @@ struct WebViewRepresentable: NSViewRepresentable { hit: HostedInspectorDividerHit( containerView: dragState.containerView, pageView: dragState.pageView, - inspectorView: dragState.inspectorView + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide ) ) #endif @@ -3968,7 +3983,8 @@ struct WebViewRepresentable: NSViewRepresentable { hostedInspectorHit: HostedInspectorDividerHit( containerView: dragState.containerView, pageView: dragState.pageView, - inspectorView: dragState.inspectorView + inspectorView: dragState.inspectorView, + dockSide: dragState.dockSide ) ) } @@ -3983,7 +3999,8 @@ struct WebViewRepresentable: NSViewRepresentable { let finalHit = HostedInspectorDividerHit( containerView: finalDragState.containerView, pageView: finalDragState.pageView, - inspectorView: finalDragState.inspectorView + inspectorView: finalDragState.inspectorView, + dockSide: finalDragState.dockSide ) debugLogHostedInspectorFrames( stage: "drag.end", @@ -4104,13 +4121,11 @@ struct WebViewRepresentable: NSViewRepresentable { private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect { let pageFrame = convert(hit.pageView.bounds, from: hit.pageView) let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView) - let minY = max(bounds.minY, min(pageFrame.minY, inspectorFrame.minY)) - let maxY = min(bounds.maxY, max(pageFrame.maxY, inspectorFrame.maxY)) - return NSRect( - x: inspectorFrame.minX - Self.hostedInspectorDividerHitExpansion, - y: minY, - width: Self.hostedInspectorDividerHitExpansion * 2, - height: max(0, maxY - minY) + return hit.dockSide.dividerHitRect( + in: bounds, + pageFrame: pageFrame, + inspectorFrame: inspectorFrame, + expansion: Self.hostedInspectorDividerHitExpansion ) } @@ -4121,21 +4136,30 @@ struct WebViewRepresentable: NSViewRepresentable { while let inspectorView = current, inspectorView !== self { guard let containerView = inspectorView.superview else { break } - let pageCandidates = containerView.subviews.filter { candidate in - guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return false } - guard candidate !== inspectorView else { return false } - guard candidate.frame.maxX <= inspectorView.frame.minX + 1 else { return false } - return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 + let pageCandidates = containerView.subviews.compactMap { candidate -> (view: NSView, dockSide: HostedInspectorDockSide)? in + guard Self.isVisibleHostedInspectorSiblingCandidate(candidate) else { return nil } + guard candidate !== inspectorView else { return nil } + guard Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8 else { + return nil + } + guard let dockSide = HostedInspectorDockSide.resolve( + pageFrame: candidate.frame, + inspectorFrame: inspectorView.frame + ) else { + return nil + } + return (view: candidate, dockSide: dockSide) } - if let pageView = pageCandidates.max(by: { - hostedInspectorPageCandidateScore($0, inspectorView: inspectorView) - < hostedInspectorPageCandidateScore($1, inspectorView: inspectorView) + if let pageCandidate = pageCandidates.max(by: { + hostedInspectorPageCandidateScore($0.view, inspectorView: inspectorView) + < hostedInspectorPageCandidateScore($1.view, inspectorView: inspectorView) }) { bestHit = HostedInspectorDividerHit( containerView: containerView, - pageView: pageView, - inspectorView: inspectorView + pageView: pageCandidate.view, + inspectorView: inspectorView, + dockSide: pageCandidate.dockSide ) } @@ -4194,16 +4218,14 @@ struct WebViewRepresentable: NSViewRepresentable { reason: String ) -> (pageFrame: NSRect, inspectorFrame: NSRect) { let containerBounds = hit.containerView.bounds - let maximumInspectorWidth = max(0, containerBounds.maxX - hit.pageView.frame.minX) - let clampedInspectorWidth = max(0, min(maximumInspectorWidth, preferredWidth)) - let dividerX = max(hit.pageView.frame.minX, containerBounds.maxX - clampedInspectorWidth) - - var pageFrame = hit.pageView.frame - pageFrame.size.width = max(0, dividerX - pageFrame.minX) - - var inspectorFrame = hit.inspectorView.frame - inspectorFrame.origin.x = dividerX - inspectorFrame.size.width = max(0, containerBounds.maxX - dividerX) + let nextFrames = hit.dockSide.resizedFrames( + preferredWidth: preferredWidth, + in: containerBounds, + pageFrame: hit.pageView.frame, + inspectorFrame: hit.inspectorView.frame + ) + let pageFrame = nextFrames.pageFrame + let inspectorFrame = nextFrames.inspectorFrame let pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5) let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5) diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 8c9b02cd..90860cf2 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -8144,6 +8144,60 @@ final class WindowBrowserHostViewTests: XCTestCase { XCTAssertTrue(host.hitTest(contentPointInHost) === child) } + func testWindowBrowserPortalIgnoresHostedInspectorSplitResizeNotifications() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 420, height: 260), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + guard let container = contentView.superview else { + XCTFail("Expected content container") + return + } + + let hostFrame = container.convert(contentView.bounds, from: contentView) + let host = WindowBrowserHostView(frame: hostFrame) + host.autoresizingMask = [.width, .height] + container.addSubview(host, positioned: .above, relativeTo: contentView) + + let appSplit = NSSplitView(frame: contentView.bounds) + appSplit.autoresizingMask = [.width, .height] + appSplit.isVertical = true + appSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: contentView.bounds.height))) + appSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: contentView.bounds.height))) + contentView.addSubview(appSplit) + + let inspectorSplit = NSSplitView(frame: host.bounds) + inspectorSplit.autoresizingMask = [.width, .height] + inspectorSplit.isVertical = true + inspectorSplit.addSubview(NSView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))) + inspectorSplit.addSubview(NSView(frame: NSRect(x: 121, y: 0, width: 299, height: host.bounds.height))) + host.addSubview(inspectorSplit) + + XCTAssertTrue( + WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( + appSplit, + window: window, + hostView: host + ), + "App layout splits should still trigger browser portal geometry sync" + ) + XCTAssertFalse( + WindowBrowserPortal.shouldTreatSplitResizeAsExternalGeometry( + inspectorSplit, + window: window, + hostView: host + ), + "Hosted DevTools/internal splits should not trigger browser portal geometry sync" + ) + } + func testDragHoverEventsPassThroughForTabTransferOnBrowserHoverEvents() { XCTAssertTrue( WindowBrowserHostView.shouldPassThroughToDragTargets(