Fix side-docked dev tools resize (#712)

* wip

* Fix side-docked dev tools resize
This commit is contained in:
Austin Wang 2026-03-05 21:28:31 -08:00 committed by GitHub
parent dd2eeae503
commit 15c7c0cc3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 2145 additions and 55 deletions

View file

@ -9314,12 +9314,32 @@ private extension NSWindow {
if let webView = candidate as? CmuxWebView {
return webView
}
if String(describing: type(of: candidate)).contains("WindowBrowserSlotView"),
let portalWebView = cmuxUniqueBrowserWebView(in: candidate) {
return portalWebView
}
current = candidate.superview
}
return nil
}
private static func cmuxUniqueBrowserWebView(in root: NSView) -> CmuxWebView? {
var stack: [NSView] = [root]
var found: CmuxWebView?
while let current = stack.popLast() {
if let webView = current as? CmuxWebView {
if found == nil {
found = webView
} else if found !== webView {
return nil
}
}
stack.append(contentsOf: current.subviews)
}
return found
}
private static func cmuxCurrentEvent(for _: NSWindow) -> NSEvent? {
#if DEBUG
if let override = cmuxFirstResponderGuardCurrentEventOverride {
@ -9335,7 +9355,22 @@ private extension NSWindow {
return override
}
#endif
return window.contentView?.hitTest(event.locationInWindow)
guard let contentView = window.contentView else { return nil }
if contentView.className == "NSGlassEffectView" {
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
}
if let themeFrame = contentView.superview {
let pointInTheme = themeFrame.convert(event.locationInWindow, from: nil)
if let hit = themeFrame.hitTest(pointInTheme) {
return hit
}
}
let pointInContent = contentView.convert(event.locationInWindow, from: nil)
return contentView.hitTest(pointInContent)
}
private static func cmuxTrackFieldEditor(_ fieldEditor: NSTextView, owningWebView webView: CmuxWebView?) {

View file

@ -24,6 +24,28 @@ final class WindowBrowserHostView: NSView {
let isVertical: Bool
}
private struct DividerHit {
let kind: DividerCursorKind
let isInHostedContent: Bool
}
private struct HostedInspectorDividerHit {
let slotView: WindowBrowserSlotView
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
}
private struct HostedInspectorDividerDragState {
let slotView: WindowBrowserSlotView
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
let initialWindowX: CGFloat
let initialPageFrame: NSRect
let initialInspectorFrame: NSRect
}
private enum DividerCursorKind: Equatable {
case vertical
case horizontal
@ -39,10 +61,54 @@ final class WindowBrowserHostView: NSView {
override var isOpaque: Bool { false }
private static let sidebarLeadingEdgeEpsilon: CGFloat = 1
private static let minimumVisibleLeadingContentWidth: CGFloat = 24
private static let hostedInspectorDividerHitExpansion: CGFloat = 6
private static let minimumHostedInspectorWidth: CGFloat = 120
private var cachedSidebarDividerX: CGFloat?
private var sidebarDividerMissCount = 0
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
#if DEBUG
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
switch event?.type {
case .leftMouseDown, .leftMouseDragged, .leftMouseUp:
return true
default:
return false
}
}
private func debugLogPointerRouting(
stage: String,
point: NSPoint,
titlebarPassThrough: Bool,
sidebarPassThrough: Bool,
dividerHit: DividerHit?,
hitView: NSView?
) {
let event = NSApp.currentEvent
guard Self.shouldLogPointerEvent(event) else { return }
let hitDesc: String = {
guard let hitView else { return "nil" }
return "\(type(of: hitView))@\(browserPortalDebugToken(hitView))"
}()
let dividerDesc: String = {
guard let dividerHit else { return "nil" }
let kind = dividerHit.kind == .vertical ? "vertical" : "horizontal"
return "kind=\(kind),hosted=\(dividerHit.isInHostedContent ? 1 : 0)"
}()
let windowPoint = convert(point, to: nil)
dlog(
"browser.portal.pointer stage=\(stage) event=\(String(describing: event?.type)) " +
"host=\(browserPortalDebugToken(self)) point=\(browserPortalDebugFrame(NSRect(origin: point, size: .zero))) " +
"windowPoint=\(browserPortalDebugFrame(NSRect(origin: windowPoint, size: .zero))) " +
"titlebar=\(titlebarPassThrough ? 1 : 0) sidebar=\(sidebarPassThrough ? 1 : 0) " +
"divider=\(dividerDesc) hit=\(hitDesc)"
)
}
#endif
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
@ -62,9 +128,29 @@ final class WindowBrowserHostView: NSView {
window?.invalidateCursorRects(for: self)
}
override func layout() {
super.layout()
reapplyHostedInspectorDividersIfNeeded(reason: "host.layout")
}
override func didAddSubview(_ subview: NSView) {
super.didAddSubview(subview)
guard let slot = subview as? WindowBrowserSlotView else { return }
slot.onHostedInspectorLayout = { [weak self] slotView in
self?.reapplyHostedInspectorDividerIfNeeded(in: slotView, reason: "slot.layout")
}
}
override func willRemoveSubview(_ subview: NSView) {
if let slot = subview as? WindowBrowserSlotView {
slot.onHostedInspectorLayout = nil
}
super.willRemoveSubview(subview)
}
override func resetCursorRects() {
super.resetCursorRects()
guard let window, let rootView = window.contentView else { return }
guard let rootView = dividerSearchRootView() else { return }
var regions: [DividerRegion] = []
Self.collectSplitDividerRegions(in: rootView, into: &regions)
let expansion: CGFloat = 4
@ -113,18 +199,57 @@ final class WindowBrowserHostView: NSView {
}
override func hitTest(_ point: NSPoint) -> NSView? {
updateDividerCursor(at: point)
let dividerHit = splitDividerHit(at: point)
let hostedInspectorHit = dividerHit == nil ? hostedInspectorDividerHit(at: point) : nil
updateDividerCursor(at: point, dividerHit: dividerHit, hostedInspectorHit: hostedInspectorHit)
if shouldPassThroughToTitlebar(at: point) {
return nil
}
if shouldPassThroughToSidebarResizer(at: point) {
return nil
}
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
let titlebarPassThrough = shouldPassThroughToTitlebar(at: point)
let sidebarPassThrough = shouldPassThroughToSidebarResizer(
at: point,
dividerHit: dividerHit,
hostedInspectorHit: hostedInspectorHit
)
let splitPassThrough = dividerHit.map { !$0.isInHostedContent } ?? false
if titlebarPassThrough {
#if DEBUG
debugLogPointerRouting(
stage: "hitTest.titlebarPass",
point: point,
titlebarPassThrough: true,
sidebarPassThrough: sidebarPassThrough,
dividerHit: dividerHit,
hitView: nil
)
#endif
return nil
}
if sidebarPassThrough {
#if DEBUG
debugLogPointerRouting(
stage: "hitTest.sidebarPass",
point: point,
titlebarPassThrough: false,
sidebarPassThrough: true,
dividerHit: dividerHit,
hitView: nil
)
#endif
return nil
}
if splitPassThrough {
#if DEBUG
debugLogPointerRouting(
stage: "hitTest.splitPass",
point: point,
titlebarPassThrough: false,
sidebarPassThrough: false,
dividerHit: dividerHit,
hitView: nil
)
#endif
return nil
}
// Mirror terminal portal routing: while tab-reorder drags are active,
// pass through to SwiftUI drop targets behind the portal host.
// Browser hover routing also arrives as cursor/enter events and may not
@ -135,10 +260,143 @@ final class WindowBrowserHostView: NSView {
) {
return nil
}
if let hostedInspectorHit {
if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) {
#if DEBUG
debugLogPointerRouting(
stage: "hitTest.hostedInspectorNative",
point: point,
titlebarPassThrough: false,
sidebarPassThrough: false,
dividerHit: DividerHit(kind: .vertical, isInHostedContent: true),
hitView: nativeHit
)
#endif
return nativeHit
}
#if DEBUG
debugLogPointerRouting(
stage: "hitTest.hostedInspectorManual",
point: point,
titlebarPassThrough: false,
sidebarPassThrough: false,
dividerHit: DividerHit(kind: .vertical, isInHostedContent: true),
hitView: hostedInspectorHit.inspectorView
)
#endif
return self
}
let hitView = super.hitTest(point)
#if DEBUG
debugLogPointerRouting(
stage: "hitTest.result",
point: point,
titlebarPassThrough: false,
sidebarPassThrough: false,
dividerHit: dividerHit,
hitView: hitView === self ? nil : hitView
)
#endif
return hitView === self ? nil : hitView
}
override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else {
super.mouseDown(with: event)
return
}
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
slotView: hostedInspectorHit.slotView,
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
inspectorView: hostedInspectorHit.inspectorView,
initialWindowX: event.locationInWindow.x,
initialPageFrame: hostedInspectorHit.pageView.frame,
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
)
#if DEBUG
dlog(
"browser.portal.manualInspectorDrag stage=start slot=\(browserPortalDebugToken(hostedInspectorHit.slotView)) " +
"page=\(browserPortalDebugToken(hostedInspectorHit.pageView)) " +
"inspector=\(browserPortalDebugToken(hostedInspectorHit.inspectorView)) " +
"pageFrame=\(browserPortalDebugFrame(hostedInspectorHit.pageView.frame)) " +
"inspectorFrame=\(browserPortalDebugFrame(hostedInspectorHit.inspectorView.frame))"
)
#endif
}
override func mouseDragged(with event: NSEvent) {
guard let dragState = hostedInspectorDividerDrag else {
super.mouseDragged(with: event)
return
}
guard dragState.slotView.window === window else {
hostedInspectorDividerDrag = nil
super.mouseDragged(with: event)
return
}
let containerBounds = dragState.containerView.bounds
let minimumInspectorWidth = min(
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)
dragState.slotView.preferredHostedInspectorWidth = inspectorWidth
let appliedFrames = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
slotView: dragState.slotView,
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
),
reason: "drag"
)
updateDividerCursor(
at: convert(event.locationInWindow, from: nil),
dividerHit: nil,
hostedInspectorHit: HostedInspectorDividerHit(
slotView: dragState.slotView,
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
)
)
#if DEBUG
dlog(
"browser.portal.manualInspectorDrag stage=update slot=\(browserPortalDebugToken(dragState.slotView)) " +
"dividerX=\(String(format: "%.1f", clampedDividerX)) " +
"pageFrame=\(browserPortalDebugFrame(appliedFrames.pageFrame)) " +
"inspectorFrame=\(browserPortalDebugFrame(appliedFrames.inspectorFrame))"
)
#endif
}
override func mouseUp(with event: NSEvent) {
if let dragState = hostedInspectorDividerDrag {
#if DEBUG
dlog(
"browser.portal.manualInspectorDrag stage=end slot=\(browserPortalDebugToken(dragState.slotView)) " +
"pageFrame=\(browserPortalDebugFrame(dragState.pageView.frame)) " +
"inspectorFrame=\(browserPortalDebugFrame(dragState.inspectorView.frame))"
)
#endif
scheduleHostedInspectorDividerReapply(in: dragState.slotView, reason: "dragEndAsync")
}
hostedInspectorDividerDrag = nil
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
super.mouseUp(with: event)
}
private func shouldPassThroughToTitlebar(at point: NSPoint) -> Bool {
guard let window else { return false }
// Window-level portal hosts sit above SwiftUI content. Never intercept
@ -152,6 +410,31 @@ final class WindowBrowserHostView: NSView {
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
let dividerHit = splitDividerHit(at: point)
let hostedInspectorHit = dividerHit == nil ? hostedInspectorDividerHit(at: point) : nil
return shouldPassThroughToSidebarResizer(
at: point,
dividerHit: dividerHit,
hostedInspectorHit: hostedInspectorHit
)
}
private func shouldPassThroughToSidebarResizer(
at point: NSPoint,
dividerHit: DividerHit?,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) -> Bool {
// If WebKit has a hosted vertical inspector split collapsed to the pane edge,
// prefer that divider over the app/sidebar resize hit zone.
if let dividerHit,
dividerHit.isInHostedContent,
dividerHit.kind == .vertical {
return false
}
if hostedInspectorHit != nil {
return false
}
// Browser portal host sits above SwiftUI content. Allow pointer/mouse events
// to reach the SwiftUI sidebar divider resizer zone.
let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView }
@ -202,13 +485,24 @@ final class WindowBrowserHostView: NSView {
return point.x >= regionMinX && point.x <= regionMaxX
}
private func updateDividerCursor(at point: NSPoint) {
if shouldPassThroughToSidebarResizer(at: point) {
private func updateDividerCursor(
at point: NSPoint,
dividerHit: DividerHit? = nil,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) {
let resolvedDividerHit = dividerHit ?? splitDividerHit(at: point)
let resolvedHostedInspectorHit = resolvedDividerHit == nil ? (hostedInspectorHit ?? hostedInspectorDividerHit(at: point)) : nil
if shouldPassThroughToSidebarResizer(
at: point,
dividerHit: resolvedDividerHit,
hostedInspectorHit: resolvedHostedInspectorHit
) {
clearActiveDividerCursor(restoreArrow: false)
return
}
guard let nextKind = splitDividerCursorKind(at: point) else {
let nextKind = resolvedDividerHit?.kind ?? (resolvedHostedInspectorHit == nil ? nil : .vertical)
guard let nextKind else {
clearActiveDividerCursor(restoreArrow: true)
return
}
@ -216,6 +510,26 @@ final class WindowBrowserHostView: NSView {
nextKind.cursor.set()
}
private func nativeHostedInspectorHit(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit
) -> NSView? {
guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil }
if nativeHit === hostedInspectorHit.pageView ||
nativeHit.isDescendant(of: hostedInspectorHit.pageView) {
return nil
}
if nativeHit === hostedInspectorHit.inspectorView ||
nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) {
return nativeHit
}
if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit),
!(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) {
return nativeHit
}
return nil
}
private func clearActiveDividerCursor(restoreArrow: Bool) {
guard activeDividerCursorKind != nil else { return }
window?.invalidateCursorRects(for: self)
@ -225,15 +539,25 @@ final class WindowBrowserHostView: NSView {
}
}
private func splitDividerCursorKind(at point: NSPoint) -> DividerCursorKind? {
guard let window else { return nil }
private func splitDividerHit(at point: NSPoint) -> DividerHit? {
guard window != nil else { return nil }
let windowPoint = convert(point, to: nil)
guard let rootView = window.contentView else { return nil }
return Self.dividerCursorKind(at: windowPoint, in: rootView)
guard let rootView = dividerSearchRootView() else { return nil }
return Self.dividerHit(at: windowPoint, in: rootView, hostView: self)
}
private func dividerSearchRootView() -> NSView? {
if let container = superview {
return container
}
return window?.contentView
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
splitDividerCursorKind(at: point) != nil
guard let dividerHit = splitDividerHit(at: point) else { return false }
// Portal host should pass split-divider events through to app layout splits,
// but keep WebKit inspector/internal split dividers interactive.
return !dividerHit.isInHostedContent
}
static func shouldPassThroughToDragTargets(
@ -261,7 +585,188 @@ final class WindowBrowserHostView: NSView {
}
}
private static func dividerCursorKind(at windowPoint: NSPoint, in view: NSView) -> DividerCursorKind? {
private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? {
let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.height > 1 }
for slot in visibleSlots {
let pointInSlot = slot.convert(point, from: self)
guard slot.bounds.contains(pointInSlot),
let hit = hostedInspectorDividerCandidate(in: slot) else {
continue
}
if hostedInspectorDividerHitRect(for: hit).contains(pointInSlot) {
return hit
}
}
return nil
}
private func hostedInspectorDividerCandidate(in slot: WindowBrowserSlotView) -> HostedInspectorDividerHit? {
let inspectorCandidates = Self.visibleDescendants(in: slot)
.filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) }
.sorted { lhs, rhs in
let lhsFrame = slot.convert(lhs.bounds, from: lhs)
let rhsFrame = slot.convert(rhs.bounds, from: rhs)
return lhsFrame.minX < rhsFrame.minX
}
var bestHit: HostedInspectorDividerHit?
var bestScore = -CGFloat.greatestFiniteMagnitude
for inspectorCandidate in inspectorCandidates {
guard let candidate = hostedInspectorDividerCandidate(in: slot, startingAt: inspectorCandidate) else {
continue
}
let score = hostedInspectorDividerCandidateScore(candidate)
if score > bestScore {
bestScore = score
bestHit = candidate
}
}
return bestHit
}
private func hostedInspectorDividerCandidate(
in slot: WindowBrowserSlotView,
startingAt inspectorLeaf: NSView
) -> HostedInspectorDividerHit? {
var current: NSView? = inspectorLeaf
var bestHit: HostedInspectorDividerHit?
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
}
if let pageView = pageCandidates.max(by: {
hostedInspectorPageCandidateScore($0, inspectorView: inspectorView)
< hostedInspectorPageCandidateScore($1, inspectorView: inspectorView)
}) {
bestHit = HostedInspectorDividerHit(
slotView: slot,
containerView: containerView,
pageView: pageView,
inspectorView: inspectorView
)
}
current = containerView
}
return bestHit
}
private func hostedInspectorDividerHitRect(for hit: HostedInspectorDividerHit) -> NSRect {
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)
)
}
private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat {
let pageFrame = hit.slotView.convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = hit.slotView.convert(hit.inspectorView.bounds, from: hit.inspectorView)
let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame)
let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX)
return (overlap * 1_000) + coverageWidth + pageFrame.width
}
private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat {
let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame)
let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX)
return (overlap * 1_000) + coverageWidth + pageView.frame.width
}
private func reapplyHostedInspectorDividersIfNeeded(reason: String) {
let visibleSlots = subviews.compactMap { $0 as? WindowBrowserSlotView }
.filter { !$0.isHidden && $0.window != nil && $0.frame.height > 1 }
for slot in visibleSlots {
reapplyHostedInspectorDividerIfNeeded(in: slot, reason: reason)
}
}
private func scheduleHostedInspectorDividerReapply(in slot: WindowBrowserSlotView, reason: String) {
guard slot.preferredHostedInspectorWidth != nil else { return }
DispatchQueue.main.async { [weak self, weak slot] in
guard let self, let slot, slot.isDescendant(of: self) else { return }
self.reapplyHostedInspectorDividerIfNeeded(in: slot, reason: reason)
}
}
fileprivate func reapplyHostedInspectorDividerIfNeeded(in slot: WindowBrowserSlotView, reason: String) {
guard let preferredWidth = slot.preferredHostedInspectorWidth else { return }
guard let hit = hostedInspectorDividerCandidate(in: slot) else { return }
_ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
}
@discardableResult
private func applyHostedInspectorDividerWidth(
_ preferredWidth: CGFloat,
to hit: HostedInspectorDividerHit,
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 pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
guard pageChanged || inspectorChanged else {
return (pageFrame, inspectorFrame)
}
hit.slotView.isApplyingHostedInspectorLayout = true
CATransaction.begin()
CATransaction.setDisableActions(true)
hit.pageView.frame = pageFrame
hit.inspectorView.frame = inspectorFrame
CATransaction.commit()
hit.slotView.isApplyingHostedInspectorLayout = false
hit.pageView.needsLayout = true
hit.inspectorView.needsLayout = true
hit.containerView.needsLayout = true
hit.slotView.needsLayout = true
#if DEBUG
dlog(
"browser.portal.manualInspectorDrag stage=reapply slot=\(browserPortalDebugToken(hit.slotView)) " +
"container=\(browserPortalDebugToken(hit.containerView)) reason=\(reason) " +
"preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
"pageFrame=\(browserPortalDebugFrame(pageFrame)) " +
"inspectorFrame=\(browserPortalDebugFrame(inspectorFrame))"
)
#endif
return (pageFrame, inspectorFrame)
}
private static func dividerHit(
at windowPoint: NSPoint,
in view: NSView,
hostView: WindowBrowserHostView
) -> DividerHit? {
guard !view.isHidden else { return nil }
if let splitView = view as? NSSplitView {
@ -299,21 +804,62 @@ final class WindowBrowserHostView: NSView {
}
let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion)
if expanded.contains(pointInSplit) {
return splitView.isVertical ? .vertical : .horizontal
return DividerHit(
kind: splitView.isVertical ? .vertical : .horizontal,
isInHostedContent: splitView.isDescendant(of: hostView)
)
}
}
}
}
for subview in view.subviews.reversed() {
if let kind = dividerCursorKind(at: windowPoint, in: subview) {
return kind
if let hit = dividerHit(at: windowPoint, in: subview, hostView: hostView) {
return hit
}
}
return nil
}
private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.01) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func visibleDescendants(in root: NSView) -> [NSView] {
var descendants: [NSView] = []
var stack = Array(root.subviews.reversed())
while let view = stack.popLast() {
descendants.append(view)
stack.append(contentsOf: view.subviews.reversed())
}
return descendants
}
private static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
private static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.height > 1
}
private static func collectSplitDividerRegions(in view: NSView, into result: inout [DividerRegion]) {
guard !view.isHidden else { return }
@ -674,6 +1220,9 @@ final class WindowBrowserSlotView: NSView {
private var displayedDropZone: DropZone?
private var dropZoneOverlayAnimationGeneration: UInt64 = 0
private var isRefreshingInteractionLayers = false
var preferredHostedInspectorWidth: CGFloat?
var onHostedInspectorLayout: ((WindowBrowserSlotView) -> Void)?
fileprivate var isApplyingHostedInspectorLayout = false
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
@ -703,6 +1252,8 @@ final class WindowBrowserSlotView: NSView {
super.layout()
paneDropTargetView.frame = bounds
applyResolvedDropZoneOverlay()
guard !isApplyingHostedInspectorLayout else { return }
onHostedInspectorLayout?(self)
}
func setDropZoneOverlay(zone: DropZone?) {
@ -1805,6 +2356,7 @@ final class WindowBrowserPortal: NSObject {
reason: "\(source):" + refreshReasons.joined(separator: ",")
)
}
hostView.reapplyHostedInspectorDividerIfNeeded(in: containerView, reason: "portal.sync")
#if DEBUG
dlog(
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +

View file

@ -2587,7 +2587,7 @@ extension BrowserPanel {
/// while its container is off-window. Avoid detaching in that transient phase if
/// DevTools is intended to remain open, because detach/reattach can blank inspector content.
func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool {
preferredDeveloperToolsVisible
preferredDeveloperToolsVisible && !hasSideDockedDeveloperToolsLayout()
}
func requestDeveloperToolsRefreshAfterNextAttach(reason: String) {
@ -3045,6 +3045,7 @@ extension BrowserPanel {
let containerType = container.map { String(describing: type(of: $0)) } ?? "nil"
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
}
}
#endif
@ -3069,6 +3070,71 @@ private extension BrowserPanel {
}
return false
}
func hasSideDockedDeveloperToolsLayout() -> Bool {
guard let container = webView.superview else { return false }
return Self.visibleDescendants(in: container)
.filter { Self.isVisibleSideDockInspectorCandidate($0) && Self.isInspectorView($0) }
.contains { inspectorCandidate in
hasSideDockedInspectorSibling(startingAt: inspectorCandidate, root: container)
}
}
func hasSideDockedInspectorSibling(startingAt inspectorLeaf: NSView, root: NSView) -> Bool {
var current: NSView? = inspectorLeaf
while let inspectorView = current, inspectorView !== root {
guard let containerView = inspectorView.superview else { break }
let hasSideDockedSibling = containerView.subviews.contains { candidate in
guard Self.isVisibleSideDockSiblingCandidate(candidate) else { return false }
guard candidate !== inspectorView else { return false }
let horizontallyAdjacent =
candidate.frame.maxX <= inspectorView.frame.minX + 1 ||
candidate.frame.minX >= inspectorView.frame.maxX - 1
guard horizontallyAdjacent else { return false }
return Self.verticalOverlap(between: candidate.frame, and: inspectorView.frame) > 8
}
if hasSideDockedSibling {
return true
}
current = containerView
}
return false
}
static func visibleDescendants(in root: NSView) -> [NSView] {
var descendants: [NSView] = []
var stack = Array(root.subviews.reversed())
while let view = stack.popLast() {
descendants.append(view)
stack.append(contentsOf: view.subviews.reversed())
}
return descendants
}
static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
static func isVisibleSideDockInspectorCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
static func isVisibleSideDockSiblingCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
}
}
private extension WKWebView {

View file

@ -3055,44 +3055,360 @@ struct WebViewRepresentable: NSViewRepresentable {
var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
}
private final class HostContainerView: NSView {
final class HostContainerView: NSView {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
private struct HostedInspectorDividerHit {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
}
private struct HostedInspectorDividerDragState {
let containerView: NSView
let pageView: NSView
let inspectorView: NSView
let initialWindowX: CGFloat
let initialPageFrame: NSRect
let initialInspectorFrame: NSRect
}
private enum DividerCursorKind: Equatable {
case vertical
var cursor: NSCursor { .resizeLeftRight }
}
private static let hostedInspectorDividerHitExpansion: CGFloat = 6
private static let minimumHostedInspectorWidth: CGFloat = 120
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
private var preferredHostedInspectorWidth: CGFloat?
private var isApplyingHostedInspectorLayout = false
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
private var hasLoggedMissingHostedInspectorCandidate = false
#endif
#if DEBUG
private static func shouldLogPointerEvent(_ event: NSEvent?) -> Bool {
switch event?.type {
case .leftMouseDown, .leftMouseDragged, .leftMouseUp:
return true
default:
return false
}
}
private func debugLogHitTest(stage: String, point: NSPoint, passThrough: Bool, hitView: NSView?) {
let event = NSApp.currentEvent
guard Self.shouldLogPointerEvent(event) else { return }
let hitDesc: String = {
guard let hitView else { return "nil" }
let token = Unmanaged.passUnretained(hitView).toOpaque()
return "\(type(of: hitView))@\(token)"
}()
let hostRectInContent: NSRect = {
guard let window, let contentView = window.contentView else { return .zero }
return contentView.convert(bounds, from: self)
}()
dlog(
"browser.panel.host stage=\(stage) event=\(String(describing: event?.type)) " +
"point=\(String(format: "%.1f,%.1f", point.x, point.y)) pass=\(passThrough ? 1 : 0) " +
"hostFrameInContent=\(String(format: "%.1f,%.1f %.1fx%.1f", hostRectInContent.origin.x, hostRectInContent.origin.y, hostRectInContent.width, hostRectInContent.height)) " +
"hit=\(hitDesc)"
)
}
private static func debugObjectID(_ object: AnyObject?) -> String {
guard let object else { return "nil" }
return String(describing: Unmanaged.passUnretained(object).toOpaque())
}
private static func debugRect(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.width, rect.height)
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.width - rhs.width) <= epsilon &&
abs(lhs.height - rhs.height) <= epsilon
}
private func debugLogHostedInspectorFrames(
stage: String,
point: NSPoint? = nil,
hit: HostedInspectorDividerHit
) {
let pointDesc = point.map { String(format: "%.1f,%.1f", $0.x, $0.y) } ?? "nil"
let preferredWidthDesc = preferredHostedInspectorWidth.map { String(format: "%.1f", $0) } ?? "nil"
dlog(
"browser.panel.hostedInspector stage=\(stage) point=\(pointDesc) " +
"host=\(Self.debugObjectID(self)) container=\(Self.debugObjectID(hit.containerView)) " +
"page=\(Self.debugObjectID(hit.pageView)) inspector=\(Self.debugObjectID(hit.inspectorView)) " +
"preferredWidth=\(preferredWidthDesc) " +
"hostFrame=\(Self.debugRect(frame)) hostBounds=\(Self.debugRect(bounds)) " +
"containerBounds=\(Self.debugRect(hit.containerView.bounds)) " +
"pageFrame=\(Self.debugRect(hit.pageView.frame)) " +
"inspectorFrame=\(Self.debugRect(hit.inspectorView.frame))"
)
}
private func debugLogHostedInspectorLayoutIfNeeded(reason: String) {
guard let hit = hostedInspectorDividerCandidate() else {
if !hasLoggedMissingHostedInspectorCandidate,
lastLoggedHostedInspectorFrames != nil || preferredHostedInspectorWidth != nil {
let preferredWidthDesc = preferredHostedInspectorWidth.map {
String(format: "%.1f", $0)
} ?? "nil"
lastLoggedHostedInspectorFrames = nil
hasLoggedMissingHostedInspectorCandidate = true
dlog(
"browser.panel.hostedInspector stage=\(reason).candidateMissing " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(preferredWidthDesc)"
)
}
return
}
hasLoggedMissingHostedInspectorCandidate = false
let nextFrames = (page: hit.pageView.frame, inspector: hit.inspectorView.frame)
if let lastLoggedHostedInspectorFrames,
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.page, nextFrames.page),
Self.rectApproximatelyEqual(lastLoggedHostedInspectorFrames.inspector, nextFrames.inspector) {
return
}
lastLoggedHostedInspectorFrames = nextFrames
debugLogHostedInspectorFrames(stage: "\(reason).layout", hit: hit)
}
#endif
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
clearActiveDividerCursor(restoreArrow: false)
} else {
reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToWindow")
}
window?.invalidateCursorRects(for: self)
onDidMoveToWindow?()
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToWindow")
#endif
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
reapplyHostedInspectorDividerIfNeeded(reason: "viewDidMoveToSuperview")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "viewDidMoveToSuperview")
#endif
}
override func layout() {
super.layout()
reapplyHostedInspectorDividerIfNeeded(reason: "layout")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
#endif
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
window?.invalidateCursorRects(for: self)
reapplyHostedInspectorDividerIfNeeded(reason: "setFrameOrigin")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameOrigin")
#endif
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
window?.invalidateCursorRects(for: self)
reapplyHostedInspectorDividerIfNeeded(reason: "setFrameSize")
onGeometryChanged?()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "setFrameSize")
#endif
}
override func resetCursorRects() {
super.resetCursorRects()
guard let hostedInspectorHit = hostedInspectorDividerCandidate() else { return }
let clipped = hostedInspectorDividerHitRect(for: hostedInspectorHit).intersection(bounds)
guard !clipped.isNull, clipped.width > 0, clipped.height > 0 else { return }
addCursorRect(clipped, cursor: NSCursor.resizeLeftRight)
}
override func updateTrackingAreas() {
if let trackingArea {
removeTrackingArea(trackingArea)
}
let options: NSTrackingArea.Options = [
.inVisibleRect,
.activeAlways,
.cursorUpdate,
.mouseMoved,
.mouseEnteredAndExited,
.enabledDuringMouseDrag,
]
let next = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
addTrackingArea(next)
trackingArea = next
super.updateTrackingAreas()
}
override func cursorUpdate(with event: NSEvent) {
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
}
override func mouseMoved(with event: NSEvent) {
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
}
override func mouseExited(with event: NSEvent) {
clearActiveDividerCursor(restoreArrow: true)
}
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSidebarResizer(at: point) {
let hostedInspectorHit = hostedInspectorDividerHit(at: point)
updateDividerCursor(at: point, hostedInspectorHit: hostedInspectorHit)
let passThrough = shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: hostedInspectorHit)
if passThrough {
#if DEBUG
debugLogHitTest(stage: "hitTest.pass", point: point, passThrough: true, hitView: nil)
#endif
return nil
}
return super.hitTest(point)
if let hostedInspectorHit {
if let nativeHit = nativeHostedInspectorHit(at: point, hostedInspectorHit: hostedInspectorHit) {
#if DEBUG
debugLogHitTest(stage: "hitTest.hostedInspectorNative", point: point, passThrough: false, hitView: nativeHit)
#endif
return nativeHit
}
#if DEBUG
debugLogHitTest(stage: "hitTest.hostedInspectorManual", point: point, passThrough: false, hitView: hostedInspectorHit.inspectorView)
#endif
return self
}
let hit = super.hitTest(point)
#if DEBUG
debugLogHitTest(stage: "hitTest.result", point: point, passThrough: false, hitView: hit)
#endif
return hit
}
private func shouldPassThroughToSidebarResizer(at point: NSPoint) -> Bool {
override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
guard let hostedInspectorHit = hostedInspectorDividerHit(at: point) else {
super.mouseDown(with: event)
return
}
hostedInspectorDividerDrag = HostedInspectorDividerDragState(
containerView: hostedInspectorHit.containerView,
pageView: hostedInspectorHit.pageView,
inspectorView: hostedInspectorHit.inspectorView,
initialWindowX: event.locationInWindow.x,
initialPageFrame: hostedInspectorHit.pageView.frame,
initialInspectorFrame: hostedInspectorHit.inspectorView.frame
)
#if DEBUG
debugLogHostedInspectorFrames(stage: "drag.start", point: point, hit: hostedInspectorHit)
#endif
}
override func mouseDragged(with event: NSEvent) {
guard let dragState = hostedInspectorDividerDrag else {
super.mouseDragged(with: event)
return
}
let containerBounds = dragState.containerView.bounds
let minimumInspectorWidth = min(
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)
preferredHostedInspectorWidth = inspectorWidth
_ = applyHostedInspectorDividerWidth(
inspectorWidth,
to: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
),
reason: "drag"
)
#if DEBUG
debugLogHostedInspectorFrames(
stage: "drag.update",
point: convert(event.locationInWindow, from: nil),
hit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
)
)
#endif
updateDividerCursor(
at: convert(event.locationInWindow, from: nil),
hostedInspectorHit: HostedInspectorDividerHit(
containerView: dragState.containerView,
pageView: dragState.pageView,
inspectorView: dragState.inspectorView
)
)
}
override func mouseUp(with event: NSEvent) {
let finalDragState = hostedInspectorDividerDrag
hostedInspectorDividerDrag = nil
updateDividerCursor(at: convert(event.locationInWindow, from: nil))
scheduleHostedInspectorDividerReapply(reason: "dragEndAsync")
#if DEBUG
if let finalDragState {
let finalHit = HostedInspectorDividerHit(
containerView: finalDragState.containerView,
pageView: finalDragState.pageView,
inspectorView: finalDragState.inspectorView
)
debugLogHostedInspectorFrames(
stage: "drag.end",
point: convert(event.locationInWindow, from: nil),
hit: finalHit
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.reapplyHostedInspectorDividerIfNeeded(reason: "drag.end.async")
self.debugLogHostedInspectorFrames(stage: "drag.end.async", hit: finalHit)
self.debugLogHostedInspectorLayoutIfNeeded(reason: "dragEndAsync")
}
}
#endif
super.mouseUp(with: event)
}
private func shouldPassThroughToSidebarResizer(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) -> Bool {
if hostedInspectorHit != nil {
return false
}
// Pass through a narrow leading-edge band so the shared sidebar divider
// handle can receive hover/click even when WKWebView is attached here.
// Keeping this deterministic avoids flicker from dynamic left-edge scans.
@ -3105,6 +3421,250 @@ struct WebViewRepresentable: NSViewRepresentable {
let hostRectInContent = contentView.convert(bounds, from: self)
return hostRectInContent.minX > 1
}
private func updateDividerCursor(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit? = nil
) {
let resolvedHostedInspectorHit = hostedInspectorHit ?? hostedInspectorDividerHit(at: point)
if shouldPassThroughToSidebarResizer(at: point, hostedInspectorHit: resolvedHostedInspectorHit) {
clearActiveDividerCursor(restoreArrow: false)
return
}
guard resolvedHostedInspectorHit != nil else {
clearActiveDividerCursor(restoreArrow: true)
return
}
activeDividerCursorKind = .vertical
NSCursor.resizeLeftRight.set()
}
private func clearActiveDividerCursor(restoreArrow: Bool) {
guard activeDividerCursorKind != nil else { return }
window?.invalidateCursorRects(for: self)
activeDividerCursorKind = nil
if restoreArrow {
NSCursor.arrow.set()
}
}
private func nativeHostedInspectorHit(
at point: NSPoint,
hostedInspectorHit: HostedInspectorDividerHit
) -> NSView? {
guard let nativeHit = super.hitTest(point), nativeHit !== self else { return nil }
if nativeHit === hostedInspectorHit.pageView ||
nativeHit.isDescendant(of: hostedInspectorHit.pageView) {
return nil
}
if nativeHit === hostedInspectorHit.inspectorView ||
nativeHit.isDescendant(of: hostedInspectorHit.inspectorView) {
return nativeHit
}
if hostedInspectorHit.inspectorView.isDescendant(of: nativeHit),
!(hostedInspectorHit.pageView === nativeHit || hostedInspectorHit.pageView.isDescendant(of: nativeHit)) {
return nativeHit
}
return nil
}
private func hostedInspectorDividerHit(at point: NSPoint) -> HostedInspectorDividerHit? {
guard let hit = hostedInspectorDividerCandidate(),
hostedInspectorDividerHitRect(for: hit).contains(point) else {
return nil
}
return hit
}
private func hostedInspectorDividerCandidate() -> HostedInspectorDividerHit? {
let inspectorCandidates = Self.visibleDescendants(in: self)
.filter { Self.isVisibleHostedInspectorCandidate($0) && Self.isInspectorView($0) }
.sorted { lhs, rhs in
let lhsFrame = convert(lhs.bounds, from: lhs)
let rhsFrame = convert(rhs.bounds, from: rhs)
return lhsFrame.minX < rhsFrame.minX
}
var bestHit: HostedInspectorDividerHit?
var bestScore = -CGFloat.greatestFiniteMagnitude
for inspectorCandidate in inspectorCandidates {
guard let candidate = hostedInspectorDividerCandidate(startingAt: inspectorCandidate) else {
continue
}
let score = hostedInspectorDividerCandidateScore(candidate)
if score > bestScore {
bestScore = score
bestHit = candidate
}
}
return bestHit
}
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)
)
}
private func hostedInspectorDividerCandidate(startingAt inspectorLeaf: NSView) -> HostedInspectorDividerHit? {
var current: NSView? = inspectorLeaf
var bestHit: HostedInspectorDividerHit?
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
}
if let pageView = pageCandidates.max(by: {
hostedInspectorPageCandidateScore($0, inspectorView: inspectorView)
< hostedInspectorPageCandidateScore($1, inspectorView: inspectorView)
}) {
bestHit = HostedInspectorDividerHit(
containerView: containerView,
pageView: pageView,
inspectorView: inspectorView
)
}
current = containerView
}
return bestHit
}
private func hostedInspectorDividerCandidateScore(_ hit: HostedInspectorDividerHit) -> CGFloat {
let pageFrame = convert(hit.pageView.bounds, from: hit.pageView)
let inspectorFrame = convert(hit.inspectorView.bounds, from: hit.inspectorView)
let overlap = Self.verticalOverlap(between: pageFrame, and: inspectorFrame)
let coverageWidth = max(pageFrame.maxX, inspectorFrame.maxX) - min(pageFrame.minX, inspectorFrame.minX)
return (overlap * 1_000) + coverageWidth + pageFrame.width
}
private func hostedInspectorPageCandidateScore(_ pageView: NSView, inspectorView: NSView) -> CGFloat {
let overlap = Self.verticalOverlap(between: pageView.frame, and: inspectorView.frame)
let coverageWidth = max(pageView.frame.maxX, inspectorView.frame.maxX) - min(pageView.frame.minX, inspectorView.frame.minX)
return (overlap * 1_000) + coverageWidth + pageView.frame.width
}
private func scheduleHostedInspectorDividerReapply(reason: String) {
guard preferredHostedInspectorWidth != nil else { return }
DispatchQueue.main.async { [weak self] in
self?.reapplyHostedInspectorDividerIfNeeded(reason: reason)
}
}
private func reapplyHostedInspectorDividerIfNeeded(reason: String) {
guard !isApplyingHostedInspectorLayout else { return }
guard let preferredWidth = preferredHostedInspectorWidth else { return }
guard let hit = hostedInspectorDividerCandidate() else {
#if DEBUG
if !hasLoggedMissingHostedInspectorCandidate {
hasLoggedMissingHostedInspectorCandidate = true
dlog(
"browser.panel.hostedInspector stage=\(reason).reapplyMissingCandidate " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth))"
)
}
#endif
return
}
#if DEBUG
hasLoggedMissingHostedInspectorCandidate = false
#endif
_ = applyHostedInspectorDividerWidth(preferredWidth, to: hit, reason: reason)
}
@discardableResult
private func applyHostedInspectorDividerWidth(
_ preferredWidth: CGFloat,
to hit: HostedInspectorDividerHit,
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 pageChanged = !Self.rectApproximatelyEqual(pageFrame, hit.pageView.frame, epsilon: 0.5)
let inspectorChanged = !Self.rectApproximatelyEqual(inspectorFrame, hit.inspectorView.frame, epsilon: 0.5)
guard pageChanged || inspectorChanged else {
return (pageFrame, inspectorFrame)
}
isApplyingHostedInspectorLayout = true
CATransaction.begin()
CATransaction.setDisableActions(true)
hit.pageView.frame = pageFrame
hit.inspectorView.frame = inspectorFrame
CATransaction.commit()
isApplyingHostedInspectorLayout = false
hit.pageView.needsLayout = true
hit.inspectorView.needsLayout = true
hit.containerView.needsLayout = true
needsLayout = true
#if DEBUG
dlog(
"browser.panel.hostedInspector stage=\(reason).reapply " +
"host=\(Self.debugObjectID(self)) preferredWidth=\(String(format: "%.1f", preferredWidth)) " +
"container=\(Self.debugObjectID(hit.containerView)) " +
"pageFrame=\(Self.debugRect(pageFrame)) inspectorFrame=\(Self.debugRect(inspectorFrame))"
)
#endif
return (pageFrame, inspectorFrame)
}
private static func visibleDescendants(in root: NSView) -> [NSView] {
var descendants: [NSView] = []
var stack = Array(root.subviews.reversed())
while let view = stack.popLast() {
descendants.append(view)
stack.append(contentsOf: view.subviews.reversed())
}
return descendants
}
private static func isInspectorView(_ view: NSView) -> Bool {
String(describing: type(of: view)).contains("WKInspector")
}
private static func isVisibleHostedInspectorCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.width > 1 &&
view.frame.height > 1
}
private static func isVisibleHostedInspectorSiblingCandidate(_ view: NSView) -> Bool {
!view.isHidden &&
view.alphaValue > 0 &&
view.frame.height > 1
}
private static func verticalOverlap(between lhs: NSRect, and rhs: NSRect) -> CGFloat {
max(0, min(lhs.maxY, rhs.maxY) - max(lhs.minY, rhs.minY))
}
}
#if DEBUG

