Fix side-docked browser DevTools resizing

This commit is contained in:
austinpower1258 2026-03-10 19:49:06 -07:00
parent 682e5bfca8
commit 91061ce341
3 changed files with 302 additions and 84 deletions

View file

@ -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()
}
})

View file

@ -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)

View file

@ -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(