diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 400b5d14..88b76d11 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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?) { diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index da7be546..291b6f6f 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -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: ®ions) 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) " + diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 89858475..f7b8ed95 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -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 { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 07295066..46ca0ff7 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3055,44 +3055,360 @@ struct WebViewRepresentable: NSViewRepresentable { var searchOverlayHostingView: NSHostingView? } - 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 diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index fd4b699b..e06895aa 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -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