Adapt attached devtools to narrow panes

This commit is contained in:
austinpower1258 2026-03-11 22:56:39 -07:00
parent df54af34cf
commit 72e7a9de76
2 changed files with 161 additions and 0 deletions

View file

@ -3715,6 +3715,17 @@ struct WebViewRepresentable: NSViewRepresentable {
final class HostContainerView: NSView {
private final class HostedInspectorSideDockContainerView: NSView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.masksToBounds = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
override var isOpaque: Bool { false }
override func resizeSubviews(withOldSize oldSize: NSSize) {
@ -3767,6 +3778,8 @@ struct WebViewRepresentable: NSViewRepresentable {
private static let hostedInspectorDividerHitExpansion: CGFloat = 10
private static let minimumHostedInspectorWidth: CGFloat = 120
private static let minimumHostedInspectorPageWidthForSideDock: CGFloat = 240
private static let adaptiveBottomDockRequestCooldown: TimeInterval = 0.25
private var trackingArea: NSTrackingArea?
private var activeDividerCursorKind: DividerCursorKind?
private var hostedInspectorDividerDrag: HostedInspectorDividerDragState?
@ -3780,6 +3793,7 @@ struct WebViewRepresentable: NSViewRepresentable {
private var isApplyingHostedInspectorLayout = false
private var hostedInspectorReapplyWorkItem: DispatchWorkItem?
private var hostedInspectorDockConfigurationSyncWorkItem: DispatchWorkItem?
private var adaptiveBottomDockRequestCooldownDeadline: Date?
private var lastHostedInspectorLayoutBoundsSize: NSSize?
#if DEBUG
private var lastLoggedHostedInspectorFrames: (page: NSRect, inspector: NSRect)?
@ -4177,6 +4191,64 @@ struct WebViewRepresentable: NSViewRepresentable {
)
}
func normalizeHostedInspectorLayoutIfNeeded(reason: String) {
if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).adaptive") {
return
}
_ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if isHostedInspectorSideDockActive() {
layoutHostedInspectorSideDockIfNeeded(reason: reason)
} else if !hasStoredHostedInspectorWidthPreference {
captureHostedInspectorPreferredWidthFromCurrentLayout(reason: reason)
}
}
private func shouldForceHostedInspectorBottomDock(using hit: HostedInspectorDividerHit) -> Bool {
let containerWidth = max(0, hit.containerView.bounds.width)
guard containerWidth > 1 else { return false }
let currentInspectorWidth = max(0, hit.inspectorView.frame.width)
let currentPageWidth = max(0, hit.pageView.frame.width)
let remainingPageWidth = max(0, containerWidth - max(Self.minimumHostedInspectorWidth, currentInspectorWidth))
let effectivePageWidth = min(currentPageWidth, remainingPageWidth)
return effectivePageWidth < Self.minimumHostedInspectorPageWidthForSideDock
}
@discardableResult
private func requestAdaptiveHostedInspectorBottomDock(reason: String) -> Bool {
let now = Date()
if let adaptiveBottomDockRequestCooldownDeadline, adaptiveBottomDockRequestCooldownDeadline > now {
return true
}
guard let hostedInspectorFrontendWebView else { return false }
adaptiveBottomDockRequestCooldownDeadline = now.addingTimeInterval(Self.adaptiveBottomDockRequestCooldown)
#if DEBUG
dlog(
"browser.panel.hostedInspector stage=\(reason).adaptiveBottomDock " +
"host=\(Self.debugObjectID(self)) bounds=\(Self.debugRect(bounds))"
)
#endif
hostedInspectorFrontendWebView.evaluateJavaScript(
"typeof WI !== 'undefined' ? WI._dockBottom() : null"
) { [weak self] _, _ in
self?.scheduleHostedInspectorDockConfigurationSync(
reason: "\(reason).adaptiveBottomDock"
)
}
return true
}
@discardableResult
private func enforceAdaptiveBottomDockIfNeeded(reason: String) -> Bool {
guard let hit = hostedInspectorDividerCandidate(),
shouldForceHostedInspectorBottomDock(using: hit) else {
return false
}
return requestAdaptiveHostedInspectorBottomDock(reason: reason)
}
fileprivate func scheduleHostedInspectorDockConfigurationSync(reason: String) {
hostedInspectorDockConfigurationSyncWorkItem?.cancel()
guard hostedInspectorFrontendWebView != nil else { return }
@ -4202,22 +4274,37 @@ struct WebViewRepresentable: NSViewRepresentable {
case "left":
hostedInspectorSideDockDockSide = .leading
if isHostedInspectorSideDockActive() {
if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockLeft") {
return
}
layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockLeft")
} else if let slotView = localInlineSlotView,
let hit = hostedInspectorDividerCandidate(in: slotView),
hit.dockSide == .leading {
if shouldForceHostedInspectorBottomDock(using: hit) {
_ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockLeft")
return
}
activateHostedInspectorSideDockIfNeeded(using: hit)
}
case "right":
hostedInspectorSideDockDockSide = .trailing
if isHostedInspectorSideDockActive() {
if enforceAdaptiveBottomDockIfNeeded(reason: "\(reason).dockRight") {
return
}
layoutHostedInspectorSideDockIfNeeded(reason: "\(reason).dockRight")
} else if let slotView = localInlineSlotView,
let hit = hostedInspectorDividerCandidate(in: slotView),
hit.dockSide == .trailing {
if shouldForceHostedInspectorBottomDock(using: hit) {
_ = requestAdaptiveHostedInspectorBottomDock(reason: "\(reason).dockRight")
return
}
activateHostedInspectorSideDockIfNeeded(using: hit)
}
default:
adaptiveBottomDockRequestCooldownDeadline = nil
if isHostedInspectorSideDockActive() {
deactivateHostedInspectorSideDockIfNeeded(reparentTo: localInlineSlotView)
if dockConfiguration == "bottom" {
@ -4259,6 +4346,13 @@ struct WebViewRepresentable: NSViewRepresentable {
override func layout() {
super.layout()
_ = promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded()
if enforceAdaptiveBottomDockIfNeeded(reason: "host.layout") {
notifyGeometryChangedIfNeeded()
#if DEBUG
debugLogHostedInspectorLayoutIfNeeded(reason: "layout")
#endif
return
}
if let previousSize = lastHostedInspectorLayoutBoundsSize,
Self.sizeApproximatelyEqual(previousSize, bounds.size, epsilon: 0.5) {
// Origin-only frame churn is common while the surrounding split layout
@ -4830,6 +4924,19 @@ struct WebViewRepresentable: NSViewRepresentable {
CATransaction.commit()
isApplyingHostedInspectorLayout = false
hit.pageView.needsDisplay = true
hit.pageView.setNeedsDisplay(hit.pageView.bounds)
hit.inspectorView.needsDisplay = true
hit.inspectorView.setNeedsDisplay(hit.inspectorView.bounds)
hit.containerView.needsDisplay = true
hit.containerView.setNeedsDisplay(hit.containerView.bounds)
if let localInlineSlotView {
localInlineSlotView.needsDisplay = true
localInlineSlotView.setNeedsDisplay(localInlineSlotView.bounds)
}
needsDisplay = true
setNeedsDisplay(bounds)
let isLiveDrag = reason == "drag"
#if DEBUG
dlog(
@ -5176,6 +5283,7 @@ struct WebViewRepresentable: NSViewRepresentable {
webView.layoutSubtreeIfNeeded()
slotView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()
host.normalizeHostedInspectorLayoutIfNeeded(reason: "localInline.update.immediate")
host.scheduleHostedInspectorDividerReapply(reason: "localInline.update.sync")
DispatchQueue.main.async { [weak host, weak webView] in
guard let host, let webView else { return }

View file

@ -9418,6 +9418,18 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
}
}
private final class TrackingInspectorFrontendWebView: WKWebView {
private(set) var evaluatedJavaScript: [String] = []
override func evaluateJavaScript(
_ javaScriptString: String,
completionHandler: ((Any?, Error?) -> Void)? = nil
) {
evaluatedJavaScript.append(javaScriptString)
completionHandler?(nil, nil)
}
}
private final class WKInspectorProbeView: NSView {
override func hitTest(_ point: NSPoint) -> NSView? {
bounds.contains(point) ? self : nil
@ -9861,6 +9873,47 @@ final class BrowserPanelHostContainerViewTests: XCTestCase {
)
}
func testBrowserPanelHostRequestsBottomDockWhenSideDockLeavesTooLittlePageWidth() {
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: 280, height: contentView.bounds.height))
host.autoresizingMask = [.minXMargin, .height]
contentView.addSubview(host)
let slotView = host.ensureLocalInlineSlotView()
let pageView = WKWebView(frame: NSRect(x: 0, y: 0, width: 120, height: host.bounds.height))
let inspectorView = TrackingInspectorFrontendWebView(
frame: NSRect(x: 120, y: 0, width: slotView.bounds.width - 120, height: host.bounds.height)
)
slotView.addSubview(pageView)
slotView.addSubview(inspectorView)
host.pinHostedWebView(pageView, in: slotView)
host.setHostedInspectorFrontendWebView(inspectorView)
contentView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()
XCTAssertTrue(host.promoteHostedInspectorSideDockFromCurrentLayoutIfNeeded())
host.setFrameSize(NSSize(width: 210, height: host.frame.height))
contentView.layoutSubtreeIfNeeded()
host.layoutSubtreeIfNeeded()
XCTAssertTrue(
inspectorView.evaluatedJavaScript.contains(where: { $0.contains("WI._dockBottom()") }),
"Narrow pane widths should request bottom-docked DevTools instead of leaving the side-docked inspector in an unstable layout"
)
}
func testBrowserPanelManagedSideDockDoesNotAutoresizeDraggedFrames() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),