View file

@ -412,7 +412,7 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
@MainActor
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusForPortalHostedWebView() {
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromPortalHostedInspectorSibling() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
@ -422,40 +422,51 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
backing: .buffered,
defer: false
)
let container = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = container
let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 240, height: 150))
container.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
let descendant = FirstResponderView(frame: NSRect(x: 0, y: 0, width: 10, height: 10))
webView.addSubview(descendant)
let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = contentView
window.makeKeyAndOrderFront(nil)
container.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1)
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
defer {
BrowserWindowPortalRegistry.detach(webView: webView)
AppDelegate.clearWindowFirstResponderGuardTesting()
window.orderOut(nil)
}
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 slot = WindowBrowserSlotView(frame: host.bounds)
slot.autoresizingMask = [.width, .height]
host.addSubview(slot)
let webView = CmuxWebView(frame: slot.bounds, configuration: WKWebViewConfiguration())
webView.autoresizingMask = [.width, .height]
slot.addSubview(webView)
let inspector = FirstResponderView(frame: NSRect(x: 440, y: 0, width: 200, height: slot.bounds.height))
inspector.autoresizingMask = [.minXMargin, .height]
slot.addSubview(inspector)
webView.allowsFirstResponderAcquisition = false
_ = window.makeFirstResponder(nil)
XCTAssertFalse(window.makeFirstResponder(descendant), "Expected blocked focus without pointer click context")
XCTAssertFalse(
window.makeFirstResponder(inspector),
"Expected portal-hosted inspector focus to stay blocked without pointer click context"
)
let timestamp = ProcessInfo.processInfo.systemUptime
let pointerPointInContent = NSPoint(x: anchor.frame.midX, y: anchor.frame.midY)
let pointerPointInWindow = container.convert(pointerPointInContent, to: nil)
let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
let pointInWindow = inspector.convert(pointInInspector, to: nil)
let pointerDownEvent = NSEvent.mouseEvent(
with: .leftMouseDown,
location: pointerPointInWindow,
location: pointInWindow,
modifierFlags: [],
timestamp: timestamp,
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 1,
@ -467,8 +478,83 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
_ = window.makeFirstResponder(nil)
XCTAssertTrue(
window.makeFirstResponder(descendant),
"Expected portal-hosted pointer click context to bypass blocked policy"
window.makeFirstResponder(inspector),
"Expected portal-hosted inspector click to bypass blocked policy using the overlay hit target"
)
}
@MainActor
func testWindowFirstResponderGuardAllowsPointerInitiatedClickFocusFromBoundPortalInspectorSiblingWhenHitTestMisses() {
_ = NSApplication.shared
AppDelegate.installWindowResponderSwizzlesForTesting()
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 420),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
window.contentView = contentView
let anchor = NSView(frame: NSRect(x: 80, y: 60, width: 480, height: 260))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
window.makeKeyAndOrderFront(nil)
contentView.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true, zPriority: 1)
BrowserWindowPortalRegistry.synchronizeForAnchor(anchor)
defer {
BrowserWindowPortalRegistry.detach(webView: webView)
AppDelegate.clearWindowFirstResponderGuardTesting()
window.orderOut(nil)
}
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected bound portal slot")
return
}
let inspector = FirstResponderView(frame: NSRect(x: 320, y: 0, width: 160, height: slot.bounds.height))
inspector.autoresizingMask = [.minXMargin, .height]
slot.addSubview(inspector)
webView.allowsFirstResponderAcquisition = false
_ = window.makeFirstResponder(nil)
XCTAssertFalse(
window.makeFirstResponder(inspector),
"Expected bound portal inspector focus to stay blocked without pointer click context"
)
let pointInInspector = NSPoint(x: inspector.bounds.midX, y: inspector.bounds.midY)
let pointInWindow = inspector.convert(pointInInspector, to: nil)
XCTAssertTrue(
BrowserWindowPortalRegistry.webViewAtWindowPoint(pointInWindow, in: window) === webView,
"Expected portal registry to resolve the owning web view from a click inside inspector chrome"
)
let pointerDownEvent = NSEvent.mouseEvent(
with: .leftMouseDown,
location: pointInWindow,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 1,
clickCount: 1,
pressure: 1.0
)
XCTAssertNotNil(pointerDownEvent)
AppDelegate.setWindowFirstResponderGuardTesting(currentEvent: pointerDownEvent, hitView: nil)
_ = window.makeFirstResponder(nil)
XCTAssertTrue(
window.makeFirstResponder(inspector),
"Expected bound portal inspector click to bypass blocked policy through portal registry fallback"
)
}
@ -2300,6 +2386,8 @@ final class BrowserSessionHistoryRestoreTests: XCTestCase {
@MainActor
final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
private final class WKInspectorProbeView: NSView {}
private final class FakeInspector: NSObject {
private(set) var showCount = 0
private(set) var closeCount = 0
@ -2498,6 +2586,42 @@ final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
XCTAssertNotNil(panel.webView.superview)
window.orderOut(nil)
}
func testTransientHideAttachmentPreserveDisablesForSideDockedInspectorLayout() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
panel.webView.frame = NSRect(x: 0, y: 0, width: 120, height: host.bounds.height)
host.addSubview(panel.webView)
let inspectorContainer = NSView(
frame: NSRect(x: 120, y: 0, width: host.bounds.width - 120, height: host.bounds.height)
)
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
host.addSubview(inspectorContainer)
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
}
func testTransientHideAttachmentPreserveStaysEnabledForBottomDockedInspectorLayout() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
let host = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 240))
panel.webView.frame = NSRect(x: 0, y: 80, width: host.bounds.width, height: host.bounds.height - 80)
host.addSubview(panel.webView)
let inspectorContainer = NSView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 80))
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
host.addSubview(inspectorContainer)
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
}
}
final class WorkspaceShortcutMapperTests: XCTestCase {
@ -7936,8 +8060,56 @@ final class WindowBrowserHostViewTests: XCTestCase {
}
}
private final class PrimaryPageProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class WKInspectorProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class EdgeTransparentWKInspectorProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
let localPoint = convert(point, from: superview)
guard bounds.contains(localPoint) else { return nil }
return localPoint.x <= 12 ? nil : self
}
}
private final class BonsplitMockSplitDelegate: NSObject, NSSplitViewDelegate {}
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
guard let event = NSEvent.mouseEvent(
with: type,
location: location,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 0,
clickCount: 1,
pressure: 1.0
) else {
fatalError("Failed to create \(type) mouse event")
}
return event
}
private func isInspectorOwnedHit(_ hit: NSView?, inspectorView: NSView, pageView: NSView) -> Bool {
guard let hit else { return false }
if hit === pageView || hit.isDescendant(of: pageView) {
return false
}
if hit === inspectorView || hit.isDescendant(of: inspectorView) {
return true
}
return inspectorView.isDescendant(of: hit) && !(pageView === hit || pageView.isDescendant(of: hit))
}
func testHostViewPassesThroughDividerWhenAdjacentPaneIsCollapsed() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 300, height: 180),
@ -7966,12 +8138,18 @@ final class WindowBrowserHostViewTests: XCTestCase {
splitView.adjustSubviews()
contentView.layoutSubtreeIfNeeded()
let host = WindowBrowserHostView(frame: contentView.bounds)
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]
let child = CapturingView(frame: host.bounds)
child.autoresizingMask = [.width, .height]
host.addSubview(child)
contentView.addSubview(host)
container.addSubview(host, positioned: .above, relativeTo: contentView)
let dividerPointInSplit = NSPoint(
x: splitView.arrangedSubviews[0].frame.maxX + (splitView.dividerThickness * 0.5),
@ -8023,6 +8201,705 @@ final class WindowBrowserHostViewTests: XCTestCase {
)
)
}
func testHostViewKeepsHostedInspectorDividerInteractive() {
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
}
// Underlying app layout split that should still be pass-through.
let appSplit = NSSplitView(frame: contentView.bounds)
appSplit.autoresizingMask = [.width, .height]
appSplit.isVertical = true
appSplit.dividerStyle = .thin
let appSplitDelegate = BonsplitMockSplitDelegate()
appSplit.delegate = appSplitDelegate
let leading = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: contentView.bounds.height))
let trailing = NSView(frame: NSRect(x: 211, y: 0, width: 209, height: contentView.bounds.height))
appSplit.addSubview(leading)
appSplit.addSubview(trailing)
contentView.addSubview(appSplit)
appSplit.adjustSubviews()
let hostFrame = container.convert(contentView.bounds, from: contentView)
let host = WindowBrowserHostView(frame: hostFrame)
host.autoresizingMask = [.width, .height]
container.addSubview(host, positioned: .above, relativeTo: contentView)
// WebKit inspector uses an internal split (page + console). Divider drags
// here must stay in hosted content, not pass through to appSplit behind it.
let inspectorSplit = NSSplitView(frame: host.bounds)
inspectorSplit.autoresizingMask = [.width, .height]
inspectorSplit.isVertical = false
inspectorSplit.dividerStyle = .thin
let inspectorDelegate = BonsplitMockSplitDelegate()
inspectorSplit.delegate = inspectorDelegate
let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: host.bounds.width, height: 160))
let consoleView = CapturingView(frame: NSRect(x: 0, y: 161, width: host.bounds.width, height: 99))
inspectorSplit.addSubview(pageView)
inspectorSplit.addSubview(consoleView)
host.addSubview(inspectorSplit)
inspectorSplit.setPosition(160, ofDividerAt: 0)
inspectorSplit.adjustSubviews()
contentView.layoutSubtreeIfNeeded()
let appDividerPointInSplit = NSPoint(
x: appSplit.arrangedSubviews[0].frame.maxX + (appSplit.dividerThickness * 0.5),
y: appSplit.bounds.midY
)
let appDividerPointInWindow = appSplit.convert(appDividerPointInSplit, to: nil)
let appDividerPointInHost = host.convert(appDividerPointInWindow, from: nil)
XCTAssertNil(
host.hitTest(appDividerPointInHost),
"Underlying app split divider should still pass through with a hosted inspector split present"
)
let dividerPointInInspector = NSPoint(
x: inspectorSplit.bounds.midX,
y: inspectorSplit.arrangedSubviews[0].frame.maxY + (inspectorSplit.dividerThickness * 0.5)
)
let dividerPointInWindow = inspectorSplit.convert(dividerPointInInspector, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
let hit = host.hitTest(dividerPointInHost)
XCTAssertNotNil(
hit,
"Inspector divider should receive hit-testing in hosted content, not pass through"
)
XCTAssertFalse(hit === host)
if let hit {
XCTAssertTrue(
hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
"Expected hit to remain inside inspector split subtree"
)
}
}
func testHostViewKeepsHostedVerticalInspectorDividerInteractiveAtSlotLeadingEdge() {
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 slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
slot.autoresizingMask = [.minXMargin, .height]
host.addSubview(slot)
let inspectorSplit = NSSplitView(frame: slot.bounds)
inspectorSplit.autoresizingMask = [.width, .height]
inspectorSplit.isVertical = true
inspectorSplit.dividerStyle = .thin
let inspectorDelegate = BonsplitMockSplitDelegate()
inspectorSplit.delegate = inspectorDelegate
let pageView = CapturingView(frame: NSRect(x: 0, y: 0, width: 1, height: slot.bounds.height))
let inspectorView = CapturingView(
frame: NSRect(x: 2, y: 0, width: slot.bounds.width - 2, height: slot.bounds.height)
)
inspectorSplit.addSubview(pageView)
inspectorSplit.addSubview(inspectorView)
slot.addSubview(inspectorSplit)
inspectorSplit.setPosition(1, ofDividerAt: 0)
inspectorSplit.adjustSubviews()
contentView.layoutSubtreeIfNeeded()
let dividerPointInSplit = NSPoint(
x: inspectorSplit.arrangedSubviews[0].frame.maxX + (inspectorSplit.dividerThickness * 0.5),
y: inspectorSplit.bounds.midY
)
let dividerPointInWindow = inspectorSplit.convert(dividerPointInSplit, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
XCTAssertLessThanOrEqual(inspectorSplit.arrangedSubviews[0].frame.width, 1.5)
XCTAssertTrue(
abs(dividerPointInHost.x - slot.frame.minX) <= SidebarResizeInteraction.hitWidthPerSide,
"Expected collapsed hosted divider to overlap the browser slot leading-edge resizer zone"
)
let hit = host.hitTest(dividerPointInHost)
XCTAssertNotNil(
hit,
"Hosted vertical inspector divider should stay interactive even when collapsed onto the slot edge"
)
XCTAssertFalse(hit === host)
if let hit {
XCTAssertTrue(
hit === inspectorSplit || hit.isDescendant(of: inspectorSplit),
"Expected hit to remain inside hosted inspector split subtree at the slot edge"
)
}
}
func testHostViewPrefersNativeHostedInspectorSiblingDividerHit() {
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 slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
slot.autoresizingMask = [.minXMargin, .height]
host.addSubview(slot)
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
let inspectorView = WKInspectorProbeView(
frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
)
slot.addSubview(pageView)
slot.addSubview(inspectorView)
contentView.layoutSubtreeIfNeeded()
let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
let bodyPointInSlot = NSPoint(x: inspectorView.frame.minX + 18, y: slot.bounds.midY)
let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
let dividerHit = host.hitTest(dividerPointInHost)
XCTAssertTrue(
isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
"Hosted right-docked inspector divider should stay on the native WebKit hit path when WebKit exposes a hittable inspector-side view. actual=\(String(describing: dividerHit))"
)
let interiorHit = host.hitTest(bodyPointInHost)
XCTAssertTrue(
isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
"Only the divider edge should be claimed; interior inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
)
}
func testHostViewPrefersNativeNestedHostedInspectorSiblingDividerHit() {
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 slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
slot.autoresizingMask = [.minXMargin, .height]
host.addSubview(slot)
let wrapper = NSView(frame: slot.bounds)
wrapper.autoresizingMask = [.width, .height]
slot.addSubview(wrapper)
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height))
let inspectorContainer = NSView(
frame: NSRect(x: 92, y: 0, width: wrapper.bounds.width - 92, height: wrapper.bounds.height)
)
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
wrapper.addSubview(pageView)
wrapper.addSubview(inspectorContainer)
contentView.layoutSubtreeIfNeeded()
let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 2, y: slot.bounds.midY)
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
let bodyPointInSlot = NSPoint(x: inspectorContainer.frame.minX + 18, y: slot.bounds.midY)
let bodyPointInWindow = slot.convert(bodyPointInSlot, to: nil)
let bodyPointInHost = host.convert(bodyPointInWindow, from: nil)
let dividerHit = host.hitTest(dividerPointInHost)
XCTAssertTrue(
isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
"Portal host should prefer the native nested WebKit hit target on the right-docked divider when available. actual=\(String(describing: dividerHit))"
)
let interiorHit = host.hitTest(bodyPointInHost)
XCTAssertTrue(
isInspectorOwnedHit(interiorHit, inspectorView: inspectorView, pageView: pageView),
"Only the divider edge should be claimed; interior nested inspector hits should still reach WebKit content. actual=\(String(describing: interiorHit))"
)
}
func testHostViewReappliesStoredHostedInspectorWidthAfterSlotLayoutReset() {
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 slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
slot.autoresizingMask = [.minXMargin, .height]
host.addSubview(slot)
let wrapper = NSView(frame: slot.bounds)
wrapper.autoresizingMask = [.width, .height]
slot.addSubview(wrapper)
let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: wrapper.bounds.height)
let originalInspectorFrame = NSRect(
x: 92,
y: 0,
width: wrapper.bounds.width - 92,
height: wrapper.bounds.height
)
let pageView = PrimaryPageProbeView(frame: originalPageFrame)
let inspectorContainer = NSView(frame: originalInspectorFrame)
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
wrapper.addSubview(pageView)
wrapper.addSubview(inspectorContainer)
contentView.layoutSubtreeIfNeeded()
let dividerPointInSlot = NSPoint(x: inspectorContainer.frame.minX, y: slot.bounds.midY)
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
host.mouseDown(with: down)
let drag = makeMouseEvent(
type: .leftMouseDragged,
location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
window: window
)
host.mouseDragged(with: drag)
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
let draggedPageWidth = pageView.frame.width
let draggedInspectorMinX = inspectorContainer.frame.minX
XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
pageView.frame = originalPageFrame
inspectorContainer.frame = originalInspectorFrame
slot.needsLayout = true
slot.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()
XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
}
func testHostViewFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
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 slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
slot.autoresizingMask = [.minXMargin, .height]
host.addSubview(slot)
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: slot.bounds.height))
let inspectorView = EdgeTransparentWKInspectorProbeView(
frame: NSRect(x: 92, y: 0, width: slot.bounds.width - 92, height: slot.bounds.height)
)
slot.addSubview(pageView)
slot.addSubview(inspectorView)
contentView.layoutSubtreeIfNeeded()
let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
let dividerHit = host.hitTest(dividerPointInHost)
XCTAssertTrue(
dividerHit === host,
"Host should only take the manual fallback path when the right-docked divider edge is not natively hittable. actual=\(String(describing: dividerHit))"
)
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
host.mouseDown(with: down)
let drag = makeMouseEvent(
type: .leftMouseDragged,
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
window: window
)
host.mouseDragged(with: drag)
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
XCTAssertGreaterThan(pageView.frame.width, 92)
XCTAssertGreaterThan(inspectorView.frame.minX, 92)
}
func testHostViewClaimsCollapsedHostedInspectorSiblingDividerAtSlotLeadingEdge() {
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 slot = WindowBrowserSlotView(frame: NSRect(x: 180, y: 0, width: 240, height: host.bounds.height))
slot.autoresizingMask = [.minXMargin, .height]
host.addSubview(slot)
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: slot.bounds.height))
let inspectorView = WKInspectorProbeView(frame: slot.bounds)
slot.addSubview(pageView)
slot.addSubview(inspectorView)
contentView.layoutSubtreeIfNeeded()
let dividerPointInSlot = NSPoint(x: inspectorView.frame.minX + 2, y: slot.bounds.midY)
let dividerPointInWindow = slot.convert(dividerPointInSlot, to: nil)
let dividerPointInHost = host.convert(dividerPointInWindow, from: nil)
XCTAssertLessThanOrEqual(dividerPointInHost.x - slot.frame.minX, SidebarResizeInteraction.hitWidthPerSide)
let dividerHit = host.hitTest(dividerPointInHost)
XCTAssertTrue(
isInspectorOwnedHit(dividerHit, inspectorView: inspectorView, pageView: pageView),
"Collapsed right-docked hosted inspector divider should stay on the native WebKit hit path while still beating the sidebar-resizer overlap zone. actual=\(String(describing: dividerHit))"
)
}
}
@MainActor
final class BrowserPanelHostContainerViewTests: XCTestCase {
private final class PrimaryPageProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class WKInspectorProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
}
}
private final class EdgeTransparentWKInspectorProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
let localPoint = convert(point, from: superview)
guard bounds.contains(localPoint) else { return nil }
return localPoint.x <= 12 ? nil : self
}
}
private func makeMouseEvent(type: NSEvent.EventType, location: NSPoint, window: NSWindow) -> NSEvent {
guard let event = NSEvent.mouseEvent(
with: type,
location: location,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
eventNumber: 0,
clickCount: 1,
pressure: 1.0
) else {
fatalError("Failed to create \(type) mouse event")
}
return event
}
func testBrowserPanelHostPrefersNativeHostedInspectorSiblingDividerHit() {
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
}
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
host.autoresizingMask = [.minXMargin, .height]
contentView.addSubview(host)
let webViewRoot = NSView(frame: host.bounds)
webViewRoot.autoresizingMask = [.width, .height]
host.addSubview(webViewRoot)
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
let inspectorContainer = NSView(
frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
)
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
webViewRoot.addSubview(pageView)
webViewRoot.addSubview(inspectorContainer)
contentView.layoutSubtreeIfNeeded()
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
let bodyPointInHost = NSPoint(x: inspectorContainer.frame.minX + 18, y: host.bounds.midY)
let interiorHit = host.hitTest(bodyPointInHost)
XCTAssertTrue(
host.hitTest(dividerPointInHost) === host,
"Browser panel host should claim the right-docked divider edge for the manual resize path"
)
XCTAssertTrue(
interiorHit == nil || interiorHit !== host,
"Only the divider edge should be claimed; interior inspector hits should not be stolen by the host. actual=\(String(describing: interiorHit))"
)
}
func testBrowserPanelHostClaimsCollapsedHostedInspectorSiblingDividerAtLeadingEdge() {
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
}
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
host.autoresizingMask = [.minXMargin, .height]
contentView.addSubview(host)
let webViewRoot = NSView(frame: host.bounds)
webViewRoot.autoresizingMask = [.width, .height]
host.addSubview(webViewRoot)
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 0, height: webViewRoot.bounds.height))
let inspectorContainer = NSView(frame: webViewRoot.bounds)
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
webViewRoot.addSubview(pageView)
webViewRoot.addSubview(inspectorContainer)
contentView.layoutSubtreeIfNeeded()
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
XCTAssertTrue(
host.hitTest(dividerPointInHost) === host,
"Collapsed right-docked divider should stay on the manual browser-panel resize path while beating the sidebar-resizer overlap"
)
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
host.mouseDown(with: down)
let drag = makeMouseEvent(
type: .leftMouseDragged,
location: NSPoint(x: dividerPointInWindow.x + 36, y: dividerPointInWindow.y),
window: window
)
host.mouseDragged(with: drag)
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
XCTAssertGreaterThan(pageView.frame.width, 0)
XCTAssertGreaterThan(inspectorContainer.frame.minX, 0)
}
func testBrowserPanelHostFallsBackToManualHostedInspectorDragWhenNativeDividerHitIsUnavailable() {
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
}
let host = WebViewRepresentable.HostContainerView(frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height))
host.autoresizingMask = [.minXMargin, .height]
contentView.addSubview(host)
let webViewRoot = NSView(frame: host.bounds)
webViewRoot.autoresizingMask = [.width, .height]
host.addSubview(webViewRoot)
let pageView = PrimaryPageProbeView(frame: NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height))
let inspectorContainer = EdgeTransparentWKInspectorProbeView(
frame: NSRect(x: 92, y: 0, width: webViewRoot.bounds.width - 92, height: webViewRoot.bounds.height)
)
webViewRoot.addSubview(pageView)
webViewRoot.addSubview(inspectorContainer)
contentView.layoutSubtreeIfNeeded()
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
XCTAssertTrue(
host.hitTest(dividerPointInHost) === host,
"Browser panel host should only take the manual fallback path when the divider edge is not natively hittable"
)
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
host.mouseDown(with: down)
let drag = makeMouseEvent(
type: .leftMouseDragged,
location: NSPoint(x: dividerPointInWindow.x + 40, y: dividerPointInWindow.y),
window: window
)
host.mouseDragged(with: drag)
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
XCTAssertGreaterThan(pageView.frame.width, 92)
XCTAssertGreaterThan(inspectorContainer.frame.minX, 92)
}
func testBrowserPanelHostReappliesStoredHostedInspectorWidthAfterLayoutReset() {
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
}
let host = WebViewRepresentable.HostContainerView(
frame: NSRect(x: 180, y: 0, width: 240, height: contentView.bounds.height)
)
host.autoresizingMask = [.minXMargin, .height]
contentView.addSubview(host)
let webViewRoot = NSView(frame: host.bounds)
webViewRoot.autoresizingMask = [.width, .height]
host.addSubview(webViewRoot)
let originalPageFrame = NSRect(x: 0, y: 0, width: 92, height: webViewRoot.bounds.height)
let originalInspectorFrame = NSRect(
x: 92,
y: 0,
width: webViewRoot.bounds.width - 92,
height: webViewRoot.bounds.height
)
let pageView = PrimaryPageProbeView(frame: originalPageFrame)
let inspectorContainer = NSView(frame: originalInspectorFrame)
let inspectorView = WKInspectorProbeView(frame: inspectorContainer.bounds)
inspectorView.autoresizingMask = [.width, .height]
inspectorContainer.addSubview(inspectorView)
webViewRoot.addSubview(pageView)
webViewRoot.addSubview(inspectorContainer)
contentView.layoutSubtreeIfNeeded()
let dividerPointInHost = NSPoint(x: inspectorContainer.frame.minX + 2, y: host.bounds.midY)
let dividerPointInWindow = host.convert(dividerPointInHost, to: nil)
let down = makeMouseEvent(type: .leftMouseDown, location: dividerPointInWindow, window: window)
host.mouseDown(with: down)
let drag = makeMouseEvent(
type: .leftMouseDragged,
location: NSPoint(x: dividerPointInWindow.x + 48, y: dividerPointInWindow.y),
window: window
)
host.mouseDragged(with: drag)
host.mouseUp(with: makeMouseEvent(type: .leftMouseUp, location: drag.locationInWindow, window: window))
let draggedPageWidth = pageView.frame.width
let draggedInspectorMinX = inspectorContainer.frame.minX
XCTAssertGreaterThan(draggedPageWidth, originalPageFrame.width)
XCTAssertGreaterThan(draggedInspectorMinX, originalInspectorFrame.minX)
pageView.frame = originalPageFrame
inspectorContainer.frame = originalInspectorFrame
host.needsLayout = true
host.layoutSubtreeIfNeeded()
XCTAssertEqual(pageView.frame.width, draggedPageWidth, accuracy: 0.5)
XCTAssertEqual(inspectorContainer.frame.minX, draggedInspectorMinX, accuracy: 0.5)
}
}
@MainActor