Fix side-docked dev tools resize (#712)
* wip * Fix side-docked dev tools resize
This commit is contained in:
parent
dd2eeae503
commit
15c7c0cc3c
5 changed files with 2145 additions and 55 deletions
|
|
@ -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?) {
|
||||
|
|
|
|||
|
|
@ -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) " +
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue